| File: | blib/lib/Database/BI/Model/DataSource.pm |
| Coverage: | 91.1% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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 | ||||||
| 15 | our $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 | ||||||
| 252 | Readonly 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. | |||||
| 272 | Readonly 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. | |||||
| 284 | sub _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. | |||||
| 309 | sub _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). | |||||
| 320 | sub _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 | ||||||
| 432 | sub 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. | |||||
| 509 | sub _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. | |||||
| 544 | sub _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. | |||||
| 581 | sub _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. | |||||
| 626 | sub _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 => [...] }. | |||||
| 668 | sub _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' | |||||
| 987 | sub _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. | |||||
| 1012 | sub _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, ...). | |||||
| 1029 | sub _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). | |||||
| 1058 | sub _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. | |||||
| 1091 | sub _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 | ||||||
| 1374 | sub 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 | ||||||
| 1418 | sub 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 | ||||||
| 1486 | sub 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 | ||||||
| 1554 | sub 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 | ||||||
| 1597 | sub 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 | ||||||
| 1637 | sub 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 | ||||||
| 1668 | sub 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 | ||||||
| 1701 | sub 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 | ||||||
| 1830 | sub 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 | ||||||
| 1861 | sub 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 | ||||||
| 1950 | sub 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 | ||||||
| 1957 | 1; | |||||
| 1958 | ||||||