File Coverage

File:blib/lib/Database/Abstraction.pm
Coverage:78.0%

linestmtbrancondsubtimecode
1package Database::Abstraction;
2
3# Author Nigel Horne: njh@nigelhorne.com
4# Copyright (C) 2015-2026, Nigel Horne
5
6# Usage is subject to licence terms.
7
8# TODO: Switch "entry" to off by default, and enable by passing 'entry'
9#       though that wouldn't be so nice for AUTOLOAD
10# TODO: support a directory hierarchy of databases
11# TODO: consider returning an object or array of objects, rather than hashes
12# Redis backend (database => 'redis://...') was added in 0.48.
13# TODO: Add a "key" property, defaulting to "entry", which would be the name of the key
14# TODO: The maximum number to return should be tuneable (as a LIMIT)
15# TODO: Add full CRUD support
16# TODO: It would be better for the default sep_char to be ',' rather than '!'
17# TODO: Other noSQL databases e.g., MongoDB, Cassandra
18# TODO: The no_entry/entry terminology is confusing.  Replace with no_id/id_column
19# TODO: Log queries and the time that they took to execute per database
20# TODO: Use DBD::JSON if one is ever written
21
22
51
51
51
5414317
45
1037
use warnings;
23
51
51
51
92
38
564
use strict;
24
51
51
51
4789
157666
151
use autodie qw(:all);
25
26
51
51
51
252410
73
1349
use Carp;
27
51
51
51
10699
470927
165
use Class::Abstract;
28
51
51
51
15872
72977
1140
use Data::Reuse;
29
51
51
51
36060
379454
1597
use DBI;
30
51
51
51
214
58
5243
use Fcntl;      # For O_RDONLY
31
51
51
51
121
49
1155
use Cwd;
32
51
51
51
95
41
580
use File::Spec;
33
51
51
51
9983
182366
1725
use File::Temp;
34
51
51
51
107
44
1432
use List::Util qw(all any);
35
51
51
51
22921
2650383
1278
use Log::Abstraction 0.33;
36
51
51
51
16001
275194
1066
use Object::Configure 0.16;
37
51
51
51
207
297
988
use Params::Get 0.17;
38
51
51
51
121
55
905
use Return::Set qw(set_return);
39
51
51
51
100
42
780
use Scalar::Util;
40
51
51
51
102
46
204
use Sub::Private { mode => 'enforce' };
41
51
51
51
18265
59596
148
use Sub::Protected;
42
43# File::Slurp::Remote is loaded lazily in _open() when host => '...' is given.
44
45our %defaults;
46
51
51
51
5605
46
3163
use constant    DEFAULT_MAX_SLURP_SIZE => 16 * 1024; # CSV files <= than this size are read into memory
47
48# Compiled once at module load; reused in every identifier-safety check.
49# Using \A/\z (true string anchors) rather than ^/$ which match around \n.
50# SAFE_IDENTIFIER: bare SQL identifier — letters, digits, underscore.
51# SAFE_QUALIFIED:  allows a single dot for table.column notation in JOINs.
52my $SAFE_IDENTIFIER = qr/\A[a-zA-Z_][a-zA-Z0-9_]*\z/;
53my $SAFE_QUALIFIED  = qr/\A[a-zA-Z_][a-zA-Z0-9_.]*\z/;
54
55# Type-inference regexes and sample size for schema() when infer_types => 1.
56# Compiled once at load; reused in _infer_type() across all schema() calls.
57
51
51
51
112
41
188714
use constant INFER_TYPE_SAMPLE_SIZE => 100;
58my $INFER_INT_RE  = qr/\A-?\d+\z/;
59my $INFER_REAL_RE = qr/
60        \A              # start of string
61        -?              # optional leading minus
62        \d+             # integer part -- required
63        (?:\.\d+)?      # optional decimal part
64        (?:             # optional exponent block:
65                [eE]        #   e or E marker
66                [+-]?       #   optional sign
67                \d+         #   exponent digits
68        )?
69        \z              # end of string
70/x;
71# No \z anchor: intentionally matches any valid timestamp prefix so that full
72# timestamps with seconds, fractional seconds, or timezone offsets are still
73# classified as TIMESTAMP -- e.g. 2024-01-01 12:34:00.000+05:30
74my $INFER_TS_RE   = qr/
75        \A                              # start of string
76        \d{4}                           # 4-digit year
77        - (?:0[1-9]|1[0-2])            # month 01-12
78        - (?:0[1-9]|[12]\d|3[01])      # day   01-31
79        [T\ ]                           # ISO 8601 date-time separator: T or space
80        \d{2}:\d{2}                     # HH:MM -- no end anchor, see note above
81/x;
82my $INFER_DATE_RE = qr/
83        \A                              # start of string
84        \d{4}                           # 4-digit year
85        - (?:0[1-9]|1[0-2])            # month 01-12
86        - (?:0[1-9]|[12]\d|3[01])      # day   01-31
87        \z                              # end of string
88/x;
89
90# Module-level constant: valid JOIN types after uc() normalisation.
91# Built once at compile time; reused by every _build_joins call.
92my %VALID_JOIN_TYPES = map { $_ => 1 } qw(INNER LEFT RIGHT FULL CROSS);
93
94# Valid aggregate functions (lower-cased) for _validate_aggregate_spec.
95my %VALID_AGG_FNS = map { $_ => 1 } qw(sum avg min max count count_distinct median stddev);
96
97# Functions that are Perl-fold-only (no SQL pushdown).
98my %PERL_ONLY_AGG_FNS = map { $_ => 1 } qw(median stddev);
99
100=encoding utf8
101
102 - 110
=head1 NAME

Database::Abstraction - Read-only Database Abstraction Layer (ORM)

=head1 VERSION

Version 0.52

=cut
111
112our $VERSION = '0.52';
113
114 - 510
=head1 DESCRIPTION

C<Database::Abstraction> is a read-only ORM for Perl that gives a uniform
interface over CSV, PSV, TSV, JSON, XML, SQLite, DBM::Deep, BerkeleyDB, and
Excel (XLS/XLSX) files - local, remote (via SSH), or fetched from a URL -
without writing any SQL.
Effectively it allows you to access a database table, of many different
database formats, as an object.

Key features:

=over 4

=item *

B<No SQL required.>  Use plain Perl method calls for simple lookups and
scans; switch storage formats without changing application code.

=item *

B<Rich query criteria.>  Pass plain values, SQL wildcards, C<undef> (IS NULL),
comparison operators (C<< > >> C<< < >> C<< >= >> C<< <= >> C<!=>), pattern
operators (C<-like>, C<-not_like>), set operators (C<-in>, C<-not_in>,
C<-between>), and logical groupings (C<-or>, C<-and>).

=item *

B<Automatic joins.>  Add a C<join> parameter to any select method to
combine tables with INNER, LEFT, RIGHT, FULL, or CROSS joins.

=item *

B<Chained query builder.>  The C<query()> method returns a
L<Database::Abstraction::Query> object for fluent, composable queries:
C<< $db->query->where(...)->order_by(...)->limit(...)->all() >>.

=item *

B<Aggregation.>  The C<aggregate()> method and the fluent
C<< ->group_by()->measure()->having()->all() >> chain push C<GROUP BY> to
SQL backends and fold rows in Perl on slurp-only backends.

=item *

B<Schema introspection.>  C<columns()> lists column names; C<schema()>
returns full type/nullability metadata, using native driver introspection
(C<PRAGMA table_info> for SQLite, C<column_info> for others).

=item *

B<DSN portability.>  Pass a C<dsn> (plus optional C<username>/C<password>)
to connect to any DBI-supported database (SQLite, PostgreSQL, MySQL, ...)
instead of pointing at a local file.

=item *

B<Performance.>  Small files are slurped into a RAM hash for sub-millisecond
lookups.  All DBI statement handles are cached with C<prepare_cached()>.
A CHI-compatible cache layer is also supported.

=back

=head1 SYNOPSIS

    # 1. Create a thin subclass for your table (e.g. Database/Foo.pm)
    package Database::Foo;
    use parent 'Database::Abstraction';

    # 2. Open the database - file is auto-detected from the class name
    #    (looks for foo.sql / foo.sqlite / foo.sqlite3 / foo.psv / foo.tsv / foo.csv / foo.xlsx / foo.xml / foo.json / foo.db)
    my $db = Database::Foo->new(directory => '/path/to/data');

    # 3. Simple lookups -----------------------------------------------

    # Fetch one row
    my $row = $db->fetchrow_hashref(entry => 'key1');

    # Fetch all rows matching a criterion
    my $rows = $db->selectall_arrayref(status => 'active');

    # Column shortcut via AUTOLOAD
    my $name = $db->name(entry => 'key1');

    # 4. Rich criteria ------------------------------------------------

    # Comparison operators
    my $high = $db->selectall_arrayref(score => { '>' => 90 });

    # Set membership
    my $selected = $db->selectall_arrayref(
        name => { -in => ['Alice', 'Bob'] }
    );

    # Range
    my $mid = $db->selectall_arrayref(
        score => { -between => [60, 80] }
    );

    # OR grouping
    my $either = $db->selectall_arrayref(
        -or => [
            { status => 'active'    },
            { score  => { '>' => 95 } },
        ]
    );

    # 5. Joins --------------------------------------------------------

    my $joined = $db->selectall_arrayref(
        join => { table => 'dept', on => 'foo.dept_id = dept.id', type => 'LEFT' }
    );

    # 6. Chained query builder ----------------------------------------

    my $results = $db->query
        ->where(status => 'active')
        ->where(score  => { '>=' => 80 })
        ->order_by('score DESC')
        ->limit(10)
        ->all();

    my $first = $db->query->where(name => 'Alice')->first();
    my $count = $db->query->where(status => 'active')->count();

    # 7. Connect via DSN (PostgreSQL, MySQL, SQLite, ...) ---------------

    my $db2 = Database::Foo->new(
        dsn      => 'dbi:Pg:dbname=mydb;host=db.example.com',
        username => 'myuser',
        password => 's3cret',
    );

    # 8. Schema introspection -----------------------------------------

    my $cols   = $db->columns();  # ['entry', 'name', 'score', ...]
    my $schema = $db->schema();   # { name => { type=>'TEXT', nullable=>1, ... }, ... }

    # 9. Aggregation (GROUP BY) ----------------------------------------

    # Total revenue and order count per region
    my $summary = $db->aggregate(
        by       => ['region'],
        measures => [
            { col => 'amount', fn => 'sum',   as => 'revenue' },
            {                  fn => 'count', as => 'orders'  },
        ],
    );

    # Grand total across all rows
    my $totals = $db->aggregate(
        measures => [{ col => 'amount', fn => 'sum', as => 'total' }],
    )->[0];

    # Fluent style with HAVING and sort
    my $top = $db->query
        ->group_by('region')
        ->measure('amount', 'sum',   'revenue')
        ->measure(undef,    'count', 'orders')
        ->having(revenue => { '>' => 1000 })
        ->order_by('revenue DESC')
        ->limit(5)
        ->all();

=head1 QUICK START EXAMPLE

If F</var/dat/foo.csv> contains:

    "customer_id","name"
    "plugh","John"
    "xyzzy","Jane"

Create a driver in F<.../Database/foo.pm>:

    package Database::foo;
    use parent 'Database::Abstraction';

    # Regular CSV: no entry column, comma-separated
    sub new {
        my ($class, %args) = @_;
        return $class->SUPER::new(no_entry => 1, sep_char => ',', %args);
    }

Then query it:

    my $foo = Database::foo->new(directory => '/var/dat');

    # Prints "John"
    print 'Customer: ', $foo->name(customer_id => 'plugh'), "\n";

    # Returns { customer_id => 'xyzzy', name => 'Jane' }
    my $row = $foo->fetchrow_hashref(customer_id => 'xyzzy');

=head1 FILE FORMATS

The module probes the C<directory> for files in this priority order:

=over 4

=item 1. C<SQLite>

File ending C<.sql>, C<.sqlite>, or C<.sqlite3>.
Requires L<DBD::SQLite>.

=item 2. C<Deep>

DBM::Deep file ending C<.dbm> or C<.deep>.  The entire file is slurped
into a plain Perl hash on open; all in-memory fast-paths apply.
Requires L<DBM::Deep> (loaded lazily).

=item 3. C<PSV>

Pipe-separated file, ending C<.psv>.

=item 4. C<TSV>

Tab-separated file, ending C<.tsv>.

=item 5. C<CSV>

Comma (or custom) separated file, ending C<.csv> or C<.db>; can be
gzipped (C<.csv.gz> or C<.db.gz>).
B<Note:> the default separator is C<!> not C<,> for historical
reasons - pass C<< sep_char => ',' >> for standard CSVs.
Requires L<Text::xSV::Slurp> for the slurp fast-path (loaded lazily).

=item 6. C<Excel> (C<.xls>) and C<XLSX> (C<.xlsx>)

Two separate Excel backends - one per file format:

=over 4

=item B<.xls> - old binary format, opened via L<DBD::Excel> (which uses
L<Spreadsheet::ParseExcel> internally).  All queries go through DBI/SQL;
no in-memory slurp path.  C<max_slurp_size> has no effect.

=item B<.xlsx> - modern OOXML format, parsed directly via
L<Spreadsheet::ParseXLSX> and slurped into an in-memory hash (keyed mode)
or array (C<no_entry> mode).  No DBI handle is created; all queries use
the in-memory fast-path.  Complex criteria (operator hashes, C<-or>/C<-and>)
will fall through to the SQL path and croak - use simple scalar criteria.

=back

For both formats, each worksheet is a separate logical table; the active
worksheet is determined by the class-derived table name (or the C<table>
constructor parameter).  Both modules are loaded lazily.

=item 7. C<XML>

File ending C<.xml>.
Requires L<XML::Simple> for the slurp fast-path (loaded lazily).

=item 8. C<JSON>

File ending C<.json>, slurped into memory via L<JSON::MaybeXS> (loaded
lazily).

The file may contain either a JSON array of row objects:

    [
      { "entry": "key1", "col": "val1" },
      { "entry": "key2", "col": "val2" }
    ]

or a JSON object whose keys are the primary-key values:

    {
      "key1": { "col": "val1" },
      "key2": { "col": "val2" }
    }

In the object form, each key is injected into its row hash under the C<id>
column name (default C<entry>), so all normal lookups work identically to
the array form.

A zero-byte or whitespace-only file is treated as empty - all query methods
return 0 / C<undef> / C<[]> without throwing.
Requires L<JSON::MaybeXS> (loaded lazily).

=item 9. C<BerkeleyDB>

Binary key-value file ending C<.db>.

=item 10. C<HTML>

HTML page fetched via a C<url>.  Pass C<url => 'https://...'> instead of
C<directory>; the module fetches the page with L<LWP::UserAgent::Cached>, parses all
C<< <table> >> elements with L<HTML::TableExtract>, and slurps the first (or
C<html_table_index>-selected) table into memory.  The first row of the table
is treated as column headers.  Both modules are loaded lazily and are not
required for other backends.

=back

Pass C<dsn> to bypass file detection entirely and connect via any DBI driver.
Pass C<url> to fetch and slurp data from a remote source without a local
directory.  When the URL returns C<Content-Type: application/json> or the URL
path ends in C<.json>, the response is parsed as JSON (see item 8 above).
Otherwise the response is parsed as an HTML page (item 10).

Example - fetching CPAN Testers results:

    package Database::cpantesters;
    use parent 'Database::Abstraction';

    my $db = Database::cpantesters->new(
        url      => 'https://www.cpantesters.org/show/Database-Abstraction.json',
        no_entry => 1,
    );
    my $passes = $db->selectall_arrayref(grade => 'PASS');

=head1 QUERY CRITERIA

All select methods (C<selectall_arrayref>, C<selectall_array>,
C<fetchrow_hashref>, C<count>) accept the same criteria syntax.

=head2 Plain value

    status => 'active'          # status = 'active'
    name   => undef             # name IS NULL

Values containing C<%> or C<_> are matched with C<LIKE>:

    name => 'A%'                # name LIKE 'A%'

=head2 Comparison operator hashref

    score => { '>'  => 90  }   # score > 90
    score => { '<'  => 50  }   # score < 50
    score => { '>=' => 80  }   # score >= 80
    score => { '<=' => 100 }   # score <= 100
    score => { '!=' => 0   }   # score != 0

Multiple operators on one column are ANDed:

    score => { '>' => 60, '<' => 90 }   # 60 < score < 90

=head2 Pattern matching

    name => { -like     => 'A%'  }   # name LIKE 'A%'
    name => { -not_like => 'Z%'  }   # name NOT LIKE 'Z%'

=head2 Set membership

    name => { -in     => ['Alice', 'Bob'] }   # name IN (...)
    name => { -not_in => ['Alice', 'Bob'] }   # name NOT IN (...)

=head2 Range

    score => { -between => [60, 90] }   # score BETWEEN 60 AND 90

=head2 Logical groupings

C<-or> and C<-and> take an arrayref of condition hashrefs:

    -or => [
        { status => 'active'        },
        { score  => { '>' => 95 }   },
    ]

    -and => [
        { status => 'active'        },
        { score  => { '>=' => 80 }  },
    ]

=head2 Joins

Any select method accepts a C<join> key with a hashref (or arrayref of
hashrefs) describing the join:

    join => {
        table => 'dept',
        on    => 'employees.dept_id = dept.id',
        type  => 'LEFT',    # INNER (default) | LEFT | RIGHT | FULL | CROSS
    }

    # Multiple joins
    join => [
        { table => 'dept',    on => 'e.dept_id   = dept.id'   },
        { table => 'country', on => 'e.country_id = country.id' },
    ]

=head1 SUBROUTINES/METHODS

=head2 init

Set class-level defaults shared by all instances.

    Database::Abstraction::init(directory => '../data');

Accepts the same parameters as L</new>.  Returns a reference to the
current defaults hash, so you can read them back:

    my $defaults = Database::Abstraction::init();
    print $defaults->{'directory'}, "\n";

=cut
511
512# Subroutine to initialize with args
513sub init
514{
515
174
1279726
        if(my $params = Params::Get::get_params(undef, \@_)) {
516
168
2564
                if(($params->{'expires_in'} && !$params->{'cache_duration'})) {
517                        # Compatibility with CHI
518
4
6
                        $params->{'cache_duration'} = $params->{'expires_in'};
519                }
520
521
168
168
191
401
                %defaults = (%defaults, %{$params});
522
168
2466
                $defaults{'cache_duration'} ||= '1 hour';
523        }
524
525
174
26766
        return \%defaults
526}
527
528 - 538
=head2 import

The module can be initialised by the C<use> directive.

    use Database::Abstraction 'directory' => '/etc/data';

or

    use Database::Abstraction { 'directory' => '/etc/data' };

=cut
539
540sub import
541{
542
151
797049
        my $pkg = shift;
543
544
151
652
        if((scalar(@_) == 0) && (ref($pkg) eq 'HASH')) {
545
0
0
                init(Object::Configure::configure(__PACKAGE__, $pkg));
546        } elsif((scalar(@_) % 2) == 0) {
547
148
153
                my %h = @_;
548
148
322
                init(Object::Configure::configure($pkg, \%h));
549        } elsif((scalar(@_) == 1) && (ref($_[0]) eq 'HASH')) {
550
3
11
                init(Object::Configure::configure($pkg, $_[0]));
551        } elsif(scalar(@_) > 0) {    # >= 3 would also work here
552
0
0
                init(\@_);
553        }
554}
555
556 - 784
=head2 new

Create an object pointing to a read-only database.

Accepts arguments as a hash, a hashref, or - as a shortcut - a single bare
string which is taken to be C<directory>.

=head3 Connection parameters

=over 4

=item * C<directory>

Directory containing the data files.  The module probes this directory for
files named after the subclass (see L</FILE FORMATS>).  Required unless
C<dsn> is given.

=item * C<dsn>

A DBI data-source string (e.g. C<dbi:SQLite:dbname=/path/to/db> or
C<dbi:Pg:dbname=mydb;host=db.example.com>).  When present, file detection
is skipped entirely and the DSN is used directly.  The SQL dialect is
inferred from the DSN prefix (C<sqlite>, C<postgres>, C<mysql>).

=item * C<username>

Database username.  Used only with C<dsn>; ignored for file-based backends.

=item * C<password>

Database password.  Used only with C<dsn>; ignored for file-based backends.

=item * C<dbname>

Override the filename stem searched in C<directory> (default: the table
name derived from the class name).

=item * C<table>

Override the table (or worksheet) name used in SQL queries for this object.
Default is the class-name-derived table name (e.g. C<Database::Foo> =>
C<foo>).  Particularly useful for Excel workbooks where a single F<.xlsx>
file contains multiple worksheets: pass C<< table => 'Summary' >> to query
the C<Summary> worksheet without creating a dedicated subclass.  Also works
with SQLite/DSN connections to select a table other than the class-derived
default.  The filename stem (C<dbname>) continues to fall back to the class
name, so the correct file is opened regardless of this override.  The value
is validated against C<$SAFE_QUALIFIED> at construction time.

=item * C<filename>

Override the full filename (relative to C<directory>).  Takes precedence
over C<dbname>.

=item * C<host>

Remote hostname (or C<user@host>) from which to fetch the data file(s) via
SSH/SCP.  When present, each candidate filename is fetched with
L<File::Slurp::Remote> into a local temporary directory; the existing
extension-based file-type detection then runs against that directory.
C<directory> is treated as the remote path (no local canonicalization is
applied).  Using C<filename> together with C<host> avoids probing multiple
extensions and is therefore more efficient.  L<File::Slurp::Remote> must be
installed; it is loaded lazily (only when C<host> is given).

=item * C<url>

A URL (C<http://> or C<https://>) pointing to an HTML page that contains one
or more C<< <table> >> elements.  When present, C<directory> is not required.
The first row of the selected table is used as column headers.
Requires L<LWP::UserAgent::Cached> and L<HTML::TableExtract> (both loaded lazily).

=item * C<database>

A Redis connection URL in the form C<redis://[password@]host[:port][/db_index]>.
When present, C<directory>, C<dsn>, and C<url> are not required.  Rows are
expected to be stored as Redis Hashes at keys of the form
C<tablename:entry_value>; fields of each Hash become column values.  The
module slurps all matching keys via C<KEYS tablename:*> + C<HGETALL> into the
same in-memory structure used by the CSV and DBM::Deep backends, so all
existing fast-paths (C<selectall_arrayref>, C<selectall_array>,
C<fetchrow_hashref>, C<count>, C<columns>, C<schema>, AUTOLOAD) work without
modification.  C<no_entry> mode is supported.

L<Redis::Fast> is tried first (XS, faster); if unavailable, L<Redis>
(pure-Perl) is used as a fallback.  Both are loaded lazily - neither is
required when using file-based or DSN-based backends.  The URL scheme is
validated at construction time (C<redis://> only; all other schemes croak).

Use L</select> to switch the active Redis database on an already-open
connection.

=back

=head3 Behaviour parameters

=over 4

=item * C<no_entry>

Set to C<1> when the table has no key column (standard CSVs, for example).
Default is C<0> (keyed on C<entry>).

=item * C<id>

Name of the key column.  Default is C<entry>.

=item * C<sep_char>

Field separator for CSV/PSV files.
Default is C<!> - pass C<< sep_char => ',' >>
for standard comma-separated files.

=item * C<max_slurp_size>

Files smaller than this (in bytes) are loaded entirely into memory for fast
lookups.  Default is 16 KB.  Set to C<0> to force SQL mode for all sizes.

=item * C<no_fixate>

Set to C<1> to return mutable arrays.  Default is C<0> (arrays are made
read-only via L<Data::Reuse>).

=item * C<auto_load>

Set to C<0> to disable the AUTOLOAD column shortcut.  Default is C<1>
(enabled).

=item * C<html_table_index>

Zero-based index of the HTML C<< <table> >> to extract when the C<url>
backend is used.  Default is C<0> (the first table on the page).

=item * C<infer_types>

Set to C<1> to enable heuristic type inference in C<schema()> for
slurp-backed sources (CSV, JSON, XLSX, XML, HTML URL, DBM::Deep).
Default is C<0> (off; all columns reported as C<TEXT> for backward
compatibility).

When enabled, C<schema()> scans the first 100 rows and promotes column
types: all-integer values => C<INTEGER>; all-floating-point values =>
C<REAL>; all ISO-8601 timestamps => C<TIMESTAMP>; all ISO-8601 dates =>
C<DATE>; otherwise C<TEXT>.  C<NULL> (C<undef> or empty string) values
are skipped during the scan.

=item * C<base_criteria>

A hashref of column-value pairs that is ANDed into B<every> SELECT
automatically.  Callers do not need to repeat these criteria on each
call.  Common use cases:

=over 4

=item * Row-level security: C<< base_criteria => { tenant_id => $tid } >>

=item * Soft-delete filtering: C<< base_criteria => { deleted_at => undef } >>

=item * Status gates: C<< base_criteria => { active => 1 } >>

=back

The hashref is shallow-copied at construction time so later mutations to
the original do not affect the object.  Column names are validated
against the same identifier rules as the C<id> parameter.  C<-or> and
C<-and> groupings are allowed as keys.

B<SQL path>: the criteria are merged into the C<WHERE> clause before
the caller-supplied criteria (caller values win on key collision).

B<Slurp/in-memory path>: the criteria are applied as an additional
filter.  The C<fetchrow_hashref> entry-key fast path checks
C<base_criteria> against the returned row before returning it.

B<Query builder>: C<base_criteria> are merged into the C<WHERE> clause
generated by C<_build_sql>, so C<< $db->query->where(...)->all() >>
also respects them.

=back

=head3 Caching and logging

=over 4

=item * C<cache>

A L<CHI>-compatible cache object.  When set, query results are stored and
retrieved from the cache.

=item * C<cache_duration> / C<expires_in>

TTL for cached results.  Default is C<'1 hour'>.  C<expires_in> is a
synonym for compatibility with L<CHI>.

=item * C<logger>

An object that understands C<warn()> and C<trace()> (e.g.
L<Log::Log4perl>, L<Log::Any>), a code reference, or a filename.

=item * C<config_file>

Path to a YAML, XML, or INI configuration file whose keys are merged into
the constructor arguments.  Loaded via L<Object::Configure>.

=back

=head3 Notes

=over 4

=item *

If no arguments are set, class-level defaults set via C<init()> or C<use>
are used.

=item *

Slurp mode assumes the key column (C<entry>) is unique.  If it is not,
searches will be incomplete - disable slurp mode by setting
C<< max_slurp_size => 0 >>.

=item *

Passing an existing object as C<$class> clones it, merging any new
arguments.

=back

=cut
785
786sub new {
787
800
5752822
        my $class = shift;
788
800
727
        my %args;
789
790
800
1725
        Class::Abstract::check_abstract($class);        # enforces abstract contract
791
792        # Handle hash or hashref arguments
793
800
7136
        if((scalar(@_) == 1) && !ref($_[0])) {
794
168
674
                $args{'directory'} = $_[0];
795        } elsif(my $params = Params::Get::get_params(undef, @_)) {
796
622
622
9238
1072
                %args = %{$params};
797        }
798
799
800
2515
        if(!defined($class)) {
800
0
0
                if((scalar keys %args) > 0) {
801                        # Using Database::Abstraction->new(), not Database::Abstraction::new()
802
0
0
                        carp(__PACKAGE__, ' use ->new() not ::new() to instantiate');
803
0
0
                        return;
804                }
805                # FIXME: this only works when no arguments are given
806
0
0
                $class = __PACKAGE__;
807        } elsif($class eq __PACKAGE__) {
808
6
57
                croak("$class: abstract class");
809        } elsif(Scalar::Util::blessed($class)) {
810                # If $class is an object, clone it with new arguments.
811                # Validate 'id' and 'table' before merging — the validation block below is
812                # skipped by this early return, so hostile clone args would otherwise bypass
813                # all guards and be interpolated directly into SQL.
814
15
25
                if(defined $args{'id'}) {
815                        croak(ref($class), ": unsafe id column name '$args{id}'")
816
6
64
                                unless $args{'id'} =~ $SAFE_IDENTIFIER;
817                }
818
10
15
                if(defined $args{'table'}) {
819                        croak(ref($class), ": unsafe table name '$args{table}'")
820
3
30
                                unless $args{'table'} =~ $SAFE_QUALIFIED;
821                }
822
7
7
8
33
                return bless { %{$class}, %args }, ref($class);
823        }
824
825        # Load the configuration from a config file, if provided
826
779
779
608
1564
        %args = %{Object::Configure::configure($class, \%args)};
827
828        # Normalise logger: wrap code-refs, filenames, and strings in Log::Abstraction
829        # so that the rest of the code can always call ->$level(...) uniformly.
830
779
1576682
        if(defined $args{'logger'} && !Scalar::Util::blessed($args{'logger'})) {
831
0
0
                $args{'logger'} = Log::Abstraction->new($args{'logger'});
832        }
833
834
779
4000
        unless($args{'dsn'} || $defaults{'dsn'} || $args{'url'} || $defaults{'url'} || $args{'database'} || $defaults{'database'}) {
835
625
1019
                croak("$class: where are the files?") unless($args{'directory'} || $defaults{'directory'});
836
837                # Skip the local -d check only for genuinely remote hosts.
838                # localhost / 127.0.0.1 / current hostname are treated as local.
839
619
1181
                my $given_host = $args{'host'} // $defaults{'host'};
840
619
877
                unless($given_host && !$class->_is_local_host($given_host)) {
841
594
3852
                        croak("$class: ", $args{'directory'} || $defaults{'directory'}, ' is not a directory') unless(-d ($args{'directory'} || $defaults{'directory'}));
842                }
843        }
844
845        # Validate the primary-key column name to prevent SQL injection via ORDER BY / WHERE
846
762
991
        for my $src (\%defaults, \%args) {
847
1524
1559
                if(defined $src->{'id'}) {
848                        croak("$class: unsafe id column name '$src->{id}'")
849
63
534
                                unless $src->{'id'} =~ $SAFE_IDENTIFIER;
850                }
851
1503
1348
                if(defined $src->{'host'}) {
852                        croak("$class: unsafe host '$src->{host}'")
853
39
343
                                unless $src->{'host'} =~ /\A
854                                        (?:                   # optional "user\@" prefix
855                                                [a-zA-Z0-9]       # username: first char must be alnum
856                                                [a-zA-Z0-9._-]*   # username: rest may include dots and hyphens
857                                                \@                # literal at-sign; \@ prevents array interpolation
858                                        )?
859                                        [a-zA-Z0-9:]          # first char of host or IP; colon allows IPv6
860                                        [a-zA-Z0-9._:-]*      # rest: hostname, dotted IPv4, or IPv6 hex groups
861                                \z/x;
862                }
863
1482
1425
                if(defined $src->{'url'}) {
864                        croak("$class: unsafe url '$src->{url}'")
865
41
294
                                unless $src->{'url'} =~ m{\Ahttps?://}i;
866                }
867
1467
1841
                if(defined $src->{'database'}) {
868                        croak("$class: unsafe database '$src->{database}'")
869
1
4
                                unless $src->{'database'} =~ m{\Aredis://}i;
870                }
871
1467
1294
                if(defined $src->{'table'}) {
872                        croak("$class: unsafe table name '$src->{table}'")
873
39
465
                                unless $src->{'table'} =~ $SAFE_QUALIFIED;
874                }
875                # redis_key: the literal Redis key name used for lookups.  It is
876                # intentionally NOT validated as a SQL identifier — Redis key names
877                # may contain colons, hyphens and other non-SQL characters.  Only
878                # accepted when a database (redis://) URL is also present so it
879                # cannot be injected into non-Redis SQL paths.
880
1443
1527
                if(defined $src->{'redis_key'} && !defined $src->{'database'}) {
881
0
0
                        croak("$class: redis_key is only valid with a redis:// database URL");
882                }
883
1443
1548
                if(defined $src->{'base_criteria'}) {
884
65
64
                        my $bc = $src->{'base_criteria'};
885
65
288
                        croak("$class: base_criteria must be a hashref")
886                                unless ref($bc) eq 'HASH';
887
55
55
33
81
                        for my $k (keys %{$bc}) {
888
52
78
                                next if $k =~ /\A-/;    # allow -or/-and groupings
889
48
349
                                croak("$class: unsafe base_criteria key '$k'")
890                                        unless $k =~ $SAFE_QUALIFIED;
891                        }
892
41
41
36
75
                        $src->{'base_criteria'} = { %{$bc} };        # shallow copy for safety
893                }
894        }
895
896        # Defaults are set first so that %args keys override them
897
657
3626
        return bless {
898                no_entry => 0,
899                no_fixate => 0,
900                id => 'entry',
901                cache_duration => '1 hour',
902                max_slurp_size => DEFAULT_MAX_SLURP_SIZE,
903                %defaults,
904                %args,
905        }, $class;
906}
907
908 - 912
=head2  set_logger

Sets the class, code reference, or file that will be used for logging.

=cut
913
914sub set_logger
915{
916
16
4678
        my $self = shift;
917
16
23
        my $params = Params::Get::get_params('logger', @_);
918
919
13
188
        if(my $logger = $params->{'logger'}) {
920
12
25
                if(Scalar::Util::blessed($logger)) {
921
4
13
                        $self->{'logger'} = $logger;
922                } else {
923
8
18
                        $self->{'logger'} = Log::Abstraction->new($logger);
924                }
925
12
683
                return $self;
926        }
927
1
15
        Carp::croak('Usage: set_logger(logger => $logger)')
928}
929
930 - 939
=head2  select

Select a Redis database by number (0-15).  Clears the in-memory data cache so
the next query re-slurps from the newly selected database.  Returns C<$self>
for chaining.  Croaks if the object is not backed by a Redis connection.

    $db->select(3);          # switch to Redis DB 3
    my $count = $db->count;  # queries the new DB

=cut
940
941sub select
942{
943
0
0
        my $self = shift;
944
0
0
        my $db_num = Params::Get::get_params('database', \@_)->{'database'};
945
946
0
0
        Carp::croak(ref($self), ': select: db number must be a non-negative integer')
947                unless defined($db_num) && $db_num =~ /\A\d+\z/;
948
949
0
0
        $self->_open_table({});
950        Carp::croak(ref($self), ': select: not a Redis connection')
951
0
0
                unless $self->{'_redis'};
952
953
0
0
        $self->{'_redis'}->select(int($db_num));
954
955        # Clear cached data so next query re-slurps from the new database
956
0
0
        delete $self->{'data'};
957
0
0
        delete $self->{'_columns'};
958
0
0
        delete $self->{'_schema'};
959
960
0
0
        return $self;
961}
962
963# Open the database connection based on the specified type (e.g., SQLite, CSV).
964# Read the data into memory or establish a connection to the database file.
965# column_names allows the column names to be overridden on CSV files
966
967sub _open :Protected
968{
969        # Enforce that _open is only reachable from within this class hierarchy;
970        # caller() returns the calling package name as a plain string.
971        do { my $c = (caller)[0]; Carp::croak('Illegal Operation: _open may only be called within ', __PACKAGE__) unless $c && $c->isa(__PACKAGE__) };
972
973        my $self = shift;
974        my $params = Params::Get::get_params(undef, \@_);
975
976        $params->{'sep_char'} ||= $self->{'sep_char'} ? $self->{'sep_char'} : '!';
977        my $max_slurp_size = $params->{'max_slurp_size'} || $self->{'max_slurp_size'};
978
979        my $table = $self->{'table'} || ref($self);
980        $table =~ s/\A.*:://;
981
982        $self->_trace(ref($self), ": _open $table");
983
984        return if($self->{$table});
985
986        # Read in the database
987        my $dbh;
988
989        # DSN-based connection bypasses file detection entirely
990        if(my $dsn = $self->{'dsn'} || $defaults{'dsn'}) {
991                my $dialect = 'generic';
992                if    ($dsn =~ /\Adbi:SQLite:/i) { $dialect = 'sqlite'   }
993                elsif ($dsn =~ /\Adbi:Pg:/i)     { $dialect = 'postgres' }
994                elsif ($dsn =~ /\Adbi:mysql:/i)  { $dialect = 'mysql'    }
995                $self->{'_dialect'} = $dialect;
996
997                $dbh = DBI->connect(
998                        $dsn,
999                        $self->{'username'},
1000                        $self->{'password'},
1001                        { RaiseError => 1, AutoCommit => 1 },
1002                ) or Carp::croak(ref($self), ": cannot connect: $DBI::errstr");
1003
1004                if($dialect eq 'sqlite') {
1005                        $dbh->do('PRAGMA synchronous = OFF');
1006                        $dbh->do('PRAGMA cache_size = -4096');
1007                        $dbh->do('PRAGMA journal_mode = OFF');
1008                        $dbh->do('PRAGMA temp_store = MEMORY');
1009                        $dbh->do('PRAGMA mmap_size = 1048576');
1010                        $dbh->sqlite_busy_timeout(100000);
1011                }
1012
1013                $self->{'type'} = 'DBI';
1014                $self->{$table} = $dbh;
1015                $self->{'_updated'} = time();
1016                return $self;
1017        }
1018
1019        # Redis backend — connects to a Redis server and slurps hash keys matching
1020        # "tablename:*" into memory, mirroring the DBM::Deep slurp structure.
1021        # Lazily loads Redis::Fast (preferred) or Redis.
1022        if(my $db_url = $self->{'database'} || $defaults{'database'}) {
1023                # Parse redis://[userinfo@]host[:port][/db_index]
1024                my ($userinfo, $host, $port, $db_idx) =
1025                        $db_url =~ m{\Aredis://(?:([^@]*)@)?([^/:]*)?(?::(\d+))?(?:/(\d+))?\z}i;
1026                $host   //= 'localhost';
1027                $port   //= 6379;
1028                $db_idx //= 0;
1029                my $password;
1030                if(defined $userinfo) {
1031                        $password = ($userinfo =~ /:(.*)/) ? $1 : (length($userinfo) ? $userinfo : undef);
1032                }
1033
1034                unless($self->{'_redis'}) {
1035                        my $redis_class;
1036                        eval { require Redis::Fast; $redis_class = 'Redis::Fast' };
1037                        if($@) {
1038                                require Redis;
1039                                $redis_class = 'Redis';
1040                        }
1041                        $self->{'_redis'} = $redis_class->new(server => "$host:$port", reconnect => 2, every => 100);
1042                        $self->{'_redis'}->auth($password) if defined $password && length $password;
1043                }
1044                $self->{'_redis'}->select($db_idx) if $db_idx;
1045
1046                my $id        = $self->{'id'};
1047                # redis_key overrides table for the actual Redis key lookup so that
1048                # callers can pass an arbitrary key name (colons, hyphens, etc.) without
1049                # it needing to pass the SQL-identifier check on the table parameter.
1050                my $redis_key = $self->{'redis_key'} // $table;
1051                my @keys = $self->{'_redis'}->keys("${redis_key}:*");
1052
1053                if($self->{'no_entry'}) {
1054                        my @data;
1055                        for my $key (@keys) {
1056                                my %fields = $self->{'_redis'}->hgetall($key);
1057                                push @data, \%fields if %fields;
1058                        }
1059                        $self->{'data'} = @data ? \@data : undef;
1060                } else {
1061                        my %data;
1062                        for my $key (@keys) {
1063                                (my $entry = $key) =~ s/\A\Q${redis_key}\E://;
1064                                my %fields = $self->{'_redis'}->hgetall($key);
1065                                $data{$entry} = { $id => $entry, %fields } if %fields;
1066                        }
1067                        $self->{'data'} = %data ? \%data : undef;
1068                }
1069
1070                # Fallback: if no compound "keyname:entry" sub-keys were found, try
1071                # the key itself as a standalone value.  This supports any Redis data
1072                # structure (hash, list, set, sorted set, string) stored under a plain
1073                # key rather than the keyname:entry compound-key convention.
1074                unless(@keys) {
1075                        my $key_type = lc($self->{'_redis'}->type($redis_key));
1076                        if($key_type eq 'hash') {
1077                                my %fields = $self->{'_redis'}->hgetall($redis_key);
1078                                if(%fields) {
1079                                        $self->{'data'} = $self->{'no_entry'}
1080                                                ? [\%fields]
1081                                                : { $redis_key => { $id => $redis_key, %fields } };
1082                                }
1083                        } elsif($key_type eq 'list') {
1084                                my @items = $self->{'_redis'}->lrange($redis_key, 0, -1);
1085                                my $i = 0;
1086                                $self->{'data'} = [map { { index => $i++, value => $_ } } @items];
1087                        } elsif($key_type eq 'set') {
1088                                my @members = sort $self->{'_redis'}->smembers($redis_key);
1089                                $self->{'data'} = [map { { member => $_ } } @members];
1090                        } elsif($key_type eq 'zset') {
1091                                my @pairs = $self->{'_redis'}->zrange($redis_key, 0, -1, 'WITHSCORES');
1092                                my @rows;
1093                                while(@pairs) {
1094                                        my($m, $s) = splice @pairs, 0, 2;
1095                                        push @rows, { member => $m, score => $s };
1096                                }
1097                                $self->{'data'} = \@rows;
1098                        } elsif($key_type eq 'string') {
1099                                my $val = $self->{'_redis'}->get($redis_key);
1100                                $self->{'data'} = [{ value => $val }] if defined $val;
1101                        }
1102                        # 'none': key does not exist -- data stays undef; returns empty below.
1103                }
1104
1105                $self->{'type'}     = 'Redis';
1106                $self->{'_updated'} = time();
1107                return $self;
1108        }
1109
1110        # URL-based backend — handles both JSON and HTML table responses.
1111        # LWP::UserAgent::Cached is always required; JSON::MaybeXS and
1112        # HTML::TableExtract are loaded lazily based on the response Content-Type.
1113        if(my $url = $self->{'url'} || $defaults{'url'}) {
1114                require LWP::UserAgent::Cached;
1115
1116                my $ua = $self->{ua} // LWP::UserAgent::Cached->new(timeout => 30, agent => __PACKAGE__ . '/' . $VERSION);
1117                $ua->env_proxy(1);
1118                my $response = $ua->get($url);
1119                Carp::croak(ref($self), ": cannot fetch '$url': ", $response->status_line)
1120                        unless $response->is_success;
1121
1122                my $content_type = $response->content_type() // '';
1123                if($content_type =~ m{\bapplication/json\b}i || $url =~ /\.json(?:[?#]|\z)/i) {
1124                        # JSON URL backend — lazy-loads JSON::MaybeXS.
1125                        # Detects by Content-Type: application/json or a .json URL suffix.
1126                        require JSON::MaybeXS;
1127                        my $parsed = JSON::MaybeXS::decode_json($response->decoded_content);
1128                        my @data;
1129                        if(ref($parsed) eq 'ARRAY') {
1130                                @data = @{$parsed};
1131                        } elsif(ref($parsed) eq 'HASH') {
1132                                # Object keyed by primary-key value — inject id column into each row
1133                                my $id_col = $self->{'id'};
1134                                for my $k (sort keys %{$parsed}) {
1135                                        my $row = $parsed->{$k};
1136                                        if(ref($row) eq 'HASH') {
1137                                                push @data, { $id_col => $k, %{$row} };
1138                                        } else {
1139                                                push @data, { $id_col => $k, value => $row };
1140                                        }
1141                                }
1142                        } else {
1143                                Carp::croak(ref($self), ": JSON URL: unexpected top-level structure from '$url'");
1144                        }
1145                        if($self->{'no_entry'}) {
1146                                $self->{'data'} = @data ? \@data : undef;
1147                        } else {
1148                                $self->{'data'} = { map { $_->{$self->{'id'}} => $_ } @data };
1149                        }
1150                        $self->{'type'} = 'JSON';
1151                } else {
1152                        # HTML table backend — lazy-loads HTML::TableExtract.
1153                        require HTML::TableExtract;
1154
1155                        my $te = HTML::TableExtract->new();
1156                        $te->parse($response->decoded_content);
1157
1158                        my $tidx = $self->{'html_table_index'} // $defaults{'html_table_index'} // 0;
1159                        my @tables = $te->tables;
1160                        Carp::croak(ref($self), ": no HTML tables found at '$url'")
1161                                unless @tables;
1162                        Carp::croak(ref($self), ": html_table_index $tidx out of range (", scalar @tables, " tables) at '$url'")
1163                                if $tidx >= @tables;
1164
1165                        my @rows = $tables[$tidx]->rows;
1166                        Carp::croak(ref($self), ": empty HTML table at '$url'")
1167                                unless @rows;
1168
1169                        my @headers = map { defined($_) ? "$_" : '' } @{$rows[0]};
1170                        my $id = $self->{'id'};
1171
1172                        if($self->{'no_entry'}) {
1173                                my @data;
1174                                for my $i (1 .. $#rows) {
1175                                        my %row;
1176                                        @row{@headers} = map { defined($_) ? "$_" : undef } @{$rows[$i]};
1177                                        push @data, \%row;
1178                                }
1179                                $self->{'data'} = \@data;
1180                        } else {
1181                                my %data;
1182                                for my $i (1 .. $#rows) {
1183                                        my %row;
1184                                        @row{@headers} = map { defined($_) ? "$_" : undef } @{$rows[$i]};
1185                                        my $key = $row{$id};
1186                                        next unless defined $key;
1187                                        $data{$key} = \%row;
1188                                }
1189                                $self->{'data'} = \%data;
1190                        }
1191                        $self->{'type'} = 'HTML';
1192                }
1193
1194                $self->{'_updated'} = time();
1195                $self->_fixate($self->{'data'}) if $self->{'data'} && ref($self->{'data'}) eq 'HASH';
1196                $self->{$table} = undef;     # No DBI handle; all queries use the in-memory data path
1197                return $self;
1198        }
1199
1200        # Derive the filename stem from the class name, NOT from any $table override.
1201        # This allows table => 'Sheet2' to query a different worksheet within the same
1202        # file (e.g. test1.xlsx with a 'sheet2' worksheet) without needing an explicit
1203        # dbname. When dbname is set explicitly it always wins.
1204        my $class_stem = ref($self);
1205        $class_stem =~ s/\A.*:://;
1206        my $dbname = $self->{'dbname'} || $defaults{'dbname'} || $class_stem;
1207        # \A/\z (not ^/$) so a trailing newline cannot sneak past the $ anchor.
1208        Carp::croak(ref($self), ": unsafe dbname '$dbname'")
1209                unless $dbname =~ /\A[a-zA-Z0-9_.-]+\z/ && $dbname !~ /\.\./;
1210
1211        # When a remote host is given, fetch all candidate files into a local temp
1212        # directory via File::Slurp::Remote (SSH/SCP).  localhost / 127.0.0.1 / the
1213        # current machine's hostname are treated as local (no SSH, no temp dir).
1214        my $dir;
1215        if(my $host = $self->{'host'} || $defaults{'host'}) {
1216                if($self->_is_local_host($host)) {
1217                        $self->_debug("host '$host' is local; reading directory directly");
1218                        my $raw_dir = $self->{'directory'} || $defaults{'directory'};
1219                        Carp::croak(ref($self), ': no directory specified') unless $raw_dir;
1220                        $dir = Cwd::abs_path($raw_dir);
1221                } else {
1222                        require File::Slurp::Remote;
1223                        require POSIX;
1224                        my $remote_dir = $self->{'directory'} || $defaults{'directory'};
1225                        my $tmpdir_obj = File::Temp->newdir(CLEANUP => 1);
1226                        $self->{'_remote_tmpdir'} = $tmpdir_obj;     # auto-cleans on DESTROY
1227                        my $tmpdir = $tmpdir_obj->dirname();
1228                        my @probe_exts = qw(sql sqlite sqlite3 dbm deep db csv.gz db.gz psv tsv xls xlsx csv xml json);
1229
1230                        if(my $filename = $self->{'filename'} || $defaults{'filename'}) {
1231                                # Explicit filename given — one SSH fetch, no extension probing needed.
1232                                # Validate the same way _open() will when it processes the filename later.
1233                                Carp::croak(ref($self), ": unsafe filename '$filename'")
1234                                        unless $filename =~ /\A[a-zA-Z0-9_.-]+\z/ && $filename !~ /\.\./;
1235                                my $remote_file = "$remote_dir/$filename";
1236                                my $content = eval { scalar File::Slurp::Remote::read_remote_file($host, $remote_file) };
1237                                if(defined($content) && length($content)) {
1238                                        my $local = File::Spec->catfile($tmpdir, $filename);
1239                                        open(my $fh, '>', $local);
1240                                        binmode $fh;
1241                                        print $fh $content;
1242                                        close $fh;
1243                                        $self->_debug("fetched remote $host:$remote_file");
1244                                }
1245                        } else {
1246                                # No explicit filename — probe all candidate extensions.
1247                                # Fork one child per extension so all SSH calls run concurrently.
1248                                # Falls back to sequential for any extension whose fork() fails.
1249                                # Children MUST exit via POSIX::_exit(0) to skip Perl's cleanup
1250                                # phase; a normal exit(0) would run DESTROY on the inherited
1251                                # File::Temp::Dir, deleting the tmpdir while the parent still needs it.
1252                                my (@pids, @fallback_exts);
1253                                for my $ext (@probe_exts) {
1254                                        my $pid = eval { fork() };
1255                                        if(!defined $pid) {
1256                                                # fork() failed or unavailable — defer to sequential pass
1257                                                push @fallback_exts, $ext;
1258                                                next;
1259                                        }
1260                                        if($pid == 0) {
1261                                                # child: fetch one extension; eval guards against autodie croaks
1262                                                eval {
1263                                                        my $remote_file = "$remote_dir/$dbname.$ext";
1264                                                        my $content = File::Slurp::Remote::read_remote_file($host, $remote_file);
1265                                                        if(defined($content) && length($content)) {
1266                                                                my $local = File::Spec->catfile($tmpdir, "$dbname.$ext");
1267                                                                open(my $fh, '>', $local);
1268                                                                binmode $fh;
1269                                                                print $fh $content;
1270                                                                close $fh;
1271                                                        }
1272                                                };
1273                                                POSIX::_exit(0);
1274                                        }
1275                                        push @pids, $pid;
1276                                }
1277                                waitpid($_, 0) for @pids;
1278
1279                                # Sequential pass for any extensions that couldn't be forked
1280                                for my $ext (@fallback_exts) {
1281                                        my $remote_file = "$remote_dir/$dbname.$ext";
1282                                        my $content = eval { scalar File::Slurp::Remote::read_remote_file($host, $remote_file) };
1283                                        next unless defined($content) && length($content);
1284                                        my $local = File::Spec->catfile($tmpdir, "$dbname.$ext");
1285                                        open(my $fh, '>', $local);
1286                                        binmode $fh;
1287                                        print $fh $content;
1288                                        close $fh;
1289                                        $self->_debug("fetched remote $host:$remote_file");
1290                                }
1291                        }
1292                        $dir = $tmpdir;
1293                }
1294        } else {
1295                my $raw_dir = $self->{'directory'} || $defaults{'directory'};
1296                Carp::croak(ref($self), ': no directory specified') unless $raw_dir;
1297                $dir = Cwd::abs_path($raw_dir);
1298        }
1299        # Probe for SQLite files (.sql, .sqlite, .sqlite3)
1300        my $slurp_file;
1301        for my $ext (qw(sql sqlite sqlite3)) {
1302                my $candidate = File::Spec->catfile($dir, "$dbname.$ext");
1303                if(-r $candidate) { $slurp_file = $candidate; last }
1304        }
1305        $slurp_file //= File::Spec->catfile($dir, "$dbname.sql");
1306
1307        $self->_debug("_open: try to open $slurp_file");
1308
1309        # Probe for DBM::Deep files (.dbm or .deep) before the CSV/BerkeleyDB fallback.
1310        # Loaded lazily so the DBM::Deep module is not required for other backends.
1311        my $deep_file;
1312        for my $ext (qw(dbm deep)) {
1313                my $candidate = File::Spec->catfile($dir, "$dbname.$ext");
1314                if(-r $candidate) { $deep_file = $candidate; last }
1315        }
1316        # Also detect DBM::Deep files by magic bytes (covers .db files and arbitrary extensions).
1317        if(!$deep_file) {
1318                my $db_candidate = File::Spec->catfile($dir, "$dbname.db");
1319                $deep_file = $db_candidate if -r $db_candidate && $self->_is_deep_db($db_candidate);
1320        }
1321
1322        # Look at various places to find the file and derive the file type from the file's name
1323        if(-r $slurp_file) {
1324                # SQLite file
1325                require DBD::SQLite::Constants;
1326                $dbh = DBI->connect("dbi:SQLite:dbname=$slurp_file", undef, undef, {
1327                        sqlite_open_flags => DBD::SQLite::Constants::SQLITE_OPEN_READONLY(),
1328                });
1329        }
1330        if($dbh) {
1331                $dbh->do('PRAGMA synchronous = OFF');
1332                $dbh->do('PRAGMA cache_size = -4096');       # Use 4MB cache - negative = KB)
1333                $dbh->do('PRAGMA journal_mode = OFF');       # Read-only, no journal needed
1334                $dbh->do('PRAGMA temp_store = MEMORY');      # Store temp data in RAM
1335                $dbh->do('PRAGMA mmap_size = 1048576');      # Use 1MB memory-mapped I/O
1336                $dbh->sqlite_busy_timeout(100000);   # 10s
1337                $self->_debug("read in $table from SQLite $slurp_file");
1338                $self->{'type'} = 'DBI';
1339        } elsif($deep_file) {
1340                # DBM::Deep file (.dbm or .deep) — slurp the entire tied hash into a plain
1341                # Perl hash so all existing in-memory fast-paths work without modification.
1342                require DBM::Deep;
1343                my $deep = DBM::Deep->new({ file => $deep_file, read_only => 1 });
1344                my $id = $self->{'id'};
1345                if($self->{'no_entry'}) {
1346                        # Not keyed — produce an ordered arrayref of row hashrefs, same as CSV no_entry.
1347                        my @data;
1348                        for my $k (sort keys %{$deep}) {
1349                                my $row = $deep->{$k};
1350                                # Use reftype (not ref) so blessed DBM::Deep::Hash objects are recognised.
1351                                # Inject the outer key as the id column so criteria on that column work.
1352                                push @data, (Scalar::Util::reftype($row) // '') eq 'HASH'
1353                                        ? { $id => $k, %{$row} }
1354                                        : { $id => $k, value => $row };
1355                        }
1356                        $self->{'data'} = @data ? \@data : undef;
1357                } else {
1358                        # Keyed on the primary-key column (default: 'entry') for O(1) lookups.
1359                        # Each row hash must contain the id column (like CSV rows), so that
1360                        # selectall_arrayref and AUTOLOAD can access it by name.
1361                        my %data;
1362                        for my $k (keys %{$deep}) {
1363                                my $row = $deep->{$k};
1364                                $data{$k} = (Scalar::Util::reftype($row) // '') eq 'HASH'
1365                                        ? { $id => $k, %{$row} }
1366                                        : { $id => $k, value => $row };
1367                        }
1368                        $self->{'data'} = %data ? \%data : undef;
1369                }
1370                $slurp_file = $deep_file;
1371                $self->_debug("read in $table from DBM::Deep $deep_file");
1372                $self->{'type'} = 'Deep';
1373        } elsif($self->_is_berkeley_db(File::Spec->catfile($dir, "$dbname.db"))) {
1374                $self->_debug("$table is a BerkeleyDB file");
1375                $self->{'type'} = 'BerkeleyDB';
1376        } else {
1377                my $fin;
1378                # File::pfopen splits $path on ':' which breaks Windows drive letters
1379                # (C:\foo becomes ['C', '\foo']).  Since we always have a single directory
1380                # we use File::Spec->catfile directly — same behaviour, portable.
1381                my $gz_file;
1382                for my $ext (qw(csv.gz db.gz)) {
1383                        my $candidate = File::Spec->catfile($dir, "$dbname.$ext");
1384                        next unless -r $candidate;
1385                        open($fin, '<', $candidate);
1386                        $gz_file = $candidate;
1387                        last;
1388                }
1389                if($gz_file) {
1390                        require Gzip::Faster;
1391
1392                        close($fin);
1393                        $fin = File::Temp->new(SUFFIX => '.csv', UNLINK => 1, CLEANUP => 1);
1394                        print $fin Gzip::Faster::gunzip_file($gz_file);
1395                        $fin->flush();
1396                        $slurp_file = $fin->filename();
1397                        $self->{'_temp_fh'} = $fin;  # Keep object alive; auto-unlinks at DESTROY
1398                } else {
1399                        my $psv = File::Spec->catfile($dir, "$dbname.psv");
1400                        if(-r $psv) {
1401                                open($fin, '<', $psv);
1402                                # Pipe separated file
1403                                $slurp_file = $psv;
1404                                $params->{'sep_char'} = '|';
1405                        } else {
1406                                my $tsv = File::Spec->catfile($dir, "$dbname.tsv");
1407                                if(-r $tsv) {
1408                                        open($fin, '<', $tsv);
1409                                        # Tab separated file
1410                                        $slurp_file = $tsv;
1411                                        $params->{'sep_char'} = "\t";
1412                                } else {
1413                                        # CSV or BerkeleyDB-extension file
1414                                        for my $ext (qw(csv db)) {
1415                                                my $candidate = File::Spec->catfile($dir, "$dbname.$ext");
1416                                                next unless -r $candidate;
1417                                                open($fin, '<', $candidate);
1418                                                $slurp_file = $candidate;
1419                                                last;
1420                                        }
1421                                }
1422                        }
1423                }
1424                if(my $filename = $self->{'filename'} || $defaults{'filename'}) {
1425                        Carp::croak(ref($self), ": unsafe filename '$filename'")
1426                                unless $filename =~ /\A[a-zA-Z0-9_.-]+\z/ && $filename !~ /\.\./;
1427                        $self->_debug("Looking for $filename in $dir");
1428                        $slurp_file = File::Spec->catfile($dir, $filename);
1429                }
1430                if(defined($slurp_file) && (-r $slurp_file)) {
1431                        close($fin) if(defined($fin));
1432                        my $sep_char = $params->{'sep_char'};
1433
1434                        $self->_debug(__LINE__, ' of ', __PACKAGE__, ": slurp_file = $slurp_file, sep_char = $sep_char");
1435
1436                        if($params->{'column_names'}) {
1437                                $dbh = DBI->connect("dbi:CSV:db_name=$slurp_file", undef, undef,
1438                                        {
1439                                                csv_sep_char => $sep_char,
1440                                                csv_tables => {
1441                                                        $table => {
1442                                                                col_names => $params->{'column_names'},
1443                                                        },
1444                                                },
1445                                                f_dir      => $dir,
1446                                                RaiseError => 1,
1447                                                PrintError => 0
1448                                        }
1449                                );
1450                        } else {
1451                                $dbh = DBI->connect("dbi:CSV:db_name=$slurp_file", undef, undef, { csv_sep_char => $sep_char, f_dir => $dir, RaiseError => 1 });
1452                        }
1453                        $dbh->{'RaiseError'} = 1;
1454
1455                        $self->_debug("read in $table from CSV $slurp_file");
1456
1457                        $dbh->{csv_tables}->{$table} = {
1458                                allow_loose_quotes => 1,
1459                                blank_is_undef => 1,
1460                                empty_is_undef => 1,
1461                                binary => 1,
1462                                f_file => $slurp_file,
1463                                escape_char => '\\',
1464                                sep_char => $sep_char,
1465                                # Don't do this, causes "Bizarre copy of HASH
1466                                #       in scalar assignment in error_diag
1467                                #       RT121127
1468                                # auto_diag => 1,
1469                                auto_diag => 0,
1470                                # Don't do this, it causes "Attempt to free unreferenced scalar"
1471                                # callbacks => {
1472                                        # after_parse => sub {
1473                                                # my ($csv, @rows) = @_;
1474                                                # my @rc;
1475                                                # foreach my $row(@rows) {
1476                                                        # if($row->[0] !~ /^#/) {
1477                                                                # push @rc, $row;
1478                                                        # }
1479                                                # }
1480                                                # return @rc;
1481                                        # }
1482                                # }
1483                        };
1484
1485                        # Text::xSV::Slurp cannot override column names, so skip slurp when
1486                        # column_names is set — the DBI CSV connection will supply names instead.
1487                        if(((-s $slurp_file) <= $max_slurp_size) && !$params->{'column_names'}) {
1488                                if((-s $slurp_file) == 0) {
1489                                        # Empty file — mirror what the newline-only path stores so
1490                                        # fast-path query methods return 0/undef/[] instead of falling
1491                                        # through to DBI (which croaks on a 0-column table).
1492                                        $self->{'data'} = $self->{'no_entry'} ? undef : {};
1493                                } else {
1494                                        require Text::xSV::Slurp;
1495
1496                                        $self->_debug('slurp in');
1497
1498                                        my $dataref = Text::xSV::Slurp::xsv_slurp(
1499                                                shape => 'aoh',
1500                                                text_csv => {
1501                                                        sep_char => $sep_char,
1502                                                        allow_loose_quotes => 1,
1503                                                        blank_is_undef => 1,
1504                                                        empty_is_undef => 1,
1505                                                        binary => 1,
1506                                                        escape_char => '\\',
1507                                                },
1508                                                # string => \join('', grep(!/^\s*(#|$)/, <DATA>))
1509                                                file => $slurp_file
1510                                        );
1511
1512                                        # Filter out blank lines and comment rows (lines starting with #)
1513                                        # Two passes replaced with one: pre-compute id column to avoid N hash
1514                                # lookups per element, and combine both conditions into one grep.
1515                                my $id_col = $self->{'id'};
1516                                my @data = grep { defined($_->{$id_col}) && $_->{$id_col} !~ /\A\s*#/ } @{$dataref};
1517
1518                                        if($self->{'no_entry'}) {
1519                                                # Not keyed on a primary column — keep as ordered list.
1520                                                # Only store a reference when rows were found; an empty-array ref
1521                                                # is truthy, which would activate the in-memory fast-path and
1522                                                # silently return 0 results instead of falling through to SQL.
1523                                                $self->{'data'} = @data ? \@data : undef;
1524                                        } else {
1525                                                # Key the hash by $self->{'id'} for O(1) entry lookups
1526                                                $self->{'data'} = { map { $_->{$self->{'id'}} => $_ } @data };
1527                                        }
1528                                }
1529                        }
1530                        $self->{'type'} = 'CSV';
1531                } else {
1532                        my $xls_file = File::Spec->catfile($dir, "$dbname.xls");
1533                        if(-r $xls_file) {
1534                                # Old binary XLS format via DBD::Excel (Spreadsheet::ParseExcel).
1535                                # All queries go through DBI/SQL; no in-memory slurp path.
1536                                require DBD::Excel;
1537                                $dbh = DBI->connect("dbi:Excel:file=$xls_file", undef, undef, {
1538                                        RaiseError => 1,
1539                                        PrintError => 0,
1540                                }) or Carp::croak(ref($self), ": can't open $xls_file: $DBI::errstr");
1541                                $self->{'type'} = 'Excel';
1542                                $slurp_file = $xls_file;
1543                        } else {
1544                        my $xlsx_file = File::Spec->catfile($dir, "$dbname.xlsx");
1545                        if(-r $xlsx_file) {
1546                                # Modern OOXML format via Spreadsheet::ParseXLSX — slurped into
1547                                # an in-memory hash (keyed) or array (no_entry).  No DBI handle.
1548                                require Spreadsheet::ParseXLSX;
1549                                my $workbook = Spreadsheet::ParseXLSX->new()->parse($xlsx_file)
1550                                        or Carp::croak(ref($self), ": can't parse $xlsx_file");
1551                                my ($sheet) = grep { $_->get_name() eq $table } $workbook->worksheets();
1552                                $sheet //= ($workbook->worksheets())[0];
1553                                Carp::croak(ref($self), ": no worksheet in $xlsx_file") unless $sheet;
1554                                my ($row_min, $row_max) = $sheet->row_range();
1555                                my ($col_min, $col_max) = $sheet->col_range();
1556                                my @headers;
1557                                for my $c ($col_min .. $col_max) {
1558                                        my $cell = $sheet->get_cell($row_min, $c);
1559                                        push @headers, $cell ? $cell->value() : "col$c";
1560                                }
1561                                my @data;
1562                                for my $r ($row_min + 1 .. $row_max) {
1563                                        my %row;
1564                                        for my $c ($col_min .. $col_max) {
1565                                                my $cell = $sheet->get_cell($r, $c);
1566                                                $row{$headers[$c - $col_min]} = $cell ? $cell->value() : undef;
1567                                        }
1568                                        push @data, \%row;
1569                                }
1570                                if($self->{'no_entry'}) {
1571                                        $self->{'data'} = @data ? \@data : undef;
1572                                } else {
1573                                        $self->{'data'} = { map { $_->{$self->{'id'}} => $_ } @data };
1574                                }
1575                                $self->{'type'} = 'XLSX';
1576                                $slurp_file = $xlsx_file;
1577                        } else {
1578                                $slurp_file = File::Spec->catfile($dir, "$dbname.xml");
1579                                if(-r $slurp_file) {
1580                                        if((-s $slurp_file) <= $max_slurp_size) {
1581                                                require XML::Simple;
1582
1583                                                my $xml = XML::Simple::XMLin($slurp_file);
1584                                                my @keys = keys %{$xml};
1585                                                my $key = $keys[0];
1586                                                my @data;
1587                                                if(ref($xml->{$key}) eq 'ARRAY') {
1588                                                        @data = @{$xml->{$key}};
1589                                                } elsif(ref($xml) eq 'ARRAY') {
1590                                                        @data = @{$xml};
1591                                                } elsif((ref($xml) eq 'HASH') && !$self->{'no_entry'}) {
1592                                                        if(scalar(keys %{$xml}) == 1) {
1593                                                                if($xml->{$table}) {
1594                                                                        @data = $xml->{$table};
1595                                                                } else {
1596                                                                        Carp::croak('XML slurp: complex documents with an "entry" field are not yet supported');
1597                                                                }
1598                                                        } else {
1599                                                                Carp::croak('XML slurp: multi-key documents are not yet supported');
1600                                                        }
1601                                                } else {
1602                                                        Carp::croak('XML slurp: cannot handle ', ref($xml), ' structure');
1603                                                }
1604                                                if($self->{'no_entry'}) {
1605                                                        # Not keyed, will need to scan each entry
1606                                                        my $i = 0;
1607                                                        foreach my $d(@data) {
1608                                                                $self->{'data'}->{$i++} = $d;
1609                                                        }
1610                                                } else {
1611                                                        # keyed on the $self->{'id'} (default: "entry") column
1612                                                        foreach my $d(@data) {
1613                                                                $self->{'data'}->{$d->{$self->{'id'}}} = $d;
1614                                                        }
1615                                                }
1616                                        } else {
1617                                                $dbh = DBI->connect('dbi:XMLSimple(RaiseError=>1):');
1618                                                $dbh->{'RaiseError'} = 1;
1619                                                $self->_debug("read in $table from XML $slurp_file");
1620                                                $dbh->func($table, 'XML', $slurp_file, 'xmlsimple_import');
1621                                        }
1622                                } else {
1623                                        my $json_file = File::Spec->catfile($dir, "$dbname.json");
1624                                        if(-r $json_file) {
1625                                                if((-s $json_file) == 0) {
1626                                                        $self->{'data'} = $self->{'no_entry'} ? undef : {};
1627                                                } else {
1628                                                        require JSON::MaybeXS;
1629
1630                                                        open(my $jfh, '<', $json_file);
1631                                                        local $/;
1632                                                        my $raw = <$jfh>;
1633                                                        close $jfh;
1634                                                        if(!defined($raw) || $raw =~ /\A\s*\z/) {
1635                                                                # Whitespace-only — treat as empty, same as zero-byte
1636                                                                $self->{'data'} = $self->{'no_entry'} ? undef : {};
1637                                                        } else {
1638                                                                my $parsed = JSON::MaybeXS::decode_json($raw);
1639                                                                my @data;
1640                                                                if(ref($parsed) eq 'ARRAY') {
1641                                                                        @data = @{$parsed};
1642                                                                } elsif(ref($parsed) eq 'HASH') {
1643                                                                        # Hash of id => row — inject id column into each row
1644                                                                        my $id_col = $self->{'id'};
1645                                                                        for my $k (sort keys %{$parsed}) {
1646                                                                                my $row = $parsed->{$k};
1647                                                                                if(ref($row) eq 'HASH') {
1648                                                                                        push @data, { $id_col => $k, %{$row} };
1649                                                                                } else {
1650                                                                                        push @data, { $id_col => $k, value => $row };
1651                                                                                }
1652                                                                        }
1653                                                                } else {
1654                                                                        Carp::croak(ref($self), ": JSON slurp: unexpected top-level structure in $json_file");
1655                                                                }
1656                                                                if($self->{'no_entry'}) {
1657                                                                        $self->{'data'} = @data ? \@data : undef;
1658                                                                } else {
1659                                                                        $self->{'data'} = { map { $_->{$self->{'id'}} => $_ } @data };
1660                                                                }
1661                                                        }
1662                                                }
1663                                                $slurp_file = $json_file;
1664                                                $self->_debug("read in $table from JSON $json_file");
1665                                                $self->{'type'} = 'JSON';
1666                                        } else {
1667                                        # throw Error(-file => "$dir/$table");
1668                                        $self->_fatal("Can't find a file called '$dbname' for the table $table in $dir");
1669                                        }
1670                                }
1671                                $self->{'type'} //= 'XML';
1672                        }
1673                }       # end: xlsx else (xml path)
1674                }       # end: xls else
1675        }
1676
1677        # ref() must be called on the variable, not on the result of 'eq'
1678        $self->_fixate($self->{'data'}) if($self->{'data'} && (ref($self->{'data'}) eq 'HASH'));
1679
1680        $self->{$table} = $dbh;
1681        my @statb = stat($slurp_file);
1682        $self->{'_updated'} = $statb[9];
1683
1684        return $self;
1685
51
51
51
249
70
816
}
1686
1687# Project columns and/or deduplicate an arrayref of row hashrefs.
1688# $rows     - arrayref of hashrefs
1689# $cols     - arrayref of column names to keep, or undef for all columns
1690# $distinct - true to remove duplicate rows (after projection)
1691# Returns a new arrayref; input row hashrefs are not modified.
1692sub _apply_distinct {
1693
5
26
        my ($rows, $cols, $distinct) = @_;
1694
5
7
        return $rows unless defined($cols) || $distinct;
1695
5
5
7
6
        return $rows unless defined($rows) && @{$rows};
1696
1697
5
3
        my (@out, %seen);
1698
5
5
2
5
        for my $row (@{$rows}) {
1699
23
13
                my $r;
1700
23
20
                if(defined $cols) {
1701                        # Use exists() guard: fixate() locks all keys in slurp hashrefs;
1702                        # accessing a missing key on a locked hash throws an exception.
1703
23
33
23
7
41
36
                        $r = { map { $_ => (exists($row->{$_}) ? $row->{$_} : undef) } @{$cols} };
1704                } else {
1705
0
0
                        $r = $row;
1706                }
1707
23
19
                if($distinct) {
1708
8
13
8
7
15
7
                        my $key = join("\x00", map { defined($r->{$_}) ? $r->{$_} : "\x01" } sort keys %{$r});
1709
8
12
                        next if $seen{$key}++;
1710                }
1711
21
16
                push @out, $r;
1712        }
1713
5
8
        return \@out;
1714}
1715
1716 - 1818
=head2 selectall_arrayref

Returns a reference to an array of hash references for every row that
matches the given criteria, or C<undef> when there are no matches.

    my $rows = $db->selectall_arrayref();                    # all rows
    my $rows = $db->selectall_arrayref(status => 'active');  # exact match
    my $rows = $db->selectall_arrayref(score => { '>' => 8 });  # operator

The full criteria syntax is described in L</QUERY CRITERIA>.

Pass a C<join> key to combine with another table:

    my $rows = $db->selectall_arrayref(
        dept_name => 'Engineering',
        join      => { table => 'dept', on => 'e.dept_id = dept.id' },
    );

Pass C<limit =E<gt> N> and/or C<offset =E<gt> M> for pagination:

    my $page = $db->selectall_arrayref(status => 'active', limit => 10, offset => 20);

Both values must be non-negative integers; invalid values are ignored with a
C<carp> warning.  When C<offset> is given without C<limit> the SQL backend
uses C<LIMIT -1> on SQLite (meaning "no upper bound") so the C<OFFSET> clause
is legal.

Pass C<sort_by =E<gt> 'col'> (ascending) or C<sort_by =E<gt> ['col', 'DESC']>
to request a specific sort column and direction instead of the default primary-key
ordering:

    my $rows = $db->selectall_arrayref(sort_by => 'name');
    my $rows = $db->selectall_arrayref(sort_by => ['score', 'DESC']);

The column name is validated against the same identifier rules as all other
column parameters.  An unsafe name or an unrecognised direction (anything other
than C<ASC> or C<DESC>, case-insensitive) is ignored with a C<carp> warning and
the default sort order is used instead.  Sorting is applied before
C<limit>/C<offset> pagination.

Pass C<distinct =E<gt> 1> to return only unique rows (deduplication):

    my $rows = $db->selectall_arrayref(distinct => 1);
    my $rows = $db->selectall_arrayref(distinct => 1, columns => [qw(status dept)]);

Pass C<columns =E<gt> \@cols> to restrict which columns appear in the returned
rows.  Column names are validated against the same identifier rules as other
parameters; invalid names are dropped with a C<carp> warning.

On SQL-capable backends (SQLite, PostgreSQL, MySQL, DBD::CSV E<ge> 0.64)
C<distinct =E<gt> 1> emits C<SELECT DISTINCT>.  On the slurp (in-memory) path
C<distinct> without C<columns> is a no-op because each row is already unique.

B<Order of operations:> sort E<rarr> offset E<rarr> limit E<rarr> C<_apply_distinct>.
This means C<< limit => N, distinct => 1 >> deduplicates the already-limited
slice, not the full result set.  If you need N distinct rows from a larger set,
omit C<limit> and slice the returned arrayref yourself.

Results are returned in the cache (if configured) and the returned array
reference is made read-only unless C<no_fixate> was set.

B<Note:> this always returns all matching rows.  Use L</selectall_array>
in scalar context, or C<< $db->query->limit(1)->all() >>, to fetch just one row.

=head3 PSEUDOCODE

    1. Parse criteria; extract and build any JOIN clause.
       Extract distinct, columns, limit, offset, sort_by.
    2. If data is slurped AND no joins AND criteria are simple:
       a. No criteria -> return all rows as arrayref.
       b. entry-only lookup -> return [$data{entry}].
       c. Otherwise -> scan rows in-memory with _match_criterion.
       In all slurp cases: sort by sort_by column (if given), then apply
       offset/limit, then _apply_distinct (columns projection + dedup).
    3. Otherwise build SQL: SELECT [DISTINCT] [columns|*] FROM table
       [JOIN] [WHERE] ORDER BY sort_by [else id] [LIMIT] [OFFSET].
       All DBI backends (including DBD::CSV >= 0.64) use native SELECT DISTINCT.
    4. Check cache; return cached arrayref on HIT.
    5. prepare_cached + execute; fetch all rows.
    6. Store result in cache; fixate the array; return arrayref.

=head3 API SPECIFICATION

=head4 Arguments

    %criteria   # optional key-value pairs or operator hashrefs
    join        => HashRef | ArrayRef  # optional join spec(s)
    sort_by     => Str | ArrayRef      # optional: 'col' or ['col','DESC']
    limit       => Int                 # optional: max rows
    offset      => Int                 # optional: rows to skip
    distinct    => Bool                # optional: deduplicate rows
    columns     => ArrayRef[Str]       # optional: restrict output columns
    no_fixate   => Bool                # optional: skip read-only locking
    table       => Str                 # optional: override table name

=head4 Returns

    ArrayRef[HashRef] | undef

An arrayref of row hashrefs on success.  Returns C<undef> when no
rows match.  Each hashref is read-only unless C<no_fixate> was set.

=cut
1819
1820sub selectall_arrayref {
1821
402
131807
        my $self = shift;
1822
1823        # Fire _open() first so $self->{'berkeley'} is known before we parse @_.
1824        # BerkeleyDB param parsing must use get_params(undef, \@_) so that
1825        # key-value pairs like (join => {...}) are not mangled by the positional
1826        # 'entry' mapping that non-BerkeleyDB paths use.
1827
402
710
        $self->_open_table({});
1828
1829
396
382
        my $params;
1830
1831
396
518
        if($self->{'berkeley'}) {
1832
12
20
                $params = Params::Get::get_params(undef, \@_) // {};
1833
12
152
                my $bl       = delete $params->{'limit'};
1834
12
12
                my $bo       = delete $params->{'offset'};
1835
12
10
                my $bdist    = delete $params->{'distinct'};
1836
12
11
                my $bcols    = delete $params->{'columns'};
1837
12
18
                my ($bsc, $bsd) = _parse_sort_by(delete($params->{'sort_by'}), 'selectall_arrayref');
1838
12
21
                if(defined($bl) && $bl !~ /\A\d+\z/) {
1839
0
0
                        Carp::carp('selectall_arrayref: limit must be a non-negative integer, ignoring');
1840
0
0
                        undef $bl;
1841                }
1842
12
18
                if(defined($bo) && $bo !~ /\A\d+\z/) {
1843
0
0
                        Carp::carp('selectall_arrayref: offset must be a non-negative integer, ignoring');
1844
0
0
                        undef $bo;
1845                }
1846
12
23
                $params = $self->_merge_base_criteria($params);
1847
12
24
                my $rows = $self->_scan_berkeley($params);
1848
10
11
                if(defined $bsc) {
1849
0
0
                        if($bsd eq 'DESC') {
1850
0
0
0
0
0
0
0
0
                                @{$rows} = sort { ($b->{$bsc} // '') cmp ($a->{$bsc} // '') } @{$rows};
1851                        } else {
1852
0
0
0
0
0
0
0
0
                                @{$rows} = sort { ($a->{$bsc} // '') cmp ($b->{$bsc} // '') } @{$rows};
1853                        }
1854                }
1855
10
23
                if(defined($bl) || defined($bo)) {
1856
0
0
0
0
                        splice(@{$rows}, 0, int($bo)) if $bo;
1857
0
0
0
0
                        splice(@{$rows}, int($bl))    if defined $bl;
1858                }
1859
10
19
                $rows = _apply_distinct($rows, $bcols, $bdist) if $bcols || $bdist;
1860
10
17
                return set_return($rows, { type => 'arrayref' });
1861        }
1862
1863
384
555
        if($self->{'no_entry'}) {
1864
173
310
                $params = Params::Get::get_params(undef, \@_);
1865        } elsif(scalar(@_)) {
1866
85
139
                $params = Params::Get::get_params('entry', @_);
1867        }
1868
1869
384
3471
        my $table = $self->_open_table($params);
1870
1871
381
609
        $params //= {};
1872
1873
381
343
        my $join_clause = '';
1874
381
508
        if(my $join_spec = delete $params->{'join'}) {
1875
38
53
                $join_clause = $self->_build_joins($join_spec);
1876        }
1877
1878
364
324
        my $limit  = delete $params->{'limit'};
1879
364
326
        my $offset = delete $params->{'offset'};
1880
364
443
        if(defined $limit) {
1881
11
79
                if($limit !~ /\A\d+\z/) {
1882
4
27
                        Carp::carp('selectall_arrayref: limit must be a non-negative integer, ignoring');
1883
4
593
                        undef $limit;
1884                } else {
1885
7
7
                        $limit = int($limit);
1886                }
1887        }
1888
364
404
        if(defined $offset) {
1889
4
9
                if($offset !~ /\A\d+\z/) {
1890
1
5
                        Carp::carp('selectall_arrayref: offset must be a non-negative integer, ignoring');
1891
1
93
                        undef $offset;
1892                } else {
1893
3
3
                        $offset = int($offset);
1894                }
1895        }
1896
1897
364
550
        my ($sort_col, $sort_dir) = _parse_sort_by(delete($params->{'sort_by'}), 'selectall_arrayref');
1898
1899
364
351
        my $distinct = delete $params->{'distinct'};
1900
364
282
        my $columns  = delete $params->{'columns'};
1901
364
347
        if(defined $columns) {
1902
9
10
                if(ref($columns) ne 'ARRAY') {
1903
0
0
                        Carp::carp('selectall_arrayref: columns must be an arrayref, ignoring');
1904
0
0
                        undef $columns;
1905                } else {
1906
9
9
                        my @valid;
1907
9
9
2
9
                        for my $c (@{$columns}) {
1908
14
62
                                if(defined($c) && $c =~ $SAFE_QUALIFIED) {
1909
13
15
                                        push @valid, $c;
1910                                } else {
1911
1
9
                                        Carp::carp("selectall_arrayref: unsafe column name '$c', ignoring");
1912                                }
1913                        }
1914
9
154
                        $columns = @valid ? \@valid : undef;
1915                }
1916        }
1917
1918
364
635
        $params = $self->_merge_base_criteria($params);
1919
1920
364
1025
        if(!$join_clause && $self->{'data'} && !$self->_has_complex_criteria($params)) {
1921
183
183
132
247
                if(scalar(keys %{$params}) == 0) {
1922
124
232
                        $self->_trace("$table: selectall_arrayref fast track return");
1923
124
2831
                        my @rc;
1924
124
218
                        if(ref($self->{'data'}) eq 'HASH') {
1925
120
120
116
228
                                $self->_debug("$table: returning ", scalar keys %{$self->{'data'}}, ' entries');
1926
120
120
2168
196
                                if(scalar keys %{$self->{'data'}} <= 10) {
1927
120
120
120
107
315
300
                                        $self->_debug(do { require Data::Dumper; Data::Dumper::Dumper($self->{'data'}) });
1928                                }
1929
120
120
2408
186
                                @rc = values %{$self->{'data'}};
1930                        } else {
1931
4
4
4
6
                                @rc = @{$self->{'data'}};
1932                        }
1933
124
163
                        if(defined $sort_col) {
1934
4
4
                                if($sort_dir eq 'DESC') {
1935
1
4
2
12
                                        @rc = sort { ($b->{$sort_col} // '') cmp ($a->{$sort_col} // '') } @rc;
1936                                } else {
1937
3
12
4
18
                                        @rc = sort { ($a->{$sort_col} // '') cmp ($b->{$sort_col} // '') } @rc;
1938                                }
1939                        }
1940
124
291
                        if(defined($offset) || defined($limit)) {
1941
8
8
                                splice(@rc, 0, $offset) if $offset;
1942
8
13
                                splice(@rc, $limit)     if defined $limit;
1943                        }
1944
124
4
166
7
                        @rc = @{ _apply_distinct(\@rc, $columns, $columns ? $distinct : undef) }
1945                                if $columns;
1946
124
305
                        return set_return(\@rc, { type => 'arrayref' });
1947
59
250
                } elsif((scalar(keys %{$params}) == 1) && defined($params->{'entry'}) && !$self->{'no_entry'}) {
1948                        # exists() guard: fixate() locks all keys in the slurp hash; return []
1949                        # (not [undef]) when the key is missing so callers get an empty result
1950                        return set_return([], { type => 'arrayref' })
1951
20
54
                                unless exists($self->{'data'}->{$params->{'entry'}});
1952
11
21
                        my @rc = ($self->{'data'}->{$params->{'entry'}});
1953
11
21
                        if(defined $sort_col) {
1954
0
0
                                if($sort_dir eq 'DESC') {
1955
0
0
0
0
                                        @rc = sort { ($b->{$sort_col} // '') cmp ($a->{$sort_col} // '') } @rc;
1956                                } else {
1957
0
0
0
0
                                        @rc = sort { ($a->{$sort_col} // '') cmp ($b->{$sort_col} // '') } @rc;
1958                                }
1959                        }
1960
11
29
                        if(defined($offset) || defined($limit)) {
1961
0
0
                                splice(@rc, 0, $offset) if $offset;
1962
0
0
                                splice(@rc, $limit)     if defined $limit;
1963                        }
1964
11
0
18
0
                        @rc = @{ _apply_distinct(\@rc, $columns, $columns ? $distinct : undef) }
1965                                if $columns;
1966
11
30
                        return set_return(\@rc, { type => 'arrayref' });
1967                } elsif(ref($self->{'data'}) eq 'HASH') {
1968                        # Scan in-memory hash for simple column criteria without touching DBI.
1969                        # fixate() locks hash keys, so use exists() to avoid throwing on unknown columns.
1970
35
72
                        $self->_debug("$table: selectall_arrayref in-memory scan with criteria");
1971                        # Pre-compute param keys once — avoids re-running keys() inside the inner
1972                        # closure on every row iteration (N hash-key extractions → 1).
1973
35
35
947
48
                        my @param_keys = keys %{$params};
1974                        my @rc = grep {
1975
135
105
                                my $row = $_;
1976
138
239
                                all { $self->_match_criterion(exists($row->{$_}) ? $row->{$_} : undef, $params->{$_}, $_) } @param_keys
1977
35
135
35
38
201
59
                        } values %{$self->{'data'}};
1978
35
43
                        if(defined $sort_col) {
1979
0
0
                                if($sort_dir eq 'DESC') {
1980
0
0
0
0
                                        @rc = sort { ($b->{$sort_col} // '') cmp ($a->{$sort_col} // '') } @rc;
1981                                } else {
1982
0
0
0
0
                                        @rc = sort { ($a->{$sort_col} // '') cmp ($b->{$sort_col} // '') } @rc;
1983                                }
1984                        }
1985
35
70
                        if(defined($offset) || defined($limit)) {
1986
0
0
                                splice(@rc, 0, $offset) if $offset;
1987
0
0
                                splice(@rc, $limit)     if defined $limit;
1988                        }
1989
35
1
68
1
                        @rc = @{ _apply_distinct(\@rc, $columns, $distinct) } if $columns || $distinct;
1990
35
81
                        return set_return(\@rc, { type => 'arrayref' });
1991                } elsif(ref($self->{'data'}) eq 'ARRAY' && !$self->{$table}) {
1992                        # In-memory scan for array-backed no_entry stores (JSON, XLSX, HTML URL)
1993                        # where there is no DBI handle.  Mirrors the equivalent path in count().
1994
1
2
                        $self->_debug("$table: selectall_arrayref in-memory array scan with criteria");
1995
1
1
27
2
                        my @param_keys = keys %{$params};
1996                        my @rc = grep {
1997
3
2
                                my $row = $_;
1998
3
22
                                all { $self->_match_criterion(exists($row->{$_}) ? $row->{$_} : undef, $params->{$_}, $_) } @param_keys
1999
1
3
1
1
8
2
                        } @{$self->{'data'}};
2000
1
2
                        if(defined $sort_col) {
2001
0
0
                                if($sort_dir eq 'DESC') {
2002
0
0
0
0
                                        @rc = sort { ($b->{$sort_col} // '') cmp ($a->{$sort_col} // '') } @rc;
2003                                } else {
2004
0
0
0
0
                                        @rc = sort { ($a->{$sort_col} // '') cmp ($b->{$sort_col} // '') } @rc;
2005                                }
2006                        }
2007
1
3
                        if(defined($offset) || defined($limit)) {
2008
0
0
                                splice(@rc, 0, $offset) if $offset;
2009
0
0
                                splice(@rc, $limit)     if defined $limit;
2010                        }
2011
1
0
2
0
                        @rc = @{ _apply_distinct(\@rc, $columns, $distinct) } if $columns || $distinct;
2012
1
3
                        return set_return(\@rc, { type => 'arrayref' });
2013                }
2014        }
2015
2016        # Redis backend: when _open found no data (key doesn't exist or is empty),
2017        # avoid crashing on the DBI path which has no handle for a Redis connection.
2018        return set_return([], { type => 'arrayref' })
2019
184
325
                if ($self->{'type'} // '') eq 'Redis';
2020
2021
184
300
        my ($where, $wargs) = $self->_build_where($params);
2022
173
173
141
156
        my @query_args = @{$wargs};
2023
2024
173
123
        my $select_expr;
2025
173
213
        if($distinct) {
2026
4
4
                if($columns) {
2027
3
3
2
5
                        $select_expr = 'SELECT DISTINCT ' . join(', ', @{$columns});
2028                } else {
2029
1
1
                        $select_expr = 'SELECT DISTINCT *';
2030                }
2031        } elsif($columns) {
2032
1
1
1
1
                $select_expr = 'SELECT ' . join(', ', @{$columns});
2033        } else {
2034
168
131
                $select_expr = 'SELECT *';
2035        }
2036
173
177
        my $query = "$select_expr FROM $table";
2037
173
191
        $query .= " $join_clause" if $join_clause;
2038
173
349
        if($join_clause) {
2039
20
22
                $query .= " WHERE $where" if $where;
2040        } elsif(($self->{'type'} eq 'CSV') && !$self->{'no_entry'}) {
2041
17
18
                my $id = $self->{'id'};
2042
17
20
                $query .= " WHERE $id IS NOT NULL AND $id NOT LIKE '#%'";
2043
17
23
                $query .= " AND ($where)" if $where;
2044        } else {
2045
136
147
                $query .= " WHERE $where" if $where;
2046        }
2047
173
289
        if(defined $sort_col) {
2048
0
0
                $query .= " ORDER BY $sort_col $sort_dir";
2049        } elsif(!$self->{'no_entry'}) {
2050
31
39
                $query .= ' ORDER BY ' . $self->{'id'};
2051        }
2052
173
220
        if(defined($limit)) {
2053
0
0
                $query .= ' LIMIT ?';
2054
0
0
                push @query_args, $limit;
2055        } elsif(defined($offset)) {
2056                # SQLite requires a LIMIT clause when OFFSET is present; -1 means no limit
2057
0
0
                my $dbh = $self->{$table};
2058                $query .= ' LIMIT -1'
2059                        if ($dbh && (($dbh->{Driver}{Name} // '') eq 'SQLite'))
2060
0
0
                        || (($self->{'_dialect'} // '') eq 'sqlite');
2061        }
2062
173
183
        if(defined($offset)) {
2063
0
0
                $query .= ' OFFSET ?';
2064
0
0
                push @query_args, $offset;
2065        }
2066
2067
173
193
        if(defined($query_args[0])) {
2068
87
202
                $self->_debug("selectall_arrayref $query: ", join(', ', @query_args));
2069        } else {
2070
86
166
                $self->_debug("selectall_arrayref $query");
2071        }
2072
2073
173
5084
        my $key;
2074        my $c;
2075
173
223
        if($c = $self->{cache}) {
2076
20
33
                $key = ref($self) . "::$query array";
2077
20
22
                if(defined($query_args[0])) {
2078
2
3
                        $key .= ' ' . join(', ', @query_args);
2079                }
2080
20
34
                $self->_debug("cache key = '$key'");
2081
20
462
                if(my $rc = $c->get($key)) {
2082
10
834
                        $self->_debug('cache HIT');
2083
10
216
                        return $rc;     # We stored a ref to the array
2084
2085                        # This use of a temporary variable is to avoid
2086                        #       "Implicit scalar context for array in return"
2087                        # my @rc = @{$rc};
2088                        # return @rc;
2089                }
2090
10
674
                $self->_debug('cache MISS');
2091        } else {
2092
153
149
                $self->_debug('cache not used');
2093        }
2094
2095
163
4020
        if(my $sth = $self->{$table}->prepare_cached($query)) {
2096
160
58782
                $sth->execute(@query_args) || croak("$query: @query_args");
2097
2098
154
46269
                my $rc;
2099
154
1383
                while(my $href = $sth->fetchrow_hashref()) {
2100
400
400
3171
1609
                        push @{$rc}, $href;
2101                }
2102
2103
154
983
                $c->set($key, $rc, $self->{'cache_duration'}) if $c;
2104
2105
154
2034
                if($rc && !$self->{'no_fixate'}) {
2106
138
313
                        $self->_fixate($rc);
2107                }
2108
2109
154
22076
                return $rc;
2110        }
2111
0
0
        $self->_warn("selectall_arrayref failure on $query: @query_args");
2112
0
0
        croak("$query: @query_args");
2113}
2114
2115 - 2120
=head2 selectall_hashref

Deprecated alias for L</selectall_arrayref>.  Use C<selectall_arrayref> in
new code.

=cut
2121
2122sub selectall_hashref
2123{
2124
9
1390
        my $self = shift;
2125
9
33
        return $self->selectall_arrayref(@_);
2126}
2127
2128 - 2173
=head2 each_row

    $db->each_row(\&callback);
    $db->each_row(\&callback, status => 'active');
    $db->each_row(\&callback, sort_by => 'name', limit => 100, offset => 20);

Iterates over matching rows one at a time, calling C<\&callback> once per
row with the row hashref as the sole argument.  Uses B<constant memory> on
the SQL path: rows are fetched from the database one at a time via
C<fetchrow_hashref> without materialising the full result array.  On the
slurp/in-memory path data is already in RAM, so memory use is equivalent to
L</selectall_arrayref>.

Accepts the same criteria, C<join>, C<sort_by>, C<limit>, and C<offset>
parameters as L</selectall_arrayref>.

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

Exceptions raised inside C<\&callback> abort iteration and propagate to
the caller; the DBI statement handle is left in a valid state (C<finish()>
is called on exception).

B<Note:> rows on the SQL path are not fixated (made read-only) because they
are discarded after each callback invocation.  Slurp-path rows are already
fixated from the initial load.

=head3 API SPECIFICATION

=head4 Arguments

    CodeRef     # required: callback receiving one row hashref per call
    %criteria   # optional: same criteria syntax as selectall_arrayref
    join        => HashRef | ArrayRef  # optional join spec(s)
    sort_by     => Str | ArrayRef      # optional sort column
    limit       => Int                 # optional: max rows to visit
    offset      => Int                 # optional: rows to skip

=head4 Returns

    Int (>= 0)

The number of rows visited (i.e. the number of times the callback
was called).  Any exception thrown inside the callback is re-thrown
after the DBI statement handle is properly finished.

=cut
2174
2175sub each_row
2176{
2177
66
16373
        my $self = shift;
2178
66
61
        my $callback = shift;
2179
2180
66
191
        Carp::croak(ref($self), ': each_row: callback must be a code reference')
2181                unless ref($callback) eq 'CODE';
2182
2183
58
97
        $self->_open_table({});
2184
2185
58
53
        my $params;
2186
2187
58
95
        if($self->{'berkeley'}) {
2188
0
0
                $params = Params::Get::get_params(undef, \@_) // {};
2189
0
0
                my $bl = delete $params->{'limit'};
2190
0
0
                my $bo = delete $params->{'offset'};
2191
0
0
                my ($bsc, $bsd) = _parse_sort_by(delete($params->{'sort_by'}), 'each_row');
2192
0
0
                if(defined($bl) && $bl !~ /\A\d+\z/) {
2193
0
0
                        Carp::carp('each_row: limit must be a non-negative integer, ignoring');
2194
0
0
                        undef $bl;
2195                }
2196
0
0
                if(defined($bo) && $bo !~ /\A\d+\z/) {
2197
0
0
                        Carp::carp('each_row: offset must be a non-negative integer, ignoring');
2198
0
0
                        undef $bo;
2199                }
2200
0
0
                $params = $self->_merge_base_criteria($params);
2201
0
0
                my $rows = $self->_scan_berkeley($params);
2202
0
0
                if(defined $bsc) {
2203
0
0
                        if($bsd eq 'DESC') {
2204
0
0
0
0
0
0
0
0
                                @{$rows} = sort { ($b->{$bsc} // '') cmp ($a->{$bsc} // '') } @{$rows};
2205                        } else {
2206
0
0
0
0
0
0
0
0
                                @{$rows} = sort { ($a->{$bsc} // '') cmp ($b->{$bsc} // '') } @{$rows};
2207                        }
2208                }
2209
0
0
                if(defined($bl) || defined($bo)) {
2210
0
0
0
0
                        splice(@{$rows}, 0, int($bo)) if $bo;
2211
0
0
0
0
                        splice(@{$rows}, int($bl))    if defined $bl;
2212                }
2213
0
0
                my $n = 0;
2214
0
0
0
0
                for my $row (@{$rows}) {
2215
0
0
                        local $_;
2216
0
0
                        $callback->($row);
2217
0
0
                        $n++;
2218                }
2219
0
0
                return $n;
2220        }
2221
2222
58
104
        if($self->{'no_entry'}) {
2223
7
15
                $params = Params::Get::get_params(undef, \@_);
2224        } elsif(scalar(@_)) {
2225
19
31
                $params = Params::Get::get_params('entry', @_);
2226        }
2227
2228
58
397
        my $table = $self->_open_table($params);
2229
2230
58
130
        $params //= {};
2231
2232
58
46
        my $join_clause = '';
2233
58
104
        if(my $join_spec = delete $params->{'join'}) {
2234
0
0
                $join_clause = $self->_build_joins($join_spec);
2235        }
2236
2237
58
81
        my $limit  = delete $params->{'limit'};
2238
58
58
        my $offset = delete $params->{'offset'};
2239
58
64
        if(defined $limit) {
2240
6
20
                if($limit !~ /\A\d+\z/) {
2241
0
0
                        Carp::carp('each_row: limit must be a non-negative integer, ignoring');
2242
0
0
                        undef $limit;
2243                } else {
2244
6
7
                        $limit = int($limit);
2245                }
2246        }
2247
58
64
        if(defined $offset) {
2248
2
4
                if($offset !~ /\A\d+\z/) {
2249
0
0
                        Carp::carp('each_row: offset must be a non-negative integer, ignoring');
2250
0
0
                        undef $offset;
2251                } else {
2252
2
3
                        $offset = int($offset);
2253                }
2254        }
2255
2256
58
92
        my ($sort_col, $sort_dir) = _parse_sort_by(delete($params->{'sort_by'}), 'each_row');
2257
58
108
        $params = $self->_merge_base_criteria($params);
2258
2259
58
171
        if(!$join_clause && $self->{'data'} && !$self->_has_complex_criteria($params)) {
2260
39
30
                my @rc;
2261
39
39
25
47
                if(scalar(keys %{$params}) == 0) {
2262
29
28
1
41
39
1
                        @rc = ref($self->{'data'}) eq 'HASH' ? values %{$self->{'data'}} : @{$self->{'data'}};
2263
10
41
                } elsif((scalar(keys %{$params}) == 1) && defined($params->{'entry'}) && !$self->{'no_entry'}) {
2264
7
19
                        return 0 unless exists($self->{'data'}->{$params->{'entry'}});
2265
5
9
                        @rc = ($self->{'data'}->{$params->{'entry'}});
2266                } elsif(ref($self->{'data'}) eq 'HASH') {
2267
3
13
                        $self->_debug("$table: each_row in-memory scan with criteria");
2268
3
3
62
4
                        my @param_keys = keys %{$params};
2269                        @rc = grep {
2270
12
8
                                my $row = $_;
2271
12
20
                                all { $self->_match_criterion(exists($row->{$_}) ? $row->{$_} : undef, $params->{$_}, $_) } @param_keys
2272
3
12
3
3
16
4
                        } values %{$self->{'data'}};
2273                } elsif(ref($self->{'data'}) eq 'ARRAY' && !$self->{$table}) {
2274
0
0
                        $self->_debug("$table: each_row in-memory array scan with criteria");
2275
0
0
0
0
                        my @param_keys = keys %{$params};
2276                        @rc = grep {
2277
0
0
                                my $row = $_;
2278
0
0
                                all { $self->_match_criterion(exists($row->{$_}) ? $row->{$_} : undef, $params->{$_}, $_) } @param_keys
2279
0
0
0
0
0
0
                        } @{$self->{'data'}};
2280                }
2281
37
46
                if(@rc) {
2282
37
56
                        if(defined $sort_col) {
2283
7
7
                                if($sort_dir eq 'DESC') {
2284
2
10
6
15
                                        @rc = sort { ($b->{$sort_col} // '') cmp ($a->{$sort_col} // '') } @rc;
2285                                } else {
2286
5
25
29
31
                                        @rc = sort { ($a->{$sort_col} // '') cmp ($b->{$sort_col} // '') } @rc;
2287                                }
2288                        }
2289
37
62
                        if(defined($offset) || defined($limit)) {
2290
5
6
                                splice(@rc, 0, $offset) if $offset;
2291
5
8
                                splice(@rc, $limit)     if defined $limit;
2292                        }
2293                }
2294
37
29
                my $n = 0;
2295
37
40
                for my $row (@rc) {
2296
88
49
                        local $_;
2297
88
109
                        $callback->($row);
2298
78
106
                        $n++;
2299                }
2300
27
53
                return $n;
2301        }
2302
2303
19
41
        my ($where, $wargs) = $self->_build_where($params);
2304
19
19
21
24
        my @query_args = @{$wargs};
2305
2306
19
25
        my $query = "SELECT * FROM $table";
2307
19
29
        $query .= " $join_clause" if $join_clause;
2308
19
60
        if($join_clause) {
2309
0
0
                $query .= " WHERE $where" if $where;
2310        } elsif(($self->{'type'} eq 'CSV') && !$self->{'no_entry'}) {
2311
5
4
                my $id = $self->{'id'};
2312
5
7
                $query .= " WHERE $id IS NOT NULL AND $id NOT LIKE '#%'";
2313
5
9
                $query .= " AND ($where)" if $where;
2314        } else {
2315
14
19
                $query .= " WHERE $where" if $where;
2316        }
2317
19
41
        if(defined $sort_col) {
2318
1
3
                $query .= " ORDER BY $sort_col $sort_dir";
2319        } elsif(!$self->{'no_entry'}) {
2320
12
12
                $query .= ' ORDER BY ' . $self->{'id'};
2321        }
2322
19
36
        if(defined($limit)) {
2323
2
4
                $query .= ' LIMIT ?';
2324
2
4
                push @query_args, $limit;
2325        } elsif(defined($offset)) {
2326
0
0
                my $dbh = $self->{$table};
2327                $query .= ' LIMIT -1'
2328                        if ($dbh && (($dbh->{Driver}{Name} // '') eq 'SQLite'))
2329
0
0
                        || (($self->{'_dialect'} // '') eq 'sqlite');
2330        }
2331
19
28
        if(defined($offset)) {
2332
0
0
                $query .= ' OFFSET ?';
2333
0
0
                push @query_args, $offset;
2334        }
2335
2336
19
25
        if(defined($query_args[0])) {
2337
4
14
                $self->_debug("each_row $query: ", join(', ', @query_args));
2338        } else {
2339
15
31
                $self->_debug("each_row $query");
2340        }
2341
2342        my $sth = $self->{$table}->prepare_cached($query)
2343
19
679
                or Carp::croak(ref($self), ": each_row prepare failed: ", $self->{$table}->errstr());
2344
19
13597
        $sth->execute(@query_args) || croak("$query: @query_args");
2345
2346
19
6861
        my $n = 0;
2347
19
17
        eval {
2348
19
175
                while(my $row = $sth->fetchrow_hashref()) {
2349
49
209
                        local $_;
2350
49
61
                        $callback->($row);
2351
41
216
                        $n++;
2352                }
2353        };
2354
19
61
        if(my $err = $@) {
2355
8
28
                $sth->finish();
2356
8
46
                die $err;
2357        }
2358
11
25
        return $n;
2359}
2360
2361 - 2376
=head2 selectall_array

Similar to L</selectall_arrayref> but returns a list of hash references
rather than a reference to an array.

    my @rows = $db->selectall_array(status => 'active');

In B<scalar context> it applies C<LIMIT 1> and returns just the first
matching hash reference - making it more efficient than C<selectall_arrayref>
when you only need one row.  In B<list context> all matching rows are returned.

Accepts the same criteria, C<join>, C<limit>, C<offset>, and C<sort_by>
parameters as L</selectall_arrayref>.  When C<limit> is given in scalar context
it overrides the implicit C<LIMIT 1>.

=cut
2377
2378sub selectall_array
2379{
2380
69
15729
        my $self = shift;
2381
2382
69
137
        $self->_open_table({});
2383
2384
69
140
        if($self->{'berkeley'}) {
2385
3
4
                my $params = Params::Get::get_params(undef, \@_) // {};
2386
3
32
                my $bl = delete $params->{'limit'};
2387
3
3
                my $bo = delete $params->{'offset'};
2388
3
5
                my ($bsc, $bsd) = _parse_sort_by(delete($params->{'sort_by'}), 'selectall_array');
2389
3
18
                if(defined($bl) && $bl !~ /\A\d+\z/) {
2390
0
0
                        Carp::carp('selectall_array: limit must be a non-negative integer, ignoring');
2391
0
0
                        undef $bl;
2392                }
2393
3
3
                if(defined($bo) && $bo !~ /\A\d+\z/) {
2394
0
0
                        Carp::carp('selectall_array: offset must be a non-negative integer, ignoring');
2395
0
0
                        undef $bo;
2396                }
2397
3
7
                $params = $self->_merge_base_criteria($params);
2398
3
9
                my $rows = $self->_scan_berkeley($params);
2399
3
3
                if(defined $bsc) {
2400
0
0
                        if($bsd eq 'DESC') {
2401
0
0
0
0
0
0
0
0
                                @{$rows} = sort { ($b->{$bsc} // '') cmp ($a->{$bsc} // '') } @{$rows};
2402                        } else {
2403
0
0
0
0
0
0
0
0
                                @{$rows} = sort { ($a->{$bsc} // '') cmp ($b->{$bsc} // '') } @{$rows};
2404                        }
2405                }
2406
3
7
                if(defined($bl) || defined($bo)) {
2407
0
0
0
0
                        splice(@{$rows}, 0, int($bo)) if $bo;
2408
0
0
0
0
                        splice(@{$rows}, int($bl))    if defined $bl;
2409                }
2410
3
2
7
4
                return wantarray ? @{$rows} : $rows->[0];
2411        }
2412
2413
66
155
        my $params = Params::Get::get_params(undef, \@_);
2414
66
866
        my $table = $self->_open_table($params);
2415
2416
65
169
        $params //= {};
2417
65
82
        my $join_clause = '';
2418
65
110
        if(my $join_spec = delete $params->{'join'}) {
2419
1
1
                $join_clause = $self->_build_joins($join_spec);
2420        }
2421
2422
65
73
        my $limit  = delete $params->{'limit'};
2423
65
63
        my $offset = delete $params->{'offset'};
2424
65
98
        if(defined $limit) {
2425
0
0
                if($limit !~ /\A\d+\z/) {
2426
0
0
                        Carp::carp('selectall_array: limit must be a non-negative integer, ignoring');
2427
0
0
                        undef $limit;
2428                } else {
2429
0
0
                        $limit = int($limit);
2430                }
2431        }
2432
65
81
        if(defined $offset) {
2433
0
0
                if($offset !~ /\A\d+\z/) {
2434
0
0
                        Carp::carp('selectall_array: offset must be a non-negative integer, ignoring');
2435
0
0
                        undef $offset;
2436                } else {
2437
0
0
                        $offset = int($offset);
2438                }
2439        }
2440
2441
65
118
        my ($sort_col, $sort_dir) = _parse_sort_by(delete($params->{'sort_by'}), 'selectall_array');
2442
65
143
        $params = $self->_merge_base_criteria($params);
2443
2444
65
202
        if(!$join_clause && $self->{'data'} && !$self->_has_complex_criteria($params)) {
2445
48
48
65
70
                if(scalar(keys %{$params}) == 0) {
2446
32
57
                        $self->_trace("$table: selectall_array fast track return");
2447                        my @rc = ref($self->{'data'}) eq 'HASH'
2448
31
48
                                ? values %{$self->{'data'}}
2449
32
1
847
2
                                : @{$self->{'data'}};
2450
32
50
                        if(defined $sort_col) {
2451
0
0
                                if($sort_dir eq 'DESC') {
2452
0
0
0
0
                                        @rc = sort { ($b->{$sort_col} // '') cmp ($a->{$sort_col} // '') } @rc;
2453                                } else {
2454
0
0
0
0
                                        @rc = sort { ($a->{$sort_col} // '') cmp ($b->{$sort_col} // '') } @rc;
2455                                }
2456                        }
2457
32
96
                        if(defined($offset) || defined($limit)) {
2458
0
0
                                splice(@rc, 0, $offset) if $offset;
2459
0
0
                                splice(@rc, $limit)     if defined $limit;
2460                        }
2461
32
108
                        return @rc;
2462
16
82
                } elsif((scalar(keys %{$params}) == 1) && defined($params->{'entry'}) && !$self->{'no_entry'}) {
2463                        # exists() guard: fixate() locks all keys; return empty list (not undef)
2464                        # for a missing entry so callers in list context get 0 elements not 1
2465
6
16
                        return () unless exists($self->{'data'}->{$params->{'entry'}});
2466
3
20
                        if(defined($offset) || defined($limit)) {
2467                                # limit/offset: build list, splice, then return context-appropriately
2468
0
0
                                my @rc = ($self->{'data'}->{$params->{'entry'}});
2469
0
0
                                splice(@rc, 0, $offset) if $offset;
2470
0
0
                                splice(@rc, $limit)     if defined $limit;
2471
0
0
                                return wantarray ? @rc : $rc[0];
2472                        }
2473                        # Preserve original scalar-context behaviour: return the hashref directly
2474                        # (not the count of a 1-element list).
2475
3
12
                        return $self->{'data'}->{$params->{'entry'}};
2476                } elsif(ref($self->{'data'}) eq 'HASH') {
2477                        # Same as selectall_arrayref scan but returns a list
2478
10
25
                        $self->_debug("$table: selectall_array in-memory scan with criteria");
2479                        # Pre-compute param keys once — same optimisation as selectall_arrayref:
2480                        # avoids N * K hash-key extractions inside the inner closure.
2481
10
10
266
16
                        my @param_keys = keys %{$params};
2482                        my @rc = grep {
2483
36
26
                                my $row = $_;
2484
36
63
                                all { $self->_match_criterion(exists($row->{$_}) ? $row->{$_} : undef, $params->{$_}, $_) } @param_keys
2485
10
36
10
12
72
17
                        } values %{$self->{'data'}};
2486
10
18
                        if(defined $sort_col) {
2487
0
0
                                if($sort_dir eq 'DESC') {
2488
0
0
0
0
                                        @rc = sort { ($b->{$sort_col} // '') cmp ($a->{$sort_col} // '') } @rc;
2489                                } else {
2490
0
0
0
0
                                        @rc = sort { ($a->{$sort_col} // '') cmp ($b->{$sort_col} // '') } @rc;
2491                                }
2492                        }
2493
10
42
                        if(defined($offset) || defined($limit)) {
2494
0
0
                                splice(@rc, 0, $offset) if $offset;
2495
0
0
                                splice(@rc, $limit)     if defined $limit;
2496                        }
2497
10
71
                        return @rc;
2498                } elsif(ref($self->{'data'}) eq 'ARRAY' && !$self->{$table}) {
2499                        # In-memory scan for array-backed no_entry stores (JSON, XLSX, HTML URL)
2500                        # where there is no DBI handle.  Mirrors the equivalent path in count().
2501
0
0
                        $self->_debug("$table: selectall_array in-memory array scan with criteria");
2502
0
0
0
0
                        my @param_keys = keys %{$params};
2503                        my @rc = grep {
2504
0
0
                                my $row = $_;
2505
0
0
                                all { $self->_match_criterion(exists($row->{$_}) ? $row->{$_} : undef, $params->{$_}, $_) } @param_keys
2506
0
0
0
0
0
0
                        } @{$self->{'data'}};
2507
0
0
                        if(defined $sort_col) {
2508
0
0
                                if($sort_dir eq 'DESC') {
2509
0
0
0
0
                                        @rc = sort { ($b->{$sort_col} // '') cmp ($a->{$sort_col} // '') } @rc;
2510                                } else {
2511
0
0
0
0
                                        @rc = sort { ($a->{$sort_col} // '') cmp ($b->{$sort_col} // '') } @rc;
2512                                }
2513                        }
2514
0
0
                        if(defined($offset) || defined($limit)) {
2515
0
0
                                splice(@rc, 0, $offset) if $offset;
2516
0
0
                                splice(@rc, $limit)     if defined $limit;
2517                        }
2518
0
0
                        return @rc;
2519                }
2520        }
2521
2522
17
34
        my ($where, $wargs) = $self->_build_where($params);
2523
17
17
18
18
        my @query_args = @{$wargs};
2524
2525
17
21
        my $query = "SELECT * FROM $table";
2526
17
23
        $query .= " $join_clause" if $join_clause;
2527
17
55
        if($join_clause) {
2528
1
1
                $query .= " WHERE $where" if $where;
2529        } elsif(($self->{'type'} eq 'CSV') && !$self->{'no_entry'}) {
2530
3
4
                my $id = $self->{'id'};
2531
3
5
                $query .= " WHERE $id IS NOT NULL AND $id NOT LIKE '#%'";
2532
3
6
                $query .= " AND ($where)" if $where;
2533        } else {
2534
13
19
                $query .= " WHERE $where" if $where;
2535        }
2536
17
36
        if(defined $sort_col) {
2537
0
0
                $query .= " ORDER BY $sort_col $sort_dir";
2538        } elsif(!$self->{'no_entry'}) {
2539
12
15
                $query .= ' ORDER BY ' . $self->{'id'};
2540        }
2541
17
41
        if(defined($limit)) {
2542
0
0
                $query .= ' LIMIT ?';
2543
0
0
                push @query_args, $limit;
2544
0
0
                if(defined($offset)) {
2545
0
0
                        $query .= ' OFFSET ?';
2546
0
0
                        push @query_args, $offset;
2547                }
2548        } elsif(!wantarray) {
2549
6
7
                if(defined($offset)) {
2550
0
0
                        $query .= ' LIMIT 1 OFFSET ?';
2551
0
0
                        push @query_args, $offset;
2552                } else {
2553
6
6
                        $query .= ' LIMIT 1';
2554                }
2555        } elsif(defined($offset)) {
2556                # List context, offset but no limit — SQLite requires LIMIT with OFFSET
2557
0
0
                my $dbh = $self->{$table};
2558                $query .= ' LIMIT -1'
2559                        if ($dbh && (($dbh->{Driver}{Name} // '') eq 'SQLite'))
2560
0
0
                        || (($self->{'_dialect'} // '') eq 'sqlite');
2561
0
0
                $query .= ' OFFSET ?';
2562
0
0
                push @query_args, $offset;
2563        }
2564
2565
17
23
        if(defined($query_args[0])) {
2566
6
15
                $self->_debug("selectall_array $query: ", join(', ', @query_args));
2567        } else {
2568
11
40
                $self->_debug("selectall_array $query");
2569        }
2570
2571
17
405
        my $key;
2572        my $c;
2573
17
26
        if($c = $self->{cache}) {
2574
9
11
                $key = ref($self) . '::' . $query;
2575
9
8
                if(wantarray) {
2576
6
8
                        $key .= ' array';
2577                }
2578
9
15
                if(defined($query_args[0])) {
2579
4
5
                        $key .= ' ' . join(', ', @query_args);
2580                }
2581
9
18
                $self->_debug("cache key = '$key'");
2582
9
185
                if(my $rc = $c->get($key)) {
2583
4
275
                        $self->_debug('cache HIT');
2584
4
3
65
10
                        return wantarray ? @{$rc} : $rc;        # We stored a ref to the array
2585
2586                        # This use of a temporary variable is to avoid
2587                        #       "Implicit scalar context for array in return"
2588                        # my @rc = @{$rc};
2589                        # return @rc;
2590                }
2591
5
202
                $self->_debug('cache MISS');
2592        } else {
2593
8
15
                $self->_debug('cache not used');
2594        }
2595
2596
13
267
        if(my $sth = $self->{$table}->prepare_cached($query)) {
2597
13
5571
                $sth->execute(@query_args) || croak("$query: @query_args");
2598
2599
13
7804
                my $rc;
2600
13
114
                while(my $href = $sth->fetchrow_hashref()) {
2601
37
577
                        if(!wantarray) {
2602                                # Scalar context: return just the first row; cache it too
2603
5
13
                                $sth->finish();
2604
5
13
                                $c->set($key, [$href], $self->{'cache_duration'}) if $c;
2605
5
265
                                return $href;
2606                        }
2607
32
32
20
96
                        push @{$rc}, $href;
2608                }
2609
8
77
                $c->set($key, $rc, $self->{'cache_duration'}) if $c;
2610
2611
8
491
                if($rc) {
2612
8
11
                        if(!$self->{'no_fixate'}) {
2613
8
18
                                $self->_fixate($rc);
2614                        }
2615
8
8
1406
30
                        return @{$rc};
2616                }
2617
0
0
                return;
2618        }
2619
0
0
        $self->_warn("selectall_array failure on $query: @query_args");
2620
0
0
        croak("$query: @query_args");
2621}
2622
2623 - 2628
=head2 selectall_hash

Deprecated alias for L</selectall_array>.  Use C<selectall_array> in new
code.

=cut
2629
2630sub selectall_hash
2631{
2632
12
4608
        my $self = shift;
2633
12
86
        return $self->selectall_array(@_);
2634}
2635
2636 - 2661
=head2 count

Returns the number of rows matching the given criteria.

    my $total  = $db->count();
    my $active = $db->count(status => 'active');
    my $high   = $db->count(score  => { '>' => 90 });

Accepts the full criteria syntax described in L</QUERY CRITERIA>.

=head3 API SPECIFICATION

=head4 Arguments

    %criteria   # optional: same criteria syntax as selectall_arrayref
    join        => HashRef | ArrayRef  # optional join spec(s)
    table       => Str                 # optional: override table name

=head4 Returns

    Int (>= 0)

The number of rows that match the given criteria.  Returns C<0>
when no rows match -- never C<undef>.

=cut
2662
2663sub count
2664{
2665
344
34847
        my $self = shift;
2666
2667
344
751
        $self->_open_table({});
2668
2669
303
662
        my $params = Params::Get::get_params(undef, \@_) // {};
2670
303
3821
        if($self->{'berkeley'}) {
2671
4
8
                $params = $self->_merge_base_criteria($params);
2672
4
4
5
7
                return scalar @{$self->_scan_berkeley($params)};
2673        }
2674
2675
299
446
        my $table = $self->_open_table($params);
2676
298
772
        $params = $self->_merge_base_criteria($params // {});
2677
2678
298
407
        if($self->{'data'}) {
2679
197
197
144
290
                if(scalar(keys %{$params}) == 0) {
2680
164
330
                        $self->_trace("$table: count fast track return");
2681
164
4247
                        if(ref($self->{'data'}) eq 'HASH') {
2682
140
140
122
384
                                return scalar keys %{$self->{'data'}};
2683                        }
2684
24
24
25
62
                        return scalar @{$self->{'data'}};
2685
33
141
                } elsif((scalar(keys %{$params}) == 1) && defined($params->{'entry'}) && !$self->{'no_entry'}) {
2686                        # exists() guard: fixate() locks all keys in the slurp hash
2687
13
85
                        return (exists($self->{'data'}->{$params->{'entry'}}) && $self->{'data'}->{$params->{'entry'}}) ? 1 : 0;
2688                } elsif(!$self->_has_complex_criteria($params) && !$self->{$table}) {
2689                        # General in-memory scan for simple column criteria.
2690                        # Only taken when there is no DBI handle ($self->{$table} is undef),
2691                        # i.e. slurp-only backends like Deep.  CSV/XML/SQLite have a DBI handle
2692                        # and must fall through to the SQL path so that column-name validation
2693                        # fires in _build_where_conditions.
2694
4
9
                        $self->_debug("$table: count in-memory scan");
2695
4
4
77
6
                        my @param_keys = keys %{$params};
2696                        # Pass the row list directly to grep — avoids materialising an
2697                        # intermediate @rows array of N references on the stack.
2698                        return scalar grep {
2699
12
8
                                my $row = $_;
2700
12
21
                                all { $self->_match_criterion(exists($row->{$_}) ? $row->{$_} : undef, $params->{$_}, $_) } @param_keys
2701
4
12
4
0
7
18
7
0
                        } (ref($self->{'data'}) eq 'HASH' ? values %{$self->{'data'}} : @{$self->{'data'}});
2702                }
2703        }
2704
2705
117
246
        my ($where, $wargs) = $self->_build_where($params);
2706
113
113
123
118
        my @query_args = @{$wargs};
2707
2708
113
101
        my $query;
2709
113
331
        if(($self->{'type'} eq 'CSV') && !$self->{'no_entry'}) {
2710
32
36
                my $id = $self->{'id'};
2711
32
41
                $query = "SELECT COUNT(*) FROM $table WHERE $id IS NOT NULL AND $id NOT LIKE '#%'";
2712
32
59
                $query .= " AND ($where)" if $where;
2713        } elsif($self->{'no_entry'}) {
2714
54
64
                $query = "SELECT COUNT(*) FROM $table";
2715
54
85
                $query .= " WHERE $where" if $where;
2716        } else {
2717
27
37
                $query = "SELECT COUNT(" . $self->{'id'} . ") FROM $table";
2718
27
32
                $query .= " WHERE $where" if $where;
2719        }
2720
2721
113
135
        if(defined($query_args[0])) {
2722
37
89
                $self->_debug("count $query: ", join(', ', @query_args));
2723        } else {
2724
76
150
                $self->_debug("count $query");
2725        }
2726
2727
113
3048
        my $key;
2728        my $c;
2729
113
196
        if($c = $self->{'cache'}) {
2730                # Opportunistic: if a selectall_arrayref for the same criteria is already
2731                # in cache, derive the count from that array rather than hitting the DB.
2732                # The key is built to match what selectall_arrayref would store.
2733
8
10
                $key = ref($self) . '::' . $query;
2734                # [^)]+ is a negated character class: O(n) with zero backtracking.
2735                # The former lazy .+? could scan past the first ) in pathological SQL;
2736                # [^)]+ is also semantically correct (COUNT(expr) never contains ) ).
2737
8
34
                $key =~ s/COUNT\(([^)]+)\)/$1/;
2738
8
8
                $key .= ' array';
2739
8
15
                if(defined($query_args[0])) {
2740
3
5
                        $key .= ' ' . join(', ', @query_args);
2741                }
2742
8
14
                if(my $rc = $c->get($key)) {
2743
2
148
                        $self->_debug('count: cache HIT (selectall array)');
2744
2
2
41
6
                        return ref($rc) eq 'ARRAY' ? scalar @{$rc} : 0;
2745                }
2746
6
244
                $self->_debug('count: cache MISS');
2747        } else {
2748
105
133
                $self->_debug('cache not used');
2749        }
2750
2751
111
2546
        if(my $sth = $self->{$table}->prepare_cached($query)) {
2752
110
80422
                $sth->execute(@query_args) || croak("$query: @query_args");
2753
2754
110
44243
                my $count = $sth->fetchrow_arrayref()->[0];
2755
110
1001
                $sth->finish();
2756
2757
110
443
                return $count;
2758        }
2759
0
0
        $self->_warn("count failure on $query: @query_args");
2760
0
0
        croak("$query: @query_args");
2761}
2762
2763 - 2810
=head2 fetchrow_hashref

Returns a hash reference for the first row matching the given criteria,
or C<undef> when there is no match.  Always applies C<LIMIT 1>.

    my $row = $db->fetchrow_hashref(entry => 'key1');
    my $row = $db->fetchrow_hashref(score => { '>=' => 10 });

When C<no_entry> is B<not> set you may pass a single bare value and it is
used as the C<entry> key:

    my $row = $db->fetchrow_hashref('key1');    # same as entry => 'key1'

Accepts the full criteria syntax described in L</QUERY CRITERIA>, including
the C<join> parameter:

    my $row = $db->fetchrow_hashref(
        name => 'Alice',
        join => { table => 'dept', on => 'e.dept_id = dept.id' },
    );

Pass C<< table => $other_table >> to query a table other than the one
derived from the class name.

=head3 API SPECIFICATION

=head4 Arguments

    entry       => Str    # primary-key value to look up (positional first
                          # arg when no_entry is false)
    %criteria   # optional additional column filters
    join        => HashRef | ArrayRef  # optional join spec(s)
    no_fixate   => Bool                # optional: skip read-only locking
    table       => Str                 # optional: override table name

=head4 Returns

    HashRef | undef

A single row hashref when exactly one matching row is found.  Returns
C<undef> when the key is not found or no row matches the criteria.
The hashref is read-only unless C<no_fixate> was set.

B<Note:> C<fetchrow_hashref> does B<not> croak when multiple rows
match the given criteria; it returns whichever row the backend
delivers first (usually the first row in file/insertion order).

=cut
2811
2812sub fetchrow_hashref {
2813
143
37516
        my $self = shift;
2814
2815
143
285
        $self->_trace('Entering fetchrow_hashref');
2816
2817
143
3828
        my $params;
2818
2819
143
238
        if(!$self->{'no_entry'}) {
2820
125
294
                $params = Params::Get::get_params('entry', @_);
2821        } else {
2822
18
30
                $params = Params::Get::get_params(undef, @_);
2823        }
2824
2825
143
2051
        my $table = $self->_open_table($params);
2826
2827
139
109
387
388
        if($self->{'data'} && (!$self->{'no_entry'}) && (scalar keys(%{$params}) == 1) && defined($params->{'entry'}) && !$self->_has_complex_criteria($params)) {
2828
102
138
                $self->_debug('Fast return from slurped data');
2829                # Use exists(), fixate() locks the outer hash; accessing a missing key throws
2830
102
2175
                return undef unless exists($self->{'data'}->{$params->{'entry'}});
2831
79
114
                my $row = $self->{'data'}->{$params->{'entry'}};
2832
79
105
                if(my $bc = $self->{'base_criteria'}) {
2833
4
4
4
8
10
6
                        return undef unless all { $self->_match_criterion(exists($row->{$_}) ? $row->{$_} : undef, $bc->{$_}, $_) } keys %{$bc};
2834                }
2835
77
195
                return $row;
2836        }
2837
2838
37
93
        if($self->{'berkeley'}) {
2839
1
1
3
4
                if((!$self->{'no_entry'}) && (scalar keys(%{$params}) == 1) && defined($params->{'entry'})) {
2840
0
0
                        return { entry => $self->{'berkeley'}->{$params->{'entry'}} };
2841                }
2842
1
1
                my $id = $self->{'id'};
2843
1
0
3
0
                if($self->{'no_entry'} && (scalar keys(%{$params}) == 1) && defined($id) && defined($params->{$id})) {
2844
0
0
                        if(my $rc = $self->{'berkeley'}->{$params->{$id}}) {
2845
0
0
                                return { $params->{$id} => $rc }  # Return key->value as a hash pair
2846                        }
2847
0
0
                        return;
2848                }
2849
1
9
                Carp::croak(ref($self), ': fetchrow_hashref is meaningless on a NoSQL database');
2850        }
2851
2852
36
44
        my $raw_target = delete $params->{'table'};
2853
36
54
        if(defined $raw_target) {
2854
0
0
                $raw_target =~ s/\A.*:://;
2855
0
0
                Carp::croak(ref($self), ": unsafe table name '$raw_target'")
2856                        unless $raw_target =~ $SAFE_QUALIFIED;
2857        }
2858
36
89
        my $target = $raw_target // $table;
2859
36
36
        my $join_spec = delete $params->{'join'};
2860
36
101
        $params = $self->_merge_base_criteria($params);
2861
36
60
        my $join_clause = $join_spec ? $self->_build_joins($join_spec) : '';
2862
36
75
        my ($where, $wargs) = $self->_build_where($params);
2863
34
34
32
40
        my @query_args = @{$wargs};
2864
2865
34
53
        my $query = "SELECT * FROM $target";
2866
34
52
        $query .= " $join_clause" if $join_clause;
2867
34
99
        if($join_clause) {
2868
1
1
                $query .= " WHERE $where" if $where;
2869        } elsif(($self->{'type'} eq 'CSV') && !$self->{'no_entry'}) {
2870
8
31
                my $id = $self->{'id'};
2871
8
10
                $query .= " WHERE $id IS NOT NULL AND $id NOT LIKE '#%'";
2872
8
56
                $query .= " AND ($where)" if $where;
2873        } else {
2874
25
41
                $query .= " WHERE $where" if $where;
2875        }
2876
34
31
        $query .= ' LIMIT 1';
2877
34
44
        if(defined($query_args[0])) {
2878
33
86
                my @call_details = caller(0);
2879
33
297
                $self->_debug("fetchrow_hashref $query: ", join(', ', @query_args),
2880                        ' called from ', $call_details[2], ' of ', $call_details[1]);
2881        } else {
2882
1
2
                $self->_debug("fetchrow_hashref $query");
2883        }
2884        # Premise: $key is only needed when a cache is configured.
2885        # Conclusion: assemble it lazily inside the cache block — no dead store on
2886        # the common (no-cache) path.
2887
34
808
        my $c = $self->{'cache'};
2888
34
27
        my $key;    # declared here so the second if($c) block (cache SET) can see it
2889
34
47
        if($c) {
2890
7
9
                $key = ref($self) . '::';
2891
7
9
                if(defined($query_args[0])) {
2892
7
7
                        $key .= 'array ' if wantarray;
2893
7
13
                        $key .= "fetchrow $query " . join(', ', @query_args);
2894                } else {
2895
0
0
                        $key .= "fetchrow $query";
2896                }
2897
7
19
                if(my $rc = $c->get($key)) {
2898
3
260
                        if(wantarray) {
2899
0
0
                                if(ref($rc) eq 'ARRAY') {
2900
0
0
0
0
                                        return @{$rc};  # We stored a ref to the array
2901                                }
2902                        } else {
2903
3
11
                                return $rc;
2904                        }
2905                }
2906        }
2907
2908        my $sth = $self->{$table}->prepare_cached($query)
2909
31
348
                or Carp::croak(ref($self), ": prepare failed: ", $self->{$table}->errstr());
2910
31
27804
        $sth->execute(@query_args) || croak("$query: @query_args");
2911
31
11833
        my $rc = $sth->fetchrow_hashref();
2912
31
478
        $sth->finish();
2913
31
67
        if($c) {
2914
4
6
                if($rc) {
2915
4
17
                        $self->_debug("stash $key=>$rc in the cache for ", $self->{'cache_duration'});
2916
4
4
4
98
13
22
                        $self->_debug("returns ", do { require Data::Dumper; Data::Dumper->new([$rc])->Dump() });
2917                } else {
2918
0
0
                        $self->_debug("Stash $key=>undef in the cache for ", $self->{'cache_duration'});
2919                }
2920
4
99
                $c->set($key, $rc, $self->{'cache_duration'});
2921        }
2922
31
652
        return $rc;
2923}
2924
2925 - 2946
=head2 execute

Execute a raw SQL query on the underlying database.

    # Scalar context: returns the first row as a hashref
    my $row = $db->execute(query => 'SELECT * FROM foo WHERE id = 1');

    # List context: returns all rows as a list of hashrefs
    my @rows = $db->execute(query => 'SELECT * FROM foo WHERE score > ?',
                            args  => [80]);

The C<FROM E<lt>tableE<gt>> clause is appended automatically if omitted.

On CSV tables without C<no_entry> it may help to add
C<WHERE entry IS NOT NULL AND entry NOT LIKE '#%'> to filter comment rows.

If the data have been slurped into memory this method still hits the actual
database file directly.

C<args> is an arrayref of bind values (see L<DBI/execute>).

=cut
2947
2948sub execute
2949{
2950
18
4441
        my $self = shift;
2951
2952
18
43
        if($self->{'berkeley'}) {
2953
1
15
                Carp::croak(ref($self), ': execute is meaningless on a NoSQL database');
2954        }
2955
2956
17
30
        my $args = Params::Get::get_params('query', @_);
2957
2958        # Ensure the 'query' parameter is provided
2959        Carp::croak(__PACKAGE__, ': Usage: execute(query => $query)')
2960
15
213
                unless defined $args->{'query'};
2961
2962
15
25
        my $table = $self->_open_table($args);
2963
2964
15
20
        my $query = $args->{'query'};
2965
2966        # Append "FROM <table>" if missing
2967        # \bFROM\b catches the keyword at any word boundary (space, tab, newline),
2968        # unlike the former \sFROM\s which missed forms like "col\tFROM" or leading FROM.
2969
15
44
        $query .= " FROM $table" unless $query =~ /\bFROM\b/i;
2970
2971        # Log the query if a logger is available
2972
15
37
        $self->_debug("execute $query");
2973
2974        # Prepare and execute the query
2975
15
492
        my $sth = $self->{$table}->prepare_cached($query);
2976        # DBI->execute() takes a list; normalise args to an array whether it
2977        # was passed as an arrayref ([30]) or a bare scalar/list (30).
2978
15
2368
        if(exists($args->{'args'})) {
2979
9
5
18
8
                my @bind = ref($args->{'args'}) eq 'ARRAY' ? @{$args->{'args'}} : ($args->{'args'});
2980
9
157
                $sth->execute(@bind) or croak("$query: ", join(', ', @bind));
2981        } else {
2982
6
102
                $sth->execute() or croak($query);
2983        }
2984
2985        # Fetch the results
2986
15
2810
        my @results;
2987
15
130
        while (my $row = $sth->fetchrow_hashref()) {
2988
31
260
                unless(wantarray) {
2989
3
10
                        $sth->finish();
2990
3
7
                        return $row;
2991                }
2992
28
114
                push @results, $row;
2993        }
2994
2995        # Return all rows as an array in list context
2996
12
90
        return @results;
2997}
2998
2999 - 3015
=head2 updated

Returns the Unix timestamp of the last database update.

For file-based backends (CSV, XML, SQLite via C<directory>), this is the
mtime of the backing file, set at C<new()> time.

For SQLite DSN connections (C<dbi:SQLite:dbname=...>), the file path is
extracted from the DSN and C<stat()>-ed live on every call, so callers
get a current mtime suitable for cache-invalidation even when the database
was opened via a DSN rather than a C<directory>.

For all other DSN-based connections (PostgreSQL, MySQL, etc.) and for
URL-based backends, returns the Unix timestamp of the most recent
C<new()> call (connection time).

=cut
3016
3017sub updated {
3018
34
3002530
        my $self = shift;
3019
3020
34
121
        if(($self->{'_dialect'} // '') eq 'sqlite') {
3021
22
34
                my $dsn = $self->{'dsn'};
3022
22
138
                if(defined($dsn) && $dsn =~ /\Adbi:SQLite:(?:dbname=)?(.+)\z/i) {
3023
22
42
                        my $path = $1;
3024
22
139
                        my @st = stat($path);
3025
22
85
                        return $st[9] if @st;
3026                }
3027        }
3028
3029
12
21
        return $self->{'_updated'};
3030}
3031
3032 - 3072
=head2 columns

Returns an array reference of column names for the current table.

    my $cols = $db->columns();    # e.g. ['entry', 'name', 'score', 'status']

Column names are always returned in alphabetical (lexicographic) order,
regardless of backend. This makes the result stable and portable when the
same logical table is backed by different engines (CSV => SQLite, etc.).

The source of column names varies by backend:

=over 4

=item * B<Slurp mode> - keys of the first row in memory.

=item * B<SQLite / other DBI> - a zero-row C<SELECT *> exposes the driver's
C<NAME> attribute.

=item * B<BerkeleyDB> - always returns C<['entry', 'value']>.

=back

The result is cached inside the object after the first call.

=head3 API SPECIFICATION

=head4 Arguments

None beyond the implicit invocant.  The result is cached in the
object after the first call.

=head4 Returns

    ArrayRef[Str]

A sorted (lexicographic, ascending) arrayref of column names.  The
sort order is identical across all backends, so switching from CSV
to SQLite does not change the returned order.

=cut
3073
3074sub columns {
3075
34
7795
        my $self = shift;
3076
3077
34
100
        return $self->{'_columns'} if $self->{'_columns'};
3078
3079
27
62
        my $table = $self->_open_table({});
3080
3081
27
30
        my @cols;
3082
3083
27
51
        if($self->{'berkeley'}) {
3084
1
3
                return $self->{'_columns'} = ['entry', 'value'];
3085        }
3086
3087
26
49
        if(my $data = $self->{'data'}) {
3088
20
44
                if(ref($data) eq 'HASH') {
3089
15
15
13
24
                        my ($first) = values %{$data};
3090
15
15
24
37
                        @cols = sort keys %{$first} if $first;
3091
5
8
                } elsif(ref($data) eq 'ARRAY' && @{$data}) {
3092
5
5
3
13
                        @cols = sort keys %{$data->[0]};
3093                }
3094        } else {
3095
6
30
                my $sth = $self->{$table}->prepare_cached("SELECT * FROM $table WHERE 1=0");
3096
6
3591
                $sth->execute();
3097
6
6
5987
40
                @cols = sort @{$sth->{NAME}};
3098
6
37
                $sth->finish();
3099        }
3100
3101
26
75
        return $self->{'_columns'} = \@cols;
3102}
3103
3104 - 3176
=head2 schema

Returns a hash reference describing the schema of the current table.
Each key is a column name; each value is a hash reference with these keys:

=over 4

=item * C<type> - data type string (e.g. C<TEXT>, C<INTEGER>, C<REAL>)

=item * C<nullable> - C<1> if the column may be NULL, C<0> if NOT NULL

=item * C<default> - default value string, or C<undef>

=item * C<pk> - C<1> if this column is (part of) the primary key, C<0> otherwise

=back

    my $schema = $db->schema();

    for my $col (sort keys %{$schema}) {
        my $info = $schema->{$col};
        printf "%s  %s  %s\n",
            $col,
            $info->{type},
            $info->{nullable} ? 'NULL' : 'NOT NULL';
    }

The schema is determined by the backend:

=over 4

=item * B<SQLite> - C<PRAGMA table_info(table)>

=item * B<Other DBI drivers> - C<< $dbh->column_info(...) >>

=item * B<Slurp mode> - inferred from the first row; all columns typed as
C<TEXT> by default.  When C<< infer_types => 1 >> was passed to the
constructor, up to 100 rows are scanned per column and the type is
promoted: all-integer values => C<INTEGER>; all-float values =>
C<REAL>; all ISO-8601 timestamps (C<YYYY-MM-DDThh:mm>) => C<TIMESTAMP>;
all ISO-8601 dates (C<YYYY-MM-DD>) => C<DATE>; otherwise C<TEXT>.
C<undef> and empty-string values are treated as SQL C<NULL> and skipped
during the scan.

=item * B<BerkeleyDB> - always returns C<entry> (pk) and C<value>

=back

The result is cached inside the object after the first call.

=head3 API SPECIFICATION

=head4 Arguments

    infer_types => Bool  # enable heuristic type inference for slurp backends
                         # (may also be set at construction time)

=head4 Returns

    HashRef[Str => HashRef]

A hashref keyed by column name.  Each value is a hashref with:

    type     => Str          # 'TEXT', 'INTEGER', 'REAL', 'TIMESTAMP', 'DATE'
    nullable => Bool         # 1 if the column may be NULL, 0 if NOT NULL
    default  => Str | undef  # default value, or undef
    pk       => Bool         # 1 if (part of) the primary key

For slurp-mode backends all columns default to type C<TEXT> unless
C<infer_types> is set.  The result is cached in the object after the
first call.

=cut
3177
3178sub schema {
3179
37
10925
        my $self = shift;
3180
3181
37
89
        return $self->{'_schema'} if $self->{'_schema'};
3182
3183
28
88
        my $table = $self->_open_table({});
3184
28
33
        my %schema;
3185
3186
28
47
        if($self->{'berkeley'}) {
3187
1
6
                return $self->{'_schema'} = {
3188                        entry => { type => 'TEXT', nullable => 0, default => undef, pk => 1 },
3189                        value => { type => 'TEXT', nullable => 1, default => undef, pk => 0 },
3190                };
3191        }
3192
3193
27
50
        if(my $data = $self->{'data'}) {
3194
21
23
                my $first;
3195                my @sample;
3196
21
45
                if(ref($data) eq 'HASH') {
3197
14
14
14
17
                        ($first) = values %{$data};
3198
14
29
                        if($self->{'infer_types'} && $first) {
3199
2
2
                                my $n = 0;
3200
2
2
2
1
                                for my $row (values %{$data}) {
3201
6
6
                                        push @sample, $row;
3202
6
5
                                        last if ++$n >= INFER_TYPE_SAMPLE_SIZE;
3203                                }
3204                        }
3205
7
13
                } elsif(ref($data) eq 'ARRAY' && @{$data}) {
3206
7
8
                        $first = $data->[0];
3207
7
14
                        if($self->{'infer_types'}) {
3208
1
1
1
1
2
1
                                my $last = $#{$data} < INFER_TYPE_SAMPLE_SIZE - 1 ? $#{$data} : INFER_TYPE_SAMPLE_SIZE - 1;
3209
1
1
2
2
                                @sample = @{$data}[0 .. $last];
3210                        }
3211                }
3212
21
62
                if($first) {
3213
21
27
                        my $id = $self->{'id'};
3214
21
21
28
31
                        for my $col (keys %{$first}) {
3215                                my $type = @sample
3216
54
36
85
48
                                        ? _infer_type([map { exists($_->{$col}) ? $_->{$col} : undef } @sample])
3217                                        : 'TEXT';
3218
54
165
                                $schema{$col} = {
3219                                        type     => $type,
3220                                        nullable => ($col eq $id ? 0 : 1),
3221                                        default  => undef,
3222                                        pk       => ($col eq $id ? 1 : 0),
3223                                };
3224                        }
3225                }
3226        } else {
3227
6
59
                my $driver = $self->{$table}->{'Driver'}{'Name'} // '';
3228
6
23
                if($driver eq 'SQLite') {
3229
5
19
                        my $sth = $self->{$table}->prepare_cached("PRAGMA table_info($table)");
3230
5
272
                        $sth->execute();
3231
5
71
                        while(my $row = $sth->fetchrow_hashref()) {
3232                                $schema{$row->{'name'}} = {
3233                                        type     => $row->{'type'},
3234                                        nullable => !$row->{'notnull'},
3235                                        default  => $row->{'dflt_value'},
3236
13
112
                                        pk       => $row->{'pk'},
3237                                };
3238                        }
3239
5
13
                        $sth->finish();
3240                } else {
3241
1
25
                        my $sth = $self->{$table}->column_info(undef, undef, $table, '%');
3242
1
6
                        if($sth) {
3243
0
0
                                while(my $row = $sth->fetchrow_hashref()) {
3244                                        $schema{$row->{'COLUMN_NAME'}} = {
3245                                                type     => $row->{'TYPE_NAME'},
3246                                                nullable => $row->{'NULLABLE'},
3247
0
0
                                                default  => $row->{'COLUMN_DEF'},
3248                                                pk       => 0,
3249                                        };
3250                                }
3251
0
0
                                $sth->finish();
3252                        }
3253                }
3254        }
3255
3256
27
73
        return $self->{'_schema'} = \%schema;
3257}
3258
3259 - 3284
=head2 dbi_source

Returns a hashref C<{ dbh =E<gt> $dbh, table =E<gt> $name }> when the backend
is a live SQLite connection, or C<undef> for every other backend (slurp-mode
CSV, JSON, XLSX, HTML URL, DBM::Deep, BerkeleyDB, PostgreSQL, MySQL, ...).

The hashref is consumed by C<Database::Join> to perform a zero-copy
C<ATTACH DATABASE> so rows never pass through Perl.
Nested C<Database::Join> objects that are themselves SQLite-backed expose themselves
as attachable sources to parent joins through the same interface.

Subclasses may override this method to expose non-SQLite DBI connections
if their join layer supports them.

=head3 API SPECIFICATION

=head4 Arguments

None beyond the implicit invocant.

=head4 Returns

A hashref C<{ dbh =E<gt> DBI::db, table =E<gt> Str }> on a SQLite-backed
instance, or C<undef> on all other backends.

=cut
3285
3286sub dbi_source
3287{
3288
15
1674
        my $self = shift;
3289
3290
15
19
        my $table = $self->_open_table({});
3291
15
16
        my $dbh   = $self->{$table};
3292
3293
15
98
        return undef unless $dbh && ref($dbh) && (($dbh->{Driver}{Name} // '') eq 'SQLite');
3294
3295
9
23
        return { dbh => $dbh, table => $table };
3296}
3297
3298 - 3402
=head2 aggregate

Groups matching rows and computes aggregate measures over each group.
Pushes C<GROUP BY> SQL to the backend when a DBI handle is available;
otherwise folds rows in Perl (BerkeleyDB, DBM::Deep, XLSX, JSON, HTML, Redis).

    my $rows = $db->aggregate(
        by       => ['region', 'month'],
        measures => [
            { col => 'amount', fn => 'sum',   as => 'revenue' },
            {                  fn => 'count', as => 'orders'  },
        ],
        status => 'active',
    );

    # Grand total (no 'by')
    my $row = $db->aggregate(
        measures => [{ col => 'score', fn => 'avg', as => 'mean_score' }],
    )->[0];

Reserved parameter keys (extracted before the rest is treated as WHERE criteria):
C<by>, C<measures>, C<having>, C<sort_by>, C<limit>, C<offset>, C<table>, C<join>.

Always returns an B<arrayref of hashrefs> (never C<undef>); returns C<[]> when
no group qualifies.  Row keys are the C<by> column names (in declaration order)
followed by the measure aliases.

=head3 Measure specification

Each measure hashref:

    { col => 'amount', fn => 'sum',           as => 'revenue'   }
    {                  fn => 'count',         as => 'orders'    }  # COUNT(*)
    { col => 'amount', fn => 'count',         as => 'paid'      }  # COUNT(col)
    { col => 'customer', fn => 'count_distinct', as => 'customers' }

C<fn> must be one of C<sum>, C<avg>, C<min>, C<max>, C<count>, C<count_distinct>,
C<median>, C<stddev> (case-insensitive).  C<col> is required for every function
except plain C<count>.  C<as> (the result key) must be a valid SQL identifier
and must not duplicate another alias or a C<by> column name.

C<median> computes the median (middle value of sorted values; average of two
middle values for even count).  C<stddev> computes the sample standard deviation
(using N-1 denominator).  Both are computed in Perl (no SQL pushdown); on SQL
backends the full result set is fetched first and then folded in memory.

Empty-set normalisation: C<median> and C<stddev> become C<undef> when no rows
are present; C<stddev> on a single-row group returns C<0>.

=head3 HAVING

Pass C<< having => { alias_or_by_col => criterion } >> to filter groups
after aggregation.  Accepts the full criteria syntax.

    having => { revenue => { '>' => 1000 } }

=head3 PSEUDOCODE

    1. _open_table; croak if JOIN requested on no-DBI backend.
    2. _validate_aggregate_spec: extract/validate by/measures/having/
       sort_by/limit/offset; remainder of params is WHERE criteria.
    3. Merge base_criteria.
    4. SQL path (DBI handle): _build_aggregate_sql; prepare_cached+execute;
       normalise empty-set values; cache; fixate; return.
    5. Perl fold: selectall_arrayref(criteria); _fold_aggregate;
       HAVING via _match_criteria; sort; offset; limit; fixate; return.

=head3 Backend notes

On slurp-only backends all matching rows are materialised before folding.
Use a SQL backend (SQLite DSN, PostgreSQL, MySQL) for large datasets.

=head3 API SPECIFICATION

=head4 Arguments

    by         => ArrayRef[Str]     # optional: GROUP BY column names
    measures   => ArrayRef[HashRef] # required: one or more measure specs
    having     => HashRef           # optional: post-group filter criteria
    sort_by    => Str | ArrayRef    # optional: sort column (Str) or [col,dir]
    limit      => Int               # optional: max rows to return
    offset     => Int               # optional: rows to skip before returning
    join       => HashRef | ArrayRef # optional: join spec(s) -- SQL path only
    no_fixate  => Bool              # optional: skip read-only locking
    %criteria                       # optional: WHERE filter before grouping

Each element of C<measures> is a hashref with keys:

    col  => Str    # column to aggregate; omit only for plain count()
    fn   => Str    # one of: sum avg min max count count_distinct median stddev
    as   => Str    # result key name (required; valid SQL identifier)

=head4 Returns

    ArrayRef[HashRef]

An arrayref of hashrefs.  Each hashref has one key per C<by> column
and one key per measure C<as> alias.  Returns C<[]> (empty arrayref)
when no groups match -- never C<undef>.

Empty-set normalisation: C<sum>/C<count>/C<count_distinct> become
C<0>; C<avg>/C<min>/C<max>/C<median>/C<stddev> become C<undef> when no rows
are present.

=cut
3403
3404sub aggregate
3405{
3406
267
78649
        my $self = shift;
3407
3408
267
427
        $self->_open_table({});
3409
3410
267
235
        my $params;
3411
267
353
        if($self->{'no_entry'}) {
3412
147
230
                $params = Params::Get::get_params(undef, \@_);
3413        } elsif(scalar(@_)) {
3414
118
173
                $params = Params::Get::get_params('entry', @_);
3415        }
3416
267
3359
        $params //= {};
3417
3418
267
263
        my $table     = $self->_open_table($params);
3419
267
255
        my $no_fixate = delete $params->{'no_fixate'};
3420
267
214
        my $join_spec = delete $params->{'join'};
3421
267
188
        delete $params->{'table'};
3422
3423
267
185
        my $join_clause = '';
3424
267
290
        if($join_spec) {
3425
7
16
                $join_clause = $self->_build_joins($join_spec);
3426        }
3427
3428
265
371
        my $spec = $self->_validate_aggregate_spec($params);
3429
3430
175
209
        if($join_clause && !$self->{$table}) {
3431
4
7
                my $backend = $self->{'berkeley'} ? 'BerkeleyDB' : ($self->{'type'} // 'slurp');
3432
4
21
                Carp::croak(ref($self), ": aggregate: cannot aggregate a JOIN on the $backend backend");
3433        }
3434
3435
171
327
        $spec->{'criteria'} = $self->_merge_base_criteria($spec->{'criteria'});
3436
3437        # median and stddev are computed in Perl; skip SQL pushdown for them.
3438
171
187
171
122
247
171
        my $has_perl_only = grep { $PERL_ONLY_AGG_FNS{$_->{'fn'}} } @{$spec->{'measures'}};
3439
3440
171
331
        if($self->{$table} && !$has_perl_only) {
3441
125
177
                my ($sql, $args) = $self->_build_aggregate_sql($spec, $join_clause, $table, $self->{$table});
3442
3443
121
133
                if(defined($args->[0])) {
3444
50
50
51
74
                        $self->_debug("aggregate $sql: ", join(', ', @{$args}));
3445                } else {
3446
71
110
                        $self->_debug("aggregate $sql");
3447                }
3448
3449
121
3285
                my ($key, $c);
3450
121
156
                if($c = $self->{'cache'}) {
3451
0
0
                        $key = ref($self) . "::$sql aggregate";
3452
0
0
0
0
                        $key .= ' ' . join(', ', @{$args}) if defined($args->[0]);
3453
0
0
                        $self->_debug("aggregate cache key = '$key'");
3454
0
0
                        if(my $rc = $c->get($key)) {
3455
0
0
                                $self->_debug('aggregate: cache HIT');
3456
0
0
                                return $rc;
3457                        }
3458
0
0
                        $self->_debug('aggregate: cache MISS');
3459                } else {
3460
121
117
                        $self->_debug('aggregate cache not used');
3461                }
3462
3463                my $sth = $self->{$table}->prepare_cached($sql)
3464
121
2657
                        or do {
3465
0
0
                                $self->_warn("aggregate prepare failed on $sql");
3466
0
0
                                Carp::croak(ref($self), ': prepare failed: ', $self->{$table}->errstr());
3467                        };
3468
121
0
121
23510
0
2754
                $sth->execute(@{$args}) or Carp::croak("$sql: @{$args}");
3469
3470
120
8919
                my @rows;
3471
120
1165
                while(my $row = $sth->fetchrow_hashref()) {
3472
169
1085
                        push @rows, $row;
3473                }
3474
3475                # Empty-set normalisation (§5.3 of design spec):
3476                # sum / count / count_distinct → 0; avg / min / max stay undef.
3477
120
298
                for my $row (@rows) {
3478
169
169
101
159
                        for my $m (@{$spec->{'measures'}}) {
3479
185
137
                                my $as = $m->{'as'};
3480
185
131
                                my $fn = $m->{'fn'};
3481
185
308
                                if(!defined($row->{$as})
3482                                        && ($fn eq 'sum' || $fn eq 'count' || $fn eq 'count_distinct')) {
3483
6
9
                                        $row->{$as} = 0;
3484                                }
3485                        }
3486                }
3487
3488
120
122
                $c->set($key, \@rows, $self->{'cache_duration'}) if $c;
3489
120
258
                $self->_fixate(\@rows) unless $no_fixate;
3490
120
10822
                return set_return(\@rows, { type => 'arrayref' });
3491        }
3492
3493        # Perl fold path: fetch matching rows then group and aggregate in memory.
3494        # BerkeleyDB rows arrive via _scan_berkeley inside selectall_arrayref;
3495        # all other no-DBI backends (XLSX, Deep, JSON, HTML, Redis) have slurped data.
3496
46
46
55
89
        my $rows = $self->selectall_arrayref(%{$spec->{'criteria'}});
3497
46
2735
        my $out  = $self->_fold_aggregate($rows, $spec);
3498
3499
46
55
        if(my $hav = $spec->{'having'}) {
3500
1
2
1
1
4
1
                $out = [ grep { $self->_match_criteria($_, $hav) } @{$out} ];
3501        }
3502
3503
46
28
46
31
31
45
        my @by_names = map { $_->{'name'} } @{$spec->{'by'}};
3504
46
66
        if(my $s = $spec->{'sort'}) {
3505
5
5
5
6
                my ($scol, $sdir) = @{$s};
3506
5
4
                my $desc       = ($sdir eq 'DESC');
3507
5
7
                my $is_measure = $spec->{'aliases'}{$scol};
3508
5
6
                @{$out} = sort {
3509
5
10
                        my ($av, $bv) = ($a->{$scol} // '', $b->{$scol} // '');
3510
5
10
                        $desc
3511                                ? ($is_measure ? ($bv <=> $av) : ($bv cmp $av))
3512                                : ($is_measure ? ($av <=> $bv) : ($av cmp $bv))
3513
5
5
6
8
                } @{$out};
3514        } elsif(@by_names) {
3515
23
20
                @{$out} = sort {
3516
16
15
                        my $cmp = 0;
3517
16
10
                        for my $bn (@by_names) {
3518
16
29
                                $cmp = (($a->{$bn} // '') cmp ($b->{$bn} // ''));
3519
16
30
                                last if $cmp;
3520                        }
3521                        $cmp;
3522
23
23
15
36
                } @{$out};
3523        }
3524
3525
46
0
57
0
        splice(@{$out}, 0, $spec->{'offset'}) if $spec->{'offset'};
3526
46
5
43
6
        splice(@{$out}, $spec->{'limit'})     if defined $spec->{'limit'};
3527
3528
46
88
        $self->_fixate($out) unless $no_fixate;
3529
46
3520
        return set_return($out, { type => 'arrayref' });
3530}
3531
3532# Validate and extract aggregate spec from %params (mutates $params in place,
3533# deleting aggregate control keys; the remainder becomes the WHERE criteria).
3534# Returns \%spec on success; croaks on invalid input.
3535# No database access — pure validation, trivially unit-testable.
3536sub _validate_aggregate_spec
3537{
3538
331
26939
        my ($self, $params) = @_;
3539
3540
331
328
        my $by      = delete $params->{'by'};
3541
331
257
        my $meas    = delete $params->{'measures'};
3542
331
258
        my $having  = delete $params->{'having'};
3543
331
265
        my $sort_by = delete $params->{'sort_by'};
3544
331
223
        my $limit   = delete $params->{'limit'};
3545
331
230
        my $offset  = delete $params->{'offset'};
3546
3547
331
483
        $by //= [];
3548
331
398
        Carp::croak(ref($self), ": aggregate: 'by' must be an arrayref of column names")
3549                unless ref($by) eq 'ARRAY';
3550
3551
325
236
        my (@by_names, %by_name_set);
3552
325
325
212
338
        for my $col (@{$by}) {
3553
156
789
                Carp::croak(ref($self), ": aggregate: unsafe group-by column '". ($col // '') ."'")
3554                        unless defined($col) && $col =~ $SAFE_QUALIFIED;
3555
141
128
                push @by_names, $col;
3556
141
188
                $by_name_set{$col} = 1;
3557        }
3558
3559        Carp::croak(ref($self), ": aggregate: 'measures' must be a non-empty arrayref")
3560
310
302
425
410
                unless ref($meas) eq 'ARRAY' && scalar(@{$meas}) > 0;
3561
3562
298
228
        my (@measure_specs, %alias_set);
3563
298
298
204
243
        for my $m (@{$meas}) {
3564
320
353
                Carp::croak(ref($self), ": aggregate: measure must be a hashref, got ", (ref($m) || 'scalar'))
3565                        unless ref($m) eq 'HASH';
3566
3567
314
397
                my $fn  = lc($m->{'fn'} // '');
3568
314
222
                my $col = $m->{'col'};
3569
314
219
                my $as  = $m->{'as'};
3570
3571                Carp::croak(ref($self), ": aggregate: unknown measure function '$fn'")
3572
314
421
                        unless $VALID_AGG_FNS{$fn};
3573
3574
304
300
                if($fn ne 'count') {
3575
179
334
                        Carp::croak(ref($self), ": aggregate: measure column is required for function '$fn'")
3576                                unless defined($col) && length($col);
3577                }
3578
294
403
                if(defined($col) && length($col)) {
3579
172
472
                        Carp::croak(ref($self), ": aggregate: unsafe measure column '$col'")
3580                                unless $col =~ $SAFE_QUALIFIED;
3581                }
3582
3583
282
461
                Carp::croak(ref($self), ": aggregate: measure 'as' is required")
3584                        unless defined($as) && length($as);
3585
276
636
                Carp::croak(ref($self), ": aggregate: unsafe measure alias '$as'")
3586                        unless $as =~ $SAFE_IDENTIFIER;
3587                Carp::croak(ref($self), ": aggregate: duplicate measure alias '$as'")
3588
264
322
                        if $alias_set{$as};
3589                Carp::croak(ref($self), ": aggregate: measure alias '$as' collides with a group-by column")
3590
259
265
                        if $by_name_set{$as};
3591
3592
255
259
                $alias_set{$as} = 1;
3593
3594
255
168
                my $sql_expr;
3595
255
505
                if($fn eq 'count' && !(defined($col) && length($col))) {
3596
113
94
                        $sql_expr = 'COUNT(*)';
3597                } elsif($fn eq 'count_distinct') {
3598
7
9
                        $sql_expr = "COUNT(DISTINCT $col)";
3599                } elsif($fn eq 'median') {
3600
6
6
                        $sql_expr = "MEDIAN($col)";   # Perl-fold path only; SQL expr unused
3601                } elsif($fn eq 'stddev') {
3602
3
4
                        $sql_expr = "STDDEV($col)";   # Perl-fold path only; SQL expr unused
3603                } else {
3604
126
164
                        $sql_expr = uc($fn) . "($col)";
3605                }
3606
3607
255
583
                push @measure_specs, { fn => $fn, col => $col, as => $as, sql => $sql_expr };
3608        }
3609
3610
233
255
        if(defined $having) {
3611
27
49
                Carp::croak(ref($self), ": aggregate: 'having' must be a hashref")
3612                        unless ref($having) eq 'HASH';
3613
24
24
19
33
                for my $k (keys %{$having}) {
3614
25
58
                        next if $k eq '-or' || $k eq '-and';
3615                        Carp::croak(ref($self), ": aggregate: having key '$k' is not a group-by column or measure alias")
3616
22
94
                                unless $by_name_set{$k} || $alias_set{$k};
3617                }
3618        }
3619
3620
225
150
        my ($sort_col, $sort_dir);
3621
225
211
        if(defined $sort_by) {
3622
28
62
                ($sort_col, $sort_dir) = ref($sort_by) eq 'ARRAY'
3623                        ? ($sort_by->[0], $sort_by->[1])
3624                        : ($sort_by, 'ASC');
3625
28
44
                $sort_dir = defined($sort_dir) ? uc($sort_dir) : 'ASC';
3626                Carp::croak(ref($self), ": aggregate: sort_by names '". ($sort_col // '') ."', which is not a group-by column or measure alias")
3627
28
120
                        unless defined($sort_col) && ($by_name_set{$sort_col} || $alias_set{$sort_col});
3628
22
110
                Carp::croak(ref($self), ": aggregate: sort_by names '". ($sort_col // '') ."', which is not a group-by column or measure alias")
3629                        unless $sort_dir eq 'ASC' || $sort_dir eq 'DESC';
3630        }
3631
3632
218
195
        if(defined $limit) {
3633
26
63
                if($limit !~ /\A\d+\z/) {
3634
5
29
                        Carp::carp('aggregate: limit must be a non-negative integer, ignoring');
3635
5
582
                        undef $limit;
3636                } else {
3637
21
18
                        $limit = int($limit);
3638                }
3639        }
3640
218
196
        if(defined $offset) {
3641
9
18
                if($offset !~ /\A\d+\z/) {
3642
3
18
                        Carp::carp('aggregate: offset must be a non-negative integer, ignoring');
3643
3
359
                        undef $offset;
3644                } else {
3645
6
6
                        $offset = int($offset);
3646                }
3647        }
3648
3649        return {
3650
218
132
487
577
                by       => [ map { { name => $_ } } @by_names ],
3651                measures => \@measure_specs,
3652                aliases  => \%alias_set,
3653                by_names => \%by_name_set,
3654                having   => $having,
3655                criteria => $params,
3656                sort     => defined($sort_col) ? [ $sort_col, $sort_dir ] : undef,
3657                limit    => $limit,
3658                offset   => $offset,
3659        };
3660}
3661
3662# Build the GROUP BY aggregate SQL query and bind-value list.
3663# Returns ($sql, \@bind_args).
3664sub _build_aggregate_sql
3665{
3666
133
162
        my ($self, $spec, $join_clause, $table, $dbh) = @_;
3667
3668
133
93
133
95
118
138
        my @by_names = map { $_->{'name'} } @{$spec->{'by'}};
3669
133
133
93
135
        my @meas     = @{$spec->{'measures'}};
3670
3671        # SELECT: by columns then "expr AS alias" for each measure.
3672
133
89
        my @sel;
3673
133
117
        push @sel, @by_names;
3674
133
145
104
242
        push @sel, map { "$_->{'sql'} AS $_->{'as'}" } @meas;
3675
133
213
        my $sql = 'SELECT ' . join(', ', @sel) . " FROM $table";
3676
133
133
        $sql .= " $join_clause" if $join_clause;
3677
3678        # WHERE: criteria + CSV comment-row guard.
3679
133
214
        my ($where, $wargs) = $self->_build_where($spec->{'criteria'});
3680
131
131
96
117
        my @args = @{$wargs};
3681
3682
131
282
        if($join_clause) {
3683
1
3
                $sql .= " WHERE $where" if $where;
3684        } elsif(($self->{'type'} // '') eq 'CSV' && !$self->{'no_entry'}) {
3685
9
11
                my $id = $self->{'id'};
3686
9
8
                $sql .= " WHERE $id IS NOT NULL AND $id NOT LIKE '#%'";
3687
9
11
                $sql .= " AND ($where)" if $where;
3688        } else {
3689
121
146
                $sql .= " WHERE $where" if $where;
3690        }
3691
3692        # GROUP BY.
3693
131
151
        $sql .= ' GROUP BY ' . join(', ', @by_names) if @by_names;
3694
3695        # HAVING: substitute measure aliases with their SQL expressions so the
3696        # query is valid on PostgreSQL (which forbids output-column aliases in HAVING).
3697
131
161
        if(my $hav = $spec->{'having'}) {
3698
15
16
15
29
                my %a2s = map { $_->{'as'} => $_->{'sql'} } @meas;
3699
15
51
                my ($hfrag, $hargs) = $self->_build_having_fragment($hav, \%a2s);
3700
13
18
                if($hfrag) {
3701
13
13
                        $sql .= " HAVING $hfrag";
3702
13
13
12
18
                        push @args, @{$hargs};
3703                }
3704        }
3705
3706        # ORDER BY: explicit sort_by takes precedence; default is by-columns ASC.
3707
129
175
        if(my $s = $spec->{'sort'}) {
3708
14
21
                $sql .= " ORDER BY $s->[0] $s->[1]";
3709        } elsif(@by_names) {
3710
80
73
                $sql .= ' ORDER BY ' . join(', ', @by_names);
3711        }
3712
3713        # LIMIT / OFFSET.
3714
129
181
        if(defined $spec->{'limit'}) {
3715
15
14
                $sql .= ' LIMIT ?';
3716
15
81
                push @args, $spec->{'limit'};
3717        } elsif(defined $spec->{'offset'}) {
3718                # SQLite requires a LIMIT clause before OFFSET; -1 means no upper bound.
3719                $sql .= ' LIMIT -1'
3720                        if ($dbh && (($dbh->{Driver}{Name} // '') eq 'SQLite'))
3721
2
20
                        || (($self->{'_dialect'} // '') eq 'sqlite');
3722        }
3723
129
130
        if(defined $spec->{'offset'}) {
3724
4
5
                $sql .= ' OFFSET ?';
3725
4
5
                push @args, $spec->{'offset'};
3726        }
3727
3728
129
215
        return ($sql, \@args);
3729}
3730
3731# Build a HAVING clause fragment, substituting measure aliases with their SQL
3732# expressions (PostgreSQL forbids output-column aliases in HAVING).
3733# $alias_to_sql maps alias → SQL expression (e.g. revenue → SUM(amount)).
3734# Mirrors _build_where_conditions but uses pre-validated SQL expressions as LHS
3735# for measure keys, so the $SAFE_QUALIFIED identifier check is skipped there.
3736#
3737# SQLite 3.51+ regression: bound parameters compared to aggregate expressions
3738# (SUM, AVG, etc.) in HAVING are not type-coerced to numeric automatically,
3739# causing numeric comparisons to return 0 rows.  When the LHS was substituted
3740# from an alias (i.e. it is an aggregate expression, not a bare column name),
3741# wrap all numeric bind placeholders in CAST(? AS REAL) so SQLite performs the
3742# comparison correctly.  CAST(? AS REAL) is valid SQL in SQLite, PostgreSQL and
3743# MySQL and is a no-op on those dialects where type coercion already works.
3744#
3745# Returns ($fragment, \@bind_values).
3746sub _build_having_fragment
3747{
3748
33
7212
        my ($self, $having, $alias_to_sql) = @_;
3749
3750
33
27
        my (@clauses, @args);
3751
33
33
28
86
        for my $k (sort keys %{$having}) {
3752
34
34
                my $agg_expr = $alias_to_sql->{$k};
3753
34
62
                my $lhs      = $agg_expr // $k;
3754
34
29
                my $val      = $having->{$k};
3755
3756                # ph() returns the right placeholder: CAST(? AS REAL) for aggregate
3757                # expression LHS (numeric result), bare ? for group-by column LHS.
3758
34
51
                my $ph = defined($agg_expr) ? 'CAST(? AS REAL)' : '?';
3759
3760
34
60
                if(ref($val) eq 'HASH') {
3761
23
23
20
26
                        for my $op (keys %{$val}) {
3762
23
20
                                my $operand = $val->{$op};
3763
23
110
                                if($op eq '-in' || $op eq '-not_in') {
3764
2
3
                                        my $sql_op = $op eq '-in' ? 'IN' : 'NOT IN';
3765
2
2
1
4
                                        my $phs = join(', ', ($ph) x scalar(@{$operand}));
3766
2
3
                                        push @clauses, "$lhs $sql_op ($phs)";
3767
2
2
2
4
                                        push @args, @{$operand};
3768                                } elsif($op eq '-between') {
3769
1
2
                                        push @clauses, "$lhs BETWEEN $ph AND $ph";
3770
1
2
                                        push @args, $operand->[0], $operand->[1];
3771                                } elsif($op eq '-like') {
3772
1
2
                                        push @clauses, "$lhs LIKE ?";
3773
1
1
                                        push @args, $operand;
3774                                } elsif($op eq '-not_like') {
3775
1
1
                                        push @clauses, "$lhs NOT LIKE ?";
3776
1
3
                                        push @args, $operand;
3777                                } elsif($op eq '!=') {
3778
2
3
                                        if(!defined($operand)) {
3779
1
2
                                                push @clauses, "$lhs IS NOT NULL";
3780                                        } else {
3781
1
1
                                                push @clauses, "$lhs != $ph";
3782
1
2
                                                push @args, $operand;
3783                                        }
3784                                } elsif($op =~ /\A[<>]=?\z/) {
3785
15
26
                                        push @clauses, "$lhs $op $ph";
3786
15
23
                                        push @args, $operand;
3787                                } else {
3788
1
9
                                        Carp::croak("Unknown operator '$op' in having for key '$k'");
3789                                }
3790                        }
3791                } elsif(ref($val)) {
3792
3
20
                        Carp::croak("$k: expected scalar or operator hashref, got ", ref($val));
3793                } elsif(!defined($val)) {
3794
1
2
                        push @clauses, "$lhs IS NULL";
3795                } elsif($val =~ /[%_]/) {
3796
1
1
                        push @clauses, "$lhs LIKE ?";
3797
1
2
                        push @args, $val;
3798                } else {
3799
6
9
                        push @clauses, "$lhs = $ph";
3800
6
9
                        push @args, $val;
3801                }
3802        }
3803
3804
29
52
        return (join(' AND ', @clauses), \@args);
3805}
3806
3807# Recursive in-memory criterion matcher supporting -or/-and groupings.
3808# Used by aggregate()'s Perl fold path for HAVING evaluation.
3809# Leaf comparisons are delegated to _match_criterion.
3810# Uses exists() guards for fixated slurp hashrefs (see Data::Reuse notes).
3811sub _match_criteria
3812{
3813
37
86
        my ($self, $row, $criteria) = @_;
3814
3815
37
37
26
31
        for my $k (keys %{$criteria}) {
3816
37
40
                if($k eq '-or') {
3817
6
4
                        my $found = 0;
3818
6
6
4
6
                        for my $sub (@{$criteria->{$k}}) {
3819
8
11
                                if($self->_match_criteria($row, $sub)) {
3820
4
4
                                        $found = 1;
3821
4
3
                                        last;
3822                                }
3823                        }
3824
6
9
                        return 0 unless $found;
3825                } elsif($k eq '-and') {
3826
5
5
4
6
                        for my $sub (@{$criteria->{$k}}) {
3827
8
6
                                return 0 unless $self->_match_criteria($row, $sub);
3828                        }
3829                } else {
3830
26
24
                        my $row_val = exists($row->{$k}) ? $row->{$k} : undef;
3831
26
29
                        return 0 unless $self->_match_criterion($row_val, $criteria->{$k}, $k);
3832                }
3833        }
3834
23
32
        return 1;
3835}
3836
3837# Group an arrayref of rows and compute aggregate measures in Perl.
3838# Used by aggregate() when no DBI handle is available (slurp-only backends).
3839# All matching rows are materialised by the caller before this is called.
3840# Returns an arrayref of group rows (unsorted; sorting happens in aggregate()).
3841sub _fold_aggregate
3842{
3843
63
131
        my ($self, $rows, $spec) = @_;
3844
3845
63
63
66
70
        my @by_specs = @{$spec->{'by'}};
3846
63
63
47
69
        my @meas     = @{$spec->{'measures'}};
3847
3848        # Pre-compute per-measure numeric flag from cached schema (only when already
3849        # populated by a prior schema() call — never triggers a PRAGMA round-trip).
3850        my @meas_numeric = map {
3851
63
68
55
62
                my $col = $_->{'col'};
3852                ($col && $self->{'_schema'})
3853
68
140
                        ? do {
3854
0
0
                                my $t = ($self->{'_schema'}{$col} // {})->{'type'} // 'TEXT';
3855
0
0
                                ($t eq 'INTEGER' || $t eq 'REAL') ? 1 : 0;
3856                          }
3857                        : 0;
3858        } @meas;
3859
3860
63
77
        my (%groups, @group_order);
3861
3862
63
63
52
63
        for my $row (@{$rows}) {
3863                # Build group key: NUL-separated by-column values ('undef' → '').
3864                my $key = join("\x00",
3865
204
119
159
211
                        map { exists($row->{$_->{'name'}}) ? ($row->{$_->{'name'}} // '') : '' }
3866                        @by_specs
3867                );
3868
3869
204
191
                unless(exists $groups{$key}) {
3870
84
63
                        push @group_order, $key;
3871
84
65
                        my %g;
3872                        # Seed group with by-column values from the first row seen.
3873
84
57
                        for my $b (@by_specs) {
3874
54
110
                                $g{$b->{'name'}} = exists($row->{$b->{'name'}}) ? $row->{$b->{'name'}} : undef;
3875                        }
3876                        # Initialise per-measure accumulator state.
3877
84
60
                        for my $m (@meas) {
3878
91
77
                                my $fn = $m->{'fn'};
3879
91
72
                                my $as = $m->{'as'};
3880
91
197
                                if($fn eq 'sum') {
3881
21
44
                                        $g{"__agg_$as"} = { sum => 0, bad => 0, first_bad => undef };
3882                                } elsif($fn eq 'avg') {
3883
7
13
                                        $g{"__agg_$as"} = { sum => 0, cnt => 0, bad => 0, first_bad => undef };
3884                                } elsif($fn eq 'min' || $fn eq 'max') {
3885
7
9
                                        $g{"__agg_$as"} = { val => undef };
3886                                } elsif($fn eq 'count') {
3887
41
73
                                        $g{"__agg_$as"} = { cnt => 0 };
3888                                } elsif($fn eq 'count_distinct') {
3889
2
5
                                        $g{"__agg_$as"} = { seen => {} };
3890                                } elsif($fn eq 'median' || $fn eq 'stddev') {
3891
13
23
                                        $g{"__agg_$as"} = { vals => [] };
3892                                }
3893                        }
3894
84
134
                        $groups{$key} = \%g;
3895                }
3896
3897
204
145
                my $g  = $groups{$key};
3898
204
119
                my $mi = 0;
3899
204
164
                for my $m (@meas) {
3900
221
217
                        my $fn      = $m->{'fn'};
3901
221
142
                        my $col     = $m->{'col'};
3902
221
133
                        my $as      = $m->{'as'};
3903
221
170
                        my $numeric = $meas_numeric[$mi++];
3904
221
166
                        my $acc     = $g->{"__agg_$as"};
3905
221
265
                        my $val = (defined($col) && exists($row->{$col})) ? $row->{$col} : undef;
3906
3907
221
280
                        if($fn eq 'sum') {
3908
45
119
                                if(defined($val) && length($val) && Scalar::Util::looks_like_number($val)) {
3909
41
47
                                        $acc->{'sum'} += $val;
3910                                } elsif(defined($val) && length($val)) {
3911
4
64
                                        $acc->{'bad'}++;
3912
4
14
                                        $acc->{'first_bad'} //= $val;
3913                                }
3914                        } elsif($fn eq 'avg') {
3915
14
39
                                if(defined($val) && length($val) && Scalar::Util::looks_like_number($val)) {
3916
11
6
                                        $acc->{'sum'} += $val;
3917
11
14
                                        $acc->{'cnt'}++;
3918                                } elsif(defined($val) && length($val)) {
3919
1
1
                                        $acc->{'bad'}++;
3920
1
6
                                        $acc->{'first_bad'} //= $val;
3921                                }
3922                        } elsif($fn eq 'min') {
3923
8
7
                                if(defined($val)) {
3924
6
11
                                        if(!defined($acc->{'val'})) {
3925
3
3
                                                $acc->{'val'} = $val;
3926                                        } elsif($numeric ? ($val < $acc->{'val'}) : ($val lt $acc->{'val'})) {
3927
1
2
                                                $acc->{'val'} = $val;
3928                                        }
3929                                }
3930                        } elsif($fn eq 'max') {
3931
6
6
                                if(defined($val)) {
3932
6
11
                                        if(!defined($acc->{'val'})) {
3933
3
2
                                                $acc->{'val'} = $val;
3934                                        } elsif($numeric ? ($val > $acc->{'val'}) : ($val gt $acc->{'val'})) {
3935
1
1
                                                $acc->{'val'} = $val;
3936                                        }
3937                                }
3938                        } elsif($fn eq 'count') {
3939
99
75
                                if(defined $col) {
3940
2
3
                                        $acc->{'cnt'}++ if defined($val);
3941                                } else {
3942
97
94
                                        $acc->{'cnt'}++;
3943                                }
3944                        } elsif($fn eq 'count_distinct') {
3945
7
11
                                $acc->{'seen'}{$val}++ if defined($val);
3946                        } elsif($fn eq 'median' || $fn eq 'stddev') {
3947
42
67
                                if(defined($val) && length($val) && Scalar::Util::looks_like_number($val)) {
3948
42
42
21
51
                                        push @{$acc->{'vals'}}, $val + 0;
3949                                } elsif(defined($val) && length($val)) {
3950
0
0
                                        $acc->{'bad'}++;
3951
0
0
                                        $acc->{'first_bad'} //= $val;
3952                                }
3953                        }
3954                }
3955        }
3956
3957        # Finalise groups: derive final result values from accumulators.
3958        # %warned tracks which measure aliases have already had their non-numeric
3959        # warning emitted; the warning fires at most once per measure per call.
3960
63
53
        my (%warned, @out);
3961
63
44
        for my $key (@group_order) {
3962
84
66
                my $g = $groups{$key};
3963
84
55
                my %result;
3964
84
62
                for my $b (@by_specs) {
3965
54
59
                        $result{$b->{'name'}} = $g->{$b->{'name'}};
3966                }
3967
84
62
                for my $m (@meas) {
3968
91
72
                        my $fn  = $m->{'fn'};
3969
91
55
                        my $as  = $m->{'as'};
3970
91
56
                        my $col = $m->{'col'};
3971
91
70
                        my $acc = $g->{"__agg_$as"};
3972
91
52
                        my $val;
3973
91
187
                        if($fn eq 'sum') {
3974
21
30
                                if($acc->{'bad'} && !$warned{$as}++) {
3975
3
40
                                        Carp::carp("aggregate: column '$col' has $acc->{'bad'} non-numeric value(s), skipped, e.g. '$acc->{'first_bad'}'");
3976                                }
3977
21
561
                                $val = $acc->{'sum'};
3978                        } elsif($fn eq 'avg') {
3979
7
13
                                if($acc->{'bad'} && !$warned{$as}++) {
3980
1
13
                                        Carp::carp("aggregate: column '$col' has $acc->{'bad'} non-numeric value(s), skipped, e.g. '$acc->{'first_bad'}'");
3981                                }
3982
7
101
                                $val = $acc->{'cnt'} ? ($acc->{'sum'} / $acc->{'cnt'}) : undef;
3983                        } elsif($fn eq 'min' || $fn eq 'max') {
3984
7
5
                                $val = $acc->{'val'};
3985                        } elsif($fn eq 'count') {
3986
41
33
                                $val = $acc->{'cnt'};
3987                        } elsif($fn eq 'count_distinct') {
3988
2
2
3
2
                                $val = scalar(keys %{$acc->{'seen'}});
3989                        } elsif($fn eq 'median') {
3990
8
9
                                if($acc->{'bad'} && !$warned{$as}++) {
3991
0
0
                                        Carp::carp("aggregate: column '$col' has $acc->{'bad'} non-numeric value(s), skipped, e.g. '$acc->{'first_bad'}'");
3992                                }
3993
8
31
8
7
24
11
                                my @sorted = sort { $a <=> $b } @{$acc->{'vals'}};
3994
8
7
                                my $n = scalar @sorted;
3995
8
14
                                if($n == 0) {
3996
0
0
                                        $val = undef;
3997                                } elsif($n % 2) {
3998
5
9
                                        $val = $sorted[int($n / 2)];
3999                                } else {
4000
3
5
                                        $val = ($sorted[$n / 2 - 1] + $sorted[$n / 2]) / 2;
4001                                }
4002                        } elsif($fn eq 'stddev') {
4003
5
7
                                if($acc->{'bad'} && !$warned{$as}++) {
4004
0
0
                                        Carp::carp("aggregate: column '$col' has $acc->{'bad'} non-numeric value(s), skipped, e.g. '$acc->{'first_bad'}'");
4005                                }
4006
5
5
3
5
                                my $n = scalar @{$acc->{'vals'}};
4007
5
24
                                if($n < 2) {
4008
1
2
                                        $val = $n == 1 ? 0 : undef;
4009                                } else {
4010
4
3
                                        my $mean = 0;
4011
4
4
2
6
                                        $mean += $_ for @{$acc->{'vals'}};
4012
4
3
                                        $mean /= $n;
4013
4
3
                                        my $sq_sum = 0;
4014
4
4
2
8
                                        $sq_sum += ($_ - $mean) ** 2 for @{$acc->{'vals'}};
4015
4
4
                                        $val = sqrt($sq_sum / ($n - 1));
4016                                }
4017                        }
4018
91
90
                        $result{$as} = $val;
4019                }
4020
84
85
                push @out, \%result;
4021        }
4022
4023
63
128
        return \@out;
4024}
4025
4026 - 4047
=head2 query

Returns a new L<Database::Abstraction::Query> builder object bound to this
database instance, for fluent method-chaining queries.

    # All active rows with high scores, newest first, max 10
    my $rows = $db->query
        ->where(status => 'active')
        ->where(score  => { '>' => 80 })
        ->order_by('score DESC')
        ->limit(10)
        ->all();

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

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

See L<Database::Abstraction::Query> for the full API.

=cut
4048
4049sub query
4050{
4051
219
76062
        my $self = shift;
4052
219
3446
        require Database::Abstraction::Query;
4053
219
458
        return Database::Abstraction::Query->new(_db => $self);
4054}
4055
4056 - 4100
=head2 AUTOLOAD - column shortcut

Calling an unknown method whose name matches a column name performs a column
lookup.  The method name is the column you want; the arguments are criteria.

    # Scalar context: return the first match
    my $name = $db->name(entry => 'key1');

    # List context: return all matching values
    my @names = $db->name();

    # Shortcut when the table has an 'entry' key column
    my $name = $db->name('key1');    # same as name(entry => 'key1')

    # Unique/distinct values
    my @statuses = $db->status(distinct => 1);

B<In list context> the full column is returned (all rows), ordered by the
column value.  B<In scalar context> only the first match is returned
(C<LIMIT 1>).

Results come from the slurp cache when available.

Throws an error if the column does not exist (slurp mode) or if AUTOLOAD
has been disabled with C<< auto_load => 0 >>.

=head3 PSEUDOCODE

    1. Extract column name from $AUTOLOAD; guard on DESTROY.
    2. Croak if auto_load => 0.
    3. Validate $column against /^[a-zA-Z_][a-zA-Z0-9_]*$/.
    4. If data is slurped:
       a. List context, no params -> map column over all rows (exists guard).
       b. entry-only param -> direct hash lookup (exists guard).
       c. No params, scalar -> first value in hash.
       d. no_entry set -> scan array for matching key/value pair.
       e. Other params -> scan keyed hash for matching column.
    5. If not slurped, build SQL:
       - List:   SELECT column FROM table [WHERE ...] ORDER BY column
       - Scalar: SELECT DISTINCT column FROM table [WHERE ...] LIMIT 1
    6. Check cache; return on HIT.
    7. prepare_cached + execute; fetch result.
    8. Store in cache; fixate; return.

=cut
4101
4102sub AUTOLOAD {
4103
148
24240
        our $AUTOLOAD;
4104
148
553
        my ($column) = $AUTOLOAD =~ /::([A-Za-z_]\w*)\z/;
4105
4106
148
326
        return if($column eq 'DESTROY');
4107
148
252
        return if($column =~ /\A_/);    # never treat private method names as column lookups
4108
4109
144
234
        my $self = shift or return;
4110
4111
144
192
        Carp::croak(__PACKAGE__, ": Unknown column $column") if(!ref($self));
4112
4113        # Allow the AUTOLOAD feature to be disabled
4114
144
400
        Carp::croak(__PACKAGE__, ": AUTOLOAD disabled (auto_load => 0)") if(exists($self->{'auto_load'}) && !$self->{'auto_load'});
4115
4116        # Validate column name - only allow safe column name
4117
137
508
        Carp::croak(__PACKAGE__, ": Invalid column name: $column") unless $column =~ $SAFE_IDENTIFIER;
4118
4119
137
239
        my $table = $self->_open_table();
4120
4121
136
120
        my %params;
4122
136
307
        if(ref($_[0]) eq 'HASH') {
4123
2
2
3
4
                %params = %{$_[0]};
4124        } elsif((scalar(@_) % 2) == 0) {
4125
89
120
                %params = @_;
4126        } elsif(scalar(@_) == 1) {
4127                # Don't error on key-value databases, since there's no idea of columns
4128
45
123
                if($self->{'no_entry'} && !$self->{'berkeley'}) {
4129
0
0
                        Carp::croak(ref($self), "::($_[0]): ", $self->{'id'}, ' is not a column');
4130                }
4131
45
69
                $params{'entry'} = shift;
4132        }
4133
4134
136
278
        if($self->{'berkeley'}) {
4135
0
0
                if(my $id = $self->{'id'}) {
4136
0
0
                        return $self->{'berkeley'}->{$params{$id}};
4137                }
4138
0
0
                return $self->{'berkeley'}->{$params{'entry'}};
4139        }
4140
4141
136
184
        croak('Where did the data come from?') if(!defined($self->{'type'}));
4142
136
89
        my $query;
4143
136
114
        my $done_where = 0;
4144
136
315
        my $distinct = delete($params{'distinct'}) || delete($params{'unique'});
4145
4146
136
230
        if(wantarray && !$distinct) {
4147
27
83
                if(((scalar keys %params) == 0) && (my $data = $self->{'data'})) {
4148                        # Return all column values from the in-memory hash.
4149                        # Use exists() because fixate() locks inner row hashes —
4150                        # accessing a disallowed key would throw without the guard.
4151                        # Handle both HASH (keyed data) and ARRAY (no_entry CSV slurp).
4152
20
2
18
32
3
30
                        my @_rows = ref($data) eq 'ARRAY' ? @{$data} : values %{$data};
4153
20
80
26
137
                        return map { exists($_->{$column}) ? $_->{$column} : undef } @_rows;
4154                }
4155
7
24
                if($self->{'data'} && !$self->{$table} && !$self->_has_complex_criteria(\%params)) {
4156                        # Non-DBI backends (JSON, XLSX, HTML URL): scan in-memory data with criteria.
4157
1
1
                        my @param_keys = keys %params;
4158
1
0
1
3
0
1
                        my @_rows = ref($self->{'data'}) eq 'ARRAY' ? @{$self->{'data'}} : values %{$self->{'data'}};
4159
1
4
                        return map { exists($_->{$column}) ? $_->{$column} : undef }
4160                               grep {
4161
1
4
2
3
                                   my $row = $_;
4162
4
7
                                   all { $self->_match_criterion(exists($row->{$_}) ? $row->{$_} : undef, $params{$_}, $_) } @param_keys
4163
4
7
                               } @_rows;
4164                }
4165
6
9
                my $id = $self->{'id'};
4166
6
47
                if(($self->{'type'} eq 'CSV') && !$self->{'no_entry'}) {
4167
4
5
                        $query = "SELECT $column FROM $table WHERE $id IS NOT NULL AND $id NOT LIKE '#%'";
4168
4
5
                        $done_where = 1;
4169                } else {
4170
2
3
                        $query = "SELECT $column FROM $table";
4171                }
4172        } else {
4173
109
158
                if(my $data = $self->{'data'}) {
4174                        # The data has been read in using Text::xSV::Slurp,
4175                        #       so no need to do any SQL
4176
80
120
                        $self->_debug('AUTOLOAD using slurped data');
4177
80
2384
                        if($self->{'no_entry'}) {
4178
4
5
                                $self->_debug('no_entry is set');
4179
4
94
                                my ($key, $value) = %params;
4180
4
8
                                if(defined($key)) {
4181
4
11
                                        $self->_debug("key = $key, value = $value, column = $column");
4182
4
4
55
6
                                        foreach my $row(@{$data}) {
4183                                                # exists() guards: fixate() locks row hashes recursively
4184
6
19
                                                next unless exists($row->{$key}) && defined($row->{$key}) && $row->{$key} eq $value;
4185
3
6
                                                my $rc = exists($row->{$column}) ? $row->{$column} : undef;
4186
3
13
                                                $self->_trace(__LINE__, ": AUTOLOAD $key: return ", defined($rc) ? "'$rc'" : 'undef', ' from slurped data');
4187
3
45
                                                return $rc;
4188                                        }
4189
1
2
                                        $self->_debug('not found in slurped data');
4190                                }
4191                        } elsif(((scalar keys %params) == 1) && defined(my $key = $params{'entry'})) {
4192                                # Look up a single entry by its key.
4193                                # Use exists() before accessing — fixate() locks the outer hash and
4194                                # dereferencing a missing key on a locked hash throws an exception.
4195
62
54
                                my $rc;
4196
62
152
                                if(exists($data->{$key}) && defined(my $hash = $data->{$key})) {
4197
50
67
                                        if(!exists($hash->{$column})) {
4198
4
33
                                                Carp::croak(__PACKAGE__, ": There is no column $column in $table");
4199                                        }
4200
46
65
                                        $rc = $hash->{$column};
4201                                }
4202
58
99
                                if(defined($rc)) {
4203
44
112
                                        $self->_trace(__LINE__, ": AUTOLOAD $key: return '$rc' from slurped data");
4204                                } else {
4205
14
32
                                        $self->_trace(__LINE__, ": AUTOLOAD $key: return undef from slurped data");
4206                                }
4207
58
1373
                                return $rc
4208                        } elsif((scalar keys %params) == 0) {
4209
10
15
                                if(wantarray) {
4210
10
14
                                        if($distinct) {
4211                                                # Single pass instead of three (map→grep→map): avoids three
4212                                                # intermediate lists of size N on the stack.
4213
10
10
                                                my %h;
4214
10
10
10
18
                                                for my $r (values %{$data}) {
4215
38
47
                                                        my $v = exists($r->{$column}) ? $r->{$column} : undef;
4216
38
52
                                                        $h{$v} = 1 if defined $v;
4217                                                }
4218
10
30
                                                return keys %h;
4219                                        }
4220                                        # DEAD CODE: unreachable because the outer `if(wantarray && !$distinct)`
4221                                        # handles the wantarray+!distinct case. In this else branch, wantarray
4222                                        # implies $distinct (which returns above), so this line is never executed.
4223                                        # return map { exists($_->{$column}) ? $_->{$column} : undef } values %{$data}
4224                                }
4225                                # Scalar: return the first value found without building a full list
4226
0
0
0
0
                                foreach my $v (values %{$data}) {
4227
0
0
                                        return exists($v->{$column}) ? $v->{$column} : undef;
4228                                }
4229                        } else {
4230                                # Keyed data but filtering on a non-key column
4231
4
9
                                my ($key, $value) = %params;
4232
4
4
5
8
                                foreach my $row (values %{$data}) {
4233
7
24
                                        next unless exists($row->{$key}) && defined($row->{$key}) && $row->{$key} eq $value;
4234
4
7
                                        next unless exists($row->{$column});
4235
4
9
                                        if(my $rc = $row->{$column}) {
4236
4
11
                                                $self->_trace(__LINE__, ": AUTOLOAD $key: return '$rc' from slurped data");
4237
4
179
                                                return $rc
4238                                        }
4239                                }
4240                        }
4241                        return
4242
1
16
                }
4243                # Data has not been slurped in
4244
29
33
                my $id = $self->{'id'};
4245
29
96
                if(($self->{'type'} eq 'CSV') && !$self->{'no_entry'}) {
4246
17
46
                        $query = "SELECT DISTINCT $column FROM $table WHERE $id IS NOT NULL AND $id NOT LIKE '#%'";
4247
17
16
                        $done_where = 1;
4248                } else {
4249
12
18
                        $query = "SELECT DISTINCT $column FROM $table";
4250                }
4251        }
4252
35
30
        my @args;
4253        # Avoid `each` — it carries hidden iterator state across calls
4254
35
64
        for my $k (sort keys %params) {
4255                # Guard against SQL injection via column names — same rule as _build_where_conditions
4256
28
157
                Carp::croak(__PACKAGE__, ": unsafe column name '$k'")
4257                        unless $k =~ $SAFE_QUALIFIED;
4258
24
29
                my $value = $params{$k};
4259
24
61
                $self->_debug(__PACKAGE__, ": AUTOLOAD adding key/value pair $k=>", defined($value) ? $value : 'NULL');
4260
24
1019
                if(defined($value)) {
4261
22
62
                        $query .= $done_where ? " AND $k = ?" : " WHERE $k = ?";
4262
22
20
                        $done_where = 1;
4263
22
36
                        push @args, $value;
4264                } else {
4265
2
4
                        $query .= $done_where ? " AND $k IS NULL" : " WHERE $k IS NULL";
4266
2
4
                        $done_where = 1;
4267                }
4268        }
4269
31
38
        if(wantarray) {
4270
7
8
                $query .= " ORDER BY $column";
4271        } else {
4272
24
42
                $query .= ' LIMIT 1';
4273        }
4274
31
81
        if(scalar(@args) && $args[0]) {
4275
22
53
                $self->_debug("AUTOLOAD $query: ", join(', ', @args));
4276        } else {
4277
9
16
                $self->_debug("AUTOLOAD $query");
4278        }
4279
31
990
        my $cache;
4280
31
46
        my $key = ref($self) . '::';
4281
31
48
        if($cache = $self->{cache}) {
4282
11
14
                if(wantarray) {
4283
2
3
                        $key .= 'array ';
4284                }
4285
11
14
                if(defined($args[0])) {
4286
9
15
                        $key .= "fetchrow $query " . join(', ', @args);
4287                } else {
4288
2
3
                        $key .= "fetchrow $query";
4289                }
4290
11
24
                if(my $rc = $cache->get($key)) {
4291
5
382
                        $self->_debug('cache HIT');
4292
5
1
119
4
                        return wantarray ? @{$rc} : $rc;        # We stored a ref to the array
4293                }
4294
6
305
                $self->_debug('cache MISS');
4295        } else {
4296
20
21
                $self->_debug('cache not used');
4297        }
4298
26
877
        my $sth = $self->{$table}->prepare_cached($query) || croak($query);
4299
26
38097
        $sth->execute(@args) || croak($query);
4300
4301
26
32317
        if(wantarray) {
4302                # fetchall_arrayref([0]) asks DBI to project column 0 server-side, so each
4303                # returned row is [$col0] instead of the full row — less memory, same result.
4304
6
21
6
6
390
39
                my @rc = map { $_->[0] } @{$sth->fetchall_arrayref([0])};
4305
6
14
                if($cache) {
4306
1
3
                        $cache->set($key, \@rc, $self->{'cache_duration'});       # Store a ref to the array
4307                }
4308
6
144
                Database::Abstraction::_fixate($self, \@rc) if(scalar(@rc) && !$self->{'no_fixate'});
4309
6
395
                return @rc;
4310        }
4311
20
116
        my $rc = $sth->fetchrow_array();     # Return the first match only
4312
20
333
        $sth->finish();
4313
20
76
        if($cache) {
4314                # Store the value, then return it — cache->set() return value is unreliable
4315
5
15
                $cache->set($key, $rc, $self->{'cache_duration'});
4316        }
4317
20
716
        return $rc;
4318}
4319
4320sub DESTROY
4321{
4322
668
212515
        if(defined($^V) && ($^V ge 'v5.14.0')) {
4323
668
1121
                return if ${^GLOBAL_PHASE} eq 'DESTRUCT';       # >= 5.14.0 only
4324        }
4325
4326
668
994
        my $self = shift;
4327
4328        # Clean up temporary files — deleting File::Temp objects triggers auto-unlink/rmdir
4329        # If that doesn't happen for some reason, explicitly unlink
4330
668
962
        if(defined $self->{'_temp_fh'}) {
4331
9
12
                my $temp_fh = $self->{'_temp_fh'};
4332
9
9
10
20
                my $temp_path = eval { $temp_fh->filename() };
4333
9
42
                delete $self->{'_temp_fh'};
4334
9
15
                undef $temp_fh; # Release the local strong reference
4335                # Fallback explicit unlink if File::Temp didn't clean up
4336
9
612
                unlink($temp_path) if defined($temp_path) && -f $temp_path;
4337        }
4338
668
840
        delete $self->{'_remote_tmpdir'};
4339
4340        # Disconnect Redis
4341
668
903
        if(my $redis = delete $self->{'_redis'}) {
4342
0
0
0
0
                eval { $redis->quit() };
4343        }
4344
4345        # Clean up database handles
4346
668
1270
        my $table_name = $self->{'table'} || ref($self);
4347
668
1725
        $table_name =~ s/\A.*:://;
4348
4349
668
999
        if(my $dbh = delete $self->{$table_name}) {
4350
394
6938
                $dbh->disconnect() if $dbh->can('disconnect');
4351
394
5655
                $dbh->finish() if $dbh->can('finish');
4352        }
4353
4354        # Clean up Berkeley DB
4355
668
926
        if($self->{'berkeley'}) {
4356
23
26
                eval {
4357
23
23
19
31
                        untie %{$self->{'berkeley'}};
4358                };
4359
23
32
                delete $self->{'berkeley'};
4360        }
4361
4362        # Clear all other attributes to break potential circular references
4363
668
1318
        foreach my $key (keys %$self) {
4364
7192
20042
                delete $self->{$key};
4365        }
4366}
4367
4368# Build the JOIN clause(s) from a single join hashref or arrayref of hashrefs.
4369# Each spec needs keys: table (required), on (required), type (default INNER).
4370sub _build_joins
4371{
4372
66
4169
        my ($self, $join_spec) = @_;
4373
4374
66
8
89
9
        my @specs = ref($join_spec) eq 'ARRAY' ? @{$join_spec} : ($join_spec);
4375
66
56
        my @clauses;
4376
4377
66
54
        for my $j (@specs) {
4378
67
124
                my $type  = uc($j->{'type'}  // 'INNER');
4379
67
107
                my $jtable = $j->{'table'} or Carp::croak('join: missing "table"');
4380
63
286
                Carp::croak("join: unsafe table name '$jtable'")
4381                        unless $jtable =~ $SAFE_QUALIFIED;
4382
52
89
                my $on     = $j->{'on'}    or Carp::croak('join: missing "on" condition');
4383
47
137
                Carp::croak("Invalid JOIN type: $type") unless $VALID_JOIN_TYPES{$type};
4384
40
68
                push @clauses, "$type JOIN $jtable ON ($on)";
4385        }
4386
4387
39
63
        return join(' ', @clauses);
4388}
4389
4390# Infer a SQL type token from an arrayref of sample values for one column.
4391# Returns 'INTEGER', 'REAL', 'TIMESTAMP', 'DATE', or 'TEXT' (the default).
4392# undef and empty-string values are treated as NULL and skipped; a column
4393# whose entire sample is NULL is typed TEXT (most permissive safe default).
4394# Order of checks: INTEGER before REAL (integers also match REAL); TIMESTAMP
4395# before DATE (timestamps start like dates but have a time component).
4396sub _infer_type
4397{
4398
21
236
        my ($vals) = @_;
4399
21
58
21
13
79
21
        my @non_null = grep { defined($_) && $_ ne '' } @{$vals};
4400
21
26
        return 'TEXT' unless @non_null;
4401
18
30
37
72
        return 'INTEGER'   if all { $_ =~ $INFER_INT_RE  } @non_null;
4402
12
22
26
59
        return 'REAL'      if all { $_ =~ $INFER_REAL_RE } @non_null;
4403
7
10
17
25
        return 'TIMESTAMP' if all { $_ =~ $INFER_TS_RE   } @non_null;
4404
5
8
13
21
        return 'DATE'      if all { $_ =~ $INFER_DATE_RE } @non_null;
4405
3
7
        return 'TEXT';
4406}
4407
4408# Merge $self->{'base_criteria'} into a caller-supplied $params hashref.
4409# Returns a new hashref with base_criteria keys added; caller keys WIN on collision
4410# so callers can always further restrict.  Returns $params unchanged when no
4411# base_criteria is set, avoiding an extra allocation on the common code path.
4412sub _merge_base_criteria :Private {
4413        my ($self, $params) = @_;
4414        my $bc = $self->{'base_criteria'};
4415        return $params unless $bc;
4416        return { %{$bc}, %{$params} };
4417
51
51
51
424083
68
634
}
4418
4419# Parse a sort_by parameter value into ($col, $dir).
4420# Accepts: scalar column name (ASC), or [$col, $dir] arrayref.
4421# Validates column against $SAFE_QUALIFIED and direction as ASC/DESC.
4422# Carps and returns (undef, 'ASC') on invalid input so callers fall back
4423# to the default id-column sort.
4424sub _parse_sort_by
4425{
4426
520
6949
        my ($sb, $method) = @_;
4427
520
806
        return (undef, 'ASC') unless defined $sb;
4428
38
17
64
25
        my ($col, $dir) = ref($sb) eq 'ARRAY' ? @{$sb}[0, 1] : ($sb, undef);
4429
38
49
        $dir = defined($dir) ? uc($dir) : 'ASC';
4430
38
165
        unless(defined($col) && $col =~ $SAFE_QUALIFIED) {
4431
11
82
                Carp::carp("$method: unsafe sort_by column, ignoring");
4432
11
1816
                return (undef, 'ASC');
4433        }
4434
27
56
        unless($dir eq 'ASC' || $dir eq 'DESC') {
4435
5
34
                Carp::carp("$method: invalid sort_by direction '$dir', ignoring");
4436
5
691
                return (undef, 'ASC');
4437        }
4438
22
36
        return ($col, $dir);
4439}
4440
4441# Return true when $params contains operator hashrefs, -or, or -and groupings
4442# that the simple slurp fast-path cannot handle.
4443sub _has_complex_criteria
4444{
4445
421
388
        my ($self, $params) = @_;
4446
421
447
        return 0 unless defined $params;
4447        # -or / -and keys short-circuit immediately: no value scan needed.
4448
418
864
        return 1 if exists $params->{'-or'} || exists $params->{'-and'};
4449        # LOGICAL INVARIANT: when this returns false, NO value in %$params is a
4450        # reference.  This is the Boolean gate that makes _match_criterion's
4451        # hashref branch provably unreachable from the public select API:
4452        #   Premise A — in-memory scan path requires: !_has_complex_criteria(params)
4453        #   Premise B — _has_complex_criteria returns true if any value is a ref
4454        #   Conclusion — inside the in-memory scan, every crit_val is a scalar/undef
4455        # any() short-circuits on the first truthy element; the for-loop equivalent
4456        # would iterate through all values before returning false in the common case.
4457
411
230
411
698
542
921
        return (any { ref($_) } values %{$params}) ? 1 : 0;
4458}
4459
4460# Build the WHERE clause body (everything after "WHERE") from a criteria hash.
4461# Handles -or / -and groupings then delegates per-column work to _build_where_conditions.
4462# Returns ($sql_fragment, \@bind_values).
4463# Wrapper around Data::Reuse::fixate() that suppresses the spurious
4464# "Use of uninitialized value in hash slice" warning.  Data::Alias's XS
4465# hash-aliasing code does not fully initialise key SVs on older Perl
4466# versions when the source hash contains undef values (NULL columns).
4467# Data::Reuse still fixates correctly; the warning is a false positive.
4468# $struct must be the hashref or arrayref to fixate.
4469sub _fixate :Private
4470{
4471        my (undef, $struct) = @_;
4472        return unless defined $struct;
4473        # Clear stale address→canonical mappings from prior fixate calls before
4474        # fixating $struct.  Without this, freed hashref addresses from a previous
4475        # object's slurp or DBI result can be reused by the allocator for new
4476        # hashrefs; fixate() would then find the stale entry and alias the new
4477        # hashref to the wrong canonical, silently substituting one row's data for
4478        # another.  This is the same stale-address hazard that affects DBI paths
4479        # (see selectall_arrayref / selectall_array) but also affects the slurp
4480        # fixate when earlier objects go out of scope before a new slurp runs.
4481        Data::Reuse::forget();
4482        # Suppress the benign "Use of uninitialized value in hash slice" warning
4483        # that Data::Reuse/Data::Alias emits on some Perl < 5.34 builds when a
4484        # row has undef columns.  All other warnings are re-emitted verbatim so
4485        # unrelated problems remain visible.  This is intentional, not debug code.
4486        local $SIG{__WARN__} = sub {
4487                warn @_ unless
4488                        index($_[0], 'Use of uninitialized value') == 0
4489                        && index($_[0], 'in hash slice') >= 0;
4490        };
4491        &Data::Reuse::fixate($struct);
4492
51
51
51
32313
47
487
}
4493
4494sub _build_where
4495{
4496
612
5993
        my ($self, $params) = @_;
4497
4498
612
633
        $params //= {};
4499
612
486
        my @clauses;
4500        my @args;
4501
4502        # Avoid an O(K) hash copy in the common case where neither -or nor -and is
4503        # present.  Only copy when we actually need to delete grouping keys, so the
4504        # plain-criteria path (the vast majority of queries) passes $params through
4505        # directly to _build_where_conditions without any allocation.
4506
612
508
        my $or_list  = $params->{'-or'};
4507
612
552
        my $and_list = $params->{'-and'};
4508
612
438
        my $plain;
4509
612
985
        if(defined($or_list) || defined($and_list)) {
4510
25
25
41
33
                my %p = %{$params};
4511
25
31
                delete @p{qw(-or -and)};
4512
25
53
                $plain = \%p;
4513        } else {
4514
587
448
                $plain = $params;
4515        }
4516
4517
612
612
        if($or_list) {
4518
17
15
                my (@sub_clauses, @sub_args);
4519
17
17
17
21
                for my $cond (@{$or_list}) {
4520
32
36
                        my ($s, $a) = $self->_build_where_conditions($cond);
4521
32
38
                        if($s) {
4522
32
31
                                push @sub_clauses, "($s)";
4523
32
32
20
37
                                push @sub_args, @{$a};
4524                        }
4525                }
4526
17
24
                if(@sub_clauses) {
4527
16
52
                        push @clauses, '(' . join(' OR ', @sub_clauses) . ')';
4528
16
20
                        push @args, @sub_args;
4529                }
4530        }
4531
612
564
        if($and_list) {
4532
8
7
                my (@sub_clauses, @sub_args);
4533
8
8
10
10
                for my $cond (@{$and_list}) {
4534
13
19
                        my ($s, $a) = $self->_build_where_conditions($cond);
4535
13
40
                        if($s) {
4536
13
15
                                push @sub_clauses, "($s)";
4537
13
13
8
17
                                push @sub_args, @{$a};
4538                        }
4539                }
4540
8
11
                if(@sub_clauses) {
4541
7
12
                        push @clauses, '(' . join(' AND ', @sub_clauses) . ')';
4542
7
10
                        push @args, @sub_args;
4543                }
4544        }
4545
4546
612
863
        my ($more, $margs) = $self->_build_where_conditions($plain);
4547
592
686
        if($more) {
4548
246
208
                push @clauses, $more;
4549
246
246
174
219
                push @args, @{$margs};
4550        }
4551
4552
592
966
        return (join(' AND ', @clauses), \@args);
4553}
4554
4555# Build a WHERE-body fragment for a flat col => val hash.
4556# Values may be plain scalars (= / LIKE / IS NULL) or operator hashrefs
4557# ({ '>' => n }, { -in => [...] }, { -between => [lo,hi] }, etc.).
4558sub _build_where_conditions
4559{
4560
688
9020
        my ($self, $params) = @_;
4561
4562
688
507
        my @clauses;
4563        my @args;
4564
4565
688
688
461
1016
        for my $col (sort keys %{$params}) {
4566
359
331
                my $val = $params->{$col};
4567
4568                # Guard against SQL injection via column names; allow table.column notation for JOINs
4569
359
1335
                Carp::croak("_build_where_conditions: unsafe column name '$col'")
4570                        unless $col =~ $SAFE_QUALIFIED;
4571
4572
341
742
                if(ref($val) eq 'HASH') {
4573
105
105
74
117
                        for my $op (keys %{$val}) {    # no sort — operator hashes typically have 1-2 keys; sort adds O(K log K) overhead
4574
107
86
                                my $operand = $val->{$op};
4575
107
392
                                if($op eq '-in' || $op eq '-not_in') {
4576
19
26
                                        my $sql_op = $op eq '-in' ? 'IN' : 'NOT IN';
4577
19
19
18
30
                                        my $ph = join(', ', ('?') x scalar(@{$operand}));
4578
19
31
                                        push @clauses, "$col $sql_op ($ph)";
4579
19
19
15
38
                                        push @args, @{$operand};
4580                                } elsif($op eq '-between') {
4581
8
11
                                        push @clauses, "$col BETWEEN ? AND ?";
4582
8
18
                                        push @args, $operand->[0], $operand->[1];
4583                                } elsif($op eq '-like') {
4584
9
12
                                        push @clauses, "$col LIKE ?";
4585
9
18
                                        push @args, $operand;
4586                                } elsif($op eq '-not_like') {
4587
6
9
                                        push @clauses, "$col NOT LIKE ?";
4588
6
10
                                        push @args, $operand;
4589                                } elsif($op eq '!=') {
4590
8
12
                                        if(!defined($operand)) {
4591
2
5
                                                push @clauses, "$col IS NOT NULL";
4592                                        } else {
4593
6
8
                                                push @clauses, "$col != ?";
4594
6
10
                                                push @args, $operand;
4595                                        }
4596                                # [<>]=? matches >, <, >=, <= as a single character class — no alternation
4597                                # overhead, no backtracking, and self-documenting.
4598                                } elsif($op =~ /\A[<>]=?\z/) {
4599
54
69
                                        push @clauses, "$col $op ?";
4600
54
86
                                        push @args, $operand;
4601                                } else {
4602
3
34
                                        Carp::croak("Unknown operator '$op' for column '$col'");
4603                                }
4604                        }
4605                } elsif(ref($val)) {
4606
5
35
                        Carp::croak("$col: expected scalar or operator hashref, got ", ref($val));
4607                } elsif(!defined($val)) {
4608
11
20
                        push @clauses, "$col IS NULL";
4609                } elsif($val =~ /[%_]/) {
4610
16
24
                        push @clauses, "$col LIKE ?";
4611
16
25
                        push @args, $val;
4612                } else {
4613
204
246
                        push @clauses, "$col = ?";
4614
204
247
                        push @args, $val;
4615                }
4616        }
4617
4618
662
1212
        return (join(' AND ', @clauses), \@args);
4619}
4620
4621# Test a single in-memory row value against a criteria value.
4622# $crit_val may be a plain scalar or an operator hashref.
4623# Returns true when the row value satisfies the criterion.
4624# Scan the entire BerkeleyDB tied hash, building rows as {entry=>$k, value=>$v},
4625# and filter by $params criteria using _match_criterion.
4626# Croaks when JOINs or -or/-and groupings are requested (unsupported for key-value stores).
4627sub _scan_berkeley
4628{
4629
28
835
        my ($self, $params) = @_;
4630
28
34
        $params //= {};
4631
4632        # TODO: Data Flow Anomaly - Mutation side effect: delete mutates the caller's
4633        # $params hashref in-place.  All current callers (selectall_arrayref,
4634        # selectall_array, count) do not reuse $params after this call, so it is safe;
4635        # but a future caller that reuses $params would silently lose the 'join' key.
4636        # Fix: use exists($params->{'join'}) to check; copy params before deleting.
4637
28
40
        if(delete $params->{'join'}) {
4638
3
37
                Carp::croak(ref($self), ': BerkeleyDB does not support JOINs');
4639        }
4640        # any() short-circuits: once the first -or/-and key is found the croak fires
4641        # without scanning the remaining keys — O(1) best case vs O(K) for grep.
4642
25
12
25
48
31
59
        if(any { $_ eq '-or' || $_ eq '-and' } keys %{$params}) {
4643
4
42
                Carp::croak(ref($self), ': BerkeleyDB does not support -or/-and groupings');
4644        }
4645
4646
21
42
        my $bdb = $self->{'berkeley'};
4647
21
21
18
21
        my @cols = keys %{$params};
4648
21
17
        my @rows;
4649
4650
21
20
        if(@cols) {
4651                # Single-pass build+filter: avoids materialising the full N-row list
4652                # before filtering.  Peak memory is O(matching rows) instead of O(N).
4653
8
8
6
11
                for my $k (keys %{$bdb}) {
4654
22
24
                        my $row = { entry => $k, value => $bdb->{$k} };
4655
22
18
                        my $match = 1;
4656
22
13
                        for my $col (@cols) {
4657
22
31
                                unless($self->_match_criterion($row->{$col}, $params->{$col}, $col)) {
4658
13
5
                                        $match = 0;
4659
13
12
                                        last;
4660                                }
4661                        }
4662
22
28
                        push @rows, $row if $match;
4663                }
4664        } else {
4665
13
25
13
10
40
17
                @rows = map { { entry => $_, value => $bdb->{$_} } } keys %{$bdb};
4666        }
4667
4668
21
36
        return \@rows;
4669}
4670
4671# SQL LIKE match — case-insensitive, ReDoS-safe, no catastrophic backtracking.
4672# % matches any sequence of chars; _ matches exactly one char.
4673#
4674# Fast paths cover the four most common LIKE shapes using O(1) string ops:
4675#   '%'        â†’ always true
4676#   no wildcard → lc eq comparison
4677#   '%suffix'  â†’ ends-with check via substr
4678#   'prefix%'  â†’ starts-with check via index
4679#   '%mid%'    â†’ contains check via index  (single inner literal, no _ wildcards)
4680#
4681# Full DP uses O(m) memory (two 1-D rolling arrays) instead of the O(m*n) 2-D
4682# table the naive approach allocates.  Character access uses substr() instead of
4683# split(//) so no per-character scalar objects are created.
4684sub _like_match
4685{
4686
83
4642
        my ($str, $pattern) = @_;
4687
4688        # Fast path 1: bare '%' — matches any string regardless of content.
4689
83
99
        return 1 if $pattern eq '%';
4690
4691
75
70
        my $lc_str = lc($str);
4692
75
56
        my $lc_pat = lc($pattern);
4693
75
57
        my $pat_len = length($lc_pat);
4694
75
57
        my $str_len = length($lc_str);
4695
4696        # Fast path 2: no wildcard characters — plain case-insensitive equality.
4697
75
143
        return ($lc_str eq $lc_pat)
4698                if index($lc_pat, '%') == -1 && index($lc_pat, '_') == -1;
4699
4700        # Fast paths 3-5 apply only when the pattern has no '_' wildcards.
4701        # (Patterns with '_' need per-character DP to enforce the single-char rule.)
4702
61
81
        if(index($lc_pat, '_') == -1) {
4703
48
38
                my $first_pct = index($lc_pat, '%');
4704
48
42
                my $last_pct  = rindex($lc_pat, '%');
4705
4706                # Fast path 3: '%suffix' — exactly one '%', at the start.
4707
48
78
                if($first_pct == 0 && $last_pct == 0) {
4708
12
16
                        my $sfx = substr($lc_pat, 1);
4709
12
5
                        my $sfx_len = length($sfx);
4710
12
43
                        return $str_len >= $sfx_len
4711                                && substr($lc_str, $str_len - $sfx_len) eq $sfx;
4712                }
4713
4714                # Fast path 4: 'prefix%' — exactly one '%', at the end.
4715
36
65
                if($last_pct == $pat_len - 1 && $first_pct == $pat_len - 1) {
4716
17
18
                        my $pfx = substr($lc_pat, 0, $pat_len - 1);
4717
17
41
                        return index($lc_str, $pfx) == 0;
4718                }
4719
4720                # Fast path 5: '%literal%' — '%' at both ends, no inner '%'.
4721                # pat_len >= 3 ensures there are two distinct '%' characters.
4722                # Only return here when the middle segment is free of further wildcards;
4723                # if it contains '%' (e.g. '%a%b%'), fall through to the full DP.
4724
19
66
                if($first_pct == 0 && $last_pct == $pat_len - 1 && $pat_len >= 3) {
4725
12
14
                        my $needle = substr($lc_pat, 1, $pat_len - 2);
4726
12
15
                        if(index($needle, '%') == -1) {
4727
7
19
                                return index($lc_str, $needle) >= 0;
4728                        }
4729                }
4730        }
4731
4732        # Full DP — O(m*n) time, O(m) memory.
4733        # Two 1-D arrayrefs (@$prev, @$curr) replace the O(m*n) 2-D table.
4734        # substr() replaces split(//) — no per-char scalar allocation.
4735        # Ref-swap ($prev,$curr) = ($curr,$prev) at the end of each outer iteration
4736        # avoids the O(m) array copy that "@prev = @curr" would perform.
4737
25
25
        my $m = $str_len;
4738
25
12
        my $n = $pat_len;
4739
4740
25
135
        my ($prev, $curr) = ([1, (0) x $m], [(0) x ($m + 1)]);
4741
4742
25
34
        for my $i (1 .. $n) {
4743
252
252
164
765
                @{$curr} = (0) x ($m + 1);    # zero in-place — reuses existing allocation
4744
252
188
                my $pc = substr($lc_pat, $i - 1, 1);
4745
252
177
                if($pc eq '%') {
4746
129
79
                        $curr->[0] = $prev->[0];
4747
129
89
                        for my $j (1 .. $m) {
4748
14596
12005
                                $curr->[$j] = ($prev->[$j] || $curr->[$j - 1]) ? 1 : 0;
4749                        }
4750                } else {
4751
123
91
                        for my $j (1 .. $m) {
4752
14197
15366
                                $curr->[$j] = ($prev->[$j - 1]
4753                                        && ($pc eq '_' || $pc eq substr($lc_str, $j - 1, 1))) ? 1 : 0;
4754                        }
4755                }
4756
252
184
                ($prev, $curr) = ($curr, $prev);    # O(1) ref swap — no array copy
4757        }
4758
25
67
        return $prev->[$m];
4759}
4760
4761sub _match_criterion
4762{
4763
374
1749
        my ($self, $row_val, $crit_val, $col) = @_;
4764
4765        # Use numeric equality/membership operators when the schema (if already
4766        # cached) reports the column as INTEGER or REAL.  The numeric ordering
4767        # operators (>, <, >=, <=, -between) already use Perl's numeric context
4768        # regardless; only equality-family operators need the type-aware path.
4769        my $numeric = ($col && $self->{'_schema'})
4770
374
26
531
33
                ? do { my $t = ($self->{'_schema'}{$col} // {})->{'type'} // 'TEXT';
4771
26
38
                       $t eq 'INTEGER' || $t eq 'REAL' }
4772                : 0;
4773
4774
374
364
        if(ref($crit_val) eq 'HASH') {
4775
109
109
76
115
                for my $op (keys %{$crit_val}) {
4776
108
90
                        my $operand = $crit_val->{$op};
4777
108
241
                        if($op eq '-in') {
4778                                return 0 unless defined($row_val)
4779                                        && ($numeric
4780
4
2
8
3
                                                ? grep { $row_val == $_ } @{$operand}
4781
11
20
8
21
32
7
                                                : grep { $row_val eq $_ } @{$operand});
4782                        } elsif($op eq '-not_in') {
4783                                return 0 if defined($row_val)
4784                                        && ($numeric
4785
2
2
5
2
                                                ? grep { $row_val == $_ } @{$operand}
4786
8
16
6
17
23
6
                                                : grep { $row_val eq $_ } @{$operand});
4787                        } elsif($op eq '-between') {
4788
10
64
                                return 0 unless defined($row_val) && $row_val >= $operand->[0] && $row_val <= $operand->[1];
4789                        } elsif($op eq '-like') {
4790
31
37
                                return 0 unless defined($row_val);
4791
30
33
                                return 0 unless _like_match($row_val, $operand);
4792                        } elsif($op eq '-not_like') {
4793
5
9
                                return 0 unless defined($row_val);
4794
4
7
                                return 0 if _like_match($row_val, $operand);
4795                        } elsif($op eq '!=') {
4796
14
17
                                if(!defined($operand)) {
4797
5
12
                                        return 0 unless defined($row_val);
4798                                } elsif($numeric) {
4799
2
9
                                        return 0 unless defined($row_val) && $row_val != $operand;
4800                                } else {
4801
7
21
                                        return 0 unless defined($row_val) && $row_val ne $operand;
4802                                }
4803                        } elsif($op eq '>') {
4804
16
47
                                return 0 unless defined($row_val) && $row_val > $operand;
4805                        } elsif($op eq '<') {
4806
5
15
                                return 0 unless defined($row_val) && $row_val < $operand;
4807                        } elsif($op eq '>=') {
4808
4
12
                                return 0 unless defined($row_val) && $row_val >= $operand;
4809                        } elsif($op eq '<=') {
4810
4
10
                                return 0 unless defined($row_val) && $row_val <= $operand;
4811                        }
4812                }
4813
58
129
                return 1;
4814        }
4815
4816
265
861
        return !defined($row_val) && !defined($crit_val) ? 1
4817                : !defined($row_val) || !defined($crit_val) ? 0
4818                : $numeric ? $row_val == $crit_val
4819                : $row_val eq $crit_val;
4820}
4821
4822# Determine the table and open the database
4823sub _open_table
4824{
4825
2854
2766
        my($self, $params) = @_;
4826
4827        # Derive the table name, caching the result in '_table_name' for the common
4828        # case of no caller-supplied 'table' override.  Avoids repeating ref()+regex
4829        # on every query when the same object makes many calls.
4830
2854
1901
        my $table;
4831
2854
3087
        if($params->{'table'}) {
4832
10
17
                ($table = $params->{'table'}) =~ s/\A.*:://;
4833
10
103
                Carp::croak(ref($self), ": unsafe table name '$table'")
4834                        unless $table =~ $SAFE_QUALIFIED;
4835        } else {
4836
2844
4045
                $table = $self->{'_table_name'} //= do {
4837
520
1130
                        my $t = $self->{'table'} || ref($self);
4838
520
1340
                        $t =~ s/\A.*:://;
4839
520
845
                        $t;
4840                };
4841        }
4842
4843        # Open a connection if it's not already open.
4844        # BerkeleyDB never sets $self->{$table} (no DBI handle) or $self->{'data'},
4845        # so we also guard on $self->{'berkeley'} to avoid re-tying on every call.
4846
2847
5446
        $self->_open() if(!$self->{$table} && !$self->{'data'} && !$self->{'berkeley'});
4847
4848
2797
2698
        return $table;
4849}
4850
4851# Quote a SQL identifier using the current connection's dialect rules.
4852# Falls back to ANSI double-quoting when no connection is available.
4853sub _quote_identifier
4854{
4855
4
1260
        my ($self, $name) = @_;
4856
4857
4
12
        my $table = $self->{'table'} || ref($self);
4858
4
10
        $table =~ s/\A.*:://;
4859
4
7
        if(my $dbh = $self->{$table}) {
4860
1
7
                return $dbh->quote_identifier($name);
4861        }
4862
3
8
        return qq{"$name"};
4863}
4864
4865# Determine whether a given file is a valid Berkeley DB file.
4866# It combines a fast preliminary check with a more thorough validation step for accuracy.
4867# It looks for the magic number at both byte 0 and byte 12.
4868sub _is_berkeley_db {
4869
332
822
        my ($self, $file) = @_;
4870
4871        # Step 1: Check magic number
4872        # no autodie here: the file may not exist, and we want a silent false return
4873
332
288
        my $fh;
4874
51
51
51
332
332
81361
57
220
238
4196
        do { no autodie qw(open); open $fh, '<', $file } or return 0;
4875
2
7
        binmode $fh;
4876
4877
2
518
        my $is_db = $self->_has_bdb_magic($fh);
4878
2
5
        close $fh;
4879
4880
2
354
        if($is_db) {
4881                # Step 2: Attempt to open as Berkeley DB
4882
4883
0
0
                require DB_File;
4884
4885
0
0
                my %bdb;
4886
0
0
                if(tie %bdb, 'DB_File', $file, O_RDONLY, 0644, $DB_File::DB_HASH) {
4887                        # untie %db;
4888
0
0
                        $self->{'berkeley'} = \%bdb;
4889
0
0
                        return 1;       # Successfully identified as a Berkeley DB file
4890                }
4891        }
4892
2
7
        return 0;
4893}
4894
4895# Check for Berkeley DB magic bytes at offsets 0 and 12.
4896# Returns true if either location contains a recognised BDB magic number.
4897sub _has_bdb_magic {
4898
8
1678
        my ($self, $fh) = @_;
4899
4900        # Offset 0: 32-bit magic number in both endian forms
4901
8
14
        read($fh, my $buf, 4) == 4 or return 0;
4902
7
28
675
34
        my %magic = map { $_ => 1 } (0x00061561, 0x00053162, 0x00042253, 0x00052444);
4903
7
36
        return 1 if $magic{unpack('N', $buf)} || $magic{unpack('V', $buf)};
4904
4905        # Offset 12: Btree magic prefix (fallback for some BDB file variants)
4906
4
8
        seek $fh, 12, 0 or return 0;
4907
4
495
        read($fh, $buf, 4) or return 0;
4908
3
80
        my $hex12 = substr(unpack('H*', $buf), 0, 4);
4909
3
13
        return($hex12 eq '6115' || $hex12 eq '1561');
4910}
4911
4912# Return true if $host refers to the current machine (localhost, loopback, or
4913# the machine's own hostname).  Strips an optional user@ prefix first.
4914# Used by new() and _open() to decide whether to use local file access instead
4915# of File::Slurp::Remote, so the caller never loads that module unnecessarily.
4916sub _is_local_host {
4917
79
525
        my ($self, $host) = @_;
4918
4919        # Strip optional user@ prefix.  \A (not ^) so a newline cannot split the match.
4920
79
104
        (my $bare = $host) =~ s/\A[^@]*@//;
4921
4922        # \z anchors at true end-of-string; $ would match before a trailing newline.
4923
79
236
        return 1 if $bare =~ /\A(?:localhost|127\.0\.0\.1|::1)\z/i;
4924
4925
42
119
        require Sys::Hostname;
4926
42
107
        my $me = lc(Sys::Hostname::hostname());
4927
42
127
        my $lc_bare = lc($bare);
4928
42
60
        return 1 if $lc_bare eq $me;
4929
4930        # Match on short hostname: 'mybox' matches 'mybox.example.com' and vice-versa
4931
34
43
        (my $me_short   = $me)      =~ s/\..*//;
4932
34
52
        (my $bare_short = $lc_bare) =~ s/\..*//;
4933
34
74
        return($bare_short eq $me_short);
4934}
4935
4936# Determine whether a given file is a DBM::Deep file by checking its magic bytes.
4937# The standard DBM::Deep magic is 'DPDB' (0x44 0x50 0x44 0x42); 'DPDP' (0x44 0x50 0x44 0x50)
4938# is also accepted for compatibility with files created by alternative tooling.
4939# Returns 1 if the first 4 bytes match a known DBM::Deep signature, 0 otherwise.
4940sub _is_deep_db {
4941
20
6279
        my ($self, $file) = @_;
4942
4943
20
18
        my $fh;
4944
51
51
51
20
20
19065
49
113
17
230
        do { no autodie qw(open); open $fh, '<', $file } or return 0;
4945
16
34
        binmode $fh;
4946
16
2149
        my $n = read($fh, my $magic, 4);
4947
16
2037
        close $fh;
4948
16
716
        return 0 unless defined($n) && $n == 4;
4949
4950
14
62
        return($magic eq 'DPDB' || $magic eq 'DPDP');
4951}
4952
4953# Log and remember a message
4954sub _log
4955{
4956
3851
4264
        my ($self, $level, @messages) = @_;
4957
4958        # FIXME: add caller's function
4959        # if(($level eq 'warn') || ($level eq 'notice')) {
4960
3851
3851
2556
9695
                push @{$self->{'messages'}}, { level => $level, message => join('', grep defined, @messages) };
4961        # }
4962
4963
3851
6962
        if(scalar(@messages) && (my $logger = $self->{'logger'})) {
4964
3851
7790
                $self->{'logger'}->$level(join('', grep defined, @messages));
4965        }
4966}
4967
4968sub _debug {
4969
2806
8043
        my $self = shift;
4970
2806
3114
        $self->_log('debug', @_);
4971}
4972
4973sub _trace {
4974
1038
1451
        my $self = shift;
4975
1038
1606
        $self->_log('trace', @_);
4976}
4977
4978# Emit a warning message somewhere
4979sub _warn {
4980
1
409
        my $self = shift;
4981
1
4
        my $params = Params::Get::get_params('warning', \@_);
4982
4983
1
16
        $self->_log('warn', $params->{'warning'});
4984
1
300
        Carp::carp(join('', grep defined, $params->{'warning'}));
4985}
4986
4987# Die
4988sub _fatal {
4989
6
625
        my $self = shift;
4990
6
14
        my $params = Params::Get::get_params('warning', \@_);
4991
4992
6
98
        $self->_log('error', $params->{'warning'});
4993
6
2325
        Carp::croak(join('', grep defined, $params->{'warning'}));
4994}
4995
4996 - 5508
=head1 AUTHOR

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

=head1 SUPPORT

This module is provided as-is without any warranty.

Please report any bugs or feature requests to C<bug-database-abstraction at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Database-Abstraction>.
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

=head1 MESSAGES

The table below lists every error that the module can croak or carp, what
triggers it, and how to resolve it.

=over 4

=item C<< I<Class>: abstract class >>

Direct instantiation of C<Database::Abstraction> was attempted.
Create a subclass and instantiate that instead.

=item C<< I<Class>: where are the files? >>

Neither C<directory> nor C<dsn> was supplied to C<new()>.

=item C<< I<Class>: I</path> is not a directory >>

The C<directory> argument exists on disk but is not a directory.

=item C<< I<Class>: cannot connect: I<$DBI::errstr> >>

DBI failed to connect to the given C<dsn>.  Check credentials and host.

=item C<< Can't find a file called 'I<name>' for the table I<T> in I<dir> >>

None of the probe extensions (C<.sql>, C<.sqlite>, C<.sqlite3>, C<.psv>, C<.tsv>, C<.csv>, C<.xlsx>, C<.db>, C<.xml>)
matched in C<directory>.

=item C<< I<Class>: prepare failed: I<$errstr> >>

C<prepare_cached()> returned false.  Usually a syntax error in an internally
built query; file a bug if you see this from a normal API call.

=item C<< _build_where_conditions: unsafe column name 'I<name>' >>

A criteria key contained characters outside C<[A-Za-z0-9_.]>.
This is a SQL-injection guard.  Use only valid SQL identifier characters.

=item C<< join: missing "table" >> / C<< join: missing "on" condition >>

A join spec hashref is incomplete.  Both C<table> and C<on> are required.

=item C<< Invalid JOIN type: I<TYPE> >>

C<type> in a join spec was not one of C<INNER LEFT RIGHT FULL CROSS>.

=item C<< I<Class>: Unknown column I<col> >> / C<< I<Class>: AUTOLOAD disabled >>

An AUTOLOAD call was made for a column that does not exist, or AUTOLOAD
was disabled with C<< auto_load => 0 >>.

=item C<< Usage: set_logger(logger => $logger) >>

C<set_logger()> was called without a C<logger> argument.

=item C<< Usage: execute(query => $query) >>

C<execute()> was called without a C<query> argument.

=item C<< XML slurp: I<...> is not yet supported >>

The XML file structure is too complex for slurp mode.
Use C<< max_slurp_size => 0 >> to force the DBI/XMLSimple SQL path.

=item C<< I<Class>: I<method> is meaningless on a NoSQL database >>

A relational method (C<selectall_arrayref>, C<count>, C<execute>, etc.)
was called on a BerkeleyDB backend, which only supports key-value lookup
via C<fetchrow_hashref>.

=item C<< I<Class>: aggregate: 'measures' must be a non-empty arrayref >>

C<aggregate()> was called without a C<measures> arrayref, or with an empty one.

=item C<< I<Class>: aggregate: measure must be a hashref, got I<ref> >>

An element of the C<measures> arrayref is not a hashref.

=item C<< I<Class>: aggregate: unknown measure function 'I<fn>' >>

C<fn> must be one of C<sum>, C<avg>, C<min>, C<max>, C<count>, C<count_distinct>,
C<median>, C<stddev>.

=item C<< I<Class>: aggregate: measure column is required for function 'I<fn>' >>

C<col> was omitted for a function that requires it.  Only plain C<count> (COUNT(*)) accepts a missing C<col>.

=item C<< I<Class>: aggregate: unsafe measure column 'I<col>' >>

The C<col> value is not a valid SQL identifier.

=item C<< I<Class>: aggregate: measure 'as' is required >>

The C<as> alias was missing from a measure hashref.

=item C<< I<Class>: aggregate: unsafe measure alias 'I<as>' >>

The C<as> value is not a valid SQL identifier.

=item C<< I<Class>: aggregate: duplicate measure alias 'I<as>' >>

Two measure hashrefs share the same C<as> alias.

=item C<< I<Class>: aggregate: measure alias 'I<as>' collides with a group-by column >>

A measure alias duplicates a name in the C<by> list.

=item C<< I<Class>: aggregate: 'by' must be an arrayref of column names >>

The C<by> parameter was not an arrayref.

=item C<< I<Class>: aggregate: unsafe group-by column 'I<col>' >>

A C<by> element is not a valid SQL identifier.

=item C<< I<Class>: aggregate: having key 'I<key>' is not a group-by column or measure alias >>

A C<having> key does not match any C<by> column name or measure C<as> alias.

=item C<< I<Class>: aggregate: sort_by names 'I<name>', which is not a group-by column or measure alias >>

The C<sort_by> column is not one of the C<by> columns or measure aliases.

=item C<< I<Class>: aggregate: cannot aggregate a JOIN on the I<backend> backend >>

C<join> was passed to C<aggregate()> on a slurp-only backend that has no DBI handle.

=item C<< Database::Abstraction::Query: count() is not available once measure() has been called >>

C<count()> was called on a query builder that has measures set.  Use C<all()> and check C<scalar @{$rows}>.

=back

=head1 COMMON PITFALLS

The following mistakes cause unexpected behaviour in common usage.
Read this section before filing a bug report.

=over 4

=item B<Default CSV separator is C<!>, not C<,>>

For historical reasons the module defaults to bang (C<!>) as the
field separator for CSV files.  Standard RFC 4180 comma-separated
files require you to pass C<< sep_char => ',' >> in your subclass
constructor.

    sub new {
        my ($class, %args) = @_;
        return $class->SUPER::new(sep_char => ',', %args);
    }

=item B<Primary key column is named C<entry>, not C<key>>

The default primary-key column name is C<entry> because C<key> is
a reserved word in SQL.  If your data uses a different column name
(e.g. C<id>, C<customer_id>), pass C<< id => 'customer_id' >> to
C<new()>.

=item B<Duplicate primary keys silently overwrite earlier rows>

In slurp mode the entire data file is loaded into a Perl hash keyed
by the C<entry> column.  If two rows share the same key value the
second row replaces the first.  To keep duplicates either disable
slurp with C<< max_slurp_size => 0 >> or switch to a C<no_entry>
schema.

=item B<Operator-hash criteria do not work on no-DBI backends>

No-DBI backends (XLSX, DBM::Deep, JSON, HTML URL, Redis) have no SQL
engine.  Criteria that use operator hashes (e.g.
C<< score => { '>=' => 80 } >>) trigger the SQL path and will croak
with a "meaningless on a NoSQL database" error.  Use simple scalar
criteria (C<< score => 80 >>) on these backends.

=item B<Read-only results: do not modify returned hashrefs>

Unless you pass C<< no_fixate => 1 >>, every hashref returned by
C<selectall_arrayref>, C<fetchrow_hashref>, and similar methods is
made read-only by C<Data::Reuse>.  Attempting to assign a value to
a key in the returned hashref will throw an exception.  If you need
to modify a row, either copy it first (C<my %row = %{$orig_row}>) or
construct the object with C<< no_fixate => 1 >>.

=item B<C<where()> calls in the query builder use AND semantics>

Each call to C<< $q->where(...) >> adds an additional required
condition.  Two separate C<where()> calls do NOT produce OR
semantics.  To express OR, pass C<< -or => [...] >> inside a single
C<where()> call:

    # Wrong: this is AND, not OR
    $q->where(status => 'active')->where(status => 'pending');

    # Correct: OR grouping inside one where() call
    $q->where(-or => [{ status => 'active' }, { status => 'pending' }]);

=item B<C<aggregate()> C<sort_by> must name a grouped or aliased column>

The C<sort_by> parameter for C<aggregate()> only accepts column names
from the C<by> list or measure C<as> aliases.  You cannot sort
aggregate results by a raw data column that is not part of the group.
This restriction applies equally to the C<< ->order_by() >> call on a
query builder that has C<measure()> methods set.

=item B<C<sort_by> is not an argument to the query builder's C<all()>>

When using the fluent query builder to build an aggregate query, set
the sort column via C<< ->order_by('col') >> before calling
C<< ->all() >>.  Passing C<< ->all(sort_by => 'col') >> has no
effect because the C<all()> terminal method ignores extra arguments.

=item B<C<Params::Get> positional mapping with C<no_entry = 0>>

When C<no_entry> is false (the default), the first positional argument
to C<selectall_arrayref> and C<fetchrow_hashref> is silently mapped to
the C<entry> key.  Passing an operator-hash criteria in that position
(e.g. C<selectall_arrayref(status => { '!=' => 'x' })>) causes
C<status> to be treated as the entry value rather than a column name,
which will produce wrong results.  Always pass a hashref of criteria
as a single reference, or use C<no_entry => 1> in your subclass.

=back

=head1 ENCODING

=over 4

=item B<Column names> must match the pattern C<[a-zA-Z_][a-zA-Z0-9_.]*>.
Only ASCII letters, digits, underscores, and dots (for qualified names
such as C<table.column>) are accepted.  Non-ASCII Unicode characters
in column names will cause a croak with an "unsafe column name" error.

=item B<Column values> (data stored in your rows) are passed through
unchanged.  Full UTF-8 strings, emoji, and any valid Perl string are
safe as values in criteria and are stored and returned as-is.  The
exact encoding depends on your backend:

=over 4

=item * B<SQLite / DBI backends> -- DBD::SQLite stores strings as
UTF-8 internally.  Enable C<< sqlite_unicode => 1 >> in your DSN
or connect attributes if you want Perl to receive decoded Unicode
scalars rather than raw UTF-8 bytes.

=item * B<CSV / PSV / TSV> -- Text::CSV and DBD::CSV pass bytes
through unchanged.  If your file is UTF-8-encoded, strings are
returned as UTF-8 byte strings.  You may need to decode them with
C<Encode::decode('UTF-8', $val)> in your application.

=item * B<JSON> -- JSON::MaybeXS decodes JSON strings to Perl Unicode
scalars automatically.  UTF-8 and emoji in JSON values are fully
supported.

=item * B<XML> -- XML::Simple decodes the file with the XML encoding
declaration.  UTF-8 encoded XML (the default) produces Perl Unicode
scalars.

=item * B<XLSX> -- Spreadsheet::ParseXLSX decodes strings to Perl
Unicode scalars.  Emoji in cell values are fully supported.

=item * B<HTML URL> -- LWP::UserAgent fetches bytes; HTML::TableExtract
returns them as-is.  Decode with Encode if the page is UTF-8.

=back

=item B<File paths> are handled by C<File::Spec::catfile> and work
on any platform.  On Linux and macOS, UTF-8 directory and file names
are fully supported.

=back

=head1 KNOWN LIMITATIONS

=over 4

=item *

B<Read-only.>  No INSERT, UPDATE, or DELETE is provided.  C<execute()>
runs raw read-only SQL.

=item *

B<Default CSV separator is C<!>>, not C<,>, for historical reasons.
Pass C<< sep_char => ',' >> for standard RFC 4180 files.

=item *

B<Primary-key column is named C<entry>>, not C<key>, because C<key>
is a SQL reserved word.  Override with the C<id> parameter.

=item *

B<XML slurp is limited.>  Only simple flat XML structures are supported
in slurp mode.  Multi-key or deeply nested documents will croak.
Force SQL mode with C<< max_slurp_size => 0 >> if slurp fails.

=item *

B<Unique key assumption in slurp mode.>  Duplicate values in the key
column silently overwrite earlier rows.  Disable slurp with
C<< max_slurp_size => 0 >> if duplicates are expected.

=item *

B<BerkeleyDB does not support joins or the chained query builder.>

=item *

B<Column names must be valid SQL identifiers> (letters, digits,
underscores, and a single dot for C<table.column> join notation).
Other characters will cause a croak.

=item *

B<count() cache is opportunistic.>  Count results are served from cache
only when a prior C<selectall_arrayref()> or C<count()> call with the
same criteria has already populated it.

=back

=head1 SEE ALSO

=over 4

=item * L<Database::Abstraction::Query> - chained query builder

=item * L<Configure an Object at Runtime|Object::Configure>

=item * L<JSON::MaybeXS> - JSON backend (optional; install for C<.json> support)

=item * L<Test Dashboard|https://nigelhorne.github.io/Database-Abstraction/coverage/>

=back

=head1 FORMAL SPECIFICATION

The following section uses Z-notation to describe the key state
transitions and invariants for the module.  Z is a formal
specification language based on set theory and predicate logic.
Unicode is used here as permitted by the documentation skill.

=head2 Database object state

    State == [
        data       : TABLE_NAME --> (ENTRY --> ROW) | seq ROW | undef,
        dbh        : TABLE_NAME --> DBI_HANDLE | undef,
        type       : Str | undef,
        base_crit  : CRITERIA,
        _columns   : seq Str | undef,
        _schema    : NAME --> COL_INFO | undef,
        _destroyed : Bool
    ]

    Initial state S0 == [
        data?        = undef,
        dbh?         = undef,
        type?        = undef,
        base_crit?   = {},
        _columns?    = undef,
        _schema?     = undef,
        _destroyed?  = false
    ]

=head2 _open_table

    _open_table ___________________________________________
    DeltaState
    class? : CLASS
    dir?   : DIRPATH | undef
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    pre:  _destroyed = false
    pre:  dbh(table) = undef AND data(table) = undef
    post: (EXISTS f : FILE_FORMATS WITH f.probe(dir) = true
           IMPLIES (f.slurp? IMPLIES data'(table) != undef)
                AND (f.dbi?  IMPLIES dbh'(table)  != undef))

=head2 selectall_arrayref

    selectall_arrayref __________________________________
    XiState  (read-only; state unchanged except cache)
    criteria? : CRITERIA
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    pre:  _destroyed = false
    result == (data != undef AND simple(criteria)
               IMPLIES { r : data | match(r, criteria) }
               ELSE    dbh != undef
               IMPLIES SQL_SELECT(dbh, criteria))
    post: result : seq ROW UNION {undef}

=head2 aggregate

    aggregate ____________________________________________
    XiState
    by?       : seq COLNAME
    measures? : seq MEASURE_SPEC
    criteria? : CRITERIA
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    pre:  measures != empty
    pre:  FORALL m : measures WITH m.as NOT IN by
    pre:  sort_by != undef IMPLIES sort_by IN ran(by) UNION aliases(measures)
    result == (dbh != undef
               IMPLIES SQL_GROUP_BY(dbh, by, measures, criteria)
               ELSE    PERL_FOLD(selectall_arrayref(criteria), by, measures))
    post: result : seq ROW (keys = by UNION aliases(measures))
    post: FORALL r : result WITH
              r.count != undef IMPLIES r.count >= 0
            AND r.sum = undef IMPLIES no rows in that group

=head2 DESTROY

    DESTROY ______________________________________________
    DeltaState
    â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    post: _destroyed' = true
    post: dbh'      = undef  (DBI handles disconnected)
    post: data'     = undef
    post: _columns' = undef
    post: _schema'  = undef

=head1 STATE DIAGRAM

The following ASCII diagram shows every valid state the object can
occupy and the transitions between them.  Transitions are labelled
with the method call or event that triggers them.  Actions are shown
in parentheses.

    .-----------------------------------------------------------.
    |                  S1: CONSTRUCTED                          |
    |  dbh=undef, data=undef, type=undef                        |
    '---.-------.-------.-------.-------.-----.---.-------------'
        |       |       |       |       |     |   |
        | first query call triggers _open_table()
        |       |       |       |       |     |   |
        v       v       v       v       v     v   v
    .------. .------. .------. .------. .--. .--. .--.
    | S2   | | S3   | | S4   | | S5   | |S6| |S7| |S8|
    | DBI  | | CSV  | | XML  | | JSON | |Xls| |Bk| |Dp|
    '---^--' '--^---' '--^---' '--^---' '-^-' '-^-' '-^-'
        |       |       |       |       |   |   |   |
        |       |       '-------'-------'   |   |   |
        |       |         slurp path        |   |   |
        |       '-----------+---------------'   |   |
        |                   |                   |   |
        |         (data populated,              |   |
        |          _columns/_schema undef)      |   |
        |                                       |   |
        | (dbh populated,                       |   |
        |  data undef)                          |   |
        |                                       |   |
        +-------+--------+-----------+----------+   |
                |        |           |              |
          selectall   fetchrow    aggregate      fetchrow
          count       _hashref    query->all     _hashref
          each_row    AUTOLOAD    AUTOLOAD        AUTOLOAD
                |        |           |              |
                v        v           v              v
    .-----------------------------------------------------------.
    |                  (result returned to caller)              |
    |          state is unchanged; only cache may be updated    |
    '-----------------------------------------------------------'
                                |
                    DESTROY (or object goes out of scope)
                                |
                                v
    .-----------------------------------------------------------.
    |                  S_DEAD: DESTROYED                        |
    |  _destroyed=true; all handles disconnected;               |
    |  temp files unlinked; _remote_tmpdir removed              |
    '-----------------------------------------------------------'

    State labels:
      S1  CONSTRUCTED  - new() returned; no I/O done yet (lazy open)
      S2  DBI          - live DBI handle (SQLite file, DSN, DBD::CSV)
      S3  CSV_SLURP    - small CSV/PSV/TSV/gzip-CSV slurped into RAM hash
      S4  XML_SLURP    - XML file slurped into RAM hash
      S5  JSON_SLURP   - JSON file slurped into RAM hash
      S6  XLSX_SLURP   - XLSX file slurped (type='XLSX', no DBI handle)
      S7  BERKELEYDB   - BerkeleyDB key-value file open (berkeley flag set)
      S8  DEEP_SLURP   - DBM::Deep file slurped into plain RAM hash

    Blocked transitions (croak before state changes):
      S1 -> any  if constructor arguments are invalid
                 (unsafe id/table/host/url)
      S* -> S*   aggregate() with join on S3/S4/S5/S6/S7/S8
                 (no DBI handle)
      S7 -> S*   join() or select()-projection on BerkeleyDB
      S8 -> S*   join() on DBM::Deep

=head1 LICENSE AND COPYRIGHT

Copyright 2015-2026 Nigel Horne.

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

=cut
5509
55101;