| File: | blib/lib/Database/Abstraction.pm |
| Coverage: | 78.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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 | ||||||
| 45 | our %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. | |||||
| 52 | my $SAFE_IDENTIFIER = qr/\A[a-zA-Z_][a-zA-Z0-9_]*\z/; | |||||
| 53 | my $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; | |||
| 58 | my $INFER_INT_RE = qr/\A-?\d+\z/; | |||||
| 59 | my $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 | |||||
| 74 | my $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; | |||||
| 82 | my $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. | |||||
| 92 | my %VALID_JOIN_TYPES = map { $_ => 1 } qw(INNER LEFT RIGHT FULL CROSS); | |||||
| 93 | ||||||
| 94 | # Valid aggregate functions (lower-cased) for _validate_aggregate_spec. | |||||
| 95 | my %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). | |||||
| 98 | my %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 | ||||||
| 112 | our $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 | |||||
| 513 | sub 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 | ||||||
| 540 | sub 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 | ||||||
| 786 | sub 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 | ||||||
| 914 | sub 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 | ||||||
| 941 | sub 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 | ||||||
| 967 | sub _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. | |||||
| 1692 | sub _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 | ||||||
| 1820 | sub 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 | ||||||
| 2122 | sub 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 | ||||||
| 2175 | sub 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 | ||||||
| 2378 | sub 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 | ||||||
| 2630 | sub 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 | ||||||
| 2663 | sub 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 | ||||||
| 2812 | sub 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 | ||||||
| 2948 | sub 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 | ||||||
| 3017 | sub 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 | ||||||
| 3074 | sub 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 | ||||||
| 3178 | sub 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 | ||||||
| 3286 | sub 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 | ||||||
| 3404 | sub 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. | |||||
| 3536 | sub _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). | |||||
| 3664 | sub _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). | |||||
| 3746 | sub _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). | |||||
| 3811 | sub _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()). | |||||
| 3841 | sub _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 | ||||||
| 4049 | sub 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 | ||||||
| 4102 | sub 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 | ||||||
| 4320 | sub 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). | |||||
| 4370 | sub _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). | |||||
| 4396 | sub _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. | |||||
| 4412 | sub _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. | |||||
| 4424 | sub _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. | |||||
| 4443 | sub _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. | |||||
| 4469 | sub _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 | ||||||
| 4494 | sub _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.). | |||||
| 4558 | sub _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). | |||||
| 4627 | sub _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. | |||||
| 4684 | sub _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 | ||||||
| 4761 | sub _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 | |||||
| 4823 | sub _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. | |||||
| 4853 | sub _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. | |||||
| 4868 | sub _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. | |||||
| 4897 | sub _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. | |||||
| 4916 | sub _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. | |||||
| 4940 | sub _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 | |||||
| 4954 | sub _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 | ||||||
| 4968 | sub _debug { | |||||
| 4969 | 2806 | 8043 | my $self = shift; | |||
| 4970 | 2806 | 3114 | $self->_log('debug', @_); | |||
| 4971 | } | |||||
| 4972 | ||||||
| 4973 | sub _trace { | |||||
| 4974 | 1038 | 1451 | my $self = shift; | |||
| 4975 | 1038 | 1606 | $self->_log('trace', @_); | |||
| 4976 | } | |||||
| 4977 | ||||||
| 4978 | # Emit a warning message somewhere | |||||
| 4979 | sub _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 | |||||
| 4988 | sub _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 | ||||||
| 5510 | 1; | |||||