File Coverage

File:blib/lib/Database/BI/Controller/Dashboard.pm
Coverage:77.7%

linestmtbrancondsubtimecode
1package Database::BI::Controller::Dashboard;
2
3
12
12
12
10866
20
137
use strict;
4
12
12
12
21
11
459
use warnings;
5
6our $VERSION = '0.012.0';
7
8
12
12
12
26
12
52
use Mojo::Base 'Mojolicious::Controller', -strict, -signatures;
9
10
12
12
12
6725
15
298
use Carp                qw(croak carp);
11
12
12
12
19
13
333
use List::Util          qw(min max sum);
12
12
12
12
3025
880097
219
use CGI::Info;
13
12
12
12
4732
147575
258
use CGI::Lingua;
14
12
12
12
44
13
309
use Mojo::File;
15
12
12
12
26
12
296
use Mojo::JSON          qw(encode_json decode_json);
16
12
12
12
21
13
218
use Mojo::Util          qw(url_escape encode);
17
12
12
12
29
12
208
use File::Path          qw(remove_tree);
18
12
12
12
22
11
196
use File::Temp          qw(tempfile tempdir);
19
12
12
12
17
12
165
use Readonly;
20
12
12
12
21
11
196
use Socket              qw(inet_aton);
21
12
12
12
19
15
59
use Sub::Protected;
22
23# ---------------------------------------------------------------------------
24# Constants
25# ---------------------------------------------------------------------------
26
27# File extensions that Database::Abstraction can probe, in probe order.
28# .sql, .sqlite, and .sqlite3 are all SQLite databases; .db is Berkeley DB.
29Readonly my @SUPPORTED_EXT => qw( csv db sql sqlite sqlite3 xml tsv psv xlsx );
30# Use \z (absolute end-of-string) not $ (which permits a trailing \n before \z).
31# A query param decoded from "file.csv%0A" has basename "sales.csv\n"; without \z
32# that passes the extension guard and reaches realpath with an embedded newline.
33Readonly my $EXT_RE        => do { my $p = join '|', @SUPPORTED_EXT; qr/\.(?:$p)\z/i };
34
35# Table name safe-identifier pattern -- must match DataSource's TABLE_NAME_RE
36# exactly: first character must be a letter or underscore (not a digit), so
37# that every table name the controller accepts can also be opened by DataSource
38# without an unhandled croak.
39Readonly my $TABLE_NAME_RE => qr/\A[A-Za-z_][A-Za-z0-9_]*\z/;
40
41# URL spec pattern shared by _open_spec and _spec_to_url.
42# qr{} delimiter avoids the \/ escaping noise required by the // form.
43# /s: makes . match \n so a percent-decoded newline inside a URL does not
44# silently truncate the captured URL before the \z end-of-string anchor.
45Readonly my $URL_SPEC_RE   => qr{\Aurl:(https?://.+)\z}si;
46
47# Redis spec pattern: redis:tablename|redis://[password@]host[:port][/db_index]
48# The pipe separates the table name from the connection URL so the URL's own
49# colons and slashes do not require extra quoting.
50Readonly my $REDIS_SPEC_RE => qr{\Aredis:([^|]+)\|(redis://.+)\z}si;
51
52# Bare Redis URL -- no table name -- used to list available keys.
53# Excludes '|' so it cannot match the redis:table|redis://... full spec.
54Readonly my $REDIS_URL_RE  => qr{\Aredis://[^\s|]+\z}si;
55
56# Hostname pattern accepted in /../hostname/... remote paths.
57# Mirrors Database::Abstraction's own host validation regex.
58# Accepts 'hostname', 'user@hostname', and IPv4 addresses (IPv6 is excluded
59# because the [...] notation would be ambiguous in the path syntax).
60Readonly my $REMOTE_HOST_RE => qr/\A(?:[a-zA-Z0-9][a-zA-Z0-9._-]*\@)?[a-zA-Z0-9][a-zA-Z0-9._-]*\z/;
61
62# ---------------------------------------------------------------------------
63# I18N message dictionary for all user-visible strings in this controller.
64# To plug in a Locale::Maketext backend, extend _i18n() below.
65# ---------------------------------------------------------------------------
66
67Readonly my %MESSAGES => (
68        error_table_invalid    => 'Invalid table name "%s"',
69        error_table_open       => 'Could not open table "%s": %s',
70        error_file_open        => 'Could not open "%s": %s',
71        error_no_path          => 'No path specified',
72        error_not_found        => 'File or directory not found: %s',
73        error_dir_not_found    => 'Directory not found: %s',
74        error_write_failed     => 'Write failed: %s',
75        error_ext_required     => 'Use a .csv, .sql, or .json filename extension',
76        error_upload_none      => 'No file received',
77        error_upload_ext       => 'Unsupported file type. Accepted: CSV, TSV, PSV, XML, SQLite (.sql, .sqlite, .sqlite3), Berkeley DB (.db), XLSX',
78        error_upload_too_large => 'File too large (maximum %s MiB)',
79        error_upload_save      => 'Could not save uploaded file',
80        error_path_required    => '"path" parameter is required',
81        error_url_required     => 'Please enter a URL',
82        error_url_invalid      => '"%s" is not a valid http:// or https:// URL',
83        error_url_fetch        => 'Could not load HTML table from "%s": %s',
84        error_url_ssrf         => '"%s" resolves to a private or reserved address and cannot be fetched',
85        warn_combine_sort      => 'Sorting is applied in memory for /combine; result order may differ from a backend sort',
86        error_sort_column      => 'Unknown sort column "%s"',
87        warn_measure_fallback       => 'Measure "%s" on column "%s" is not uniformly numeric; totalled in Perl',
88        error_measure_fn            => 'Unknown measure function "%s"',
89        error_measure_column        => 'Measure column "%s" is not in the view',
90        error_pivot_rows_required   => 'At least one rows= dimension is required',
91        error_pivot_measure_req     => 'At least one m= measure (col:fn:as) is required',
92        error_pivot_measure_invalid => 'Invalid measure spec "%s"; expected col:fn:as where fn is one of: sum avg min max count count_distinct',
93        error_pivot_dim_not_found   => 'Row/column dimension "%s" is not in the view',
94        error_pivot_dim_comma       => 'Dimension name "%s" must not contain a comma',
95        error_calc_compile          => 'Calculated field "%s": %s',
96        error_calc_name             => 'Calculated field name "%s" is not a valid identifier',
97        error_topn_col              => 'Top-N column "%s" is not a measure alias in this view',
98        warn_calc_undef             => 'Calculated field "%s" returned undef for %d row(s)',
99        error_date_pattern          => 'Unsupported date format pattern "%s"; allowed: %%m/%%d/%%Y, %%d/%%m/%%Y, %%d-%%b-%%Y',
100);
101
102# Maximum accepted upload body size.  Enforced both here (application layer)
103# and via Mojolicious max_request_size (transport layer) set in startup().
104# Must match $MAX_REQUEST_SIZE in BI.pm.
105Readonly my $MAX_UPLOAD_MIB      => 50;
106Readonly my $MAX_UPLOAD_BYTES    => $MAX_UPLOAD_MIB * 1_048_576;
107Readonly my $DEFAULT_JOIN_MAX_ROWS => 10_000;
108Readonly my $MAX_PREFIX_LEN        => 20;
109Readonly my $DEFAULT_PER_PAGE      => 200;
110Readonly my $MAX_PER_PAGE          => 2_000;
111
112# Valid aggregate function vocabulary shared by _measure, _grouped_totals,
113# and the /pivot measure-spec parser (Phase 2).  Do not add functions here
114# without a corresponding implementation in _grouped_totals's Perl fold AND
115# a corresponding entry in the lower-layer aggregate() implementations.
116Readonly my @MEASURE_FNS  => qw(sum avg min max count count_distinct);
117Readonly my %MEASURE_FN_OK => map { $_ => 1 } @MEASURE_FNS;
118
119# Allowed strptime-style date format patterns for the /dates/save endpoint.
120Readonly my %ALLOWED_DATE_PATTERNS => map { $_ => 1 }
121        qw(%m/%d/%Y %d/%m/%Y %d-%b-%Y);
122
123# ---------------------------------------------------------------------------
124# Protected helpers
125# ---------------------------------------------------------------------------
126
127# _pagination($self) -> ($page, $per_page, $offset)
128#
129# Reads ?page= and ?per_page= from the current request, clamps them to sane
130# ranges, and returns the 1-based page number, the page size, and the
131# 0-based row offset.  Pagination is optional: when neither param is present
132# per_page defaults to DEFAULT_PER_PAGE.
133sub _pagination {
134
254
326
        my $self = $_[0];
135
136
254
527
        my $per_page = int($self->param('per_page') // $DEFAULT_PER_PAGE);
137
254
20175
        $per_page = 1         if $per_page < 1;
138
254
477
        $per_page = $MAX_PER_PAGE if $per_page > $MAX_PER_PAGE;
139
254
855
        my $page = int($self->param('page') // 1);
140
254
5806
        $page = 1 if $page < 1;
141
254
362
        my $offset = ($page - 1) * $per_page;
142
254
423
        return ($page, $per_page, $offset);
143}
144
145# _i18n($self, $key, @sprintf_args) -> $string
146#
147# Purpose: Look up a user-visible string from %MESSAGES and apply sprintf
148#          for positional arguments.  The entry point for future i18n
149#          backend integration (e.g. Locale::Maketext).
150# Entry:   $key must be a key in %MESSAGES; @args are sprintf positionals.
151# Exit:    Returns the formatted string, or a fallback containing $key.
152sub _i18n :Protected ($self, $key, @args) {
153        my $tmpl = $MESSAGES{$key} // return "Internal error: unknown message key '$key'";
154        return @args ? sprintf($tmpl, @args) : $tmpl;
155
12
12
12
5044
10
98
}
156
157# _is_safe_url($url) -> bool
158#
159# Purpose: SSRF guard.  Blocks the most dangerous classes of Server-Side Request
160#          Forgery targets: loopback aliases (localhost, 127/8) and literal
161#          private/link-local/CGNAT IPv4 addresses in the URL host component.
162#
163# Design rationale for scope:
164#   Resolving hostnames via DNS and checking the result is ineffective: a
165#   separate DNS lookup happens at LWP connect time (TOCTOU / DNS rebinding
166#   window).  The authoritative defence against hostname-based SSRF is a
167#   network-layer egress firewall.  This function handles the Perl-layer
168#   interception for the most common patterns that operators cannot easily
169#   filter at the network level: bare loopback aliases and literal private IPs
170#   hard-coded by an attacker.
171#
172# Blocked targets:
173#   localhost / 127.0.0.0/8 / 0.0.0.0 / ::1  -- loopback aliases
174#   10.0.0.0/8    -- RFC 1918 private (literal IP)
175#   172.16.0.0/12 -- RFC 1918 private (literal IP)
176#   192.168.0.0/16-- RFC 1918 private (literal IP)
177#   169.254.0.0/16-- link-local; AWS/GCP/Azure metadata endpoint (literal IP)
178#   100.64.0.0/10 -- CGNAT / Tailscale shared space (literal IP)
179#
180# Hostname-based targets (e.g. http://internal.corp.example.com/) are allowed
181# at this layer; block them with egress firewall rules instead.
182sub _is_safe_url {
183
121
34294
        my $url = $_[0];
184
185
121
358
        return 0 unless $url =~ m{\Ahttps?://([^/:?\[\]#]+)}i;
186
106
141
        my $host = lc $1;
187
188        # Block well-known loopback aliases.
189
106
413
        return 0 if $host eq 'localhost'
190                  || $host =~ /\A127\./
191                  || $host eq '0.0.0.0'
192                  || $host eq '::1';
193
194        # For literal IPv4 addresses only: check private/link-local/CGNAT ranges.
195        # Skipping DNS for hostname targets avoids a live network call in tests and
196        # removes the TOCTOU window that makes DNS-resolved checks illusory anyway.
197
79
194
        return 1 unless $host =~ /\A\d{1,3}(?:\.\d{1,3}){3}\z/;
198
199
59
239
        my $packed = inet_aton($host) or return 1;
200
58
85
        my $n = unpack 'N', $packed;
201
202
58
98
        return 0 if ($n & 0xFF000000) == 0x0A000000;        # 10/8
203
48
61
        return 0 if ($n & 0xFFF00000) == 0xAC100000;        # 172.16/12
204
39
49
        return 0 if ($n & 0xFFFF0000) == 0xC0A80000;        # 192.168/16
205
31
46
        return 0 if ($n & 0xFFFF0000) == 0xA9FE0000;        # 169.254/16 (link-local / metadata)
206
24
47
        return 0 if ($n & 0xFFC00000) == 0x64400000;        # 100.64/10 (CGNAT)
207
16
27
        return 1;
208}
209
210# _safe_back_url($url) -> $url | undef
211#
212# Purpose: Sanitise a user-supplied back-link URL before it is passed to the
213#          template and rendered inside an href="" attribute.  Template Toolkit's
214#          | html filter encodes <>&" but does NOT block the javascript: or
215#          data: URI schemes, so a raw param('back') = "javascript:alert(1)"
216#          would be rendered verbatim inside the href and execute on click.
217#
218# Rule:    Only absolute http(s) URLs and root-relative paths (starting with /)
219#          are permitted.  Anything else (javascript:, data:, vbscript:, empty
220#          strings, undef) returns undef so the template renders no href.
221#
222sub _safe_back_url {
223
649
51057
        my ($url) = @_;
224
649
1982
        return undef unless defined $url && length($url);
225
61
175
        return $url if $url =~ m{\A(?:/(?!/)|https?://)}i;
226
28
55
        return undef;
227}
228
229# _resolve_template($self) -> ($platform, $language)
230#
231# Purpose: Read platform and language from config, then resolve the Accept-Language
232#          header to a language code -- falling back to the config default when
233#          no template directory exists for the resolved language.
234# Exit:    Returns ($platform, $language) -- both guaranteed non-empty strings.
235sub _resolve_template :Protected ($self) {
236        my $conf         = $self->app->config();
237        my $cfg_platform = $conf->{platform} // 'web';
238        my $cfg_language = $conf->{language} // 'en';
239
240        # Detect platform from the request User-Agent via CGI::Info.
241        # Falls back to the configured platform when:
242        #   (a) CGI::Info is unavailable or throws, or
243        #   (b) no templates/<detected>/ directory exists.
244        my $platform = _detect_platform(
245                $self->req->headers->user_agent // '',
246                $cfg_platform,
247        );
248        unless (-d $self->app->home->child("templates/$platform")) {
249                $platform = $cfg_platform;
250        }
251
252        my $language = $self->_resolve_language($platform, $cfg_language);
253        return ($platform, $language);
254
12
12
12
8694
13
54
}
255
256# _detect_platform($user_agent, $fallback) -> $platform_string
257#
258# Purpose: Map a raw User-Agent string to a VWF platform dimension
259#          ('mobile', 'tablet', or 'web') via CGI::Info.
260# Entry:   $user_agent -- the raw HTTP User-Agent header value (may be empty).
261#          $fallback   -- value to return if detection fails.
262# Exit:    Returns 'mobile', 'tablet', 'web', or $fallback.
263# Side Effects: temporarily sets $ENV{HTTP_USER_AGENT} with local().
264
472
472
472
472
12175
461
491
371
sub _detect_platform ($user_agent, $fallback) {
265
472
411
        my $platform = eval {
266
472
3241
                local $ENV{HTTP_USER_AGENT} = $user_agent;
267
472
2300
                my $info = CGI::Info->new();
268
472
1016196
                $info->is_mobile() ? 'mobile'
269                : $info->is_tablet() ? 'tablet'
270                :                      'web';
271        };
272
472
184899
        return $platform // $fallback;
273}
274
275# _resolve_language($self, $platform, $default) -> $language_code
276#
277# Purpose: Determine the best ISO 639-1 language code for this request.
278#          When Accept-Language is present, CGI::Lingua selects the best
279#          match from the languages that have a template directory under
280#          templates/<platform>/.  Falls back to $default when the header
281#          is absent, no templates exist for the matched language, or
282#          CGI::Lingua is unavailable.
283# Entry:   $platform is the resolved VWF platform string.
284#          $default  is a non-empty fallback language code (e.g. 'en').
285# Exit:    Returns a two-letter ISO 639-1 language code string.
286# Side Effects: filesystem stats for template directories;
287#               temporarily sets $ENV{HTTP_ACCEPT_LANGUAGE} with local().
288sub _resolve_language :Protected ($self, $platform, $default) {
289        my $accept = $self->req->headers->accept_language() // '';
290        return $default unless $accept;
291
292        # Discover supported languages from template directories so CGI::Lingua
293        # only returns a code we can actually serve.
294        my $tmpl_base = $self->app->home->child("templates/$platform");
295        my @supported;
296        if (-d $tmpl_base) {
297                @supported = map  { Mojo::File->new($_)->basename }
298                             grep { -d $_ }
299                             @{ $tmpl_base->list({ dir => 1 }) };
300        }
301        push @supported, $default unless grep { $_ eq $default } @supported;
302
303        my $lang = eval {
304                local $ENV{HTTP_ACCEPT_LANGUAGE} = $accept;
305                my $lingua = CGI::Lingua->new(supported => \@supported);
306                $lingua->language_code_alpha2();
307        };
308        $lang //= $default;
309
310        # Confirm a template directory exists for the chosen language.
311        if ($lang ne $default) {
312                my $dir = $self->app->home->child("templates/$platform/$lang");
313                $lang   = $default unless -d $dir;
314        }
315        return $lang;
316
12
12
12
4513
13
44
}
317
318# _scan_data_dir($self) -> \@table_list
319#
320# Purpose: Return an arrayref of { name, file } hashrefs for every supported
321#          data file in the configured data_dir.  Used by the index action and
322#          by the join panel "available tables" dropdown.
323# Entry:   None.
324# Exit:    Returns [] when data_dir does not exist or contains no supported files.
325# Side Effects: Filesystem directory read.
326#
327# Optimisation: single-pass map replaces a two-pass grep+map chain, and calls
328# basename() only once per entry (the original chain called it twice: once in
329# grep to check the extension and again in map to extract the stem).
330# ->to_array avoids the extra flat-list expansion that ->each produced.
331sub _scan_data_dir :Protected ($self) {
332        my $dir = $self->app->home->child($self->app->config->{data_dir} // 'data');
333        return [] unless -d $dir;
334        return $dir->list->map(sub {
335                my $base = $_->basename;
336                return unless $base =~ $EXT_RE;
337                (my $name = $base) =~ s/\.[^.]+\z//;
338                { name => $name, file => $base, path => $_->to_string }
339        })->to_array;
340
12
12
12
2706
12
41
}
341
342# _open_spec($self, $spec) -> ($DataSource, $label) or ()
343#
344# Purpose: Parse a "table:name" or "path:/abs/path" spec, verify the underlying
345#          file exists (Database::Abstraction creates empty objects silently for
346#          missing files, so we must check ourselves), and return a live
347#          DataSource object and a display label.
348# Entry:   $spec is a non-empty string.
349# Exit:    Returns ($datasource, $label) on success; an empty list on any failure
350#          (invalid spec format, file missing, DataSource init error).
351# Side Effects: Filesystem stat; DataSource construction (reads file header).
352sub _open_spec :Protected ($self, $spec) {
353        if ($spec =~ /\Atable:([A-Za-z0-9_]+)\z/) {
354                my $table    = lc $1;
355                my $data_dir = $self->app->home->child($self->app->config->{data_dir} // 'data');
356                return () unless grep { -f $data_dir->child("$table.$_") } @SUPPORTED_EXT;
357                my $src = eval { $self->open_table($table, directory => $data_dir->to_string) };
358                return ($src, $table) if $src && !$@;
359        } elsif ($spec =~ /\Apath:(.+)\z/) {
360                my $path_arg = $1;
361                # Remote path: /../hostname/dir/file -- delegate to DataSource with host param.
362                # No EXT_RE check: security comes from REMOTE_HOST_RE.  The original
363                # extension (if any) is passed as file_ext so _init_backend tries it first.
364                if ($path_arg =~ m{\A/\.\./([^/]+)((?:/.+)?)/([^/]+)\z}) {
365                        my ($host, $dir_part, $file) = ($1, $2 // '', $3);
366                        $dir_part = '/' unless length $dir_part;
367                        return () unless $host =~ $REMOTE_HOST_RE;
368                        my ($table, $ext) = $file =~ /\A(.+)\.([^.]+)\z/ ? ($1, $2) : ($file, undef);
369                        my $src = eval { $self->open_table($table, directory => $dir_part, host => $host,
370                                defined $ext ? (file_ext => $ext) : ()) };
371                        return ($src, $file) if $src && !$@;
372                        return ();
373                }
374                my $file = eval { Mojo::File->new($path_arg)->realpath };
375                if (defined $file && -f $file && $file->basename =~ $EXT_RE) {
376                        my $dir = $file->dirname->to_string;
377                        (my $table = $file->basename) =~ s/\.[^.]+\z//;
378                        my $src = eval { $self->open_table($table, directory => $dir) };
379                        return ($src, $file->basename) if $src && !$@;
380                }
381        } elsif($spec =~ $URL_SPEC_RE) {
382                my $url = $1;
383                return () unless _is_safe_url($url);
384                my $src = eval { $self->open_table('', url => $url) };
385                return ($src, $src->table_name) if $src && !$@;
386        } elsif($spec =~ $REDIS_SPEC_RE) {
387                my ($table, $redis_url) = ($1, $2);
388                my $src = eval { $self->open_table($table, redis => $redis_url) };
389                return ($src, $table) if $src && !$@;
390        }
391        return ();
392
12
12
12
5624
13
43
}
393
394# _spec_to_url($self, $spec) -> $url_string
395#
396# Purpose: Convert a table/path spec back to a browseable URL (used for the
397#          back-link on join and export views).
398# Entry:   $spec is a "table:name" or "path:/abs" string.
399# Exit:    Returns a URL string beginning with '/'.
400sub _spec_to_url :Protected ($self, $spec) {
401        return "/view/$1" if $spec =~ /\Atable:([A-Za-z0-9_]+)\z/;
402        return '/open?path=' . url_escape($1) if $spec =~ /\Apath:(.+)\z/;
403        return '/import?url=' . url_escape($1) if $spec =~ $URL_SPEC_RE;
404        return '/' if $spec =~ $REDIS_SPEC_RE;
405        return '/';
406
12
12
12
2910
12
41
}
407
408# _url_attribution($self) -> ($url_string, $date_string) or (undef, undef)
409#
410# Purpose: Return source URL and access date for chart attribution when the
411#          left table was imported from a web URL.  The date is the time of
412#          the current request (which is when the data is being accessed),
413#          formatted as "D Month YYYY".
414# Entry:   Reads the 'l' query param.
415# Exit:    ($url, $date) when the spec matches $URL_SPEC_RE; (undef, undef)
416#          for table: and path: specs.
417sub _url_attribution :Protected ($self) {
418        my $spec = $self->param('l') // '';
419        return (undef, undef) unless $spec =~ $URL_SPEC_RE;
420        my $url  = $1;
421        my @MON  = qw(January February March April May June July August
422                      September October November December);
423        my (undef, undef, undef, $mday, $mon, $year) = localtime;
424        return ($url, sprintf('%d %s %d', $mday, $MON[$mon], $year + 1900));
425
12
12
12
2630
9
37
}
426
427# _get_columns($source, $records) -> @column_names
428#
429# Purpose: Return an ordered column list from a DataSource object.
430#          For CSV/TSV/PSV the DataSource stores the original file-header order.
431#          For SQLite/XML where no file-header order is available, the fallback
432#          is: id_column first (only if it actually appears in the data), then
433#          the remaining columns sorted alphabetically.
434# Entry:   $source is a DataSource; $records is an arrayref of hashrefs (may be empty).
435# Exit:    Returns a flat list of column-name strings (may be empty).
436#
437# Reduction: columns() is a pure accessor -- cache once to avoid a redundant
438# method dispatch on the second reference.
439sub _get_columns {
440
457
14940
        my ($source, $records) = @_;
441
457
1080
        my $cols = $source->columns;
442
457
10912
        return @$cols if $cols;
443
15
27
        return () unless $records->[0];
444
12
27
12
12
38
24
        my %all = map { $_ => 1 } keys %{ $records->[0] };
445
12
31
        my $c   = $source->id_column;
446
12
51
        my $id  = ($c && $all{$c}) ? $c : (sort keys %all)[0];
447
12
15
        delete $all{$id};
448
12
31
        return ($id, sort keys %all);
449}
450
451# _apply_filter_spec($records, $spec) -> \@filtered_records
452#
453# Purpose: Apply one "col:op:val" filter to an arrayref of record hashrefs and
454#          return a new (possibly shorter) arrayref.  The original is not mutated.
455#          Value may contain colons: "col:op:val:with:colons" -- split limit 3
456#          puts everything after the second colon into $val.
457#          Unknown operators fall through to 1 (all rows pass) so new operators
458#          added in future do not break existing callers.
459# Entry:   $records is an arrayref of hashrefs; $spec is a colon-delimited string.
460# Exit:    Returns filtered arrayref (same reference if spec is invalid/unparseable).
461#
462# Domain Constraints (operator names):
463#   Operator names are CASE-SENSITIVE.  Only the exact lowercase forms below are
464#   recognised; an uppercase or mixed-case name (e.g. "EQ", "Lt") falls through
465#   to the default `1` branch and all rows pass unfiltered.
466#   Valid operators:
467#     String (value comparison is case-insensitive via lc()):
468#       eq, ne, contains, starts
469#     Numeric (Perl <, <=, >, >= coercion):
470#       lt (strict), le (inclusive), gt (strict), ge (inclusive)
471#     Unary (val is ignored):
472#       empty (cell eq ''), notempty (cell ne '')
473#   Boundary rules for numeric operators:
474#     le/ge include the boundary value; lt/gt exclude it.
475#   contains/starts with empty val ('') match every record (index always 0).
476#
477# Reduction: split(/:/, $spec, 3) always produces at least one defined element,
478# so "defined $col" is structurally guaranteed true and is removed.
479# "$_->{$col} // ''" replaces the equivalent ternary form.
480#
481# Optimisation: $lval = lc($val) is computed once before the grep.  Without this
482# precompute, every case-insensitive operator (eq, ne, contains, starts) would
483# call lc($val) O(N) times for N records -- a pure O(N) waste since $val is
484# constant within a single filter application.
485sub _apply_filter_spec {
486
419
85633
        my ($records, $spec) = @_;
487
419
564
        my ($col, $op, $val) = split /:/, $spec, 3;
488
419
957
        return $records unless length($col // '') && defined $op && length $op;
489
410
350
        $val //= '';
490
410
300
        my $lval = lc $val;     # precompute once; avoids O(N) redundant lc() inside grep
491        return [grep {
492
410
978
396
1007
                my $cell = $_->{$col} // '';
493
978
2664
                $op eq 'eq'       ? lc($cell) eq $lval                :
494                $op eq 'ne'       ? lc($cell) ne $lval                :
495                $op eq 'contains' ? index(lc($cell), $lval) != -1     :
496                $op eq 'starts'   ? index(lc($cell), $lval) == 0      :
497                $op eq 'lt'       ? $cell <  $val                     :
498                $op eq 'le'       ? $cell <= $val                     :
499                $op eq 'gt'       ? $cell >  $val                     :
500                $op eq 'ge'       ? $cell >= $val                     :
501                $op eq 'empty'    ? $cell eq ''                        :
502                $op eq 'notempty' ? $cell ne ''                        :
503                1
504        } @$records];
505}
506
507# _apply_filters($self, $records) -> ($filtered_records, \@raw_specs, $json_string)
508#
509# Purpose: Read all "f=" query params, apply them in order via _apply_filter_spec,
510#          and return: the filtered arrayref, the raw spec strings (for building
511#          export URLs), and a script-safe JSON string for pre-populating the UI.
512# Entry:   $records is an arrayref of hashrefs.
513# Exit:    Three-element list; never croaks.  $json_string has '</' escaped to
514#          '<\/' so it is safe to embed directly in a <script> block.
515#
516# Reduction: the original code had two sequential loops over @specs -- one to
517# apply filters, one to parse them for JSON.  By Modus Ponens, _apply_filter_spec
518# returns $records unchanged for a malformed spec (same guard condition), so
519# skipping the call for malformed specs is equivalent.  Merging into one loop
520# eliminates a full O(n) pass and the redundant split() on every spec.
521sub _apply_filters :Protected ($self, $records) {
522        my @specs  = @{ $self->every_param('f') // [] };
523        my @parsed;
524        for my $s (@specs) {
525                my ($col, $op, $val) = split /:/, $s, 3;
526                next unless length($col // '') && defined $op && length $op;
527                push @parsed, { col => $col, op => $op, val => $val // '' };
528                $records = _apply_filter_spec($records, $s);
529        }
530        my $json = encode_json(\@parsed);
531        $json =~ s{</}{<\\/}g;
532        return ($records, \@specs, $json);
533
12
12
12
5224
12
41
}
534
535# _dedup_records(\@records, \@columns) -> \@unique_records
536#
537# Purpose: Remove duplicate rows from $records.  Two rows are duplicates when
538#          every column value is identical (string comparison, undef treated as '').
539#          Preserves the order of first occurrence.
540# Entry:   $records is an arrayref of hashrefs; $columns is an arrayref of names.
541# Exit:    Returns a new arrayref; the input is not modified.
542sub _dedup_records {
543
16
28930
        my ($records, $columns) = @_;
544
16
11
        my (%seen, @out);
545
16
20
        for my $row (@$records) {
546
45
100
27
112
                my $key = join "\x00", map { $row->{$_} // '' } @$columns;
547
45
68
                push @out, $row unless $seen{$key}++;
548        }
549
16
23
        return \@out;
550}
551
552# _filter_specs_to_criteria( \@specs, $source ) -> ( \%criteria, \@residual_specs )
553#
554# Purpose: Map the f= filter vocabulary onto Database::Abstraction / Database::Join
555#          criteria hashrefs so the backend can apply pushable filters before
556#          paginating.  Specs that cannot be safely pushed are returned as
557#          @residual_specs for subsequent in-memory application.
558#
559# Pushdown is only performed when $source->dbi_source() confirms a SQL (SQLite)
560# backend.  For CSV (slurp path), in-memory, XLSX, BerkeleyDB and URL sources
561# everything is placed in @residual_specs so the existing _apply_filter_spec
562# Perl path handles them.
563#
564# Pushable mapping (SQLite only):
565#   eq / ne         -- LIKE / NOT LIKE without wildcards.  SQLite LIKE is
566#                      case-insensitive for ASCII, matching the existing Perl
567#                      lc() semantics.  Values containing % or _ are pushed to
568#                      residual to avoid LIKE metacharacter conflicts.
569#   contains        -- LIKE '%val%'.  Residual when value contains % or _.
570#   starts          -- LIKE 'val%'.   Residual when value contains % or _.
571#   lt / le / gt / ge -- SQL numeric comparison.  Residual when the value is
572#                      not a plain number (avoids column-affinity surprises
573#                      with currency or comma-formatted values).
574#   empty / notempty -- Always residual.  Must match both SQL NULL and the
575#                      literal empty string; cannot be expressed as a single
576#                      D::A criterion without an -or grouping.
577#
578# Column collision: if a column already has a pushable criterion, the second
579# spec for that column is placed in @residual_specs.  This keeps the AND logic
580# simple and safe; callers with multiple conditions on one column (e.g. a range)
581# still get correct results via the in-memory fallback.
582sub _filter_specs_to_criteria {
583
72
32549
        my ($specs, $source) = @_;
584
585        # Only push when the source has a live SQL (SQLite) backend.
586
72
255
        my $is_sql = $source
587                && $source->can('dbi_source')
588                && $source->dbi_source();
589
72
164302
        return ({}, [@$specs]) unless $is_sql;
590
591
20
181
        require Scalar::Util;
592
20
26
        my (%criteria, @residual);
593
594
20
27
        for my $spec (@$specs) {
595
21
59
                my ($col, $op, $val) = split /:/, $spec, 3;
596
21
113
                next unless length($col // '') && defined $op && length $op;
597
21
163
                $val //= '';
598
599                # A second spec on the same column goes to residual.
600
21
31
                if (exists $criteria{$col}) {
601
1
2
                        push @residual, $spec;
602
1
1
                        next;
603                }
604
605
20
55
                if ($op eq 'eq' || $op eq 'ne') {
606
15
36
                        if ($val =~ /[%_]/) {
607
1
2
                                push @residual, $spec;
608                        } else {
609
14
48
                                $criteria{$col} = ($op eq 'eq')
610                                        ? { -like     => $val }
611                                        : { -not_like => $val };
612                        }
613                } elsif ($op eq 'contains') {
614
1
3
                        if ($val =~ /[%_]/) {
615
0
0
                                push @residual, $spec;
616                        } else {
617
1
3
                                $criteria{$col} = { -like => "%${val}%" };
618                        }
619                } elsif ($op eq 'starts') {
620
1
2
                        if ($val =~ /[%_]/) {
621
0
0
                                push @residual, $spec;
622                        } else {
623
1
4
                                $criteria{$col} = { -like => "${val}%" };
624                        }
625                } elsif ($op eq 'lt') {
626                        Scalar::Util::looks_like_number($val)
627
0
0
                                ? ($criteria{$col} = { '<'  => $val })
628                                : push @residual, $spec;
629                } elsif ($op eq 'le') {
630                        Scalar::Util::looks_like_number($val)
631
0
0
                                ? ($criteria{$col} = { '<=' => $val })
632                                : push @residual, $spec;
633                } elsif ($op eq 'gt') {
634                        Scalar::Util::looks_like_number($val)
635
2
8
                                ? ($criteria{$col} = { '>'  => $val })
636                                : push @residual, $spec;
637                } elsif ($op eq 'ge') {
638                        Scalar::Util::looks_like_number($val)
639
0
0
                                ? ($criteria{$col} = { '>=' => $val })
640                                : push @residual, $spec;
641                } else {
642                        # empty, notempty, unknown operator: always residual.
643
1
2
                        push @residual, $spec;
644                }
645        }
646
647
20
57
        return (\%criteria, \@residual);
648}
649
650# ---------------------------------------------------------------------------
651# Phase 1 semantic-layer helpers
652# ---------------------------------------------------------------------------
653
654# _measure($fn, $col, $as) -> \%measure
655#
656# Build one measure descriptor in the vocabulary shared by Database::Abstraction
657# and Database::Join (sum|avg|min|max|count|count_distinct).  $col is the
658# source column name, or undef for COUNT(*).  $as is the result-row key.
659# The returned hashref is consumed directly by aggregate(measures => [...]).
660sub _measure {
661
84
259
        my ($fn, $col, $as) = @_;
662
84
268
        return { fn => $fn, (defined $col ? (col => $col) : ()), as => $as };
663}
664
665# _numeric_cell($v) -> $number | undef
666#
667# Extract a numeric value from a raw cell, handling currency prefixes
668# (e.g. $1,234.56) and accounting-notation negatives ((1,234.56) -> -1234.56).
669# Returns undef for empty, undef, or non-numeric input.
670# Used by the Perl fold path of _grouped_totals so all four chart routes
671# share one extraction function.
672sub _numeric_cell {
673
188
160
        my $v = shift;
674
188
317
        return undef unless defined $v && length($v);
675
186
194
        my $is_acct_neg = ($v =~ /\A\s*\(/);
676
186
257
        (my $n = $v) =~ s/[^\d.\-]//g;
677
186
231
        $n = "-$n" if $is_acct_neg && $n =~ /\A\d/;
678
186
329
        return undef unless $n =~ /\A-?\d+(?:\.\d+)?\z/;
679
163
223
        return $n + 0;
680}
681
682# _measure_pushable($source, $fn, $col, \@records) -> bool
683#
684# Returns true when the measure can be computed by aggregate() on $source and
685# the result will be identical to the Perl fold.  False means use the Perl fold.
686#
687# Decision table:
688#   count / count_distinct -- always pushable (no numeric coercion).
689#   sum / avg / min / max  -- only when every sampled value in $col is either
690#                             a plain number (looks_like_number) or empty/undef.
691#                             Currency symbols or accounting parentheses in any
692#                             value force the Perl fold, which strips them
693#                             correctly; SQLite CAST would coerce them to 0.
694#   Missing $source        -- never pushable.
695#   Missing $col           -- sum/avg/min/max are not pushable without a column.
696sub _measure_pushable {
697
88
150
        my ($source, $fn, $col, $records) = @_;
698
88
237
        return 0 unless $source && $source->can('aggregate');
699
88
765
        return 1 if $fn eq 'count' || $fn eq 'count_distinct';
700
69
93
        return 0 unless defined $col;
701        # For sum/avg/min/max, only push when at least one value is a plain number
702        # and every non-null/non-blank value passes looks_like_number.
703        #
704        # Two cases where both paths must agree:
705        #   - All values undef/blank: Perl fold produces no rows; SQL SUM(NULL)
706        #     produces NULL, normalised to 0 by D::A → discrepancy.  Return 0 so
707        #     the Perl fold runs and yields the expected "No plottable data" result.
708        #   - Currency/accounting notation (e.g. ($1,234)): _numeric_cell strips
709        #     these correctly; SQL sees non-numeric text → SUM = NULL → 0.  Return 0
710        #     so _numeric_cell is used on every value.
711
69
213
        require Scalar::Util;
712
69
66
        my $found = 0;
713
69
69
69
126
        for my $row (@{ $records // [] }) {
714
265
202
                my $v = $row->{$col};
715
265
342
                next unless defined $v && length($v);   # undef/blank: same effect as NULL
716
263
309
                return 0 unless Scalar::Util::looks_like_number($v);
717
246
170
                $found = 1;
718        }
719
52
101
        return $found;    # 0 (false) when every sampled value was undef or blank
720}
721
722# _parse_measure_param($spec_str) -> \%measure | undef
723#
724# Parse 'col:fn:as' into { col => ..., fn => ..., as => ... }.
725# Shared dialect for /pivot (Phase 2) so it does not invent a second format.
726# Returns undef for an invalid or unrecognised spec.
727sub _parse_measure_param {
728
25
32
        my $str = shift;
729
25
117
        return undef unless defined $str && length($str);
730
25
76
        my ($col, $fn, $as) = split /:/, $str, 3;
731
25
176
        return undef unless defined $fn && defined $as && length($fn) && length($as);
732
25
44
        $fn = lc $fn;
733
25
127
        return undef unless $MEASURE_FN_OK{$fn};
734
24
180
        return { col => $col, fn => $fn, as => $as };
735}
736
737# _grouped_totals($self, \%pipeline, \%spec) -> \@rows
738#
739# Single GROUP BY entry point for all four chart routes.  Returns an arrayref
740# of hashrefs, each containing the 'by' column values and the aggregate result
741# under $spec->{as}.
742#
743# $pipeline = { source => $src_or_undef, records => \@rows }
744#   source  -- a DataSource or Database::Join for the push path; undef when
745#              the pipeline is a /combine union (must fold in Perl).
746#   records -- the already-fetched rows (pre-filtered by the caller).
747#
748# $spec = { by => [...], fn => ..., val => ..., as => ..., [sort_by, having, limit, offset] }
749#
750# Dispatch:
751#   Push path  -- $source is defined, no active f= filters, measure is pushable
752#                 (checked by _measure_pushable). Calls $source->aggregate().
753#                 Phase 1 does not push filter criteria (that is Phase 0.3);
754#                 when f= filters are active the Perl fold is always used.
755#   Perl fold  -- Iterates $pipeline->{records} (pre-filtered) directly.
756#
757# $ENV{BI_FORCE_PERL_AGG}: set to a true value in tests to force the Perl fold
758# regardless of source type, enabling cross-backend equivalence assertions.
759sub _grouped_totals :Protected ($self, $pipeline, $spec) {
760        my $source  = $pipeline->{source};
761        my $records = $pipeline->{records} // [];
762        my $by      = $spec->{by}  // [];
763        my $fn      = lc($spec->{fn}  // 'count');
764        my $val_col = $spec->{val};
765        my $as      = $spec->{as}  // 'value';
766
767        # Push path: use backend aggregate() when safe.
768        # Active f= filters block the push path in Phase 1 because
769        # _filter_specs_to_criteria is a stub that cannot push criteria to SQL yet.
770        my @f_specs    = grep { /\S/ } @{ $self->every_param('f') // [] };
771        my $has_filter = !!@f_specs;
772
773        # SQL::Statement 1.414 (the SQL engine under DBD::CSV) uses
774        # "use warnings FATAL => 'all'" and calls join("\0", @row[@keycols])
775        # inside getAffectedResult without guarding against undef.  When a GROUP BY
776        # column contains blank CSV cells (empty_is_undef => 1 converts them to
777        # undef), the join triggers a fatal "uninitialized value" exception and the
778        # execute() call dies.  The correct fix is to use
779        #   map { $_ // '' } @row[@keycols]
780        # in SQL::Statement::getAffectedResult, but the module has been unmaintained
781        # since 2020 (21 open RT tickets as of 2026-10).
782        # See: https://rt.cpan.org/Ticket/Display.html?id=181946
783        # Skip the push path whenever any 'by' column has a blank or undef value so
784        # DBD::CSV/SQL::Statement is never asked to GROUP BY a null key.
785        # TODO: remove this guard once SQL-Statement RT#181946 is fixed (last checked: 1.414).
786        my $has_null_by_key = @$by && do {
787                my $found;
788                KEY_SCAN: for my $row (@$records) {
789                        for my $col (@$by) {
790                                my $v = $row->{$col};
791                                if (!defined($v) || !length($v)) { $found = 1; last KEY_SCAN; }
792                        }
793                }
794                $found;
795        };
796
797        unless ($ENV{BI_FORCE_PERL_AGG} || $has_filter || $has_null_by_key || !$source
798                || !_measure_pushable($source, $fn, $val_col, $records)) {
799                my $m = _measure($fn, $val_col, $as);
800                my $agg_rows = eval {
801                        $source->aggregate(
802                                by       => $by,
803                                measures => [$m],
804                                ($spec->{sort_by} ? (sort_by => $spec->{sort_by}) : ()),
805                                ($spec->{having}  ? (having  => $spec->{having})  : ()),
806                                ($spec->{limit}   ? (limit   => $spec->{limit})   : ()),
807                                ($spec->{offset}  ? (offset  => $spec->{offset})  : ()),
808                        )
809                };
810                unless ($@) {
811                        return $agg_rows // [];
812                }
813                carp "aggregate() push failed: $@; falling back to Perl fold";
814        }
815
816        # Perl fold over pre-filtered records.
817        my (%seen_warned, %seen_key, @key_order);
818        my (%totals, %count_map, %distinct_vals);
819
820        for my $row (@$records) {
821                # Skip rows where any 'by' column is empty/undef (mirrors SQL GROUP BY NULL exclusion).
822                # Note: calc field columns store '(unparseable)' (not undef) for failed expressions,
823                # so those rows pass this guard and appear in the (unparseable) group instead.
824                my @key_parts = map { $row->{$_} // '' } @$by;
825                next if @$by && grep { !length($_) } @key_parts;
826                my $key = join("\x00", @key_parts);
827
828                if ($fn eq 'count') {
829                        push @key_order, $key unless $seen_key{$key}++;
830                        $totals{$key}++;
831                } elsif ($fn eq 'count_distinct') {
832                        push @key_order, $key unless $seen_key{$key}++;
833                        my $dv = defined $val_col ? ($row->{$val_col} // '') : '';
834                        $distinct_vals{$key}{$dv}++;
835                } else {
836                        my $v = defined $val_col ? ($row->{$val_col} // '') : '';
837                        my $n = _numeric_cell($v);
838                        unless (defined $n) {
839                                unless ($seen_warned{"$fn\x01" . ($val_col // '')}++) {
840                                        carp $self->_i18n('warn_measure_fallback', $fn, $val_col // '(none)');
841                                }
842                                next;   # skip: do NOT add key to order for non-numeric rows
843                        }
844                        push @key_order, $key unless $seen_key{$key}++;
845                        if    ($fn eq 'sum') { $totals{$key}  += $n                                                             }
846                        elsif ($fn eq 'avg') { $totals{$key}  += $n; $count_map{$key}++                                        }
847                        elsif ($fn eq 'min') { $totals{$key}   = (!exists $totals{$key} || $n < $totals{$key}) ? $n : $totals{$key} }
848                        elsif ($fn eq 'max') { $totals{$key}   = (!exists $totals{$key} || $n > $totals{$key}) ? $n : $totals{$key} }
849                }
850        }
851
852        if ($fn eq 'avg') {
853                for my $k (keys %totals) {
854                        $totals{$k} = $count_map{$k} ? $totals{$k} / $count_map{$k} : undef;
855                }
856        } elsif ($fn eq 'count_distinct') {
857                for my $k (@key_order) {
858                        $totals{$k} = scalar keys %{ $distinct_vals{$k} // {} };
859                }
860        }
861
862        # Assemble result rows in first-seen (insertion) order.
863        my @result;
864        for my $key (@key_order) {
865                my @parts = @$by ? (split /\x00/, $key, scalar @$by) : ();
866                my %row;
867                for my $i (0 .. $#$by) {
868                        $row{$by->[$i]} = $parts[$i];
869                }
870                $row{$as} = $totals{$key} // ($fn =~ /\A(?:sum|count|count_distinct)\z/ ? 0 : undef);
871                push @result, \%row;
872        }
873
874        return \@result;
875
12
12
12
15062
13
50
}
876
877# _fetch_page( $self, $source, \%params ) -> ( $records, $has_next )
878#   params: per_page  -- page size (required)
879#           offset    -- 0-based row offset (required)
880#           dedup     -- truthy when ?d=1 dedup is requested
881#           sort_col  -- column name to sort by (optional)
882#           sort_dir  -- 'asc' or 'desc' (default 'asc'; ignored unless sort_col set)
883#
884# Purpose: Fetch and reduce one page of records from $source, honouring the
885#          reduce-before-paginate ordering contract.  Two modes:
886#
887#   Fast path (no f= filters, no dedup): uses the N+1 trick
888#     selectall_arrayref(limit => per_page+1, offset => offset).  has_next is
889#     determined by the extra row before it is popped.
890#
891#   Reduction path (f= filters or dedup present): materialises the full result
892#     in memory, applies all f= filters with _apply_filter_spec (case-insensitive),
893#     applies dedup if requested, then paginates in Perl.  This guarantees that
894#     the Nth page contains the Nth page of the *reduced* set, not the Nth page
895#     of the raw set reduced to fewer rows.  Memory cost: the full reduced set.
896#
897# Sort: sort_col/sort_dir are forwarded to selectall_arrayref as
898#   sort_by => [$sort_col, $sort_dir].  Both Database::Abstraction 0.49+
899#   and Database::Join 0.009.0 honour this parameter natively.
900#
901# Returns (undef, 0) when the underlying fetch throws; the caller must handle
902# $records being undef.
903sub _fetch_page :Protected ($self, $source, $params) {
904        my $per_page = $params->{per_page};
905        my $offset   = $params->{offset};
906        my $dedup    = $params->{dedup};
907        my $sort_col = $params->{sort_col};
908        my $sort_dir = ($params->{sort_dir} // 'asc') =~ /\Adesc\z/i ? 'desc' : 'asc';
909
910        # sort_by args forwarded to the backend when a sort column is requested.
911        # D::A 0.49+ and D::J 0.009.0 both support sort_by => [$col, $dir].
912        my @sort_args = $sort_col ? (sort_by => [$sort_col, $sort_dir]) : ();
913
914        # Collect and validate f= specs.
915        my @f_specs = grep {
916                my ($c, $o) = split /:/, $_, 3;
917                length($c // '') && defined $o && length $o;
918        } @{ $self->every_param('f') // [] };
919
920        unless (@f_specs || $dedup) {
921                # Fast path: no filters, no dedup.  Let the backend paginate via N+1 trick.
922                # Copy before pop: D::A may return a read-only ref to its internal cache.
923                my @records = @{ $source->selectall_arrayref(
924                        limit => $per_page + 1, offset => $offset, @sort_args,
925                ) // [] };
926                my $has_next = (@records > $per_page) ? 1 : 0;
927                pop @records if $has_next;
928                return (\@records, $has_next);
929        }
930
931        # Criteria pushdown: translate as many f= specs as possible into SQL
932        # criteria (_filter_specs_to_criteria returns a criteria hashref and a
933        # residual list for any spec that cannot be safely pushed to SQL).
934        my ($criteria, $residual) = _filter_specs_to_criteria(\@f_specs, $source);
935        my $has_criteria = %$criteria;
936
937        # Full-pushdown fast path: all specs translated, no dedup.
938        # Use N+1 trick with SQL criteria + LIMIT/OFFSET -- avoids full-table fetch.
939        # Copy before pop: D::A may return a read-only ref to its internal cache.
940        if (!$dedup && !@$residual) {
941                my @records = @{ $source->selectall_arrayref(
942                        %$criteria, limit => $per_page + 1, offset => $offset, @sort_args,
943                ) // [] };
944                my $has_next = (@records > $per_page) ? 1 : 0;
945                pop @records if $has_next;
946                return (\@records, $has_next);
947        }
948
949        # Partial-pushdown / dedup path: fetch the SQL-filtered (and sorted) set,
950        # apply residual specs and/or dedup in Perl, then paginate the reduced set.
951        # Memory cost: the full reduced set.
952        my $all = $has_criteria
953                ? $source->selectall_arrayref(%$criteria, @sort_args)
954                : $source->selectall_arrayref(@sort_args);
955        $all //= [];
956
957        $all = _apply_filter_spec($all, $_) for @$residual;
958
959        if ($dedup) {
960                my $cols = $source->columns;
961                my @dcols = $cols ? @$cols : (keys %{ $all->[0] // {} });
962                $all = _dedup_records($all, \@dcols);
963        }
964
965        my $total    = scalar @$all;
966        my $has_next = ($total > $offset + $per_page) ? 1 : 0;
967        my @page     = splice(@$all, $offset, $per_page);
968
969        return (\@page, $has_next);
970
12
12
12
5728
11
45
}
971
972# _combine_tables(\@sources) -> (\@merged_records, \@merged_columns)
973#
974# Purpose: Perform a vertical stack (UNION ALL equivalent) of two or more
975#          record sets.  The merged column list is the union of all source
976#          column lists: the first source's columns appear first (in their
977#          original order), then each subsequent source contributes any new
978#          column names it introduces, in order.  Where a source does not
979#          have a particular column, its rows receive an empty string for
980#          that column.
981# Entry:   $sources is an arrayref of [$records_aref, $columns_aref] pairs.
982#          At least one element is required; a single-element call returns
983#          that element's data unchanged (no allocation beyond the column copy).
984# Exit:    Returns (\@merged_records, \@merged_columns).
985# Side Effects: None; allocates new record hashrefs (original rows are not mutated).
986sub _combine_tables {
987
23
16777
        my ($sources) = @_;
988
989        # Build the unified column order: first source's columns first, then any
990        # new columns introduced by each subsequent source in their file order.
991
23
22
        my (@all_cols, %seen);
992
23
32
        for my $src (@$sources) {
993
43
43
37
41
                for my $col (@{ $src->[1] }) {
994
129
157
                        push @all_cols, $col unless $seen{$col}++;
995                }
996        }
997
998
23
23
        my @merged;
999
23
26
        for my $src (@$sources) {
1000
43
39
                my ($recs, $cols) = @$src;
1001
43
129
46
128
                my %has_col = map { $_ => 1 } @$cols;
1002
43
42
                for my $row (@$recs) {
1003
161
97
                        my %new_row;
1004                        $new_row{$_} = $has_col{$_} ? ($row->{$_} // '') : ''
1005
161
575
                                for @all_cols;
1006
161
144
                        push @merged, \%new_row;
1007                }
1008        }
1009
1010
23
40
        return (\@merged, \@all_cols);
1011}
1012
1013# _csv_row(@fields) -> $csv_line_with_crlf
1014#
1015# Purpose: Format one row as an RFC 4180 CSV line.  Fields containing commas,
1016#          double-quotes, or newlines are enclosed in double-quotes; embedded
1017#          double-quotes are doubled.  The line ends with CRLF.
1018# Entry:   @fields may contain undef (treated as empty string).
1019# Exit:    Returns a string ending with "\r\n".
1020sub _csv_row {
1021        return join(',', map {
1022
381
1966
13933
1335
                my $f = $_ // '';
1023
1966
12
12
2052
18
25
                $f =~ /[,"\r\n]/ ? do { (my $q = $f) =~ s/"/""/g; qq{"$q"} } : $f;
1024        } @_) . "\r\n";
1025}
1026
1027# _build_export_url($self, $left_spec, \@join_specs, \@filter_specs) -> $url_string
1028#
1029# Purpose: Assemble a /export?l=...&j=...&f=... URL string for the current
1030#          logical view.  The caller must apply | html in TT to escape & as &amp;.
1031# Entry:   All args non-undef; spec arrays may be empty.
1032# Exit:    Returns a relative URL string beginning with '/export'.
1033sub _build_export_url :Protected ($self, $left_spec, $join_specs, $filter_specs, $combine_specs = undef, $dedup = 0) {
1034        my $u = '/export?l=' . url_escape($left_spec);
1035        $u .= '&j=' . url_escape($_) for @$join_specs;
1036        $u .= '&c=' . url_escape($_) for @{ $combine_specs // [] };
1037        $u .= '&f=' . url_escape($_) for @$filter_specs;
1038        $u .= '&d=1' if $dedup;
1039        return $u;
1040
12
12
12
5367
12
48
}
1041
1042# _list_dir($self, $dir, $want_files) -> { dirs => [...], files => [...] }
1043#
1044# Purpose: Shared directory-listing kernel used by browse (which needs files)
1045#          and dirs_api (which needs only subdirectories).  Hidden entries
1046#          (names starting with ".") are excluded.  Entries are sorted
1047#          case-insensitively.
1048# Entry:   $dir is a Mojo::File pointing to an existing readable directory.
1049#          $want_files: if true, populate 'files' with entries matching $EXT_RE.
1050# Exit:    Returns a hashref with 'dirs' and 'files' arrayrefs (files is always
1051#          present but empty when $want_files is false).
1052# Side Effects: Filesystem directory read.
1053sub _list_dir :Protected ($self, $dir, $want_files) {
1054        my (@dirs, @files);
1055        if (opendir my $dh, $dir->to_string) {
1056                while (my $entry = readdir $dh) {
1057                        next if $entry eq '.' || $entry eq '..' || $entry =~ /\A\./;
1058                        my $f = $dir->child($entry);
1059                        if (-d $f) {
1060                                push @dirs,  { name => $entry, path => $f->to_string };
1061                        }
1062                        elsif ($want_files && $entry =~ $EXT_RE) {
1063                                push @files, { name => $entry, path => $f->to_string };
1064                        }
1065                }
1066                closedir $dh;
1067        }
1068        return {
1069                dirs  => [ sort { lc($a->{name}) cmp lc($b->{name}) } @dirs  ],
1070                files => [ sort { lc($a->{name}) cmp lc($b->{name}) } @files ],
1071        };
1072
12
12
12
4083
8
44
}
1073
1074# _write_sqlite_db($self, $records, $columns) -> $raw_bytes
1075#
1076# Purpose: Write $records to an in-process SQLite database (single table named
1077#          "data"; every column declared TEXT) and return the raw file bytes.
1078#          The temporary file is created, written, read, and then unlinked in
1079#          one synchronous sequence.  Used by both _render_sqlite (HTTP download)
1080#          and export_write (filesystem write) to eliminate code duplication.
1081# Entry:   $records is an arrayref of hashrefs; $columns is an arrayref of names.
1082# Exit:    Returns scalar binary string.  Croaks on any DBI error.
1083# Side Effects: Creates and unlinks one temporary file under system tmpdir.
1084sub _write_sqlite_db :Protected ($self, $records, $columns) {
1085        # D~ fix: an empty column list produces "CREATE TABLE data ()" which is
1086        # invalid SQLite syntax.  Croak before touching the filesystem.
1087        croak 'Cannot export SQLite: result set has no columns'
1088                unless @$columns;
1089
1090        require DBI;
1091        my ($tmp_fh, $tmpfile) = tempfile(SUFFIX => '.db', UNLINK => 0);
1092        close $tmp_fh;
1093
1094        my $dbh = eval { DBI->connect("dbi:SQLite:dbname=$tmpfile", '', '', {
1095                RaiseError => 1, AutoCommit => 1,
1096        }) };
1097        # Guard both the eval-caught die ($@) and the undef-without-die edge case.
1098        if ($@ || !$dbh) {
1099                unlink $tmpfile;
1100                croak "DBI connect failed: $@";
1101        }
1102
1103        # O~ fix: wrap all DBI work in eval so any mid-flight exception still
1104        # triggers cleanup (disconnect + unlink) before the error propagates.
1105        eval {
1106                my @quoted = map { (my $c = $_) =~ s/"/""/g; qq{"$c"} } @$columns;
1107                $dbh->do('CREATE TABLE "data" (' . join(', ', map { "$_ TEXT" } @quoted) . ')');
1108                if (@$records) {
1109                        my $ph  = join(', ', ('?') x scalar @$columns);
1110                        my $sth = $dbh->prepare(
1111                                'INSERT INTO "data" (' . join(', ', @quoted) . ") VALUES ($ph)"
1112                        );
1113                        for my $row (@$records) {
1114                                $sth->execute(map { $row->{$_} } @$columns);
1115                        }
1116                }
1117                $dbh->disconnect;
1118        };
1119        if (my $err = $@) {
1120                eval { $dbh->disconnect };   # best-effort; may already be disconnected
1121                unlink $tmpfile;
1122                croak $err;
1123        }
1124
1125        my $data = Mojo::File->new($tmpfile)->slurp;
1126        unlink $tmpfile;
1127        return $data;
1128
12
12
12
5159
11
42
}
1129
1130# _run_pipeline_source($self) -> ($src, $records, \@columns, $left_label) or ()
1131#
1132# Purpose: Inner pipeline: opens the left spec, builds the join chain, fetches
1133#          all rows, and runs any c= combine step.  Does NOT apply f= filters,
1134#          dedup, or pagination -- that is the caller's responsibility.
1135#          Chart routes call this directly so they can access $src for the
1136#          Phase 1 aggregate() push path.  Row-viewing routes and export routes
1137#          call the thin _run_export_pipeline wrapper instead.
1138# Exit:    On success: ($src_or_undef, $records_aref, $columns_aref, $label).
1139#          $src is undef when a c= combine makes the result non-aggregatable.
1140#          On failure: empty list (left table not found / fetch error).
1141sub _run_pipeline_source :Protected ($self) {
1142        my $left_spec = $self->param('l') // '';
1143        my ($left_src, $left_label) = $self->_open_spec($left_spec);
1144        return () unless $left_src;
1145
1146        # Build the join chain lazily.
1147        my $max_rows  = $self->app->config('join_max_rows') // $DEFAULT_JOIN_MAX_ROWS;
1148        my $join_type = $self->param('jtype') // 'left';
1149        $join_type    = 'left' unless $join_type =~ /\A(?:left|inner|outer)\z/;
1150        my $src = $left_src;
1151        my @schema_warnings;
1152        for my $jspec (@{ $self->every_param('j') }) {
1153                my ($right_spec, $left_key, $right_key) = split /\|/, $jspec, 3;
1154                next unless defined $right_spec && defined $left_key && defined $right_key;
1155
1156                my $cur_cols = $src->columns;
1157                next unless $cur_cols;
1158                next unless +{ map { $_ => 1 } @$cur_cols }->{$left_key};
1159
1160                my ($right_src, $right_label) = $self->_open_spec($right_spec);
1161                next unless $right_src;
1162
1163                my $right_cols = $right_src->columns;
1164                next unless $right_cols;
1165                next unless +{ map { $_ => 1 } @$right_cols }->{$right_key};
1166
1167                require Database::Join;
1168                local $SIG{__WARN__} = sub { push @schema_warnings, $_[0] };
1169                $src = Database::Join->new(
1170                        databases        => [$src, $right_src],
1171                        join_column      => $left_key,
1172                        backend          => 'auto',
1173                        max_array_rows   => $max_rows,
1174                        join_type        => $join_type,
1175                        ($left_key ne $right_key ? (join_map         => {1 => $right_key})   : ()),
1176                        ($right_label             ? (collision_prefix => {1 => $right_label}) : ()),
1177                );
1178        }
1179
1180        $self->stash(schema_warnings => \@schema_warnings) if @schema_warnings;
1181
1182        my $records = eval { $src->selectall_arrayref };
1183        return () if $@;
1184        $records //= [];
1185        my @columns = _get_columns($src, $records);
1186
1187        # Combine (vertical stack): sets $src = undef since the result is a union
1188        # with no single aggregatable object.
1189        my @cspecs = @{ $self->every_param('c') // [] };
1190        if (@cspecs) {
1191                my @sources = ([$records, \@columns]);
1192                for my $cspec (@cspecs) {
1193                        my ($csrc) = $self->_open_spec($cspec);
1194                        next unless $csrc;
1195                        my $crecs = eval { $csrc->fetch_all } // [];
1196                        next if $@;
1197                        my @ccols = _get_columns($csrc, $crecs);
1198                        push @sources, [$crecs, \@ccols];
1199                }
1200                if (@sources > 1) {
1201                        ($records, my $new_cols) = _combine_tables(\@sources);
1202                        @columns = @$new_cols;
1203                        $src = undef;   # union: no single aggregatable source
1204                }
1205        }
1206
1207        return ($src, $records, \@columns, $left_label);
1208
12
12
12
6277
11
42
}
1209
1210# _apply_date_normalization($self, $records, \@columns, $spec_key) -> void
1211#
1212# Reads date_patterns from .bi/models.json for $spec_key and normalises each
1213# configured column's values to ISO-8601 in-place.  A value that does not match
1214# the stored pattern is left unchanged so YEAR/MONTH/etc. return undef (and the
1215# row falls into the '(unparseable)' bucket in _grouped_totals).
1216# Called at the top of _apply_calc_fields so registered patterns are applied
1217# before any calc expression is evaluated.
1218sub _apply_date_normalization :Protected ($self, $records, $columns, $spec_key) {
1219        return unless defined $spec_key && length $spec_key;
1220        my $models   = $self->_read_models;
1221        my $patterns = $models->{date_patterns}{$spec_key};
1222        return unless $patterns && %{$patterns};
1223        my %col_set = map { $_ => 1 } @{$columns};
1224        require Database::BI::Calc;
1225        for my $col (keys %{$patterns}) {
1226                next unless $col_set{$col};
1227                my $pat = $patterns->{$col};
1228                for my $row (@{$records}) {
1229                        my $v = $row->{$col};
1230                        next unless defined $v && length $v;
1231                        # Only overwrite when normalisation succeeds; leave unchanged on
1232                        # mismatch so the row lands in the '(unparseable)' group.
1233                        my $iso = Database::BI::Calc::date_normalise($v, $pat);
1234                        $row->{$col} = $iso if defined $iso;
1235                }
1236        }
1237
12
12
12
4225
21
42
}
1238
1239# _apply_calc_fields($self, $records, \@columns) -> ($records, \@columns)
1240#
1241# Reads ?c=<name>:<expression> params (those NOT matching a path:/table:/url:/redis:
1242# source spec) and compiles each via Database::BI::Calc.  Evaluates per row,
1243# appending the virtual column to each row hashref.  Croaks on compile error
1244# (caller renders 400); runtime undef values are logged at warn level.
1245# Later ?c= specs may reference names defined by earlier ones (evaluated in order).
1246sub _apply_calc_fields :Protected ($self, $records, $columns) {
1247        # Normalise date columns before evaluating expressions so that date
1248        # functions (YEAR, MONTH, etc.) see ISO-8601 values even when the source
1249        # file uses a non-ISO format (e.g. M/D/YYYY).
1250        $self->_apply_date_normalization($records, $columns, $self->param('l'));
1251
1252        # Build calc spec list: stored fields from models.json first (lower priority),
1253        # then URL ?c= params (higher priority — they override stored fields of the
1254        # same name so the user can test a new expression without permanently saving).
1255        my (@calc_specs, %seen_calc);
1256        {
1257                my $sk = $self->param('l') // '';
1258                if (length $sk) {
1259                        my $stored = ($self->_read_models)->{fields}{$sk} // {};
1260                        for my $n (sort keys %{$stored}) {
1261                                push @calc_specs, { name => $n, expr => $stored->{$n} };
1262                                $seen_calc{$n} = $#calc_specs;
1263                        }
1264                }
1265        }
1266        for my $spec (@{ $self->every_param('c') // [] }) {
1267                # Skip source specs handled by _run_pipeline_source.
1268                next if $spec =~ /\A(?:path:|table:|url:|redis:)/i;
1269                my ($name, $expr) = ($spec =~ /\A([A-Za-z_][A-Za-z0-9_]*):(.+)\z/s);
1270                next unless defined $name && defined $expr;
1271                if (exists $seen_calc{$name}) {
1272                        $calc_specs[$seen_calc{$name}]{expr} = $expr; # URL overrides stored
1273                } else {
1274                        push @calc_specs, { name => $name, expr => $expr };
1275                        $seen_calc{$name} = $#calc_specs;
1276                }
1277        }
1278
1279        my @cols = @{$columns};
1280
1281        if (@calc_specs) {
1282                require Database::BI::Calc;
1283                for my $cs (@calc_specs) {
1284                        my $name = $cs->{name};
1285                        my $calc = Database::BI::Calc->new(expr => $cs->{expr}, columns => \@cols);
1286                        croak $self->_i18n('error_calc_compile', $name, $calc->error)
1287                                if $calc->has_error;
1288
1289                        my $undef_count = 0;
1290                        for my $row (@{$records}) {
1291                                my $val = $calc->evaluate($row);
1292                                # Store '(unparseable)' rather than undef so that pivot/bar
1293                                # grouping (which skips empty-string keys) keeps these rows in a
1294                                # visible '(unparseable)' bucket rather than silently dropping them.
1295                                $row->{$name} = defined $val ? $val : '(unparseable)';
1296                                $undef_count++ unless defined $val;
1297                        }
1298                        if ($undef_count) {
1299                                $self->app->log->warn(
1300                                        $self->_i18n('warn_calc_undef', $name, $undef_count)
1301                                );
1302                        }
1303                        push @cols, $name unless grep { $_ eq $name } @cols;
1304                }
1305        }
1306
1307        # Apply named value groups from models.json.
1308        # For each source column that has groups registered under the current l=
1309        # spec, add a virtual "${col}_group" column to every row.  Each row's value
1310        # is mapped to its group label; values not in any group become '(other)'.
1311        {
1312                my $spec_key = $self->param('l') // '';
1313                if (length $spec_key) {
1314                        my $models = $self->_read_models;
1315                        my $grps   = $models->{groups}{$spec_key};
1316                        if ($grps && %{$grps}) {
1317                                my %col_set = map { $_ => 1 } @cols;
1318                                for my $col (sort keys %{$grps}) {
1319                                        next unless $col_set{$col};
1320                                        # Build reverse map: value -> group label.
1321                                        my %val2group;
1322                                        for my $gname (keys %{$grps->{$col}}) {
1323                                                for my $v (@{$grps->{$col}{$gname}}) {
1324                                                        $val2group{$v} = $gname;
1325                                                }
1326                                        }
1327                                        my $vcol = "${col}_group";
1328                                        for my $row (@{$records}) {
1329                                                my $v = $row->{$col} // '';
1330                                                $row->{$vcol} = $val2group{$v} // '(other)';
1331                                        }
1332                                        push @cols, $vcol unless grep { $_ eq $vcol } @cols;
1333                                }
1334                        }
1335                }
1336        }
1337
1338        return ($records, \@cols);
1339
12
12
12
8276
14
49
}
1340
1341# ---------------------------------------------------------------------------
1342# Calculated-field model storage  (.bi/models.json)
1343# ---------------------------------------------------------------------------
1344
1345# _calc_models_path($self) -> $path_string_or_undef
1346#
1347# Returns the path to .bi/models.json (creating .bi/ on first call), or undef
1348# when the write must be skipped (data_dir safety guard, mkdir failure).
1349sub _calc_models_path :Protected ($self) {
1350        my $app      = $self->app;
1351        my $home     = $app->home;
1352        my $cfg_dir  = $app->config('model_dir') // '.bi';
1353        my $bi_dir   = $home->child($cfg_dir);
1354        my $data_dir = $home->child($app->config('data_dir') // 'data');
1355
1356        if ($bi_dir->to_string eq $data_dir->to_string) {
1357                $app->log->warn('model_dir equals data_dir; skipping model file write');
1358                return undef;
1359        }
1360        unless (-d $bi_dir) {
1361                mkdir $bi_dir->to_string
1362                        or do { $app->log->warn("Cannot create $bi_dir: $!"); return undef };
1363        }
1364        return $bi_dir->child('models.json')->to_string;
1365
12
12
12
4001
12
41
}
1366
1367# _read_models($self) -> \%models
1368#
1369# Returns the parsed models hashref from .bi/models.json, or an empty scaffold.
1370sub _read_models :Protected ($self) {
1371        my $path = $self->_calc_models_path;
1372        my $scaffold = { version => 1, fields => {}, groups => {}, date_patterns => {} };
1373        return $scaffold unless defined $path && -f $path;
1374        my $json = eval { Mojo::File->new($path)->slurp };
1375        return $scaffold unless $json && !$@;
1376        my $data = eval { decode_json($json) };
1377        return $scaffold unless ref $data eq 'HASH';
1378        $data->{fields}        //= {};
1379        $data->{groups}        //= {};
1380        $data->{date_patterns} //= {};
1381        return $data;
1382
12
12
12
3114
9
41
}
1383
1384# _write_models($self, \%models) -- persists models to .bi/models.json
1385sub _write_models :Protected ($self, $models) {
1386        my $path = $self->_calc_models_path;
1387        return unless defined $path;
1388        eval { Mojo::File->new($path)->spurt(encode_json($models)) };
1389        $self->app->log->warn("Cannot write $path: $@") if $@;
1390
12
12
12
3055
11
35
}
1391
1392# _run_export_pipeline($self) -> ($records, \@columns, $left_label) or ()
1393#
1394# Purpose: Thin wrapper over _run_pipeline_source that additionally applies
1395#          f= filters, d=1 dedup, and page/per_page pagination.  Used by
1396#          export_data, export_write, folder_view, and import_url (row-viewing
1397#          routes that do not need the source object for aggregation).
1398# Entry:   Reads "l=", "j=" (repeatable), "c=" (repeatable), "f=" (repeatable),
1399#          "d=", "page=", "per_page=" query/body params.
1400# Exit:    On success: ($filtered_arrayref, \@column_names, $left_label_string).
1401#          On failure: empty list (left table not found / fetch error).
1402sub _run_export_pipeline :Protected ($self) {
1403        my (undef, $records, $columns, $left_label) = $self->_run_pipeline_source;
1404        return () unless defined $records;
1405
1406        my @columns = @{ $columns // [] };
1407
1408        for my $s (@{ $self->every_param('f') }) {
1409                $records = _apply_filter_spec($records, $s);
1410        }
1411
1412        $records = _dedup_records($records, \@columns) if $self->param('d');
1413
1414        # Sort support for /export: applied in memory after filtering/dedup.
1415        my $sort = $self->param('sort');
1416        my $dir  = ($self->param('dir') // 'asc') =~ /\Adesc\z/i ? 'desc' : 'asc';
1417        if (defined $sort && grep { $_ eq $sort } @columns) {
1418                @$records = sort { ($a->{$sort} // '') cmp ($b->{$sort} // '') } @$records;
1419                @$records = reverse @$records if $dir eq 'desc';
1420        } elsif (defined $sort) {
1421                carp $self->_i18n('error_sort_column', $sort);
1422        }
1423
1424        # Server-side pagination for /export.
1425        if (defined $self->param('page') || defined $self->param('per_page')) {
1426                my ($page, $per_page, $offset) = $self->_pagination;
1427                my $total    = scalar @$records;
1428                my $has_next = ($total > $offset + $per_page) ? 1 : 0;
1429                my @page_recs = splice(@$records, $offset, $per_page);
1430                $records = \@page_recs;
1431                $self->stash(export_has_next => $has_next, export_page => $page, export_per_page => $per_page);
1432        }
1433
1434        return ($records, \@columns, $left_label);
1435
12
12
12
5075
10
40
}
1436
1437# _decode_cell($s) -> $character_string
1438#
1439# Purpose: Ensure $s is a Perl character string, not raw UTF-8 bytes.
1440#          URL-fetched data (via LWP::UserAgent::Cached + D::A HTML parsing)
1441#          arrives as byte strings without the UTF-8 flag set.  encode_json
1442#          then treats each byte as a Latin-1 code point and re-encodes it,
1443#          producing mojibake (e.g. an o-umlaut character displayed as two garbage bytes).  This helper
1444#          upgrades the flag in-place on a copy so the original hash key is
1445#          unchanged but the value passed to HTML::D3 is a proper character.
1446# Entry:   $s -- any defined scalar (undef returned as-is).
1447# Exit:    The same string value, but with the UTF-8 flag set if it was absent.
1448sub _decode_cell {
1449
418
294
        my $s = shift;
1450
418
339
        return $s unless defined $s;
1451
418
576
        utf8::decode($s) unless utf8::is_utf8($s);
1452
418
501
        $s;
1453}
1454
1455# _serialize_csv($records, \@columns) -> $utf8_string
1456#
1457# Purpose: Build a complete RFC 4180 CSV document (header + data rows) from
1458#          $records.  Shared by _render_csv (HTTP download) and the csv branch
1459#          of export_write (filesystem write) to eliminate duplicated logic.
1460# Entry:   $records is an arrayref of hashrefs; $columns is an arrayref of names.
1461# Exit:    Returns a UTF-8 Perl string ending with CRLF.
1462#
1463# Optimisation: rows are collected in @lines and joined in one operation.
1464# The previous pattern ($out .= _csv_row(...) per row) triggers O(N) string
1465# reallocations; joining a pre-built array is a single O(total_bytes) allocation.
1466# For exports of thousands of rows the difference is measurable.
1467#
1468# XS note: for very large exports, replacing _csv_row with Text::CSV_XS
1469# (available from CPAN) would further reduce serialisation time -- its C-level
1470# implementation is roughly 5-10x faster than the pure-Perl version here.
1471sub _serialize_csv {
1472
67
8703
        my ($records, $columns) = @_;
1473
67
156
        my @lines = _csv_row(@$columns);
1474
67
101
        for my $row (@$records) {
1475
294
1608
219
1227
                push @lines, _csv_row(map { $row->{$_} } @$columns);
1476        }
1477
67
185
        return encode('UTF-8', join('', @lines));
1478}
1479
1480# _render_csv($self, $records, \@columns, $name) -> void (renders response)
1481#
1482# Purpose: Serialise $records to RFC 4180 CSV and emit as a UTF-8 download.
1483# Entry:   $name is a safe filename stem (alphanumeric/underscore).
1484# Exit:    Renders the Mojolicious response; does not return a meaningful value.
1485# Side Effects: Writes HTTP response headers and body.
1486sub _render_csv :Protected ($self, $records, $columns, $name) {
1487        $self->res->headers->content_type('text/csv; charset=UTF-8');
1488        $self->res->headers->content_disposition(qq{attachment; filename="${name}.csv"});
1489        $self->render(data => _serialize_csv($records, $columns));
1490
12
12
12
4369
81
58
}
1491
1492# _render_sqlite($self, $records, \@columns, $name) -> void (renders response)
1493#
1494# Purpose: Write $records to a SQLite database and emit it as a binary download.
1495# Entry:   $name is a safe filename stem.
1496# Exit:    Renders the Mojolicious response.
1497# Side Effects: Creates and unlinks a temporary file; writes HTTP response.
1498sub _render_sqlite :Protected ($self, $records, $columns, $name) {
1499        my $data = $self->_write_sqlite_db($records, $columns);
1500        $self->res->headers->content_type('application/vnd.sqlite3');
1501        $self->res->headers->content_disposition(qq{attachment; filename="${name}.db"});
1502        $self->render(data => $data);
1503
12
12
12
3132
10
53
}
1504
1505# _render_json($self, $records, \@columns, $name) -> void (renders response)
1506#
1507# Purpose: Emit $records as a JSON array download.
1508# Entry:   $records is an arrayref of hashrefs; $name is a safe filename stem.
1509# Exit:    Renders the Mojolicious response.
1510# Side Effects: Writes HTTP response headers and body.
1511sub _render_json :Protected ($self, $records, $columns, $name) {
1512        $self->res->headers->content_disposition(qq{attachment; filename="${name}.json"});
1513        $self->render(json => $records);
1514
12
12
12
2608
26
35
}
1515
1516# ---------------------------------------------------------------------------
1517# Public actions
1518# ---------------------------------------------------------------------------
1519
1520 - 1555
=head1 ACTIONS

=head2 index

C<GET /> -- Scan C<data_dir> and present a card grid of available tables.

=head3 API SPECIFICATION

=head4 INPUT

None (reads C<data_dir> from application config).

=head4 OUTPUT

Renders C<home.html.tt> with:

  tables     ARRAYREF of { name => $stem, file => $basename }
  title      'Choose a Database'

=head3 MESSAGES

None produced by this action; any file-scan errors are silently ignored
(empty directory yields an empty card grid).

=head3 FORMAL SPECIFICATION

  index == lambda self .
    let dir  = resolve self.app.config.data_dir in
    let tbls = { b | b in dir /\ basename(b) =~ EXT_RE } in
    post render(home, tables: map(stem, tbls))

=head3 EXAMPLE

  GET / -> 200 text/html containing a card for each file in data/

=cut
1556
1557
20
20
20
181207
24
16
sub index ($self) {
1558
20
70
        my ($platform, $language) = $self->_resolve_template;
1559
1560
20
86
        $self->render(
1561                template => "$platform/$language/home",
1562                handler  => 'tt',
1563                format   => 'html',
1564                tables   => $self->_scan_data_dir,
1565                title    => 'Choose a Database',
1566        );
1567}
1568
1569 - 1632
=head2 view

C<GET /view/:table> -- Open and display the chosen table from C<data_dir>.

=head3 API SPECIFICATION

=head4 INPUT

  :table   string   Table name (alphanumeric + underscore).  Returns 404 on
                    other characters.
  ?f=      string   (repeatable) Filter spec: "col:op:val".

=head4 DOMAIN CONSTRAINTS: :table

C<:table> is validated against C<\A[A-Za-z_][A-Za-z0-9_]*\z> before C<open_table>
is ever called.  The first character must be a letter (A-Z, a-z) or an
underscore; subsequent characters may also include digits (0-9).

=over 4

=item Valid partition

C<sales> (letters only), C<_temp> (underscore-start), C<report_2024>
(mixed).  A name that is valid but has no backing file returns 200 with
an error message -- NOT 404.

=item Invalid partition

C<1sales> (digit-start, 404), C<my.data> (dot, 404), C<my-data>
(hyphen, 404), non-ASCII characters (404).

=item Boundary values

C<a> (single letter, valid), C<_> (single underscore, valid), C<1>
(single digit, 404), C<a1> (letter then digit, valid), C<1a> (digit
then letter, 404).

=back

=head4 OUTPUT

On success renders C<dashboard.html.tt> with table data.
On error re-renders C<home.html.tt> with an C<error> stash variable.

=head3 MESSAGES

  error_table_open   -- DataSource initialisation or fetch_all threw.

=head3 FORMAL SPECIFICATION

  view == lambda self .
    pre  self.stash('table') =~ TABLE_NAME_RE
    let src     = open_table(table) in
    let records = src.fetch_all()   in
    let cols    = get_columns(src, records) in
    post render(dashboard, records: filter(records, f_params))

=head3 EXAMPLE

  GET /view/sales      -> 200 HTML table of all sales rows
  GET /view/sales?f=region:eq:North -> shows only North region rows
  GET /view/../etc     -> 404

=cut
1633
1634
110
110
110
1610500
149
110
sub view ($self) {
1635
110
369
        my ($platform, $language) = $self->_resolve_template;
1636
110
278
        my $table = $self->stash('table');
1637
1638
110
1109
        unless (defined $table && $table =~ $TABLE_NAME_RE) {
1639
14
128
                return $self->reply->not_found;
1640        }
1641
1642
96
856
        my ($page, $per_page, $offset) = $self->_pagination;
1643
1644
96
176
        my $dedup = $self->param('d') ? 1 : 0;
1645
96
1904
        my ($source, $records, $has_next, $is_multi);
1646
96
91
        eval {
1647
96
288
                $source = $self->open_table($table);
1648                # Multi-table SQLite: flag redirect without fetching data.
1649
96
1
368
3
                if ($source && $source->sqlite_tables && @{ $source->sqlite_tables } > 1) {
1650
1
1
                        $is_multi = 1;
1651                } else {
1652                        # _fetch_page applies reduce-before-paginate: full-fetch + filter + dedup
1653                        # + Perl paginate when f= or d=1 are set; N+1 trick otherwise.
1654
95
462
                        ($records, $has_next) = $self->_fetch_page($source,
1655                                { per_page => $per_page, offset => $offset, dedup => $dedup });
1656                }
1657        };
1658
96
11206
        if ($@) {
1659
10
82
                return $self->render(
1660                        template => "$platform/$language/home",
1661                        handler  => 'tt',
1662                        format   => 'html',
1663                        tables   => [],
1664                        title    => 'Choose a Database',
1665                        error    => $self->_i18n('error_table_open', $table, $@),
1666                );
1667        }
1668
1669
86
163
        if ($is_multi) {
1670
1
4
                return $self->redirect_to('/tables?l=' . url_escape("table:$table"));
1671        }
1672
1673
85
146
        $records //= [];
1674
85
195
        my @columns = _get_columns($source, $records);
1675        # _apply_filters on already-reduced records is a no-op for filtering but
1676        # correctly builds filter_specs (for export URLs) and filters_json (for UI).
1677
85
290
        my (undef, $filter_specs, $filters_json) = $self->_apply_filters($records);
1678
1679
85
176
        my $page_url = $self->req->url->clone;
1680
85
4888
        $page_url->query->remove('page')->remove('per_page');
1681
85
1555
        my $page_base_url = $page_url->to_string;
1682
1683
85
11130
        $self->render(
1684                template         => "$platform/$language/dashboard",
1685                handler          => 'tt',
1686                format           => 'html',
1687                records          => $records,
1688                columns          => \@columns,
1689                table            => $table,
1690                title            => ucfirst($table),
1691                left_spec        => "table:$table",
1692                combine_specs    => [],
1693                current_joins    => [],
1694                available_tables => $self->_scan_data_dir,
1695                join_summaries   => [],
1696                filter_specs     => $filter_specs,
1697                filters_json     => $filters_json,
1698                dedup            => $dedup,
1699                back_url         => _safe_back_url($self->param('back2')),
1700                back_label       => $self->param('back2_label') // 'Back',
1701                export_url       => $self->_build_export_url("table:$table", [], $filter_specs, undef, $dedup),
1702                page             => $page,
1703                per_page         => $per_page,
1704                has_next         => $has_next,
1705                page_base_url    => $page_base_url,
1706        );
1707}
1708
1709 - 1748
=head2 browse

C<GET /browse> -- Navigate the filesystem and pick a data file.

=head3 API SPECIFICATION

=head4 INPUT

  ?path=   string   Absolute filesystem path to browse (default: $HOME).
                    Returns 404 when the path does not exist or is not a
                    directory.

=head4 OUTPUT

Renders C<browse.html.tt> with:

  current_path   string
  parent_href    string or undef (undef at filesystem root)
  crumbs         ARRAYREF of { name, href }
  dirs           ARRAYREF of { name, href }   -- subdirectories, sorted
  files          ARRAYREF of { name, href }   -- supported data files, sorted

=head3 MESSAGES

None; errors render as 404.

=head3 FORMAL SPECIFICATION

  browse == lambda self .
    let dir = realpath(param('path') // HOME) in
    pre  is_dir(dir)
    post render(browse, dirs: subdirs(dir), files: supported_files(dir))

=head3 EXAMPLE

  GET /browse                            -> 200, lists $HOME
  GET /browse?path=/tmp                  -> 200, lists /tmp
  GET /browse?path=/nonexistent/xyz      -> 404

=cut
1749
1750
33
33
33
277526
48
31
sub browse ($self) {
1751
33
105
        my ($platform, $language) = $self->_resolve_template;
1752
1753
33
88
        my $raw_path = $self->param('path') // $ENV{HOME} // '/';
1754
33
33
5054
71
        my $dir      = eval { Mojo::File->new($raw_path)->realpath };
1755
33
2014
        return $self->reply->not_found unless defined $dir && -d $dir;
1756
1757
26
165
        my $listing = $self->_list_dir($dir, 1);
1758
1759        # Add browse hrefs to file entries (dirs already have path; browse needs href).
1760        my @dirs = map {
1761
15
110
                +{ name => $_->{name}, href => '/browse?path=' . url_escape($_->{path}) }
1762
26
26
38
51
        } @{ $listing->{dirs} };
1763
1764        my @files = map {
1765
46
305
                +{ name => $_->{name}, href => '/open?path=' . url_escape($_->{path}) }
1766
26
26
65
41
        } @{ $listing->{files} };
1767
1768        # Build breadcrumb trail from filesystem root down to $dir.
1769
26
205
        my @crumbs;
1770        {
1771
26
26
28
29
                my $f = $dir;
1772
26
25
                while (1) {
1773
86
100
                        my $name = $f->basename;
1774
86
933
                        $name    = '/' unless length $name;
1775
86
90
                        unshift @crumbs, { name => $name, href => '/browse?path=' . url_escape($f->to_string) };
1776
86
653
                        my $parent = $f->dirname;
1777
86
1121
                        last if $parent->to_string eq $f->to_string;
1778
60
227
                        $f = $parent;
1779                }
1780        }
1781
1782
26
119
        my $parent      = $dir->dirname;
1783
26
253
        my $parent_href = $parent->to_string ne $dir->to_string
1784                ? '/browse?path=' . url_escape($parent->to_string)
1785                : undef;
1786
1787
26
298
        $self->render(
1788                template     => "$platform/$language/browse",
1789                handler      => 'tt',
1790                format       => 'html',
1791                title        => 'Browse Files',
1792                current_path => $dir->to_string,
1793                parent_href  => $parent_href,
1794                crumbs       => \@crumbs,
1795                dirs         => \@dirs,
1796                files        => \@files,
1797        );
1798}
1799
1800 - 1870
=head2 open_file

C<GET /open> -- Open a supported data file from any absolute filesystem path.

=head3 API SPECIFICATION

=head4 INPUT

  ?path=   string   Absolute path to the data file.  Returns 404 when missing,
                    not a regular file, or the extension is not in SUPPORTED_EXT.
  ?f=      string   (repeatable) Filter spec: "col:op:val".

=head4 DOMAIN CONSTRAINTS: ?path=

=over 4

=item Extension filter (C<EXT_RE>)

The basename must match C<\.(?:csv|db|sql|xml|tsv|psv)\z> (case-insensitive).
The C<\z> anchor (absolute end-of-string) means a URL-encoded trailing
newline (e.g. C<file.csv%0A> decoded to C<file.csv\n>) does NOT pass --
the C<\n> falls after the C<\z> boundary and the extension check fails.

=item Valid partition

C</data/sales.csv> (lowercase extension), C</tmp/REPORT.CSV> (uppercase
extension, /i matches), any C<.db>, C<.sql>, C<.xml>, C<.tsv>, C<.psv> regular
file.

=item Invalid partition

Absent C<?path=> (404), non-existent file (404), directory instead of
file (404), unsupported extension such as C<.txt> (404), empty string
(404), path containing a C<%0A> (newline) suffix (404).

=item Double-extension filenames

A file like C<file.php.csv> passes C<EXT_RE> (last segment is C<.csv>)
but produces table stem C<file.php> which fails C<DataSource>'s
C<TABLE_NAME_RE>.  The C<open_table> call is inside C<eval>, so the
croak is caught and the action returns 200 with a friendly error, not
a 500.

=back

=head4 OUTPUT

On success renders C<dashboard.html.tt> with C<back_url> pointing to the
directory's C</browse> page, and C<file_path> set to the resolved absolute
path (used by the template to record the file in C<localStorage>).
On error re-renders C<home.html.tt> with C<error>, C<back_url>, C<back_label>.

=head3 MESSAGES

  error_file_open   -- DataSource threw during initialisation or fetch_all.

=head3 FORMAL SPECIFICATION

  open_file == lambda self .
    let file = realpath(param('path')) in
    pre  is_file(file) /\ basename(file) =~ EXT_RE
    let src = open_table(stem(file), dir=dirname(file)) in
    post render(dashboard, file_path: file.to_string)

=head3 EXAMPLE

  GET /open?path=/data/archive/sales.csv   -> 200, table view
  GET /open                                -> 404
  GET /open?path=/etc/passwd               -> 404

=cut
1871
1872
109
109
109
1573539
149
117
sub open_file ($self) {
1873
109
378
        my ($platform, $language) = $self->_resolve_template;
1874
109
297
        my $file_path = $self->param('path');
1875
1876
109
17792
        return $self->reply->not_found unless defined $file_path;
1877
1878
104
265
        my ($page, $per_page, $offset) = $self->_pagination;
1879
1880        # Bare redis:// URL (no table name) -- redirect to key-listing page.
1881
104
296
        if ($file_path =~ $REDIS_URL_RE) {
1882
0
0
                return $self->redirect_to('/tables?l=' . url_escape($file_path));
1883        }
1884
1885        # Redis spec: redis:tablename|redis://host:port -- delegate to _open_spec.
1886
104
769
        if ($file_path =~ $REDIS_SPEC_RE) {
1887
0
0
                my $lspec = $file_path;
1888
0
0
                my ($source, $label) = $self->_open_spec($lspec);
1889
0
0
                return $self->reply->not_found unless $source;
1890
0
0
                my $table = $label;
1891
0
0
                my $dedup = $self->param('d') ? 1 : 0;
1892
0
0
                my ($records, $has_next);
1893
0
0
                eval {
1894
0
0
                        ($records, $has_next) = $self->_fetch_page($source,
1895                                { per_page => $per_page, offset => $offset, dedup => $dedup });
1896                };
1897
0
0
                if ($@) {
1898
0
0
                        return $self->render(
1899                                template   => "$platform/$language/home",
1900                                handler    => 'tt',
1901                                format     => 'html',
1902                                tables     => [],
1903                                title      => 'Error',
1904                                error      => $self->_i18n('error_file_open', $label, $@),
1905                                back_url   => '/',
1906                                back_label => 'Home',
1907                        );
1908                }
1909
0
0
                $records //= [];
1910
0
0
                my @columns  = _get_columns($source, $records);
1911
0
0
                my (undef, $filter_specs, $filters_json) = $self->_apply_filters($records);
1912
0
0
                my $rpage_url = $self->req->url->clone;
1913
0
0
                $rpage_url->query->remove('page')->remove('per_page');
1914
0
0
                return $self->render(
1915                        template         => "$platform/$language/dashboard",
1916                        handler          => 'tt',
1917                        format           => 'html',
1918                        records          => $records,
1919                        columns          => \@columns,
1920                        table            => $table,
1921                        title            => $label,
1922                        back_url         => _safe_back_url($self->param('back')) // '/',
1923                        back_label       => $self->param('back_label') // 'Home',
1924                        back2_url        => _safe_back_url($self->param('back2')),
1925                        back2_label      => $self->param('back2_label') // 'Back',
1926                        file_path        => $file_path,
1927                        left_spec        => $lspec,
1928                        combine_specs    => [],
1929                        current_joins    => [],
1930                        available_tables => $self->_scan_data_dir,
1931                        join_summaries   => [],
1932                        filter_specs     => $filter_specs,
1933                        filters_json     => $filters_json,
1934                        dedup            => $dedup,
1935                        export_url       => $self->_build_export_url($lspec, [], $filter_specs, undef, $dedup),
1936                        page             => $page,
1937                        per_page         => $per_page,
1938                        has_next         => $has_next,
1939                        page_base_url    => $rpage_url->to_string,
1940                );
1941        }
1942
1943        # Remote path /../hostname/dir/file.ext: delegate opening to _open_spec
1944        # and render the dashboard directly, bypassing the realpath/local-file path.
1945
104
738
        if ($file_path =~ m{\A/\.\./}) {
1946
4
6
                my $lspec = 'path:' . $file_path;
1947
4
12
                my ($source, $label) = $self->_open_spec($lspec);
1948
4
11
                return $self->reply->not_found unless $source;
1949
3
5
                my $filename = $label;
1950
3
11
                (my $table = $filename) =~ s/\.[^.]+\z//;
1951
3
6
                my $dedup = $self->param('d') ? 1 : 0;
1952
3
111
                my ($records, $has_next);
1953
3
3
                eval {
1954
3
12
                        ($records, $has_next) = $self->_fetch_page($source,
1955                                { per_page => $per_page, offset => $offset, dedup => $dedup });
1956                };
1957
3
6
                if ($@) {
1958
0
0
                        return $self->render(
1959                                template   => "$platform/$language/home",
1960                                handler    => 'tt',
1961                                format     => 'html',
1962                                tables     => [],
1963                                title      => 'Error',
1964                                error      => $self->_i18n('error_file_open', $filename, $@),
1965                                back_url   => '/',
1966                                back_label => 'Home',
1967                        );
1968                }
1969
3
7
                $records //= [];
1970
3
9
                my @columns  = _get_columns($source, $records);
1971
3
11
                my (undef, $filter_specs, $filters_json) = $self->_apply_filters($records);
1972
3
6
                my $rpage_url = $self->req->url->clone;
1973
3
159
                $rpage_url->query->remove('page')->remove('per_page');
1974
3
58
                return $self->render(
1975                        template         => "$platform/$language/dashboard",
1976                        handler          => 'tt',
1977                        format           => 'html',
1978                        records          => $records,
1979                        columns          => \@columns,
1980                        table            => $table,
1981                        title            => $filename,
1982                        back_url         => '/',
1983                        back_label       => 'Home',
1984                        back2_url        => _safe_back_url($self->param('back2')),
1985                        back2_label      => $self->param('back2_label') // 'Back',
1986                        file_path        => $file_path,
1987                        left_spec        => $lspec,
1988                        combine_specs    => [],
1989                        current_joins    => [],
1990                        available_tables => $self->_scan_data_dir,
1991                        join_summaries   => [],
1992                        filter_specs     => $filter_specs,
1993                        filters_json     => $filters_json,
1994                        dedup            => $dedup,
1995                        export_url       => $self->_build_export_url($lspec, [], $filter_specs, undef, $dedup),
1996                        page             => $page,
1997                        per_page         => $per_page,
1998                        has_next         => $has_next,
1999                        page_base_url    => $rpage_url->to_string,
2000                );
2001        }
2002
2003
100
100
93
206
        my $file = eval { Mojo::File->new($file_path)->realpath };
2004
100
3739
        return $self->reply->not_found
2005                unless defined $file && -f $file && $file->basename =~ $EXT_RE;
2006
2007
82
2972
        my $dir      = $file->dirname;
2008
82
1676
        (my $table   = $file->basename) =~ s/\.[^.]+\z//;
2009
82
909
        my $back     = '/browse?path=' . url_escape($dir->to_string);
2010
82
1089
        my $filename = $file->basename;
2011
82
710
        my $lspec    = 'path:' . $file->to_string;
2012
2013
82
249
        my $it = $self->param('it'); # optional: select a specific internal table
2014
2015        # Allow the tables view to override the default "Back to browser" link.
2016
82
1786
        my $back_override = _safe_back_url($self->param('back'));
2017
82
161
        if ($back_override) {
2018
1
2
                $back = $back_override;
2019        }
2020
82
167
        my $back_label_default = $back_override ? ($self->param('back_label') // 'Back') : 'Back to browser';
2021
2022
82
144
        my $dedup = $self->param('d') ? 1 : 0;
2023
82
1596
        my ($source, $records, $has_next, $is_multi_local);
2024
82
90
        eval {
2025
82
130
                $source = $self->open_table($table, directory => $dir->to_string,
2026                        defined $it ? (internal_table => $it) : ());
2027                # Multi-table SQLite without it=: flag redirect without fetching data.
2028
82
546
                if (!defined $it && $source && $source->sqlite_tables
2029
24
36
                                && @{ $source->sqlite_tables } > 1) {
2030
1
1
                        $is_multi_local = 1;
2031                } else {
2032
81
373
                        ($records, $has_next) = $self->_fetch_page($source,
2033                                { per_page => $per_page, offset => $offset, dedup => $dedup });
2034                }
2035        };
2036
82
9861
        if ($@) {
2037                # Provide a SQLite-specific hint when the error looks like a table-name
2038                # mismatch or a missing-tables failure so the user knows what to do.
2039
5
21
                my $hint;
2040
5
18
                if ($filename =~ /\.(?:sql|db)\z/i) {
2041
1
2
                        $hint = 'SQLite files must contain at least one user-defined table. '
2042                                . 'The table name inside the database does not need to match the '
2043                                . 'filename -- the application auto-detects and uses the first table found.';
2044                }
2045
5
24
                return $self->render(
2046                        template    => "$platform/$language/home",
2047                        handler     => 'tt',
2048                        format      => 'html',
2049                        tables      => [],
2050                        title       => 'Error',
2051                        error       => $self->_i18n('error_file_open', $filename, $@),
2052                        hint        => $hint,
2053                        back_url    => $back,
2054                        back_label  => $back_label_default,
2055                        back2_url   => _safe_back_url($self->param('back2')),
2056                        back2_label => $self->param('back2_label') // 'Back',
2057                );
2058        }
2059
2060
77
140
        if ($is_multi_local) {
2061
1
1
1
3
                return $self->redirect_to('/tables?l=' . url_escape("path:${\$file->to_string}"));
2062        }
2063
2064
76
128
        $records //= [];
2065
76
218
        my @columns  = _get_columns($source, $records);
2066
76
222
        my (undef, $filter_specs, $filters_json) = $self->_apply_filters($records);
2067
2068
76
147
        my $lpage_url = $self->req->url->clone;
2069
76
4098
        $lpage_url->query->remove('page')->remove('per_page');
2070
2071
76
1594
        $self->render(
2072                template         => "$platform/$language/dashboard",
2073                handler          => 'tt',
2074                format           => 'html',
2075                records          => $records,
2076                columns          => \@columns,
2077                table            => defined $it ? $it : $table,
2078                title            => defined $it ? "$filename ($it)" : $filename,
2079                back_url         => $back,
2080                back_label       => $back_label_default,
2081                back2_url        => _safe_back_url($self->param('back2')),
2082                back2_label      => $self->param('back2_label') // 'Back',
2083                file_path        => $file->to_string,
2084                left_spec        => $lspec,
2085                combine_specs    => [],
2086                current_joins    => [],
2087                available_tables => $self->_scan_data_dir,
2088                join_summaries   => [],
2089                filter_specs     => $filter_specs,
2090                filters_json     => $filters_json,
2091                dedup            => $dedup,
2092                export_url       => $self->_build_export_url($lspec, [], $filter_specs, undef, $dedup),
2093                page             => $page,
2094                per_page         => $per_page,
2095                has_next         => $has_next,
2096                page_base_url    => $lpage_url->to_string,
2097        );
2098}
2099
2100 - 2135
=head2 import_url

C<GET /import> -- Fetch a remote HTML page, extract the first (or selected)
table, and render it as a sortable data grid.

=head3 API SPECIFICATION

=head4 INPUT

  ?url=          string   Required.  Full http:// or https:// URL of the page
                          that contains the HTML table.  Returns home with an
                          error message if the scheme is wrong, the fetch fails,
                          or no table is found at the specified index.
  ?table_index=  integer  Zero-based index of the C<< <table> >> to use when
                          the page contains more than one table (default: 0).
  ?f=            string   (repeatable) Filter spec: "col:op:val".

=head4 OUTPUT

On success renders C<dashboard.html.tt> with the same stash shape as C<open_file>,
plus C<source_url> (the original URL) for C<localStorage> tracking.

On error re-renders C<home.html.tt> with an C<error> stash variable.

=head3 MESSAGES

  error_url_required  -- empty or missing C<url> param
  error_url_invalid   -- URL does not begin with http:// or https://
  error_url_fetch     -- LWP fetch failure or no table found at the index

=head3 EXAMPLE

  GET /import?url=https://matrix.perl-magpie.org/dist/Database-Abstraction/
  GET /import?url=https://example.com/data.html&table_index=2

=cut
2136
2137
30
30
30
377624
31
31
sub import_url ($self) {
2138
30
82
        my ($platform, $language) = $self->_resolve_template;
2139
2140
30
74
        my $url = $self->param('url') // '';
2141
30
4519
        my $idx = $self->param('table_index') // 0;
2142
30
734
        $idx    = 0 unless $idx =~ /\A[0-9]+\z/;
2143
2144
24
24
24
24
31
30
26
23
        my $err_home = sub ($key, @args) {
2145
24
69
                $self->render(
2146                        template => "$platform/$language/home",
2147                        handler  => 'tt',
2148                        format   => 'html',
2149                        tables   => $self->_scan_data_dir,
2150                        title    => 'Choose a Database',
2151                        error    => $self->_i18n($key, @args),
2152                );
2153
30
76
        };
2154
2155
30
56
        return $err_home->('error_url_required') unless length $url;
2156
25
131
        return $err_home->('error_url_invalid', $url)
2157                unless $url =~ m{\Ahttps?://}i;
2158
19
41
        return $err_home->('error_url_ssrf', $url)
2159                unless _is_safe_url($url);
2160
2161
8
8
9
26
        my $source = eval { $self->open_table('', url => $url, html_table_index => $idx) };
2162
8
368
        return $err_home->('error_url_fetch', $url, $@ // 'unknown error') if $@ || !$source;
2163
2164
7
7
9
19
        my $records = eval { $source->fetch_all };
2165
7
222
        return $err_home->('error_url_fetch', $url, $@ // 'empty result') if $@;
2166
2167
6
17
        my $label   = $source->table_name;
2168
6
10
        my $lspec   = "url:$url";
2169
6
16
        my @columns = _get_columns($source, $records);
2170
6
19
        my ($filtered, $filter_specs, $filters_json) = $self->_apply_filters($records);
2171
6
11
        my $dedup = $self->param('d') ? 1 : 0;
2172
6
143
        $filtered = _dedup_records($filtered, \@columns) if $dedup;
2173
2174
6
11
        my $self_url = '/import?url=' . url_escape($url);
2175
6
78
        $self_url   .= '&table_index=' . $idx if $idx;
2176
2177
6
19
        $self->render(
2178                template         => "$platform/$language/dashboard",
2179                handler          => 'tt',
2180                format           => 'html',
2181                records          => $filtered,
2182                columns          => \@columns,
2183                table            => $label,
2184                title            => $label,
2185                source_url       => $url,
2186                back_url         => $self_url,
2187                back_label       => $label,
2188                back2_url        => _safe_back_url($self->param('back2')),
2189                back2_label      => $self->param('back2_label') // 'Back',
2190                left_spec        => $lspec,
2191                combine_specs    => [],
2192                current_joins    => [],
2193                available_tables => $self->_scan_data_dir,
2194                join_summaries   => [],
2195                filter_specs     => $filter_specs,
2196                filters_json     => $filters_json,
2197                dedup            => $dedup,
2198                export_url       => $self->_build_export_url($lspec, [], $filter_specs, undef, $dedup),
2199        );
2200}
2201
2202 - 2243
=head2 columns_api

C<GET /api/columns> -- Return column names for a table or file as JSON.

Used by the join UI to populate the right-key dropdown without a page reload.

=head3 API SPECIFICATION

=head4 INPUT

  ?table=  string   Table name from C<data_dir>.
  ?path=   string   Absolute path to a data file.
  ?spec=   string   Unified spec: C<"table:name"> or C<"path:/abs/path">.
                    Parsed only when C<table> and C<path> are both absent.

One of C<table>, C<path>, or C<spec> must be present; C<table> takes
precedence over C<path>, and both take precedence over C<spec>.

=head4 OUTPUT

  200 application/json   { "columns": ["col1", "col2", ...] }
  404 application/json   { "error": "not found" }

=head3 MESSAGES

None produced by this action directly; internal errors yield a 404.

=head3 FORMAL SPECIFICATION

  columns_api == lambda self .
    let src = open_spec(table_param or path_param or parse_spec(spec_param)) in
    pre  src /= undef
    post render_json({ columns: get_columns(src) })

=head3 EXAMPLE

  GET /api/columns?table=sales            -> {"columns":["product","region","amount"]}
  GET /api/columns?spec=table:sales       -> {"columns":["product","region","amount"]}
  GET /api/columns?spec=path:/data/f.csv  -> {"columns":[...]}
  GET /api/columns?table=noexist          -> 404

=cut
2244
2245
22
22
22
205084
26
21
sub columns_api ($self) {
2246
22
50
        my $table_name = $self->param('table');
2247
22
2649
        my $path       = $self->param('path');
2248
2249        # Accept the unified spec format used by /join and /graph:
2250        # "table:name" or "path:/abs/path" -- mirrors _open_spec's parsing.
2251
22
514
        if (!defined($table_name) && !defined($path)) {
2252
7
13
                my $spec = $self->param('spec') // '';
2253
7
167
                if ($spec =~ /\Atable:([A-Za-z_][A-Za-z0-9_]*)\z/) {
2254
3
5
                        $table_name = $1;
2255                } elsif ($spec =~ /\Apath:(.+)\z/s) {
2256
3
5
                        $path = $1;
2257                }
2258        }
2259
2260
22
23
        my $source;
2261
22
129
        if (defined $table_name && $table_name =~ $TABLE_NAME_RE) {
2262
14
108
                my $data_dir = $self->app->home->child($self->app->config->{data_dir} // 'data');
2263                return $self->render(json => { error => 'not found' }, status => 404)
2264
14
126
320
1314
                        unless grep { -f $data_dir->child(lc($table_name) . ".$_") } @SUPPORTED_EXT;
2265
11
11
122
26
                $source = eval { $self->open_table(lc $table_name, directory => $data_dir->to_string) };
2266        }
2267        elsif (defined $path) {
2268
6
24
                if ($path =~ m{\A/\.\./([^/]+)((?:/.+)?)/([^/]+)\z}) {
2269
2
5
                        my ($host, $dir_part, $file) = ($1, $2 // '', $3);
2270
2
4
                        $dir_part = '/' unless length $dir_part;
2271
2
12
                        if ($host =~ $REMOTE_HOST_RE) {
2272
2
17
                                my ($tbl, $ext) = $file =~ /\A(.+)\.([^.]+)\z/ ? ($1, $2) : ($file, undef);
2273
2
2
2
8
                                $source = eval { $self->open_table($tbl, directory => $dir_part, host => $host,
2274                                        defined $ext ? (file_ext => $ext) : ()) };
2275                        }
2276                } else {
2277
4
4
6
9
                        my $file = eval { Mojo::File->new($path)->realpath };
2278
4
96
                        if (defined $file && -f $file && $file->basename =~ $EXT_RE) {
2279
3
128
                                my $dir = $file->dirname->to_string;
2280
3
71
                                (my $tbl = $file->basename) =~ s/\.[^.]+\z//;
2281
3
3
32
7
                                $source = eval { $self->open_table($tbl, directory => $dir) };
2282                        }
2283                }
2284        }
2285
2286
19
94
        return $self->render(json => { error => 'not found' }, status => 404) unless $source;
2287
2288        # Use _get_columns to ensure consistent ordering with the view action.
2289
16
0
43
0
        my $recs = ($source->columns ? [] : eval { $source->fetch_all } // []);
2290
16
2352
        my @cols = _get_columns($source, $recs);
2291
2292
16
58
        $self->render(json => { columns => \@cols });
2293}
2294
2295 - 2344
=head2 join_tables

C<GET /join> -- Perform one or more left joins and render the merged table.

=head3 API SPECIFICATION

=head4 INPUT

  ?l=    string   Required. Left table spec: "table:name" or "path:/abs/path".
                  Returns 404 if unresolvable.
  ?j=    string   (repeatable) Join step: "<right-spec>|<left-key>|<right-key>".
                  Invalid or non-existent steps are silently skipped.
  ?f=    string   (repeatable) Result filter: "col:op:val".

=head4 OUTPUT

Renders C<dashboard.html.tt> with the merged/filtered result set.

=head3 MESSAGES

  error_table_open   -- Left table fetch threw (re-renders home with error).

=head3 FORMAL SPECIFICATION

  join_tables == lambda self .
    let left = open_spec(param('l')) in
    pre  left /= undef
    let joined  = fold(_left_join, left, param_list('j')) in
    let filtered = fold(_apply_filter_spec, joined, param_list('f')) in
    post render(dashboard, records: filtered)

=head3 PSEUDOCODE

  1. Parse and open left table spec; 404 on failure.
  2. For each j= param (in order):
       a. Parse right-spec, left-key, right-key from "|"-split.
       b. Verify left-key exists in current column list.
       c. Open right table; skip step on any error.
       d. Verify right-key exists in right column list.
       e. Call _left_join; update records and columns.
  3. Apply all f= filters via _apply_filter_spec.
  4. Build stable table key for localStorage ("join:left:right1:...").
  5. Render dashboard.

=head3 EXAMPLE

  GET /join?l=table:sales&j=table:products|product|name
    -> merged left join of sales and products on product/name columns

=cut
2345
2346
29
29
29
346221
49
29
sub join_tables ($self) {
2347
29
97
        my ($platform, $language) = $self->_resolve_template;
2348
2349
29
77
        my $left_spec = $self->param('l') // '';
2350
29
5046
        my ($left_src, $left_label) = $self->_open_spec($left_spec);
2351
29
226
        return $self->reply->not_found unless $left_src;
2352
2353
22
22
25
55
        my @join_specs = @{ $self->every_param('j') };
2354
22
778
        my @summaries;
2355
2356        # Build the join chain: each step wraps the previous source in a
2357        # Database::Join.  Data is not fetched until after the loop.
2358
22
41
        my $max_rows  = $self->app->config('join_max_rows') // $DEFAULT_JOIN_MAX_ROWS;
2359
22
344
        my $join_type = $self->param('jtype') // 'left';
2360
22
577
        $join_type    = 'left' unless $join_type =~ /\A(?:left|inner|outer)\z/;
2361
22
26
        my $src = $left_src;
2362
22
28
        my @schema_warnings;
2363
22
34
        for my $jspec (@join_specs) {
2364
11
39
                my ($right_spec, $left_key, $right_key) = split /\|/, $jspec, 3;
2365
11
55
                next unless defined $right_spec && defined $left_key && defined $right_key;
2366
2367
11
28
                my $cur_cols = $src->columns;
2368
11
23
                next unless $cur_cols;
2369
11
57
17
77
                next unless +{ map { $_ => 1 } @$cur_cols }->{$left_key};
2370
2371
11
33
                my ($right_src, $right_label) = $self->_open_spec($right_spec);
2372
11
100
                next unless $right_src;
2373
2374
9
17
                my $right_cols = $right_src->columns;
2375
9
19
                next unless $right_cols;
2376
9
32
15
49
                next unless +{ map { $_ => 1 } @$right_cols }->{$right_key};
2377
2378
9
1929
                require Database::Join;
2379
9
0
75023
0
                local $SIG{__WARN__} = sub { push @schema_warnings, $_[0] };
2380
9
74
                $src = Database::Join->new(
2381                        databases        => [$src, $right_src],
2382                        join_column      => $left_key,
2383                        backend          => 'auto',
2384                        max_array_rows   => $max_rows,
2385                        join_type        => $join_type,
2386                        ($left_key ne $right_key ? (join_map         => {1 => $right_key})   : ()),
2387                        ($right_label             ? (collision_prefix => {1 => $right_label}) : ()),
2388                );
2389
9
5641
                push @summaries, { label => $right_label, left_key => $left_key, right_key => $right_key };
2390        }
2391
2392
22
63
        my ($page, $per_page, $offset) = $self->_pagination;
2393
22
44
        my $dedup  = $self->param('d') ? 1 : 0;
2394
22
459
        my $sort   = $self->param('sort');
2395
22
418
        my $dir    = ($self->param('dir') // 'asc') =~ /\Adesc\z/i ? 'desc' : 'asc';
2396
2397
22
488
        my ($records, $has_next);
2398
22
21
        eval {
2399
22
113
                ($records, $has_next) = $self->_fetch_page($src,
2400                        { per_page  => $per_page, offset   => $offset,
2401                          dedup     => $dedup,    sort_col => $sort,
2402                          sort_dir  => $dir });
2403        };
2404
22
50
        if ($@) {
2405
0
0
                return $self->render(
2406                        template => "$platform/$language/home",
2407                        handler  => 'tt',
2408                        format   => 'html',
2409                        tables   => [],
2410                        title    => 'Error',
2411                        error    => $self->_i18n('error_table_open', $left_label, $@),
2412                );
2413        }
2414
22
43
        $records //= [];
2415
22
57
        my @columns = _get_columns($src, $records);
2416
2417
22
6
67
10
        if (defined $sort && !grep { $_ eq $sort } @columns) {
2418
0
0
                carp $self->_i18n('error_sort_column', $sort);
2419        }
2420
2421
22
69
        my (undef, $filter_specs, $filters_json) = $self->_apply_filters($records);
2422
2423
22
33
        my $title     = $left_label;
2424
22
9
52
25
        $title       .= ' + ' . join(' + ', map { $_->{label} } @summaries) if @summaries;
2425
22
35
        my $table_key = 'join:' . lc($left_label);
2426
22
46
        $table_key   .= ':' . lc($_->{label}) for @summaries;
2427
2428
22
33
        my $page_url = $self->req->url->clone;
2429
22
1431
        $page_url->query->remove('page')->remove('per_page');
2430
2431
22
478
        $self->render(
2432                template         => "$platform/$language/dashboard",
2433                handler          => 'tt',
2434                format           => 'html',
2435                records          => $records,
2436                columns          => \@columns,
2437                table            => $table_key,
2438                title            => $title,
2439                back_url         => $self->_spec_to_url($left_spec),
2440                back_label       => "Back to $left_label",
2441                back2_url        => _safe_back_url($self->param('back2')),
2442                back2_label      => $self->param('back2_label') // 'Back',
2443                left_spec        => $left_spec,
2444                combine_specs    => [],
2445                current_joins    => \@join_specs,
2446                available_tables => $self->_scan_data_dir,
2447                join_summaries   => \@summaries,
2448                filter_specs     => $filter_specs,
2449                filters_json     => $filters_json,
2450                dedup            => $dedup,
2451                export_url       => $self->_build_export_url($left_spec, \@join_specs, $filter_specs, undef, $dedup),
2452                schema_warnings  => \@schema_warnings,
2453                page             => $page,
2454                per_page         => $per_page,
2455                has_next         => $has_next,
2456                page_base_url    => $page_url->to_string,
2457        );
2458}
2459
2460 - 2509
=head2 combine_tables

C<GET /combine> -- Stack rows from two or more tables vertically into a single
unified view.

Unlike C</join> (which appends columns from a matching row in a second table),
C</combine> appends the I<rows> from a second table beneath the rows of the
first.  All columns from all sources appear as headers; where a row has no
value for a particular column (because that column did not exist in its source
file) the cell is left blank.

This is equivalent to a SQL C<UNION ALL> across heterogeneous schemas.

=head3 API SPECIFICATION

=head4 INPUT

  ?l=    string   (required) Left table spec: "table:name" or "path:/abs/path".
                  Returns 404 if unresolvable.
  ?c=    string   (repeatable) Additional table spec to stack beneath the
                  current result.  Invalid or non-existent specs are silently
                  skipped.
  ?f=    string   (repeatable) Result filter: "col:op:val".

=head4 OUTPUT

Renders C<dashboard.html.tt> with the combined result set.

=head3 MESSAGES

  error_table_open   -- Left table fetch threw (re-renders home with error).

=head3 FORMAL SPECIFICATION

  combine_tables == lambda self .
    let left = open_spec(param('l')) in
    pre  left /= undef
    let sources  = [left] ++ map(open_spec, param_list('c')) in
    let combined = _combine_tables(sources) in
    let filtered = fold(_apply_filter_spec, combined, param_list('f')) in
    post render(dashboard, records: filtered)

=head3 EXAMPLE

  GET /combine?l=table:cats&c=table:dogs
    -> unified view: all cat and dog rows; Species/Name/Color/Breed/Eye Color
       appear for both; Environment (cats-only) and Sex/Fixed (dogs-only)
       are blank where the source file lacks that column.

=cut
2510
2511
18
18
18
224660
24
17
sub combine_tables ($self) {
2512
18
64
        my ($platform, $language) = $self->_resolve_template;
2513
2514
18
50
        my $left_spec = $self->param('l') // '';
2515
18
3241
        my ($left_src, $left_label) = $self->_open_spec($left_spec);
2516
18
153
        return $self->reply->not_found unless $left_src;
2517
2518
14
14
14
41
        my $left_recs = eval { $left_src->fetch_all };
2519
14
28
        if ($@) {
2520
0
0
                return $self->render(
2521                        template => "$platform/$language/home",
2522                        handler  => 'tt',
2523                        format   => 'html',
2524                        tables   => [],
2525                        title    => 'Error',
2526                        error    => $self->_i18n('error_table_open', $left_label, $@),
2527                );
2528        }
2529
14
29
        $left_recs //= [];
2530
2531
14
94
        my @left_cols     = _get_columns($left_src, $left_recs);
2532
14
14
16
33
        my @combine_specs = @{ $self->every_param('c') };
2533
2534
14
527
        my @sources   = ([$left_recs, \@left_cols]);
2535
14
14
        my @summaries;
2536
2537
14
21
        for my $cspec (@combine_specs) {
2538
12
24
                my ($csrc, $clabel) = $self->_open_spec($cspec);
2539
12
135
                next unless $csrc;
2540
11
11
11
24
                my $crecs = eval { $csrc->fetch_all } // [];
2541
11
18
                next if $@;
2542
11
22
                my @ccols = _get_columns($csrc, $crecs);
2543
11
21
                push @sources,   [$crecs, \@ccols];
2544
11
48
                push @summaries, { label => $clabel };
2545        }
2546
2547
14
630
        my ($records, $cols_ref) = @sources > 1
2548                ? _combine_tables(\@sources)
2549                : ($left_recs, \@left_cols);
2550
14
29
        my @columns = @$cols_ref;
2551
2552        # Apply f= filters in memory before paginating (reduce-before-paginate).
2553
14
14
18
30
        for my $s (@{ $self->every_param('f') // [] }) {
2554
2
77
                $records = _apply_filter_spec($records, $s);
2555        }
2556
14
410
        my $dedup = $self->param('d') ? 1 : 0;
2557
14
323
        $records = _dedup_records($records, \@columns) if $dedup;
2558
2559        # In-memory sort for /combine (no single backend to delegate to).
2560
14
24
        my $sort = $self->param('sort');
2561
14
277
        my $dir  = ($self->param('dir') // 'asc') =~ /\Adesc\z/i ? 'desc' : 'asc';
2562
14
4
325
6
        if (defined $sort && grep { $_ eq $sort } @columns) {
2563
2
6
                carp $self->_i18n('warn_combine_sort');
2564
2
10
376
28
                @$records = sort { ($a->{$sort} // '') cmp ($b->{$sort} // '') } @$records;
2565
2
6
                @$records = reverse @$records if $dir eq 'desc';
2566        } elsif (defined $sort) {
2567
0
0
                carp $self->_i18n('error_sort_column', $sort);
2568        }
2569
2570        # Server-side pagination: slice the fully-reduced combined set.
2571
14
35
        my ($page, $per_page, $offset) = $self->_pagination;
2572
14
19
        my $total    = scalar @$records;
2573
14
29
        my $has_next = ($total > $offset + $per_page) ? 1 : 0;
2574
14
28
        my @page_recs = splice(@$records, $offset, $per_page);
2575
14
20
        $records = \@page_recs;
2576
2577
14
37
        my (undef, $filter_specs, $filters_json) = $self->_apply_filters($records);
2578
2579
14
17
        my $title     = $left_label;
2580
14
11
39
30
        $title       .= ' + ' . join(' + ', map { $_->{label} } @summaries) if @summaries;
2581
14
23
        my $table_key = 'combine:' . lc($left_label);
2582
14
32
        $table_key   .= ':' . lc($_->{label}) for @summaries;
2583
2584
14
22
        my $page_url = $self->req->url->clone;
2585
14
782
        $page_url->query->remove('page')->remove('per_page');
2586
2587
14
297
        $self->render(
2588                template         => "$platform/$language/dashboard",
2589                handler          => 'tt',
2590                format           => 'html',
2591                records          => $records,
2592                columns          => \@columns,
2593                table            => $table_key,
2594                title            => $title,
2595                back_url         => $self->_spec_to_url($left_spec),
2596                back_label       => "Back to $left_label",
2597                back2_url        => _safe_back_url($self->param('back2')),
2598                back2_label      => $self->param('back2_label') // 'Back',
2599                left_spec        => $left_spec,
2600                combine_specs    => \@combine_specs,
2601                current_joins    => [],
2602                available_tables => $self->_scan_data_dir,
2603                join_summaries   => \@summaries,
2604                filter_specs     => $filter_specs,
2605                filters_json     => $filters_json,
2606                dedup            => $dedup,
2607                export_url       => $self->_build_export_url($left_spec, [], $filter_specs, \@combine_specs, $dedup),
2608                page             => $page,
2609                per_page         => $per_page,
2610                has_next         => $has_next,
2611                page_base_url    => $page_url->to_string,
2612        );
2613}
2614
2615 - 2671
=head2 export_data

C<GET /export> -- Stream the current logical view as a browser file download.

=head3 API SPECIFICATION

=head4 INPUT

  ?l=        string   (required) Left table spec.
  ?j=        string   (repeatable) Join steps.
  ?f=        string   (repeatable) Filter specs.
  ?format=   string   "csv" (default), "sqlite", or "json".

=head4 DOMAIN CONSTRAINTS: ?format=

Exact lowercase string match.

=over 4

=item Valid partitions

C<csv> (explicit CSV), C<sqlite> (SQLite binary), C<json> (JSON array),
absent/undef (defaults to CSV).

=item Invalid partitions (all fall back to CSV)

Any value not in the valid set (e.g. C<SQLITE>, C<Json>).

=back

=head4 OUTPUT

  200 text/csv                  Content-Disposition: attachment; filename=<name>.csv
  200 application/vnd.sqlite3   Content-Disposition: attachment; filename=<name>.db
  200 application/json          Content-Disposition: attachment; filename=<name>.json
  404 application/json          { "error": "Table not found" }

=head3 MESSAGES

None beyond the 404 response.

=head3 FORMAL SPECIFICATION

  export_data == lambda self .
    let (recs, cols, label) = _run_export_pipeline() in
    pre  recs /= undef
    post if format = 'sqlite' then render_sqlite(recs, cols)
         else if format = 'json' then render_json(recs, cols)
         else render_csv(recs, cols)

=head3 EXAMPLE

  GET /export?l=table:sales&format=csv     -> downloads sales.csv
  GET /export?l=table:sales&format=sqlite  -> downloads sales.db
  GET /export?l=table:sales&format=json    -> downloads sales.json

=cut
2672
2673
55
55
55
538307
73
59
sub export_data ($self) {
2674
55
173
        my ($records, $columns, $left_label) = $self->_run_export_pipeline;
2675
55
128
        return $self->reply->not_found unless $records;
2676
2677        # Surface schema-type-mismatch warnings in a response header so API
2678        # consumers (and the user via browser dev tools) can see them even
2679        # though exports render no HTML template.
2680
49
88
        my $warnings = $self->stash('schema_warnings') // [];
2681
49
325
        if (@$warnings) {
2682
0
0
0
0
                my $hdr = join('; ', map { s/\s+/ /gr =~ s/^\s+|\s+$//gr } @$warnings);
2683
0
0
                $self->res->headers->header('X-BI-Warnings' => $hdr);
2684        }
2685
2686
49
85
        my $format = $self->param('format') // 'csv';
2687
49
1113
        (my $safe_name = lc $left_label) =~ s/[^a-z0-9_]+/_/g;
2688
2689
49
228
        return $format eq 'sqlite' ? $self->_render_sqlite($records, $columns, $safe_name)
2690             : $format eq 'json'   ? $self->_render_json($records, $columns, $safe_name)
2691             :                       $self->_render_csv($records, $columns, $safe_name);
2692}
2693
2694 - 2763
=head2 export_write

C<POST /export> -- Write the current logical view to a chosen filesystem path.

=head3 API SPECIFICATION

=head4 INPUT

  l=         string   (required) Left table spec.
  j=         string   (repeatable) Join steps.
  f=         string   (repeatable) Filter specs.
  dir=       string   Target directory (must exist; resolved via realpath).
  filename=  string   Output filename including extension.  Extension determines
                      format: C<.csv> -> RFC 4180 CSV; C<.sql> -> SQLite;
                      C<.json> -> JSON array of row objects.

=head4 DOMAIN CONSTRAINTS: filename=

The extension check uses C</\.csv\z/i> (CSV), C</\.sql\z/i> (SQLite), or
C</\.json\z/i> (JSON): the C</i> flag makes matching case-insensitive, so
C<.CSV>, C<.SQL>, and C<.JSON> are accepted alongside lowercase forms.
Everything else returns 415.

Path separator characters (C</> and C<\>) in C<filename> are stripped
first via C<m{([^/\\]+)\z}> -- only the basename is kept, preventing
directory traversal.

=over 4

=item Valid partitions

C<report.csv>, C<report.sql>, C<report.json>, C<REPORT.CSV>, C<report.SQL>.
A single-character stem (C<a.csv>) is also valid.

=item Invalid partitions (415)

C<report.txt>, C<report> (no extension), empty string.

=back

=head4 OUTPUT

  200 application/json   { "saved": "/abs/path/to/file" }
  404 application/json   { "error": "..." }  -- bad dir or table not found
  415 application/json   { "error": "..." }  -- unsupported extension
  500 application/json   { "error": "..." }  -- write failure

=head3 MESSAGES

  error_dir_not_found   -- realpath of dir failed or result is not a directory.
  error_ext_required    -- filename extension is not .csv or .sql.
  error_write_failed    -- DBI or filesystem write threw.

=head3 FORMAL SPECIFICATION

  export_write == lambda self .
    let dir  = realpath(param('dir'))      in
    let file = dir / strip_path(param('filename')) in
    pre  is_dir(dir) /\ ext(file) in {csv, sql}
    let (recs, cols, _) = _run_export_pipeline() in
    pre  recs /= undef
    post spurt(file, serialise(recs, cols))
         /\ render_json({ saved: file.to_string })

=head3 EXAMPLE

  POST /export  l=table:sales dir=/home/user/exports filename=report.csv
    -> { "saved": "/home/user/exports/report.csv" }

=cut
2764
2765
43
43
43
280128
48
35
sub export_write ($self) {
2766
43
82
        my $dir      = $self->param('dir')      // '';
2767
43
7216
        my $filename = $self->param('filename') // '';
2768
2769
43
43
1003
129
        my $dest_dir = eval { Mojo::File->new($dir)->realpath };
2770
43
2075
        unless (defined $dest_dir && -d $dest_dir) {
2771
5
29
                return $self->render(
2772                        json   => { error => $self->_i18n('error_dir_not_found', $dir) },
2773                        status => 404,
2774                );
2775        }
2776
2777        # Strip any path separators the browser might include.
2778
38
283
        ($filename) = $filename =~ m{([^/\\]+)\z};
2779
38
43
        my $format;
2780
38
24
241
29
        if    ($filename && $filename =~ /\.csv\z/i)  { $format = 'csv';    }
2781
4
7
        elsif ($filename && $filename =~ /\.sql\z/i)  { $format = 'sqlite'; }
2782
0
0
        elsif ($filename && $filename =~ /\.json\z/i) { $format = 'json';   }
2783        else {
2784
10
29
                return $self->render(
2785                        json   => { error => $self->_i18n('error_ext_required') },
2786                        status => 415,
2787                );
2788        }
2789
2790
28
80
        my ($records, $columns, $left_label) = $self->_run_export_pipeline;
2791
28
64
        return $self->render(json => { error => 'Table not found' }, status => 404)
2792                unless $records;
2793
2794
26
70
        my $dest = $dest_dir->child($filename);
2795
26
289
        eval {
2796
26
57
                if ($format eq 'csv') {
2797
22
46
                        $dest->spurt(_serialize_csv($records, $columns));
2798                }
2799                elsif ($format eq 'json') {
2800
0
0
                        $dest->spurt(encode_json($records));
2801                }
2802                else {
2803
4
12
                        $dest->spurt($self->_write_sqlite_db($records, $columns));
2804                }
2805        };
2806
26
2884
        return $self->render(
2807                json   => { error => $self->_i18n('error_write_failed', $@) },
2808                status => 500,
2809        ) if $@;
2810
2811
25
66
        $self->render(json => { saved => $dest->to_string });
2812}
2813
2814 - 2852
=head2 dirs_api

C<GET /api/dirs> -- Return a JSON directory listing for the export panel.

=head3 API SPECIFICATION

=head4 INPUT

  ?path=   string   Directory to list (default: $HOME).  Returns 404 when not
                    a directory.  Hidden entries (names starting with ".") are
                    excluded.

=head4 OUTPUT

  200 application/json
    {
      "path":   "/abs/path",
      "parent": "/abs/parent" or null (at filesystem root),
      "dirs":   [ { "name": "alpha", "path": "/abs/path/alpha" }, ... ]
    }

  404 application/json   { "error": "Not a directory" }

=head3 MESSAGES

None beyond the 404 response.

=head3 FORMAL SPECIFICATION

  dirs_api == lambda self .
    let dir = realpath(param('path') // HOME) in
    pre  is_dir(dir)
    post render_json({ path: dir, parent: parent(dir), dirs: subdirs(dir) })

=head3 EXAMPLE

  GET /api/dirs?path=/home/user  -> {"path":"/home/user","parent":"/home","dirs":[...]}

=cut
2853
2854
12
12
12
61082
11
11
sub dirs_api ($self) {
2855
12
23
        my $raw = $self->param('path') // $ENV{HOME} // '/';
2856
12
12
1359
24
        my $dir = eval { Mojo::File->new($raw)->realpath };
2857
12
860
        return $self->render(json => { error => 'Not a directory' }, status => 404)
2858                unless defined $dir && -d $dir;
2859
2860
7
52
        my $listing = $self->_list_dir($dir, 0);
2861
7
20
        my $parent  = $dir->dirname;
2862
2863        $self->render(json => {
2864                path   => $dir->to_string,
2865                parent => ($parent->to_string ne $dir->to_string ? $parent->to_string : undef),
2866                dirs   => $listing->{dirs},
2867
7
208
        });
2868}
2869
2870 - 2919
=head2 stat_api

C<GET /api/stat> -- Return filesystem metadata for a file path.

Used by the home page tooltip to show modification time and size for recently
opened files without a page reload.

=head3 API SPECIFICATION

=head4 INPUT

  ?path=   string   Absolute path to query.  Returns HTTP 400 when absent.

=head4 OUTPUT

  200 application/json   (file exists)
    { "exists": true, "path": "/resolved/path", "mtime": 1700000000, "size": 4096 }

  200 application/json   (file does not exist)
    { "exists": false, "path": "/original/path" }

  400 application/json   { "error": "\"path\" parameter is required" }

C<mtime> is Unix epoch seconds.  C<size> is bytes.  A missing/unresolvable
path returns HTTP 200 with C<exists: false> (not 404) so the UI can
distinguish "file was deleted" from a request error.

=head3 MESSAGES

  error_path_required   -- the "path" query parameter was not supplied.

=head3 FORMAL SPECIFICATION

  stat_api == lambda self .
    let path = param('path') in
    pre  path /= undef
    let file = realpath(path) in
    post if is_file(file) then
           render_json({ exists: true, mtime: mtime(file), size: size(file) })
         else render_json({ exists: false })

=head3 EXAMPLE

  GET /api/stat?path=/data/sales.csv
    -> { "exists": true, "path": "/data/sales.csv", "mtime": 1700000000, "size": 1234 }

  GET /api/stat?path=/deleted.csv
    -> { "exists": false, "path": "/deleted.csv" }

=cut
2920
2921
30
30
30
175932
37
24
sub stat_api ($self) {
2922
30
65
        my $path = $self->param('path');
2923
30
3630
        unless (defined $path && length $path) {
2924
6
24
                return $self->render(
2925                        json   => { error => $self->_i18n('error_path_required') },
2926                        status => 400,
2927                );
2928        }
2929
2930
24
24
23
44
        my $file = eval { Mojo::File->new($path)->realpath };
2931        # Restrict to files with a supported data extension so stat_api cannot be
2932        # used as a filesystem oracle to probe /etc/shadow, /root/.ssh/, etc.
2933
24
1406
        return $self->render(json => { exists => \0, path => $path })
2934                unless defined $file && -f $file && $file->basename =~ $EXT_RE;
2935
2936
12
517
        my @s = stat $file->to_string;
2937
2938        # For SQLite files, count user tables so the home-page tooltip can show
2939        # "SQLite database - N tables". Silently omit the count if the file is
2940        # not actually a SQLite file (e.g. a Berkeley DB .db file).
2941
12
60
        my $table_count;
2942
12
20
        if ($file->basename =~ /\.(?:sql|sqlite|sqlite3|db)\z/i) {
2943
1
12
                eval {
2944
1
4
                        require DBI;
2945
1
1
                        my $dbh = DBI->connect(
2946                                'dbi:SQLite:dbname=' . $file->to_string,
2947                                '', '',
2948                                { RaiseError => 1, PrintError => 0, AutoCommit => 1 },
2949                        );
2950
1
308
                        my $tbls = $dbh->selectcol_arrayref(
2951                                q{SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'}
2952                        );
2953
1
1
80
2
                        $table_count = scalar @{ $tbls // [] };
2954
1
35
                        $dbh->disconnect;
2955                };
2956        }
2957
2958
12
167
        $self->render(json => {
2959                exists => \1,
2960                path   => $file->to_string,
2961                mtime  => $s[9],
2962                size   => $s[7],
2963                (defined $table_count ? (table_count => $table_count) : ()),
2964        });
2965}
2966
2967 - 3025
=head2 upload_file

C<POST /upload> -- Accept a drag-and-dropped data file and return a redirect URL.

The file is saved under its original filename in a managed subdirectory of
C<< <app_home>/.uploads/ >>.  The subdirectory name is randomised so that
concurrent uploads of files with the same name do not collide.

=head3 API SPECIFICATION

=head4 INPUT

Multipart form upload, field name: C<file>.

  file   upload   Supported extensions: csv, db, sql, xml, tsv, psv.

=head4 OUTPUT

  200 application/json   { "url": "/open?path=/abs/path/file.csv", "path": "/abs/path/file.csv" }
  400 application/json   { "error": "No file received" }
  413 application/json   { "error": "File too large to upload (limit: 50 MB)" }
  415 application/json   { "error": "Unsupported file type. Accepted: ..." }
  500 application/json   { "error": "Could not save uploaded file" }

=head3 MESSAGES

  error_upload_none       -- no file was received in the multipart upload.
  error_upload_ext        -- the file's extension is not in the supported list.
  error_upload_too_large  -- the upload body exceeded MAX_UPLOAD_MIB (50 MiB).
  error_upload_save       -- move_to failed or the destination file was not
                             created (I/O error, full disk, permission error).

=head3 IMPLEMENTATION NOTES

The C<move_to> call is wrapped with the C<eval { ...; 1 }> idiom (not
C<eval {}> + C<if ($@)>).  Mojolicious dispatch and DESTROY-phase evals can
leave C<$@> set before the action runs; checking C<if ($@)> after a new eval
may fire falsely on that stale value.  C<eval { move_to(); 1 }> returns 1 on
success and C<undef> on exception, so the result is independent of any prior
C<$@> value.

=head3 FORMAL SPECIFICATION

  upload_file == lambda self .
    let upload = req.upload('file') in
    pre  upload /= undef /\ basename(upload.filename) =~ EXT_RE
         /\ NOT req->is_limit_exceeded
         /\ upload.size <= MAX_UPLOAD_BYTES
    let dest = home/.uploads/<random>/<filename> in
    let ok = eval { upload.move_to(dest); 1 } in
    pre  ok /\ -f dest
    post render_json({ url: '/open?path=' ++ url_escape(dest), path: dest })

=head3 EXAMPLE

  POST /upload  (multipart: file=@sales.csv)
    -> { "url": "/open?path=%2F...%2Fsales.csv", "path": "/.../.uploads/abc123/sales.csv" }

=cut
3026
3027
43
43
43
364897
53
41
sub upload_file ($self) {
3028
43
67
        my $upload = $self->req->upload('file');
3029
43
3561
        unless ($upload && $upload->filename) {
3030
4
13
                return $self->render(
3031                        json   => { error => $self->_i18n('error_upload_none') },
3032                        status => 400,
3033                );
3034        }
3035
3036        # Enforce upload size limit.  max_request_size in startup() sets the
3037        # transport-layer cap, but Mojolicious still calls the controller when the
3038        # limit fires (with req->is_limit_exceeded true and partial content in the
3039        # upload asset).  Check is_limit_exceeded first; fall through to an asset
3040        # size check as a belt-and-braces guard for any bytes that slipped through.
3041
39
169
        if ($self->req->is_limit_exceeded || $upload->size > $MAX_UPLOAD_BYTES) {
3042
3
24
                return $self->render(
3043                        json   => { error => $self->_i18n('error_upload_too_large', $MAX_UPLOAD_MIB) },
3044                        status => 413,
3045                );
3046        }
3047
3048        # /s so .* matches \n; a percent-decoded newline inside a browser filename
3049        # must not silently truncate the directory-strip, leaving "dir\ncomponent" in.
3050
36
636
        (my $filename = $upload->filename) =~ s{.*[/\\]}{}s;
3051
36
215
        unless ($filename && $filename =~ $EXT_RE) {
3052
5
57
                return $self->render(
3053                        json   => { error => $self->_i18n('error_upload_ext') },
3054                        status => 415,
3055                );
3056        }
3057
3058        # Store uploads in <app_home>/.uploads/<random_subdir>/<original_filename>.
3059        # Using the original filename is essential: Database::Abstraction derives
3060        # the table name from the file stem, so "sales.csv" must stay "sales.csv".
3061        # The random subdirectory prevents collisions from concurrent same-name uploads.
3062
31
298
        my $uploads_base = $self->app->home->child('.uploads');
3063
31
559
        $uploads_base->make_path unless -d $uploads_base;
3064
31
487
        my $sub_dir = tempdir(DIR => $uploads_base->to_string, CLEANUP => 0);
3065
31
5708
        my $dest    = Mojo::File->new($sub_dir)->child($filename)->to_string;
3066
3067        # move_to returns the destination Mojo::File on success; croaks on I/O
3068        # failure.  Wrap in eval so a disk error becomes a 500 JSON response
3069        # rather than a bare Mojolicious exception page.
3070        # Use the "eval { ...; 1 }" idiom: $ok is undef only when THIS eval
3071        # threw, avoiding false positives from a stale $@ left by Mojo's
3072        # internal dispatch code that runs before the controller action.
3073
31
31
30
450
78
1930
        my $ok = eval { $upload->move_to($dest); 1 };
3074
31
482
        if (!$ok || !-f $dest) {
3075
2
249
                remove_tree($sub_dir);
3076
2
8
                return $self->render(
3077                        json   => { error => $self->_i18n('error_upload_save') },
3078                        status => 500,
3079                );
3080        }
3081
3082
29
71
        $self->render(json => {
3083                url  => '/open?path=' . url_escape($dest),
3084                path => $dest,
3085        });
3086}
3087
3088
5
5
5
41239
7
5
sub clear_uploads ($self) {
3089
5
8
        my $uploads_dir = $self->app->home->child('.uploads');
3090
3091
5
80
        return $self->render(json => { freed => 0, count => 0 })
3092                unless -d $uploads_dir;
3093
3094
5
50
        my ($freed, $count) = (0, 0);
3095
3096        # Each upload lands in <uploads_dir>/<random_subdir>/<filename>.
3097        # Iterate the subdirs, tally file sizes, then remove each subdir tree.
3098        $uploads_dir->list({ dir => 1 })->each(sub {
3099
29
2451
                my ($entry) = @_;
3100
29
26
                if (-d $entry) {
3101                        $entry->list->each(sub {
3102
29
808
                                my ($file) = @_;
3103
29
27
                                return unless -f $file;
3104
29
95
                                $freed += (-s $file) // 0;
3105
29
91
                                $count++;
3106
29
108
                        });
3107
29
76
                        $entry->remove_tree;
3108                } elsif (-f $entry) {
3109
0
0
                        $freed += (-s $entry) // 0;
3110
0
0
                        $count++;
3111
0
0
                        unlink $entry->to_string;
3112                }
3113
5
17
        });
3114
3115
5
311
        $self->render(json => { freed => $freed, count => $count });
3116}
3117
3118# graph_view
3119#
3120# Purpose: Render a D3.js line chart from the current data pipeline.
3121# Entry:   Query params identical to /join (l=, j=, c=, f=, d=) plus:
3122#            x=<column>  -- X-axis column name
3123#            y=<column>  -- Y-axis column name (values must be numeric)
3124#            back=<url>  -- URL for the "Back to table" link (set by JS)
3125# Exit:    Emits a TT-rendered HTML page with a brush-to-zoom HTML::D3 snippet.
3126#          Each data point carries an "extra" hash of every column not used
3127#          as the X or Y axis; HTML::D3 displays these in the hover tooltip.
3128#          A "Reset zoom" button appears after the user brushes to zoom in.
3129#          Returns HTTP 400 for missing/invalid column names, 404 if the
3130#          data source cannot be opened, 200 with an error message when
3131#          no plottable rows exist.
3132
34
34
34
336102
41
41
sub graph_view ($self) {
3133
34
74
        my $x_col      = $self->param('x')      // '';
3134
34
5606
        my $y_col      = $self->param('y')      // '';
3135
34
816
        my $series_col = $self->param('series') // '';
3136
34
744
        my $back       = _safe_back_url($self->param('back')) // '/';
3137
34
60
        my $back2      = _safe_back_url($self->param('back2'));
3138
34
55
        my $back2_lbl  = $self->param('back2_label') // '';
3139
3140
34
763
        return $self->render(text => 'Missing x or y column parameter', status => 400)
3141                unless length($x_col) && length($y_col);
3142
3143
32
87
        my ($src, $records, $columns) = $self->_run_pipeline_source;
3144
32
77
        return $self->render(text => 'Could not open data source', status => 404)
3145                unless defined $records;
3146
3147
30
30
31
83
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
3148
30
0
0
51
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
3149
3150        # Apply f= filters (pre-filter before building pairs, same as _run_export_pipeline).
3151
30
30
29
44
        for my $s (@{ $self->every_param('f') }) {
3152
1
20
                $records = _apply_filter_spec($records, $s);
3153        }
3154
3155
30
104
30
565
114
36
        my %col_set = map { $_ => 1 } @{$columns};
3156        return $self->render(text => "Column not found: $x_col", status => 400)
3157
30
78
                unless $col_set{$x_col};
3158        return $self->render(text => "Column not found: $y_col", status => 400)
3159
29
55
                unless $col_set{$y_col};
3160
28
48
        if (length $series_col) {
3161                return $self->render(text => "Column not found: $series_col", status => 400)
3162
3
7
                        unless $col_set{$series_col};
3163        }
3164
3165        # --- Multi-series path: ?series=<col> groups records into one line per value ---
3166
27
47
        if (length $series_col) {
3167
2
2
                my %by_series;
3168                my @series_order;
3169
2
2
2
4
                for my $row (@{$records}) {
3170
12
14
                        my $x   = $row->{$x_col}      // '';
3171
12
13
                        my $y   = $row->{$y_col}      // '';
3172
12
5
                        my $grp = $row->{$series_col} // '';
3173
12
28
                        next unless length($x) && length($y) && length($grp);
3174
12
6
                        my $yn  = _numeric_cell($y);
3175
12
8
                        next unless defined $yn;
3176
12
13
                        unless (exists $by_series{$grp}) {
3177
4
5
                                push @series_order, $grp;
3178
4
3
                                $by_series{$grp} = [];
3179                        }
3180
12
12
9
12
                        push @{$by_series{$grp}}, { label => _decode_cell($x), value => $yn };
3181                }
3182
3183
2
4
                return $self->render(
3184                        text   => 'No plottable data: no rows have valid numeric values in the Y column.',
3185                        status => 200,
3186                ) unless @series_order;
3187
3188
2
4
3
8
                my @series_data = map { { name => $_, data => $by_series{$_} } } @series_order;
3189
3190
2
6
                require HTML::D3;
3191
2
3
                my $title = "$y_col vs $x_col by $series_col";
3192
2
9
                my $html  = HTML::D3->new(title => $title, width => 1100, height => 580)
3193                        ->render_multi_series_line_chart_with_interactive_legends(\@series_data);
3194
3195
2
3611
                utf8::decode($html) unless utf8::is_utf8($html);
3196
2
6
                return $self->render(data => encode('UTF-8', $html), format => 'html');
3197        }
3198
3199        # --- Single-series path (original behaviour when ?series= is absent) ---
3200
3201        # Extract [label, value] pairs; skip rows where Y is missing or non-numeric.
3202
25
23
        my @pairs;
3203
25
25
23
38
        for my $row (@{$records}) {
3204
90
110
                my $x  = $row->{$x_col} // '';
3205
90
92
                my $y  = $row->{$y_col} // '';
3206
90
139
                next unless length($x) && length($y);
3207
87
90
                my $yn = _numeric_cell($y);
3208
87
83
                next unless defined $yn;
3209
75
158
308
75
53
161
331
54
                my %extra = map { $_ => _decode_cell($row->{$_}) } grep { $_ ne $x_col && $_ ne $y_col } @{$columns};
3210
75
78
                push @pairs, [_decode_cell($x), $yn, \%extra];
3211        }
3212
3213
25
46
        return $self->render(
3214                text   => 'No plottable data: no rows have valid numeric values in the Y column.',
3215                status => 200,
3216        ) unless @pairs;
3217
3218
20
75
26
64
        my @y_vals = map { $_->[1] } @pairs;
3219
20
24
        my ($min_y, $max_y, $avg_y);
3220
3221        # Try aggregate() push path for the global min/max/avg reference lines.
3222        # Falls back to List::Util on the already-extracted @y_vals when push is
3223        # not available (no source, filters active, or non-pushable column).
3224
20
1
20
21
22
166
        my @f_specs    = grep { /\S/ } @{ $self->every_param('f') // [] };
3225
20
401
        my $has_filter = !!@f_specs;
3226
20
124
        if (!$ENV{BI_FORCE_PERL_AGG} && $src && !$has_filter
3227                && _measure_pushable($src, 'avg', $y_col, $records)) {
3228
7
8
                my $ref = eval {
3229
7
17
                        $src->aggregate(
3230                                by       => [],
3231                                measures => [
3232                                        _measure('min', $y_col, 'lo'),
3233                                        _measure('max', $y_col, 'hi'),
3234                                        _measure('avg', $y_col, 'mean'),
3235                                ],
3236                        )
3237                };
3238
7
43433
                if (!$@ && $ref && @$ref) {
3239
7
11
                        $min_y = $ref->[0]{lo};
3240
7
11
                        $max_y = $ref->[0]{hi};
3241
7
13
                        $avg_y = $ref->[0]{mean};
3242                }
3243        }
3244        # Perl fallback: compute from the already-stripped @y_vals.
3245
20
38
        unless (defined $min_y) {
3246
13
24
                $min_y = min(@y_vals);
3247
13
21
                $max_y = max(@y_vals);
3248
13
38
                $avg_y = sum(@y_vals) / scalar(@y_vals);
3249        }
3250
3251
20
57
        my ($source_url, $source_accessed) = $self->_url_attribution;
3252
3253
20
154
        require HTML::D3;
3254
20
29
        my $title   = "$y_col vs $x_col";
3255
20
114
        my $snippet = HTML::D3->new(title => $title, width => 1100, height => 580, responsive => 1)
3256                ->render_zoomable_line_chart_snippet(\@pairs, { animated => 1 });
3257
3258
20
40660
        my $graph_html = $snippet->{html};
3259
20
52
        utf8::decode($graph_html) unless utf8::is_utf8($graph_html);
3260
3261
20
61
        my ($platform, $language) = $self->_resolve_template;
3262
20
254
        $self->render(
3263                handler          => 'tt',
3264                template         => "$platform/$language/graph",
3265                format           => 'html',
3266                title            => $title,
3267                graph_html       => $graph_html,
3268                back_url         => ($back2 // $back),
3269                back_label       => ($back2 ? ($back2_lbl || 'Back') : 'Back to table'),
3270                ($back2 ? (back2_url => $back, back2_label => 'Back to table') : ()),
3271                point_count      => scalar @pairs,
3272                ref_min_y        => $min_y,
3273                ref_max_y        => $max_y,
3274                ref_avg_y        => sprintf('%.4g', $avg_y),
3275                source_url       => $source_url,
3276                source_accessed  => $source_accessed,
3277        );
3278}
3279
3280
23
23
23
193561
30
20
sub pie_view ($self) {
3281
23
49
        my $cat_col      = $self->param('cat') // '';
3282
23
3822
        my $val_col      = $self->param('val') // '';
3283
23
557
        my $donut        = $self->param('donut') ? 1 : 0;
3284
23
479
        my $back         = _safe_back_url($self->param('back')) // '/';
3285
23
166
        my $back2        = _safe_back_url($self->param('back2'));
3286
23
38
        my $back2_lbl    = $self->param('back2_label') // '';
3287
3288
23
531
        return $self->render(text => 'Missing cat or val column parameter', status => 400)
3289                unless length($cat_col) && length($val_col);
3290
3291
19
56
        my ($src, $records, $columns) = $self->_run_pipeline_source;
3292
19
41
        return $self->render(text => 'Could not open data source', status => 404)
3293                unless defined $records;
3294
3295
18
18
17
58
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
3296
18
0
0
40
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
3297
3298        # Apply f= filters.
3299
18
18
21
27
        for my $s (@{ $self->every_param('f') }) {
3300
0
0
                $records = _apply_filter_spec($records, $s);
3301        }
3302
3303
18
72
18
379
78
22
        my %col_set = map { $_ => 1 } @{$columns};
3304        return $self->render(text => "Column not found: $cat_col", status => 400)
3305
18
48
                unless $col_set{$cat_col};
3306
3307        # '__count__' is the sentinel value sent when the user picks "Count (rows)".
3308
17
25
        my $count_mode = ($val_col eq '__count__');
3309        return $self->render(text => "Column not found: $val_col", status => 400)
3310
17
51
                unless $count_mode || $col_set{$val_col};
3311
3312        # Detect the currency symbol from the first non-empty source value.
3313        # This is a display concern; derive from the pre-filtered source rows so the
3314        # $/$GBP/EUR prefix on the legend is unchanged regardless of the push path.
3315
17
20
        my $currency_symbol = '';
3316
17
31
        if (!$count_mode) {
3317
14
17
                for my $row (@$records) {
3318
53
72
                        my $v = $row->{$val_col} // '';
3319
53
43
                        next unless length($v);
3320
53
84
                        if ($v =~ /\A\s*\(?\s*([^\d\s.,\-\(])/) {
3321
4
6
                                $currency_symbol = $1;
3322
4
6
                                last;
3323                        }
3324                }
3325        }
3326
3327        # Aggregate via _grouped_totals (push path or Perl fold).
3328
17
129
        my $agg_rows = $self->_grouped_totals(
3329                { source => $src, records => $records },
3330                {
3331                        by  => [$cat_col],
3332                        fn  => $count_mode ? 'count' : 'sum',
3333                        val => $count_mode ? undef   : $val_col,
3334                        as  => 'value',
3335                },
3336        );
3337
3338        # Top-N: keep the highest-N slices, aggregate the rest as "Other".
3339
17
67
        my $topn_n = int($self->param('topn') // 0);
3340
17
734
        if ($topn_n > 0 && @$agg_rows > $topn_n) {
3341
0
0
0
0
                my @sorted   = sort { ($b->{value} // 0) <=> ($a->{value} // 0) } @$agg_rows;
3342
0
0
                my @top_agg  = @sorted[0 .. $topn_n - 1];
3343
0
0
0
0
                my %top_cats = map { ($_->{$cat_col} // '') => 1 } @top_agg;
3344                my $other_rec = [
3345
0
0
0
0
                        grep { !$top_cats{$_->{$cat_col} // ''} } @$records
3346                ];
3347
0
0
                if (@$other_rec) {
3348
0
0
                        my $other = $self->_grouped_totals(
3349                                { source => undef, records => $other_rec },
3350                                { by  => [], fn  => ($count_mode ? 'count' : 'sum'),
3351                                  val => ($count_mode ? undef : $val_col), as => 'value' },
3352                        );
3353                        push @top_agg, { $cat_col => 'Other', value => $other->[0]{value} }
3354
0
0
                                if @$other && defined $other->[0]{value};
3355                }
3356
0
0
                $agg_rows = \@top_agg;
3357        }
3358
3359
17
34
        return $self->render(
3360                text   => 'No plottable data: no rows have valid data in the selected columns.',
3361                status => 200,
3362        ) unless @$agg_rows;
3363
3364
16
49
21
81
        my @slices = map { [_decode_cell($_->{$cat_col}), $_->{value}] } @$agg_rows;
3365
3366
16
48
        my ($source_url, $source_accessed) = $self->_url_attribution;
3367
3368
16
139
        require HTML::D3;
3369
16
26
        my $scheme  = $self->param('scheme') // 'tableau10';
3370
16
373
        my $title   = $count_mode ? "Count by $cat_col" : "$val_col by $cat_col";
3371
16
86
        my $snippet = HTML::D3->new(title => $title, width => 600, height => 500, responsive => 1)
3372                ->render_pie_chart_snippet(\@slices, {
3373                        animated     => 1,
3374                        donut        => $donut,
3375                        sort_slices  => 'value',
3376                        max_slices   => 12,
3377                        legend       => 1,
3378                        color_scheme => $scheme,
3379                });
3380
3381
16
32687
        my $pie_html = $snippet->{html};
3382
16
39
        utf8::decode($pie_html) unless utf8::is_utf8($pie_html);
3383
3384
16
47
        my ($platform, $language) = $self->_resolve_template;
3385
16
128
        $self->render(
3386                handler          => 'tt',
3387                template         => "$platform/$language/pie",
3388                format           => 'html',
3389                title            => $title,
3390                pie_html         => $pie_html,
3391                back_url         => ($back2 // $back),
3392                back_label       => ($back2 ? ($back2_lbl || 'Back') : 'Back to table'),
3393                ($back2 ? (back2_url => $back, back2_label => 'Back to table') : ()),
3394                drill_back_url   => $back,
3395                slice_count      => scalar @slices,
3396                cat_col          => $cat_col,
3397                val_col          => $val_col,
3398                currency_symbol  => $currency_symbol,
3399                source_url       => $source_url,
3400                source_accessed  => $source_accessed,
3401        );
3402}
3403
3404 - 3455
=head2 heatmap_view

C<GET /heatmap> -- Render a D3.js grid heatmap for two categorical axes.

Accepts the same C<l=>, C<j=>, C<f=> pipeline params as C</join>, plus:

  x=<col>      X-axis (horizontal) categorical column (required)
  y=<col>      Y-axis (vertical) categorical column (required)
  val=<col>    Cell value column (optional; when absent, cells show row count)
  scheme=<name> D3 sequential colour scheme name (default: YlOrRd)
  show_val=1   Overlay the numeric value inside each cell
  back=<url>   URL for the "Back to table" breadcrumb link

=head3 API SPECIFICATION

=head4 INPUT

  l       spec     Required.  Left table spec (table:name or path:/abs/path).
  x       string   Required.  Name of the X-axis categorical column.
  y       string   Required.  Name of the Y-axis categorical column.
  val     string   Optional.  Name of the value column; omit for count mode.
  scheme  string   Optional.  D3 sequential colour scheme name.
  show_val flag    Optional.  Set to 1 to render values inside cells.
  back    url      Optional.  Safe back-link URL (sanitised by _safe_back_url).

=head4 OUTPUT

  200 text/html           Rendered heatmap page.
  200 text/plain          "No plottable data: ..." when grid is empty.
  400 text/plain          Missing x= or y= parameter.
  400 text/plain          Column not found: <col>.
  404 text/plain          "Could not open data source" when l= is absent/invalid.

=head3 MESSAGES

  error_heatmap_no_data -- no (x,y) pair has a non-empty value.

=head3 FORMAL SPECIFICATION

  heatmap_view == lambda self .
    pre length(x_col) > 0 /\ length(y_col) > 0
        /\ x_col in columns /\ y_col in columns
    let triples = { (row[x_col], row[y_col], row[val_col]) | row in records,
                    all three fields non-empty } in
    post render(heatmap, triples)

=head3 EXAMPLE

  GET /heatmap?l=table:sales&x=region&y=product&val=amount
    -> 200 HTML page containing D3 heatmap SVG

=cut
3456
3457
15
15
15
98102
21
13
sub heatmap_view ($self) {
3458
15
23
        my $x_col     = $self->param('x')        // '';
3459
15
2299
        my $y_col     = $self->param('y')        // '';
3460
15
339
        my $val_col   = $self->param('val')      // '';
3461
15
306
        my $scheme    = $self->param('scheme')   // 'YlOrRd';
3462
15
305
        my $show_v    = $self->param('show_val') ? 1 : 0;
3463
15
293
        my $back      = _safe_back_url($self->param('back')) // '/';
3464
15
22
        my $back2     = _safe_back_url($self->param('back2'));
3465
15
19
        my $back2_lbl = $self->param('back2_label') // '';
3466
3467
15
328
        return $self->render(text => 'Missing x or y column parameter', status => 400)
3468                unless length($x_col) && length($y_col);
3469
3470
11
34
        my ($src, $records, $columns) = $self->_run_pipeline_source;
3471
11
29
        return $self->render(text => 'Could not open data source', status => 404)
3472                unless defined $records;
3473
3474
10
10
8
31
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
3475
10
0
0
16
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
3476
3477        # Apply f= filters.
3478
10
10
12
16
        for my $s (@{ $self->every_param('f') }) {
3479
0
0
                $records = _apply_filter_spec($records, $s);
3480        }
3481
3482
10
31
10
190
37
11
        my %col_set = map { $_ => 1 } @{$columns};
3483        return $self->render(text => "Column not found: $x_col", status => 400)
3484
10
29
                unless $col_set{$x_col};
3485        return $self->render(text => "Column not found: $y_col", status => 400)
3486
8
21
                unless $col_set{$y_col};
3487        return $self->render(text => "Column not found: $val_col", status => 400)
3488
7
25
                if length($val_col) && !$col_set{$val_col};
3489
3490        # Collect first-seen axis order from the filtered source rows.
3491        # The aggregate path returns groups ordered by (x,y) ascending, which
3492        # differs from first-seen.  We re-derive the layout order from the source
3493        # so the grid renders in natural data order regardless of backend.
3494
7
7
        my (@x_order, @y_order, %seen_x, %seen_y);
3495
7
9
        for my $row (@$records) {
3496
24
29
                my $x = $row->{$x_col} // next;
3497
22
24
                my $y = $row->{$y_col} // next;
3498
22
31
                next unless length($x) && length($y);
3499
22
28
                push @x_order, $x unless $seen_x{$x}++;
3500
22
33
                push @y_order, $y unless $seen_y{$y}++;
3501        }
3502
3503        # Aggregate with _grouped_totals (push or Perl fold).
3504
7
59
        my $agg_rows = $self->_grouped_totals(
3505                { source => $src, records => $records },
3506                {
3507                        by  => [$x_col, $y_col],
3508                        fn  => length($val_col) ? 'sum' : 'count',
3509                        val => length($val_col) ? $val_col : undef,
3510                        as  => 'value',
3511                },
3512        );
3513
3514        # Index aggregate rows by [x][y] for O(1) lookup.
3515
7
17
        my %grid;
3516
7
11
        for my $row (@$agg_rows) {
3517
22
35
                $grid{$row->{$x_col}}{$row->{$y_col}} = $row->{value};
3518        }
3519
3520        my @triples = map {
3521
7
12
11
22
                my ($raw_x, $xv) = ($_, _decode_cell($_));
3522
12
24
13
18
                map { [$xv, _decode_cell($_), $grid{$raw_x}{$_} // 0] } @y_order
3523        } @x_order;
3524
3525
7
16
        return $self->render(
3526                text   => 'No plottable data: check that x and y columns contain values.',
3527                status => 200,
3528        ) unless @triples;
3529
3530
6
21
        my ($source_url, $source_accessed) = $self->_url_attribution;
3531
3532
6
552
        require HTML::D3;
3533
6
3819
        my $title   = length($val_col) ? "$val_col by $x_col and $y_col"
3534                                        : "Count by $x_col and $y_col";
3535
6
35
        my $snippet = HTML::D3->new(title => $title, width => 900, height => 500, responsive => 1)
3536                ->render_heatmap_snippet(\@triples, {
3537                        x_label      => $x_col,
3538                        y_label      => $y_col,
3539                        val_label    => (length($val_col) ? $val_col : 'Count'),
3540                        color_scheme => $scheme,
3541                        show_values  => $show_v,
3542                        animated     => 1,
3543                });
3544
3545
6
12367
        my $heatmap_html = $snippet->{html};
3546
6
15
        utf8::decode($heatmap_html) unless utf8::is_utf8($heatmap_html);
3547
3548
6
19
        my ($platform, $language) = $self->_resolve_template;
3549
6
43
        $self->render(
3550                handler          => 'tt',
3551                template         => "$platform/$language/heatmap",
3552                format           => 'html',
3553                title            => $title,
3554                heatmap_html     => $heatmap_html,
3555                cell_count       => scalar @triples,
3556                back_url         => ($back2 // $back),
3557                back_label       => ($back2 ? ($back2_lbl || 'Back') : 'Back to table'),
3558                ($back2 ? (back2_url => $back, back2_label => 'Back to table') : ()),
3559                source_url       => $source_url,
3560                source_accessed  => $source_accessed,
3561        );
3562}
3563
3564 - 3616
=head2 bar_view

C<GET /bar> -- Render a D3.js bar chart grouped by a categorical column.

Accepts the same C<l=>, C<j=>, C<f=> pipeline params as C</join>, plus:

  cat=<col>    Category (X-axis) column (required)
  val=<col>    Value column to sum per category; omit or use C<__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 "Back to table" breadcrumb link

=head3 API SPECIFICATION

=head4 INPUT

  l       spec     Required.  Left table spec (table:name or path:/abs/path).
  cat     string   Required.  Name of the category column.
  val     string   Optional.  Name of the value column; use __count__ or
                   omit entirely to count rows per category.
  orient  string   Optional.  "h" for horizontal bars; default vertical.
  sort    string   Optional.  "value" or "label"; default file order.
  max     integer  Optional.  Collapse beyond top-N categories into "Other".
  back    url      Optional.  Safe back-link URL (sanitised by _safe_back_url).

=head4 OUTPUT

  200 text/html           Rendered bar chart page (SVG element id="bar_chart").
  200 text/plain          "No plottable data: ..." when no categories exist.
  400 text/plain          Missing cat= parameter.
  400 text/plain          Column not found: <col>.
  404 text/plain          "Could not open data source" when l= is absent/invalid.

=head3 MESSAGES

  error_bar_no_data -- no row has a non-empty category value.

=head3 FORMAL SPECIFICATION

  bar_view == lambda self .
    pre length(cat_col) > 0 /\ cat_col in columns
    let bars = { (cat, sum(val)) | row in records, row[cat_col] = cat,
                 val /= undef } in
    post render(bar, bars, orientation, sort, max_bars)

=head3 EXAMPLE

  GET /bar?l=table:sales&cat=region&val=amount&sort=value
    -> 200 HTML page with id="bar_chart" SVG element

=cut
3617
3618
27
27
27
212313
30
30
sub bar_view ($self) {
3619
27
45
        my $cat_col   = $self->param('cat')    // '';
3620
27
4231
        my $val_col   = $self->param('val')    // '';
3621
27
626
        my $orient    = $self->param('orient') // 'vertical';
3622
27
582
        my $sort      = $self->param('sort')   // 'none';
3623
27
553
        my $max_bars  = int($self->param('max') // 0);
3624
27
576
        my $back      = _safe_back_url($self->param('back')) // '/';
3625
27
43
        my $back2     = _safe_back_url($self->param('back2'));
3626
27
41
        my $back2_lbl = $self->param('back2_label') // '';
3627
3628
27
555
        return $self->render(text => 'Missing cat column parameter', status => 400)
3629                unless length($cat_col);
3630
3631
24
64
        my ($src, $records, $columns) = $self->_run_pipeline_source;
3632
24
46
        return $self->render(text => 'Could not open data source', status => 404)
3633                unless defined $records;
3634
3635
23
23
26
63
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
3636
23
0
0
41
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
3637
3638        # Apply f= filters.
3639
23
23
27
36
        for my $s (@{ $self->every_param('f') }) {
3640
0
0
                $records = _apply_filter_spec($records, $s);
3641        }
3642
3643
23
68
23
435
78
37
        my %col_set = map { $_ => 1 } @{$columns};
3644        return $self->render(text => "Column not found: $cat_col", status => 400)
3645
23
67
                unless $col_set{$cat_col};
3646
3647        # '__count__' sentinel: count rows per category instead of summing a column.
3648
21
59
        my $count_mode = ($val_col eq '__count__' || !length($val_col));
3649        return $self->render(text => "Column not found: $val_col", status => 400)
3650
21
61
                unless $count_mode || $col_set{$val_col};
3651
3652        # Aggregate via _grouped_totals (push path or Perl fold).
3653
20
159
        my $agg_rows = $self->_grouped_totals(
3654                { source => $src, records => $records },
3655                {
3656                        by  => [$cat_col],
3657                        fn  => $count_mode ? 'count' : 'sum',
3658                        val => $count_mode ? undef   : $val_col,
3659                        as  => 'value',
3660                },
3661        );
3662
3663        # Top-N: keep the highest-N categories, aggregate the rest as "Other".
3664
20
82
        my $topn_n = int($self->param('topn') // 0);
3665
20
855
        if ($topn_n > 0 && @$agg_rows > $topn_n) {
3666
1
8
2
10
                my @sorted   = sort { ($b->{value} // 0) <=> ($a->{value} // 0) } @$agg_rows;
3667
1
3
                my @top_agg  = @sorted[0 .. $topn_n - 1];
3668
1
3
2
5
                my %top_cats = map { ($_->{$cat_col} // '') => 1 } @top_agg;
3669                my $other_rec = [
3670
1
5
2
7
                        grep { !$top_cats{$_->{$cat_col} // ''} } @$records
3671                ];
3672
1
2
                if (@$other_rec) {
3673
1
7
                        my $other = $self->_grouped_totals(
3674                                { source => undef, records => $other_rec },
3675                                { by  => [], fn  => ($count_mode ? 'count' : 'sum'),
3676                                  val => ($count_mode ? undef : $val_col), as => 'value' },
3677                        );
3678                        push @top_agg, { $cat_col => 'Other', value => $other->[0]{value} }
3679
1
6
                                if @$other && defined $other->[0]{value};
3680                }
3681
1
2
                $agg_rows = \@top_agg;
3682        }
3683
3684
20
45
        return $self->render(
3685                text   => 'No plottable data: no rows have valid data in the selected columns.',
3686                status => 200,
3687        ) unless @$agg_rows;
3688
3689
18
54
25
76
        my @bars = map { [_decode_cell($_->{$cat_col}), $_->{value}] } @$agg_rows;
3690
3691
18
35
        my $orientation = ($orient eq 'h') ? 'horizontal' : 'vertical';
3692
18
65
        my $sort_bars   = ($sort eq 'value' || $sort eq 'label') ? $sort : 'none';
3693
3694
18
56
        my ($source_url, $source_accessed) = $self->_url_attribution;
3695
3696
18
184
        require HTML::D3;
3697
18
47
        my $title   = $count_mode ? "Count by $cat_col"
3698                                  : "$val_col by $cat_col";
3699
18
104
        my $snippet = HTML::D3->new(title => $title, width => 800, height => 480, responsive => 1)
3700                ->render_bar_chart_snippet(\@bars, {
3701                        animated     => 1,
3702                        orientation  => $orientation,
3703                        sort_bars    => $sort_bars,
3704                        color        => 'categorical',
3705                        show_values  => 0,
3706                        value_label  => ($count_mode ? 'Count' : $val_col),
3707                        x_label      => $cat_col,
3708                        ($max_bars > 0 ? (max_bars => $max_bars) : ()),
3709                });
3710
3711
18
36935
        my $bar_html = $snippet->{html};
3712
18
52
        utf8::decode($bar_html) unless utf8::is_utf8($bar_html);
3713
3714
18
57
        my ($platform, $language) = $self->_resolve_template;
3715
18
137
        $self->render(
3716                handler         => 'tt',
3717                template        => "$platform/$language/bar",
3718                format          => 'html',
3719                title           => $title,
3720                bar_html        => $bar_html,
3721                bar_count       => scalar @bars,
3722                cat_col         => $cat_col,
3723                back_url        => ($back2 // $back),
3724                back_label      => ($back2 ? ($back2_lbl || 'Back') : 'Back to table'),
3725                ($back2 ? (back2_url => $back, back2_label => 'Back to table') : ()),
3726                source_url      => $source_url,
3727                source_accessed => $source_accessed,
3728        );
3729}
3730
3731 - 3791
=head2 folder_view

C<GET /folder> -- Browse a data table grouped by the letter-prefix hierarchy
of an order-number column (e.g. A10, CT4, B3).

The top level displays one folder icon per initial letter group (A, B, C ...).
Clicking a folder with sub-prefixes (e.g. C contains CA and CB items) shows
the sub-folders; clicking a leaf prefix shows the actual data rows sorted
numerically by the embedded digit sequence.

Accepts the same C<l=>, C<j=>, C<f=> pipeline params as C</join>, plus:

  col=<col>      Column whose values contain order codes (optional; auto-
                 detected when absent by scanning the first 10 rows for
                 values matching C<[A-Z]{1,4}\d+[A-Z]?>)
  prefix=<str>   Current drill-down prefix, e.g. "CA" (optional; top-level
                 when absent)
  back=<url>     URL for the "Back to table" breadcrumb link (optional;
                 derived from C<l=> when absent)

=head3 API SPECIFICATION

=head4 INPUT

  l       spec     Required.  Left table spec (table:name or path:/abs/path).
  col     string   Optional.  Order-code column name.
  prefix  string   Optional.  Current letter prefix; max length MAX_PREFIX_LEN.
  back    url      Optional.  Safe back-link URL (sanitised by _safe_back_url).

=head4 OUTPUT

  200 text/html    Rendered folder page with folder grid and/or item table.
  400 text/plain   "Missing l= parameter" when l= is absent.
  400 text/plain   "Prefix too long" when prefix exceeds MAX_PREFIX_LEN (20).
  404 text/plain   "Could not open data source" when the data source is unavailable.

=head3 MESSAGES

None -- all failure paths render plain text with an HTTP status code.

=head3 FORMAL SPECIFICATION

  folder_view == lambda self .
    pre length(left_spec) > 0 /\ length(prefix) <= MAX_PREFIX_LEN
    let records = pipeline(left_spec, joins, filters)
        prefix_map = { p -> [row] | row in records, row[col] =~ /[A-Z]+/ -> p } in
    IF prefix = "" THEN
      post render(folder, folders = first_letters(prefix_map))
    ELSE
      post render(folder, folders = sub_prefixes(prefix, prefix_map),
                          items   = sort_numeric(prefix_map[prefix]))

=head3 EXAMPLE

  GET /folder?l=table:orders&col=order_no
    -> 200 HTML folder grid showing A (12 items), B (7 items), C (4 items)

  GET /folder?l=table:orders&col=order_no&prefix=A
    -> 200 HTML folder grid for sub-prefixes and items directly under "A"

=cut
3792
3793
32
32
32
238259
37
30
sub folder_view ($self) {
3794
32
61
        my $key_col   = $self->param('col')    // '';
3795
32
4319
        my $prefix    = $self->param('prefix') // '';
3796
32
730
        my $left_spec = $self->param('l')      // '';
3797
3798
32
685
        return $self->render(text => 'Missing l= parameter', status => 400)
3799                unless length($left_spec);
3800
3801        # Guard against DoS: a crafted prefix longer than any real order-code would
3802        # generate O(N) breadcrumb entries and a proportionally large HTML response.
3803
27
90
        return $self->render(text => 'Prefix too long', status => 400)
3804                if length($prefix) > $MAX_PREFIX_LEN;
3805
3806        # Fall back to the table's own URL when no explicit back= is supplied,
3807        # so the breadcrumb always has a "Back to table" link.
3808
23
80
        my $back = _safe_back_url($self->param('back'))
3809                || $self->_spec_to_url($left_spec);
3810
3811
23
181
        my ($records, $columns) = $self->_run_export_pipeline;
3812
23
41
        return $self->render(text => 'Could not open data source', status => 404)
3813                unless $records;
3814
3815        # Auto-detect key column: find the first column whose sampled values look
3816        # like order-number codes (1-4 uppercase letters followed by digits).
3817
21
36
        if (!length($key_col)) {
3818
12
12
12
12
24
9
                my $sample_end = $#{$records} < 9 ? $#{$records} : 9;
3819
12
12
16
22
                OUTER: for my $col (@{$columns}) {
3820
57
57
41
55
                        for my $row (@{$records}[0 .. $sample_end]) {
3821
327
337
                                if (($row->{$col} // '') =~ /\A[A-Z]{1,4}\d+[A-Z]?\z/) {
3822
3
3
                                        $key_col = $col;
3823
3
5
                                        last OUTER;
3824                                }
3825                        }
3826                }
3827
12
31
                $key_col ||= ($columns->[0] // '');
3828        }
3829
3830        # Build prefix -> [rows] map.  Prefix is the leading letter-run of each
3831        # key value: "A10" -> "A", "CT4" -> "CT", "CC30A" -> "CC".
3832
21
25
        my %prefix_to_rows;
3833
21
21
21
27
        for my $row (@{$records}) {
3834
111
110
                my $val = $row->{$key_col} // '';
3835
111
108
                my ($p) = $val =~ /\A([A-Z]+)/;
3836
111
103
                next unless defined $p;
3837
45
45
28
49
                push @{ $prefix_to_rows{$p} }, $row;
3838        }
3839
3840        # Return the sorted list of "one-step" child prefixes of $par: all known
3841        # prefixes that share the parent as a strict prefix and extend it by
3842        # exactly one letter.
3843        my $sub_prefixes = sub {
3844
10
16
                my ($par) = @_;
3845
10
12
                my $len = length($par);
3846
10
14
                my %seen;
3847
10
15
                for my $p (keys %prefix_to_rows) {
3848
15
20
                        next if length($p) <= $len;
3849
4
6
                        next if substr($p, 0, $len) ne $par;
3850
2
3
                        $seen{ substr($p, 0, $len + 1) } = 1;
3851                }
3852
10
24
                return sort keys %seen;
3853
21
53
        };
3854
3855        # Count total items underneath a prefix (including all deeper sub-prefixes).
3856        my $deep_count = sub {
3857
16
13
                my ($par) = @_;
3858
16
13
                my $n = 0;
3859
16
14
                for my $p (keys %prefix_to_rows) {
3860
52
18
51
17
                        $n += scalar @{ $prefix_to_rows{$p} }
3861                                if substr($p, 0, length($par)) eq $par;
3862                }
3863
16
21
                return $n;
3864
21
40
        };
3865
3866
21
54
        my ($platform, $language) = $self->_resolve_template;
3867
3868        # Base URL carries the table spec (and optional col=) so every sub-link
3869        # re-uses the same data source without repeating params.
3870
21
48
        my $base_url = '/folder?l=' . url_escape($left_spec);
3871
21
233
        $base_url .= '&col=' . url_escape($key_col) if length($key_col);
3872
3873
21
101
        my (@folders, @items, @breadcrumb);
3874
3875
21
39
        if (!length($prefix)) {
3876                # Top level: group all prefixes by their first letter.
3877
11
12
                my %first_letter;
3878
11
15
                for my $p (keys %prefix_to_rows) {
3879
16
20
                        $first_letter{ substr($p, 0, 1) } = 1;
3880                }
3881
11
23
                for my $fl (sort keys %first_letter) {
3882
14
53
                        push @folders, {
3883                                name  => $fl,
3884                                count => $deep_count->($fl),
3885                                href  => $base_url . '&prefix=' . url_escape($fl),
3886                        };
3887                }
3888        } else {
3889                # Build breadcrumb: one entry per letter-prefix layer up to current.
3890                # e.g. prefix="CD" -> [ {name="C", href=...}, {name="CD", href=undef} ]
3891
10
21
                for my $i (1 .. length($prefix)) {
3892
46
147
                        my $p = substr($prefix, 0, $i);
3893
46
65
                        push @breadcrumb, {
3894                                name => $p,
3895                                href => ($i < length($prefix))
3896                                        ? ($base_url . '&prefix=' . url_escape($p))
3897                                        : undef,
3898                        };
3899                }
3900
3901                # One-step child prefixes of the current prefix (sub-folders).
3902
10
18
                for my $sp ($sub_prefixes->($prefix)) {
3903
2
7
                        push @folders, {
3904                                name  => $sp,
3905                                count => $deep_count->($sp),
3906                                href  => $base_url . '&prefix=' . url_escape($sp),
3907                        };
3908                }
3909
3910                # Items whose prefix matches exactly (e.g. prefix="C" shows C1, C3, ...).
3911
10
10
16
30
                @items = @{ $prefix_to_rows{$prefix} // [] };
3912
3913                # Sort by the numeric part of the key value so A9 < A10.
3914                @items = sort {
3915
10
5
23
13
                        my ($an) = ($a->{$key_col} // '') =~ /(\d+)/;
3916
5
12
                        my ($bn) = ($b->{$key_col} // '') =~ /(\d+)/;
3917                        ($an // 0) <=> ($bn // 0)
3918
5
20
                                || ($a->{$key_col} // '') cmp ($b->{$key_col} // '');
3919                } @items;
3920        }
3921
3922
21
65
        my $title = length($prefix) ? "Folder: $prefix" : 'Folder view';
3923
3924
21
81
        $self->render(
3925                handler    => 'tt',
3926                template   => "$platform/$language/folder",
3927                format     => 'html',
3928                title      => $title,
3929                folders    => \@folders,
3930                items      => \@items,
3931                columns    => $columns,
3932                key_col    => $key_col,
3933                prefix     => $prefix,
3934                breadcrumb => \@breadcrumb,
3935                base_url   => $base_url,
3936                back_url   => $back,
3937                back_label => 'Back to table',
3938                left_spec  => $left_spec,
3939        );
3940}
3941
3942 - 3965
=head2 tables_view

C<GET /tables> -- Schema browser for multi-table databases (SQLite, etc.).
Lists all internal tables with their columns; clicking a table opens it.

=head3 API SPECIFICATION

=head4 INPUT

  ?l=   string   Required.  Table spec: "table:name" (data_dir) or
                 "path:/abs/path" (filesystem path).  The file must be a
                 SQLite database.

=head4 OUTPUT

Renders C<tables.html.tt> with:

  db_label   string   Short name / stem of the database file
  tables     ARRAYREF of { name, columns => [...], href }
  spec       string   The raw l= spec (for building back links)
  back_url   string   URL to navigate back one level
  back_label string   Label for the back link

=cut
3966
3967
13
13
13
144045
17
12
sub tables_view ($self) {
3968
13
35
        my ($platform, $language) = $self->_resolve_template;
3969
13
36
        my $spec = $self->param('l');
3970
13
1892
        return $self->reply->not_found unless defined $spec && length $spec;
3971
3972
10
17
        my ($file_path, $db_label, $back_url, $back_label);
3973
10
0
        my @tables;
3974
3975
10
56
        if ($spec =~ /\Atable:([A-Za-z0-9_]+)\z/) {
3976
3
7
                my $table_name = lc $1;
3977                my $data_dir   = $self->app->home->child(
3978
3
6
                        $self->app->config->{data_dir} // 'data');
3979
3
50
                for my $ext (qw(sql sqlite sqlite3 db)) {
3980
12
53
                        my $p = $data_dir->child("$table_name.$ext");
3981
12
68
                        if (-f $p) {
3982
2
12
                                $file_path = $p->to_string;
3983
2
6
                                last;
3984                        }
3985                }
3986
3
12
                return $self->reply->not_found unless defined $file_path;
3987
2
2
                $db_label  = $table_name;
3988
2
2
                $back_url  = '/';
3989
2
3
                $back_label = 'Home';
3990        } elsif ($spec =~ /\Apath:(.+)\z/) {
3991
5
8
                my $path_arg = $1;
3992
5
5
6
9
                my $file = eval { Mojo::File->new($path_arg)->realpath };
3993
5
669
                return $self->reply->not_found
3994                        unless defined $file && -f $file && $file->basename =~ /\.(?:sql|sqlite|sqlite3|db)\z/i;
3995
3
88
                $file_path  = $file->to_string;
3996
3
10
                ($db_label  = $file->basename) =~ s/\.[^.]+\z//;
3997
3
34
                $back_url   = '/browse?path=' . url_escape($file->dirname->to_string);
3998
3
84
                $back_label = 'Back to browser';
3999        } elsif ($spec =~ $REDIS_URL_RE) {
4000                # Bare redis:// URL -- scan keys and present a picker.
4001                # Parse redis://[:password@]host[:port][/db]
4002
0
0
                my ($password, $hostport, $db_idx);
4003
0
0
                if ($spec =~ m{\Aredis://(?::([^@]*)@)?([^/]+)(?:/(\d+))?\z}i) {
4004
0
0
                        ($password, $hostport, $db_idx) = ($1, $2, $3);
4005                }
4006
0
0
                $hostport //= 'localhost:6379';
4007
0
0
                $db_label   = "redis://$hostport";
4008
0
0
                $back_url   = '/';
4009
0
0
                $back_label = 'Home';
4010
4011
0
0
                eval {
4012                        # Load Redis::Fast if available, fall back to Redis.
4013                        # Use the file-path form ("Redis/Fast.pm") because require with a
4014                        # plain string treats it as a filename, not a module name.
4015
0
0
                        my $pkg;
4016
0
0
                        for my $mod ('Redis::Fast', 'Redis') {
4017
0
0
                                (my $file = $mod) =~ s{::}{/}g;
4018
0
0
0
0
0
0
0
0
0
0
                                if (eval { require "$file.pm"; 1 }) { $pkg = $mod; last }
4019                        }
4020
0
0
                        croak 'Redis or Redis::Fast must be installed to browse a Redis server'
4021                                unless $pkg;
4022
4023
0
0
                        my $redis = $pkg->new(
4024                                server      => $hostport,
4025                                cnx_timeout => 5,
4026                                read_timeout => 10,
4027                        );
4028
0
0
                        $redis->auth($password) if defined $password && length $password;
4029
0
0
                        $redis->select(int($db_idx)) if defined $db_idx && int($db_idx) > 0;
4030
4031                        # SCAN in a loop; stop at 200 keys -- enough for a picker.
4032
0
0
                        my $cursor = 0;
4033
0
0
                        my @all_keys;
4034
0
0
                        do {
4035
0
0
                                my ($new_cursor, $batch) = $redis->scan($cursor, 'COUNT', 100);
4036
0
0
                                $cursor = $new_cursor;
4037
0
0
0
0
                                push @all_keys, @{ $batch // [] };
4038                        } while ($cursor ne '0' && @all_keys < 200);
4039
4040
0
0
                        my $tables_url = '/tables?l=' . url_escape($spec);
4041
0
0
                        for my $key (sort @all_keys) {
4042
0
0
                                my $open_url = '/open?path='    . url_escape("redis:$key|$spec")
4043                                        . '&back='       . url_escape($tables_url)
4044                                        . '&back_label=Redis+keys';
4045
0
0
                                push @tables, { name => $key, columns => [], href => $open_url };
4046                        }
4047                };
4048
0
0
                return $self->reply->not_found if $@;
4049        } else {
4050
2
13
                return $self->reply->not_found;
4051        }
4052
4053
5
5
11
5
        if (defined $file_path) { eval {
4054
5
19
                require DBI;
4055
5
28
                my $dbh = DBI->connect(
4056                        "dbi:SQLite:dbname=$file_path", q{}, q{},
4057                        { RaiseError => 1, PrintError => 0, AutoCommit => 1 });
4058
5
1364
                my $tnames = $dbh->selectcol_arrayref(
4059                        q{SELECT name FROM sqlite_master }
4060                        . q{WHERE type='table' AND name NOT LIKE 'sqlite_%' }
4061                        . q{ORDER BY name});
4062
5
5
416
12
                for my $tname (@{ $tnames // [] }) {
4063
12
7
                        my @cols;
4064
12
12
10
24
                        my $sth = eval { $dbh->prepare("PRAGMA table_info(\"$tname\")") };
4065
12
182
                        if ($sth) {
4066
12
65
                                $sth->execute;
4067
12
96
                                while (my $row = $sth->fetchrow_hashref) {
4068
35
144
                                        push @cols, $row->{name};
4069                                }
4070                        }
4071
12
21
                        my $open_url = '/open?path=' . url_escape($file_path)
4072                                . '&it=' . url_escape($tname)
4073                                . '&back=' . url_escape('/tables?l=' . $spec)
4074                                . '&back_label=' . url_escape(ucfirst($db_label) . ' schema');
4075
12
369
                        push @tables, { name => $tname, columns => \@cols, href => $open_url };
4076                }
4077
5
165
                $dbh->disconnect;
4078
5
11
        }; return $self->reply->not_found if $@; }
4079
4080        $self->render(
4081
5
23
                template   => "$platform/$language/tables",
4082                handler    => 'tt',
4083                format     => 'html',
4084                title      => ucfirst($db_label) . ' -- Tables',
4085                db_label   => $db_label,
4086                tables     => \@tables,
4087                spec       => $spec,
4088                back_url   => $back_url,
4089                back_label => $back_label,
4090        );
4091}
4092
4093 - 4109
=head2 pivot_view

C<GET /pivot> -- Crosstab / pivot table view.

Accepts the same C<l=>, C<j=>, C<f=> pipeline params as C</join>, plus:

  rows=<col>[,<col>...]  Row dimensions (required, comma-separated column names)
  cols=<col>[,<col>...]  Column dimensions (optional)
  m=<col>:<fn>:<as>      Measure spec, repeatable (required); col may be empty for count
  h=<as>:<op>:<val>      Post-aggregation HAVING filter (repeatable, optional)
  totals=1               Include row subtotals, column subtotals, and grand total
  scheme=<name>          Colour scheme for cell shading (optional)
  max_cols=<n>           Collapse column headers beyond top-N into "Other"
  page= / per_page=      Pagination over row groups
  format=csv|sqlite|json Export the pivot grid

=cut
4110
4111
27
27
27
185630
31
32
sub pivot_view ($self) {
4112
27
59
        my $left_spec = $self->param('l') // '';
4113
27
4990
        return $self->render(text => 'Missing l= parameter', status => 400)
4114                unless length $left_spec;
4115
4116
27
56
        my $back      = _safe_back_url($self->param('back')) // '/';
4117
27
48
        my $back2     = _safe_back_url($self->param('back2'));
4118
27
45
        my $back2_lbl = $self->param('back2_label') // '';
4119
4120
27
607
        my $rows_str = $self->param('rows') // '';
4121
27
599
        return $self->render(text => $self->_i18n('error_pivot_rows_required'), status => 400)
4122                unless length $rows_str;
4123
4124
26
26
33
44
        my @measure_strs = @{ $self->every_param('m') // [] };
4125
26
540
        return $self->render(text => $self->_i18n('error_pivot_measure_req'), status => 400)
4126                unless @measure_strs;
4127
4128        # Split dimensions on comma.  A dimension name containing a comma
4129        # would produce an ambiguous split; that must be rejected.
4130
25
64
        my @rows = split /,/, $rows_str, -1;
4131
25
39
        my @cols = split /,/, ($self->param('cols') // ''), -1;
4132
25
1
584
2
        @cols = grep { length $_ } @cols;
4133
4134
25
29
        my @measures;
4135
25
35
        for my $ms (@measure_strs) {
4136
25
54
                my $m = _parse_measure_param($ms);
4137
25
60
                return $self->render(
4138                        text   => $self->_i18n('error_pivot_measure_invalid', $ms),
4139                        status => 400,
4140                ) unless $m;
4141
24
38
                push @measures, $m;
4142        }
4143
4144
24
39
        my $show_totals = ($self->param('totals') // '') ? 1 : 0;
4145
24
568
        my $scheme      = $self->param('scheme') // '';
4146
24
523
        my $max_cols_n  = int($self->param('max_cols') // 0);
4147
24
555
        my $format      = lc($self->param('format') // '');
4148
4149        # Build the data pipeline.
4150
24
581
        my ($src, $records, $columns) = $self->_run_pipeline_source;
4151
24
57
        return $self->render(text => 'Could not open data source', status => 404)
4152                unless defined $records;
4153
4154        # Calculated fields (?c=name:expr) -- evaluated before aggregation so calc
4155        # columns are available as row/col dimensions and measure columns.
4156        {
4157
24
24
27
24
                my $err;
4158
24
24
25
67
                eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
4159
24
6
6
1342
18
141
                if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
4160        }
4161
4162
18
18
17
29
        for my $s (@{ $self->every_param('f') }) {
4163
0
0
                $records = _apply_filter_spec($records, $s);
4164        }
4165
4166
18
47
18
357
57
22
        my %col_set = map { $_ => 1 } @{$columns};
4167
4168
18
30
        for my $dim (@rows, @cols) {
4169                return $self->render(
4170                        text   => $self->_i18n('error_pivot_dim_not_found', $dim),
4171                        status => 400,
4172
19
47
                ) unless $col_set{$dim};
4173        }
4174
4175
18
25
        for my $m (@measures) {
4176
18
64
                next unless defined $m->{col} && length $m->{col};
4177                return $self->render(
4178                        text   => $self->_i18n('error_measure_column', $m->{col}),
4179                        status => 400,
4180
16
37
                ) unless $col_set{$m->{col}};
4181        }
4182
4183
18
41
        my $pipeline = { source => $src, records => $records };
4184
4185        # -----------------------------------------------------------------------
4186        # Aggregate: one _grouped_totals call per measure, grouped by all
4187        # row AND column dimensions together.
4188        # -----------------------------------------------------------------------
4189
18
39
        my (%cells, %row_combos, %col_combos);
4190
18
0
        my (@row_key_order, @col_key_order);
4191
18
0
        my (%row_seen, %col_seen);
4192
4193
18
22
        for my $m (@measures) {
4194
18
66
                my $cv  = (defined $m->{col} && length $m->{col}) ? $m->{col} : undef;
4195                my $agg = $self->_grouped_totals($pipeline, {
4196                        by  => [@rows, @cols],
4197                        fn  => $m->{fn},
4198                        val => $cv,
4199                        as  => $m->{as},
4200
18
99
                });
4201
18
51
                for my $row (@$agg) {
4202
74
74
64
118
                        my $rkey = join("\x00", map { $row->{$_} // '' } @rows);
4203
74
4
76
5
                        my $ckey = join("\x00", map { $row->{$_} // '' } @cols);
4204
74
98
                        unless ($row_seen{$rkey}++) {
4205
72
65
                                push @row_key_order, $rkey;
4206
72
72
63
136
                                $row_combos{$rkey} = { map { $_ => ($row->{$_} // '') } @rows };
4207                        }
4208
74
102
                        unless ($col_seen{$ckey}++) {
4209
19
28
                                push @col_key_order, $ckey;
4210
19
2
41
4
                                $col_combos{$ckey} = { map { $_ => ($row->{$_} // '') } @cols };
4211                        }
4212
74
154
                        $cells{$rkey}{$ckey}{$m->{as}} = $row->{$m->{as}};
4213                }
4214        }
4215
4216        # -----------------------------------------------------------------------
4217        # Post-aggregation HAVING filters (h=<as>:<op>:<val>) applied row-by-row.
4218        # Each filter operates on a virtual row whose keys are the measure aliases.
4219        # -----------------------------------------------------------------------
4220
18
18
24
51
        my @h_specs = @{ $self->every_param('h') // [] };
4221
18
814
        if (@h_specs) {
4222
0
0
                my @keep;
4223
0
0
                for my $rkey (@row_key_order) {
4224                        # Build a virtual row containing each measure's cell value
4225                        # (using first col-combo as representative, or grand-total style).
4226                        # For HAVING, we filter on the *row subtotal* value, not a single cell.
4227                        # We'll compute a temp subtotal row for each row key.
4228
0
0
                        my %vrow;
4229
0
0
                        for my $m (@measures) {
4230                                # Sum across cols for this row as representative value.
4231
0
0
                                my $v = 0;
4232
0
0
                                for my $ck (@col_key_order) {
4233
0
0
                                        my $cv = $cells{$rkey}{$ck}{$m->{as}};
4234
0
0
                                        $v += $cv if defined $cv;
4235                                }
4236
0
0
                                $vrow{$m->{as}} = $v;
4237                        }
4238
0
0
                        my $ok = 1;
4239
0
0
                        for my $hs (@h_specs) {
4240
0
0
                                my $passed = _apply_filter_spec([\%vrow], $hs);
4241
0
0
                                $ok = 0 unless @$passed;
4242                        }
4243
0
0
                        push @keep, $rkey if $ok;
4244                }
4245
0
0
                @row_key_order = @keep;
4246        }
4247
4248        # -----------------------------------------------------------------------
4249        # Top-N rows: keep only the top-N row groups (ranked by measure sum
4250        # across all col combos); aggregate the remaining rows as an "Other" row.
4251        # -----------------------------------------------------------------------
4252        {
4253
18
18
21
35
                my $topn_n   = int($self->param('topn') // 0);
4254
18
515
                my $topn_col = $self->param('topn_col') // '';
4255
4256
18
475
                if ($topn_n > 0 && @row_key_order > $topn_n) {
4257
2
2
2
4
                        my %m_aliases = map { $_->{as} => 1 } @measures;
4258
2
7
                        if (length $topn_col && !$m_aliases{$topn_col}) {
4259
1
3
                                return $self->render(
4260                                        text   => $self->_i18n('error_topn_col', $topn_col),
4261                                        status => 400,
4262                                );
4263                        }
4264
1
2
                        my $sort_as = length($topn_col) ? $topn_col : $measures[0]{as};
4265
4266                        # Sort rows by descending sum of sort_as across all column combos.
4267                        my @sorted_rkeys = sort {
4268
1
7
7
7
3
1
10
6
                                (do { my $s = 0; $s += ($cells{$b}{$_}{$sort_as} // 0) for @col_key_order; $s })
4269                                <=>
4270
7
7
7
7
7
2
10
5
                                (do { my $s = 0; $s += ($cells{$a}{$_}{$sort_as} // 0) for @col_key_order; $s })
4271                        } @row_key_order;
4272
4273
1
3
                        my @top_rkeys  = @sorted_rkeys[0 .. $topn_n - 1];
4274
1
2
                        my @rest_rkeys = @sorted_rkeys[$topn_n .. $#sorted_rkeys];
4275
4276
1
2
                        if (@rest_rkeys) {
4277
1
2
2
2
                                my %top_rkey_set = map { $_ => 1 } @top_rkeys;
4278                                my $other_rec = [
4279                                        grep {
4280
1
5
3
2
                                                my $r = $_;
4281
5
5
4
9
                                                !$top_rkey_set{ join("\x00", map { $r->{$_} // '' } @rows) }
4282                                        } @$records
4283                                ];
4284
1
2
                                if (@$other_rec) {
4285
1
1
                                        my $ork = '__topn_other__';
4286
1
1
2
5
                                        $row_combos{$ork} = { map { $_ => 'Other' } @rows };
4287
1
1
                                        for my $m (@measures) {
4288
1
4
                                                my $cv = (defined $m->{col} && length $m->{col}) ? $m->{col} : undef;
4289                                                my $oa = $self->_grouped_totals(
4290                                                        { source => undef, records => $other_rec },
4291                                                        { by => [@cols], fn => $m->{fn}, val => $cv, as => $m->{as} },
4292
1
16
                                                );
4293
1
3
                                                for my $row (@$oa) {
4294
1
0
2
0
                                                        my $ck = join("\x00", map { $row->{$_} // '' } @cols);
4295
1
3
                                                        $cells{$ork}{$ck}{$m->{as}} = $row->{$m->{as}};
4296                                                }
4297                                        }
4298
1
3
                                        @row_key_order = (@top_rkeys, $ork);
4299                                } else {
4300
0
0
                                        @row_key_order = @top_rkeys;
4301                                }
4302                        } else {
4303
0
0
                                @row_key_order = @top_rkeys;
4304                        }
4305                }
4306        }
4307
4308        # -----------------------------------------------------------------------
4309        # Subtotals and grand total (three additional _grouped_totals calls
4310        # per measure when totals=1).
4311        # -----------------------------------------------------------------------
4312
17
20
        my (%row_subtotals, %col_subtotals, %grand_total);
4313
4314
17
26
        if ($show_totals) {
4315
2
3
                for my $m (@measures) {
4316
2
10
                        my $cv = (defined $m->{col} && length $m->{col}) ? $m->{col} : undef;
4317
4318                        # Row subtotals: group by @rows only.
4319                        my $rsub = $self->_grouped_totals($pipeline, {
4320                                by  => [@rows],
4321                                fn  => $m->{fn},
4322                                val => $cv,
4323                                as  => $m->{as},
4324
2
11
                        });
4325
2
5
                        for my $r (@$rsub) {
4326
8
8
8
13
                                my $rk = join("\x00", map { $r->{$_} // '' } @rows);
4327
8
13
                                $row_subtotals{$rk}{$m->{as}} = $r->{$m->{as}};
4328                        }
4329
4330                        # Column subtotals: group by @cols only.
4331
2
5
                        if (@cols) {
4332                                my $csub = $self->_grouped_totals($pipeline, {
4333                                        by  => [@cols],
4334                                        fn  => $m->{fn},
4335                                        val => $cv,
4336                                        as  => $m->{as},
4337
1
6
                                });
4338
1
3
                                for my $c (@$csub) {
4339
2
2
2
4
                                        my $ck = join("\x00", map { $c->{$_} // '' } @cols);
4340
2
5
                                        $col_subtotals{$ck}{$m->{as}} = $c->{$m->{as}};
4341                                }
4342                        }
4343
4344                        # Grand total: group by nothing (all rows → one result row).
4345                        my $gtot = $self->_grouped_totals($pipeline, {
4346                                by  => [],
4347                                fn  => $m->{fn},
4348                                val => $cv,
4349                                as  => $m->{as},
4350
2
8
                        });
4351
2
11
                        $grand_total{$m->{as}} = $gtot->[0]{$m->{as}} if @$gtot;
4352                }
4353        }
4354
4355        # -----------------------------------------------------------------------
4356        # max_cols: collapse the tail of column combos beyond top-N into "Other".
4357        # The top-N are ranked by the first measure's column subtotal (or cell sum).
4358        # -----------------------------------------------------------------------
4359
17
38
        if ($max_cols_n > 0 && @col_key_order > $max_cols_n) {
4360
0
0
                my $first_as = $measures[0]{as};
4361                my @sorted_ckeys = sort {
4362
0
0
                        (  $show_totals
4363                           ? ($col_subtotals{$b}{$first_as} // 0)
4364
0
0
0
0
0
0
                           : (do { my $s = 0; $s += ($cells{$_}{$b}{$first_as} // 0) for @row_key_order; $s })
4365                        )
4366                        <=>
4367                        (  $show_totals
4368                           ? ($col_subtotals{$a}{$first_as} // 0)
4369
0
0
0
0
0
0
0
0
                           : (do { my $s = 0; $s += ($cells{$_}{$a}{$first_as} // 0) for @row_key_order; $s })
4370                        )
4371                } @col_key_order;
4372
0
0
                my @top_ckeys  = @sorted_ckeys[0 .. $max_cols_n - 1];
4373
0
0
                my @rest_ckeys = @sorted_ckeys[$max_cols_n .. $#sorted_ckeys];
4374
0
0
                if (@rest_ckeys) {
4375
0
0
                        my $other_key = '__other__';
4376
0
0
0
0
                        $col_combos{$other_key} = { map { $_ => 'Other' } @cols };
4377
0
0
                        for my $rk (@row_key_order) {
4378
0
0
                                for my $m (@measures) {
4379
0
0
                                        my ($v, $have) = (0, 0);
4380
0
0
                                        for my $ck (@rest_ckeys) {
4381
0
0
                                                my $cv = $cells{$rk}{$ck}{$m->{as}};
4382
0
0
0
0
0
0
                                                if (defined $cv) { $v += $cv; $have = 1 }
4383                                        }
4384
0
0
                                        $cells{$rk}{$other_key}{$m->{as}} = $have ? $v : undef;
4385                                }
4386                        }
4387
0
0
                        @col_key_order = (@top_ckeys, $other_key);
4388                } else {
4389
0
0
                        @col_key_order = @top_ckeys;
4390                }
4391        }
4392
4393        # -----------------------------------------------------------------------
4394        # Export path: build flat records from the grid.
4395        # -----------------------------------------------------------------------
4396
17
43
        if ($format && $format =~ /\A(?:csv|sqlite|sql|json|xlsx)\z/) {
4397
2
3
                my @grid_records;
4398
2
3
                for my $rk (@row_key_order) {
4399
8
6
                        for my $ck (@col_key_order) {
4400
8
6
                                my %rec;
4401
8
13
                                $rec{$_} = $row_combos{$rk}{$_} // '' for @rows;
4402
8
6
                                $rec{$_} = $col_combos{$ck}{$_} // '' for @cols;
4403
8
15
                                $rec{$_->{as}} = $cells{$rk}{$ck}{$_->{as}} // '' for @measures;
4404
8
8
                                push @grid_records, \%rec;
4405                        }
4406                }
4407
2
2
3
4
                my @export_cols = (@rows, @cols, map { $_->{as} } @measures);
4408
2
10
                return $self->_render_csv(\@grid_records, \@export_cols, 'pivot')
4409                        if $format eq 'csv';
4410
0
0
                return $self->_render_sqlite(\@grid_records, \@export_cols, 'pivot')
4411                        if $format eq 'sqlite' || $format eq 'sql';
4412
0
0
                return $self->_render_json(\@grid_records, \@export_cols, 'pivot')
4413                        if $format eq 'json';
4414
0
0
                if ($format eq 'xlsx') {
4415
0
0
                        require Excel::Writer::XLSX;
4416
0
0
                        my $out = '';
4417
0
0
                        open my $fh, '>', \$out
4418                                or return $self->render(text => 'Cannot open XLSX buffer', status => 500);
4419
0
0
                        my $wb = Excel::Writer::XLSX->new($fh);
4420
0
0
                        my $ws = $wb->add_worksheet('pivot');
4421
0
0
                        $ws->write_row(0, 0, \@export_cols);
4422
0
0
                        my $row_idx = 1;
4423
0
0
                        for my $rec (@grid_records) {
4424
0
0
0
0
                                $ws->write_row($row_idx++, 0, [ map { $rec->{$_} // '' } @export_cols ]);
4425                        }
4426
0
0
                        $wb->close;
4427
0
0
                        $self->res->headers->content_type(
4428                                'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
4429                        );
4430
0
0
                        $self->res->headers->content_disposition(
4431                                'attachment; filename="pivot.xlsx"'
4432                        );
4433
0
0
                        return $self->render(data => $out);
4434                }
4435        }
4436
4437        # -----------------------------------------------------------------------
4438        # Pagination: slice @row_key_order using the N+1 trick.
4439        # Must come after the export early-return above (exports use the full set)
4440        # and after subtotals/grand total (computed from $pipeline, not @row_key_order).
4441        # -----------------------------------------------------------------------
4442
15
34
        my ($page, $per_page, $offset) = $self->_pagination;
4443
15
19
        my $total_row_groups = scalar @row_key_order;
4444
15
31
        my $piv_has_next = ($total_row_groups > $offset + $per_page) ? 1 : 0;
4445
15
67
        @row_key_order = $offset < $total_row_groups
4446                ? @row_key_order[$offset .. ($piv_has_next ? $offset + $per_page - 1 : $#row_key_order)]
4447                : ();
4448
4449
15
22
        my $piv_page_url = $self->req->url->clone;
4450
15
897
        $piv_page_url->query->remove('page')->remove('per_page');
4451
4452        # -----------------------------------------------------------------------
4453        # Build the template-ready data structure.
4454        # -----------------------------------------------------------------------
4455        my @col_combo_list = map {
4456
15
16
306
22
                my $ck = $_;
4457                {
4458                        key      => $ck,
4459                        dim_vals => $col_combos{$ck},
4460
16
2
68
7
                        label    => join(' / ', map { $col_combos{$ck}{$_} // '' } @cols),
4461                }
4462        } @col_key_order;
4463
4464
15
17
        my @grid_rows;
4465
15
22
        for my $rk (@row_key_order) {
4466
35
25
                my %row_cells;
4467
35
28
                for my $ck (@col_key_order) {
4468
37
61
                        $row_cells{$ck} = $cells{$rk}{$ck} // {};
4469                }
4470                push @grid_rows, {
4471                        row_vals  => $row_combos{$rk},
4472                        cells     => \%row_cells,
4473
35
98
                        subtotals => $show_totals ? ($row_subtotals{$rk} // {}) : {},
4474                };
4475        }
4476
4477
15
65
        (my $left_label = $left_spec) =~ s/\A(?:table:|path:|url:)//;
4478
4479
15
53
        my ($platform, $language) = $self->_resolve_template;
4480
15
113
        $self->render(
4481                handler       => 'tt',
4482                template      => "$platform/$language/pivot",
4483                format        => 'html',
4484                title         => 'Pivot: ' . join(', ', @rows),
4485                rows_dims     => \@rows,
4486                cols_dims     => \@cols,
4487                measures      => \@measures,
4488                col_combos    => \@col_combo_list,
4489                grid_rows     => \@grid_rows,
4490                show_totals   => $show_totals,
4491                col_subtotals => \%col_subtotals,
4492                grand_total   => \%grand_total,
4493                scheme            => $scheme,
4494                left_spec         => $left_spec,
4495                left_label        => $left_label,
4496                back_url          => ($back2 // $back),
4497                back_label        => ($back2 ? ($back2_lbl || 'Back') : ($self->param('back_label') // 'Back to table')),
4498                ($back2 ? (back2_url => $back, back2_label => ($self->param('back_label') // 'Back to table')) : ()),
4499                page              => $page,
4500                per_page          => $per_page,
4501                has_next          => $piv_has_next,
4502                page_base_url     => $piv_page_url->to_string,
4503                total_row_groups  => $total_row_groups,
4504        );
4505}
4506
4507 - 4518
=head2 stats_view

C<GET /stats> -- Column statistics panel.

Accepts the same C<l=>, C<j=>, C<f=> pipeline params as C</join> and displays
per-column statistics: min, max, mean, median, null_count, distinct_count,
row_count.

Statistics are computed through C<_grouped_totals> so they agree with the pivot
view.  Median is computed in Perl by sorting the column values.

=cut
4519
4520
2
2
2
13549
2
2
sub stats_view ($self) {
4521
2
6
        my $left_spec = $self->param('l') // '';
4522
2
210
        return $self->render(text => 'Missing l= parameter', status => 400)
4523                unless length $left_spec;
4524
4525
1
2
        my $back      = _safe_back_url($self->param('back')) // '/';
4526
1
2
        my $back2     = _safe_back_url($self->param('back2'));
4527
1
2
        my $back2_lbl = $self->param('back2_label') // '';
4528
4529
1
22
        my ($src, $records, $columns) = $self->_run_pipeline_source;
4530
1
2
        return $self->render(text => 'Could not open data source', status => 404)
4531                unless defined $records;
4532
4533
1
1
2
3
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
4534
1
0
0
2
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
4535
4536
1
1
2
2
        for my $s (@{ $self->every_param('f') }) {
4537
0
0
                $records = _apply_filter_spec($records, $s);
4538        }
4539
4540
1
18
        my $row_count = scalar @$records;
4541
1
2
        my $pipeline  = { source => $src, records => $records };
4542
4543
1
2
        my @stats;
4544
1
1
1
2
        for my $col (@{$columns}) {
4545                # null_count = rows where cell is undef or '' (phase 2 definition).
4546
2
6
4
11
                my @nonnull    = grep { defined $_->{$col} && length($_->{$col}) } @$records;
4547
2
3
                my $null_count = $row_count - scalar @nonnull;
4548
4549                # distinct_count via _grouped_totals (count groups by the column).
4550
2
8
                my $dist_rows      = $self->_grouped_totals($pipeline, {
4551                        by => [$col],
4552                        fn => 'count',
4553                        as => 'cnt',
4554                });
4555
2
3
                my $distinct_count = scalar @$dist_rows;
4556
4557                # Numeric statistics.
4558
2
6
6
4
5
5
                my @nums = grep { defined $_ } map { _numeric_cell($_->{$col}) } @nonnull;
4559
2
3
                my ($min_val, $max_val, $avg_val, $median_val);
4560
2
4
                if (@nums) {
4561
1
3
1
3
                        my @sorted = sort { $a <=> $b } @nums;
4562
1
2
                        $min_val = $sorted[0];
4563
1
1
                        $max_val = $sorted[-1];
4564
1
1
                        my $sum  = 0;
4565
1
2
                        $sum += $_ for @nums;
4566
1
1
                        $avg_val = $sum / @nums;
4567
1
2
                        my $mid  = int(@sorted / 2);
4568
1
3
                        $median_val = @sorted % 2
4569                                ? $sorted[$mid]
4570                                : ($sorted[$mid - 1] + $sorted[$mid]) / 2;
4571                }
4572
4573
2
16
                push @stats, {
4574                        col            => $col,
4575                        row_count      => $row_count,
4576                        null_count     => $null_count,
4577                        distinct_count => $distinct_count,
4578                        (defined $min_val ? (
4579                                min    => $min_val,
4580                                max    => $max_val,
4581                                avg    => sprintf('%.4g', $avg_val),
4582                                median => sprintf('%.4g', $median_val),
4583                        ) : ()),
4584                };
4585        }
4586
4587
1
4
        (my $left_label = $left_spec) =~ s/\A(?:table:|path:|url:)//;
4588
4589
1
3
        my ($platform, $language) = $self->_resolve_template;
4590
1
5
        $self->render(
4591                handler    => 'tt',
4592                template   => "$platform/$language/stats",
4593                format     => 'html',
4594                title          => 'Column statistics',
4595                stats          => \@stats,
4596                row_count      => $row_count,
4597                left_spec      => $left_spec,
4598                left_label     => $left_label,
4599                drill_back_url => $back,
4600                drill_sep      => (CORE::index($back, '?') >= 0 ? '&' : '?'),
4601                stats_url      => $self->req->url->path_query,
4602                back_url       => ($back2 // $back),
4603                back_label     => ($back2 ? ($back2_lbl || 'Back') : ($self->param('back_label') // 'Back to table')),
4604                ($back2 ? (back2_url => $back, back2_label => ($self->param('back_label') // 'Back to table')) : ()),
4605        );
4606}
4607
4608# ---------------------------------------------------------------------------
4609# Calculated-field CRUD  (POST /calc/save, POST /calc/delete, GET /calc/list)
4610# ---------------------------------------------------------------------------
4611
4612
5
5
5
24410
5
4
sub calc_save ($self) {
4613
5
8
        my $spec = $self->param('l') // '';
4614
5
753
        my $name = $self->param('name') // '';
4615
5
118
        my $expr = $self->param('expr') // '';
4616
4617
5
106
        return $self->render(json => { error => 'l= required' }, status => 400)
4618                unless length $spec;
4619
4
14
        return $self->render(
4620                json   => { error => $self->_i18n('error_calc_name', $name) },
4621                status => 400,
4622        ) unless $name =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
4623
3
9
        return $self->render(json => { error => 'expr= required' }, status => 400)
4624                unless length $expr;
4625
4626
2
4
        my $models = $self->_read_models;
4627
2
5
        $models->{fields}{$spec}{$name} = $expr;
4628
2
5
        $self->_write_models($models);
4629
2
5
        $self->render(json => { ok => 1 });
4630}
4631
4632
2
2
2
8017
3
1
sub calc_delete ($self) {
4633
2
3
        my $spec = $self->param('l') // '';
4634
2
266
        my $name = $self->param('name') // '';
4635
4636
2
45
        return $self->render(json => { error => 'l= required' }, status => 400)
4637                unless length $spec;
4638
4639
2
5
        my $models = $self->_read_models;
4640
2
7
        delete $models->{fields}{$spec}{$name};
4641
2
3
        $self->_write_models($models);
4642
2
7
        $self->render(json => { ok => 1 });
4643}
4644
4645
7
7
7
27824
10
6
sub calc_list ($self) {
4646
7
13
        my $spec = $self->param('l') // '';
4647
7
722
        return $self->render(json => { error => 'l= required' }, status => 400)
4648                unless length $spec;
4649
4650
5
10
        my $models = $self->_read_models;
4651
5
19
        $self->render(json => { fields => $models->{fields}{$spec} // {} });
4652}
4653
4654# ---------------------------------------------------------------------------
4655# Named-group CRUD  (POST /groups/save, POST /groups/delete, GET /groups/list)
4656# ---------------------------------------------------------------------------
4657
4658
2
2
2
15528
2
2
sub groups_save ($self) {
4659
2
4
        my $spec       = $self->param('l')     // '';
4660
2
395
        my $col        = $self->param('col')   // '';
4661
2
48
        my $group_name = $self->param('group') // '';
4662
2
2
43
3
        my @values     = @{ $self->every_param('values') // [] };
4663
4664
2
41
        return $self->render(json => { error => 'l= required' }, status => 400)
4665                unless length $spec;
4666
2
3
        return $self->render(json => { error => 'col= required' }, status => 400)
4667                unless length $col;
4668
2
2
        return $self->render(json => { error => 'group= required' }, status => 400)
4669                unless length $group_name;
4670
4671
2
4
        my $models = $self->_read_models;
4672
2
7
        $models->{groups}{$spec}{$col}{$group_name} = \@values;
4673
2
5
        $self->_write_models($models);
4674
2
6
        $self->render(json => { ok => 1 });
4675}
4676
4677
1
1
1
8576
1
1
sub groups_delete ($self) {
4678
1
2
        my $spec       = $self->param('l')     // '';
4679
1
136
        my $col        = $self->param('col')   // '';
4680
1
25
        my $group_name = $self->param('group') // '';
4681
4682
1
20
        return $self->render(json => { error => 'l= required' }, status => 400)
4683                unless length $spec;
4684
4685
1
3
        my $models = $self->_read_models;
4686
1
4
        if (length $col && length $group_name) {
4687
0
0
                delete $models->{groups}{$spec}{$col}{$group_name};
4688        } elsif (length $col) {
4689
0
0
                delete $models->{groups}{$spec}{$col};
4690        } else {
4691
1
3
                delete $models->{groups}{$spec};
4692        }
4693
1
3
        $self->_write_models($models);
4694
1
4
        $self->render(json => { ok => 1 });
4695}
4696
4697
1
1
1
4515
1
2
sub groups_list ($self) {
4698
1
2
        my $spec = $self->param('l') // '';
4699
1
136
        return $self->render(json => { error => 'l= required' }, status => 400)
4700                unless length $spec;
4701
4702
1
3
        my $models = $self->_read_models;
4703
1
4
        $self->render(json => { groups => $models->{groups}{$spec} // {} });
4704}
4705
4706# ---------------------------------------------------------------------------
4707# Date-pattern CRUD  (POST /dates/save, POST /dates/delete, GET /dates/list)
4708# ---------------------------------------------------------------------------
4709
4710# dates_save: POST /dates/save?l=<spec>&col=<col>&pattern=<fmt>
4711# Stores a strptime-style date normalisation pattern for $col in models.json.
4712# Only the three patterns in %ALLOWED_DATE_PATTERNS are accepted.
4713
4
4
4
18864
4
5
sub dates_save ($self) {
4714
4
24
        my $spec    = $self->param('l')       // '';
4715
4
638
        my $col     = $self->param('col')     // '';
4716
4
93
        my $pattern = $self->param('pattern') // '';
4717
4718
4
86
        return $self->render(json => { error => 'l= required' }, status => 400)
4719                unless length $spec;
4720
3
4
        return $self->render(json => { error => 'col= required' }, status => 400)
4721                unless length $col;
4722        return $self->render(
4723                json   => { error => $self->_i18n('error_date_pattern', $pattern) },
4724                status => 400,
4725
3
12
        ) unless $ALLOWED_DATE_PATTERNS{$pattern};
4726
4727
2
12
        my $models = $self->_read_models;
4728
2
5
        $models->{date_patterns}{$spec}{$col} = $pattern;
4729
2
6
        $self->_write_models($models);
4730
2
8
        $self->render(json => { ok => 1 });
4731}
4732
4733# dates_delete: POST /dates/delete?l=<spec>&col=<col>
4734# Removes the pattern for $col (or all patterns for $spec when col is absent).
4735
2
2
2
12321
2
2
sub dates_delete ($self) {
4736
2
4
        my $spec = $self->param('l')   // '';
4737
2
265
        my $col  = $self->param('col') // '';
4738
4739
2
46
        return $self->render(json => { error => 'l= required' }, status => 400)
4740                unless length $spec;
4741
4742
2
4
        my $models = $self->_read_models;
4743
2
4
        if (length $col) {
4744
1
3
                delete $models->{date_patterns}{$spec}{$col};
4745        } else {
4746
1
2
                delete $models->{date_patterns}{$spec};
4747        }
4748
2
6
        $self->_write_models($models);
4749
2
6
        $self->render(json => { ok => 1 });
4750}
4751
4752# dates_list: GET /dates/list?l=<spec>
4753# Returns { patterns: { col: pattern, ... } } for $spec.
4754
4
4
4
18867
6
4
sub dates_list ($self) {
4755
4
7
        my $spec = $self->param('l') // '';
4756
4
439
        return $self->render(json => { error => 'l= required' }, status => 400)
4757                unless length $spec;
4758
4759
3
6
        my $models = $self->_read_models;
4760
3
14
        $self->render(json => { patterns => $models->{date_patterns}{$spec} // {} });
4761}
4762
4763# ---------------------------------------------------------------------------
4764# Scatter chart  (GET /scatter)
4765# ---------------------------------------------------------------------------
4766
4767
3
3
3
17243
4
2
sub scatter_view ($self) {
4768
3
6
        my $x_col     = $self->param('x')    // '';
4769
3
431
        my $y_col     = $self->param('y')    // '';
4770
3
71
        my $back      = _safe_back_url($self->param('back')) // '/';
4771
3
5
        my $back2     = _safe_back_url($self->param('back2'));
4772
3
5
        my $back2_lbl = $self->param('back2_label') // '';
4773
4774
3
60
        return $self->render(text => 'Missing x= parameter', status => 400) unless length $x_col;
4775
2
6
        return $self->render(text => 'Missing y= parameter', status => 400) unless length $y_col;
4776
4777
1
4
        my ($src, $records, $columns) = $self->_run_pipeline_source;
4778
1
2
        return $self->render(text => 'Could not open data source', status => 404) unless defined $records;
4779
4780
1
1
1
3
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
4781
1
0
0
3
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
4782
4783
1
1
2
1
        for my $s (@{ $self->every_param('f') }) {
4784
0
0
                $records = _apply_filter_spec($records, $s);
4785        }
4786
4787
1
2
1
20
3
2
        my %col_set = map { $_ => 1 } @{$columns};
4788
1
2
        return $self->render(text => "Column not found: $x_col", status => 400) unless $col_set{$x_col};
4789
1
2
        return $self->render(text => "Column not found: $y_col", status => 400) unless $col_set{$y_col};
4790
4791
1
2
        my @points;
4792
1
1
        for my $row (@$records) {
4793
5
7
                my $xv = _numeric_cell($row->{$x_col} // '');
4794
5
5
                my $yv = _numeric_cell($row->{$y_col} // '');
4795
5
7
                next unless defined $xv && defined $yv;
4796
0
0
                my %extra = map { $_ => _decode_cell($row->{$_} // '') }
4797
5
10
5
11
                            grep { $_ ne $x_col && $_ ne $y_col } @$columns;
4798
5
7
                push @points, [$xv, $yv, \%extra];
4799        }
4800
4801
1
2
        return $self->render(
4802                text   => 'No plottable data: no rows have valid numeric values in both selected columns.',
4803                status => 200,
4804        ) unless @points;
4805
4806
1
3
        my ($source_url, $source_accessed) = $self->_url_attribution;
4807
4808
1
12
        require HTML::D3;
4809
1
2
        my $title   = "$y_col vs $x_col";
4810
1
6
        my $snippet = HTML::D3->new(title => $title, width => 800, height => 480, responsive => 1)
4811                ->render_scatter_chart_snippet(\@points, {
4812                        animated => 1,
4813                        x_label  => $x_col,
4814                        y_label  => $y_col,
4815                });
4816
4817
1
2132
        my $scatter_html = $snippet->{html};
4818
1
3
        utf8::decode($scatter_html) unless utf8::is_utf8($scatter_html);
4819
4820
1
3
        my ($platform, $language) = $self->_resolve_template;
4821
1
7
        $self->render(
4822                handler         => 'tt',
4823                template        => "$platform/$language/scatter",
4824                format          => 'html',
4825                title           => $title,
4826                scatter_html    => $scatter_html,
4827                point_count     => scalar @points,
4828                x_col           => $x_col,
4829                y_col           => $y_col,
4830                back_url        => ($back2 // $back),
4831                back_label      => ($back2 ? ($back2_lbl || 'Back') : 'Back to table'),
4832                ($back2 ? (back2_url => $back, back2_label => 'Back to table') : ()),
4833                source_url      => $source_url,
4834                source_accessed => $source_accessed,
4835        );
4836}
4837
4838# ---------------------------------------------------------------------------
4839# Stacked bar chart  (GET /stacked)
4840# ---------------------------------------------------------------------------
4841
4842
5
5
5
31058
7
5
sub stacked_view ($self) {
4843
5
10
        my $cat_col   = $self->param('cat')    // '';
4844
5
736
        my $seg_col   = $self->param('seg')    // '';
4845
5
113
        my $val_col   = $self->param('val')    // '';
4846
5
106
        my $orient    = $self->param('orient') // 'v';
4847
5
103
        my $back      = _safe_back_url($self->param('back')) // '/';
4848
5
8
        my $back2     = _safe_back_url($self->param('back2'));
4849
5
9
        my $back2_lbl = $self->param('back2_label') // '';
4850
4851
5
101
        return $self->render(text => 'Missing cat= parameter', status => 400) unless length $cat_col;
4852
4
7
        return $self->render(text => 'Missing seg= parameter', status => 400) unless length $seg_col;
4853
4854
3
8
        my ($src, $records, $columns) = $self->_run_pipeline_source;
4855
3
5
        return $self->render(text => 'Could not open data source', status => 404) unless defined $records;
4856
4857
3
3
4
8
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
4858
3
0
0
6
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
4859
4860
3
3
3
4
        for my $s (@{ $self->every_param('f') }) {
4861
0
0
                $records = _apply_filter_spec($records, $s);
4862        }
4863
4864
3
9
3
55
12
5
        my %col_set = map { $_ => 1 } @{$columns};
4865
3
8
        return $self->render(text => "Column not found: $cat_col", status => 400) unless $col_set{$cat_col};
4866
3
5
        return $self->render(text => "Column not found: $seg_col", status => 400) unless $col_set{$seg_col};
4867
4868
3
8
        my $count_mode = (!length $val_col || $val_col eq '__count__');
4869        return $self->render(text => "Column not found: $val_col", status => 400)
4870
3
7
                unless $count_mode || $col_set{$val_col};
4871
4872        # Build [{cat=>, seg=>, val=>N}] via Perl fold.
4873
3
3
        my (%totals, %cat_seen, %seg_seen, @cat_order);
4874
3
5
        for my $row (@$records) {
4875
12
17
                my $cat = _decode_cell($row->{$cat_col} // '');
4876
12
15
                my $seg = _decode_cell($row->{$seg_col} // '');
4877
12
13
                next unless length $cat && length $seg;
4878
12
16
                push @cat_order, $cat unless $cat_seen{$cat}++;
4879
12
12
                $seg_seen{$seg} = 1;
4880
12
10
                if ($count_mode) {
4881
8
8
                        $totals{$cat}{$seg}++;
4882                } else {
4883
4
7
                        my $v = _numeric_cell($row->{$val_col} // '');
4884
4
6
                        $totals{$cat}{$seg} += ($v // 0) if defined $v;
4885                }
4886        }
4887
4888
3
4
        my @data;
4889
3
3
        for my $cat (@cat_order) {
4890
6
9
                for my $seg (sort keys %seg_seen) {
4891
12
24
                        push @data, { cat => $cat, seg => $seg, val => $totals{$cat}{$seg} // 0 };
4892                }
4893        }
4894
4895
3
3
        return $self->render(
4896                text   => 'No plottable data: no rows have valid data in the selected columns.',
4897                status => 200,
4898        ) unless @data;
4899
4900
3
8
        my ($source_url, $source_accessed) = $self->_url_attribution;
4901
4902
3
29
        require HTML::D3;
4903
3
7
        my $title   = $count_mode ? "Count by $cat_col and $seg_col"
4904                                  : "$val_col by $cat_col and $seg_col";
4905
3
13
        my $snippet = HTML::D3->new(title => $title, width => 900, height => 500, responsive => 1)
4906                ->render_stacked_bar_snippet(\@data, {
4907                        orient  => ($orient eq 'h') ? 'h' : 'v',
4908                        x_label => $cat_col,
4909                });
4910
4911
3
5673
        my $stacked_html = $snippet->{html};
4912
3
6
        utf8::decode($stacked_html) unless utf8::is_utf8($stacked_html);
4913
4914
3
7
        my ($platform, $language) = $self->_resolve_template;
4915
3
21
        $self->render(
4916                handler         => 'tt',
4917                template        => "$platform/$language/stacked",
4918                format          => 'html',
4919                title           => $title,
4920                stacked_html    => $stacked_html,
4921                bar_count       => scalar keys %cat_seen,
4922                back_url        => ($back2 // $back),
4923                back_label      => ($back2 ? ($back2_lbl || 'Back') : 'Back to table'),
4924                ($back2 ? (back2_url => $back, back2_label => 'Back to table') : ()),
4925                source_url      => $source_url,
4926                source_accessed => $source_accessed,
4927        );
4928}
4929
4930# ---------------------------------------------------------------------------
4931# Combo bar+line chart  (GET /combo)
4932# ---------------------------------------------------------------------------
4933
4934
4
4
4
20491
4
5
sub combo_view ($self) {
4935
4
9
        my $x_col      = $self->param('x')          // '';
4936
4
639
        my $bar_col    = $self->param('bar')         // '';
4937
4
96
        my $line_col   = $self->param('line')        // '';
4938
4
86
        my $bar_label  = $self->param('bar_label')   // '';
4939
4
84
        my $line_label = $self->param('line_label')  // '';
4940
4
79
        my $back       = _safe_back_url($self->param('back')) // '/';
4941
4
6
        my $back2      = _safe_back_url($self->param('back2'));
4942
4
6
        my $back2_lbl  = $self->param('back2_label') // '';
4943
4944
4
83
        return $self->render(text => 'Missing x= parameter',    status => 400) unless length $x_col;
4945
3
7
        return $self->render(text => 'Missing bar= parameter',  status => 400) unless length $bar_col;
4946
2
6
        return $self->render(text => 'Missing line= parameter', status => 400) unless length $line_col;
4947
4948
1
3
        my ($src, $records, $columns) = $self->_run_pipeline_source;
4949
1
3
        return $self->render(text => 'Could not open data source', status => 404) unless defined $records;
4950
4951
1
1
2
3
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
4952
1
0
0
2
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
4953
4954
1
1
2
1
        for my $s (@{ $self->every_param('f') }) {
4955
0
0
                $records = _apply_filter_spec($records, $s);
4956        }
4957
4958
1
3
1
20
4
2
        my %col_set = map { $_ => 1 } @{$columns};
4959
1
3
        return $self->render(text => "Column not found: $x_col",    status => 400) unless $col_set{$x_col};
4960
1
3
        return $self->render(text => "Column not found: $bar_col",  status => 400) unless $col_set{$bar_col};
4961
1
2
        return $self->render(text => "Column not found: $line_col", status => 400) unless $col_set{$line_col};
4962
4963        # Aggregate bar and line values by x via Perl fold.
4964
1
1
        my (%bar_sum, %line_sum, @x_order, %x_seen);
4965
1
2
        for my $row (@$records) {
4966
3
6
                my $xv = _decode_cell($row->{$x_col} // '');
4967
3
4
                next unless length $xv;
4968
3
4
                push @x_order, $xv unless $x_seen{$xv}++;
4969
3
6
                my $bv = _numeric_cell($row->{$bar_col}  // '');
4970
3
5
                my $lv = _numeric_cell($row->{$line_col} // '');
4971
3
5
                $bar_sum{$xv}  += ($bv // 0);
4972
3
4
                $line_sum{$xv} += ($lv // 0);
4973        }
4974
4975
1
2
        my @data = map { {
4976                x        => $_,
4977                bar_val  => $bar_sum{$_}  // 0,
4978
3
7
                line_val => $line_sum{$_} // 0,
4979        } } @x_order;
4980
4981
1
2
        return $self->render(
4982                text   => 'No plottable data: no rows have valid data in the selected columns.',
4983                status => 200,
4984        ) unless @data;
4985
4986
1
3
        my ($source_url, $source_accessed) = $self->_url_attribution;
4987
4988
1
11
        require HTML::D3;
4989
1
4
        my $eff_bar_lbl  = $bar_label  || $bar_col;
4990
1
3
        my $eff_line_lbl = $line_label || $line_col;
4991
1
2
        my $title        = "$eff_bar_lbl and $eff_line_lbl by $x_col";
4992
1
5
        my $snippet = HTML::D3->new(title => $title, width => 900, height => 500, responsive => 1)
4993                ->render_combo_snippet(\@data, {
4994                        bar_label  => $eff_bar_lbl,
4995                        line_label => $eff_line_lbl,
4996                });
4997
4998
1
1954
        my $combo_html = $snippet->{html};
4999
1
3
        utf8::decode($combo_html) unless utf8::is_utf8($combo_html);
5000
5001
1
2
        my ($platform, $language) = $self->_resolve_template;
5002
1
10
        $self->render(
5003                handler         => 'tt',
5004                template        => "$platform/$language/combo",
5005                format          => 'html',
5006                title           => $title,
5007                combo_html      => $combo_html,
5008                point_count     => scalar @data,
5009                back_url        => ($back2 // $back),
5010                back_label      => ($back2 ? ($back2_lbl || 'Back') : 'Back to table'),
5011                ($back2 ? (back2_url => $back, back2_label => 'Back to table') : ()),
5012                source_url      => $source_url,
5013                source_accessed => $source_accessed,
5014        );
5015}
5016
5017# ---------------------------------------------------------------------------
5018# Treemap  (GET /treemap)
5019# ---------------------------------------------------------------------------
5020
5021
3
3
3
21386
5
3
sub treemap_view ($self) {
5022
3
6
        my $cat_col   = $self->param('cat')    // '';
5023
3
390
        my $val_col   = $self->param('val')    // '';
5024
3
73
        my $scheme    = $self->param('scheme') // 'tableau10';
5025
3
130
        my $back      = _safe_back_url($self->param('back')) // '/';
5026
3
5
        my $back2     = _safe_back_url($self->param('back2'));
5027
3
6
        my $back2_lbl = $self->param('back2_label') // '';
5028
5029
3
62
        return $self->render(text => 'Missing cat= parameter', status => 400) unless length $cat_col;
5030
5031
2
5
        my ($src, $records, $columns) = $self->_run_pipeline_source;
5032
2
5
        return $self->render(text => 'Could not open data source', status => 404) unless defined $records;
5033
5034
2
2
2
7
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
5035
2
0
0
4
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
5036
5037
2
2
2
4
        for my $s (@{ $self->every_param('f') }) {
5038
0
0
                $records = _apply_filter_spec($records, $s);
5039        }
5040
5041
2
12
2
37
11
3
        my %col_set = map { $_ => 1 } @{$columns};
5042
2
5
        return $self->render(text => "Column not found: $cat_col", status => 400) unless $col_set{$cat_col};
5043
5044
2
4
        my $count_mode = (!length $val_col || $val_col eq '__count__');
5045        return $self->render(text => "Column not found: $val_col", status => 400)
5046
2
2
                unless $count_mode || $col_set{$val_col};
5047
5048
2
16
        my $agg_rows = $self->_grouped_totals(
5049                { source => $src, records => $records },
5050                {
5051                        by  => [$cat_col],
5052                        fn  => $count_mode ? 'count' : 'sum',
5053                        val => $count_mode ? undef   : $val_col,
5054                        as  => 'value',
5055                },
5056        );
5057
5058        # Top-N: keep the highest-N tiles, aggregate the rest as "Other".
5059
2
9
        my $topn_n = int($self->param('topn') // 0);
5060
2
83
        if ($topn_n > 0 && @$agg_rows > $topn_n) {
5061
0
0
0
0
                my @sorted   = sort { ($b->{value} // 0) <=> ($a->{value} // 0) } @$agg_rows;
5062
0
0
                my @top_agg  = @sorted[0 .. $topn_n - 1];
5063
0
0
0
0
                my %top_cats = map { ($_->{$cat_col} // '') => 1 } @top_agg;
5064                my $other_rec = [
5065
0
0
0
0
                        grep { !$top_cats{$_->{$cat_col} // ''} } @$records
5066                ];
5067
0
0
                if (@$other_rec) {
5068
0
0
                        my $other = $self->_grouped_totals(
5069                                { source => undef, records => $other_rec },
5070                                { by  => [], fn  => ($count_mode ? 'count' : 'sum'),
5071                                  val => ($count_mode ? undef : $val_col), as => 'value' },
5072                        );
5073                        push @top_agg, { $cat_col => 'Other', value => $other->[0]{value} }
5074
0
0
                                if @$other && defined $other->[0]{value};
5075                }
5076
0
0
                $agg_rows = \@top_agg;
5077        }
5078
5079
2
4
        return $self->render(
5080                text   => 'No plottable data: no rows have valid data in the selected column.',
5081                status => 200,
5082        ) unless @$agg_rows;
5083
5084        my @data = map {
5085
2
3
                { path => 'Root/' . _decode_cell($_->{$cat_col}), value => $_->{value} }
5086
7
10
        } @$agg_rows;
5087
5088
2
7
        my ($source_url, $source_accessed) = $self->_url_attribution;
5089
5090
2
18
        require HTML::D3;
5091
2
5
        my $title   = $count_mode ? "Treemap of $cat_col by count" : "Treemap of $cat_col by $val_col";
5092
2
11
        my $snippet = HTML::D3->new(title => $title, width => 800, height => 500, responsive => 1)
5093                ->render_treemap_snippet(\@data, {
5094                        scheme => $scheme,
5095                });
5096
5097
2
3859
        my $treemap_html = $snippet->{html};
5098
2
6
        utf8::decode($treemap_html) unless utf8::is_utf8($treemap_html);
5099
5100
2
5
        my ($platform, $language) = $self->_resolve_template;
5101
2
16
        $self->render(
5102                handler         => 'tt',
5103                template        => "$platform/$language/treemap",
5104                format          => 'html',
5105                title           => $title,
5106                treemap_html    => $treemap_html,
5107                tile_count      => scalar @data,
5108                cat_col         => $cat_col,
5109                drill_back_url  => $back,
5110                back_url        => ($back2 // $back),
5111                back_label      => ($back2 ? ($back2_lbl || 'Back') : 'Back to table'),
5112                ($back2 ? (back2_url => $back, back2_label => 'Back to table') : ()),
5113                source_url      => $source_url,
5114                source_accessed => $source_accessed,
5115        );
5116}
5117
5118# ---------------------------------------------------------------------------
5119# Histogram  (GET /histogram)
5120# ---------------------------------------------------------------------------
5121
5122
4
4
4
24999
4
3
sub histogram_view ($self) {
5123
4
8
        my $col       = $self->param('col')   // '';
5124
4
541
        my $bins      = $self->param('bins')  // 20;
5125
4
93
        my $back      = _safe_back_url($self->param('back')) // '/';
5126
4
6
        my $back2     = _safe_back_url($self->param('back2'));
5127
4
7
        my $back2_lbl = $self->param('back2_label') // '';
5128
5129
4
82
        return $self->render(text => 'Missing col= parameter', status => 400) unless length $col;
5130
5131
3
8
        my ($src, $records, $columns) = $self->_run_pipeline_source;
5132
3
7
        return $self->render(text => 'Could not open data source', status => 404) unless defined $records;
5133
5134
3
3
3
7
        eval { ($records, $columns) = $self->_apply_calc_fields($records, $columns) };
5135
3
0
0
6
0
0
        if ($@) { (my $e = $@) =~ s/ at \S+ line \d+\.?\n?$//; return $self->render(text => $e, status => 400) }
5136
5137
3
3
2
6
        for my $s (@{ $self->every_param('f') }) {
5138
0
0
                $records = _apply_filter_spec($records, $s);
5139        }
5140
5141
3
8
3
55
10
5
        my %col_set = map { $_ => 1 } @{$columns};
5142
3
8
        return $self->render(text => "Column not found: $col", status => 400) unless $col_set{$col};
5143
5144
3
3
        my @values;
5145
3
4
        for my $row (@$records) {
5146
26
25
                my $v = _numeric_cell($row->{$col} // '');
5147
26
24
                push @values, $v if defined $v;
5148        }
5149
5150
3
7
        return $self->render(
5151                text   => 'No plottable data: no rows have valid numeric values in the selected column.',
5152                status => 200,
5153        ) unless @values;
5154
5155
2
3
        $bins = int($bins);
5156
2
3
        $bins = 1   if $bins < 1;
5157
2
4
        $bins = 200 if $bins > 200;
5158
5159
2
5
        my ($source_url, $source_accessed) = $self->_url_attribution;
5160
5161
2
18
        require HTML::D3;
5162
2
2
        my $title   = "Distribution of $col";
5163
2
10
        my $snippet = HTML::D3->new(title => $title, width => 800, height => 480, responsive => 1)
5164                ->render_histogram_snippet(\@values, {
5165                        bins    => $bins,
5166                        x_label => $col,
5167                });
5168
5169
2
3877
        my $histogram_html = $snippet->{html};
5170
2
4
        utf8::decode($histogram_html) unless utf8::is_utf8($histogram_html);
5171
5172
2
5
        my ($platform, $language) = $self->_resolve_template;
5173
2
14
        $self->render(
5174                handler          => 'tt',
5175                template         => "$platform/$language/histogram",
5176                format           => 'html',
5177                title            => $title,
5178                histogram_html   => $histogram_html,
5179                value_count      => scalar @values,
5180                bins             => $bins,
5181                back_url         => ($back2 // $back),
5182                back_label       => ($back2 ? ($back2_lbl || 'Back') : 'Back to table'),
5183                ($back2 ? (back2_url => $back, back2_label => 'Back to table') : ()),
5184                source_url       => $source_url,
5185                source_accessed  => $source_accessed,
5186        );
5187}
5188
51891;
5190