File Coverage

lib/Concierge/Sessions/SQLite.pm
Criterion Covered Total %
statement 111 135 82.2
branch 34 62 54.8
condition 7 12 58.3
subroutine 14 14 100.0
pod 6 7 85.7
total 172 230 74.7


line stmt bran cond sub pod time code
1             package Concierge::Sessions::SQLite v0.11.0;
2 4     4   49 use v5.36;
  4         15  
3              
4 4     4   548 use parent 'Concierge::Sessions::Base';
  4         344  
  4         27  
5 4     4   4823 use DBI;
  4         64315  
  4         309  
6 4     4   31 use File::Spec;
  4         6  
  4         118  
7 4     4   18 use Carp qw(croak);
  4         20  
  4         196  
8 4     4   3238 use JSON::PP;
  4         69563  
  4         6768  
9              
10             sub new {
11 62     62 0 220 my ($class, %args) = @_;
12 62         384 my $self = $class->SUPER::new(%args);
13 62   50     370 $self->{storage_dir} = $args{storage_dir} || '/tmp/sessions';
14              
15 62 50       1557 unless (-d $self->{storage_dir}) {
16 0 0       0 unless (mkdir $self->{storage_dir}) {
17 0         0 croak "Failed to create storage directory '$self->{storage_dir}': $!";
18             }
19             }
20              
21 62         1311 $self->{dsn} = File::Spec->catfile( $self->{storage_dir}, 'sessions.db' );
22              
23 62         746 $self->{dbh} = DBI->connect("dbi:SQLite:dbname=$self->{dsn}", "", "", {
24             RaiseError => 0,
25             AutoCommit => 1,
26             });
27              
28 62 50       95735 unless ($self->{dbh}) {
29 0         0 croak "Failed to connect to SQLite database '$self->{dsn}': " . DBI->errstr;
30             }
31              
32 62         467 my $result = $self->{dbh}->do(q{
33             CREATE TABLE IF NOT EXISTS sessions (
34             session_id TEXT PRIMARY KEY,
35             user_id TEXT,
36             created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
37             expires_at TIMESTAMP,
38             last_updated TIMESTAMP,
39             session_timeout INTEGER,
40             status JSON,
41             data JSON
42             )
43             });
44              
45 62 50       51046 unless ($result) {
46 0         0 croak "Failed to create sessions table: " . $self->{dbh}->errstr;
47             }
48              
49 62         411 $result = $self->{dbh}->do(q{ CREATE INDEX IF NOT EXISTS idx_session_id ON sessions (session_id) });
50 62 50       58806 unless ($result) {
51 0         0 croak "Failed to create session_id index: " . $self->{dbh}->errstr;
52             }
53              
54 62         401 $result = $self->{dbh}->do(q{ CREATE INDEX IF NOT EXISTS expirations ON sessions (expires_at) });
55 62 50       46525 unless ($result) {
56 0         0 croak "Failed to create expiration index: " . $self->{dbh}->errstr;
57             }
58              
59 62         383 return $self;
60             }
61              
62             sub create_session {
63 57     57 1 178 my ($self, %args) = @_;
64              
65             return { success => 0, message => "Cannot create session without user_id" }
66 57 50       200 unless $args{user_id};
67              
68 57         152 my $user_id = $args{user_id};
69              
70             # Delete any existing sessions for this user (enforce single session per user)
71 57         298 $self->delete_user_session($user_id);
72              
73             # Build session_info structure
74 57         400 my $session_id = $self->generate_session_id();
75              
76 57         1864 my $now = time();
77              
78             # Handle session timeout: 'indefinite' or numeric value in seconds
79 57   33     231 my $timeout = $args{session_timeout} || $self->{session_timeout};
80 57         126 my $expires_at;
81 57 100 66     356 if (defined $timeout && $timeout eq 'indefinite') {
82 3         10 $expires_at = 'indefinite';
83             } else {
84 54         106 $expires_at = $now + $timeout;
85             }
86              
87 57         101 my $created_at = $now;
88 57         80 my $last_updated = $now;
89 57         247 my $status = { state => 'active', dirty => 0 };
90 57         350 my $status_json = JSON::PP->new->utf8->encode( $status );
91 57   100     13206 my $data = $args{data} || {}; # for app data
92 57         209 my $data_json = JSON::PP->new->utf8->encode( $data );
93              
94             my $sth = $self->{dbh}->prepare(
95 57         6238 "INSERT INTO sessions (
96             session_id,
97             user_id,
98             created_at,
99             expires_at,
100             last_updated,
101             session_timeout,
102             status,
103             data
104             ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
105             );
106              
107 57 50       5975 unless ($sth) {
108 0         0 return { success => 0, message => "Failed to prepare insert statement: " . $self->{dbh}->errstr };
109             }
110              
111 57         909934 my $result = $sth->execute(
112             $session_id,
113             $user_id,
114             $created_at,
115             $expires_at,
116             $last_updated,
117             $timeout,
118             $status_json,
119             $data_json
120             );
121              
122 57 50       590 unless ($result) {
123 0         0 return { success => 0, message => "Failed to insert session: " . $sth->errstr };
124             }
125              
126 57         3136 return { success => 1, session_id => $session_id };
127             }
128              
129             sub get_session_info {
130 79     79 1 268 my ($self, $session_id) = @_;
131              
132 79 50       274 unless ($session_id) {
133 0         0 return { success => 0, message => "Session ID required to retrieve session from SQLite backend" };
134             }
135              
136             # Query filters expired sessions but allows indefinite sessions
137             my $sth = $self->{dbh}->prepare(
138 79         1008 "SELECT * FROM sessions WHERE session_id = ? AND (expires_at = 'indefinite' OR expires_at > ?)"
139             );
140              
141 79 50       13457 unless ($sth) {
142 0         0 return { success => 0, message => "Failed to prepare select statement: " . $self->{dbh}->errstr };
143             }
144              
145 79         8341 my $result = $sth->execute($session_id, time());
146              
147 79 50       381 unless ($result) {
148 0         0 return { success => 0, message => "Failed to execute session query: " . $sth->errstr };
149             }
150              
151 79         4032 my $session_info = $sth->fetchrow_hashref;
152              
153 79 100       439 unless ($session_info) {
154 8         183 return { success => 0, message => "Session not found or expired" };
155             }
156              
157             # Decode session_info from JSON to hashref
158 71         663 $session_info->{status} = JSON::PP->new->utf8->decode( $session_info->{status} );
159 71         40446 $session_info->{data} = JSON::PP->new->utf8->decode( $session_info->{data} );
160              
161 71         25145 return { success => 1, info => $session_info };
162             }
163              
164             sub update_session {
165 12     12 1 49 my ($self, $session_id, $updates) = @_;
166              
167 12 50       32 unless ($session_id) {
168 0         0 return { success => 0, message => "Session ID required to update session in SQLite backend" };
169             }
170              
171 12 50       30 unless ($updates) {
172 0         0 return { success => 1, message => "No updates specified for File backend session update" };
173             }
174              
175             # Always update last_updated timestamp
176 12         25 my $now = time();
177              
178             # Build SET clause dynamically based on what's being updated
179 12         28 my @set_clauses;
180             my @bind_values;
181              
182 12 50       42 if (exists $updates->{data}) {
183 12         28 push @set_clauses, 'data = ?';
184 12   50     90 push @bind_values, JSON::PP->new->utf8->encode($updates->{data} || {});
185             }
186              
187 12 50       3484 if (exists $updates->{expires_at}) {
188 12         24 push @set_clauses, 'expires_at = ?';
189 12         27 push @bind_values, $updates->{expires_at};
190             }
191              
192             # Always update last_updated
193 12         40 push @set_clauses, 'last_updated = ?';
194 12         32 push @bind_values, $now;
195              
196             # Build SQL statement
197 12         40 my $sql = 'UPDATE sessions SET ' . join(', ', @set_clauses) . ' WHERE session_id = ?';
198 12         20 push @bind_values, $session_id;
199              
200 12         101 my $sth = $self->{dbh}->prepare($sql);
201              
202 12 50       1206 unless ($sth) {
203 0         0 return { success => 0, message => "Failed to prepare update statement: " . $self->{dbh}->errstr };
204             }
205              
206 12         226569 my $result = $sth->execute(@bind_values);
207              
208 12 50       125 unless ($result) {
209 0         0 return { success => 0, message => "Failed to update session: " . $sth->errstr };
210             }
211              
212 12 50       49 unless ($result > 0) {
213 0         0 return { success => 0, message => "Session not found or no changes made" };
214             }
215              
216 12         577 return { success => 1 };
217             }
218              
219             sub delete_session {
220 4     4 1 12 my ($self, $session_id) = @_;
221              
222 4 50       13 unless ($session_id) {
223 0         0 return { success => 0, message => "Session ID required to delete session from SQLite backend" };
224             }
225              
226 4         31 my $sth = $self->{dbh}->prepare("DELETE FROM sessions WHERE session_id = ?");
227              
228 4 50       352 unless ($sth) {
229 0         0 return { success => 0, message => "Failed to prepare delete statement: " . $self->{dbh}->errstr };
230             }
231              
232 4         45701 my $result = $sth->execute($session_id);
233              
234 4 50       32 unless ($result) {
235 0         0 return { success => 0, message => "Failed to delete session: " . $sth->errstr };
236             }
237              
238 4         112 return { success => 1 };
239             }
240              
241             sub cleanup_sessions {
242 2     2 1 6 my ($self) = shift;
243              
244             # Delete only sessions with numeric expiration times that have passed
245             # Indefinite sessions (expires_at = 'indefinite') are preserved
246 2         28 my $sth = $self->{dbh}->prepare("DELETE FROM sessions WHERE expires_at != 'indefinite' AND expires_at < ?");
247              
248 2 50       168 unless ($sth) {
249 0         0 return { success => 0, message => "Failed to prepare cleanup statement: " . $self->{dbh}->errstr };
250             }
251              
252 2         12829 my $result = $sth->execute( time() );
253              
254 2 50       18 unless ($result) {
255 0         0 return { success => 0, message => "Failed to cleanup expired sessions: " . $sth->errstr };
256             }
257              
258             # Convert 0E0 to plain 0 if no rows deleted
259 2 100       11 my $deleted_count = $result eq '0E0' ? 0 : $result;
260            
261 2         108 my $active = $self->{dbh}->selectcol_arrayref(qq{SELECT session_id FROM sessions WHERE session_id != '' });
262              
263 2         530 return { success => 1, deleted_count => $deleted_count, active => $active };
264             }
265              
266             # sub delete_user_sessions {
267             sub delete_user_session {
268 57     57 1 149 my ($self, $user_id) = @_;
269              
270 57 50       162 unless ($user_id) {
271 0         0 return { success => 0, message => "user_id required to delete user sessions from SQLite backend" };
272             }
273              
274 57         411 my $sth = $self->{dbh}->prepare("DELETE FROM sessions WHERE user_id = ?");
275              
276 57 50       5045 unless ($sth) {
277 0         0 return { success => 0, message => "Failed to prepare delete user sessions statement: " . $self->{dbh}->errstr };
278             }
279              
280 57         50989 my $result = $sth->execute($user_id);
281              
282 57 50       247 unless ($result) {
283 0         0 return { success => 0, message => "Failed to delete user sessions: " . $sth->errstr };
284             }
285              
286             # Convert 0E0 to plain 0 if no rows deleted
287 57 100       231 my $deleted_count = $result eq '0E0' ? 0 : $result;
288              
289 57         1198 return { success => 1, deleted_count => $deleted_count };
290             }
291              
292             sub DESTROY {
293 62     62   50167 my ($self) = @_;
294 62         9171 $self->{dbh}->disconnect;
295             }
296              
297             1;
298              
299             __END__