File Coverage

blib/lib/SQL/Abstract/Prefetch.pm
Criterion Covered Total %
statement 156 156 100.0
branch 32 44 72.7
condition 31 42 73.8
subroutine 11 11 100.0
pod 3 3 100.0
total 233 256 91.0


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