File Coverage

File:blib/lib/Database/BI/Model/DataSource.pm
Coverage:91.1%

linestmtbrancondsubtimecode
1package Database::BI::Model::DataSource;
2
3
12
12
12
344534
15
97
use strict;
4
12
12
12
14
32
221
use warnings;
5
12
12
12
1230
55043
31
use autodie qw(:all);
6
7
12
12
12
72220
34
246
use Carp                qw(croak carp);
8
12
12
12
20
10
80
use File::Spec          ();
9
12
12
12
19
9
212
use Readonly;
10
12
12
12
15
10
148
use Scalar::Util        qw(blessed);
11
12
12
12
2015
130934
37
use Sub::Protected;
12
12
12
12
1205
10
199
use Params::Validate::Strict qw(validate_strict);
13
12
12
12
22
9
2923
use Params::Get         ();
14
15our $VERSION = '0.012.0';
16
17 - 183
=head1 NAME

Database::BI::Model::DataSource - Table-agnostic adapter around Database::Abstraction

=head1 VERSION

0.011.0

=head1 SYNOPSIS

B<Read all rows from a CSV file:>

    use Database::BI::Model::DataSource;

    my $source = Database::BI::Model::DataSource->new(
        directory => '/path/to/data',
        table     => 'sales',           # looks for data/sales.csv, tsv, .psv, .sql, .xml, etc.
    );

    my $records = $source->fetch_all;   # arrayref of hashrefs -- one hashref per row

    for my $row (@{$records}) {
        printf "Product: %s, Amount: %s\n", $row->{product}, $row->{amount};
    }

B<Get column names in the original file order (CSV/PSV/TSV only):>

    my $cols = $source->columns;        # returns undef for SQLite and XML
    if ($cols) {
        print join(', ', @{$cols}), "\n";
    }

B<Find out which column is the primary key:>

    print "Primary key column: ", $source->id_column, "\n";

B<Open a SQLite file (.sql extension):>

    my $source = Database::BI::Model::DataSource->new(
        directory => '/var/data',
        table     => 'inventory',       # looks for /var/data/inventory.sql
    );

B<Open a pipe-separated file (.psv extension):>

    my $source = Database::BI::Model::DataSource->new(
        directory => '/var/data',
        table     => 'products',        # looks for /var/data/products.psv
    );

B<Open a tab-separated file (.tsv extension):>

    my $source = Database::BI::Model::DataSource->new(
        directory => '/var/data',
        table     => 'products',        # looks for /var/data/products.tsv
    );

B<Use a custom i18n object to translate error messages:>

    # The i18n object must have a maketext($key, @args) method.
    my $source = Database::BI::Model::DataSource->new(
        directory => '/path/to/data',
        table     => 'sales',
        i18n      => My::I18N::Handle->new,
    );

B<Open a remote HTML table from a URL:>

    # Requires LWP::UserAgent::Cached and HTML::TableExtract.
    # The table is fetched and cached in memory; no file is saved to disk.
    my $source = Database::BI::Model::DataSource->new(
        url => 'https://example.com/data-page.html',
    );
    my $records = $source->fetch_all;

B<Select a specific table when a page has more than one HTML table:>

    my $source = Database::BI::Model::DataSource->new(
        url              => 'https://example.com/page.html',
        html_table_index => 2,   # zero-based: 0 = first table, 2 = third table
    );

B<Open one specific internal table from a multi-table SQLite database:>

    # A SQLite file may contain more than one table.
    # Use internal_table to say which one you want.
    my $source = Database::BI::Model::DataSource->new(
        directory      => '/var/data',
        table          => 'store',          # looks for store.db, store.sql, etc.
        internal_table => 'customer',       # open just the "customer" table inside
    );
    my $records = $source->fetch_all;

B<Check whether a SQLite file has more than one table:>

    my $source = Database::BI::Model::DataSource->new(
        directory => '/var/data',
        table     => 'store',
    );
    my $tbl_list = $source->sqlite_tables;   # undef unless it is a multi-table SQLite
    if ($tbl_list && @{$tbl_list} > 1) {
        # let the user choose which table to open
        for my $name (@{$tbl_list}) {
            printf "  table: %s\n", $name;
        }
    }

B<Open a remote file over SSH (SFTP):>

    # Requires Net::SFTP::Foreign.
    my $source = Database::BI::Model::DataSource->new(
        directory => '/remote/data',
        table     => 'report',
        host      => 'deploy@dbserver.example.com',
        file_ext  => 'log',         # hint: try report.log first, then other exts
    );
    my $records = $source->fetch_all;

B<Handle errors gracefully:>

    my $source = eval {
        Database::BI::Model::DataSource->new(
            directory => $dir,
            table     => $table,
        );
    };
    if ($@) {
        carp "Could not open table: $@";
        # $@ contains a translated message from %MESSAGES
    }

    my $records = eval { $source->fetch_all };
    if ($@) {
        carp "Could not read records: $@";
    }

=head1 DESCRIPTION

C<Database::BI::Model::DataSource> is a thin, table-agnostic adapter that
wraps L<Database::Abstraction> and exposes three accessors (C<fetch_all>,
C<columns>, C<id_column>) used by the controller.

L<Database::Abstraction> is a read-only ORM that discovers data files
(CSV, PSV, TSV, SQLite, XML, etc.) automatically from a directory based on the
calling class name.  C<DataSource> generates an ephemeral subclass at
construction time so callers never interact with L<Database::Abstraction>
directly.  To swap the backend for L<Database::Join> in Phase 2, only the
C<open_table> helper in C<Database::BI> needs to change; the controller and
C<DataSource> are untouched.

C<_detect_file_info> peeks at the first header line of CSV/PSV/TSV files to
extract the correct separator character, the primary-key column name, and
the full ordered column list.  Without this, two silent L<Database::Abstraction>
defaults corrupt every result: C<sep_char> defaults to C<'!'> (turning a
comma-separated file into a single-field table) and C<id> defaults to
C<'entry'> (causing every row to be discarded when no C<entry> column
exists).

Result filtering (C<eq>, C<contains>, C<gt>, etc.) is performed at the
controller layer by C<Dashboard::_apply_filter_spec> after C<fetch_all>
returns.  C<DataSource> itself is filter-unaware.

All user-visible strings and exception messages are keyed through the
C<%MESSAGES> dictionary and routed via C<_msg()>, making every diagnostic
replaceable by an i18n object at instantiation time.

=cut
184
185 - 242
=head1 ENCODING

=head2 What is safe to put in cell values

Cell values (the actual data inside rows) are passed through as Perl
character strings exactly as C<Database::Abstraction> and the underlying
DBI driver return them.  Full Unicode -- including non-ASCII letters,
accented characters, CJK ideographs, and emoji -- is supported in cell
values, provided the source file is encoded in UTF-8 (or the encoding
declared in its HTTP headers for URL-backed tables).

=head2 What must stay ASCII

The following structural items must contain B<only ASCII characters>:

=over 4

=item C<table> constructor argument

The bare file stem used to look up the file on disk.  Non-ASCII characters
are not valid SQL identifiers and will be silently replaced with underscores
(C<_>) by the sanitization step in C<new()>.

=item Column header names (CSV / TSV / PSV first line)

Column names are used as hash keys and as SQL identifiers inside
C<Database::Abstraction>.  Non-ASCII headers cause C<DBD::CSV> to
fail or produce garbage keys.  If your source file has non-ASCII
column headers, rename them before opening via C<DataSource>.

=item C<host> argument

The hostname or C<user@hostname> string for SFTP access.  Must match
C<[A-Za-z0-9._@-]>; anything outside that character set is rejected by
the host-validation regex and produces C<error_backend_init>.

=back

=head2 URL-backed tables

URL-backed tables (the C<url =E<gt>> constructor path) are fetched with
L<LWP::UserAgent::Cached>.  If the remote server declares a charset in its
HTTP headers or in an HTML C<meta> tag, L<Database::Abstraction> uses it
to decode the response body.  If the charset declaration is absent or wrong,
cell values may arrive as raw bytes rather than character strings, and
multi-byte sequences will look garbled.

=head2 File-backed tables (CSV / TSV / PSV)

For files smaller than 16 KB, L<Text::xSV::Slurp> reads bytes and returns
them without re-encoding; for larger files the L<DBD::CSV> path is used
instead.  In both cases C<DataSource> forces the slurp path (by passing
C<max_slurp_size =E<gt> -s $file>) so that column names with spaces are
preserved as-is.  The caller (the Mojolicious controller) is responsible
for setting the correct C<Content-Type: text/html; charset=UTF-8> response
header.

=cut
243
244# ---------------------------------------------------------------------------
245# I18N message dictionary.
246# All user-visible strings and exception messages are keyed here.
247# To plug in a real i18n backend (e.g. Locale::Maketext), pass an object
248# that responds to maketext($key, @args) as the "i18n" constructor argument;
249# it will be called in preference to this table.
250# ---------------------------------------------------------------------------
251
252Readonly our %MESSAGES => (
253        error_directory_required        => 'DataSource: argument "directory" is required',
254        error_table_required            => 'DataSource: argument "table" is required',
255        error_directory_missing         => 'DataSource: directory "%s" does not exist or is not readable',
256        error_table_name_invalid        => 'DataSource: table name "%s" contains illegal characters (alphanumeric and underscore only)',
257        error_backend_init              => 'DataSource: failed to initialise database backend for table "%s": %s',
258        error_fetch_failed              => 'DataSource: fetch_all failed for table "%s": %s',
259        error_url_invalid               => 'DataSource: URL "%s" must begin with http:// or https://',
260        error_url_fetch                 => 'DataSource: failed to open HTML table at "%s": %s',
261        error_no_safe_id                => 'DataSource: table "%s" has no column with a safe identifier name (letters, digits, underscore); rename at least one column header',
262        error_no_tables                 => 'DataSource: SQLite file "%s" contains no user-defined tables',
263        error_table_not_found           => 'DataSource: SQLite file "%s" does not contain a table named "%s"',
264        error_unsupported_db            => 'DataSource: "%s" is a Berkeley DB file but the installed DB_File library cannot open it (file may use a newer Berkeley DB format)',
265        error_redis_invalid             => 'DataSource: Redis URL "%s" must begin with redis://',
266        warn_empty_result               => 'DataSource: fetch_all returned no records for table "%s"',
267        warn_data_normalised            => 'DataSource: result from backend was a hashref; converted to arrayref for table "%s"',
268);
269
270# A table name is a bare SQL-safe identifier: starts with a letter or
271# underscore, followed by zero or more alphanumeric/underscore characters.
272Readonly my $TABLE_NAME_RE => qr/\A[A-Za-z_][A-Za-z0-9_]*\z/;
273
274# ---------------------------------------------------------------------------
275# Protected helpers
276# ---------------------------------------------------------------------------
277
278# _url_label( $url ) -> $string
279#
280# Derive a safe, lowercase identifier from a URL for use as the table label.
281# Takes the last non-empty path component, strips the extension, then replaces
282# non-alphanumeric characters with underscores.  Falls back to the hostname
283# when the path component is absent or starts with a digit.
284sub _url_label {
285
27
15624
        my $url = $_[0];
286
27
99
        my ($path) = $url =~ m{https?://[^/?#]+(.*)}i;
287
27
51
68
59
        my @parts  = grep { length } split m{/}, ($path // '');
288
27
40
        my $last   = @parts ? $parts[-1] : '';
289        # /s: . must cross \n in case a percent-decoded newline hides inside the URL.
290
27
32
        $last =~ s/[?#].*//s;           # strip query / fragment
291
27
31
        $last =~ s/\.[^.]+\z//; # strip file extension (\z: no trailing-\n loophole)
292
27
57
        $last =~ s/[^A-Za-z0-9_]/_/g;   # sanitize
293
27
69
        unless (length $last && $last =~ /\A[A-Za-z_]/) {
294                # No usable path component -- fall back to hostname
295
9
18
                my ($host) = $url =~ m{https?://([^/:?#]+)};
296
9
8
8
11
15
10
                $last = defined $host ? do { (my $h = $host) =~ s/[^A-Za-z0-9_]/_/g; $h } : 'html';
297        }
298        # $last is always non-empty here: the unless-block sets it to either
299        # the sanitized hostname (>= 1 char) or the literal 'html'.
300
27
96
        return lc($last);
301}
302
303# _fmt( $key [, @sprintf_args] ) -> $string
304#
305# Package-level (not a method) i18n formatter. Looks up $key in %MESSAGES and
306# applies sprintf if positional arguments are supplied. This function is used
307# in new() before the object exists; instance methods should use _msg() instead
308# so that a caller-supplied i18n object can override the built-in strings.
309sub _fmt :Protected {
310        my ($key, @args) = @_;
311        my $tmpl = $MESSAGES{$key} // "Internal error: unknown message key '$key'";
312        return @args ? sprintf($tmpl, @args) : $tmpl;
313
12
12
12
25
11
141
}
314
315# _msg( $self, $key [, @sprintf_args] ) -> $string
316#
317# Instance-level i18n formatter. Delegates to the caller-supplied i18n object
318# (if any) before falling back to _fmt(). The i18n object must implement
319# maketext($key, @args).
320sub _msg :Protected {
321        my ($self, $key, @args) = @_;
322        if (my $i18n = $self->{_i18n}) {
323                return $i18n->maketext($key, @args);
324        }
325        return _fmt($key, @args);
326
12
12
12
41106
18
164
}
327
328# ---------------------------------------------------------------------------
329# Constructor
330# ---------------------------------------------------------------------------
331
332 - 430
=head1 CONSTRUCTOR

=head2 new

Creates and returns a new C<Database::BI::Model::DataSource> instance.

=head3 API SPECIFICATION

=head4 INPUT

        {
            directory      => 'string',   # required; local dir, or remote dir when host is set
            table          => 'string',   # required; bare file stem (no extension)
            host           => 'string',   # optional; "hostname" or "user@hostname" for SFTP access
            file_ext       => 'string',   # optional; extension hint when remote file has non-standard suffix (e.g. "log")
            internal_table => 'string',   # optional; name of a specific internal SQLite table to open
            cache          => 'object',   # optional; CHI cache object for result caching
            cache_ttl_url  => 'string',   # optional; CHI TTL string for URL-backed tables (default "15 min")
            i18n           => 'object',   # optional; must implement maketext($key, @args) for i18n
        }

C<internal_table> selects a named table inside a multi-table SQLite file.
When omitted, C<DataSource> auto-selects the first user table whose name
matches the filename stem; if no match, it picks the first table found.
Supplying a name that does not exist in the file croaks C<error_table_not_found>.

Accepts a flat key/value list, a hashref, or positional arguments via
C<Params::Get>.

When C<host> is provided, C<directory> is treated as a path on the remote
host and is not checked with C<-d> locally.  C<DataSource> downloads the
file via SFTP (L<Net::SFTP::Foreign>) to a process-local temporary directory
and then processes the local copy.  The temporary directory persists for the
lifetime of the C<DataSource> object and is cleaned up automatically on
destruction.

When C<file_ext> is provided together with C<host>, it is used as the first
candidate extension when probing the remote host for the file.  If the
downloaded file does not have a standard extension (one of C<csv>, C<tsv>,
C<psv>, C<xlsx>, C<xls>, C<sql>, C<sqlite>, C<sqlite3>, C<db>, C<xml>),
the file content is sniffed (SQLite magic bytes, XML preamble, or first-line
field separator) and the file is renamed to the correct standard extension
before further processing.

=head4 DOMAIN CONSTRAINTS

=over 4

=item C<directory>

Must satisfy C<-d $directory> (must exist and be a directory).  An empty
string, a non-existent path, or the path to a regular file all produce
C<error_directory_missing>.

  Valid partition:   any existing directory path
  Invalid partition: non-existent path, regular file path, empty string ""

=item C<table>

The bare file stem (no extension).  Characters that are illegal in SQL
identifiers - hyphens, dots, spaces, etc. - are silently replaced with
underscores before the name is used internally.  A stem that starts with a
digit is prefixed with C<_>.  Only a completely empty string croaks.

  Valid partition:   "sales", "_tmp", "report_2024",
                     "Transactions-2026-09-08" (hyphens sanitized to underscores),
                     "my.data" (dot sanitized), "1sales" (prefixed to "_1sales")
  Invalid partition: "" (empty string)
  Boundary values:   "a" (length-1 letter, valid), "_" (length-1 underscore,
                     valid), "" (empty string, croaks error_table_name_invalid)

=back

=head4 OUTPUT

Returns C<$self> (a blessed hashref). Croaks on invalid arguments.

=head3 MESSAGES

  error_directory_required    -- "directory" argument was not supplied
  error_table_required        -- "table" argument was not supplied
  error_directory_missing     -- supplied directory does not exist / is unreadable
  error_table_name_invalid    -- table name contains illegal characters or is empty
  error_no_safe_id            -- no column in the file has a safe SQL identifier name;
                                 rename at least one column header to an alphanumeric name
  error_backend_init          -- backend initialisation failed; sub-cases:
                                   * Net::SFTP::Foreign is not installed
                                   * could not connect to <host> via SFTP
                                   * could not fetch any supported file from <host>:<dir>/<stem>.*
                                   * Database::Abstraction subclass could not be instantiated
  error_no_tables             -- SQLite file opened successfully but contains no user-defined tables
  error_unsupported_db        -- .db file has BerkeleyDB magic bytes but DB_File could not open it
                                 (file likely uses a newer BDB format than the installed libdb supports)
  error_fetch_failed          -- fetch_all raised an exception (wraps the underlying DBI/D::A error)
  error_url_invalid           -- URL passed to the url=> constructor path does not begin
                                 with http:// or https://
  error_url_fetch             -- fetching or parsing the HTML table at a URL failed

=cut
431
432sub new {
433        # Strategy: normalise the argument list with Params::Get so callers may
434        # pass a hashref or a flat list interchangeably, then validate strictly
435        # with Params::Validate before touching any value.
436        # When a "url" key is present, dispatch to the URL/HTML-table path instead.
437
598
455472
        my $class = shift;
438
598
1512
        my $raw   = Params::Get::get_params(undef, \@_) // {};
439
598
10019
        return $class->_new_from_url($raw)   if exists $raw->{url};
440
586
1032
        return $class->_new_from_redis($raw) if exists $raw->{redis};
441
442
586
5096
        my $args = validate_strict(
443                schema => {
444                        directory      => { type => 'string' },
445                        table          => { type => 'string' },
446                        host           => { type => 'string', optional => 1, default => undef },
447                        file_ext       => { type => 'string', optional => 1, default => undef },
448                        internal_table => { type => 'string', optional => 1, default => undef },
449                        i18n           => { type => 'object', optional => 1, default => undef, can => 'maketext' },
450                        cache          => { type => 'object', optional => 1, default => undef },
451                        cache_ttl_url  => { type => 'string', optional => 1, default => '15 min' },
452                },
453                input => $raw,
454        );
455
456        # For remote files (host is set), the directory is on the remote host and
457        # cannot be stat()d locally.  Only check local existence when no host is given.
458        croak _fmt('error_directory_missing', $args->{directory})
459
582
133278
                unless defined $args->{host} || -d $args->{directory};
460
461        # Reject path-traversal characters first: '/', '\', and NUL are the only
462        # characters that could let _raw_table escape the intended directory when
463        # D::A constructs "$dir/$dbname.$ext".  Everything else is either safe as a
464        # filename component or will be sanitized below.
465        croak _fmt('error_table_name_invalid', $args->{table})
466
569
2193
                if !length($args->{table}) || $args->{table} =~ m{[/\\\x00]};
467
468        # Silently sanitize table names derived from file stems: replace characters
469        # that are illegal in SQL identifiers (hyphens, dots, spaces, etc.) with
470        # underscores.  A leading digit is prefixed with '_'.  The original name is
471        # kept in _raw_table for filesystem lookup (dbname) so the actual file is
472        # still found; the sanitized name is used only as the internal D::A
473        # identifier and ephemeral package name.
474
563
642
        my $raw_table = $args->{table};
475
563
1023
        (my $safe_table = $raw_table) =~ s/[^A-Za-z0-9_]/_/g;
476
563
1043
        $safe_table = '_' . $safe_table if $safe_table =~ /\A[0-9]/;
477
478
563
1358
        croak _fmt('error_table_name_invalid', $raw_table)
479                unless $safe_table =~ $TABLE_NAME_RE;
480
481        my $self = bless {
482                _directory      => $args->{directory},
483                _table          => $safe_table,
484                _raw_table      => $raw_table,
485                _host           => $args->{host},
486                _file_ext       => $args->{file_ext},
487                _internal_table => $args->{internal_table},
488                _i18n           => $args->{i18n},
489                _cache          => $args->{cache},
490                _cache_ttl_url  => $args->{cache_ttl_url},
491
563
5511
                _db             => undef,
492        }, $class;
493
494
563
1358
        $self->_init_backend();
495
557
2548
        return $self;
496
497}
498
499# _new_from_url( $class, \%raw_params ) -> $self
500#
501# Alternate constructor path for URL-backed HTML tables.
502#          Validates the URL scheme, derives a display label from the URL path,
503#          then calls _init_url_backend to build the D::A in-memory table.
504# Entry:   $raw->{url} must be an http:// or https:// URL.
505#          $raw->{html_table_index} (optional, default 0): zero-based table index.
506#          $raw->{i18n} (optional): Locale::Maketext-compatible object.
507# Exit:    Returns $self.  Croaks on invalid URL scheme or backend init failure.
508# Side Effects: Issues an HTTP GET to the URL via LWP::UserAgent.
509sub _new_from_url :Protected {
510        my ($class, $raw) = @_;
511
512        my $url = $raw->{url} // croak _fmt('error_url_invalid', '');
513        croak _fmt('error_url_invalid', $url)
514                unless $url =~ m{\Ahttps?://}i;
515
516        my $table_idx = $raw->{html_table_index} // 0;
517        my $self = bless {
518                _url              => $url,
519                _table            => _url_label($url),
520                _i18n             => $raw->{i18n},
521                _cache            => $raw->{cache},
522                _cache_ttl_url    => $raw->{cache_ttl_url} // '15 min',
523                _html_table_index => $table_idx,
524                _id_col           => undef,
525                _columns          => undef,
526                _db               => undef,
527        }, $class;
528
529        $self->_init_url_backend($table_idx);
530        return $self;
531
12
12
12
5439
9
88
}
532
533# _init_url_backend( $self, $table_index ) -> void
534#
535# Purpose: Construct the Database::Abstraction backend for URL/HTML-table mode.
536#          D::A fetches the page via LWP::UserAgent, parses it with
537#          HTML::TableExtract, and stores all rows (including headers from row 0)
538#          as an in-memory arrayref.  Column order is not recoverable after
539#          construction (hash keys lose order), so _columns stays undef and
540#          _get_columns in the controller falls back to alphabetical sorting.
541# Entry:   $self->{_url} is a valid http(s) URL; $table_index is a non-negative int.
542# Exit:    Sets $self->{_db}.  Croaks on LWP or HTML::TableExtract failure.
543# Side Effects: Network I/O; may take up to LWP's default timeout.
544sub _init_url_backend :Protected {
545        my ($self, $table_index) = @_;
546        my $url = $self->{_url};
547
548        require Database::Abstraction;
549
550        # Reuse a single generic package for all URL-backed instances.
551        # The package name is irrelevant for the URL code path -- D::A branches on
552        # the presence of $self->{'url'}, not on the class name.
553        my $pkg = 'Database::BI::_DB::HtmlUrl';
554        {
555
12
12
12
2546
13
682
                no strict 'refs';
556                push @{"${pkg}::ISA"}, 'Database::Abstraction'
557                        unless $pkg->isa('Database::Abstraction');
558        }
559
560        my $db = eval {
561                $pkg->new({
562                        url              => $url,
563                        html_table_index => $table_index,
564                        no_entry         => 1,
565                })
566        };
567        croak $self->_msg('error_url_fetch', $url, $@) if $@;
568
569        $self->{_db} = $db;
570        return;
571
12
12
12
20
10
74
}
572
573# _new_from_redis( $class, \%raw_params ) -> $self
574#
575# Alternate constructor path for Redis-backed tables.
576# Entry:   $raw->{redis} must be a redis:// URL.
577#          $raw->{table} (required): table name -- becomes the Redis key prefix
578#                         (keys in Redis must follow the pattern tablename:*).
579#          $raw->{i18n} (optional): Locale::Maketext-compatible object.
580# Exit:    Returns $self.  Croaks on invalid URL or table name.
581sub _new_from_redis :Protected {
582        my ($class, $raw) = @_;
583
584        my $redis_url = $raw->{redis} // croak _fmt('error_redis_invalid', '');
585        croak _fmt('error_redis_invalid', $redis_url)
586                unless $redis_url =~ m{\Aredis://}i;
587
588        my $table = $raw->{table} // croak _fmt('error_table_required');
589        croak _fmt('error_table_name_invalid', $table)
590                if !length($table) || $table =~ m{[/\\\x00]};
591
592        my $raw_table = $table;
593        (my $safe_table = $raw_table) =~ s/[^A-Za-z0-9_]/_/g;
594        $safe_table = '_' . $safe_table if $safe_table =~ /\A[0-9]/;
595        croak _fmt('error_table_name_invalid', $raw_table)
596                unless $safe_table =~ $TABLE_NAME_RE;
597
598        my $self = bless {
599                _table         => $safe_table,
600                _raw_table     => $raw_table,
601                _redis_url     => $redis_url,
602                _i18n          => $raw->{i18n},
603                _cache         => $raw->{cache},
604                _cache_ttl_url => $raw->{cache_ttl_url} // '15 min',
605                _id_col        => 'entry',
606                _columns       => undef,
607                _db            => undef,
608        }, $class;
609
610        $self->_init_redis_backend();
611        return $self;
612
12
12
12
3530
10
75
}
613
614# _init_redis_backend( $self ) -> void
615#
616# Purpose: Construct the Database::Abstraction backend for Redis mode.
617#          D::A connects to the Redis server lazily (on first data access).
618#          We pass redis_key => $raw_table so D::A uses the original key name
619#          (which may contain colons, hyphens, etc.) for Redis lookups, while
620#          the sanitized $table is used only as the Perl package-name suffix.
621#          no_entry => 1 requests array-style rows (no id-keyed hash).
622# Entry:   $self->{_redis_url} is a valid redis:// URL.
623#          $self->{_table}     is a safe SQL identifier (sanitized key name).
624#          $self->{_raw_table} is the original Redis key name.
625# Exit:    Sets $self->{_db}.  Croaks when D::A construction fails.
626sub _init_redis_backend :Protected {
627        my $self  = shift;
628        my $table = $self->{_table};
629
630        require Database::Abstraction;
631
632        my $pkg = 'Database::BI::_DB::' . ucfirst($table);
633        {
634
12
12
12
2309
11
659
                no strict 'refs';
635                push @{"${pkg}::ISA"}, 'Database::Abstraction'
636                        unless $pkg->isa('Database::Abstraction');
637        }
638
639        my $db = eval { $pkg->new(
640                database  => $self->{_redis_url},
641                table     => $table,
642                redis_key => $self->{_raw_table},
643        ) };
644        croak $self->_msg('error_backend_init', $table, $@) if $@;
645
646        $self->{_db} = $db;
647        return;
648
12
12
12
13
12
62
}
649
650# ---------------------------------------------------------------------------
651# Protected initialisation
652# ---------------------------------------------------------------------------
653
654# _detect_file_info( $dir, $table ) -> \%info
655#
656# Peek at the first header line of a CSV, TSV or PSV file and return a hashref:
657#   sep_char => field separator character (',' or '|')
658#   id       => first column name (used as Database::Abstraction's id key)
659#   columns  => arrayref of all column names in file order
660#
661# Two non-obvious defaults in Database::Abstraction make this necessary:
662#   1. sep_char defaults to '!' -- so a plain CSV is read as one giant field
663#      per row, producing a single comma-joined string instead of columns.
664#   2. id defaults to 'entry' -- the slurp filter greps on that column; if it
665#      doesn't exist every row is silently discarded.
666# Returns an empty hashref for non-CSV/TSV/PSV/XLSX/SQLite formats (XML, etc.).
667# For SQLite/.db files that can be opened, returns { sqlite_tables => [...] }.
668sub _detect_file_info :Protected {
669        my ($dir, $table) = @_;
670
671        # XLSX: DBD::Excel 0.07 only handles .xls (its source skips files whose
672        # name does not match /\.xls$/i, so .xlsx is silently ignored).  Parse
673        # directly with Spreadsheet::ParseXLSX and return pre-loaded row data so
674        # _init_backend can skip D::A entirely, exactly like the headerless-CSV path.
675        {
676                my $path = File::Spec->catfile($dir, "$table.xlsx");
677                if (-r $path) {
678                        my $ok = eval { require Spreadsheet::ParseXLSX; 1 };
679                        if ($ok) {
680                                my $parser = Spreadsheet::ParseXLSX->new;
681                                my $wb     = $parser->parse($path);
682                                my $ws     = $wb ? $wb->worksheet(0) : undef;
683
684                                unless ($ws) {
685                                        # Empty workbook or parse failure: sentinel so _init_backend
686                                        # skips D::A (which would fail with "(no error string)").
687                                        return { _file_is_empty => 1, file_size => -s $path };
688                                }
689
690                                my ($rmin, $rmax) = $ws->row_range;
691                                my ($cmin, $cmax) = $ws->col_range;
692
693                                # No rows at all (blank worksheet).
694                                return { _file_is_empty => 1, file_size => -s $path }
695                                        if $rmax < $rmin;
696
697                                # Row 0 = column headers.
698                                my @cols;
699                                for my $c ($cmin .. $cmax) {
700                                        my $cell = $ws->get_cell($rmin, $c);
701                                        push @cols, defined $cell ? ($cell->value // '') : '';
702                                }
703                                for (@cols) { s/\A[\s"]+//; s/[\s"]+\z// }
704                                @cols = grep { length } @cols;
705
706                                # No parseable column names.
707                                return { _file_is_empty => 1, file_size => -s $path }
708                                        unless @cols;
709
710                                my $safe_re = qr/\A[a-zA-Z_][a-zA-Z0-9_]*\z/;
711                                my ($safe_id) = grep { $_ =~ $safe_re } @cols;
712
713                                # Data rows.
714                                my @rows;
715                                for my $r ($rmin + 1 .. $rmax) {
716                                        my %row;
717                                        for my $i (0 .. $#cols) {
718                                                my $cell = $ws->get_cell($r, $cmin + $i);
719                                                $row{ $cols[$i] } = defined $cell ? ($cell->value // '') : '';
720                                        }
721                                        push @rows, \%row;
722                                }
723
724                                return {
725                                        columns          => \@cols,
726                                        id               => $safe_id,
727                                        _headerless_data => \@rows,
728                                        file_size        => -s $path,
729                                };
730                        }
731                }
732        }
733
734        for my $ext (qw(csv psv tsv)) {
735                my $path = File::Spec->catfile($dir, "$table.$ext");
736                next unless -r $path;
737                # "use autodie" makes open() die on failure, so "or next" would be dead
738                # code.  Disable autodie for this open so a vanishing file (race between
739                # the -r probe and the open) results in a clean skip rather than a croak.
740                my $fh;
741
12
12
12
4630
10
47
                { no autodie 'open'; open $fh, '<', $path or next }
742                my $line = <$fh>;
743                # 0-byte file: no header, no rows.  Return a sentinel so _init_backend
744                # can skip Database::Abstraction entirely.  If we let D::A see an empty
745                # file it falls through to the DBD::CSV path, whose error handling on
746                # an empty SELECT corrupts DBI's internal Errstr SV; on a DEBUGGING perl
747                # (DBI 1.651 + perl 5.44.0) this triggers an XS assertion at cleanup.
748                unless (defined $line) {
749                        close $fh;
750                        return { _file_is_empty => 1, file_size => 0 };
751                }
752                chomp $line;
753                $line =~ s/\r\z//;      # strip CR from CRLF files before any split
754
755                my $sep;
756                if($ext eq 'psv') {
757                        $sep = '|';
758                } elsif($ext eq 'tsv') {
759                        $sep = "\t";
760                } else {
761                        # Sniff the separator: Database::Abstraction uses '!' natively and
762                        # sometimes stores those files with a .csv extension.  If splitting
763                        # on ',' yields a single field that itself contains '!', the real
764                        # separator is almost certainly '!'.
765                        my @probe = split /,/, $line, -1;
766                        $sep = (@probe == 1 && $line =~ /!/) ? '!' : ',';
767                }
768
769                my @cols = split /\Q$sep\E/, $line;
770                # Two separate substitutions: the /g on an anchored alternation wastes
771                # O(N) engine cycles retrying \A (which can only match at position 0).
772                for (@cols) { s/\A[\s"]+//; s/[\s"]+\z// }    # strip whitespace and quotes
773                @cols = grep { length } @cols;
774
775                # A blank first line (e.g. a file containing only "\n") produces an
776                # empty column list.  Treat that the same as a 0-byte file: return the
777                # _file_is_empty sentinel so _init_backend skips D::A entirely.
778                # Attempting to construct D::A with id => undef and an empty columns
779                # list would croak error_no_safe_id -- misleading for what is effectively
780                # an empty file.
781                if (!@cols) {
782                        close $fh;
783                        return { _file_is_empty => 1, file_size => -s $path };
784                }
785
786                # Database::Abstraction validates id against $SAFE_IDENTIFIER
787                # (/\A[a-zA-Z_][a-zA-Z0-9_]*\z/) at construction time and uses it as
788                # a row-existence sentinel: every data row must have a defined, non-#
789                # value in the id column or D::A drops it (empty_is_undef => 1 makes
790                # truly empty cells undef).
791                #
792                # Strategy: read the first non-empty data row and find the first safe
793                # column whose value in that row is non-empty.  A simple split (same
794                # separator) is used rather than a full CSV parse; it may mis-index
795                # fields that contain the separator inside quotes, but correctly detects
796                # whether a given index position is blank -- sufficient for id selection.
797                # Fall back to the first safe column in the header if the data row
798                # check is inconclusive (e.g. file has only a header line).
799                my $safe_re = qr/\A[a-zA-Z_][a-zA-Z0-9_]*\z/;
800                my ($safe_id) = grep { $_ =~ $safe_re } @cols;  # header-only fallback
801
802                my $data_line;
803                while (defined($data_line = <$fh>)) {
804                        chomp $data_line;
805                        $data_line =~ s/\r\z//;
806                        last if length $data_line;      # skip blank lines between header and data
807                }
808                if (defined $data_line) {
809                        my @vals = split /\Q$sep\E/, $data_line, -1;
810                        for my $i (0 .. $#cols) {
811                                next unless $cols[$i] =~ $safe_re;
812                                my $val = $vals[$i] // '';
813                                $val =~ s/\A[\s"]+//;
814                                $val =~ s/[\s"]+\z//;
815                                if (length $val) {
816                                        $safe_id = $cols[$i];
817                                        last;
818                                }
819                        }
820                }
821                # If no safe identifier was found in the header, check whether the first
822                # row looks like data values rather than column names.  A CSV exported
823                # from a bank or accounting system often has no header row at all -- the
824                # first line is already a transaction record.  When that is the case,
825                # synthesize safe column names by inferring the type of each value
826                # (date, amount, description) and pre-read the entire file so
827                # _init_backend can return the rows directly without touching D::A.
828                unless (defined $safe_id) {
829                        if (_values_are_data_like(\@cols)) {
830                                my @synth = _synthesize_col_names(\@cols);
831                                seek $fh, 0, 0; # rewind: first line is a data row, not a header
832                                my @rows;
833                                while (defined(my $dline = <$fh>)) {
834                                        chomp $dline;
835                                        $dline =~ s/\r\z//;
836                                        next unless length $dline;
837                                        my @vals = split /\Q$sep\E/, $dline, scalar @synth;
838                                        for (@vals) { s/\A[\s"]+//; s/[\s"]+\z// }
839                                        my %row;
840                                        for my $i (0 .. $#synth) {
841                                                $row{ $synth[$i] } = $vals[$i] // '';
842                                        }
843                                        push @rows, \%row;
844                                }
845                                close $fh;
846                                return {
847                                        sep_char         => $sep,
848                                        id               => $synth[0],
849                                        columns          => \@synth,
850                                        _headerless_data => \@rows,
851                                        file_size        => -s $path,
852                                };
853                        }
854                }
855
856                close $fh;
857
858                # Return file_size so _init_backend can pass it as max_slurp_size to
859                # Database::Abstraction.  Without this, files larger than D::A's default
860                # 16 KB threshold go through the DBI/DBD::CSV path, which lowercases
861                # column names and replaces spaces with underscores ("Account Number" ->
862                # "account_number").  The slurp path (Text::xSV::Slurp) preserves the
863                # original header names, so forcing it avoids the mismatch.
864                return {
865                        sep_char  => $sep,
866                        id        => $safe_id,
867                        columns   => \@cols,
868                        file_size => -s $path,
869                };
870        }
871
872        # SQLite / Berkeley DB: peek at sqlite_master to discover the internal table
873        # names.  _init_backend uses this list to auto-select the correct table when
874        # the filename stem (dbname) differs from the table name inside the file --
875        # e.g. obituaries.sql whose internal table is called "deceased".  If the
876        # file is not a valid SQLite database (e.g. a Berkeley DB file) the eval
877        # fails; for .db files we then check for BerkeleyDB magic so we can read
878        # the data directly (or return an error sentinel) instead of letting D::A
879        # fall through to its CSV path, which would read a large binary file as text.
880        # .sqlite and .sqlite3 are common alternative SQLite extensions -- treated identically to .sql.
881        my $non_sqlite_db;      # set to path when a .db file fails the SQLite probe
882        for my $ext (qw(sql sqlite sqlite3 db)) {
883                my $path = File::Spec->catfile($dir, "$table.$ext");
884                next unless -r $path;
885                my $tables = eval {
886                        require DBI;
887                        my $dbh = DBI->connect(
888                                "dbi:SQLite:dbname=$path", q{}, q{},
889                                { RaiseError => 1, PrintError => 0, AutoCommit => 1 });
890                        my $t = $dbh->selectcol_arrayref(
891                                q{SELECT name FROM sqlite_master }
892                                . q{WHERE type='table' AND name NOT LIKE 'sqlite_%' }
893                                . q{ORDER BY name});
894                        $dbh->disconnect;
895                        $t;
896                };
897                # defined $tables means the eval succeeded (even an empty list is valid)
898                return { sqlite_tables => ($tables // []), file_size => -s $path }
899                        if defined $tables;
900                $non_sqlite_db = $path if $ext eq 'db';
901                last;   # file found but not SQLite -- do not try the other ext
902        }
903
904        # A .db file that is not SQLite: check for BerkeleyDB magic bytes.
905        # BerkeleyDB Hash/Btree/Queue files begin with a 32-bit magic number either
906        # at offset 0 (big-endian files) or at offset 12 (little-endian files).
907        # If found, try to open via DB_File under a 10-second alarm so a version-
908        # incompatible file does not block the server for minutes.
909        if (defined $non_sqlite_db) {
910                my $magic_ok = 0;
911                if (open(my $fh, '<:raw', $non_sqlite_db)) {
912                        my $buf = '';
913                        if (read($fh, $buf, 4) == 4) {
914                                my %MAGIC = map { $_ => 1 }
915                                        (0x00061561, 0x00053162, 0x00042253, 0x00052444);
916                                $magic_ok = 1 if $MAGIC{unpack('N', $buf)} || $MAGIC{unpack('V', $buf)};
917                        }
918                        unless ($magic_ok) {
919                                if (seek $fh, 12, 0) {
920                                        my $b12 = '';
921                                        if (read($fh, $b12, 4) == 4) {
922                                                my $hex = substr(unpack('H*', $b12), 0, 4);
923                                                $magic_ok = 1 if $hex eq '6115' || $hex eq '1561';
924                                        }
925                                }
926                        }
927                        close $fh;
928                }
929                if ($magic_ok) {
930                        my $fsize = -s $non_sqlite_db;
931                        # BerkeleyDB magic confirmed.  Attempt to read via DB_File,
932                        # but only for reasonably-sized files (<= 100 MB).  Opening a
933                        # multi-GB BerkeleyDB file with an incompatible library version
934                        # causes multi-minute disk I/O that cannot be interrupted with
935                        # alarm() (XS I/O in libdb-5.x ignores SIGALRM via SA_RESTART).
936                        # For large files, return the unsupported sentinel immediately.
937                        Readonly my $BDB_MAX_BYTES => 100 * 1024 * 1024;     # 100 MB
938                        if (defined $fsize && $fsize > $BDB_MAX_BYTES) {
939                                return { _unknown_binary_db => 1, file_size => $fsize };
940                        }
941                        my $data = eval {
942                                require DB_File;
943                                my %bdb;
944                                # Capture the tie object so we can undef it before untie --
945                                # DB_File warns "untie while N inner references" if the object
946                                # reference count is > 0 when untie fires.
947                                my $db_obj = tie %bdb, 'DB_File', $non_sqlite_db,
948                                        Fcntl::O_RDONLY(), 0644, $DB_File::DB_HASH;
949                                return undef unless $db_obj;
950                                my @rows = map { { entry => $_, value => $bdb{$_} } } sort keys %bdb;
951                                undef $db_obj;
952                                untie %bdb;
953                                \@rows;
954                        };
955                        if (defined $data) {
956                                return {
957                                        _headerless_data => $data,
958                                        columns          => ['entry', 'value'],
959                                        id               => 'entry',
960                                        file_size        => $fsize,
961                                };
962                        }
963                        # tie failed: return a sentinel so _init_backend can show a proper
964                        # error instead of letting D::A read the binary file as CSV text.
965                        return { _unknown_binary_db => 1, file_size => $fsize };
966                }
967        }
968        return {};
969
12
12
12
8968
10
134
}
970
971# _values_are_data_like( \@vals ) -> bool
972#
973# Return true when the values look like actual data (dates, numbers, free text)
974# _sniff_data_ext( $path ) -> $ext
975#
976# Peek at the content of $path and return the standard file extension that best
977# describes its format.  Used when a remote file has a non-standard extension
978# (e.g. ".log") and we need to rename it so that _detect_file_info and
979# Database::Abstraction can discover it automatically.
980#
981# Detection order:
982#   1. SQLite magic bytes ("SQLite format 3") -> 'db'
983#   2. XML preamble ("<?xml" or "<" as first non-space character) -> 'xml'
984#   3. First data line contains tabs -> 'tsv'
985#   4. First data line contains pipes -> 'psv'
986#   5. Fallback -> 'csv'
987sub _sniff_data_ext {
988
8
7292
        my $path = $_[0];
989
8
17
        open(my $fh, '<:raw', $path) or return 'csv';
990
8
960
        my $header = '';
991
8
33
        read $fh, $header, 20;
992
8
542
        close $fh;
993
8
187
        return 'db'  if $header =~ /\ASQLite format/;
994
7
15
        return 'xml' if $header =~ /\A\s*<\?xml/i;
995
6
11
        return 'xml' if $header =~ /\A\s*</;
996
5
8
        open(my $lh, '<', $path) or return 'csv';
997
5
180
        my $line = <$lh>;
998
5
7
        close $lh;
999
5
85
        return 'csv' unless defined $line;
1000
5
9
        return 'tsv' if $line =~ /\t/;
1001
4
10
        return 'psv' if $line =~ /\|/;
1002
3
8
        return 'csv';
1003}
1004
1005# _values_are_data_like( \@vals ) -> bool
1006#
1007# Returns true when the values look like a row of real data (dates / numbers)
1008# rather than column headers.  Used to detect header-less CSV files where the
1009# first line is a data row.  At least one value must match a date or numeric
1010# pattern -- a row of plain hyphenated identifiers (e.g. "First-Name") is NOT
1011# considered data-like.
1012sub _values_are_data_like {
1013
33
12288
        my $vals = $_[0];
1014
33
33
24
35
        for my $v (@{$vals}) {
1015
47
79
                return 1 if $v =~ /\A\d{4}-\d{2}-\d{2}\z/;              # YYYY-MM-DD
1016
36
45
                return 1 if $v =~ /\A\d{1,2}\/\d{1,2}\/\d{4}\z/;        # M/D/YYYY or D/M/YYYY
1017
33
69
                return 1 if $v =~ /\A[+\-]\d+(?:\.\d+)?\z/;             # signed numeric (e.g. -75.13)
1018
28
38
                return 1 if $v =~ /\A\(\d+(?:\.\d+)?\)\z/;              # accounting negative (e.g. (75.13))
1019        }
1020
11
20
        return 0;
1021}
1022
1023# _synthesize_col_names( \@vals ) -> @names
1024#
1025# Infer a safe SQL identifier for each positional value by examining its
1026# content: ISO dates become "Date", numeric/currency values become "Amount",
1027# and free text becomes "Description".  Duplicate types are disambiguated with
1028# a numeric suffix (Date, Date2, Date3, ...).
1029sub _synthesize_col_names {
1030
23
6545
        my $vals = $_[0];
1031
23
24
        my %type_count;
1032        my @names;
1033
23
23
17
22
        for my $v (@{$vals}) {
1034
53
39
                my $type;
1035
53
160
                if ($v =~ /\A\d{4}-\d{2}-\d{2}\z/ || $v =~ /\A\d{1,2}\/\d{1,2}\/\d{4}\z/) {
1036
20
24
                        $type = 'Date';
1037                } elsif ($v =~ /\A[+\-]?\d+(?:\.\d+)?\z/ || $v =~ /\A\(\d+(?:\.\d+)?\)\z/) {
1038
21
22
                        $type = 'Amount';
1039                } else {
1040
12
11
                        $type = 'Description';
1041                }
1042
53
50
                $type_count{$type}++;
1043
53
77
                push @names, $type_count{$type} == 1 ? $type : $type . $type_count{$type};
1044        }
1045
23
47
        return @names;
1046}
1047
1048# _cache_key( $self ) -> $key | undef
1049#
1050# Purpose: Derive a stable cache key for this DataSource's full result set.
1051#          URL tables use a fixed key (TTL handles invalidation).
1052#          File tables encode the file's mtime in the key so a changed file
1053#          naturally produces a miss -- the stale entry is orphaned and evicts
1054#          passively when the CHI driver reclaims memory.
1055# Entry:   $self->{_cache} must be defined (caller checks this before calling).
1056# Exit:    Returns a non-empty string key, or undef when no key is derivable
1057#          (no URL, no file path on disk).
1058sub _cache_key :Protected {
1059        my $self = shift;
1060
1061        if (defined $self->{_url}) {
1062                my $idx = $self->{_html_table_index} // 0;
1063                # Include the table index in the key: a different index on the same URL
1064                # selects a different table from the page and must not share a cache entry.
1065                return 'bi:url:' . $self->{_url} . ':' . $idx;
1066        }
1067
1068        if (defined $self->{_file_path} && -f $self->{_file_path}) {
1069                my $mtime = (stat($self->{_file_path}))[9];
1070                return defined $mtime
1071                        ? 'bi:file:' . $self->{_file_path} . ':' . $mtime
1072                        : undef;
1073        }
1074
1075        return undef;
1076
12
12
12
6919
10
97
}
1077
1078# _init_backend( $self ) -> void
1079#
1080# Strategy: Database::Abstraction is designed as a base class where the
1081# lowercased package name maps to the data file in the directory
1082# (e.g. "Database::BI::_DB::Sales" -> data/sales.csv or data/sales.db).
1083# We synthesise an ephemeral subclass at runtime so DataSource remains
1084# fully table-agnostic and callers never need to touch Database::Abstraction
1085# directly. In Phase 2, this method can be replaced with Database::Join
1086# instantiation without any change to the public API.
1087#
1088# no_entry => 1: a BI viewer wants every row; we do not need O(1) keyed
1089# lookups on a primary key.  This stores data as an arrayref instead of a
1090# hashref, which the fast-track path in selectall_arrayref returns directly.
1091sub _init_backend :Protected {
1092        my $self      = shift;
1093        my $table     = $self->{_table};     # sanitized: used for pkg name and D::A table param
1094        my $raw_table = $self->{_raw_table} // $table;  # original: used for file lookup and dbname
1095        my $dir       = $self->{_directory};
1096
1097        # Remote file path (host is set): download via SFTP to a local temp directory,
1098        # then proceed with normal local processing on the downloaded copy.
1099        # Net::SFTP::Foreign is loaded lazily so non-remote usage has no extra deps.
1100        if (defined $self->{_host}) {
1101                eval { require Net::SFTP::Foreign }
1102                        or croak $self->_msg('error_backend_init', $table,
1103                                'Net::SFTP::Foreign is not installed (required for remote file access)');
1104                require File::Temp;
1105                # Split optional user@host into ($user, $host).
1106                my ($user, $host) = $self->{_host} =~ /\A([^@]+)\@(.+)\z/
1107                        ? ($1, $2) : (undef, $self->{_host});
1108                my $tmpdir = File::Temp->newdir(CLEANUP => 1);
1109                my @remote_exts = qw(csv tsv psv xlsx xls sql sqlite sqlite3 db xml);
1110                # Try the caller-supplied extension first (e.g. .log), then fall back.
1111                if (defined $self->{_file_ext}) {
1112                        my $hint = lc $self->{_file_ext};
1113                        @remote_exts = ($hint, grep { $_ ne $hint } @remote_exts);
1114                }
1115                # One SFTP connection shared across all extension attempts.
1116                my $sftp = eval { Net::SFTP::Foreign->new($host,
1117                        defined $user ? (user => $user) : (),
1118                        timeout => 10,
1119                ) };
1120                croak $self->_msg('error_backend_init', $table,
1121                        "could not connect to $host via SFTP: " . ($@ || ($sftp ? $sftp->error : 'unknown')))
1122                        unless defined $sftp && !$sftp->error;
1123                my $fetched_ext;
1124                for my $ext (@remote_exts) {
1125                        my $remote_path = "$dir/$raw_table.$ext";
1126                        my $local_file  = File::Spec->catfile("$tmpdir", "$raw_table.$ext");
1127                        $sftp->get($remote_path, $local_file);
1128                        # Verify the file landed on disk; sftp->error may be set on ENOENT.
1129                        next if $sftp->error || !-f $local_file || !-s $local_file;
1130                        $fetched_ext = $ext;
1131                        last;
1132                }
1133                croak $self->_msg('error_backend_init', $table,
1134                        "could not fetch any supported file from $self->{_host}:$dir/$raw_table.*")
1135                        unless defined $fetched_ext;
1136                $self->{_remote_tmpdir} = $tmpdir;   # prevents cleanup until $self is destroyed
1137
1138                # If the downloaded file has a non-standard extension (e.g. .log), rename
1139                # it to one that _detect_file_info and D::A can discover automatically.
1140                # We do this by sniffing the first bytes/line for format signatures.
1141                my %KNOWN_EXT = map { $_ => 1 }
1142                        qw(csv tsv psv xlsx xls sql sqlite sqlite3 db xml);
1143                unless ($KNOWN_EXT{lc $fetched_ext}) {
1144                        my $orig_file = File::Spec->catfile("$tmpdir", "$raw_table.$fetched_ext");
1145                        my $std_ext   = _sniff_data_ext($orig_file);
1146                        if ($std_ext ne lc $fetched_ext) {
1147                                my $new_file = File::Spec->catfile("$tmpdir", "$raw_table.$std_ext");
1148
12
12
12
5266
20
25
                                { no autodie; rename $orig_file, $new_file }
1149                                $fetched_ext = $std_ext;
1150                        }
1151                }
1152
1153                $dir = "$tmpdir";     # switch to local temp dir for all subsequent processing
1154        }
1155
1156        require Database::Abstraction;
1157
1158        my $pkg = 'Database::BI::_DB::' . ucfirst($table);
1159        {
1160
12
12
12
14593
12
1946
                no strict 'refs';
1161                push @{"${pkg}::ISA"}, 'Database::Abstraction'
1162                        unless $pkg->isa('Database::Abstraction');
1163        }
1164
1165        my $info   = _detect_file_info($dir, $raw_table);
1166
1167        # Probe for the actual file on disk so _cache_key can compute its mtime.
1168        # This runs before the early-return paths so even empty files get a path.
1169        for my $e (qw(csv tsv psv sql sqlite sqlite3 xml db xlsx xls)) {
1170                my $p = File::Spec->catfile($dir, "$raw_table.$e");
1171                if (-f $p) {
1172                        $self->{_file_path} = File::Spec->rel2abs($p);
1173                        last;
1174                }
1175        }
1176
1177        # BerkeleyDB file detected by magic bytes, but tie() failed (version
1178        # incompatibility -- e.g. the file uses BDB format 9 but the installed
1179        # libdb only supports an older format).  Croak now so D::A never falls
1180        # through to its CSV path and reads the entire binary file as text.
1181        if ($info->{_unknown_binary_db}) {
1182                croak $self->_msg('error_unsupported_db', $raw_table);
1183        }
1184
1185        # 0-byte file: skip D::A/DBI entirely.  D::A on an empty file falls through
1186        # to DBD::CSV, whose error handling corrupts DBI's Errstr SV and triggers an
1187        # XS assertion failure at cleanup on DEBUGGING perls (DBI 1.651, perl 5.44).
1188        if ($info->{_file_is_empty}) {
1189                $self->{_columns}       = [];
1190                $self->{_id_col}        = 'entry';
1191                $self->{_file_is_empty} = 1;
1192                return;
1193        }
1194
1195        # Headerless CSV or XLSX: _detect_file_info already pre-loaded all rows.
1196        # MUST check before error_no_safe_id: XLSX files with all-unsafe column
1197        # headers (e.g. "First Name", "Account Number") have id => undef because
1198        # none of the headers match the safe-identifier regex, but the data is
1199        # pre-loaded and the id column is irrelevant for the direct-data fast path.
1200        # Checking error_no_safe_id first would croak spuriously for valid XLSX files.
1201        if ($info->{_headerless_data}) {
1202                $self->{_id_col}  = $info->{id} // 'entry';
1203                $self->{_columns} = $info->{columns};
1204                $self->{_file_data} = $info->{_headerless_data};
1205                return;
1206        }
1207
1208        # _detect_file_info returns undef for id when every column header contains
1209        # characters that are not safe SQL identifiers (spaces, hyphens, etc.).
1210        # Falling back to the D::A default ('entry') would silently return 0 rows
1211        # since no 'entry' column exists.  Croak with a human-readable message.
1212        # This guard applies only to CSV/TSV/PSV/SQLite/XML -- headerless and XLSX paths
1213        # are handled by the _headerless_data check immediately above.
1214        croak $self->_msg('error_no_safe_id', $table)
1215                if exists $info->{columns} && !defined $info->{id};
1216        my $id_col = $info->{id} // 'entry';
1217        $self->{_id_col}  = $id_col;
1218        $self->{_columns} = $info->{columns};     # undef for SQLite/XML
1219
1220        # D::A validates dbname as a SQL identifier and rejects names that contain
1221        # spaces or other characters that are illegal in SQL (e.g. "transactions for
1222        # Nigel").  This check fires at query time (inside selectall_arrayref) for
1223        # the DBI path used by XLSX, SQLite, and XML files -- after construction
1224        # succeeds -- so the error surfaces as error_fetch_failed, not error_backend_init.
1225        #
1226        # Fix: when the original filename stem was sanitized (raw_table != table),
1227        # create a temporary directory with a symlink that uses the safe name.
1228        # D::A opens the symlink, sees a space-free dbname, and builds valid SQL.
1229        # The temp-dir object is kept in $self so the symlink persists for the full
1230        # lifetime of this DataSource instance and is cleaned up automatically when
1231        # $self is destroyed.
1232        my $dbname = $raw_table;
1233        my $da_dir = $dir;
1234        if ($raw_table ne $table) {
1235                my $safe_ext;
1236                for my $e (qw(xlsx xls db sql sqlite sqlite3 xml csv tsv psv)) {
1237                        $safe_ext = $e, last
1238                                if -f File::Spec->catfile($dir, "$raw_table.$e");
1239                }
1240                if (defined $safe_ext) {
1241                        require File::Temp;
1242                        my $tmp     = File::Temp->newdir(CLEANUP => 1);
1243                        my $abs_src = File::Spec->rel2abs(
1244                                File::Spec->catfile($dir, "$raw_table.$safe_ext"));
1245                        my $link    = File::Spec->catfile("$tmp", "$table.$safe_ext");
1246                        {
1247
12
12
12
35
14
16
                                no autodie;     # symlink failure gives our message, not autodie's
1248                                symlink($abs_src, $link)
1249                                        or croak $self->_msg('error_backend_init', $table,
1250                                                "cannot create safe-name symlink for '$raw_table.$safe_ext': $!");
1251                        }
1252                        $self->{_tmpdir} = $tmp;     # prevents cleanup until $self is destroyed
1253                        $dbname = $table;
1254                        $da_dir = "$tmp";
1255                }
1256        }
1257
1258        # SQLite: _detect_file_info probed sqlite_master and returned the internal
1259        # table names.  When $dbname (the filename stem or its safe alias) is not
1260        # among those names, auto-select the first user table (or use the caller-
1261        # supplied internal_table) and create a symlink from <target>.<ext> to the
1262        # original file so D::A can issue SQL against the real table name.
1263        # D::A uses 'dbname' to find the file and 'table' for the SELECT statement,
1264        # so both must be set to the actual table name when a mismatch is corrected.
1265        my $da_table = $table;  # D::A 'table' param -- controls the SQL table name
1266        if (exists $info->{sqlite_tables}) {
1267                my @tbls = @{ $info->{sqlite_tables} };
1268                croak $self->_msg('error_no_tables', $raw_table) unless @tbls;
1269
1270                # Store the full table list so callers (e.g. the tables_view action)
1271                # can detect multi-table databases after construction.
1272                $self->{_sqlite_tables} = \@tbls;
1273
1274                # Determine target table: caller-specified or auto-detected.
1275                my $desired = $self->{_internal_table};
1276                my $target;
1277                if (defined $desired) {
1278                        my ($found) = grep { $_ eq $desired } @tbls;
1279                        croak $self->_msg('error_table_not_found', $raw_table, $desired)
1280                                unless defined $found;
1281                        $target = $desired;
1282                } else {
1283                        my ($match) = grep { $_ eq $dbname } @tbls;
1284                        $target = defined $match ? $match : $tbls[0];
1285                }
1286
1287                # When the target differs from dbname, create a symlink so D::A can
1288                # find the file by target name and issue SELECT * FROM <target>.
1289                # Always use a D::A-recognised SQLite extension (.sql, .sqlite, .sqlite3)
1290                # for the symlink name -- D::A does not use the DBI/SQLite backend for
1291                # .db files, so a customer.db symlink would be read as CSV.
1292                if ($target ne $dbname) {
1293                        my ($src_ext) = grep { -f File::Spec->catfile($dir, "$raw_table.$_") }
1294                                qw(sql sqlite sqlite3 db);
1295                        $src_ext //= 'sql';
1296                        my $link_ext = ($src_ext eq 'db') ? 'sql' : $src_ext;
1297                        require File::Temp;
1298                        my $tmp     = File::Temp->newdir(CLEANUP => 1);
1299                        my $abs_src = File::Spec->rel2abs(
1300                                File::Spec->catfile($dir, "$raw_table.$src_ext"));
1301                        my $link = File::Spec->catfile("$tmp", "$target.$link_ext");
1302                        {
1303
12
12
12
14825
10
17
                                no autodie;
1304                                symlink($abs_src, $link)
1305                                        or croak $self->_msg('error_backend_init', $raw_table,
1306                                                "cannot create table-name symlink for '$raw_table.$src_ext': $!");
1307                        }
1308                        $self->{_tmpdir} = $tmp;
1309                        $dbname   = $target;            # D::A uses this for the filename stem
1310                        $da_table = $target;            # D::A uses this for SELECT * FROM <table>
1311                        $da_dir   = "$tmp";
1312                }
1313        }
1314
1315        my $db = eval {
1316                $pkg->new({
1317                        directory      => $da_dir,
1318                        table          => $da_table,
1319                        # D::A >= 0.41 uses the class-name suffix as dbname, not the table
1320                        # parameter, so a package like Database::BI::_DB::Orders would look
1321                        # for Orders.csv on a case-sensitive filesystem even when table =>
1322                        # 'orders' is passed.  Passing dbname explicitly keeps the filename
1323                        # stem correct regardless of the package name or D::A version.
1324                        # When the table name was sanitized (spaces, hyphens -> underscores),
1325                        # $dbname is the safe name so D::A never sees illegal SQL characters,
1326                        # and D::A finds the file via the symlink in $da_dir.
1327                        dbname         => $dbname,
1328                        id             => $id_col,
1329                        no_entry       => 1,
1330                        defined($info->{sep_char})  ? (sep_char       => $info->{sep_char})  : (),
1331                        # Force the Text::xSV::Slurp path for CSV/TSV/PSV files: D::A's default
1332                        # slurp threshold is 16 KB; larger files fall back to DBD::CSV, which
1333                        # sanitizes column names (lowercases and replaces spaces with
1334                        # underscores).  Passing the actual file size ensures the slurp path
1335                        # is always taken, so "Account Number" stays "Account Number" rather
1336                        # than becoming "account_number".
1337                        defined($info->{file_size}) ? (max_slurp_size => $info->{file_size}) : (),
1338                });
1339        };
1340        if ($@) {
1341                croak $self->_msg('error_backend_init', $table, $@);
1342        }
1343
1344        $self->{_db} = $db;
1345        return;
1346
12
12
12
16263
12
119
}
1347
1348# ---------------------------------------------------------------------------
1349# Public accessors
1350# ---------------------------------------------------------------------------
1351
1352 - 1372
=head1 ACCESSORS

=head2 table_name

Returns the (lowercased) table name this instance was opened against.

=head3 API SPECIFICATION

=head4 INPUT

None.

=head4 OUTPUT

Returns a C<SCALAR> string.

=head3 MESSAGES

None.

=cut
1373
1374sub table_name {
1375
24
2842
        my $self = shift;
1376
24
67
        return $self->{_table};
1377}
1378
1379 - 1416
=head2 sqlite_tables

Returns an arrayref of internal table names when the backing file is a
multi-table SQLite database, or C<undef> for single-table and non-SQLite
sources.  Set during C<_init_backend> after probing C<sqlite_master>.

When the return value is a reference to a list with B<more than one
element>, the caller should redirect the user to the schema browser
(C</tables>) so they can pick which table to open, rather than
silently opening the first one.

Example:

    my $tbls = $source->sqlite_tables;
    if ($tbls && @{$tbls} > 1) {
        # multiple tables -- let the user choose
        print "Tables: ", join(', ', @{$tbls}), "\n";
    }

=head3 API SPECIFICATION

=head4 INPUT

None.

=head4 OUTPUT

    ARRAYREF of SCALAR    # ['customer', 'product', 'sales'] for a 3-table SQLite file
    undef                 # for CSV, TSV, PSV, XML, URL-backed, and single-table SQLite

The arrayref contains the names in the order returned by C<sqlite_master>
(alphabetical).  It is set once during construction and never changes.

=head3 MESSAGES

None.

=cut
1417
1418sub sqlite_tables {
1419
209
4240
        my $self = shift;
1420
209
732
        return $self->{_sqlite_tables};
1421}
1422
1423 - 1484
=head2 columns

Returns an arrayref of column names in file order, or C<undef> when no order
is available.

=over 4

=item CSV / TSV / PSV / XLSX

Column names come from the file header (first row).  They are returned in
the order they appear in the file.  For header-less files (detected
automatically) the synthesized names (C<Date>, C<Amount>, C<Description>,
etc.) are returned in the same synthesized order.

=item SQLite / XML

Falls back to the underlying C<Database::Abstraction> object's C<columns()>
method.  This is useful when a C<DataSource> is passed directly to
C<Database::Join> as a component database.

=item URL-backed tables

Always returns C<undef>.  C<Database::Abstraction> fetches URL data lazily,
so calling C<_db-E<gt>columns()> here would trigger a live network request even
when the data came from the CHI cache.  The controller derives column order
from the data records instead (alphabetical fallback).

=item Redis-backed tables

Delegates to the underlying C<Database::Abstraction> C<columns()>, which
triggers a lazy open of the Redis connection on first call and returns the
column names derived from the first stored row (alphabetical order, includes
C<entry>).  This is required so that C<Database::Join> can validate the
join column at construction time.

=back

Always check the return value before dereferencing:

    my $cols = $source->columns;
    if ($cols) {
        print join(', ', @{$cols}), "\n";
    } else {
        # URL-backed tables: derive columns from the first data row
    }

=head3 API SPECIFICATION

=head4 INPUT

None.

=head4 OUTPUT

    ARRAYREF of SCALAR    # ['id', 'name', 'price', ...] -- original header order
    undef                 # SQLite, XML, URL-backed tables

=head3 MESSAGES

None.

=cut
1485
1486sub columns {
1487
576
53017
        my $self = shift;
1488
576
1451
        return $self->{_columns} if defined $self->{_columns};
1489        # URL-backed tables have no canonical column order at construction time;
1490        # calling _db->columns() would trigger a live HTTP request which may fail.
1491        # Return undef so _get_columns derives column names from fetched data.
1492
58
142
        return undef if defined $self->{_url};
1493        # Redis-backed tables: delegate to _db->columns() which triggers the lazy
1494        # open() on first call.  D::J requires a real column list at construction
1495        # time so returning undef here would make join validation always fail.
1496        # D::A builds its Redis column list from hgetall fields only and omits the
1497        # entry key (the suffix of the Redis key pattern tablename:entryid).
1498        # Prepend the id column so callers and D::J see a complete column list.
1499
50
131
        if (defined $self->{_redis_url} && $self->{_db}) {
1500
0
0
                my $da_cols  = $self->{_db}->columns() // [];
1501
0
0
                my $id       = $self->{_id_col} // 'entry';
1502
0
0
0
0
0
0
                my @full     = ($id, grep { $_ ne $id } @{$da_cols});
1503
0
0
                $self->{_columns} = \@full;
1504
0
0
                return $self->{_columns};
1505        }
1506
50
197
        return $self->{_db} ? $self->{_db}->columns() : undef;
1507}
1508
1509 - 1552
=head2 dbi_source

Returns a hashref C<{ dbh =E<gt> $dbh, table =E<gt> $name }> for SQLite-backed
instances, and C<undef> for everything else.

C<Database::Join> 0.009.0 probes C<dbi_source> at join-construction time to
decide whether it can take the zero-copy C<ATTACH> path (avoiding a row-by-row
spill into a temporary SQLite file).  Without this method, every C</join> on a
SQLite-backed C<DataSource> uses the spill path regardless of row count.

Returns C<undef> for:

=over 4

=item *

0-byte and header-less pre-parsed files (C<_file_is_empty>, C<_file_data> set).

=item *

Redis-backed and URL-backed sources (no DBI handle).

=item *

Any backend that does not itself implement C<dbi_source>.

=back

=head3 API SPECIFICATION

=head4 INPUT

None.

=head4 OUTPUT

    { dbh => $dbh_object, table => 'table_name' }   # SQLite-backed
    undef                                            # all other backends

=head3 MESSAGES

None.

=cut
1553
1554sub dbi_source {
1555
109
27085
        my $self = shift;
1556
109
218
        return undef if $self->{_file_is_empty};
1557
109
197
        return undef if $self->{_file_data};
1558
109
248
        return undef unless $self->{_db};
1559
109
314
        return undef unless $self->{_db}->can('dbi_source');
1560
109
787
        return $self->{_db}->dbi_source();
1561}
1562
1563 - 1595
=head2 aggregate

Delegates to the inner C<Database::Abstraction> backend's C<aggregate()> method
(requires C<Database::Abstraction> E<gt>= 0.49).

Returns C<[]> for in-memory backends (headerless CSV, XLSX, empty files) and for
URL-backed sources where aggregation is not available.

Accepts the same C<by>, C<measures>, C<having>, C<sort_by>, C<limit>, C<offset>,
and WHERE-criteria parameters as C<Database::Abstraction::aggregate()>.

=head3 API SPECIFICATION

=head4 INPUT

  by        arrayref    Column names to GROUP BY.
  measures  arrayref    Measure descriptors (see Database::Abstraction::aggregate).
  having    hashref     Optional HAVING criteria.
  sort_by   string      Optional ORDER BY column.
  limit     integer     Optional result limit.
  offset    integer     Optional result offset.
  (any other key)       WHERE column criteria passed through to D::A.

=head4 OUTPUT

  arrayref of hashrefs   -- one row per group.
  []                     -- in-memory / empty / URL-backed sources.

=head3 MESSAGES

None.

=cut
1596
1597sub aggregate {
1598
70
130
        my ($self, @args) = @_;
1599
70
114
        return [] if $self->{_file_is_empty};
1600
70
104
        return [] if $self->{_file_data};
1601
70
115
        return [] unless $self->{_db};
1602
70
224
        return [] unless $self->{_db}->can('aggregate');
1603
70
527
        return $self->{_db}->aggregate(@args);
1604}
1605
1606 - 1635
=head2 id_column

Returns the name of the column used as the primary key and as
C<Database::Abstraction>'s row-existence anchor.

For file-backed tables this is the first column in the CSV/TSV/PSV header
whose name is a valid SQL identifier (letters, digits, underscores only,
not starting with a digit) B<and> whose value in the first data row is
non-empty.  C<DataSource> detects this automatically to avoid the
C<Database::Abstraction> default of C<'entry'>, which would silently
discard every row in a file that has no C<entry> column.

Returns C<undef> for URL/HTML-table backends (no primary-key concept applies).

=head3 API SPECIFICATION

=head4 INPUT

None.

=head4 OUTPUT

    SCALAR string    # e.g. 'id', 'date', 'sku' -- the first safe-identifier column
    undef            # URL-backed (HTML table import) sources

=head3 MESSAGES

None.

=cut
1636
1637sub id_column {
1638
15
618
        my $self = shift;
1639
15
24
        return $self->{_id_col};
1640}
1641
1642 - 1666
=head2 source_url

Returns the source URL for URL/HTML-table-backed instances.
Returns C<undef> for all file-backed instances (local files and SFTP-fetched
remote files).

Use this to distinguish a URL-backed C<DataSource> from a file-backed one
when you need to decide whether to show a "re-fetch" button to the user.

=head3 API SPECIFICATION

=head4 INPUT

None.

=head4 OUTPUT

    SCALAR string    # 'https://example.com/data.html' -- the original URL
    undef            # file-backed (local or SFTP) sources

=head3 MESSAGES

None.

=cut
1667
1668sub source_url {
1669
2
4
        my $self = shift;
1670
2
5
        return $self->{_url};
1671}
1672
1673 - 1699
=head2 updated

Returns the Unix epoch timestamp (mtime) of the backing data file, or
C<undef> for URL-backed tables and for sources whose file cannot be
C<stat()>-ed.

C<Database::Join> calls C<updated()> on each component database to
detect whether its SQLite spill cache is stale.  When a source file changes
on disk, the mtime increases, the join detects the change, and re-runs the
join rather than serving stale cached data.

=head3 API SPECIFICATION

=head4 INPUT

None.

=head4 OUTPUT

    SCALAR integer   # Unix epoch seconds, e.g. 1728601234
    undef            # URL-backed tables, or file cannot be stat()-ed

=head3 MESSAGES

None.

=cut
1700
1701sub updated {
1702
15
16905
        my $self = shift;
1703        # Delegate to the D::A backend when available -- it tracks mtime internally.
1704
15
40
        return $self->{_db}->updated() if $self->{_db} && $self->{_db}->can('updated');
1705        # Fallback: stat the file path directly (header-less CSV and XLSX bypass D::A).
1706
4
21
        return (stat($self->{_file_path}))[9] if defined $self->{_file_path} && -f $self->{_file_path};
1707
2
4
        return undef;
1708}
1709
1710# ---------------------------------------------------------------------------
1711# Public data-access methods
1712# ---------------------------------------------------------------------------
1713
1714 - 1774
=head1 METHODS

=head2 fetch_all

Returns every record in the table as an arrayref of hashrefs (one hashref
per row; keys are the column names).

This is the primary data-retrieval method.  It is equivalent to calling
C<selectall_arrayref> with no arguments and normalising the result to an
arrayref.  Prefer C<selectall_arrayref> when passing to C<Database::Join>
(it is the method that C<Database::Join> calls internally).

Filtering (C<eq>, C<contains>, C<gt>, C<lt>, etc.) is applied at the
controller layer by C<Dashboard::_apply_filter_spec> after C<fetch_all>
returns.  C<DataSource> itself is filter-unaware.

Three fast-path shortcuts are checked before touching the DBI backend,
in this order:

=over 4

=item 1.

C<_file_is_empty> is set (0-byte or blank-first-line file) -- returns C<[]>
immediately, no DBI connection.

=item 2.

C<_file_data> is set (pre-parsed header-less CSV, XLSX, or BerkeleyDB data) --
returns the in-memory arrayref directly.

=item 3.

A matching CHI cache entry exists -- returns the cached arrayref without
calling C<Database::Abstraction>.

=back

=head3 API SPECIFICATION

=head4 INPUT

None.  Filtering is applied at the controller layer, not inside C<DataSource>.

=head4 OUTPUT

    ARRAYREF of HASHREF   # one hashref per row; keys are column names
    []                    # empty arrayref when the file is empty or has no data rows

Croaks (via C<croak>) when the backend raises an exception.  Emits a
warning via C<carp> (non-fatal) when the result set is empty, so the
caller can distinguish "opened successfully, no data" from a silent
failure.

=head3 MESSAGES

  error_fetch_failed     -- backend threw an exception during retrieval
  warn_empty_result      -- query succeeded but returned zero records
  warn_data_normalised   -- backend returned a hashref; converted to arrayref

=cut
1775
1776 - 1828
=head2 selectall_arrayref

C<Database::Abstraction>-compatible alias that allows a C<DataSource> object
to be passed directly to C<Database::Join> as a component database.

When called with no arguments (the normal path for a full-table scan),
results are served from the CHI cache when available.  When called with
arguments (e.g. C<limit =E<gt> N, offset =E<gt> M> for server-side
pagination), the cache is bypassed -- narrowed results must not pollute
the full-table cache entry.

Three fast-path shortcuts are checked before touching the DBI backend:

=over 4

=item 1.

C<_file_is_empty> is set -- returns C<[]> immediately.

=item 2.

C<_file_data> is set (pre-parsed headerless CSV / XLSX / BerkeleyDB) -- returns
the in-memory rows directly.

=item 3.

A matching CHI cache entry exists for the full-table scan -- returns the
cached arrayref.

=back

=head3 API SPECIFICATION

=head4 INPUT

Optional flat key/value list passed through to C<Database::Abstraction>:

    limit  => N    # return at most N rows (used by pagination)
    offset => M    # skip the first M rows (used by pagination)

When no arguments are given, the full table is returned (and cached).

=head4 OUTPUT

    ARRAYREF of HASHREF   # one hashref per row; keys are column names
    []                    # empty arrayref when the file is empty or no rows match

=head3 MESSAGES

None.  Errors from the DBI backend propagate as uncaught exceptions; wrap
calls in C<eval> if you need to recover.

=cut
1829
1830sub selectall_arrayref {
1831
450
14968
        my ($self, @args) = @_;
1832
450
938
        return [] if $self->{_file_is_empty};
1833
442
812
        return $self->{_file_data} if $self->{_file_data};
1834
1835        # Cache only plain unfiltered calls -- Database::Join passes no args for the
1836        # full table scan; a non-empty @args means a narrowed query whose result must
1837        # not be mistaken for the full-table cache entry.
1838
435
533
        my $cache = $self->{_cache};
1839
435
1231
        if ($cache && !@args) {
1840
263
672
                my $key = $self->_cache_key;
1841
263
536
                if (defined $key) {
1842
263
1015
                        my $hit = $cache->get($key);
1843
263
29027
                        return $hit if defined $hit;
1844                }
1845        }
1846
1847
262
1019
        my $data = $self->{_db}->selectall_arrayref(@args);
1848
1849
251
813186
        if ($cache && !@args && defined $data) {
1850
90
242
                my $key = $self->_cache_key;
1851
90
193
                if (defined $key) {
1852                        defined $self->{_url}
1853                                ? $cache->set($key, $data, $self->{_cache_ttl_url})
1854
90
465
                                : $cache->set($key, $data);
1855                }
1856        }
1857
1858
251
17620
        return $data;
1859}
1860
1861sub fetch_all {
1862
75
17287
        my $self  = shift;
1863
75
100
        my $table = $self->{_table};
1864
1865        # 0-byte file: no backend was created; nothing to fetch.
1866
75
134
        return [] if $self->{_file_is_empty};
1867
1868        # Headerless CSV/XLSX: data was pre-parsed with synthesized column names.
1869
71
136
        return $self->{_file_data} if $self->{_file_data};
1870
1871        # Cache check: URL tables benefit from avoiding repeated HTTP round-trips;
1872        # file tables benefit from skipping disk I/O and CSV/TSV/PSV parsing.
1873
63
71
        my $cache = $self->{_cache};
1874
63
105
        if ($cache) {
1875
33
71
                my $key = $self->_cache_key;
1876
33
62
                if (defined $key) {
1877
33
126
                        my $hit = $cache->get($key);
1878
33
3187
                        return $hit if defined $hit;
1879                }
1880        }
1881
1882
43
43
38
162
        my $data = eval { $self->{_db}->selectall_hashref() };
1883
43
167035
        if ($@) {
1884
4
16
                croak $self->_msg('error_fetch_failed', $table, $@);
1885        }
1886
1887
39
71
        return [] unless defined $data;
1888
1889        # Strategy: Database::Abstraction can return either an arrayref (when the
1890        # underlying driver iterates rows) or a hashref keyed by primary key (when
1891        # it mirrors DBI's selectall_hashref semantics). We normalise to arrayref
1892        # here so every caller above this layer sees a uniform structure.
1893
36
65
        if (ref $data eq 'HASH') {
1894
4
11
                carp $self->_msg('warn_data_normalised', $table);
1895
4
4
527
6
                $data = [ values %{$data} ];
1896        }
1897
1898
36
36
33
64
        if (!@{$data}) {
1899
4
15
                carp $self->_msg('warn_empty_result', $table);
1900        }
1901
1902        # Cache store: URL tables use a TTL so stale pages expire automatically;
1903        # file tables encode mtime in the key so a changed file produces a natural
1904        # miss without any explicit invalidation.
1905
36
426
        if ($cache) {
1906
12
29
                my $key = $self->_cache_key;
1907
12
26
                if (defined $key) {
1908                        defined $self->{_url}
1909                                ? $cache->set($key, $data, $self->{_cache_ttl_url})
1910
12
55
                                : $cache->set($key, $data);
1911                }
1912        }
1913
1914
36
2313
        return $data;
1915}
1916
1917 - 1948
=head2 count

Returns the total number of rows in the table.

For pre-parsed sources (header-less CSV, XLSX, BerkeleyDB) the count comes
from the already-loaded in-memory slice and is instant (no I/O).  For
empty-file sentinels it returns C<0> immediately.  For all DBI-backed
sources (SQLite, large CSV via DBD::CSV) it delegates to
C<Database::Abstraction::count()>, which issues a
C<SELECT COUNT(*) FROM E<lt>tableE<gt>> query against the file.

B<Warning: do not call C<count()> on a very large SQLite file> if you
only need to know whether a next page exists.  The pagination controller
uses the N+1 trick (fetch C<per_page + 1> rows via C<limit/offset>)
specifically to avoid a C<COUNT(*)> on multi-gigabyte databases, which
can block the server for minutes.

=head3 API SPECIFICATION

=head4 INPUT

None.

=head4 OUTPUT

    SCALAR non-negative integer   # total row count, 0 for empty files

=head3 MESSAGES

None.

=cut
1949
1950sub count {
1951
34
99954
        my ($self) = @_;
1952
34
57
        return 0 if $self->{_file_is_empty};
1953
34
0
48
0
        return scalar @{ $self->{_file_data} } if $self->{_file_data};
1954
34
104
        return $self->{_db}->count();
1955}
1956
19571;
1958