File Coverage

blib/lib/Concierge/Users/Database.pm
Criterion Covered Total %
statement 133 169 78.7
branch 31 62 50.0
condition 9 20 45.0
subroutine 14 16 87.5
pod 8 10 80.0
total 195 277 70.4


line stmt bran cond sub pod time code
1             package Concierge::Users::Database v0.8.0;
2 6     6   970 use v5.36;
  6         22  
3 6     6   31 use Carp qw/ croak /;
  6         21  
  6         493  
4 6     6   9738 use DBI;
  6         131370  
  6         600  
5 6     6   73 use parent qw/ Concierge::Users::Meta /;
  6         21  
  6         66  
6              
7             # ABSTRACT: Database backend for Concierge::Users
8              
9             # ==============================================================================
10             # Configure Class Method - One-time setup (called by Users->setup)
11             # ==============================================================================
12              
13             sub configure {
14 29     29 1 117 my ($class, $setup_config) = @_;
15              
16             # Extract storage_dir
17 29         73 my $storage_dir = $setup_config->{storage_dir};
18              
19             # Build SQLite DSN and file path
20 29         73 my $db_file = "$storage_dir/users.db";
21 29         78 my $dsn = "dbi:SQLite:$db_file";
22              
23             # Connect to database
24 29         447 my $dbh = DBI->connect($dsn, '', '', {
25             RaiseError => 0,
26             AutoCommit => 1,
27             PrintError => 0,
28             sqlite_unicode => 1,
29             });
30              
31 29 50       101326 unless ($dbh) {
32             return {
33 0   0     0 success => 0,
34             message => sprintf(
35             "Database backend connection failed:\n" .
36             " - Database file: %s\n" .
37             " - Error: %s",
38             $db_file,
39             $DBI::errstr || 'Unknown error'
40             ),
41             };
42             }
43              
44             # Create temporary object for ensure_storage
45             my $temp_backend = bless {
46             dbh => $dbh,
47             table_name => 'users',
48             storage_dir => $storage_dir,
49             db_file => $db_file,
50             field_definitions => $setup_config->{field_definitions},
51 29   50     396 fields => $setup_config->{fields} || [],
52             }, $class;
53              
54             # Check for existing data and archive if present
55 29         71 my $check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name=?";
56 29         201 my $sth = $dbh->prepare($check_sql);
57 29 50       2946 if ($sth) {
58 29         2008 $sth->execute('users');
59 29         277 my ($table_exists) = $sth->fetchrow_array();
60 29         139 $sth->finish();
61              
62 29 50       126 if ($table_exists) {
63             # Check if table has data
64 0         0 my $count_sql = "SELECT COUNT(*) FROM users";
65 0         0 my $count_sth = $dbh->prepare($count_sql);
66 0 0       0 if ($count_sth) {
67 0         0 $count_sth->execute();
68 0         0 my ($user_count) = $count_sth->fetchrow_array();
69 0         0 $count_sth->finish();
70              
71             # Archive if table has data
72 0 0       0 if ($user_count > 0) {
73 0         0 my $archive_result = $temp_backend->_archive_user_data();
74 0 0       0 unless ($archive_result->{success}) {
75 0         0 $temp_backend->disconnect();
76             return {
77             success => 0,
78             message => $archive_result->{message},
79 0         0 };
80             }
81             } else {
82             # Drop empty table
83 0         0 $dbh->do("DROP TABLE users");
84             }
85             }
86             }
87             }
88              
89             # Ensure storage (table) exists
90 29         153 my $storage_ok = $temp_backend->ensure_storage();
91 29 50       215 unless ($storage_ok) {
92             return {
93 0         0 success => 0,
94             message => "Failed to initialize storage for database backend",
95             };
96             }
97              
98             # Disconnect temp object
99 29         266 $temp_backend->disconnect();
100              
101             # Return success with config
102             return {
103             success => 1,
104             message => "Database backend configured successfully",
105             config => {
106             storage_dir => $storage_dir,
107             db_file => 'users.db',
108             db_full_path => $db_file,
109             table_name => 'users',
110             fields => $setup_config->{fields} || [],
111             field_definitions => $setup_config->{field_definitions},
112             },
113 29   50     1713 };
114             }
115              
116             # ==============================================================================
117             # Constructor - Runtime instantiation (called by Users->new)
118             # ==============================================================================
119             sub new {
120 27     27 1 99 my ($class, $runtime_config) = @_;
121              
122             # Extract parameters from saved config (no validation needed)
123 27         102 my $storage_dir = $runtime_config->{storage_dir};
124 27         105 my $db_file = $runtime_config->{db_full_path};
125 27   50     119 my $table_name = $runtime_config->{table_name} || 'users';
126              
127             # Build SQLite DSN
128 27         74 my $dsn = "dbi:SQLite:$db_file";
129              
130             # Connect to database
131 27         480 my $dbh = DBI->connect($dsn, '', '', {
132             RaiseError => 0,
133             AutoCommit => 1,
134             PrintError => 0,
135             sqlite_unicode => 1,
136             });
137              
138 27 50       24026 unless ($dbh) {
139 0   0     0 croak sprintf(
140             "Database backend connection failed:\n" .
141             " - Database file: %s\n" .
142             " - Error: %s",
143             $db_file,
144             $DBI::errstr || 'Unknown error'
145             );
146             }
147              
148             return bless {
149             dbh => $dbh,
150             table_name => $table_name,
151             storage_dir => $storage_dir,
152             db_file => $db_file,
153             fields => $runtime_config->{fields} || [],
154             field_definitions => $runtime_config->{field_definitions} || {},
155 27   50     438 }, $class;
      50        
156             }
157              
158             # Report backend configuration (for debugging/info)
159             sub config {
160 0     0 0 0 my ($self) = @_;
161              
162             return {
163             storage_dir => $self->{storage_dir},
164             db_file => $self->{db_file},
165             db_full_path => $self->{db_file},
166             table_name => $self->{table_name},
167             fields => $self->{fields},
168             field_definitions => $self->{field_definitions},
169 0         0 };
170             }
171              
172             # Ensure storage (table) exists
173             sub ensure_storage {
174 29     29 0 89 my ($self) = @_;
175              
176             # Check if table exists
177 29         67 my $check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name=?";
178 29         221 my $sth = $self->{dbh}->prepare($check_sql);
179 29 50       2198 return 0 unless $sth;
180 29         1442 $sth->execute($self->{table_name});
181 29         185 my ($exists) = $sth->fetchrow_array();
182              
183 29 50       143 return 1 if $exists; # Table already exists
184              
185             # Build CREATE TABLE SQL
186 29         91 my @field_defs;
187             my @indexes;
188              
189 29         55 foreach my $field (@{$self->{fields}}) {
  29         117  
190             # All fields are TEXT in our schema
191 340         627 my $field_def = "$field TEXT";
192              
193             # Check if field is required
194 340         657 my $field_def_info = $self->{field_definitions}{$field};
195 340 100 66     1161 if ($field_def_info && $field_def_info->{required}) {
196 146         282 $field_def .= " NOT NULL";
197             }
198              
199 340         656 push @field_defs, $field_def;
200             }
201              
202             # Primary key on user_id
203 29         137 push @indexes, "CREATE UNIQUE INDEX idx_user_id ON $self->{table_name}(user_id)";
204              
205             # Create table
206 29         240 my $create_sql = "CREATE TABLE $self->{table_name} (\n" .
207             join(",\n ", @field_defs) . "\n)";
208              
209 29         272 my $result = $self->{dbh}->do($create_sql);
210 29 50       519733 return 0 unless $result;
211              
212             # Create indexes
213 29         150 foreach my $index_sql (@indexes) {
214 29         648 $self->{dbh}->do($index_sql);
215             }
216              
217 29         613081 return 1;
218             }
219              
220             # Archive existing user data (internal method, called by configure)
221             sub _archive_user_data {
222 0     0   0 my ($self) = @_;
223              
224             # Generate timestamp for archive table name
225 0         0 my $timestamp = $self->archive_timestamp();
226 0         0 my $archive_table = "$self->{table_name}_$timestamp";
227              
228             # Rename table
229 0         0 my $rename_sql = "ALTER TABLE $self->{table_name} RENAME TO $archive_table";
230 0         0 my $result = $self->{dbh}->do($rename_sql);
231              
232 0 0       0 unless ($result) {
233             return {
234             success => 0,
235             message => "Failed to archive existing table: " . $self->{dbh}->errstr
236 0         0 };
237             }
238              
239 0         0 return { success => 1 };
240             }
241              
242             # Add bare record with user_id and null_values
243             sub add {
244 80     80 1 366 my ($self, $user_id, $initial_record) = @_;
245 80 50       325 return { success => 0, message => "Add Record failed: missing user_id" }
246             unless $user_id;
247 80 50       306 return { success => 0, message => "Add Record failed: missing initial record" }
248             unless $initial_record;
249              
250 80         646 my %record = $initial_record->%*;
251 80         670 $record{created_date} = $self->current_timestamp();
252             # Add last_mod_date timestamp
253 80         280 $record{last_mod_date} = $self->current_timestamp();
254              
255             # Insert into database
256 80         333 my @fields = keys %record;
257 80         300 my @placeholders = map { '?' } @fields;
  644         1186  
258 80         483 my @values = @record{@fields};
259              
260 80         1512 my $sql = "INSERT INTO $self->{table_name} (" .
261             join(', ', @fields) . ") VALUES (" .
262             join(', ', @placeholders) . ")";
263              
264 80         640 my $sth = $self->{dbh}->prepare($sql);
265 80 50       1691036 if ($sth->execute(@values)) {
266 80         6000 return { success => 1, message => "Initial record created for user '$user_id'" };
267             } else {
268 0         0 return { success => 0, message => "Failed to create initial user record: " . $self->{dbh}->errstr };
269             }
270             }
271              
272             # Fetch user by ID
273             sub fetch {
274 108     108 1 488 my ($self, $user_id) = @_;
275              
276 108         441 my $sql = "SELECT * FROM $self->{table_name} WHERE user_id = ?";
277 108         1480 my $sth = $self->{dbh}->prepare($sql);
278 108         26636 $sth->execute($user_id);
279              
280 108         4110 my $user_data = $sth->fetchrow_hashref();
281              
282             return {
283 108 100       3016 success => $user_data ? 1 : 0,
    100          
284             data => $user_data,
285             message => $user_data ? '' : "User '$user_id' not found"
286             };
287             }
288              
289             # Update user
290             sub update {
291 84     84 1 355 my ($self, $user_id, $updates) = @_;
292              
293             # Remove readonly fields from updates
294 84         335 my %readonly = map { $_ => 1 } qw(user_id created_date last_mod_date);
  252         884  
295 84         659 delete $updates->{$_} for keys %readonly;
296              
297             # Add last_mod_date timestamp
298 84         502 $updates->{last_mod_date} = $self->current_timestamp();
299              
300 84         346 my @fields = keys %$updates;
301 84         280 my @values = values %$updates;
302 84         194 push @values, $user_id; # For WHERE clause
303              
304             my $sql = "UPDATE $self->{table_name} SET " .
305 84         389 join(', ', map { "$_ = ?" } @fields) .
  165         755  
306             " WHERE user_id = ?";
307              
308 84         1299 my $sth = $self->{dbh}->prepare($sql);
309 84 50       13536 unless ($sth) {
310 0         0 return { success => 0, message => "Failed to prepare update: " . $self->{dbh}->errstr };
311             }
312              
313 84 50       1464657 if ($sth->execute(@values)) {
314 84         5336 return { success => 1, message => "User '$user_id' updated" };
315             } else {
316 0         0 return { success => 0, message => "Failed to update user: " . $self->{dbh}->errstr };
317             }
318             }
319              
320             # List users with filters
321             sub list {
322 7     7 1 24 my ($self, $filters, $options) = @_;
323              
324             # Build WHERE clause from DSL filter structure
325 7         18 my @where_clauses;
326             my @where_values;
327              
328 7 100 66     84 if (ref $filters eq 'HASH' && exists $filters->{or_groups}) {
329             # Parse DSL filter structure
330 2         4 my @or_groups;
331              
332 2         4 foreach my $and_group (@{$filters->{or_groups}}) {
  2         7  
333 2         3 my @and_clauses;
334 2         5 foreach my $condition (@$and_group) {
335 2         8 my ($field, $op, $value) = ($condition->{field}, $condition->{op}, $condition->{value});
336              
337 2         25 my $clause;
338 2 100       10 if ($op eq '=') {
    50          
    0          
    0          
    0          
339 1         4 $clause = "$field = ?";
340 1         3 push @where_values, $value;
341             } elsif ($op eq ':') {
342 1         3 $clause = "$field LIKE ?";
343 1         4 push @where_values, "%$value%";
344             } elsif ($op eq '!') {
345 0         0 $clause = "$field NOT LIKE ?";
346 0         0 push @where_values, "%$value%";
347             } elsif ($op eq '>') {
348 0         0 $clause = "$field > ?";
349 0         0 push @where_values, $value;
350             } elsif ($op eq '<') {
351 0         0 $clause = "$field < ?";
352 0         0 push @where_values, $value;
353             } else {
354 0         0 next; # Skip unknown operators
355             }
356              
357 2         6 push @and_clauses, $clause;
358             }
359              
360             # Join AND conditions
361 2 50       6 if (@and_clauses) {
362 2         10 push @or_groups, "(" . join(" AND ", @and_clauses) . ")";
363             }
364             }
365              
366             # Join OR groups
367 2 50       5 if (@or_groups) {
368 2         7 push @where_clauses, join(" OR ", @or_groups);
369             }
370             }
371              
372             # Data query
373 7         45 my $sql = "SELECT * FROM $self->{table_name}";
374 7 100       28 if (@where_clauses) {
375 2         6 $sql .= " WHERE " . join(' AND ', @where_clauses);
376             }
377              
378 7         84 my $sth = $self->{dbh}->prepare($sql);
379 7         1381 $sth->execute(@where_values);
380              
381 7         26 my @users;
382 7         288 while (my $row = $sth->fetchrow_hashref()) {
383 62         1126 push @users, $row;
384             }
385              
386             return {
387 7         159 data => \@users,
388             total_count => scalar @users,
389             };
390             }
391              
392             # Delete user
393             sub delete {
394 1     1 1 4 my ($self, $user_id) = @_;
395              
396 1         3 my $sql = "DELETE FROM $self->{table_name} WHERE user_id = ?";
397 1         5 my $sth = $self->{dbh}->prepare($sql);
398              
399 1 50       23215 if ($sth->execute($user_id)) {
400 1         47 return { success => 1, message => "User '$user_id' deleted" };
401             } else {
402 0         0 return { success => 0, message => "Failed to delete user: " . $self->{dbh}->errstr };
403             }
404             }
405              
406             # Cleanup
407             sub disconnect {
408 112     112 1 276 my $self = shift;
409 112 100       5691 if ($self->{dbh}) {
410 56         6656 $self->{dbh}->disconnect();
411 56         2538 $self->{dbh} = undef;
412             }
413             }
414              
415             sub DESTROY {
416 56     56   230 my $self = shift;
417 56         221 $self->disconnect();
418             }
419              
420             1;
421              
422             __END__