File Coverage

blib/lib/Dancer/Session/DBI.pm
Criterion Covered Total %
statement 29 78 37.1
branch 6 34 17.6
condition 2 5 40.0
subroutine 9 15 60.0
pod 4 4 100.0
total 50 136 36.7


line stmt bran cond sub pod time code
1             package Dancer::Session::DBI;
2              
3             # ABSTRACT: DBI based session engine for Dancer
4              
5             =head1 NAME
6              
7             Dancer::Session::DBI - DBI based session engine for Dancer
8              
9             =head1 SYNOPSIS
10              
11             This module implements a session engine by serializing the session,
12             and storing it in a database via L. The default serialization method is L,
13             though one can specify any serialization format you want. L and L are
14             viable alternatives.
15              
16             JSON was chosen as the default serialization format, as it is fast, terse, and portable.
17              
18             Supported databases are MySQL > 4.1.1, PostgreSQL > 9.1, and SQLite > 3.0
19              
20             =head1 USAGE
21              
22             In config.yml
23              
24             session: "DBI"
25             session_options:
26             dsn: "DBI:mysql:database=testing;host=127.0.0.1;port=3306" # DBI Data Source Name
27             table: "sessions" # Name of the table to store sessions
28             user: "user" # Username used to connect to the database
29             password: "password" # Password to connect to the database
30              
31             Alternatively, you can set the database handle in your application, by passing
32             an anonymous sub that returns an active DBH connection. Specifying a custom
33             serializer / deserializer is also possible
34              
35             set 'session_options' => {
36             dbh => sub { DBI->connect( 'DBI:mysql:database=testing;host=127.0.0.1;port=3306', 'user', 'password' ); },
37             serializer => sub { YAML::Dump(@_); },
38             deserializer => sub { YAML::Load(@_); },
39             table => 'sessions',
40             };
41              
42             The following schema is the minimum requirement.
43              
44             CREATE TABLE `sessions` (
45             `id` CHAR(40) PRIMARY KEY,
46             `session_data` TEXT
47             );
48              
49             If using a C table, you must use a C type for the C field, as that
50             table type doesn't support C
51              
52             A timestamp field that updates when a session is updated is recommended, so you can expire sessions
53             server-side as well as client-side. You can do this in MySQL with the following SQL. Other database
54             engines are left as an exercise for the reader.
55              
56             `last_active` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
57              
58             This session engine will not automagically remove expired sessions on the server, but with a timestamp
59             field as above, you should be able to to do this manually.
60              
61             =cut
62              
63 3     3   59427 use strict;
  3         8  
  3         137  
64 3     3   2656 use parent 'Dancer::Session::Abstract';
  3         1082  
  3         17  
65              
66 3     3   670650 use Dancer qw(:syntax);
  3         462247  
  3         18  
67 3     3   14183 use DBI;
  3         85961  
  3         273  
68 3     3   93 use Try::Tiny;
  3         6  
  3         3476  
69              
70             our $VERSION = '1.2.1';
71              
72              
73             =head1 METHODS
74              
75             =head2 create()
76              
77             Creates a new session. Returns the session object.
78              
79             =cut
80              
81             sub create {
82 4     4 1 4758 return Dancer::Session::DBI->new->flush;
83             }
84              
85              
86             =head2 flush()
87              
88             Write the session to the database. Returns the session object.
89              
90             =cut
91              
92             sub flush {
93 4     4 1 781 my $self = shift;
94              
95 4         15 my $quoted_table = $self->_quote_table;
96              
97             # There is no simple cross-database way to do an "upsert"
98             # without race-conditions. So we will have to check what database driver
99             # we are using, and issue the appropriate syntax.
100 0         0 my $driver = lc $self->_dbh->{Driver}{Name};
101              
102 0 0       0 if ($driver eq 'mysql') {
    0          
    0          
103              
104             # MySQL 4.1.1 made this syntax actually work. Best be extra careful
105 0 0       0 if ($self->_dbh->{mysql_serverversion} < 40101) {
106 0         0 die "A minimum of MySQL 4.1.1 is required";
107             }
108              
109 0         0 my $sth = $self->_dbh->prepare(qq{
110             INSERT INTO $quoted_table (id, session_data)
111             VALUES (?, ?)
112             ON DUPLICATE KEY
113             UPDATE session_data = ?
114             });
115              
116 0         0 $sth->execute($self->id, $self->_serialize, $self->_serialize);
117              
118 0 0       0 $self->_dbh->commit() unless $self->_dbh->{AutoCommit};
119              
120             } elsif ($driver eq 'sqlite') {
121              
122             # All stable versions of DBD::SQLite use an SQLite version that support upserts
123 0         0 my $sth = $self->_dbh->prepare(qq{
124             INSERT OR REPLACE INTO $quoted_table (id, session_data)
125             VALUES (?, ?)
126             });
127              
128 0         0 $sth->execute($self->id, $self->_serialize);
129 0 0       0 $self->_dbh->commit() unless $self->_dbh->{AutoCommit};
130              
131             } elsif ($driver eq 'pg') {
132              
133             # Upserts need writable CTE's, which only appeared in Postgres 9.1
134 0 0       0 if ($self->_dbh->{pg_server_version} < 90100) {
135 0         0 die "A minimum of PostgreSQL 9.1 is required";
136             }
137              
138 0         0 my $sth = $self->_dbh->prepare(qq{
139             WITH upsert AS (
140             UPDATE $quoted_table
141             SET session_data = ?
142             WHERE id = ?
143             RETURNING id
144             )
145              
146             INSERT INTO $quoted_table (id, session_data)
147             SELECT ?, ?
148             WHERE NOT EXISTS (SELECT 1 FROM upsert);
149             });
150              
151 0         0 my $session_data = $self->_serialize;
152 0         0 $sth->execute($session_data, $self->id, $self->id, $session_data);
153 0 0       0 $self->_dbh->commit() unless $self->_dbh->{AutoCommit};
154              
155             } else {
156              
157 0         0 die "SQLite, MySQL > 4.1.1, and PostgreSQL > 9.1 are the only supported databases";
158              
159             }
160              
161 0         0 return $self;
162             }
163              
164              
165             =head2 retrieve($id)
166              
167             Look for a session with the given id.
168              
169             Returns the session object if found, C if not. Logs a debug-level warning
170             if the session was found, but could not be deserialized.
171              
172             =cut
173              
174             sub retrieve {
175 0     0 1 0 my ($self, $session_id) = @_;
176              
177 0         0 my $quoted_table = $self->_quote_table;
178              
179 0         0 my $sth = $self->_dbh->prepare(qq{
180             SELECT session_data
181             FROM $quoted_table
182             WHERE id = ?
183             });
184              
185 0         0 $sth->execute($session_id);
186 0         0 my ($session_data) = $sth->fetchrow_array();
187              
188             # Bail early if we know we have no session data at all
189 0 0       0 if (!defined $session_data) {
190 0         0 debug "Could not retrieve session ID: $session_id";
191 0         0 return;
192             }
193              
194             # No way to check that it's valid JSON other than trying to deserialize it
195             my $session = try {
196 0     0   0 $self->_deserialize($session_data);
197             } catch {
198 0     0   0 debug "Could not deserialize session ID: $session_id - $_";
199 0         0 return;
200 0         0 };
201              
202 0 0       0 bless $session, __PACKAGE__ if $session;
203             }
204              
205              
206             =head2 destroy()
207              
208             Remove the current session object from the database.
209              
210             =cut
211              
212             sub destroy {
213 0     0 1 0 my $self = shift;
214              
215 0 0       0 if (!defined $self->id) {
216 0         0 debug "No session ID passed to destroy method";
217 0         0 return;
218             }
219              
220 0         0 my $quoted_table = $self->_quote_table;
221              
222 0         0 my $sth = $self->_dbh->prepare(qq{
223             DELETE FROM $quoted_table
224             WHERE id = ?
225             });
226              
227 0         0 $sth->execute($self->id);
228             }
229              
230              
231              
232             # Returns a dbh handle, either created from the DSN
233             # or using the one passed as a DBH argument.
234             sub _dbh {
235 2     2   3 my $self = shift;
236 2         6 my $settings = setting('session_options');
237              
238             # Prefer an active DBH over a DSN.
239 2 50       29 return $settings->{dbh}->() if defined $settings->{dbh};
240              
241             # Check the validity of the DSN if we don't have a handle
242 2   50     22 my $valid_dsn = DBI->parse_dsn($settings->{dsn} || '');
243              
244 2 100       57 die "No valid DSN specified" if !$valid_dsn;
245              
246 1 50 33     8 if (!defined $settings->{user} || !defined $settings->{password}) {
247 1         12 die "No user or password specified";
248             }
249              
250             # If all the details check out, return a fresh connection
251 0         0 return DBI->connect($settings->{dsn}, $settings->{user}, $settings->{password});
252             }
253              
254              
255             # Quotes table names to prevent SQLi,
256             # and check that we have a table name specified
257             sub _quote_table {
258 4     4   6 my $self = shift;
259 4         14 my $settings = setting('session_options');
260              
261 4 100       92 die "No table selected for session storage" if !$settings->{table};
262              
263 2         10 return $self->_dbh->quote_identifier( $settings->{table} );
264             }
265              
266              
267             # Default Serialize method
268             sub _serialize {
269 0     0     my $self = shift;
270 0           my $settings = setting('session_options');
271              
272 0 0         if (defined $settings->{serializer}) {
273 0           return $settings->{serializer}->({%$self});
274             }
275              
276             # A session is by definition ephemeral - Store it compactly
277             # This is the Dancer function, not from JSON.pm
278 0           return to_json({%$self}, { pretty => 0 });
279             }
280              
281              
282             # Default Deserialize method
283             sub _deserialize {
284 0     0     my ($self, $json) = @_;
285 0           my $settings = setting('session_options');
286              
287 0 0         if (defined $settings->{deserializer}) {
288 0           return $settings->{deserializer}->($json);
289             }
290              
291             # This is the Dancer function, not from JSON.pm
292 0           return from_json($json);
293             }
294              
295              
296              
297             =head1 SEE ALSO
298              
299             L, L, L
300              
301              
302             =head1 AUTHOR
303              
304             James Aitken
305              
306              
307             =head1 COPYRIGHT AND LICENSE
308              
309             This software is copyright (c) James Aitken.
310              
311             This is free software; you can redistribute it and/or modify it under
312             the same terms as the Perl 5 programming language system itself.
313              
314             =cut
315              
316              
317             1;