File Coverage

blib/lib/SQL/Abstract/Prefetch.pm
Criterion Covered Total %
statement 162 162 100.0
branch 34 46 73.9
condition 30 41 73.1
subroutine 13 13 100.0
pod 3 3 100.0
total 242 265 91.3


line stmt bran cond sub pod time code
1             package SQL::Abstract::Prefetch;
2              
3             our $VERSION = '0.001';
4              
5             =head1 NAME
6              
7             SQL::Abstract::Prefetch - implement "prefetch" for DBI RDBMS
8              
9             =head1 SYNOPSIS
10              
11             my $queryspec = {
12             table => 'blog',
13             fields => [
14             'html',
15             'id',
16             'is_published',
17             'markdown',
18             'slug',
19             'title',
20             'user_id',
21             ],
22             keys => [ 'id' ],
23             multi => {
24             comments => {
25             table => 'comment',
26             fields => [ 'blog_id', 'html', 'id', 'markdown', 'user_id' ],
27             keys => [ 'id' ],
28             },
29             },
30             single => {
31             user => {
32             table => 'user',
33             fields => [ 'access', 'age', 'email', 'id', 'password', 'username' ],
34             keys => [ 'id' ],
35             },
36             },
37             };
38             my $abstract = SQL::Abstract::Pg->new( name_sep => '.', quote_char => '"' );
39             my $dbh = DBI->connect( "dbi:SQLite:dbname=filename.db", '', '' );
40             my $prefetch = SQL::Abstract::Prefetch->new(
41             abstract => $abstract,
42             dbhgetter => sub { $dbh },
43             dbcatalog => undef, # for SQLite
44             dbschema => undef,
45             filter_table => sub { $_[0] !~ /^sqlite_/ },
46             );
47             my ( $sql, @bind ) = $prefetch->select_from_queryspec(
48             $queryspec,
49             { id => $items{blog}[0]{id} },
50             );
51             my ( $extractspec ) = $prefetch->extractspec_from_queryspec( $queryspec );
52             my $sth = $dbh->prepare( $sql );
53             $sth->execute( @bind );
54             my ( $got ) = $prefetch->extract_from_query( $extractspec, $sth );
55              
56             =head1 DESCRIPTION
57              
58             This class implements "prefetch" in the style of L. Stages
59             of operation:
60              
61             =over
62              
63             =item *
64              
65             Generate a "query spec" that describes what you want back from the
66             database - which fields from which tables, and what relations to join.
67              
68             =item *
69              
70             Generate SQL (and bind parameters) from that "query spec".
71              
72             =item *
73              
74             Pass the SQL and parameters to a L C<$dbh> to prepare and execute.
75              
76             =item *
77              
78             Pass the C<$sth> when ready (this allows for asynchronous operation)
79             to the extractor method to turn the returned rows into the hash-refs
80             represented, including array-ref values for any "has many" relationships.
81              
82             =back
83              
84             =head1 ATTRIBUTES
85              
86             =head2 abstract
87              
88             Currently, must be a L object.
89              
90             =head2 dbhgetter
91              
92             A code-ref that returns a L C<$dbh>.
93              
94             =head2 dbcatalog
95              
96             The L "catalog" argument for e.g. L.
97              
98             =head2 dbschema
99              
100             The L "schema" argument for e.g. L.
101              
102             =head2 filter_table
103              
104             Coderef called with a table name, returns a boolean of true to keep, false
105             to discard - typically for a system table.
106              
107             =head2 multi_namer
108              
109             Coderef called with a table name, returns a suitable name for the relation
110             to that table. Defaults to L.
111              
112             =head2 dbspec
113              
114             By default, will be calculated from the supplied C<$dbh>, using the
115             supplied C, C, C, C,
116             and C. May however be supplied, in which case those other
117             attributes are not needed.
118              
119             A "database spec"; a hash-ref mapping tables to maps of the
120             relation-name (a string) to a further hash-ref with keys:
121              
122             =over
123              
124             =item type
125              
126             either C or C
127              
128             =item fromkey
129              
130             the column name in the "from" table
131              
132             =item fromtable
133              
134             the name of the "from" table
135              
136             =item tokey
137              
138             the column name in the "to" table
139              
140             =item totable
141              
142             the name of the "to" table
143              
144             =back
145              
146             The relation-name for "multi" will be calculated using
147             the C on the remote table name.
148              
149             =head1 METHODS
150              
151             =head2 select_from_queryspec
152              
153             Parameters:
154              
155             =over
156              
157             =item *
158              
159             a "query spec"; a hash-ref with these keys:
160              
161             =over
162              
163             =item table
164              
165             =item keys
166              
167             array-ref of fields that are primary keys on this table
168              
169             =item fields
170              
171             array-ref of fields that are primitive types to show in result,
172             including PKs if wanted. If not wanted, the joins still function.
173              
174             =item single
175              
176             hash-ref mapping relation-names to "query specs" - a recursive data
177             structure; the relation is "has one"
178              
179             =item multi
180              
181             hash-ref mapping relation-names to "relate specs" as above; the relation is
182             "has many"
183              
184             =back
185              
186             =item *
187              
188             an L "where" specification
189              
190             =item *
191              
192             an L "options" specification, including C,
193             C, and C
194              
195             =back
196              
197             Returns the generated SQL, then a list of parameters to bind.
198              
199             =head2 extractspec_from_queryspec
200              
201             Parameters: a "query spec" as above.
202              
203             Returns an opaque "extract spec": data to be used by
204             L to interpret results generated from the
205             L query.
206              
207             =head2 extract_from_query
208              
209             Parameters: an opaque "extract spec" created by
210             L, and a L C<$sth>.
211              
212             Returns a list of hash-refs of items as reconstructed according to the spec.
213              
214             =head1 SEE ALSO
215              
216             L, L, L
217              
218             =cut
219              
220 1     1   258618 use Mojo::Base '-base';
  1         12  
  1         9  
221 1     1   717 use Lingua::EN::Inflect::Number ();
  1         30268  
  1         33  
222 1     1   8 use Scalar::Util qw( looks_like_number );
  1         3  
  1         2531  
223              
224             has 'abstract';
225             has 'dbhgetter';
226             has 'dbcatalog';
227             has 'dbschema';
228             has 'filter_table';
229             has multi_namer => sub { \&Lingua::EN::Inflect::Number::to_PL };
230             has dbspec => \&_build_dbspec;
231              
232             sub select_from_queryspec {
233 5     5 1 28 my ( $self, $queryspec, $where, $origopt ) = @_;
234 5 100       22 my %opt = %{ $origopt || {} };
  5         23  
235 5         19 my ( $talias, $sources, $columns ) = $self->_sc_from_queryspec(
236             $queryspec,
237             );
238 5   66     28 $opt{order_by} = _order_by( $opt{order_by} || $queryspec->{keys}, $talias );
239 5         11 my $limit = delete $opt{limit};
240 5         12 my $offset = delete $opt{offset};
241 5         13 my $abstract = $self->abstract;
242 5         33 my %inner = %$queryspec;
243 5         15 delete @inner{qw(single multi)};
244 5         14 my ( undef, $inner_s, $inner_c ) = $self->_sc_from_queryspec( \%inner );
245 5         11 my @inner_c2 = (@{ $queryspec->{keys} }, @{ $queryspec->{fields} });
  5         12  
  5         12  
246             # this is to dedup colnames as MySQL blows up if select same column > 1 time
247             # - at least in inner select - so use aliased ones for keys = already got
248 5         10 my %keysmap = map {$_=>1} @{ $queryspec->{keys} };
  5         15  
  5         10  
249 5         11 my $keyscount = @{ $queryspec->{keys} };
  5         10  
250             $inner_c2[$_] = $inner_c->[$_]
251 5         10 for grep $keysmap{$inner_c2[$_]}, $keyscount..$keyscount + $#{$queryspec->{fields}};
  5         28  
252 5 50       35 my ( $inner_sql, @bind ) = $abstract->select(
253             $inner_s,
254             \@inner_c2,
255             $where,
256             (keys %opt ? \%opt : undef),
257             );
258 5         4311 $inner_sql .= _limit_offset( $limit, $offset );
259             return ( $inner_sql, @bind )
260 5 100 66     8 if !%{ $queryspec->{single} || {} } and !%{ $queryspec->{multi} || {} };
  5 50       29  
  3 50       46  
261 2         6 $inner_sql = "( $inner_sql ) as $talias";
262 2         6 $sources->[0] = \$inner_sql;
263 2 50       9 my ( $sql ) = $abstract->select(
264             $sources,
265             $columns,
266             undef,
267             (keys %opt ? \%opt : undef),
268             );
269 2         4241 ( $sql, @bind );
270             }
271              
272             sub _limit_offset {
273 5     5   11 my ( $limit, $offset ) = @_;
274 5         9 my $extra = '';
275 5 100       13 if ( $limit ) {
276 1 50       6 die "Limit must be number" if !looks_like_number $limit;
277 1         4 $extra .= ' LIMIT ' . $limit;
278             }
279 5 100       13 if ( $offset ) {
280 1 50       28 die "Offset must be number" if !looks_like_number $offset;
281 1 50       4 $extra .= ' LIMIT ' . 2**32 if !$limit;
282 1         4 $extra .= ' OFFSET ' . $offset;
283             }
284 5         15 $extra;
285             }
286              
287             sub _order_by {
288 8     8   16 my ( $order, $talias ) = @_;
289 8 50       17 return undef if !$order;
290 8 100       27 if ( ref $order eq 'ARRAY' ) {
    100          
291 3         11 return [ map _order_by( $_, $talias ), @$order ];
292             } elsif ( ref $order eq 'HASH' ) {
293 1         5 my @o_b = %$order;
294 1         5 return { $o_b[0] => "$talias.$o_b[1]" };
295             } else {
296 4         19 return "$talias.$order";
297             }
298             }
299              
300             # SQLA sources, columns
301             sub _sc_from_queryspec {
302 14     14   31 my ( $self, $queryspec, $calias, $talias ) = @_;
303 14   100     51 $calias //= 'c000';
304 14   100     47 $talias //= 't000';
305 14         26 my $my_talias = ++$talias;
306 14         25 my $coll = $queryspec->{table};
307 14         38 my $dbspec = $self->dbspec;
308 14         75 my $abstract = $self->abstract;
309 14         60 my $sep = $abstract->{name_sep};
310 14         43 my @sources = ( \( $abstract->_quote( $coll ) . ' as ' . $my_talias ) );
311             my @columns = map [ qq{$my_talias.$_}, ++$calias ],
312 14 50       47 @{ $queryspec->{keys} || [] },
313 14 50       339 @{ $queryspec->{fields} || [] };
  14         144  
314 14   100     58 my $single = $queryspec->{single} || {};
315 14   100     39 my $multi = $queryspec->{multi} || {};
316 14         42 my %allrelations = ( %$single, %$multi );
317 14         36 for my $relname ( sort( keys %$single ), sort( keys %$multi ) ) {
318 4         9 my $relation = $allrelations{ $relname };
319 4         6 my $other_coll = $relation->{table};
320             #use Test::More; diag "fkinfo all($coll=$my_talias) ", explain $dbspec->{ $coll };
321 4         9 my $fkinfo = $dbspec->{ $coll }{ $relname };
322             #use Test::More; diag 'fkinfo ', explain $fkinfo;
323 4         15 ( my $to_talias, my $other_s, my $other_c, $calias, $talias ) =
324             $self->_sc_from_queryspec( $relation, $calias, $talias );
325             my $totable =
326 4         15 \( $abstract->_quote( $fkinfo->{totable} ) . ' as ' . $to_talias );
327 4         81 my $tokey = $to_talias . $sep . $fkinfo->{tokey};
328 4         9 my $fromkey = $my_talias . $sep . $fkinfo->{fromkey};
329 4         10 $other_s->[0] = [ -left => $totable, $tokey, $fromkey ];
330 4         9 push @sources, @$other_s;
331 4         15 push @columns, @$other_c;
332             }
333             #use Test::More; diag 'sfr so far ', explain [ \@sources, \@columns ];
334 14         71 ( $my_talias, \@sources, \@columns, $calias, $talias );
335             }
336              
337             # each "strip" = hashref:
338             # keys=start,finish
339             # fields=start,finish
340             # fieldnames
341             # offset
342             # type (single=0, multi=1)
343             # specsindex
344             # subspecs (arrayref of pairs: [key, spec])
345             sub extractspec_from_queryspec {
346 9     9 1 32593 my ( $self, $queryspec, $offset, $type, $myspecsindex ) = @_;
347 9   100     38 $myspecsindex //= 0;
348 9         16 my $specsindex = $myspecsindex;
349 9   100     25 $offset //= 0;
350 9   100     27 $type //= 1; # default = top-level, which is a special-case multi
351 9         13 my $keyscount = @{ $queryspec->{keys} };
  9         17  
352 9         14 my @fields = @{ $queryspec->{fields} };
  9         27  
353 9         18 my $highcount = $keyscount + $#fields;
354 9         45 my @specs = {
355             keys => [ 0, $keyscount - 1 ],
356             fields => [ $keyscount, $highcount ],
357             fieldnames => \@fields,
358             offset => $offset,
359             type => $type,
360             specsindex => $myspecsindex,
361             };
362 9         15 my @subspecs;
363 9         12 $offset += $highcount + 1;
364 9   100     32 my $single = $queryspec->{single} || {};
365 9   100     28 my $multi = $queryspec->{multi} || {};
366 9         39 for (
367             ( map [ 0, $_, $single->{$_} ], sort keys %$single ),
368             ( map [ 1, $_, $multi->{$_} ], sort keys %$multi ),
369             ) {
370 4         16 ( my $otherspecs, $offset, $specsindex ) = $self->extractspec_from_queryspec(
371             $_->[2],
372             $offset,
373             $_->[0], # single
374             ++$specsindex,
375             );
376 4         9 push @specs, @$otherspecs;
377 4         11 push @subspecs, [ $_->[1], $otherspecs->[0] ];
378             }
379 9         21 $specs[0]->{subspecs} = \@subspecs;
380             #use Test::More; diag "esfr ", explain [ $queryspec, \@specs ];
381 9         32 ( \@specs, $offset, $specsindex );
382             }
383              
384             sub extract_from_query {
385 5     5 1 781 my ( $self, $extractspec, $sth ) = @_;
386 5         11 my @ret;
387             my @index2ids; # ids = array-ref of the PKs for this spec we are "on"
388             # entrypoint = ref to update if new; scalar for single, array for multi
389 5         12 my @index2entrypoint = ( \@ret ); # zero-th is the overall return
390 5         58 while ( my $array = $sth->fetchrow_arrayref ) {
391             #use Test::More; diag 'after select, not undef ', explain $array;
392 8         26 SPEC: for ( my $index = 0; $index < @$extractspec; $index++ ) {
393 14         25 my $spec = $extractspec->[ $index ];
394 14         21 my ( $kstart, $kend ) = map $spec->{offset} + $_, @{ $spec->{keys} };
  14         49  
395 14         39 my $this_ids = [ @$array[ $kstart..$kend ] ];
396 14 50       37 next SPEC if !grep defined, @$this_ids; # null PK = no result
397 14 100       37 next SPEC if !_new_obj( $index2ids[ $index ], $this_ids );
398 12         34 _invalidate_ids( \@index2ids, $spec );
399 12         22 $index2ids[ $index ] = $this_ids;
400 12         18 my ( $fstart, $fend ) = map $spec->{offset} + $_, @{ $spec->{fields} };
  12         36  
401 12         20 my %hash;
402 12         23 @hash{ @{ $spec->{fieldnames} } } = @$array[ $fstart..$fend ];
  12         60  
403 12         32 _init_obj( \%hash, \@index2entrypoint, $spec );
404             #use Test::More; diag "efr ", explain [ $array, $fstart, $fend, \%hash ];
405             }
406             }
407 5         117 @ret;
408             }
409              
410             sub _invalidate_ids {
411 16     16   28 my ( $index2ids, $spec ) = @_;
412 16         30 $index2ids->[ $spec->{specsindex} ] = undef;
413 16         24 _invalidate_ids( $index2ids, $_->[1] ) for @{ $spec->{subspecs} };
  16         43  
414             }
415              
416             sub _init_obj {
417 12     12   22 my ( $obj, $index2entrypoint, $spec ) = @_;
418 12         22 my $entrypoint = $index2entrypoint->[ $spec->{specsindex} ];
419 12 100       27 if ( $spec->{type} == 0 ) {
420             # single, scalar-ref
421 2         3 $$entrypoint = $obj;
422             } else {
423             # multi, array-ref
424 10         18 push @$entrypoint, $obj;
425             }
426 12         18 for ( @{ $spec->{subspecs} } ) {
  12         82  
427 4         9 my ( $key, $subspec ) = @$_;
428 4 100       14 $obj->{ $key } = ( $subspec->{type} == 0 ) ? undef : [];
429             $index2entrypoint->[ $subspec->{specsindex} ] = ( $subspec->{type} == 0 )
430 4 100       16 ? \$obj->{ $key } : $obj->{ $key };
431             }
432             }
433              
434             # have we found a new object?
435             # true if either array-ref false
436             # false if all list members == each other
437             sub _new_obj {
438 14     14   33 my ( $a1, $a2 ) = @_;
439 14 100 66     51 return 1 if !$a1 or !$a2;
440             # this might be quicker if could rely on numerical, therefore !=
441 5   100     28 $a1->[ $_ ] ne $a2->[ $_ ] and return 1 for 0..$#$a1;
442 2         10 0;
443             }
444              
445             sub _build_dbspec {
446 1     1   13 my ( $self ) = @_;
447 1         5 my ( $dbcatalog, $dbschema ) = ( $self->dbcatalog, $self->dbschema );
448 1         12 my $dbhgetter = $self->dbhgetter;
449 1         5 my @table_names = @{ $dbhgetter->()->table_info(
  1         3  
450             $dbcatalog, $dbschema, undef, 'TABLE'
451             )->fetchall_arrayref( { TABLE_NAME => 1 } ) };
452             @table_names = grep $self->filter_table->($_), map $_->{TABLE_NAME},
453 1         548 @table_names;
454 1         38 s/\W//g for @table_names; # PostgreSQL quotes "user"
455 1         3 my %dbspec;
456 1         2 for my $table ( @table_names ) {
457             # Pg returns undef if no FKs
458 5 50       6882 next unless my $fk_sth = $dbhgetter->()->foreign_key_info(
459             undef, undef, undef, # PKT
460             $dbcatalog, $dbschema, $table, undef
461             );
462 5   33     11724 for (
463             grep $_->{PKTABLE_NAME} || $_->{UK_TABLE_NAME}, # mysql
464 5         25 @{ $fk_sth->fetchall_arrayref( {} ) }
465             ) {
466 3   33     1229 my $totable = $_->{PKTABLE_NAME} || $_->{UK_TABLE_NAME};
467 3         9 $totable =~ s/\W//g; # Pg again
468 3   33     11 my $fromkey = $_->{FKCOLUMN_NAME} || $_->{FK_COLUMN_NAME};
469 3         17 (my $fromlabel = $fromkey) =~ s#_?id$##; # simple heuristic
470 3   33     10 my $tokey = $_->{PKCOLUMN_NAME} || $_->{UK_COLUMN_NAME};
471 3         18 $dbspec{ $table }{ $fromlabel } = {
472             type => 'single',
473             fromkey => $fromkey, fromtable => $table,
474             totable => $totable, tokey => $tokey,
475             };
476 3         20 $dbspec{ $totable }{ $self->multi_namer->( $table ) } = {
477             type => 'multi',
478             fromkey => $tokey, fromtable => $totable,
479             totable => $table, tokey => $fromkey,
480             };
481             }
482             }
483 1         110 \%dbspec;
484             }
485              
486             1;