File Coverage

File:blib/lib/Database/BI.pm
Coverage:90.7%

linestmtbrancondsubtimecode
1package 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
15our $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.
19Readonly my $DEFAULT_DATA_DIR     => 'data';
20Readonly my $DEFAULT_PLATFORM     => 'web';
21Readonly my $DEFAULT_LANGUAGE     => 'en';
22Readonly 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.
27Readonly my $MAX_REQUEST_SIZE  => 50 * 1_048_576;    # 50 MiB
28Readonly 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.
782sub _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
8301;