File Coverage

blib/lib/Datafile/Array.pm
Criterion Covered Total %
statement 161 192 83.8
branch 65 134 48.5
condition 46 96 47.9
subroutine 10 10 100.0
pod 3 3 100.0
total 285 435 65.5


line stmt bran cond sub pod time code
1             package Datafile::Array;
2              
3 5     5   529779 use strict;
  5         10  
  5         193  
4 5     5   47 use warnings;
  5         16  
  5         277  
5 5     5   99 use 5.014;
  5         19  
6 5     5   31 use Exporter 'import';
  5         17  
  5         215  
7 5     5   29 use Carp;
  5         9  
  5         14738  
8              
9             our @EXPORT_OK = qw(readarray writearray parse_csv_line);
10             our $VERSION = '1.05';
11              
12             sub _trim {
13 27     27   41 my ($value, $do_trim) = @_;
14 27 50 33     78 return $value unless $do_trim && defined $value;
15 27         83 $value =~ s/^\s+|\s+$//g;
16 27         71 return $value;
17             }
18              
19             sub parse_csv_line {
20 12     12 1 187558 my ($line, $sep) = @_;
21 12   50     32 $sep //= ',';
22 12         19 my @fields;
23 12         23 my $pos = 0;
24 12   50     61 my $len = length( $line // '' );
25 12         32 while ( $pos < $len ) {
26 31         44 my $field = '';
27 31 100       68 if ( substr( $line, $pos, 1 ) eq '"' ) {
28 13         18 $pos++;
29 13         17 my $start = $pos;
30 13         22 while (1) {
31 14         25 my $qpos = index( $line, '"', $pos );
32 14 100       53 if ( $qpos == -1 ) {
33 1         4 $field .= substr( $line, $start );
34 1         3 $pos = $len;
35 1         4 last;
36             }
37 13         23 $field .= substr( $line, $start, $qpos - $start );
38 13         21 $pos = $qpos + 1;
39 13 100 100     43 if ( $pos < $len && substr( $line, $pos, 1 ) eq '"' ) {
40 1         7 $field .= '"';
41 1         2 $pos++;
42 1         2 $start = $pos;
43             }
44             else {
45 12         20 last;
46             }
47             }
48             }
49             else {
50 18         35 my $spos = index( $line, $sep, $pos );
51 18 100       36 $spos = $len if $spos == -1;
52 18         30 $field = substr( $line, $pos, $spos - $pos );
53 18         54 $pos = $spos;
54             }
55 31         51 push @fields, $field;
56 31 100 66     115 $pos++ if ( $pos < $len && substr( $line, $pos, 1 ) eq $sep );
57             }
58 12         51 return @fields;
59             }
60              
61             sub readarray {
62 3     3 1 197844 my ( $filename, $data, $pafields, $opts ) = @_;
63 3   50     11 $opts //= {};
64              
65 3   50     11 my $delim = $opts->{delimiter} // ';';
66 3   50     17 my $key_fields = $opts->{key_fields} // 1;
67 3   50     12 my $trim = $opts->{trim_values} // 1;
68 3   50     13 my $comment = $opts->{comment_char} // '#';
69 3   50     14 my $skip_empty = $opts->{skip_empty} // 1;
70 3   100     11 my $csv = $opts->{csvquotes} // 0;
71 3   50     11 my $has_headers = $opts->{has_headers} // 1;
72 3   100     10 my $prefix = $opts->{prefix} // 0;
73 3         4 my $search = $opts->{search};
74 3   50     22 my $verbose = $opts->{verbose} // 0;
75              
76 3         5 my @field_names;
77 3 50       12 if ( ref $pafields eq 'ARRAY' ) {
    0          
78 3         8 @field_names = @$pafields;
79             }
80             elsif ( defined $pafields ) {
81 0         0 croak "datafile::array::readarray: 'fields' parameter must be an ARRAY reference";
82             }
83 3 50       8 if ( ref $data eq 'ARRAY' ) {
    0          
84 3         6 @$data = ();
85             }
86             elsif ( ref $data eq 'HASH' ) {
87 0         0 %$data = ();
88             }
89             else {
90 0         0 croak "datafile::array::readarray: 'data' parameter must be an ARRAY or HASH reference";
91             }
92              
93 3         7 my @messages = ();
94             push @messages,
95             "# readarray: fields=@field_names\n",
96             "# opts: "
97 3 50       8 . join( ", ", map { "$_=$opts->{$_}" } sort keys %$opts ) . "\n"
  0         0  
98             if $verbose;
99              
100 3         8 my @compiled_patterns = ();
101 3 0 0     12 if ( defined $search && ( ref $search || length $search ) ) {
      33        
102 0 0       0 my @raw = ref $search eq 'ARRAY' ? @$search : ($search);
103 0         0 for my $pat (@raw) {
104 0 0       0 next unless defined $pat;
105 0 0       0 my $regex = ref $pat eq 'Regexp' ? $pat : qr/\Q$pat\E/i;
106 0         0 push @compiled_patterns, $regex;
107             }
108             }
109              
110 3 50       147 open( my $fh, '<:encoding(UTF-8)', $filename )
111             or return ( 0, ["WARNING: cannot open '$filename': $!"] );
112              
113 3         204 my $record_count = 0;
114 3 100       10 my $start_idx = $prefix ? 1 : 0;
115 3         72 my $delim_re = qr/\Q$delim\E/;
116 3         22 my $csvline = '';
117              
118 3         10 my $header_done = 0;
119 3 50 66     13 $has_headers = 1 if $prefix && $has_headers == 0;
120 3 50 33     40 $header_done = 1 if $has_headers == 0 && @field_names;
121              
122 3         92 while ( my $line = <$fh> ) {
123 14 100       231 next if $line =~ /^\s*\Q$comment\E/;
124 11         59 $line =~ s/[\r\n\s]+$//;
125              
126 11         17 my @fields;
127 11 100       33 if ($csv) {
128 5 100       8 $csvline .= "\n" if $csvline ne '';
129 5         8 $csvline .= $line;
130 5         10 my $count = () = $csvline =~ /\Q"\E/g;
131             next
132 5 100 66     43 unless $count % 2 == 0
      66        
133             && ( $count == 0 || $csvline =~ /"[^"]*$/ );
134 4         8 @fields = parse_csv_line( $csvline, $delim );
135 4         4 $line = $csvline;
136 4         4 $csvline = '';
137             }
138             else {
139 6         39 @fields = split $delim_re, $line, -1;
140             }
141 10 50 33     35 next if $skip_empty && $line eq '';
142              
143 10 100       21 unless ($header_done) {
144 3 50       14 if ( $has_headers > 0 ) {
145 3 50 0     20 if ( $has_headers == 1 || ( $prefix && substr( $line, 0, 1 ) eq 'H' ) )
      33        
146             {
147 3 50       11 unless (@field_names) {
148 3         13 @field_names = map { _trim( $_, 1 ) }
  8         18  
149             @fields[ $start_idx .. $#fields ];
150 3 50       8 push @messages,
151             "- header fields: @field_names\n"
152             if $verbose;
153             }
154 3 50       23 $header_done = 1 if @field_names;
155             }
156 3         6 $has_headers--;
157 3         15 next;
158             }
159             else {
160 0 0       0 croak
161             "datafile::array::readarray: no field names provided and none found in file"
162             unless @field_names;
163 0         0 $header_done = 1;
164             }
165             }
166 7 50       16 next if @fields < $start_idx + @field_names;
167              
168 7 50       14 if (@compiled_patterns) {
169 0         0 my $all_matched = 1;
170 0         0 for my $regex (@compiled_patterns) {
171 0 0       0 $all_matched = 0 unless $line =~ $regex;
172 0 0       0 last unless $all_matched;
173             }
174 0 0       0 next unless $all_matched;
175             }
176              
177 7         10 my %record;
178 7         20 for my $i ( 0 .. $#field_names ) {
179 19   50     68 my $val = $fields[ $start_idx + $i ] // '';
180 19         27 $record{ $field_names[$i] } = _trim( $val, $trim );
181             }
182              
183 7 50       44 if ( ref $data eq 'HASH' ) {
184 0         0 my @key_parts = map { _trim( $fields[$_], $trim ) }
  0         0  
185             ( 0 .. $key_fields - 1 );
186 0         0 my $key = join( $delim, @key_parts );
187 0         0 $data->{$key} = \%record;
188             }
189             else {
190 7         15 push @$data, \%record;
191             }
192              
193 7         44 $record_count++;
194             }
195 3         43 close $fh;
196 3 50       10 croak
197             "datafile::array::readarray: no field names provided and none found in file"
198             unless @field_names;
199              
200 3 50 33     30 if ( defined $pafields && @field_names && !@$pafields ) {
      33        
201 3         10 @$pafields = @field_names;
202 3 50       8 push @messages, "- return fields: @field_names\n" if $verbose;
203             }
204              
205 3 50       34 push @messages,
206             "- $record_count data records read from $filename\n"
207             if $verbose;
208 3         27 return ( $record_count, \@messages );
209             }
210              
211             sub writearray {
212 2     2 1 371757 my ( $filename, $data, $pafields, $opts ) = @_;
213 2   50     9 $opts //= {};
214              
215 2   50     9 my $delim = $opts->{delimiter} // ';';
216 2   50     14 my $comment_char = $opts->{comment_char} // '#';
217 2   50     9 my $header = $opts->{header} // 0;
218 2   100     8 my $prefix = $opts->{prefix} // 0;
219 2   50     29 my $backup = $opts->{backup} // 0;
220 2   50     11 my $prot = $opts->{prot} // 0660;
221 2   50     10 my $verbose = $opts->{verbose} // 0;
222              
223 2 50       11 my @field_names = ref $pafields eq 'ARRAY' ? @$pafields : ();
224 2 50       6 unless (@field_names) {
225 0 0       0 if (@$data) {
226 0 0       0 my $first = ref($data) eq 'HASH' ? ( values %$data )[0] : $data->[0];
227 0         0 @field_names = sort keys %$first;
228             }
229             else {
230 0         0 @field_names = ();
231             }
232             }
233              
234 2 0 33     11 if ( ref $data ne 'ARRAY' && ref $data ne 'HASH' && !@field_names ) {
      33        
235 0 0       0 if ( -f $filename ) {
236 0 0       0 unlink($filename)
237             or return ( 0, ["WARNING: unable to delete file $filename"] );
238 0         0 return ( 1, ["SUCCESS: file $filename is deleted"] );
239             }
240             croak
241 0         0 "datafile::array::writearray: 'data' parameter must be an ARRAY or HASH reference";
242             }
243              
244 2         5 my @messages = ();
245             push @messages,
246             "# writearray: fields=@field_names\n",
247             "# opts: "
248 2 50       7 . join( ", ", map { "$_=$opts->{$_}" } sort keys %$opts ) . "\n"
  0         0  
249             if $verbose;
250              
251 2         5 my $tmp = "$filename.tmp";
252 2 50   2   109 open( my $fh, '>:encoding(UTF-8):crlf', $tmp )
  2         1516  
  2         33  
  2         12  
253             or return ( 0, ["ERROR: cannot open '$tmp' for writing: $!"] );
254              
255 2 100       2510 if ( my $comment = $opts->{comment} ) {
256 1 50       6 my @lines = ref($comment) eq 'ARRAY' ? @$comment : split( /\n/, $comment );
257 1         7 print $fh "$comment_char $_\n" for @lines;
258 1 50       3 if ($verbose) { push @messages, "> $comment_char $_\n" for @lines }
  0         0  
259             }
260              
261 2 100       9 my $prefix_hdr = $prefix ? 'H' . $delim : '';
262 2 100       5 my $prefix_row = $prefix ? 'R' . $delim : '';
263              
264 2 50 33     16 if ( $header && @field_names ) {
265 2         12 print $fh $prefix_hdr . join( $delim, @field_names ) . "\n";
266 2 50       9 push @messages,
267             "> " . $prefix_hdr . join( $delim, @field_names ) . "\n"
268             if $verbose;
269             }
270              
271 2         4 my $record_count = 0;
272 2 50       13 my @records =
273             ref($data) eq 'HASH' ? sort keys %$data : 0 .. $#$data;
274              
275 2         8 for my $key (@records) {
276 4 50       14 my $rec = ref($data) eq 'HASH' ? $data->{$key} : $data->[$key];
277 4 50       9 my @values = map { defined( $rec->{$_} ) ? $rec->{$_} : '' } @field_names;
  10         70  
278 4         14 my $line = $prefix_row . join( $delim, @values );
279 4 50       14 print $fh $line . "\n"
280             or return ( 0, ["ERROR: write error to '$tmp': $!"] );
281 4         10 $record_count++;
282             }
283 2 50       17 print $fh "#EOF\n" if $comment_char eq '#';
284 2 50       163 close $fh
285             or return ( 0, ["ERROR: failed to close '$tmp': $!"] );
286              
287 2 50 33     13 if ($backup && -f $filename) {
288 0 0       0 rename( $filename, $filename . '.bak' )
289             or push @messages,
290             "WARNING: backup to ${filename}.bak failed: $!";
291             }
292 2 50       281 rename( $tmp, $filename )
293             or return ( 0, ["ERROR: failed to rename '$tmp' to '$filename': $!"] );
294 2         55 chmod $prot, $filename;
295              
296 2 50       9 push @messages,
297             "- renamed $tmp to $filename\n",
298             "- $record_count data records written to $filename\n"
299             if $verbose;
300 2         22 return ( $record_count, \@messages );
301             }
302              
303             1;
304              
305             __END__