File Coverage

blib/lib/Concierge/Users/File.pm
Criterion Covered Total %
statement 186 221 84.1
branch 51 100 51.0
condition 11 17 64.7
subroutine 17 19 89.4
pod 7 10 70.0
total 272 367 74.1


line stmt bran cond sub pod time code
1             package Concierge::Users::File v0.8.0;
2 5     5   994 use v5.36;
  5         21  
3 5     5   45 use Carp qw/ croak /;
  5         10  
  5         369  
4 5     5   4490 use Text::CSV;
  5         100942  
  5         383  
5 5     5   45 use File::Path qw/ make_path /;
  5         10  
  5         291  
6 5     5   65 use File::Spec;
  5         14  
  5         174  
7 5     5   28 use parent qw/ Concierge::Users::Meta /;
  5         8  
  5         44  
8              
9             # ABSTRACT: File backend (CSV/TSV) for Concierge::Users
10              
11             # ==============================================================================
12             # Configure Class Method - One-time setup (called by Users->setup)
13             # ==============================================================================
14              
15             sub configure {
16 17     17 1 52 my ($class, $setup_config) = @_;
17              
18             # Extract parameters
19 17         47 my $storage_dir = $setup_config->{storage_dir};
20 17   100     88 my $format = lc($setup_config->{file_format} || 'tsv');
21              
22             # Validate format
23 17 100       115 unless ($format =~ /^(csv|tsv)$/) {
24             return {
25 2         10 success => 0,
26             message => "Invalid file_format: '$format' (must be 'csv' or 'tsv')",
27             };
28             }
29              
30             # Build file name
31 15         41 my $file_name = "users.$format";
32 15         42 my $file_full_path = "$storage_dir/$file_name";
33              
34             # Initialize CSV parser
35 15 100       59 my $sep_char = ($format eq 'csv') ? ',' : "\t";
36 15         161 my $csv = Text::CSV->new({
37             sep_char => $sep_char,
38             binary => 1,
39             auto_diag => 1,
40             });
41              
42 15 50       3583 unless ($csv) {
43             return {
44 0         0 success => 0,
45             message => "Failed to initialize CSV parser for format: $format",
46             };
47             }
48              
49             # Create temporary object for ensure_storage
50             my $temp_backend = bless {
51             storage_dir => $storage_dir,
52             format => $format,
53             csv => $csv,
54             fields => $setup_config->{fields} || [],
55             field_definitions => $setup_config->{field_definitions},
56 15   50     185 }, $class;
57              
58             # Check for existing file with data and archive if present
59 15         47 my $user_file = "$storage_dir/users.$format";
60 15 50       855 if (-f $user_file) {
61             # Check if file has data rows (after header)
62 0         0 open my $fh, '<:encoding(UTF-8)', $user_file;
63 0 0       0 if ($fh) {
64 0         0 my $header = <$fh>; # Skip header
65 0         0 my $data_count = 0;
66 0         0 while (my $line = <$fh>) {
67 0         0 chomp $line;
68 0 0       0 $data_count++ if $line =~ /\S/; # Count non-empty lines
69             }
70 0         0 close $fh;
71              
72             # Archive if file has data
73 0 0       0 if ($data_count > 0) {
74 0         0 my $archive_result = $temp_backend->_archive_user_data();
75 0 0       0 unless ($archive_result->{success}) {
76             return {
77             success => 0,
78             message => $archive_result->{message},
79 0         0 };
80             }
81             }
82             # If empty, just let ensure_storage() overwrite it
83             }
84             }
85              
86             # Ensure storage (file) exists
87 15         156 my $storage_ok = $temp_backend->ensure_storage();
88 15 50       66 unless ($storage_ok) {
89             return {
90 0         0 success => 0,
91             message => "Failed to initialize storage for file backend",
92             };
93             }
94              
95             # Return success with config
96             return {
97             success => 1,
98             message => "File backend configured successfully",
99             config => {
100             storage_dir => $storage_dir,
101             file_format => $format,
102             file_name => $file_name,
103             file_full_path => $file_full_path,
104             fields => $setup_config->{fields} || [],
105             field_definitions => $setup_config->{field_definitions},
106             },
107 15   50     458 };
108             }
109              
110             # ==============================================================================
111             # Constructor - Runtime instantiation (called by Users->new)
112             # ==============================================================================
113              
114             sub new {
115 17     17 1 78 my ($class, $runtime_config) = @_;
116              
117             # Extract parameters from saved config (no validation needed)
118 17         63 my $storage_dir = $runtime_config->{storage_dir};
119 17         59 my $format = $runtime_config->{file_format};
120              
121             # Initialize CSV parser
122 17 100       89 my $sep_char = ($format eq 'csv') ? ',' : "\t";
123 17         351 my $csv = Text::CSV->new({
124             sep_char => $sep_char,
125             binary => 1,
126             auto_diag => 1,
127             });
128              
129 17 50       4647 croak "Failed to initialize CSV parser for format: $format"
130             unless $csv;
131              
132             return bless {
133             storage_dir => $storage_dir,
134             format => $format,
135             csv => $csv,
136             fields => $runtime_config->{fields} || [],
137             field_definitions => $runtime_config->{field_definitions} || {},
138 17   50     302 }, $class;
      50        
139             }
140              
141             # Report backend configuration (for debugging/info)
142             sub config {
143 0     0 0 0 my ($self) = @_;
144              
145 0         0 my $file_name = "users.$self->{format}";
146              
147             return {
148             storage_dir => $self->{storage_dir},
149             file_format => $self->{format},
150             file_name => $file_name,
151             file_full_path => "$self->{storage_dir}/$file_name",
152             fields => $self->{fields},
153             field_definitions => $self->{field_definitions},
154 0         0 };
155             }
156              
157             # Ensure storage (file) exists
158             sub ensure_storage {
159 15     15 0 46 my ($self) = @_;
160              
161 15         85 my $user_file = $self->_get_user_file();
162              
163 15 50       251 return 1 if -f $user_file;
164              
165             # Create file with header
166 15 50   4   2196 open my $fh, '>:encoding(UTF-8)', $user_file
  4         2654  
  4         62  
  4         63  
167             or croak "File backend initialization failed: Cannot create user file '$user_file': $!";
168              
169 15         6646 my @headers = @{$self->{fields}};
  15         164  
170              
171 15 50       507 if ($self->{csv}->print($fh, \@headers)) {
172 15         214 print $fh "\n";
173 15         1077 close $fh;
174 15         153 return 1;
175             } else {
176 0         0 close $fh;
177 0         0 croak "File backend initialization failed: Cannot write header to file '$user_file': " . $self->{csv}->error_diag();
178             }
179             }
180              
181             # Archive existing user data (internal method, called by configure)
182             sub _archive_user_data {
183 0     0   0 my ($self) = @_;
184              
185 0         0 my $user_file = $self->_get_user_file();
186              
187             # Generate timestamp for archive filename
188 0         0 my $timestamp = $self->archive_timestamp();
189              
190             # Build archive filename: users_YYYYMMDD_HHMMSS.csv (or .tsv)
191 0         0 my ($base, $ext) = $user_file =~ /^(.+)\.([^.]+)$/;
192 0         0 my $archive_file = "${base}_${timestamp}.${ext}";
193              
194             # Rename file
195 0 0       0 unless (rename $user_file, $archive_file) {
196             return {
197 0         0 success => 0,
198             message => "Failed to archive existing user file: $!"
199             };
200             }
201              
202 0         0 return { success => 1 };
203             }
204              
205             # Get user file path
206             sub _get_user_file {
207 98     98   213 my ($self) = @_;
208              
209 98         1954 return File::Spec->catfile($self->{storage_dir}, "users.$self->{format}");
210             }
211              
212             # Add bare record with user_id and null_values
213             sub add {
214 17     17 1 73 my ($self, $user_id, $initial_record) = @_;
215 17 50       53 return { success => 0, message => "Add Record failed: missing user_id" }
216             unless $user_id;
217 17 50       41 return { success => 0, message => "Add Record failed: missing initial record" }
218             unless $initial_record;
219              
220 17         136 my %record = $initial_record->%*;
221 17         129 $record{created_date} = $self->current_timestamp();
222             # Add last_mod_date timestamp
223 17         57 $record{last_mod_date} = $self->current_timestamp();
224              
225 17         50 my $user_file = $self->_get_user_file();
226             # Check if file exists, write header if not
227 17         657 my $write_header = ! -f $user_file;
228              
229             # Open file in append mode
230 17 50       788 open my $fh, '>>:encoding(UTF-8)', $user_file
231             or return { success => 0, message => "Failed to open user file: $!" };
232              
233             # Write header if file is new
234 17 50       1380 if ($write_header) {
235 0         0 $self->{csv}->print($fh, $self->{fields});
236 0         0 print $fh "\n";
237             }
238              
239             # Put row field values in order
240 17         66 my @row = map { $record{$_} } $self->{fields}->@*;
  147         344  
241             # Write row
242 17 50       235 if ($self->{csv}->print($fh, \@row)) {
243 17         206 print $fh "\n";
244 17         1491 close $fh;
245 17         283 return { success => 1, message => "User '$user_id' created" };
246             } else {
247 0         0 close $fh;
248 0         0 return { success => 0, message => "Failed to create initial user record: " . $self->{csv}->error_diag() };
249             }
250             }
251              
252             # Fetch user by ID
253             sub fetch {
254 40     40 1 99 my ($self, $user_id) = @_;
255              
256 40         174 my $user_file = $self->_get_user_file();
257              
258 40 50       2817 open my $fh, '<:encoding(UTF-8)', $user_file
259             or return {
260             success => 0,
261             data => '',
262             message => "Cannot open user file: $!"
263             };
264              
265             # Skip header
266 40         5009 my $header = <$fh>;
267 40         454 chomp $header;
268 40 50       247 my @fields = $self->{csv}->parse($header) ? $self->{csv}->fields() : ();
269              
270             # Search for user
271 40         2364 while (my $line = <$fh>) {
272 73         144 chomp $line;
273 73 100       225 next unless $line;
274              
275 45         151 $self->{csv}->parse($line);
276 45         1135 my @values = $self->{csv}->fields();
277              
278 45         434 my %user;
279 45         350 @user{@fields} = @values;
280              
281 45 100       356 if ($user{user_id} eq $user_id) {
282 19         343 close $fh;
283             return {
284 19         231 success => 1,
285             data => \%user,
286             message => ''
287             };
288             }
289             }
290              
291 21         415 close $fh;
292             return {
293 21         288 success => 0,
294             data => '',
295             message => "User '$user_id' not found"
296             };
297             }
298              
299             # Update user
300             sub update {
301 20     20 1 61 my ($self, $user_id, $updates) = @_;
302              
303             # Remove readonly fields from updates
304 20         65 my %readonly = map { $_ => 1 } qw(user_id created_date last_mod_date);
  60         211  
305 20         107 delete $updates->{$_} for keys %readonly;
306              
307             # Add last_mod_date timestamp
308 20         79 $updates->{last_mod_date} = $self->current_timestamp();
309              
310 20         70 my $user_file = $self->_get_user_file();
311              
312             # Read entire file
313 20 50       982 open my $fh_in, '<:encoding(UTF-8)', $user_file
314             or return { success => 0, message => "Failed to read user file: $!" };
315              
316 20         1420 my @lines;
317 20         518 my $header = <$fh_in>;
318 20         210 push @lines, $header;
319              
320 20         51 chomp $header;
321 20         157 $self->{csv}->parse($header);
322 20         702 my @fields = $self->{csv}->fields();
323              
324 20         198 my $found = 0;
325 20         89 while (my $line = <$fh_in>) {
326 41         84 chomp $line;
327              
328 41         153 $self->{csv}->parse($line);
329 41         1071 my @values = $self->{csv}->fields();
330              
331 41         352 my %user;
332 41         315 @user{@fields} = @values;
333              
334 41 100       133 if ($user{user_id} eq $user_id) {
335             # Update user with provided data
336 20         77 foreach my $field (keys %$updates) {
337 45         109 $user{$field} = $updates->{$field};
338             }
339 20         43 $found = 1;
340             }
341              
342             # Write back user data
343 41         65 my @row;
344 41         92 foreach my $field (@fields) {
345 351   100     962 push @row, $user{$field} || '';
346             }
347              
348 41         62 my $output;
349 41 50       236 if ($self->{csv}->combine(@row)) {
350 41         933 $output = $self->{csv}->string();
351 41         876 push @lines, $output;
352             }
353             }
354              
355 20         312 close $fh_in;
356              
357 20 50       63 return { success => 0, message => "User '$user_id' not found" } unless $found;
358              
359             # Write entire file back
360 20 50       2131 open my $fh_out, '>:encoding(UTF-8)', $user_file
361             or return { success => 0, message => "Failed to write user file: $!" };
362              
363 20         1711 foreach my $line (@lines) {
364 61         202 print $fh_out $line, "\n";
365             }
366              
367 20         3832 close $fh_out;
368 20         373 return { success => 1, message => "User '$user_id' updated" };
369             }
370              
371             # List users with filters
372             sub list {
373 4     4 1 12 my ($self, $filters, $options) = @_;
374              
375 4         14 my $user_file = $self->_get_user_file();
376              
377 4 50       208 open my $fh, '<:encoding(UTF-8)', $user_file
378             or return { data => [], total_count => 0 };
379              
380             # Skip header
381 4         405 my $header = <$fh>;
382 4         71 chomp $header;
383 4         29 $self->{csv}->parse($header);
384 4         139 my @fields = $self->{csv}->fields();
385              
386 4         42 my @users;
387              
388 4         21 while (my $line = <$fh>) {
389 25         46 chomp $line;
390 25 100       65 next unless $line;
391              
392 20         76 $self->{csv}->parse($line);
393 20         581 my @values = $self->{csv}->fields();
394              
395 20         181 my %user;
396 20         151 @user{@fields} = @values;
397              
398             # Skip rows where user_id is empty/undefined
399 20 100       103 next unless $user{user_id};
400              
401             # Apply DSL filters
402 11         19 my $match = 1;
403              
404 11 100 66     90 if (ref $filters eq 'HASH' && exists $filters->{or_groups}) {
405 3         7 $match = 0; # Start with no match, need at least one OR group to match
406              
407 3         5 foreach my $and_group (@{$filters->{or_groups}}) {
  3         11  
408 3         5 my $group_match = 1; # All conditions in this AND group must match
409              
410 3         7 foreach my $condition (@$and_group) {
411 3         12 my ($field, $op, $value) = ($condition->{field}, $condition->{op}, $condition->{value});
412 3   50     24 my $user_value = $user{$field} || '';
413              
414 3 50       9 if ($op eq '=') {
    0          
    0          
    0          
    0          
415 3 100       10 $group_match = 0 unless $user_value eq $value;
416             } elsif ($op eq ':') {
417 0 0       0 $group_match = 0 unless $user_value =~ /\Q$value\E/i;
418             } elsif ($op eq '!') {
419 0 0       0 $group_match = 0 if $user_value =~ /\Q$value\E/i;
420             } elsif ($op eq '>') {
421 0 0       0 $group_match = 0 unless $user_value gt $value;
422             } elsif ($op eq '<') {
423 0 0       0 $group_match = 0 unless $user_value lt $value;
424             }
425             }
426              
427 3 100       10 $match = 1 if $group_match; # At least one OR group matched
428 3 100       7 last if $match;
429             }
430             }
431              
432 11 100       179 push @users, \%user if $match;
433             }
434              
435 4         90 close $fh;
436              
437             return {
438 4         49 data => \@users,
439             total_count => scalar @users,
440             };
441             }
442              
443             # Delete user
444             sub delete {
445 2     2 1 7 my ($self, $user_id) = @_;
446              
447 2         27 my $user_file = $self->_get_user_file();
448              
449             # Read entire file
450 2 50       104 open my $fh_in, '<:encoding(UTF-8)', $user_file
451             or return { success => 0, message => "Failed to read user file: $!" };
452              
453 2         161 my @lines;
454 2         43 my $header = <$fh_in>;
455 2         54 push @lines, $header;
456              
457 2         6 chomp $header;
458 2         14 $self->{csv}->parse($header);
459 2         68 my @fields = $self->{csv}->fields();
460              
461 2         21 my $found = 0;
462 2         12 while (my $line = <$fh_in>) {
463 9         20 chomp $line;
464              
465 9         31 $self->{csv}->parse($line);
466 9         265 my @values = $self->{csv}->fields();
467              
468 9         82 my %user;
469 9         55 @user{@fields} = @values;
470              
471 9 100       24 if ($user{user_id} ne $user_id) {
472 7         59 push @lines, $line;
473             } else {
474 2         27 $found = 1;
475             }
476             }
477              
478 2         36 close $fh_in;
479              
480 2 50       9 return { success => 0, message => "User '$user_id' not found" } unless $found;
481              
482             # Write entire file back
483 2 50       214 open my $fh_out, '>:encoding(UTF-8)', $user_file
484             or return { success => 0, message => "Failed to write user file: $!" };
485              
486 2         191 foreach my $line (@lines) {
487 9         28 print $fh_out $line, "\n";
488             }
489              
490 2         382 close $fh_out;
491 2         39 return { success => 1, message => "User '$user_id' deleted" };
492             }
493              
494             # Cleanup
495             sub disconnect {
496 17     17 0 1218 my $self = shift;
497             # No resources to clean up for file backend
498             }
499              
500             1;
501              
502             __END__