File Coverage

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

linestmtbrancondsubtimecode
1package Database::BI;
2
3
12
12
12
3729782
11
46
use Mojo::Base 'Mojolicious', -strict, -signatures;
4
5
12
12
12
363547
15
236
use Carp        qw(croak);
6
12
12
12
20
10
83
use File::Spec  ();
7
12
12
12
761
4817
248
use Readonly;
8
9
12
12
12
2041
23
3583
use Database::BI::Model::DataSource;
10
11our $VERSION = '0.002.0';
12
13# Default config values used by the Config plugin and referenced explicitly
14# in startup() so callers always get a resolved value.
15Readonly my $DEFAULT_DATA_DIR => 'data';
16Readonly my $DEFAULT_PLATFORM => 'web';
17Readonly my $DEFAULT_LANGUAGE => 'en';
18
19# Transport-layer upload size cap (bytes).  Mojolicious enforces this before
20# the request body is read into memory, so an oversized upload never reaches
21# the controller.  Must match $MAX_UPLOAD_BYTES in Dashboard.pm.
22Readonly my $MAX_REQUEST_SIZE => 50 * 1_048_576;     # 50 MiB
23
24 - 318
=head1 NAME

Database::BI - Web-based Business Intelligence viewer for flat data files

=head1 VERSION

0.002.0

=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 DESCRIPTION

C<Database::BI> is a L<Mojolicious> web application that reads arbitrary
flat data files (CSV, PSV, SQLite, XML, etc.) via L<Database::Abstraction>
and presents them as styled, sortable, reorderable HTML tables.

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 / Filter results" panel on any table view
lets the user join one or more additional tables on a shared key.  Every
left row is kept; right-table columns are appended for matching rows.

=item *

B<Result filters> - the same panel lets the user add filter conditions
(column / operator / value) that are applied server-side after all joins.
Operators: C<eq>, C<ne>, C<contains>, C<starts>, C<lt>, C<le>, C<gt>,
C<ge>, C<empty>, C<notempty>.  Active filters are shown as chips in the
toolbar with a one-click "Clear" link.

=item *

B<Drag-and-drop upload> - any supported data file can be dropped directly
onto the application.  On the home page the file is opened immediately;
when the join panel is open the dropped file populates the right-table
path field.

=item *

B<Export> - the toolbar on any view offers an export panel that writes
the current logical view (after joins and filters) to a chosen filesystem
path as CSV (C<.csv>) or SQLite (C<.sql>).

=back

=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.

=item C<GET /join>

Performs one or more left joins and renders the merged table.  Parameters:

  l=<spec>               left table: "table:name" or "path:/abs/path"
  j=<spec>|<lk>|<rk>    join step (repeatable): right-spec, left key, right key
  f=<col>:<op>:<val>     result filter (repeatable)

=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 }>.  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 /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)

=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, xml, psv>

The application calls C<Database::Abstraction> which recognises exactly these
five extensions.  A file called C<inventory.sqlite> is B<not> recognised -- it
must be renamed to C<inventory.sql>.  A file called C<data.xlsx> (Excel) is
also not supported; export it as CSV first.

=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 disk must therefore also be lowercase (C<sales.csv>, not
C<Sales.csv>).

=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 *

The left-join engine (C<Dashboard::_left_join>) is an in-memory O(n*m)
hash join.  It is suitable for BI files that fit comfortably in RAM.
For very large files, replace the C<open_table> helper body with a
C<Database::Join> instance (Phase 2) without changing the controller.

=item *

The C<.uploads/> directory grows indefinitely; no automatic eviction is
performed.  Users may delete C<.uploads/> at any time to reclaim space.

=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

=cut
319
320
12
12
12
133271
14
11
sub startup ($self) {
321
12
54
        $self->plugin('Config', {
322                default => {
323                        data_dir => $DEFAULT_DATA_DIR,
324                        platform => $DEFAULT_PLATFORM,
325                        language => $DEFAULT_LANGUAGE,
326                }
327        });
328
329        # Cap inbound request body size.  When the limit fires, Mojolicious sets
330        # req->is_limit_exceeded and still dispatches to the controller (with partial
331        # content in the upload asset).  The controller checks is_limit_exceeded and
332        # returns 413 before writing to disk.
333
12
10464
        $self->max_request_size($MAX_REQUEST_SIZE);
334
335        # Default export/save directory: ~/Downloads when it exists, else HOME,
336        # else system tmpdir.  Resolved once per process and stored as a stash
337        # default so every action can read $self->stash('download_dir').
338
12
92
        my $home   = $ENV{HOME} // '';
339
12
91
        my $dl_dir = ($home && -d "$home/Downloads") ? "$home/Downloads"
340                   : ($home || File::Spec->tmpdir());
341
12
36
        $self->defaults(download_dir => $dl_dir);
342
343        # Register the TT plugin as 'tt' (not the default 'tt2') so that template
344        # files keep the .html.tt extension and handler => 'tt' in render() calls
345        # resolves correctly via Template::Provider::Mojo.  TT options must live
346        # under the 'template' key; top-level keys are plugin options only.
347
12
132
        $self->plugin('TemplateToolkit', {
348                name     => 'tt',
349                template => {
350                        POST_CHOMP => 1,
351                        TRIM       => 1,
352                },
353        });
354
355        # Factory helper: opens any named table from the configured data directory.
356        # The directory=> option overrides the default so open_file and upload_file
357        # can open tables from arbitrary filesystem paths.
358        #
359        # Phase 2: swap Database::BI::Model::DataSource for Database::Join here;
360        # the controller and all templates are untouched.
361
12
239779
        my $data_dir = $self->home->child($self->config->{data_dir})->to_string;
362
363
194
194
194
194
194
1668
163
152
198
142
        $self->helper(open_table => sub($c, $table, %opts) {
364                # URL mode: fetch a remote HTML table directly via Database::Abstraction's
365                # URL backend (LWP::UserAgent + HTML::TableExtract).
366
194
295
                if (exists $opts{url}) {
367                        return Database::BI::Model::DataSource->new(
368                                url => $opts{url},
369                                exists $opts{html_table_index}
370                                        ? (html_table_index => $opts{html_table_index})
371
7
40
                                        : (),
372                        );
373                }
374
187
324
                my $dir = exists $opts{directory} ? $opts{directory} : $data_dir;
375
187
700
                Database::BI::Model::DataSource->new(
376                        directory => $dir,
377                        table     => $table,
378                );
379
12
383
        });
380
381
12
732
        my $r = $self->routes();
382
12
107
        $r->get('/')->to('Dashboard#index');
383
12
2472
        $r->get('/view/:table')->to('Dashboard#view');
384
12
2285
        $r->get('/browse')->to('Dashboard#browse');
385
12
1457
        $r->get('/open')->to('Dashboard#open_file');
386
12
1311
        $r->get('/join')->to('Dashboard#join_tables');
387
12
1268
        $r->get('/api/columns')->to('Dashboard#columns_api');
388
12
1453
        $r->get('/import')->to('Dashboard#import_url');
389
12
1291
        $r->get('/combine')->to('Dashboard#combine_tables');
390
12
1296
        $r->get('/export')->to('Dashboard#export_data');
391
12
1347
        $r->post('/export')->to('Dashboard#export_write');
392
12
1317
        $r->get('/api/dirs')->to('Dashboard#dirs_api');
393
12
1361
        $r->get('/api/stat')->to('Dashboard#stat_api');
394
12
1401
        $r->post('/upload')->to('Dashboard#upload_file');
395}
396
397 - 425
=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
426
4271;