| File: | blib/lib/Database/Abstraction/Query.pm |
| Coverage: | 87.1% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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 | ||||||
| 26 | our $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 | ||||||
| 126 | sub 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 | ||||||
| 160 | sub 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 | ||||||
| 186 | sub 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 | ||||||
| 222 | sub 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 | ||||||
| 240 | sub 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 | ||||||
| 265 | sub 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 | ||||||
| 285 | sub 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 | ||||||
| 317 | sub 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 | ||||||
| 358 | sub 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 | ||||||
| 396 | sub 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). | |||||
| 409 | sub _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(*). | |||||
| 440 | sub _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 | ||||||
| 511 | sub 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 | ||||||
| 592 | sub 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 | ||||||
| 658 | sub 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 | ||||||
| 718 | sub 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 | ||||||
| 893 | 1; | |||||