File Coverage

File:blib/lib/Database/Abstraction/Query.pm
Coverage:87.1%

linestmtbrancondsubtimecode
1package Database::Abstraction::Query;
2
3# Chained query builder for Database::Abstraction objects.
4# Returned by $db->query() and used as:
5#   $db->query->where(col => val)->order_by('col')->limit(10)->all()
6
7
16
16
16
1668
24
250
use strict;
8
16
16
16
29
15
430
use warnings;
9
16
16
16
32
13
71
use autodie qw(:all);
10
11
16
16
16
36072
16
513
use Carp;
12
16
16
16
48
14
24198
use Scalar::Util qw(blessed);
13
14=encoding utf8
15
16 - 24
=head1 NAME

Database::Abstraction::Query - Fluent, chainable query builder for Database::Abstraction

=head1 VERSION

Version 0.51

=cut
25
26our $VERSION = '0.52';
27
28 - 115
=head1 SYNOPSIS

    my $db = Database::Foo->new(directory => '/path/to/data');

    # --- Basic usage -----------------------------------------------

    # All rows
    my $all = $db->query->all();

    # Filter, sort, page
    my $rows = $db->query
        ->where(status => 'active')
        ->where(score  => { '>=' => 80 })
        ->order_by('score DESC')
        ->limit(10)
        ->offset(20)
        ->all();

    # Single row
    my $row = $db->query->where(name => 'Alice')->first();

    # Count
    my $n = $db->query->where(status => 'active')->count();

    # --- Specific columns ------------------------------------------

    my $names = $db->query->select('name, score')->where(status => 'active')->all();

    # --- Joins -----------------------------------------------------

    my $joined = $db->query
        ->join({ table => 'dept', on => 'e.dept_id = dept.id', type => 'LEFT' })
        ->where(dept_name => 'Engineering')
        ->all();

    # --- OR criteria -----------------------------------------------

    my $either = $db->query
        ->where(-or => [
            { status => 'active'           },
            { score  => { '>=' => 95 }     },
        ])
        ->all();

=head1 DESCRIPTION

C<Database::Abstraction::Query> is a fluent query builder returned by
C<< $db->query() >>.  You assemble a query by chaining builder methods,
then execute it with a terminal method.

=over 4

=item *

B<Builder methods> (C<select>, C<where>, C<join>, C<order_by>, C<limit>,
C<offset>, C<group_by>, C<measure>, C<having>) all return C<$self>, so
calls can be chained in any order.

=item *

B<Terminal methods> (C<all>, C<first>, C<count>) assemble the SQL, execute
it, and return the result.  Each terminal method can be called on the same
builder object independently - calling C<first()> does not modify the stored
state (it temporarily sets LIMIT 1 internally).

=item *

C<where()> calls are B<merged with AND semantics>: each call adds more
required conditions.  To express OR conditions pass C<< -or => [...] >> as a
key inside a single C<where()> call.

=item *

The C<join> parameter accepts the same spec as the C<join> parameter in
L<Database::Abstraction/QUERY CRITERIA>.

=item *

BerkeleyDB backends support C<all()>, C<first()>, and C<count()> with
C<where()> criteria and C<order_by()>/C<limit()>/C<offset()> applied in Perl.
The C<join()> builder method and the C<select()> column projection are not
supported on BerkeleyDB and will raise an error.

=back

=head1 METHODS

=cut
116
117 - 124
=head2 new

    my $q = Database::Abstraction::Query->new(_db => $db_object);

Construct a new, empty query builder bound to C<$db_object>.  In practice
you almost always call this via C<< $db->query() >> instead.

=cut
125
126sub new
127{
128
224
1575
        my ($class, %args) = @_;
129
130
224
410
        my $db = $args{'_db'}
131                or croak('Database::Abstraction::Query: _db is required');
132
133
222
911
        croak('Database::Abstraction::Query: _db must be a Database::Abstraction object')
134                unless blessed($db) && $db->isa('Database::Abstraction');
135
136
220
1063
        return bless {
137                _db       => $db,
138                _select   => '*',
139                _where    => {},
140                _joins    => [],
141                _order_by => undef,
142                _limit    => undef,
143                _offset   => undef,
144                _group_by => [],
145                _measures => [],
146                _having   => {},
147        }, $class;
148}
149
150 - 158
=head2 select

    $q->select('name, score');
    $q->select('COUNT(*) AS n, status');

Set the column expression for the C<SELECT> clause.  Default is C<*> (all
columns).  Returns C<$self>.

=cut
159
160sub select
161{
162
5
6925
        my ($self, $cols) = @_;
163
5
13
        $self->{'_select'} = defined($cols) ? $cols : '*';
164
5
8
        return $self;
165}
166
167 - 184
=head2 where

    $q->where(status => 'active');
    $q->where(score  => { '>'  => 8     });
    $q->where(name   => { -in  => [...] });
    $q->where(-or    => [ {...}, {...}     ]);

Add one or more criteria to the query.  Multiple calls are merged with
AND semantics.  Accepts a flat list of key/value pairs or a single hashref.

Supports the full criteria syntax of L<Database::Abstraction/QUERY CRITERIA>:
plain scalars, wildcard strings, C<undef> (IS NULL), comparison operator
hashrefs (C<< { '>' => n } >>), C<-in>, C<-not_in>, C<-between>,
C<-like>, C<-not_like>, C<-or>, and C<-and>.

Returns C<$self>.

=cut
185
186sub where
187{
188
74
122
        my $self = shift;
189
74
5
147
5
        my %criteria = ref($_[0]) eq 'HASH' ? %{$_[0]} : @_;
190
74
120
        for my $k (keys %criteria) {
191
73
108
                $self->{'_where'}{$k} = $criteria{$k};
192        }
193
74
142
        return $self;
194}
195
196 - 220
=head2 join

    $q->join({ table => 'dept', on => 'e.dept_id = dept.id', type => 'LEFT' });

    # Multiple joins at once
    $q->join([
        { table => 'dept',    on => 'e.dept_id    = dept.id'    },
        { table => 'country', on => 'e.country_id = country.id' },
    ]);

Append one or more JOIN specs.  Each spec is a hashref with:

=over 4

=item * C<table> - the table to join (required)

=item * C<on> - the join condition, verbatim SQL (required)

=item * C<type> - join type: C<INNER> (default), C<LEFT>, C<RIGHT>, C<FULL>, C<CROSS>

=back

Multiple calls accumulate joins.  Returns C<$self>.

=cut
221
222sub join        ## no critic(ProhibitBuiltinHomonyms)
223{
224
17
339
        my ($self, $spec) = @_;
225
17
1
35
1
        my @specs = ref($spec) eq 'ARRAY' ? @{$spec} : ($spec);
226
17
17
19
24
        push @{$self->{'_joins'}}, @specs;
227
17
31
        return $self;
228}
229
230 - 238
=head2 order_by

    $q->order_by('name DESC');
    $q->order_by('score DESC, name ASC');

Set the C<ORDER BY> expression.  Replaces any previously set ordering.
Returns C<$self>.

=cut
239
240sub order_by
241{
242
34
44
        my ($self, $col) = @_;
243        # Guard the three primary SQL injection vectors before the value is
244        # interpolated verbatim into ORDER BY $col (see _build_sql).  The check
245        # is deliberately permissive so it does not break legitimate multi-column
246        # expressions ("score DESC, name ASC") or SQL functions ("ABS(score) DESC").
247
34
49
        if(defined $col) {
248                # Block the three primary SQL injection vectors that can appear inside
249                # an ORDER BY expression.  Use m{} to avoid escaping '/' and '\*'.
250
33
99
                croak("order_by: unsafe ORDER BY expression '$col'")
251                        if $col =~ m{; | -- | /\*}x;
252        }
253
31
36
        $self->{'_order_by'} = $col;
254
31
90
        return $self;
255}
256
257 - 263
=head2 limit

    $q->limit(20);

Set the maximum number of rows to return.  Returns C<$self>.

=cut
264
265sub limit
266{
267
32
35
        my ($self, $n) = @_;
268        # Validate at the setter: LIMIT must be a non-negative integer.
269        # Direct interpolation into SQL (line: LIMIT $n) makes any non-integer a
270        # potential injection vector (e.g. "10; DROP TABLE users").
271
32
151
        croak("limit: value must be a non-negative integer")
272                if !defined($n) || $n !~ /\A\d+\z/;
273
28
40
        $self->{'_limit'} = int($n);
274
28
42
        return $self;
275}
276
277 - 283
=head2 offset

    $q->offset(40);

Skip the first N rows (for pagination with L</limit>).  Returns C<$self>.

=cut
284
285sub offset
286{
287
16
19
        my ($self, $n) = @_;
288        # Same guard as limit(): OFFSET is interpolated directly into SQL.
289
16
89
        croak("offset: value must be a non-negative integer")
290                if !defined($n) || $n !~ /\A\d+\z/;
291
13
19
        $self->{'_offset'} = int($n);
292
13
16
        return $self;
293}
294
295 - 315
=head2 group_by

    $q->group_by('region', 'month');
    $q->group_by(\@cols);
    $q->group_by([]);    # clear (grand total)

Set the C<GROUP BY> columns for an aggregate query.  Replaces any previously
set list.  Has no effect unless at least one C<measure()> call follows.
Returns C<$self>.

=head3 API SPECIFICATION

=head4 Arguments

    @cols   # one or more column name strings (replaces any previous group_by)

=head4 Returns

    $self   (the same Query object, for chaining)

=cut
316
317sub group_by
318{
319
54
49
        my $self = shift;
320
54
3
143
4
        my @cols = (@_ == 1 && ref($_[0]) eq 'ARRAY') ? @{$_[0]} : @_;
321
54
67
        $self->{'_group_by'} = \@cols;
322
54
86
        return $self;
323}
324
325 - 356
=head2 measure

    $q->measure('amount', 'sum', 'revenue');
    $q->measure(undef, 'count', 'orders');       # COUNT(*)
    $q->measure({ col => 'c', fn => 'avg', as => 'mean' });

Append a measure to the aggregate query.  May be called multiple times;
measures accumulate.  Accepts either three positional arguments
(C<col>, C<fn>, C<as>) or a single hashref with those keys.

C<fn> must be one of C<sum>, C<avg>, C<min>, C<max>, C<count>,
C<count_distinct>.  C<col> is optional only for C<count> (COUNT(*)).

Returns C<$self>.

=head3 API SPECIFICATION

=head4 Arguments

    $col  # Str | undef -- column to aggregate; omit/undef for COUNT(*)
    $fn   # Str         -- one of: sum avg min max count count_distinct
    $as   # Str         -- result key alias (required; valid SQL identifier)

=head4 Returns

    $self   (the same Query object, for chaining)

Calls to C<measure()> accumulate: each call appends one measure to
the list.  To start over, create a new query builder with
C<< $db->query() >>.

=cut
357
358sub measure
359{
360
62
359
        my ($self, $col_or_spec, $fn, $as) = @_;
361
62
130
        my $spec = ref($col_or_spec) eq 'HASH'
362                ? $col_or_spec
363                : { col => $col_or_spec, fn => $fn, as => $as };
364
62
62
72
74
        push @{$self->{'_measures'}}, $spec;
365
62
122
        return $self;
366}
367
368 - 394
=head2 having

    $q->having(revenue => { '>' => 1000 });
    $q->having(\%criteria);

Add post-aggregation filter criteria.  Multiple calls are merged with AND
semantics, exactly like C<where()>.  Keys must be C<by> column names or
measure aliases (validated by C<aggregate()>).

Returns C<$self>.

=head3 API SPECIFICATION

=head4 Arguments

    %criteria   # key-value pairs where each key is a by-column name or
                # a measure alias, and each value is a scalar or an
                # operator hashref (same syntax as selectall_arrayref)

=head4 Returns

    $self   (the same Query object, for chaining)

Multiple calls to C<having()> are merged with AND semantics, exactly
like multiple calls to C<where()>.

=cut
395
396sub having
397{
398
16
26
        my $self = shift;
399
16
2
33
4
        my %criteria = ref($_[0]) eq 'HASH' ? %{$_[0]} : @_;
400
16
25
        for my $k (keys %criteria) {
401
16
25
                $self->{'_having'}{$k} = $criteria{$k};
402        }
403
16
32
        return $self;
404}
405
406# Apply Perl-side sort, offset, and limit to an arrayref of rows in place.
407# Used by the BerkeleyDB execution paths in all() and first().
408# order_by must be a single "column [ASC|DESC]" string (multi-column not supported).
409sub _apply_perl_sort_limit
410{
411
21
2517
        my ($rows, $order_by, $offset, $limit) = @_;
412
413
21
27
        if(defined $order_by) {
414                # Use \z (true end-of-string) not $ (matches before a trailing newline).
415                # (ASC|DESC) is already a non-regex check after capture; $dir is either
416                # 'ASC', 'DESC', or undef — hoist the direction decision out of the sort
417                # comparator so it is not evaluated O(N log N) times for an N-row result.
418
4
15
                my ($col, $dir) = ($order_by =~ /
419                        \A
420                        (\S+)           # column expression (non-whitespace)
421                        (?:
422                                \s+         # whitespace separator
423                                (ASC|DESC)  # sort direction keyword
424                        )?
425                        \z
426                /xi);
427
4
7
                $dir //= 'ASC';
428
4
3
                my $desc = ($dir eq 'DESC');    # evaluated once, not on every comparison
429
4
5
                @{$rows} = sort {
430                        $desc
431                                ? (($b->{$col} // '') cmp ($a->{$col} // ''))
432
13
24
                                : (($a->{$col} // '') cmp ($b->{$col} // ''))
433
4
4
3
9
                } @{$rows};
434        }
435
21
2
25
3
        splice(@{$rows}, 0, $offset) if $offset;
436
21
3
24
5
        splice(@{$rows}, $limit)     if defined $limit;
437}
438
439# Internal: assemble SQL + bind args.  $count_only replaces SELECT cols with COUNT(*).
440sub _build_sql
441{
442
91
106
        my ($self, $count_only) = @_;
443
444
91
72
        my $db = $self->{'_db'};
445
446        # _open_table populates $db->{'_table_name'} as a side-effect, so call it
447        # first and then read the cache directly — avoids a second ref()+regex.
448
91
119
        $db->_open_table({});
449
91
90
        my $table = $db->{'_table_name'};
450
451
91
109
        my $select = $count_only ? 'COUNT(*)' : $self->{'_select'};
452
91
88
        my $query  = "SELECT $select FROM $table";
453
454
91
91
54
96
        if(@{$self->{'_joins'}}) {
455
4
17
                $query .= ' ' . $db->_build_joins($self->{'_joins'});
456        }
457
458        my $qwhere = $db->{'base_criteria'}
459
10
10
15
16
                ? { %{$db->{'base_criteria'}}, %{$self->{'_where'}} }
460
90
103
                : $self->{'_where'};
461
90
136
        my ($where, $wargs) = $db->_build_where($qwhere);
462
89
89
73
102
        my @args = @{$wargs};
463
464
89
89
62
171
        if(@{$self->{'_joins'}}) {
465
3
5
                $query .= " WHERE $where" if $where;
466        } elsif(($db->{'type'} eq 'CSV') && !$db->{'no_entry'}) {
467
15
14
                my $id = $db->{'id'};
468
15
14
                $query .= " WHERE $id IS NOT NULL AND $id NOT LIKE '#%'";
469
15
27
                $query .= " AND ($where)" if $where;
470        } else {
471
71
83
                $query .= " WHERE $where" if $where;
472        }
473
474
89
99
        unless($count_only) {
475
70
120
                $query .= " ORDER BY $self->{'_order_by'}" if defined $self->{'_order_by'};
476
70
80
                $query .= " LIMIT $self->{'_limit'}"       if defined $self->{'_limit'};
477
70
70
                $query .= " OFFSET $self->{'_offset'}"     if defined $self->{'_offset'};
478        }
479
480
89
154
        return ($query, \@args, $table);
481}
482
483 - 509
=head2 all

    my $rows = $q->all();

B<Terminal method.>  Executes the assembled query and returns an array
reference of hash references, one per matching row.  Returns an empty
array reference when there are no matches (never C<undef>).

=head3 API SPECIFICATION

=head4 Arguments

None.  All query parameters are set via the builder methods before
calling C<all()>.

=head4 Returns

    ArrayRef[HashRef]

An arrayref of row hashrefs.  Returns C<[]> (empty arrayref) when
no rows match -- never C<undef>.  Each hashref is read-only unless
the database was constructed with C<< no_fixate => 1 >>.

When C<measure()> has been called, delegates to C<< $db->aggregate() >>
and returns grouped rows (see L<Database::Abstraction/aggregate>).

=cut
510
511sub all
512{
513
114
636
        my $self = shift;
514
114
114
        my $db   = $self->{'_db'};
515
516        # Ensure _open() fires before checking the backend type.
517
114
200
        $db->_open_table({});
518
519        # Aggregate path: delegate to $db->aggregate when measures have been set.
520
114
114
95
153
        if(@{$self->{'_measures'}}) {
521
46
46
35
70
                my %ap = (%{$self->{'_where'}}, measures => $self->{'_measures'});
522
46
46
39
68
                $ap{'by'}     = $self->{'_group_by'} if @{$self->{'_group_by'}};
523
46
46
36
53
                $ap{'having'} = $self->{'_having'}   if %{$self->{'_having'}};
524
46
46
35
53
                if(@{$self->{'_joins'}}) {
525
0
0
                        $ap{'join'} = $self->{'_joins'};
526                }
527
46
75
                if(defined $self->{'_order_by'}) {
528                        # Parse "col [ASC|DESC]" from order_by into sort_by format.
529                        # Multi-column order_by expressions are not supported for aggregates;
530                        # only the first token is used.
531
8
37
                        my ($col, $dir) = ($self->{'_order_by'} =~ /\A(\S+)(?:\s+(ASC|DESC))?\z/i);
532
8
30
                        $ap{'sort_by'} = defined($col)
533                                ? (defined($dir) ? [$col, uc($dir)] : $col)
534                                : undef;
535
8
14
                        delete $ap{'sort_by'} unless defined $ap{'sort_by'};
536                }
537
46
47
                $ap{'limit'}  = $self->{'_limit'}  if defined $self->{'_limit'};
538
46
51
                $ap{'offset'} = $self->{'_offset'} if defined $self->{'_offset'};
539
46
81
                return $db->aggregate(%ap);
540        }
541
542
68
189
        if($db->{'berkeley'} || ($db->{'type'} // '') =~ /\A(?:Deep|XLSX)\z/) {
543
13
24
                my $backend = $db->{'berkeley'} ? 'BerkeleyDB' : ($db->{'type'} // '');
544                croak(ref($db), ": query->all() with JOINs is not supported on $backend")
545
13
13
10
63
                        if @{$self->{'_joins'}};
546
9
9
9
21
                my $rows = $db->selectall_arrayref({%{$self->{'_where'}}});
547
9
659
                _apply_perl_sort_limit($rows, $self->{'_order_by'}, $self->{'_offset'}, $self->{'_limit'});
548
9
13
                return $rows;
549        }
550
551
55
133
        my ($query, $args, $table) = $self->_build_sql(0);
552
53
111
        $db->_debug("Query->all: $query");
553
554
53
1644
        my $sth = $db->{$table}->prepare_cached($query);
555
53
0
53
16632
0
896
        $sth->execute(@{$args}) or croak("$query: @{$args}");
556
557
53
11447
        my @rows;
558
53
428
        while(my $row = $sth->fetchrow_hashref()) {
559
128
1029
                push @rows, $row;
560        }
561
53
314
        return \@rows;
562}
563
564 - 590
=head2 first

    my $row = $q->first();    # \%hashref or undef

B<Terminal method.>  Executes the query with C<LIMIT 1> and returns the
first matching row as a hash reference, or C<undef> when there is no match.
Any C<limit()> or C<offset()> you have set is temporarily overridden for
efficiency (only the LIMIT is overridden; offset is still applied).

=head3 API SPECIFICATION

=head4 Arguments

None.

=head4 Returns

    HashRef | undef

The first matching row hashref, or C<undef> when no row matches.
Internally applies C<LIMIT 1> for SQL backends; for in-memory
backends it returns the first element of the sorted result.

When C<measure()> has been called, returns the first aggregate group
row (the same as C<< ->all()->[0] >>).

=cut
591
592sub first
593{
594
27
28
        my $self = shift;
595
27
30
        my $db = $self->{'_db'};
596
597
27
50
        $db->_open_table({});
598
599
27
27
26
44
        if(@{$self->{'_measures'}}) {
600
8
13
                local $self->{'_limit'} = 1;
601
8
14
                my $rows = $self->all();
602
7
551
                return ref($rows) eq 'ARRAY' ? $rows->[0] : undef;
603        }
604
605
19
62
        if($db->{'berkeley'} || ($db->{'type'} // '') =~ /\A(?:Deep|XLSX)\z/) {
606
9
19
                my $backend = $db->{'berkeley'} ? 'BerkeleyDB' : ($db->{'type'} // '');
607                croak(ref($db), ": query->first() with JOINs is not supported on $backend")
608
9
9
8
34
                        if @{$self->{'_joins'}};
609
7
7
5
19
                my $rows = $db->selectall_arrayref({%{$self->{'_where'}}});
610
7
513
                _apply_perl_sort_limit($rows, $self->{'_order_by'}, $self->{'_offset'}, undef);
611
7
14
                return $rows->[0];
612        }
613
614        # local on a hash element (supported since Perl 5.8.1) is exception-safe:
615        # even if _build_sql() croaks, _limit is automatically restored on unwind.
616        # The previous save/restore pair left _limit permanently at 1 on exception.
617
10
11
        local $self->{'_limit'} = 1;
618
10
17
        my ($query, $args, $table) = $self->_build_sql(0);
619
620
10
20
        $db->_debug("Query->first: $query");
621
622
10
277
        my $sth = $db->{$table}->prepare_cached($query);
623
10
0
10
1249
0
215
        $sth->execute(@{$args}) or croak("$query: @{$args}");
624
625
10
843
        my $row = $sth->fetchrow_hashref();
626
10
58
        $sth->finish();
627
10
26
        return $row;
628}
629
630 - 656
=head2 count

    my $n = $q->count();

B<Terminal method.>  Executes C<SELECT COUNT(*)> with the current C<WHERE>
and C<JOIN> clauses and returns the integer count.  C<ORDER BY>, C<LIMIT>,
and C<OFFSET> are ignored for the count query.

=head3 API SPECIFICATION

=head4 Arguments

None.

=head4 Returns

    Int (>= 0)

The number of matching rows.  Returns C<0> when nothing matches --
never C<undef>.

B<Restriction:> croaks with
C<"count() is not available once measure() has been called">
if any C<measure()> call has been made on this query builder.  Use
C<all()> and examine C<scalar @{$rows}> instead.

=cut
657
658sub count
659{
660
31
31
        my $self = shift;
661
31
37
        my $db   = $self->{'_db'};
662
663
31
61
        $db->_open_table({});
664
665        croak('Database::Abstraction::Query: count() is not available once measure() has been called')
666
31
31
31
116
                if @{$self->{'_measures'}};
667
668
26
95
        if($db->{'berkeley'} || ($db->{'type'} // '') =~ /\A(?:Deep|XLSX)\z/) {
669
8
17
                my $backend = $db->{'berkeley'} ? 'BerkeleyDB' : ($db->{'type'} // '');
670                croak(ref($db), ": query->count() with JOINs is not supported on $backend")
671
8
8
9
34
                        if @{$self->{'_joins'}};
672
6
6
6
17
                return $db->count({%{$self->{'_where'}}});
673        }
674
675
18
29
        my ($query, $args, $table) = $self->_build_sql(1);
676
18
43
        $db->_debug("Query->count: $query");
677
678
18
530
        my $sth = $db->{$table}->prepare_cached($query);
679
18
0
18
1575
0
305
        $sth->execute(@{$args}) or croak("$query: @{$args}");
680
681
18
2043
        my $row = $sth->fetchrow_arrayref();
682
18
67
        $sth->finish();
683
18
52
        return $row ? $row->[0] : 0;
684}
685
686 - 716
=head2 each

    $q->each(\&callback);
    $q->each(sub { my $row = shift; print $row->{'name'}, "\n" });

B<Terminal method.>  Iterates over matching rows one at a time, calling
C<\&callback> once per row with the row hashref as the sole argument.
Uses constant memory on the SQL path (see L<Database::Abstraction/each_row>).

Applies any C<where()>, C<join()>, C<order_by()>, C<limit()>, and C<offset()>
set on the builder.

Returns the number of rows passed to C<\&callback>.

=head3 API SPECIFICATION

=head4 Arguments

    CodeRef   # required: callback invoked with one row hashref per call

=head4 Returns

    Int (>= 0)

The number of rows visited.  Any exception thrown inside the callback
is re-thrown after the DBI statement handle is properly closed.

When C<measure()> has been called, each group row is passed to the
callback in the same order as C<all()> would return them.

=cut
717
718sub each
719{
720
11
18
        my ($self, $callback) = @_;
721
11
17
        my $db = $self->{'_db'};
722
723
11
48
        croak('Database::Abstraction::Query::each: callback must be a code reference')
724                unless ref($callback) eq 'CODE';
725
726
8
19
        $db->_open_table({});
727
728        # Aggregate path: collect all rows then iterate.  Not constant-memory.
729
8
8
70
16
        if(@{$self->{'_measures'}}) {
730
4
6
                my $rows = $self->all();
731
4
431
                my $n = 0;
732
4
4
4
9
                for my $row (@{$rows}) {
733
7
6
                        local $_;
734
7
12
                        $callback->($row);
735
6
10
                        $n++;
736                }
737
3
4
                return $n;
738        }
739
740
4
44
        if($db->{'berkeley'} || ($db->{'type'} // '') =~ /\A(?:Deep|XLSX)\z/) {
741
0
0
                my $backend = $db->{'berkeley'} ? 'BerkeleyDB' : ($db->{'type'} // '');
742                croak(ref($db), ": query->each() with JOINs is not supported on $backend")
743
0
0
0
0
                        if @{$self->{'_joins'}};
744
0
0
0
0
                my $rows = $db->selectall_arrayref({%{$self->{'_where'}}});
745
0
0
                _apply_perl_sort_limit($rows, $self->{'_order_by'}, $self->{'_offset'}, $self->{'_limit'});
746
0
0
                my $n = 0;
747
0
0
0
0
                for my $row (@{$rows}) {
748
0
0
                        local $_;
749
0
0
                        $callback->($row);
750
0
0
                        $n++;
751                }
752
0
0
                return $n;
753        }
754
755
4
9
        my ($query, $args, $table) = $self->_build_sql(0);
756
4
9
        $db->_debug("Query->each: $query");
757
758
4
127
        my $sth = $db->{$table}->prepare_cached($query);
759
4
0
4
4737
0
59
        $sth->execute(@{$args}) or croak("$query: @{$args}");
760
761
4
2356
        my $n = 0;
762
4
4
        eval {
763
4
34
                while(my $row = $sth->fetchrow_hashref()) {
764
10
143
                        local $_;
765
10
12
                        $callback->($row);
766
10
50
                        $n++;
767                }
768        };
769
4
43
        if(my $err = $@) {
770
0
0
                $sth->finish();
771
0
0
                die $err;
772        }
773
4
5
        return $n;
774}
775
776 - 891
=head1 SEE ALSO

L<Database::Abstraction> - the parent module and its L<Database::Abstraction/QUERY CRITERIA> section.

=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

=head1 SUPPORT

Please report bugs at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Database-Abstraction>.

=head1 COMMON PITFALLS

=over 4

=item B<C<where()> is AND, not OR>

Each call to C<where()> ANDs with all previous C<where()> calls.
Use C<< -or => [...] >> inside a single C<where()> call for OR logic.

=item B<C<measure()> calls accumulate>

Every C<measure()> call appends a new measure to the query.  To
build a fresh aggregate query call C<< $db->query() >> again rather
than re-using the same builder object.

=item B<C<group_by()> replaces, not appends>

Calling C<group_by()> a second time replaces the previous group-by
columns entirely.  It does not accumulate like C<measure()> or
C<where()>.

=item B<C<count()> is unavailable after C<measure()>>

Once you have called C<measure()> on a query builder, calling
C<count()> will croak.  Use C<all()> and check C<scalar @{$rows}>.

=item B<Sort column for aggregate queries must use C<order_by()>>

To sort aggregate results, call C<< ->order_by('col') >> on the
builder before C<< ->all() >>.  Passing C<sort_by> directly to
C<all()> has no effect because the terminal method ignores extra
arguments.

=back

=head1 FORMAL SPECIFICATION

=head2 Query builder state

    QueryState == [
        _db       : Database::Abstraction,
        _select   : Str,
        _where    : CRITERIA,
        _joins    : seq JOIN_SPEC,
        _order_by : Str | undef,
        _limit    : Int | undef,
        _offset   : Int | undef,
        _group_by : seq COLNAME,
        _measures : seq MEASURE_SPEC,
        _having   : CRITERIA
    ]

    Initial state Q0 == [
        _select?   = '*',
        _where?    = {},
        _joins?    = [],
        _order_by? = undef,
        _limit?    = undef,
        _offset?   = undef,
        _group_by? = [],
        _measures? = [],
        _having?   = {}
    ]

=head2 Builder method invariants

    where(C) ____________________________________________
    DeltaQueryState
    C? : CRITERIA
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    post: _where' = _where UNION C   (AND semantics)

    measure(col, fn, as) ________________________________
    DeltaQueryState
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    post: _measures' = _measures CONCAT [{col,fn,as}]

    group_by(cols) ______________________________________
    DeltaQueryState
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    post: _group_by' = cols  (replaces previous value)

=head2 all() dispatch

    all _________________________________________________
    XiQueryState  (read-only)
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    result == (_measures != empty
               IMPLIES _db.aggregate(_group_by, _measures, _where,
                                     _having, _order_by, _limit, _offset)
               ELSE    _db.selectall_arrayref(_where, _joins,
                                              _order_by, _limit, _offset))
    post: result : seq ROW

=head1 LICENSE AND COPYRIGHT

Copyright 2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut
892
8931;