File Coverage

blib/lib/Salus/Table.pm
Criterion Covered Total %
statement 33 33 100.0
branch n/a
condition n/a
subroutine 11 11 100.0
pod n/a
total 44 44 100.0


line stmt bran cond sub pod time code
1             package Salus::Table;
2 3     3   24 use strict; use warnings;
  3     3   6  
  3         110  
  3         24  
  3         3  
  3         165  
3 3     3   10 use Rope;
  3         4  
  3         19  
4 3     3   812 use Rope::Autoload;
  3         4  
  3         41  
5 3     3   3290 use Text::CSV_XS qw/csv/;
  3         57788  
  3         353  
6 3     3   30 use Types::Standard qw/Str ArrayRef Bool/;
  3         26  
  3         36  
7 3     3   7169 use Salus::Row;
  3         9  
  3         134  
8 3     3   21 use Salus::Row::Column;
  3         5  
  3         76  
9 3     3   1621 use Digest::SHA qw/hmac_sha256_hex/;
  3         8773  
  3         285  
10 3     3   1585 use Text::Diff qw//;
  3         21871  
  3         7797  
11              
12             property file => (
13             initable => 1,
14             writeable => 0,
15             configurable => 1,
16             enumerable => 1,
17             type => Str,
18             );
19              
20             property secret => (
21             initable => 1,
22             writeable => 0,
23             configurable => 1,
24             enumerable => 1,
25             type => Str,
26             );
27              
28             property unprotected_read => (
29             initable => 1,
30             writeable => 0,
31             configurable => 1,
32             enumerable => 1,
33             type => Bool,
34             value => 0
35             );
36              
37             property headers => (
38             initable => 1,
39             writeable => 0,
40             configurable => 1,
41             required => 1,
42             enumerable => 1,
43             type => ArrayRef,
44             value => []
45             );
46              
47             property rows => (
48             initable => 1,
49             writeable => 0,
50             configurable => 1,
51             enumerable => 1,
52             type => ArrayRef,
53             value => [],
54             );
55              
56             private hmac => sub {
57             my ($self, $data) = @_;
58             return hmac_sha256_hex($data, $self->secret ? $self->secret : ());
59             };
60              
61             function count => sub {
62             return scalar @{$_[0]->rows};
63             };
64              
65             function read => sub {
66             my ($self, $file, $read) = @_;
67             $file ||= $self->file;
68             my $csv = Text::CSV_XS->new ({ binary => 1, auto_diag => 1 });
69 1     1   1016 open my $fh, "<:encoding(utf8)", $file or die "${file}: $!";
  1         13  
  1         5  
70             if (!$self->unprotected_read) {
71             my $data = do { local $/; <$fh> };
72             seek $fh, 0, 0;
73             my $match = $data =~ s/\n+(.*)$// && $1;
74             if ($self->hmac($data) !~ m/^$match$/) {
75             die "HMAC does not match for file ${file}";
76             }
77             }
78             my ($line, $salus, @rows) = (0, 0);
79             while (my $columns = $csv->getline($fh)) {
80             if (0 == $line) {
81             $salus = 1 if ($columns->[-1] eq 'SALUS');
82             $line++;
83             next;
84             }
85             last if scalar @{$columns} == 1;
86             my @cols;
87             if ($salus) {
88             $salus = pop @{$columns};
89             $csv->combine(@{$columns}) or die 'kaput' . $!;
90             if ($self->hmac($csv->string()) !~ m/^$salus$/) {
91             die "HMAC does not match for row ${line} in file ${file}";
92             }
93             }
94             for (my $i = 0; $i < scalar @{$columns}; $i++) {
95             push @cols, Salus::Row::Column->new({
96             header => $self->headers->[$i],
97             value => $columns->[$i]
98             });
99             }
100             push @rows, Salus::Row->new(
101             columns => \@cols
102             );
103             $line++;
104             }
105             close $fh;
106              
107             return \@rows if $read;
108              
109             $self->rows = \@rows;
110             };
111              
112             function combine => sub {
113             my ($self, $file, $primary) = @_;
114            
115             my $rows = $self->read($file, 1);
116              
117             ROW:
118             for my $row (@{$rows}) {
119             for my $r (@{$self->rows}) {
120             if ($r->get_col($primary)->value =~ $row->get_col($primary)->value) {
121             for (@{$row->columns}) {
122             $r->get_col($_->header->index)->value = $_->value;
123             }
124             next ROW;
125             }
126             }
127             push @{$self->rows}, $row;
128             }
129             };
130              
131             function write => sub {
132             my ($self, $file) = @_;
133             $file ||= $self->file;
134             my $csv = Text::CSV_XS->new ({ binary => 1, auto_diag => 1 });
135             open my $fh, ">:encoding(utf8)", $file or die "${file}: $!";
136             my @headers = map {
137             $_->label || $_->name
138             } @{$self->headers};
139             push @headers, 'SALUS';
140             $csv->say($fh, \@headers);
141             for my $row (@{$self->rows}) {
142             my $row_array = $row->as_array;
143             $csv->combine(@{$row_array});
144             my $row_hmac = $self->hmac($csv->string());
145             push @{$row_array}, $row_hmac;
146             $csv->say($fh, $row_array);
147             }
148             close $fh;
149             open $fh, "<:encoding(utf8)", $file or die "${file}: $!";
150             my $data = do { local $/; <$fh> };
151             close $fh;
152             my $file_hmac = $self->hmac($data);
153             open my $gfh, ">>:encoding(utf8)", $file or die "${file}: $!";
154             seek $gfh, 0, 2;
155             print $gfh $file_hmac . "\n";
156             close $gfh;
157             };
158              
159             function add_row => sub {
160             my ($self, $columns) = @_;
161             my @cols;
162             for (my $i = 0; $i < scalar @{$columns}; $i++) {
163             push @cols, Salus::Row::Column->new({
164             header => $self->headers->[$i],
165             value => $columns->[$i]
166             });
167             }
168             push @{$self->rows}, Salus::Row->new(
169             columns => \@cols
170             );
171             };
172              
173             function add_rows => sub {
174             my ($self, $rows) = @_;
175             for my $columns (@{$rows}) {
176             my @cols;
177             for (my $i = 0; $i < scalar @{$columns}; $i++) {
178             push @cols, Salus::Row::Column->new({
179             header => $self->headers->[$i],
180             value => $columns->[$i]
181             });
182             }
183             push @{$self->rows}, Salus::Row->new(
184             columns => \@cols
185             );
186             }
187             };
188              
189             function add_row_hash => sub {
190             my ($self, $columns) = (shift, ref $_[0] ? $_[0] : {@_});
191             my @cols;
192             for my $header (@{$self->headers}) {
193             push @cols, Salus::Row::Column->new({
194             header => $header,
195             value => $columns->{$header->label} || $columns->{$header->name}
196             });
197             }
198             push @{$self->rows}, Salus::Row->new(
199             columns => \@cols
200             );
201             };
202              
203             function get_row => sub {
204             my ($self, $row) = @_;
205             return $self->rows->[$row];
206             };
207              
208             function get_row_col => sub {
209             my ($self, $row, $col) = @_;
210             $self->get_row($row)->get_col($col);
211             };
212              
213             function set_row => sub {
214             my ($self, $row, $cols) = @_;
215             $row = $self->get_row($row);
216             for (my $i = 0; $i < scalar @{$cols}; $i++) {
217             $row->set_col($i, $cols->[$i] // "");
218             }
219             };
220              
221             function set_row_col => sub {
222             my ($self, $row, $col, $value) = @_;
223             $self->get_row($row)->set_col($col, $value);
224             };
225              
226             function delete_row => sub {
227             my ($self, $row) = @_;
228             splice @{ $self->rows }, $row, 1;
229             };
230              
231             function delete_row_col => sub {
232             my ($self, $row, $col) = @_;
233             $self->get_row($row)->delete_col($col);
234             };
235              
236             function sort => sub {
237             my ($self, $col, $order, $return) = @_;
238             $col = $self->find_column_index($col);
239             my @rows = $order eq 'asc'
240             ? sort { $a->get_col($col)->value cmp $b->get_col($col)->value } @{$self->rows}
241             : sort { $b->get_col($col)->value cmp $a->get_col($col)->value } @{$self->rows};
242             $self->rows = \@rows unless $return;
243             return \@rows;
244             };
245              
246             function search => sub {
247             my ($self, $col, $search) = @_;
248             $col = $self->find_column_index($col);
249             my ($i, @indexes) = (0);
250             my @rows = grep {
251             if ( $_->get_col($col)->value =~ m/$search/i ) {
252             push @indexes, $i++;
253             return $_;
254             }
255             $i++;
256             return ();
257             } @{$self->rows};
258             return (\@rows, \@indexes);
259             };
260              
261             function find => sub {
262             my ($self, $col, $search) = @_;
263             $col = $self->find_column_index($col);
264             my ($i, $found) = (0, undef);
265             for ( @{$self->rows} ) {
266             if ($_->get_col($col)->value =~ m/$search/i) {
267             $found = $i;
268             last;
269             }
270             $i++;
271             }
272             return $found;
273             };
274              
275             function find_column_index => sub {
276             my ($self, $col) = @_;
277             if ($col !~ m/^\d+$/) {
278             for (@{$self->headers}) {
279             if ($_->name =~ m/^($col)$/) {
280             $col = $_->index;
281             }
282             }
283             }
284             return $col;
285             };
286              
287             function sum => sub {
288             my ($self, $col) = @_;
289             $col = $self->find_column_index($col);
290             my $sum = 0;
291             for (@{$self->rows}) {
292             my $c = $_->get_col($col);
293             if ($c->value !~ m/^\d+$/) {
294             die "Cannot sum column as it has non numeric values";
295             }
296             $sum += $c->value;
297             }
298             return $sum;
299             };
300              
301             function mean => sub {
302             my ($self, $col) = @_;
303             my $sum = $self->sum($col);
304             return $sum / scalar @{$self->rows};
305             };
306              
307             function median => sub {
308             my ($self, $col, $as_row) = @_;
309             $col = $self->find_column_index($col);
310             my $rows = $self->sort($col, 'asc', 1);
311             my $median = int(scalar @{$rows} / 2);
312             if ($median % 2 != 0) {
313             $median += 1;
314             }
315             return $as_row ? $rows->[$median - 1] : $rows->[$median - 1]->get_col($col)->value;
316             };
317              
318             function mode => sub {
319             my ($self, $col) = @_;
320             $col = $self->find_column_index($col);
321             my %map;
322             $map{$_->get_col($col)->value}++ for (@{$self->rows});
323             my ($key, $mode) = ('', 0);
324             for my $k (keys %map) {
325             if ($map{$k} > $mode) {
326             $key = $k;
327             $mode = $map{$k};
328             }
329             }
330             return ($key, $mode);
331             };
332              
333             function min => sub {
334             my ($self, $col, $as_row) = @_;
335             $col = $self->find_column_index($col);
336             my $rows = $self->sort($col, 'asc', 1);
337             return $as_row ? $rows->[0] : $rows->[0]->get_col($col)->value;
338             };
339              
340             function max => sub {
341             my ($self, $col, $as_row) = @_;
342             $col = $self->find_column_index($col);
343             my $rows = $self->sort($col, 'desc', 1);
344             return $as_row ? $rows->[0] : $rows->[0]->get_col($col)->value;
345             };
346              
347             function headers_as_array => sub {
348             my ($self) = @_;
349             my @array = map {
350             $_->{label} || $_->{name}
351             } @{$self->headers};
352             return \@array;
353             };
354              
355             function headers_stringify => sub {
356             my ($self) = @_;
357             my @array = map {
358             $_->{label} ? sprintf("%s (%s) (%s)", $_->{label}, $_->{name}, $_->{index}) : sptrintf("%s (%s)", $_->{name}, $_->{index})
359             } @{$self->headers};
360             return \@array;
361             };
362              
363             function diff_files => sub {
364             my ($self, $file1, $file2) = @_;
365             return Text::Diff::diff $file1, $file2, { STYLE => "Context" };
366             };
367              
368             1;
369              
370             __END__