| File: | blib/lib/Database/BI.pm |
| Coverage: | 90.7% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Database::BI; | |||||
| 2 | ||||||
| 3 | 12 12 12 | 3391989 13 124 | use strict; | |||
| 4 | 12 12 12 | 18 9 425 | use warnings; | |||
| 5 | ||||||
| 6 | 12 12 12 | 21 8 47 | use Mojo::Base 'Mojolicious', -strict, -signatures; | |||
| 7 | ||||||
| 8 | 12 12 12 | 317873 14 201 | use Carp qw(croak); | |||
| 9 | 12 12 12 | 2315 300038 143 | use CHI (); | |||
| 10 | 12 12 12 | 40 10 75 | use File::Spec (); | |||
| 11 | 12 12 12 | 601 4298 293 | use Readonly; | |||
| 12 | ||||||
| 13 | 12 12 12 | 2791 18 6452 | use Database::BI::Model::DataSource; | |||
| 14 | ||||||
| 15 | our $VERSION = '0.012.0'; | |||||
| 16 | ||||||
| 17 | # Default config values used by the Config plugin and referenced explicitly | |||||
| 18 | # in startup() so callers always get a resolved value. | |||||
| 19 | Readonly my $DEFAULT_DATA_DIR => 'data'; | |||||
| 20 | Readonly my $DEFAULT_PLATFORM => 'web'; | |||||
| 21 | Readonly my $DEFAULT_LANGUAGE => 'en'; | |||||
| 22 | Readonly my $DEFAULT_CACHE_TTL_URL => '15 min'; | |||||
| 23 | ||||||
| 24 | # Transport-layer upload size cap (bytes). Mojolicious enforces this before | |||||
| 25 | # the request body is read into memory, so an oversized upload never reaches | |||||
| 26 | # the controller. Must match $MAX_UPLOAD_BYTES in Dashboard.pm. | |||||
| 27 | Readonly my $MAX_REQUEST_SIZE => 50 * 1_048_576; # 50 MiB | |||||
| 28 | Readonly my $UPLOAD_MAX_AGE_S => 24 * 3600; # evict uploads older than 24 h | |||||
| 29 | ||||||
| 30 - 549 | =head1 NAME
Database::BI - Web-based Business Intelligence viewer for data files
=head1 VERSION
0.012.0
=head1 DESCRIPTION
C<Database::BI> is a self-contained L<Mojolicious> web application that reads
and views arbitrary flat data (CSV, PSV, TSV, SQLite, XML, XLSX, etc.)
and presents them as styled, sortable, reorderable
HTML tables.
It has no persistent database of its own,
it reads your files on every request.
Users navigate to pick a file, open the filesystem browser to navigate anywhere
on disc, drag-and-drop files to upload, join multiple tables in memory, apply
server-side filters, and export results as CSV, SQLite, or JSON. D3.js
visualisations (line chart, pie chart) are available from the toolbar.
Key features:
=over 4
=item *
B<File picker> -- the home page scans C<data_dir> and shows a card for
every supported file. Recently opened filesystem files appear in a
"Recently opened" section powered by C<localStorage>.
=item *
B<Filesystem browser> -- C</browse> lets the user navigate the entire
filesystem and open any supported data file, not just files in C<data_dir>.
=item *
B<Column sort and reorder> -- clicking a header sorts the table; headers
are draggable to reorder. Both settings are persisted in C<localStorage>
by column name and survive page reloads.
=item *
B<Left join> -- the "Merge data" panel on any table view lets the user
join one or more additional tables on a shared key via L<Database::Join>.
Every left row is kept; right-table columns are appended for matching rows.
Multiple joins can be chained.
=item *
B<Quick-filter bar> -- a column/operator/value row below the toolbar lets
the user add filter conditions without opening any panel. Pressing Enter
applies the filter. Each active filter chip has an individual remove (x)
button so conditions can be dropped one at a time. Operators: C<eq>,
C<ne>, C<contains>, C<starts>, C<lt>, C<le>, C<gt>, C<ge>, C<empty>,
C<notempty>. Filters are applied server-side after all joins.
=item *
B<Combine (UNION ALL)> -- the "Combine data" panel stacks rows from a
second (or further) file beneath the current rows, using a unified column
set and leaving blanks where a source file lacks a column.
=item *
B<Charts> -- the toolbar offers a line chart (L<HTML::D3>
C<render_zoomable_line_chart_snippet>; brush-to-zoom, reference lines for
min/avg/max), a pie chart (C<render_pie_chart_snippet>; animated,
sorted by value, capped at 12 slices, click-a-slice to filter the table),
and a heatmap (C<render_heatmap_snippet>; two categorical axes, optional
value column or row count, configurable sequential colour scheme).
=item *
B<Drag-and-drop upload> -- any supported data file can be dropped directly
onto the application. The file is opened immediately; when the "Combine
data" panel is open the dropped file populates the right-table path field.
=item *
B<Export> -- the toolbar export panel writes the current logical view
(after joins, filters, and dedup) to a chosen filesystem path as CSV
(C<.csv>), SQLite (C<.sql>), or JSON (C<.json>).
=item *
B<URL import> -- C</import> fetches an HTML table from any public URL and
renders it in the browser without saving to disc.
=item *
B<Redis data source> (optional) -- when the C<Redis> or C<Redis::Fast> module
is installed, any Redis hash store can be opened as a read-only data table by
entering a C<redis:tablename|redis://host:port> spec in the path field or in a
join/combine panel. Redis keys must follow the C<tablename:entry> convention
used by L<Database::Abstraction>. Because C<Redis> is an optional dependency,
the application works fully without it; Redis support activates automatically
when the module is available.
=back
=head2 UTF-8 and Encoding
All file data is returned as Perl character strings. CSV/PSV/TSV files are
read by L<Text::xSV::Slurp> or L<DBD::CSV>, both of which pass bytes
through without re-encoding; the application serves the resulting page as
C<text/html; charset=UTF-8>, so full Unicode is displayed correctly as
long as the source file itself is UTF-8.
Filter values (C<f=col:op:val>) are decoded from the URL by Mojolicious
before reaching the controller and are compared against cell values as
Perl character strings. Unicode characters in filter values are therefore
supported transparently.
URL-imported HTML tables are fetched with L<LWP::UserAgent::Cached>;
L<Database::Abstraction> uses the page's own charset declaration to decode
the body. If the remote page declares an incorrect charset, cell values
may contain mojibake -- this is a limitation of the source data, not the
application.
=head1 SYNOPSIS
B<Start the development server (restarts automatically when you edit a file):>
morbo script/database-bi
B<Start the production server:>
hypnotoad script/database-bi
B<Use a different data directory:>
# In database_bi.conf (create this file in the same folder as script/):
{ data_dir => '/home/user/data' }
B<Change the language used for templates:>
# In database_bi.conf:
{ data_dir => 'data', language => 'fr', platform => 'web' }
# Then create templates/web/fr/ and put your French .html.tt files there.
B<Run the test suite to verify everything is working:>
make test
B<Generate the Makefile for the first time or after editing Makefile.PL:>
perl Makefile.PL
=head1 ROUTES
=over 4
=item C<GET />
Scans C<data_dir>, renders a card grid of available tables.
=item C<GET /view/:table>
Opens the named table from C<data_dir>. Accepts C<?f=col:op:val>
(repeatable) to pre-filter results.
=item C<GET /browse>
Filesystem navigator. Accepts C<?path=> to set the starting directory
(defaults to C<$HOME>).
=item C<GET /open>
Opens any supported file by absolute path (C<?path=>). Accepts C<?f=>
filters. When the file is a multi-table SQLite database, passing
C<?it=E<lt>tableE<gt>> selects a specific internal table instead of the
auto-detected first one.
=item C<GET /join>
Performs one or more left joins and renders the merged table. Parameters:
l=<spec> left table spec (see below)
j=<spec>|<lk>|<rk> join step (repeatable): right-spec, left key, right key
f=<col>:<op>:<val> result filter (repeatable)
Spec formats:
table:name named table from data_dir
path:/abs/path/to/file.csv absolute local path
/../hostname/dir/file.csv remote file via SFTP
redis:tablename|redis://host:port Redis hash store (requires Redis module)
=item C<GET /api/columns>
Returns C<{ "columns": [...] }> for a table (C<?table=name>) or file
(C<?path=/abs/path>). Used by the join UI to populate the right-key
dropdown without a page reload.
=item C<GET /export>
Exports the current logical view (same C<l=>, C<j=>, C<f=> parameters as
C</join>) as a file download. Additional parameter:
format=csv (default) - RFC 4180 CSV; UTF-8; CRLF line endings
format=sqlite - SQLite 3 database with a single table named "data"
The download filename is derived from the left table label with
non-alphanumeric characters replaced by underscores.
=item C<POST /export>
Writes the current logical view to a chosen filesystem path.
Body params: C<l=>, C<j=>, C<f=> (same as GET), plus
C<dir=> (target directory) and C<filename=> (name including extension;
extension determines format: C<.csv> or C<.sql>).
Returns JSON C<{ saved: "/abs/path" }> or C<{ error: "..." }>.
=item C<GET /api/dirs>
Returns a JSON directory listing (subdirectories only) for the export
panel's inline directory browser. Accepts C<?path=> (defaults to
C<$HOME>). Returns C<{ path, parent, dirs: [{name, path}] }>.
=item C<GET /api/stat>
Returns filesystem metadata for a file path (C<?path=>).
Returns C<{ exists, path, mtime, size }>. For SQLite files
(C<.sql>, C<.sqlite>, C<.sqlite3>, C<.db>) an additional C<table_count>
integer is included when the file is a valid SQLite database; the field
is omitted for Berkeley DB or other non-SQLite payloads.
If the file does not exist, C<exists> is C<false> and the remaining
fields are absent (HTTP 200). Returns HTTP 400 when C<path> is missing.
=item C<POST /upload>
Accepts a multipart file upload (field name: C<file>), validates the
extension, saves to a managed C<.uploads/> subdirectory under the app
home, and returns JSON C<{ url, path }>.
=item C<GET /import>
Fetches an HTML table from a public URL (C<?url=>) and renders it as a
data grid. An optional C<?t=> parameter (zero-based integer) selects
which HTML table on the page to display when the page contains multiple
tables.
=item C<GET /tables>
Schema browser for multi-table SQLite databases. Accepts C<?l=E<lt>specE<gt>>
(same C<table:name> or C<path:/abs/path> spec as C</join>). Returns a card
grid listing every internal table with its columns; each card links to
C</open?path=...&it=E<lt>tableE<gt>>. Redirects to 404 when the spec is
absent, invalid, or the resolved file is not a multi-table SQLite database.
=item C<GET /combine>
Stacks rows from two or more tables vertically (UNION ALL) into a unified
view. All columns from all sources appear as headers; cells are blank
where a source file lacks a column. Parameters:
l=<spec> left table: "table:name" or "path:/abs/path"
c=<spec> additional table to stack (repeatable)
f=<col>:<op>:<val> result filter applied after combining (repeatable)
=item C<GET /graph>
Renders a D3.js v7 zoomable line chart of any two columns. Parameters:
l=<spec> left table (required)
x=<col> X-axis column name (required; any type, shown as labels)
y=<col> Y-axis column name (required; must be numeric after stripping
currency symbols and commas; accounting-notation negatives
like (1,234.56) are handled automatically)
back=<url> URL for the second breadcrumb link (optional; default "/")
back2=<url> URL for the outermost breadcrumb link when three levels are
needed (optional; e.g. /tables?l=... for the schema browser)
back2_label=<text> Label for the back2 link (optional; default "Back")
j=, f=, d= pipeline params (same as /join)
=item C<GET /pie>
Renders a D3.js v7 animated pie chart grouped by a category column.
Clicking a slice or legend entry navigates to the table view filtered to
that category. Parameters:
l=<spec> left table (required)
cat=<col> category column to group by (required)
val=<col> numeric column to sum per category (required)
donut=1 show a hole in the centre (optional)
back=<url> URL for the second breadcrumb link (optional; default "/")
back2=<url> URL for the outermost breadcrumb link (optional)
back2_label=<text> Label for the back2 link (optional; default "Back")
f= result filters applied before aggregating (repeatable)
=item C<GET /heatmap>
Renders a D3.js v7 grid heatmap with two categorical axes. Each cell
colour encodes a summed or counted numeric value. Parameters:
l=<spec> left table (required)
x=<col> X-axis column name (required; categorical)
y=<col> Y-axis column name (required; categorical)
val=<col> numeric column to sum per cell (optional; omit to count rows)
scheme=<name> colour scheme: YlOrRd Blues Greens Purples RdPu YlGnBu
(optional; default YlOrRd)
show_val=1 print the value inside each cell (optional)
back=<url> URL for the second breadcrumb link (optional; default "/")
back2=<url> URL for the outermost breadcrumb link (optional)
back2_label=<text> Label for the back2 link (optional; default "Back")
j=, f=, d= pipeline params (same as /join)
=item C<GET /bar>
Renders a D3.js v7 bar chart grouped by a categorical column. Parameters:
l=<spec> left table (required)
cat=<col> category (X-axis) column (required)
val=<col> numeric column to sum per category; omit or use __count__
to count rows instead of summing (optional)
orient=h horizontal bars (default: vertical)
sort=value|label sort bars by descending value or ascending label
max=N collapse bars beyond the top-N into an "Other" bar
back=<url> URL for the second breadcrumb link (optional; default "/")
back2=<url> URL for the outermost breadcrumb link (optional)
back2_label=<text> Label for the back2 link (optional; default "Back")
j=, f=, d= pipeline params (same as /join)
=item C<GET /folder>
Hierarchical folder view. Groups a data table by the letter-prefix of an
order-number column (e.g. A10, CT4, B3) and shows one card per prefix group.
Clicking a card with sub-prefixes drills down; clicking a leaf shows the
matching data rows. Parameters:
l=<spec> left table (required)
col=<col> order-code column (auto-detected when absent)
prefix=<str> current drill-down prefix (top level when absent)
back=<url> URL for the back link (optional)
f= result filters (repeatable)
=item C<POST /uploads/clear>
Deletes every file from the C<.uploads/> staging directory. Returns JSON
C<{ "freed": <bytes>, "count": <n> }>. No request body is needed.
=back
=head1 CONFIGURATION
Place a C<database_bi.conf> file in the application root to override
defaults:
{
data_dir => 'data', # directory scanned for data files on the home page
platform => 'web', # VWF template dimension
language => 'en', # VWF template dimension
}
=head1 COMMON PITFALLS
=over 4
=item B<The configuration file is optional but must be valid Perl if present>
C<database_bi.conf> is loaded by C<Mojolicious::Plugin::Config>, which
evaluates it as a Perl data structure. If the file exists but contains a
syntax error, the application will refuse to start. If the file does not
exist, built-in defaults are used and no error occurs. The file must return
a hashref:
# database_bi.conf -- correct
{ data_dir => 'data', platform => 'web', language => 'en' }
# WRONG -- missing braces
data_dir => 'data'
=item B<data_dir is relative to the application home directory, not the process cwd>
Setting C<data_dir =E<gt> 'data'> looks for a folder called C<data/> in the
same directory as the C<script/database-bi> launcher, regardless of where you
run the server from. An absolute path works on any system:
{ data_dir => '/var/db/mydata' }
=item B<The download_dir default is computed once at startup>
When the application starts, it picks the export directory in this order:
C<~/Downloads> (if it exists), then C<$HOME>, then the system temp directory.
This value is fixed for the life of the process. Renaming or creating
C<~/Downloads> after the server starts has no effect. To force a different
default, set it before starting:
{ data_dir => 'data' } # and create ~/Downloads before starting the server
=item B<Adding a new language requires a template directory, not just a config change>
Setting C<language =E<gt> 'de'> in C<database_bi.conf> tells the controller
to look for templates in C<templates/web/de/>. If that directory does not
exist, the controller automatically falls back to the default language. To
add German support: (1) create C<templates/web/de/>, (2) copy and translate
the C<.html.tt> files from C<templates/web/en/>, then (3) set the config.
=item B<Supported data file extensions are: csv, db, sql, sqlite, sqlite3, xml, psv, tsv, xlsx>
The application recognises C<.csv>, C<.db>, C<.sql>, C<.sqlite>, C<.sqlite3>,
C<.xml>, C<.psv>, C<.tsv>, and C<.xlsx> files. All three SQLite extensions
(C<.sql>, C<.sqlite>, C<.sqlite3>) are treated identically -- C<inventory.sqlite>
and C<inventory.sqlite3> are both opened as SQLite databases without renaming.
Excel C<.xlsx> files are read directly via C<Spreadsheet::ParseXLSX>.
URLs will work.
For example enter
L<https://worldpopulationreview.com/country-rankings/immigration-by-country>
into the C<Import from a web page> field on the dashboard.
=item B<The open_table helper lowercases the table name>
When the router matches C<GET /view/Sales> or C<GET /view/SALES>, the table
name is lowercased to C<sales> before being passed to the helper. The data
file on disc must therefore also be lowercase (C<sales.csv>, not
C<Sales.csv>).
=item B<Redis support requires the Redis or Redis::Fast module>
Redis data source access is an optional feature. If neither C<Redis> nor
C<Redis::Fast> is installed, entering a C<redis:...> spec returns a 404.
To enable Redis support, install either module:
cpanm Redis::Fast # recommended -- C bindings, faster
cpanm Redis # pure-Perl fallback
Redis keys must follow the C<tablename:entry> convention: each key is the
table name, a colon, and an entry identifier; the value is a Redis hash
whose fields become the row columns. For example:
HSET players:row1 name Alice score 10
HSET players:row2 name Bob score 20
Opening C<redis:players|redis://localhost:6379> then displays a two-row table
with columns C<entry>, C<name>, and C<score>.
=back
=head1 LIMITATIONS
=over 4
=item *
Only read operations on data files are supported. Write-back (editing
cell values in the browser and saving them to the data file) is not
implemented.
=item *
Multi-table left joins are performed in memory by L<Database::Join>. All
component tables are fetched into RAM before the merge; this is not
suitable for files that do not fit in the process's available memory.
=item *
Upload staging is managed automatically. On every server startup,
C<Database::BI> evicts upload subdirectories whose modification time is
older than 24 hours. The "Clear upload cache" button (C<POST
/uploads/clear>) triggers an immediate full purge regardless of age.
=item *
C<Sub::Protected>/:Protected enforcement relies on the CHECK compilation
phase. When a module is loaded dynamically at test time (e.g. via
C<Test::Mojo->new(...)>), the CHECK phase has already passed and the
"Too late to run CHECK block" warning is emitted -- the access
restriction is not enforced in that test context. This does not affect
production (morbo/hypnotoad) deployments where modules are compiled on
startup. Unlike the former C<Sub::Private> approach, C<Sub::Protected>
does not delete stash entries, so OO dispatch C<< $self->_method() >>
works correctly in production without any special workarounds.
=back
=head1 ROADMAP
Features planned for future releases (post-0.012.0). Items are ordered by
priority.
=over 4
=item * B<XLSX export> (High) -- C</export> supports CSV, SQLite, and JSON
but not XLSX output. C<Excel::Writer::XLSX> would close the round-trip for
users whose source data is XLSX.
=item * B<Folder view filter/join propagation> (Medium) -- The folder view
panel in the dashboard does not carry forward active C<j=> and C<f=> params
when building the C</folder> URL. Users who have filtered or joined a table
and then click "Folder view" silently lose their filter context.
=item * B<Copy-link button on chart pages> (Medium) -- The dashboard data
view has a copy-link button when filters are active, but C</graph>, C</pie>,
C</heatmap>, and C</bar> do not, even though their URLs are fully
parameterised and users share them.
=item * B<Column statistics panel> (Medium) -- A per-column popover showing
min, max, mean, median, and null-count. C<List::Util> is already in
C<PREREQ_PM>; only C<Statistics::Descriptive> (or manual computation) is
needed.
=item * B<Multi-sheet XLSX> (Medium) -- C<_detect_file_info> reads only the
first worksheet. A C<?sheet=> URL param with a sheet-name picker would
expose the full workbook.
=item * B<C<between> and C<in (a,b,c)> filter operators> (Low) -- Would
reduce multi-filter chains for common range and set queries.
=item * B<SSE streaming for large join results> (Low) -- The join pipeline
blocks the HTTP response until all rows are assembled. Mojolicious supports
server-sent events, which could progressively stream rows to a JS table
renderer, giving visible progress on slow joins.
=back
=cut | |||||
| 550 | ||||||
| 551 | # ============================================================================= | |||||
| 552 | # KNOWN GAPS & ROADMAP | |||||
| 553 | # ============================================================================= | |||||
| 554 | # | |||||
| 555 | # Items marked [NEW 0.010.0] were identified during the 0.010.0 gap analysis. | |||||
| 556 | # Items completed in 0.012.0 (Phase 0): | |||||
| 557 | # A. Pagination -- done: /view, /open, /join, /combine, /export all paginate. | |||||
| 558 | # 1. dbi_source() proxy -- done: DataSource::dbi_source() delegating method added. | |||||
| 559 | # 2. limit/offset plumbing -- done: /join, /combine, /export thread pagination. | |||||
| 560 | # 3. order_by delegation -- done: ?sort=col&dir=asc|desc on /join and /combine. | |||||
| 561 | # 5. Schema warnings UI -- done: join_tables captures and passes schema_warnings. | |||||
| 562 | # 6. updated() in DataSource -- done: DataSource::updated() delegates to inner DA. | |||||
| 563 | # | |||||
| 564 | # ---- POST-RELEASE FEATURE ROADMAP ---- | |||||
| 565 | # | |||||
| 566 | # B. [NEW 0.010.0] XLSX export (High priority) | |||||
| 567 | # /export supports CSV, SQLite, and JSON but not XLSX output. | |||||
| 568 | # Excel::Writer::XLSX would close the round-trip for users whose source | |||||
| 569 | # data is XLSX. The read side already uses Spreadsheet::ParseXLSX. | |||||
| 570 | # | |||||
| 571 | # C. [NEW 0.010.0] Copy-link button on chart pages (Medium priority) | |||||
| 572 | # /graph, /pie, /heatmap, and /bar have fully parameterised URLs but no | |||||
| 573 | # copy-link button. The mechanism already exists in dashboard.html.tt; | |||||
| 574 | # it is a template refactor to share the component. | |||||
| 575 | # | |||||
| 576 | # D. [NEW 0.010.0] Column statistics panel (Medium priority) | |||||
| 577 | # Per-column popover showing min, max, mean, median, and null-count. | |||||
| 578 | # List::Util is already in PREREQ_PM; only Statistics::Descriptive (or | |||||
| 579 | # inline computation) is needed. | |||||
| 580 | # | |||||
| 581 | # E. [NEW 0.010.0] Multi-sheet XLSX (Medium priority) | |||||
| 582 | # _detect_file_info reads only worksheet index 0. A ?sheet= URL param | |||||
| 583 | # with a sheet-name picker would expose the full workbook. | |||||
| 584 | # | |||||
| 585 | # F. [NEW 0.010.0] Folder view: propagate active filters and joins (Medium) | |||||
| 586 | # _run_export_pipeline already handles j= and f= params so /folder works | |||||
| 587 | # on joined/filtered data when the URL is crafted manually. The folder | |||||
| 588 | # panel JS in dashboard.html.tt must carry forward the current j= and f= | |||||
| 589 | # params when building the base_url so users do not lose their filter | |||||
| 590 | # context on click. | |||||
| 591 | # | |||||
| 592 | # G. [NEW 0.010.0] between / in(a,b,c) filter operators (Low priority) | |||||
| 593 | # Would reduce multi-f= chains for common range and set queries. The | |||||
| 594 | # operator allowlist in _apply_filter_spec is the only required change. | |||||
| 595 | # | |||||
| 596 | # H. [NEW 0.010.0] SSE streaming for large join results (Low priority) | |||||
| 597 | # The join pipeline blocks the HTTP response until all rows are assembled. | |||||
| 598 | # Mojolicious write_chunk / finish can progressively stream rows, giving | |||||
| 599 | # visible progress on slow multi-table joins. | |||||
| 600 | # | |||||
| 601 | # ---- OPEN INTEGRATION GAPS ---- | |||||
| 602 | # | |||||
| 603 | # 4. parallel => 1 for multi-step join chains | |||||
| 604 | # -------------------------------------------- | |||||
| 605 | # When the user chains three or more j= join steps, Database::BI creates a | |||||
| 606 | # single Database::Join object with multiple secondary databases. Enabling | |||||
| 607 | # parallel => 1 on that object would issue the secondary DA fetches | |||||
| 608 | # concurrently when the threads module is available. However: DataSource is | |||||
| 609 | # not thread-safe (it holds a CHI cache handle and open file handles). Before | |||||
| 610 | # enabling parallel, DataSource must be audited for thread safety -- at minimum | |||||
| 611 | # the CHI driver must be confirmed re-entrant, or a separate DataSource clone | |||||
| 612 | # created per thread. | |||||
| 613 | # Short-term: do not enable parallel => 1; add a FIXME comment to the join | |||||
| 614 | # route noting why it is disabled. Longer-term: implement DataSource::clone() | |||||
| 615 | # that creates an independent handle per thread. | |||||
| 616 | # | |||||
| 617 | # 5. per-table filters => on the join pipeline | |||||
| 618 | # -------------------------------------------- | |||||
| 619 | # The /join route accepts f=col:op:val filters that are applied as post-join | |||||
| 620 | # in-memory filtering in the template. Database::Join 0.007.0's filters => | |||||
| 621 | # constructor parameter applies per-database criteria at spill time on the | |||||
| 622 | # SQLite backend, which is far cheaper for large tables. When a filter column | |||||
| 623 | # is known to belong to a specific source table (i.e. it appears in | |||||
| 624 | # $join->columns() but also in one component DA's columns()), the join route | |||||
| 625 | # could partition filters by owning database and pass them as filters => to | |||||
| 626 | # the Database::Join constructor. Filters on the merged view (post-join | |||||
| 627 | # computed columns) must remain as template-side filtering. | |||||
| 628 | # | |||||
| 629 | # 6. collision_prefix exposure in the join UI | |||||
| 630 | # -------------------------------------------- | |||||
| 631 | # When two joined tables share a column name, Database::Join's default | |||||
| 632 | # last-table-wins behaviour silently discards one table's value. The join | |||||
| 633 | # UI (the "Merge data" panel) could offer a "rename duplicate columns from | |||||
| 634 | # right table" text field so users can configure collision_prefix without | |||||
| 635 | # writing code. The entered prefix would be passed as | |||||
| 636 | # collision_prefix => { 1 => $prefix } when constructing Database::Join. | |||||
| 637 | # ============================================================================= | |||||
| 638 | ||||||
| 639 | 12 12 12 | 123925 23 8 | sub startup ($self) { | |||
| 640 | 12 | 57 | $self->plugin('Config', { | |||
| 641 | default => { | |||||
| 642 | data_dir => $DEFAULT_DATA_DIR, | |||||
| 643 | platform => $DEFAULT_PLATFORM, | |||||
| 644 | language => $DEFAULT_LANGUAGE, | |||||
| 645 | } | |||||
| 646 | }); | |||||
| 647 | ||||||
| 648 | # Cap inbound request body size. When the limit fires, Mojolicious sets | |||||
| 649 | # req->is_limit_exceeded and still dispatches to the controller (with partial | |||||
| 650 | # content in the upload asset). The controller checks is_limit_exceeded and | |||||
| 651 | # returns 413 before writing to disc. | |||||
| 652 | 12 | 8808 | $self->max_request_size($MAX_REQUEST_SIZE); | |||
| 653 | ||||||
| 654 | # Default export/save directory: ~/Downloads when it exists, else HOME, | |||||
| 655 | # else system tmpdir. Resolved once per process and stored as a stash | |||||
| 656 | # default so every action can read $self->stash('download_dir'). | |||||
| 657 | 12 | 84 | my $home = $ENV{HOME} // ''; | |||
| 658 | 12 | 69 | my $dl_dir = ($home && -d "$home/Downloads") ? "$home/Downloads" | |||
| 659 | : ($home || File::Spec->tmpdir()); | |||||
| 660 | 12 | 38 | $self->defaults(download_dir => $dl_dir); | |||
| 661 | ||||||
| 662 | # Register the TT plugin as 'tt' (not the default 'tt2') so that template | |||||
| 663 | # files keep the .html.tt extension and handler => 'tt' in render() calls | |||||
| 664 | # resolves correctly via Template::Provider::Mojo. TT options must live | |||||
| 665 | # under the 'template' key; top-level keys are plugin options only. | |||||
| 666 | 12 | 125 | $self->plugin('TemplateToolkit', { | |||
| 667 | name => 'tt', | |||||
| 668 | template => { | |||||
| 669 | POST_CHOMP => 1, | |||||
| 670 | TRIM => 1, | |||||
| 671 | }, | |||||
| 672 | }); | |||||
| 673 | ||||||
| 674 | # Factory helper: opens any named table from the configured data directory. | |||||
| 675 | # The directory=> option overrides the default so open_file and upload_file | |||||
| 676 | # can open tables from arbitrary filesystem paths. | |||||
| 677 | # | |||||
| 678 | # Joins are handled by Database::Join in the controller pipeline; | |||||
| 679 | # open_table returns a single DataSource per table. | |||||
| 680 | 12 | 199914 | my $data_dir = $self->home->child($self->config->{data_dir})->to_string; | |||
| 681 | ||||||
| 682 | # In-process CHI cache: shared across all requests in the same worker process. | |||||
| 683 | # The Memory driver needs no external services and works out of the box. | |||||
| 684 | # Set global => 1 so the same namespace is reused across multiple open_table | |||||
| 685 | # calls within one process rather than creating separate isolated caches. | |||||
| 686 | 12 | 465 | my $cache_conf = $self->config->{cache} // {}; | |||
| 687 | 12 | 132 | my $cache_ttl_url = $cache_conf->{ttl_url} // $DEFAULT_CACHE_TTL_URL; | |||
| 688 | my $chi = CHI->new( | |||||
| 689 | 12 | 177 | driver => $cache_conf->{driver} // 'Memory', | |||
| 690 | global => 1, | |||||
| 691 | namespace => 'Database::BI', | |||||
| 692 | ); | |||||
| 693 | ||||||
| 694 | 480 480 480 480 480 | 5539 431 478 704 424 | $self->helper(open_table => sub($c, $table, %opts) { | |||
| 695 | # URL mode: fetch a remote HTML table directly via Database::Abstraction's | |||||
| 696 | # URL backend (LWP::UserAgent + HTML::TableExtract). | |||||
| 697 | 480 | 955 | if (exists $opts{url}) { | |||
| 698 | return Database::BI::Model::DataSource->new( | |||||
| 699 | url => $opts{url}, | |||||
| 700 | cache => $chi, | |||||
| 701 | cache_ttl_url => $cache_ttl_url, | |||||
| 702 | exists $opts{html_table_index} | |||||
| 703 | ? (html_table_index => $opts{html_table_index}) | |||||
| 704 | 9 | 76 | : (), | |||
| 705 | ); | |||||
| 706 | } | |||||
| 707 | # Redis mode: connect to a Redis server via Database::Abstraction's | |||||
| 708 | # Redis backend (Redis::Fast or Redis). | |||||
| 709 | 471 | 851 | if (exists $opts{redis}) { | |||
| 710 | return Database::BI::Model::DataSource->new( | |||||
| 711 | table => $table, | |||||
| 712 | redis => $opts{redis}, | |||||
| 713 | 0 | 0 | cache => $chi, | |||
| 714 | cache_ttl_url => $cache_ttl_url, | |||||
| 715 | ); | |||||
| 716 | } | |||||
| 717 | 471 | 929 | my $dir = exists $opts{directory} ? $opts{directory} : $data_dir; | |||
| 718 | Database::BI::Model::DataSource->new( | |||||
| 719 | directory => $dir, | |||||
| 720 | table => $table, | |||||
| 721 | cache => $chi, | |||||
| 722 | cache_ttl_url => $cache_ttl_url, | |||||
| 723 | exists $opts{host} ? (host => $opts{host}) : (), | |||||
| 724 | exists $opts{file_ext} ? (file_ext => $opts{file_ext}) : (), | |||||
| 725 | 471 | 3733 | exists $opts{internal_table} ? (internal_table => $opts{internal_table}) : (), | |||
| 726 | ); | |||||
| 727 | 12 | 421647 | }); | |||
| 728 | ||||||
| 729 | 12 | 973 | my $r = $self->routes(); | |||
| 730 | 12 | 110 | $r->get('/')->to('Dashboard#index'); | |||
| 731 | 12 | 2417 | $r->get('/view/:table')->to('Dashboard#view'); | |||
| 732 | 12 | 2162 | $r->get('/browse')->to('Dashboard#browse'); | |||
| 733 | 12 | 1319 | $r->get('/open')->to('Dashboard#open_file'); | |||
| 734 | 12 | 1235 | $r->get('/join')->to('Dashboard#join_tables'); | |||
| 735 | 12 | 1178 | $r->get('/api/columns')->to('Dashboard#columns_api'); | |||
| 736 | 12 | 1395 | $r->get('/import')->to('Dashboard#import_url'); | |||
| 737 | 12 | 1237 | $r->get('/combine')->to('Dashboard#combine_tables'); | |||
| 738 | 12 | 1225 | $r->get('/export')->to('Dashboard#export_data'); | |||
| 739 | 12 | 1253 | $r->post('/export')->to('Dashboard#export_write'); | |||
| 740 | 12 | 1200 | $r->get('/api/dirs')->to('Dashboard#dirs_api'); | |||
| 741 | 12 | 1283 | $r->get('/api/stat')->to('Dashboard#stat_api'); | |||
| 742 | 12 | 1248 | $r->post('/upload')->to('Dashboard#upload_file'); | |||
| 743 | 12 | 1212 | $r->post('/uploads/clear')->to('Dashboard#clear_uploads'); | |||
| 744 | 12 | 1379 | $r->get('/graph')->to('Dashboard#graph_view'); | |||
| 745 | 12 | 1149 | $r->get('/pie')->to('Dashboard#pie_view'); | |||
| 746 | 12 | 1157 | $r->get('/heatmap')->to('Dashboard#heatmap_view'); | |||
| 747 | 12 | 1212 | $r->get('/bar')->to('Dashboard#bar_view'); | |||
| 748 | 12 | 1079 | $r->get('/folder')->to('Dashboard#folder_view'); | |||
| 749 | 12 | 1132 | $r->get('/tables')->to('Dashboard#tables_view'); | |||
| 750 | 12 | 1130 | $r->get('/pivot')->to('Dashboard#pivot_view'); | |||
| 751 | 12 | 1122 | $r->get('/stats')->to('Dashboard#stats_view'); | |||
| 752 | 12 | 1120 | $r->post('/calc/save') ->to('Dashboard#calc_save'); | |||
| 753 | 12 | 1216 | $r->post('/calc/delete') ->to('Dashboard#calc_delete'); | |||
| 754 | 12 | 1255 | $r->get('/calc/list') ->to('Dashboard#calc_list'); | |||
| 755 | 12 | 1201 | $r->post('/groups/save') ->to('Dashboard#groups_save'); | |||
| 756 | 12 | 1260 | $r->post('/groups/delete')->to('Dashboard#groups_delete'); | |||
| 757 | 12 | 1306 | $r->get('/groups/list') ->to('Dashboard#groups_list'); | |||
| 758 | 12 | 1243 | $r->post('/dates/save') ->to('Dashboard#dates_save'); | |||
| 759 | 12 | 1204 | $r->post('/dates/delete') ->to('Dashboard#dates_delete'); | |||
| 760 | 12 | 1251 | $r->get('/dates/list') ->to('Dashboard#dates_list'); | |||
| 761 | 12 | 1249 | $r->get('/scatter') ->to('Dashboard#scatter_view'); | |||
| 762 | 12 | 1158 | $r->get('/stacked') ->to('Dashboard#stacked_view'); | |||
| 763 | 12 | 1150 | $r->get('/combo') ->to('Dashboard#combo_view'); | |||
| 764 | 12 | 1133 | $r->get('/treemap') ->to('Dashboard#treemap_view'); | |||
| 765 | 12 | 1132 | $r->get('/histogram') ->to('Dashboard#histogram_view'); | |||
| 766 | 12 | 1186 | $r->get('/line') ->to('Dashboard#graph_view'); # alias for /graph | |||
| 767 | ||||||
| 768 | # Evict stale upload subdirectories on every startup so the cache cannot | |||||
| 769 | # grow unboundedly across server restarts. Only entries whose mtime is | |||||
| 770 | # older than $UPLOAD_MAX_AGE_S seconds are removed; recent uploads that | |||||
| 771 | # were made while the previous server process was running are preserved. | |||||
| 772 | 12 | 1117 | _evict_old_uploads($self->home->child('.uploads'), $UPLOAD_MAX_AGE_S); | |||
| 773 | } | |||||
| 774 | ||||||
| 775 | # _evict_old_uploads( $uploads_dir, $max_age_s ) -> void | |||||
| 776 | # | |||||
| 777 | # Purpose: Remove upload subdirectory trees whose mtime is older than $max_age_s | |||||
| 778 | # seconds. Runs at startup and is also called by clear_uploads (via the | |||||
| 779 | # Dashboard controller) when the user explicitly requests a purge. | |||||
| 780 | # Entry: $uploads_dir is a Mojo::File. $max_age_s is a positive integer. | |||||
| 781 | # Exit: Silent on success. Individual remove_tree failures are non-fatal. | |||||
| 782 | sub _evict_old_uploads { | |||||
| 783 | 16 | 9013 | my ($uploads_dir, $max_age_s) = @_; | |||
| 784 | 16 | 78 | return unless -d $uploads_dir; | |||
| 785 | ||||||
| 786 | 14 | 138 | my $cutoff = time() - $max_age_s; | |||
| 787 | ||||||
| 788 | $uploads_dir->list({ dir => 1 })->each(sub { | |||||
| 789 | 99 | 1484 | my ($entry) = @_; | |||
| 790 | 99 | 99 | my $mtime = (stat $entry)[9] // 0; | |||
| 791 | 99 | 402 | return if $mtime >= $cutoff; # still fresh -- keep it | |||
| 792 | 4 | 3 | if (-d $entry) { | |||
| 793 | 3 | 11 | $entry->remove_tree; | |||
| 794 | } elsif (-f $entry) { | |||||
| 795 | 1 | 7 | unlink $entry->to_string; | |||
| 796 | } | |||||
| 797 | 14 | 116 | }); | |||
| 798 | } | |||||
| 799 | ||||||
| 800 - 828 | =head1 SEE ALSO =over 4 =item * L<Test Dashboard|https://nigelhorne.github.io/Database-BI/coverage/> =back =head1 REPOSITORY L<https://github.com/nigelhorne/Database-BI> =head1 SUPPORT This module is provided as-is without any warranty. =head1 AUTHOR Nigel Horne C<< <njh@nigelhorne.com> >> =head1 LICENCE AND COPYRIGHT Copyright 2026 Nigel Horne. Usage is subject to the GPL2 licence terms. If you use it, please let me know. =cut | |||||
| 829 | ||||||
| 830 | 1; | |||||