File Coverage

blib/lib/Pinwheel/Model.pm
Criterion Covered Total %
statement 22 24 91.6
branch n/a
condition n/a
subroutine 8 8 100.0
pod n/a
total 30 32 93.7


line stmt bran cond sub pod time code
1             package Pinwheel::Model;
2              
3 2     2   66429 use strict;
  2         5  
  2         84  
4 2     2   20 use warnings;
  2         5  
  2         62  
5              
6 2     2   12 use Carp;
  2         4  
  2         419  
7 2     2   9425 use DBI qw(SQL_INTEGER);
  2         73845  
  2         390  
8 2     2   3222 use Time::Local qw(timegm_nocheck);
  2         5211  
  2         156  
9              
10 2     2   810 use Pinwheel::Context;
  2         5  
  2         43  
11 2     2   561 use Pinwheel::Database qw(prepare describe fetchone_tables fetchall_tables);
  2         5  
  2         180  
12 2     2   1678 use Pinwheel::Model::Base;
  0            
  0            
13             use Pinwheel::Model::Date;
14             use Pinwheel::Model::Time;
15              
16             # Terminology in the code below:
17             # "class" - a class (package) name
18             # "stash" - the hash ref representing a class (see 'perlguts')
19             # $base_class / $base_stash - usually the "Model" class, i.e. us
20             # $model_class / $model_stash - the relevant Models::Foo class
21              
22             our $AUTOLOAD;
23              
24             my (%inheritance_keys, %inheritance);
25              
26              
27             sub import
28             {
29             my ($base_class, $table, @args) = @_;
30             return unless defined($table);
31             my $base_stash = _get_stash($base_class);
32             my $model_class = caller();
33             _export_functions($base_stash, $table, $model_class, @args);
34             }
35              
36             sub _export_functions
37             {
38             my ($base_stash, $table, $model_class, $ikey, $ivalue) = @_;
39             my ($model_stash, $fields, $column, $model, $getter);
40              
41             $model_stash = _get_stash($model_class);
42             $fields = describe($table);
43             $model = {
44             table => $table,
45             fields => $fields,
46             model_class => $model_class,
47             model_stash => $model_stash,
48             getters => {},
49             inheritance_key => $ikey,
50             inheritance_value => $ivalue,
51             associations => {},
52             };
53              
54             if ($ikey) {
55             $inheritance_keys{$table} = $ikey;
56             $inheritance{$table}{$ivalue} = $model;
57             }
58              
59             # Export these to the models class
60             $model_stash->{$_} = $base_stash->{$_}
61             for qw( query belongs_to has_one has_many );
62              
63             # Column accessors
64             _export_accessors($model) unless $ikey;
65              
66             $getter = sub {
67             my ($fn);
68             $fn = _make_finder($model, $1, $2)
69             if $AUTOLOAD =~ /::find(_all)?(?:_by_(\w+))?$/;
70             $fn = _make_finder($model, 'prefetch', 'id')
71             if $AUTOLOAD =~ /::prefetch$/;
72             croak "Can't locate $AUTOLOAD" unless $fn;
73             no strict 'refs';
74             *$AUTOLOAD = $fn;
75             goto &$fn;
76             };
77              
78             no strict 'refs';
79             # find, find_by_, find_all_by_, and prefetch
80             *{"$model_class\::AUTOLOAD"} = $getter;
81             # Private model information
82             *{"$model_class\::model"} = $model;
83             }
84              
85             sub _export_accessors
86             {
87             my ($model) = @_;
88             my ($fields, $model_class, $column, $getter);
89              
90             $fields = $model->{fields};
91             $model_class = $model->{model_class};
92              
93             foreach $column (keys %$fields) {
94             if ($fields->{$column}{type} =~ /^date(time)?/) {
95             my $class = $1 ? 'Pinwheel::Model::Time' : 'Pinwheel::Model::Date';
96             # Time/date column: wrap value in a Pinwheel::Model::Time/Date class
97             $getter = sub {
98             my $data = $_[0]->{data};
99             $_[0]->_fill_out() if (!exists($data->{$column}));
100             my $t = $data->{$column};
101             return $t if (!defined($t) || ref($t));
102             return $data->{$column} = undef if ($t =~ /^0000-00-00/);
103             # Convert timestamp/date to seconds-since-epoch (assumption:
104             # database handle timezone is GMT) and construct the wrapper
105             $t =~ /^(....)-(..)-(..)(?: (..):(..):(..))?/;
106             $t = timegm_nocheck($6 || 0, $5 || 0, $4 || 0, $3, $2 - 1, $1);
107             return $data->{$column} = $class->new($t);
108             };
109             } else {
110             # Not a time/date column: just return value untouched
111             $getter = sub {
112             my $data = $_[0]->{data};
113             $_[0]->_fill_out() if (!exists($data->{$column}));
114             return $data->{$column};
115             };
116             }
117             $model->{getters}{$column} = $getter;
118             no strict 'refs';
119             *{"$model_class\::$column"} = $getter;
120             }
121             }
122              
123              
124             sub belongs_to { _add_association(_get_stash(caller), 'belongs_to', @_) }
125             sub has_one { _add_association(_get_stash(caller), 'has_one', @_) }
126             sub has_many { _add_association(_get_stash(caller), 'has_many', @_) }
127             sub query { _add_query(scalar(caller), @_) }
128              
129             sub _add_query
130             {
131             my ($model_class, $name, %opts) = @_;
132             my ($model, $wrapfn, $queryfn, $sqldata);
133              
134             $model = _get_stash($model_class)->{model};
135             $wrapfn = _get_type_wrapper($opts{type}, $name);
136             croak 'Unknown query result type' unless $wrapfn;
137              
138             $queryfn = sub {
139             $sqldata = _parse_sql($model->{model_stash}{sql}{$name}) unless $sqldata;
140             return _do_sql($sqldata, $model, $wrapfn, \%opts, @_);
141             };
142              
143             no strict 'refs';
144             *{"$model_class\::$name"} = $queryfn;
145             }
146              
147             sub _get_type_wrapper
148             {
149             my ($type, $name) = @_;
150             my $fn;
151              
152             return if (!$type && !$name);
153              
154             if (!$type) {
155             $type = '[-]';
156             $type = '-' if $name =~ /^find(?!_all)/;
157             $type = '1' if $name =~ /^count/;
158             $type = 'x' if $name =~ /^
159             (?:set|add|remove|create|replace|update|delete)
160             (?:$|_)
161             /x;
162             }
163              
164             # [-] = List of rows (wrapped as a list of model objects)
165             # - = One row (wrapped as a model object)
166             # [1] = List of single values
167             # 1 = Single value
168             # x = No result
169             if ($type eq '[-]') {
170             $fn = \&_wrap_all_rows;
171             } elsif ($type eq '-') {
172             $fn = \&_wrap_one_row;
173             } elsif ($type eq '[1]') {
174             $fn = \&_wrap_all_column;
175             } elsif ($type eq '1') {
176             $fn = \&_wrap_one_value;
177             } elsif ($type eq 'x') {
178             $fn = \&_wrap_nothing;
179             }
180              
181             return $fn;
182             }
183              
184             sub _add_association
185             {
186             my ($model_stash, $type, $name, %opts) = @_;
187             my ($associated_class, $finder, $key) = @opts{qw(package finder key)};
188             my $fn;
189              
190             if (!$associated_class) {
191             $associated_class = make_package_name($name);
192             }
193             if (!$finder) {
194             $finder = 'find';
195             if ($type eq 'has_many') {
196             $finder .= '_all';
197             }
198             if ($type ne 'belongs_to') {
199             $finder .= '_by_' . _make_singular($model_stash->{model}{table});
200             }
201             }
202             $key = (($type eq 'belongs_to') ? $name . '_id' : 'id') unless $key;
203              
204             $fn = sub {
205             my $data = $_[0]->{data};
206             return $data->{$name} if exists($data->{$name});
207             $_[0]->_fill_out() if (!exists($data->{$key}));
208             return $data->{$name} = $associated_class->$finder($data->{$key});
209             };
210              
211             no strict 'refs';
212             $model_stash->{model}{associations}{$name} = *{"$associated_class\::"};
213             *{$model_stash->{model}{model_class} . "::$name"} = $fn;
214             }
215              
216              
217             sub _parse_sql
218             {
219             my ($sql) = @_;
220             my ($i, @dynamic, @static, $d);
221              
222             $sql =~ s[/\* .*? \*/][ ]gx;
223              
224             $i = 0;
225             foreach ($sql =~ /\?(?:\$(.*?)\$)?/g) {
226             if (defined($_)) {
227             push @dynamic, [$i++, qr/^$_$/];
228             } else {
229             push @static, $i++;
230             }
231             }
232              
233             $d = (scalar(@dynamic) > 0) ? \@dynamic : undef;
234             return [$sql, $i, $d, \@static];
235             }
236              
237             sub _gather_static_params
238             {
239             my ($sql, $info, $params) = @_;
240             my ($pos, $i, @static_params);
241            
242             @static_params = ();
243             $pos = -1;
244             $i = 0;
245             while (($pos = index($sql,'?',$pos+1)) > 0)
246             {
247             my $pnum = $info->[$i];
248             croak "not enough parameters given" unless (defined $pnum);
249             my $value = $params->[$pnum];
250             if (!defined $value) {
251             if (substr($sql,$pos-4,5) =~ /(\s*!=\s*\?)$/) {
252             substr($sql,$pos-(length($1)-1),length($1)) = ' IS NOT NULL';
253             } elsif (substr($sql,$pos-4,5) =~ /(\s*=\s*\?)$/) {
254             substr($sql,$pos-(length($1)-1),length($1)) = ' IS NULL';
255             } else {
256             push(@static_params, [undef]);
257             }
258             } elsif (ref($value) eq 'HASH') {
259             push(@static_params, [each(%$value)]);
260             } elsif (ref($value)) {
261             push(@static_params, [$value->sql_param]);
262             } else {
263             push(@static_params, [$value]);
264             }
265             $i++;
266             }
267            
268             return ($sql, @static_params);
269             }
270              
271              
272             sub _insert_dynamic_params
273             {
274             my ($sql, $info, $params) = @_;
275             my (@inserts, $i, $regex, $value);
276              
277             foreach (@$info) {
278             ($i, $regex) = @$_;
279             $value = $params->[$i++] || '';
280             push @inserts, $value;
281             croak "Parameter $i does not match requirement: $value"
282             unless ($value =~ /$regex/);
283             }
284             $i = 0;
285             $sql =~ s/\?\$.*?\$/$inserts[$i++]/ge;
286              
287             return $sql;
288             }
289              
290             sub _do_sql
291             {
292             my ($sqldata, $model, $wrapfn, $opts, @params) = @_;
293             my ($tables, $sql, @static_params, %args, $order, $sth, $i, $result);
294            
295             $tables = $opts->{include};
296             if ($opts->{fn}) {
297             # Function was provided to munge the input parameters before running
298             # the SQL; can also declare the list of relations being fetched
299             # alongside the primary table.
300             @params = $opts->{fn}(@params);
301             if (@params && ref($params[0]) eq 'ARRAY') {
302             $tables = shift(@params);
303             }
304             } else {
305             # If no fn is specified, throw away the first param
306             # if it's the class name.
307             shift @params if !ref($params[0]);
308             }
309              
310             $sql = $sqldata->[0];
311              
312             # Fill in dynamic parameters
313             if ($sqldata->[2]) {
314             $sql = _insert_dynamic_params($sql, $sqldata->[2], \@params);
315             }
316              
317             # Gather bind parameters and rewrite "= ?" to "IS NULL" if the value is undef
318             ($sql, @static_params) = _gather_static_params($sql, $sqldata->[3], \@params);
319              
320             %args = @params[$sqldata->[1] ... $#params];
321             $order = $args{'order'};
322             croak 'Invalid sort order'
323             if ($order && $order !~ /^ *\w+(?:\.\w+)?(?: +(?:asc|desc))? *$/i);
324             $sql .= " ORDER BY $order" if $order;
325             $sql .= ' LIMIT ?' if $args{'limit'};
326             $sql .= ' OFFSET ?' if $args{'offset'};
327              
328             # Fill in static parameters
329             $i = 1;
330             $sth = prepare($sql, defined($sqldata->[2]));
331             foreach (@static_params) {
332             $sth->bind_param($i++, @$_);
333             }
334             $sth->bind_param($i++, $args{'limit'}, SQL_INTEGER) if $args{'limit'};
335             $sth->bind_param($i++, $args{'offset'}, SQL_INTEGER) if $args{'offset'};
336              
337             $sth->execute();
338             $result = &$wrapfn($model, $sth, $tables);
339             $result = $opts->{postfn}(\@params,$result) if $opts->{postfn};
340             return $result;
341             }
342              
343              
344             sub _wrap_one_row
345             {
346             my ($model, $sth, $tables) = @_;
347             my $data = fetchone_tables($sth, $tables);
348             return _make_model_object($model, $data, $tables) if $data;
349             }
350              
351             sub _wrap_all_rows
352             {
353             my ($model, $sth, $tables) = @_;
354             my (@objects, $data);
355             foreach $data (@{fetchall_tables($sth, $tables)}) {
356             push @objects, _make_model_object($model, $data, $tables);
357             }
358             return \@objects;
359             }
360              
361             sub _wrap_all_column
362             {
363             my $sth = $_[1];
364             return [map { $_->[0] } @{$sth->fetchall_arrayref([0])}];
365             }
366              
367             sub _wrap_one_value
368             {
369             return $_[1]->fetchrow_arrayref()->[0];
370             }
371              
372             sub _wrap_nothing
373             {
374             return;
375             }
376              
377             sub _find_inherited_model
378             {
379             my ($model, $data) = @_;
380             my ($table, $key);
381              
382             $table = $model->{table};
383             $key = $inheritance_keys{$table};
384             if ($key) {
385             $key = $data->{$key};
386             croak 'Missing inheritance key' unless $key;
387             $model = $inheritance{$table}{$key};
388             croak "No model found for subclass $key" unless $model;
389             }
390             return $model;
391             }
392              
393             sub _make_model_object
394             {
395             my ($model, $data, $tables) = @_;
396             my ($root, $parent, @parts, $key);
397              
398             $model = _find_inherited_model($model, $data->{''});
399             $root = Pinwheel::Model::Base::new($model->{model_class}, $model, delete $data->{''});
400              
401             foreach $key (@$tables) {
402             @parts = split(/\./, $key);
403             $parent = $root;
404             $parent = $parent->$_ foreach (@parts[0 .. $#parts - 1]);
405             $parent->_prefetched_link($parts[-1], $data->{$key}) if ($parent);
406             }
407             return $root;
408             }
409              
410             sub _make_finder
411             {
412             my ($model, $all, $column) = @_;
413             my ($sql, $null, $ikey, $sqldata, $wrapfn, @conditions);
414              
415             $column = 'id' if (!$all && !$column);
416             $sql = "SELECT * FROM `$model->{table}`";
417              
418             if ($column) {
419             $column .= '_id' unless exists($model->{fields}{$column});
420             return unless exists($model->{fields}{$column});
421             }
422              
423             $null = ($column && $model->{fields}{$column}{null});
424             $ikey = $model->{inheritance_key};
425              
426             if ($ikey) {
427             push @conditions, "`$ikey` = '" . $model->{inheritance_value} . "'";
428             }
429              
430             if ($all && $all eq 'prefetch') {
431             push @conditions, "`$column` IN (?\$(?:[0-9]+,?)+\$)";
432             } elsif ($column) {
433             push @conditions, "`$column` = ?";
434             }
435              
436             $sql .= " WHERE " . join(" AND ", @conditions)
437             if @conditions;
438              
439             $sqldata = _parse_sql($sql);
440             $wrapfn = $all ? \&_wrap_all_rows : \&_wrap_one_row;
441             if (!$column) {
442             return sub { _do_sql($sqldata, $model, $wrapfn, {}, @_) };
443             } elsif (!$all && !$null && $column eq 'id') {
444             return sub {
445             my ($ctx, $obj);
446             my $class = shift;
447             return if !defined($_[0]);
448             $ctx = Pinwheel::Context::get('Model--' . $model->{table});
449             return $obj if $obj = $ctx->{$_[0]};
450             return _do_sql($sqldata, $model, $wrapfn, {}, $class, @_);
451             };
452             } elsif ($all && $all eq 'prefetch') {
453             return sub {
454             my $class = shift;
455             my ($ctx, %ids, @keys);
456             $ctx = Pinwheel::Context::get('Model--' . $model->{table});
457             map { $ids{$_} = 1 unless exists($ctx->{$_}) } @_;
458             @keys = keys %ids;
459             return 0 if scalar(@keys) == 0;
460             _do_sql($sqldata, $model, $wrapfn, {}, $class, join(',', @keys));
461             return scalar(@keys);
462             }
463             } elsif (!$null) {
464             return sub {
465             my $class = shift;
466             return if !defined($_[0]);
467             return _do_sql($sqldata, $model, $wrapfn, {}, $class, @_);
468             };
469             } else {
470             return sub {
471             my $class = shift;
472             return _do_sql($sqldata, $model, $wrapfn, {}, $class, @_);
473             };
474             }
475             }
476              
477              
478             sub make_package_name
479             {
480             my $name = shift;
481             $name =~ s/_+/ /g;
482             $name =~ s/\b(\w)/\U$1/g;
483             $name =~ s/ +//g;
484             return 'Models::' . _make_singular($name);
485             }
486              
487             sub _make_singular
488             {
489             my $s = shift;
490             $s =~ s/ories$/ory/;
491             $s =~ s/ities/ity/;
492             $s =~ s/(?<=[^s])s$// unless $s =~ /ies$/;
493             return $s;
494             }
495              
496             sub _get_stash
497             {
498             my $class = shift;
499             my $stash = \%::;
500             $stash = $stash->{"$_\::"} foreach split(/::/, $class);
501             return $stash;
502             }
503              
504             1;
505              
506             __DATA__