NAME
Database::BI::Model::DataSource - Table-agnostic adapter around Database::Abstraction
VERSION
0.007.0
SYNOPSIS
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};
}
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";
}
Find out which column is the primary key:
print "Primary key column: ", $source->id_column, "\n";
Open a SQLite file (.sql extension):
my $source = Database::BI::Model::DataSource->new(
directory => '/var/data',
table => 'inventory', # looks for /var/data/inventory.sql
);
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
);
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
);
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,
);
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;
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
);
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: $@";
}
DESCRIPTION
Database::BI::Model::DataSource is a thin, table-agnostic adapter that wraps Database::Abstraction and exposes three accessors (fetch_all, columns, id_column) used by the controller.
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. DataSource generates an ephemeral subclass at construction time so callers never interact with Database::Abstraction directly. To swap the backend for Database::Join in Phase 2, only the open_table helper in Database::BI needs to change; the controller and DataSource are untouched.
_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 Database::Abstraction defaults corrupt every result: sep_char defaults to '!' (turning a comma-separated file into a single-field table) and id defaults to 'entry' (causing every row to be discarded when no entry column exists).
Result filtering (eq, contains, gt, etc.) is performed at the controller layer by Dashboard::_apply_filter_spec after fetch_all returns. DataSource itself is filter-unaware.
All user-visible strings and exception messages are keyed through the %MESSAGES dictionary and routed via _msg(), making every diagnostic replaceable by an i18n object at instantiation time.
UTF-8 and Encoding
DataSource passes cell values through as Perl character strings exactly as Database::Abstraction and the underlying DBI driver return them. For CSV, TSV and PSV files smaller than 16 KB, Text::xSV::Slurp is used and bytes are returned without re-encoding; for larger files the DBD::CSV path is used. In both cases the caller (the controller) is responsible for setting the correct Content-Type header.
The table argument and all column names must be ASCII-only identifiers. Full Unicode is supported inside cell values -- the restriction applies only to structural metadata (column headers, table name), not to the data itself.
URL-backed tables (the url => constructor path) are fetched with LWP::UserAgent::Cached. If the remote server declares a charset in its HTTP headers or HTML meta tag, Database::Abstraction uses it to decode the response body. If the declaration is absent or wrong, cell values may contain raw bytes rather than character strings.
CONSTRUCTOR
new
Creates and returns a new Database::BI::Model::DataSource instance.
API SPECIFICATION
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")
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
}
Accepts a flat key/value list, a hashref, or positional arguments via Params::Get.
When host is provided, directory is treated as a path on the remote host and is not checked with -d locally. DataSource downloads the file via SFTP (Net::SFTP::Foreign) to a process-local temporary directory and then processes the local copy. The temporary directory persists for the lifetime of the DataSource object and is cleaned up automatically on destruction.
When file_ext is provided together with 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 csv, tsv, psv, xlsx, xls, sql, sqlite, sqlite3, db, 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.
DOMAIN CONSTRAINTS
directory-
Must satisfy
-d $directory(must exist and be a directory). An empty string, a non-existent path, or the path to a regular file all produceerror_directory_missing.Valid partition: any existing directory path Invalid partition: non-existent path, regular file path, empty string "" 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
_. 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)
OUTPUT
Returns $self (a blessed hashref). Croaks on invalid arguments.
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_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
ACCESSORS
table_name
Returns the (lowercased) table name this instance was opened against.
API SPECIFICATION
INPUT
None.
OUTPUT
Returns a SCALAR string.
MESSAGES
None.
columns
Returns an arrayref of column names in file order, or undef when no order is available. For CSV, TSV and PSV files the order comes from the file header. For SQLite and XML, falls back to the underlying Database::Abstraction object's columns() - useful when a DataSource is passed directly to Database::Join as a component database.
id_column
Returns the name of the column used as the primary key / slurp-filter anchor. Returns undef for URL/HTML-table backends (no primary-key concept applies).
source_url
Returns the source URL for URL/HTML-table-backed instances, or undef for file-backed instances.
METHODS
fetch_all
Returns every record in the table as an arrayref of hashrefs.
API SPECIFICATION
INPUT
None. Filtering is performed at the controller layer (Dashboard::_apply_filter_spec) after fetch_all returns, not inside DataSource.
OUTPUT
ARRAYREF of HASHREF # one hashref per row, keys are column names
# returns [] when the table exists but is empty
Croaks if the backend raises an exception. Carps (non-fatal) when the result set is empty so the caller can distinguish "open succeeded, no rows" from a silent failure.
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
selectall_arrayref
Database::Abstraction-compatible alias that allows a DataSource object to be passed directly to Database::Join as a component database. Passes any criteria through to the underlying backend; the BI viewer always calls it with no arguments.
COMMON PITFALLS
These are the most common mistakes when using DataSource.
- SQLite databases may use .sql, .sqlite, or .sqlite3 as their file extension
-
DataSourcerecognises.sql,.sqlite, and.sqlite3as SQLite database files. It does not recognise.db3. If you have a file calledinventory.db3, rename it toinventory.sqlbefore passing it toDataSource. - The table name is always lowercased
-
The constructor lowercases the
tableargument before using it. Passingtable => 'Sales'andtable => 'sales'both look forsales.csv(orsales.sql, etc.). The matching is case-insensitive on the table name but case-sensitive on the directory path. - CSV files with the wrong separator appear as one giant field per row
-
Database::Abstractiondefaults to!(exclamation mark) as its field separator. A standard comma-separated CSV file will look like one big field per row (for example,1,Widget A,North,100) because the library never sees the commas as separators.DataSourcefixes this automatically by reading the first line of the file and detecting the actual separator. If you bypassDataSourceand callDatabase::Abstractiondirectly, you must passsep_char => ','yourself. - A table with no "entry" column returns zero rows (without DataSource)
-
Database::Abstractionusesentryas its default primary-key column name. When slurping a CSV, it discards any row where$row->{entry}is undefined. Because most CSV files do not have anentrycolumn, all rows are silently discarded.DataSourceprevents this by reading the actual first column name from the CSV header and passing it asid, and also by settingno_entry => 1so all rows are kept as an ordered array. - columns() returns undef for SQLite and XML files
-
columns()only returns an arrayref for file formats where the header order is visible before data is read (CSV, TSV and PSV). For SQLite and XML files it returnsundef. Always check:if ($source->columns) { ... }. The controller falls back to puttingid_columnfirst and then sorting the rest alphabetically whencolumns()isundef. - XML elements named "name", "id", or "key" cause parse failures
-
XML::Simple(used byDatabase::Abstractionfor XML files) automatically turns a child element calledname,id, orkeyinto a hash key instead of keeping it as an array element. This breaks the expected data structure. Use different element names in your XML: for example,<sku>,<label>, or<code>instead of<id>and<name>.<!-- WRONG: these element names trigger XMLin key-folding --> <items> <item><id>1</id><name>Widget</name></item> </items> <!-- CORRECT: use neutral element names --> <items> <item><sku>1</sku><label>Widget</label></item> </items> - fetch_all returns an empty arrayref, not undef, for an empty table
-
When a table exists but contains no data rows,
fetch_allreturns[](an empty arrayref), notundef. Check withscalar @{$records}, not withdefined $recordsor$records. - 0-byte CSV/TSV/PSV files bypass Database::Abstraction entirely
-
When
_detect_file_infoopens a CSV, TSV or PSV file and the firstreadlinereturnsundef(the file is 0 bytes), it returns a{ _file_is_empty => 1 }sentinel instead of the normal{ id, sep_char, columns, file_size }hashref._init_backenddetects this sentinel and skipsDatabase::Abstractionconstruction;fetch_allreturns[]immediately. No DBI connection is created for 0-byte files. If you mock or spy on DBI handles and open a 0-byte CSV, the mock will never fire - this is expected behaviour, not a mock misconfiguration. - Remote files require Net::SFTP::Foreign; one connection per open
-
When
hostis set,DataSourcemakes one SFTP connection pernew()call. It tries the extension list in order (thefile_exthint first, if provided, then the full standard-extension list) and downloads the first file it finds. If no file is found under any tried extension,new()croakserror_backend_init. Connections are not pooled or reused betweenDataSourceinstances.If the remote file has a non-standard extension (e.g.
.log,.dat), the content is sniffed: SQLite magic bytes map to.db, an XML preamble to.xml, a tab-delimited first line to.tsv, a pipe-delimited line to.psv, and everything else to.csv. The temp file is then renamed to the detected extension before further processing. - DBD::CSV silently lowercases column names for files larger than 16 KB
-
Database::AbstractionusesText::xSV::Slurpfor files up to 16 KB and DBD::CSV for larger ones. The DBD::CSV path lowercases column names and replaces spaces with underscores (Account Numberbecomesaccount_number), which causes a"disallowed key"error at render time when the template iterates the original column names.DataSourceavoids this by always passingmax_slurp_size => -s $pathtoDatabase::Abstraction, forcing the slurp path regardless of file size. If you callDatabase::Abstractiondirectly, you must pass this option yourself for any file whose column names contain spaces or mixed case.
LIMITATIONS
Only read operations are supported. Write-back is not in scope.
One
DataSourceinstance corresponds to exactly one table. Multi-table joins are now delegated toDatabase::Join(see Database::Join), which acceptsDataSourceobjects directly as component databases via theselectall_arrayrefandcolumnsmethods this class exposes.The ephemeral backend class is generated into a package namespace (
Database::BI::_DB::*) that persists for the lifetime of the process. Instantiating twoDataSourceobjects for the same table name reuses the same ephemeral class.The
i18nobject, if supplied, must implementmaketext($key, @args)compatible with Locale::Maketext.
CONFIGURATION AND ENVIRONMENT
No environment variables are read. All configuration is passed through the constructor.
DEPENDENCIES
Carp, Readonly, Scalar::Util, Params::Validate::Strict, Params::Get, Database::Abstraction.
Optional (loaded lazily):
Net::SFTP::Foreign -- required for remote file access (host => ...). Spreadsheet::ParseXLSX -- required for opening .xlsx files.
INCOMPATIBILITIES
None known.
BUGS AND LIMITATIONS
Please report bugs via https://github.com/nigelhorne/Database-BI/issues.
AUTHOR
Nigel Horne <njh@nigelhorne.com>
FORMAL SPECIFICATION
new
new == [directory : PATH; table : NAME;
host? : HOST_STRING; file_ext? : EXT_STRING;
cache? : CHI_OBJECT; cache_ttl_url? : TTL_STRING;
i18n? : I18N_OBJECT]
pre (host = undef => is_dir directory)
/\ (host /= undef => host =~ REMOTE_HOST_RE)
/\ table /= ""
post result.class = DataSource
/\ (host = undef => result._db.class = Database::Abstraction)
/\ (host /= undef => result._remote_tmpdir /= undef)
table_name
table_name == lambda self . self._table
fetch_all
fetch_all == lambda self .
let rows = self._db.selectall_hashref() in
pre self._db /= undef
post result : seq HASHREF
/\ #result >= 0
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.