| File: | blib/lib/Database/BI/Calc.pm |
| Coverage: | 84.5% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Database::BI::Calc; | |||||
| 2 | ||||||
| 3 | 3 3 3 | 118929 2 36 | use strict; | |||
| 4 | 3 3 3 | 5 2 97 | use warnings; | |||
| 5 | ||||||
| 6 | 3 3 3 | 7 2 80 | use Carp qw(croak); | |||
| 7 | 3 3 3 | 5 3 12 | use POSIX qw(floor ceil); | |||
| 8 | 3 3 3 | 945 1460 63 | use Readonly; | |||
| 9 | 3 3 3 | 6 2 5136 | use Scalar::Util qw(looks_like_number); | |||
| 10 | ||||||
| 11 | our $VERSION = '0.012.0'; | |||||
| 12 | ||||||
| 13 | Readonly my $MAX_EXPR_LEN => 512; | |||||
| 14 | Readonly my $MAX_DEPTH => 20; | |||||
| 15 | ||||||
| 16 | # --- token type constants --- | |||||
| 17 | Readonly my $T_NUM => 'NUM'; | |||||
| 18 | Readonly my $T_STR => 'STR'; | |||||
| 19 | Readonly my $T_IDENT => 'IDENT'; | |||||
| 20 | Readonly my $T_PLUS => 'PLUS'; | |||||
| 21 | Readonly my $T_MINUS => 'MINUS'; | |||||
| 22 | Readonly my $T_STAR => 'STAR'; | |||||
| 23 | Readonly my $T_SLASH => 'SLASH'; | |||||
| 24 | Readonly my $T_LPAREN => 'LPAREN'; | |||||
| 25 | Readonly my $T_RPAREN => 'RPAREN'; | |||||
| 26 | Readonly my $T_COMMA => 'COMMA'; | |||||
| 27 | Readonly my $T_EOF => 'EOF'; | |||||
| 28 | ||||||
| 29 | # Comparison operator tokens (two-char forms checked before single-char forms) | |||||
| 30 | Readonly my $T_EQ => 'EQ'; # = | |||||
| 31 | Readonly my $T_NEQ => 'NEQ'; # != | |||||
| 32 | Readonly my $T_LT => 'LT'; # < | |||||
| 33 | Readonly my $T_LE => 'LE'; # <= | |||||
| 34 | Readonly my $T_GT => 'GT'; # > | |||||
| 35 | Readonly my $T_GE => 'GE'; # >= | |||||
| 36 | ||||||
| 37 | our %MESSAGES = ( | |||||
| 38 | error_expr_too_long => 'Expression too long (max %d characters)', | |||||
| 39 | error_expr_too_deep => 'Expression nesting too deep (max %d levels)', | |||||
| 40 | error_unknown_fn => "Unknown function '%s'", | |||||
| 41 | error_unknown_col => "Unknown column '%s'", | |||||
| 42 | error_unexpected_tok => "Unexpected token '%s' at position %d", | |||||
| 43 | error_expected_tok => "Expected '%s' at position %d, got '%s'", | |||||
| 44 | error_unterminated => 'Unterminated string at position %d', | |||||
| 45 | error_bad_char => "Unexpected character '%s' at position %d", | |||||
| 46 | error_trailing => "Unexpected token '%s' at position %d", | |||||
| 47 | error_double_eq => "Use = not == for equality comparison at position %d", | |||||
| 48 | ); | |||||
| 49 | ||||||
| 50 | sub _fmt { | |||||
| 51 | 27 | 28 | my ($key, @args) = @_; | |||
| 52 | 27 | 1023 | return sprintf $MESSAGES{$key}, @args; | |||
| 53 | } | |||||
| 54 | ||||||
| 55 | # --- built-in function dispatch table --- | |||||
| 56 | # 'if' is handled as a special form in _parse_call (short-circuit branches). | |||||
| 57 | # All date functions accept ISO-8601 'YYYY-MM-DD' prefix only; non-matching | |||||
| 58 | # values return undef and appear in the empty-cell bucket rather than being | |||||
| 59 | # dropped. | |||||
| 60 | ||||||
| 61 | my %FUNCTIONS = ( | |||||
| 62 | # --- string functions --- | |||||
| 63 | upper => sub { defined $_[0] ? uc($_[0]) : undef }, | |||||
| 64 | lower => sub { defined $_[0] ? lc($_[0]) : undef }, | |||||
| 65 | trim => sub { | |||||
| 66 | return undef unless defined $_[0]; | |||||
| 67 | my $s = $_[0]; | |||||
| 68 | $s =~ s/\A\s+|\s+\z//g; | |||||
| 69 | $s; | |||||
| 70 | }, | |||||
| 71 | len => sub { defined $_[0] ? length($_[0]) : undef }, | |||||
| 72 | concat => sub { join '', map { defined $_ ? $_ : '' } @_ }, | |||||
| 73 | substr => sub { | |||||
| 74 | my ($s, $start, $len) = @_; | |||||
| 75 | return undef unless defined $s; | |||||
| 76 | $start //= 0; | |||||
| 77 | defined $len ? CORE::substr($s, $start, $len) | |||||
| 78 | : CORE::substr($s, $start); | |||||
| 79 | }, | |||||
| 80 | ||||||
| 81 | # --- numeric functions --- | |||||
| 82 | round => sub { | |||||
| 83 | my ($n, $d) = @_; | |||||
| 84 | return undef unless defined $n && looks_like_number($n); | |||||
| 85 | $d = (defined $d && looks_like_number($d)) ? int($d) : 0; | |||||
| 86 | sprintf("%.${d}f", $n) + 0; | |||||
| 87 | }, | |||||
| 88 | floor => sub { | |||||
| 89 | defined $_[0] && looks_like_number($_[0]) ? POSIX::floor($_[0]) : undef; | |||||
| 90 | }, | |||||
| 91 | ceil => sub { | |||||
| 92 | defined $_[0] && looks_like_number($_[0]) ? POSIX::ceil($_[0]) : undef; | |||||
| 93 | }, | |||||
| 94 | 'abs' => sub { | |||||
| 95 | defined $_[0] && looks_like_number($_[0]) ? CORE::abs($_[0]) : undef; | |||||
| 96 | }, | |||||
| 97 | ||||||
| 98 | # --- conditional / null-handling --- | |||||
| 99 | isnull => sub { defined $_[0] ? $_[0] : $_[1] }, | |||||
| 100 | coalesce => sub { | |||||
| 101 | for my $v (@_) { return $v if defined $v } | |||||
| 102 | return undef; | |||||
| 103 | }, | |||||
| 104 | ||||||
| 105 | # --- date functions (ISO-8601 YYYY-MM-DD prefix; no external module) --- | |||||
| 106 | year => sub { | |||||
| 107 | return undef unless defined $_[0] && $_[0] =~ /\A(\d{4})-\d\d-\d\d/; | |||||
| 108 | $1 + 0; | |||||
| 109 | }, | |||||
| 110 | month => sub { | |||||
| 111 | return undef unless defined $_[0] && $_[0] =~ /\A\d{4}-(\d\d)-\d\d/; | |||||
| 112 | $1 + 0; | |||||
| 113 | }, | |||||
| 114 | day => sub { | |||||
| 115 | return undef unless defined $_[0] && $_[0] =~ /\A\d{4}-\d\d-(\d\d)/; | |||||
| 116 | $1 + 0; | |||||
| 117 | }, | |||||
| 118 | quarter => sub { | |||||
| 119 | return undef unless defined $_[0] && $_[0] =~ /\A\d{4}-(\d\d)-/; | |||||
| 120 | int(($1 - 1) / 3) + 1; | |||||
| 121 | }, | |||||
| 122 | week => sub { | |||||
| 123 | # Returns ISO 8601 week number (1-53); week containing the first | |||||
| 124 | # Thursday of the year is week 1. | |||||
| 125 | return undef unless defined $_[0] && $_[0] =~ /\A(\d{4})-(\d\d)-(\d\d)/; | |||||
| 126 | my ($yr, $mo, $dy) = ($1 + 0, $2 + 0, $3 + 0); | |||||
| 127 | my $t = POSIX::mktime(0, 0, 12, $dy, $mo - 1, $yr - 1900); | |||||
| 128 | my @lt = localtime($t); | |||||
| 129 | POSIX::strftime('%V', @lt) + 0; | |||||
| 130 | }, | |||||
| 131 | month_name => sub { | |||||
| 132 | # Returns full English month name, e.g. 'January'. | |||||
| 133 | my @M = qw(January February March April May June | |||||
| 134 | July August September October November December); | |||||
| 135 | return undef unless defined $_[0] && $_[0] =~ /\A\d{4}-(\d\d)-/; | |||||
| 136 | $M[$1 - 1] // undef; | |||||
| 137 | }, | |||||
| 138 | weekday => sub { | |||||
| 139 | # Returns ISO 8601 weekday: 1 = Monday, 7 = Sunday. | |||||
| 140 | return undef unless defined $_[0] && $_[0] =~ /\A(\d{4})-(\d\d)-(\d\d)/; | |||||
| 141 | my ($yr, $mo, $dy) = ($1 + 0, $2 + 0, $3 + 0); | |||||
| 142 | my $t = POSIX::mktime(0, 0, 12, $dy, $mo - 1, $yr - 1900); | |||||
| 143 | my $wd = (localtime($t))[6]; # 0 = Sunday | |||||
| 144 | $wd == 0 ? 7 : $wd; | |||||
| 145 | }, | |||||
| 146 | now => sub { | |||||
| 147 | # Returns today's date as 'YYYY-MM-DD'. Takes no arguments. | |||||
| 148 | my @t = (localtime)[3, 4, 5]; # mday, mon (0-based), year (since 1900) | |||||
| 149 | sprintf '%04d-%02d-%02d', $t[2] + 1900, $t[1] + 1, $t[0]; | |||||
| 150 | }, | |||||
| 151 | ||||||
| 152 | # --- grouping / binning --- | |||||
| 153 | bin => sub { | |||||
| 154 | # bin(value, width) -> '[lo,hi)' label for the interval containing value. | |||||
| 155 | # Width must be a positive number. Both endpoints use %d when width is | |||||
| 156 | # an integer, %g otherwise, to avoid spurious ".0" suffixes. | |||||
| 157 | my ($v, $w) = @_; | |||||
| 158 | return undef unless defined $v && defined $w; | |||||
| 159 | return undef unless looks_like_number($v) && looks_like_number($w) && $w > 0; | |||||
| 160 | my $lo = POSIX::floor($v / $w) * $w; | |||||
| 161 | my $hi = $lo + $w; | |||||
| 162 | my $fmt = ($w == int($w)) ? '%d' : '%g'; | |||||
| 163 | sprintf("[${fmt},${fmt})", $lo, $hi); | |||||
| 164 | }, | |||||
| 165 | ); | |||||
| 166 | ||||||
| 167 | # --------------------------------------------------------------------------- | |||||
| 168 | # Constructor | |||||
| 169 | # --------------------------------------------------------------------------- | |||||
| 170 | ||||||
| 171 | sub new { | |||||
| 172 | 108 | 35519 | my ($class, %args) = @_; | |||
| 173 | 108 | 139 | my $self = bless { | |||
| 174 | _error => undef, | |||||
| 175 | _code => undef, | |||||
| 176 | _columns => {}, | |||||
| 177 | }, $class; | |||||
| 178 | ||||||
| 179 | 108 | 117 | my $expr = $args{expr} // ''; | |||
| 180 | 108 108 | 54 110 | my @cols = @{ $args{columns} // [] }; | |||
| 181 | 108 | 137 | $self->{_columns}{$_} = 1 for @cols; | |||
| 182 | ||||||
| 183 | 108 | 119 | if (length($expr) > $MAX_EXPR_LEN) { | |||
| 184 | 1 | 3 | $self->{_error} = _fmt('error_expr_too_long', $MAX_EXPR_LEN); | |||
| 185 | 1 | 1 | return $self; | |||
| 186 | } | |||||
| 187 | ||||||
| 188 | 107 | 221 | my $tokens; | |||
| 189 | 107 107 93 | 58 98 67 | my $ok = eval { $tokens = _tokenize($expr); 1 }; | |||
| 190 | 107 | 1482 | unless ($ok) { | |||
| 191 | 14 14 | 11 33 | my $err = $@; $err =~ s/ at \S+ line \d+\.?\n?$//; | |||
| 192 | 14 | 97 | $self->{_error} = $err; | |||
| 193 | 14 | 20 | return $self; | |||
| 194 | } | |||||
| 195 | ||||||
| 196 | 93 | 53 | my $pos = 0; | |||
| 197 | 93 | 44 | my $depth = 0; | |||
| 198 | 93 | 53 | my $code; | |||
| 199 | 93 93 82 | 39 88 55 | $ok = eval { $code = _parse_cmp($tokens, \$pos, \$depth, $self->{_columns}); 1 }; | |||
| 200 | 93 | 1963 | unless ($ok) { | |||
| 201 | 11 11 | 10 38 | my $err = $@; $err =~ s/ at \S+ line \d+\.?\n?$//; | |||
| 202 | 11 | 88 | $self->{_error} = $err; | |||
| 203 | 11 | 31 | return $self; | |||
| 204 | } | |||||
| 205 | ||||||
| 206 | 82 | 68 | if ($tokens->[$pos]{type} ne $T_EOF) { | |||
| 207 | $self->{_error} = _fmt('error_trailing', | |||||
| 208 | 1 | 4 | $tokens->[$pos]{val}, $tokens->[$pos]{pos}); | |||
| 209 | 1 | 7 | return $self; | |||
| 210 | } | |||||
| 211 | ||||||
| 212 | 81 | 161 | $self->{_code} = $code; | |||
| 213 | 81 | 187 | return $self; | |||
| 214 | } | |||||
| 215 | ||||||
| 216 | # --------------------------------------------------------------------------- | |||||
| 217 | # Public API | |||||
| 218 | # --------------------------------------------------------------------------- | |||||
| 219 | ||||||
| 220 | 28 | 50 | sub error { $_[0]->{_error} } | |||
| 221 | 107 | 185 | sub has_error { defined $_[0]->{_error} } | |||
| 222 | ||||||
| 223 | sub evaluate { | |||||
| 224 | 94 | 109 | my ($self, $row) = @_; | |||
| 225 | 94 | 84 | return undef unless defined $self->{_code}; | |||
| 226 | 94 | 41 | my $result; | |||
| 227 | 94 94 94 | 52 69 158 | my $ok = eval { $result = $self->{_code}->($row); 1 }; | |||
| 228 | 94 | 68 | return undef unless $ok; | |||
| 229 | 94 | 81 | return $result; | |||
| 230 | } | |||||
| 231 | ||||||
| 232 | # --------------------------------------------------------------------------- | |||||
| 233 | # Tokenizer | |||||
| 234 | # --------------------------------------------------------------------------- | |||||
| 235 | ||||||
| 236 | sub _tokenize { | |||||
| 237 | 107 | 67 | my ($expr) = @_; | |||
| 238 | 107 | 61 | my @tokens; | |||
| 239 | 107 | 55 | my $pos = 0; | |||
| 240 | 107 | 69 | my $len = length($expr); | |||
| 241 | 107 | 110 | my %single_ops = ( | |||
| 242 | '+' => $T_PLUS, '-' => $T_MINUS, | |||||
| 243 | '*' => $T_STAR, '/' => $T_SLASH, | |||||
| 244 | '(' => $T_LPAREN, ')' => $T_RPAREN, ',' => $T_COMMA, | |||||
| 245 | ); | |||||
| 246 | ||||||
| 247 | 107 | 988 | while ($pos < $len) { | |||
| 248 | 598 | 350 | my $ch = substr($expr, $pos, 1); | |||
| 249 | ||||||
| 250 | # skip whitespace | |||||
| 251 | 598 110 110 | 439 47 79 | if ($ch =~ /\A\s/) { $pos++; next } | |||
| 252 | ||||||
| 253 | # number literal (integer, decimal, scientific) | |||||
| 254 | 488 | 456 | if (substr($expr, $pos) =~ /\A(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/) { | |||
| 255 | 96 | 69 | push @tokens, { type => $T_NUM, val => $1, pos => $pos }; | |||
| 256 | 96 | 203 | $pos += length($1); | |||
| 257 | 96 | 62 | next; | |||
| 258 | } | |||||
| 259 | ||||||
| 260 | # double-quoted string | |||||
| 261 | 392 | 268 | if ($ch eq '"') { | |||
| 262 | 9 | 15 | my $end = index($expr, '"', $pos + 1); | |||
| 263 | 9 | 13 | croak _fmt('error_unterminated', $pos) if $end < 0; | |||
| 264 | 8 | 14 | push @tokens, { | |||
| 265 | type => $T_STR, | |||||
| 266 | val => substr($expr, $pos + 1, $end - $pos - 1), | |||||
| 267 | pos => $pos, | |||||
| 268 | }; | |||||
| 269 | 8 | 19 | $pos = $end + 1; | |||
| 270 | 8 | 7 | next; | |||
| 271 | } | |||||
| 272 | ||||||
| 273 | # single-quoted string | |||||
| 274 | 383 | 240 | if ($ch eq "'") { | |||
| 275 | 0 | 0 | my $end = index($expr, "'", $pos + 1); | |||
| 276 | 0 | 0 | croak _fmt('error_unterminated', $pos) if $end < 0; | |||
| 277 | 0 | 0 | push @tokens, { | |||
| 278 | type => $T_STR, | |||||
| 279 | val => substr($expr, $pos + 1, $end - $pos - 1), | |||||
| 280 | pos => $pos, | |||||
| 281 | }; | |||||
| 282 | 0 | 0 | $pos = $end + 1; | |||
| 283 | 0 | 0 | next; | |||
| 284 | } | |||||
| 285 | ||||||
| 286 | # identifier: letters/digits/underscore, leading letter or _ | |||||
| 287 | 383 | 338 | if (substr($expr, $pos) =~ /\A([a-zA-Z_][a-zA-Z0-9_]*)/) { | |||
| 288 | 129 | 124 | push @tokens, { type => $T_IDENT, val => $1, pos => $pos }; | |||
| 289 | 129 | 345 | $pos += length($1); | |||
| 290 | 129 | 107 | next; | |||
| 291 | } | |||||
| 292 | ||||||
| 293 | # two-character comparison operators (must be tested before single-char) | |||||
| 294 | 254 | 166 | if ($pos + 1 < $len) { | |||
| 295 | 202 | 113 | my $two = substr($expr, $pos, 2); | |||
| 296 | 202 | 125 | if ($two eq '!=') { | |||
| 297 | 2 | 2 | push @tokens, { type => $T_NEQ, val => '!=', pos => $pos }; | |||
| 298 | 2 2 | 4 2 | $pos += 2; next; | |||
| 299 | } | |||||
| 300 | 200 | 127 | if ($two eq '<=') { | |||
| 301 | 3 | 4 | push @tokens, { type => $T_LE, val => '<=', pos => $pos }; | |||
| 302 | 3 3 | 5 3 | $pos += 2; next; | |||
| 303 | } | |||||
| 304 | 197 | 128 | if ($two eq '>=') { | |||
| 305 | 3 | 1 | push @tokens, { type => $T_GE, val => '>=', pos => $pos }; | |||
| 306 | 3 3 | 7 3 | $pos += 2; next; | |||
| 307 | } | |||||
| 308 | 194 | 105 | if ($two eq '==') { | |||
| 309 | 2 | 4 | croak _fmt('error_double_eq', $pos); | |||
| 310 | } | |||||
| 311 | } | |||||
| 312 | ||||||
| 313 | # single-character comparison operators | |||||
| 314 | 244 | 158 | if ($ch eq '=') { | |||
| 315 | 4 | 4 | push @tokens, { type => $T_EQ, val => '=', pos => $pos }; | |||
| 316 | 4 4 | 6 3 | $pos++; next; | |||
| 317 | } | |||||
| 318 | 240 | 123 | if ($ch eq '<') { | |||
| 319 | 3 | 3 | push @tokens, { type => $T_LT, val => '<', pos => $pos }; | |||
| 320 | 3 3 | 6 2 | $pos++; next; | |||
| 321 | } | |||||
| 322 | 237 | 143 | if ($ch eq '>') { | |||
| 323 | 7 | 5 | push @tokens, { type => $T_GT, val => '>', pos => $pos }; | |||
| 324 | 7 7 | 16 5 | $pos++; next; | |||
| 325 | } | |||||
| 326 | ||||||
| 327 | # single-character arithmetic operators and punctuation | |||||
| 328 | 230 | 151 | if (exists $single_ops{$ch}) { | |||
| 329 | 219 | 208 | push @tokens, { type => $single_ops{$ch}, val => $ch, pos => $pos }; | |||
| 330 | 219 | 116 | $pos++; | |||
| 331 | 219 | 149 | next; | |||
| 332 | } | |||||
| 333 | ||||||
| 334 | 11 | 10 | croak _fmt('error_bad_char', $ch, $pos); | |||
| 335 | } | |||||
| 336 | ||||||
| 337 | 93 | 81 | push @tokens, { type => $T_EOF, val => '', pos => $pos }; | |||
| 338 | 93 | 240 | return \@tokens; | |||
| 339 | } | |||||
| 340 | ||||||
| 341 | # --------------------------------------------------------------------------- | |||||
| 342 | # Parser (each _parse_* returns a coderef sub { my ($row) = @_; ... }) | |||||
| 343 | # --------------------------------------------------------------------------- | |||||
| 344 | ||||||
| 345 | sub _check_depth { | |||||
| 346 | 395 | 226 | my ($depth_ref) = @_; | |||
| 347 | 395 | 262 | croak _fmt('error_expr_too_deep', $MAX_DEPTH) if $$depth_ref >= $MAX_DEPTH; | |||
| 348 | } | |||||
| 349 | ||||||
| 350 | sub _expect { | |||||
| 351 | 50 | 40 | my ($tokens, $pos_ref, $expected) = @_; | |||
| 352 | 50 | 84 | my $tok = $tokens->[$$pos_ref]; | |||
| 353 | croak _fmt('error_expected_tok', $expected, $tok->{pos}, $tok->{val}) | |||||
| 354 | 50 | 40 | unless $tok->{type} eq $expected; | |||
| 355 | 50 | 35 | $$pos_ref++; | |||
| 356 | } | |||||
| 357 | ||||||
| 358 | # cmp_expr ::= add_expr (cmp_op add_expr)? | |||||
| 359 | # Comparison yields 1 (true), 0 (false), or undef (either operand undef). | |||||
| 360 | # When both operands look numeric, numeric comparison is used; otherwise | |||||
| 361 | # case-insensitive string comparison, matching _apply_filter_spec semantics. | |||||
| 362 | sub _parse_cmp { | |||||
| 363 | 187 | 120 | my ($tokens, $pos_ref, $depth_ref, $cols) = @_; | |||
| 364 | 187 | 144 | _check_depth($depth_ref); | |||
| 365 | 186 | 322 | $$depth_ref++; | |||
| 366 | ||||||
| 367 | 186 | 134 | my $left = _parse_add($tokens, $pos_ref, $depth_ref, $cols); | |||
| 368 | ||||||
| 369 | 166 | 97 | my $type = $tokens->[$$pos_ref]{type}; | |||
| 370 | 166 | 111 | if ($type eq $T_EQ || $type eq $T_NEQ | |||
| 371 | || $type eq $T_LT || $type eq $T_LE | |||||
| 372 | || $type eq $T_GT || $type eq $T_GE) { | |||||
| 373 | 22 | 128 | my $op = $type; | |||
| 374 | 22 | 14 | $$pos_ref++; | |||
| 375 | 22 | 15 | my $right = _parse_add($tokens, $pos_ref, $depth_ref, $cols); | |||
| 376 | 22 | 11 | my ($l, $r) = ($left, $right); | |||
| 377 | $left = sub { | |||||
| 378 | 25 | 18 | my $lv = $l->($_[0]); | |||
| 379 | 25 | 21 | my $rv = $r->($_[0]); | |||
| 380 | 25 | 32 | return undef unless defined $lv && defined $rv; | |||
| 381 | 24 | 37 | if (looks_like_number($lv) && looks_like_number($rv)) { | |||
| 382 | 22 | 20 | return $op eq $T_EQ ? ($lv == $rv ? 1 : 0) | |||
| 383 | : $op eq $T_NEQ ? ($lv != $rv ? 1 : 0) | |||||
| 384 | : $op eq $T_LT ? ($lv < $rv ? 1 : 0) | |||||
| 385 | : $op eq $T_LE ? ($lv <= $rv ? 1 : 0) | |||||
| 386 | : $op eq $T_GT ? ($lv > $rv ? 1 : 0) | |||||
| 387 | : ($lv >= $rv ? 1 : 0); | |||||
| 388 | } else { | |||||
| 389 | 2 | 3 | my $cmp = lc($lv) cmp lc($rv); | |||
| 390 | 2 | 2 | return $op eq $T_EQ ? ($cmp == 0 ? 1 : 0) | |||
| 391 | : $op eq $T_NEQ ? ($cmp != 0 ? 1 : 0) | |||||
| 392 | : $op eq $T_LT ? ($cmp < 0 ? 1 : 0) | |||||
| 393 | : $op eq $T_LE ? ($cmp <= 0 ? 1 : 0) | |||||
| 394 | : $op eq $T_GT ? ($cmp > 0 ? 1 : 0) | |||||
| 395 | : ($cmp >= 0 ? 1 : 0); | |||||
| 396 | } | |||||
| 397 | 22 | 32 | }; | |||
| 398 | } | |||||
| 399 | ||||||
| 400 | 166 | 1296 | $$depth_ref--; | |||
| 401 | 166 | 115 | return $left; | |||
| 402 | } | |||||
| 403 | ||||||
| 404 | # add_expr ::= term ( ('+' | '-') term )* | |||||
| 405 | sub _parse_add { | |||||
| 406 | 208 | 114 | my ($tokens, $pos_ref, $depth_ref, $cols) = @_; | |||
| 407 | 208 | 156 | _check_depth($depth_ref); | |||
| 408 | 208 | 324 | $$depth_ref++; | |||
| 409 | ||||||
| 410 | 208 | 138 | my $left = _parse_term($tokens, $pos_ref, $depth_ref, $cols); | |||
| 411 | ||||||
| 412 | 188 | 149 | while ($tokens->[$$pos_ref]{type} eq $T_PLUS | |||
| 413 | || $tokens->[$$pos_ref]{type} eq $T_MINUS) { | |||||
| 414 | 11 | 29 | my $op = $tokens->[$$pos_ref]{type}; | |||
| 415 | 11 | 6 | $$pos_ref++; | |||
| 416 | 11 | 9 | my $right = _parse_term($tokens, $pos_ref, $depth_ref, $cols); | |||
| 417 | 11 | 21 | my ($l, $r) = ($left, $right); | |||
| 418 | 11 | 8 | if ($op eq $T_PLUS) { | |||
| 419 | $left = sub { | |||||
| 420 | 7 7 | 4 5 | my $lv = $l->($_[0]); my $rv = $r->($_[0]); | |||
| 421 | 7 | 8 | return undef unless defined $lv && defined $rv; | |||
| 422 | 7 | 13 | return undef unless looks_like_number($lv) && looks_like_number($rv); | |||
| 423 | 6 | 5 | $lv + $rv; | |||
| 424 | 8 | 31 | }; | |||
| 425 | } else { | |||||
| 426 | $left = sub { | |||||
| 427 | 7 7 | 6 6 | my $lv = $l->($_[0]); my $rv = $r->($_[0]); | |||
| 428 | 7 | 12 | return undef unless defined $lv && defined $rv; | |||
| 429 | 7 | 14 | return undef unless looks_like_number($lv) && looks_like_number($rv); | |||
| 430 | 7 | 8 | $lv - $rv; | |||
| 431 | 3 | 12 | }; | |||
| 432 | } | |||||
| 433 | } | |||||
| 434 | ||||||
| 435 | 188 | 572 | $$depth_ref--; | |||
| 436 | 188 | 113 | return $left; | |||
| 437 | } | |||||
| 438 | ||||||
| 439 | # term ::= factor ( ('*' | '/') factor )* | |||||
| 440 | sub _parse_term { | |||||
| 441 | 219 | 139 | my ($tokens, $pos_ref, $depth_ref, $cols) = @_; | |||
| 442 | ||||||
| 443 | 219 | 133 | my $left = _parse_factor($tokens, $pos_ref, $depth_ref, $cols); | |||
| 444 | ||||||
| 445 | 199 | 198 | while ($tokens->[$$pos_ref]{type} eq $T_STAR | |||
| 446 | || $tokens->[$$pos_ref]{type} eq $T_SLASH) { | |||||
| 447 | 10 | 26 | my $op = $tokens->[$$pos_ref]{type}; | |||
| 448 | 10 | 6 | $$pos_ref++; | |||
| 449 | 10 | 7 | my $right = _parse_factor($tokens, $pos_ref, $depth_ref, $cols); | |||
| 450 | 10 | 7 | my ($l, $r) = ($left, $right); | |||
| 451 | 10 | 7 | if ($op eq $T_STAR) { | |||
| 452 | $left = sub { | |||||
| 453 | 5 5 | 4 3 | my $lv = $l->($_[0]); my $rv = $r->($_[0]); | |||
| 454 | 5 | 7 | return undef unless defined $lv && defined $rv; | |||
| 455 | 5 | 7 | return undef unless looks_like_number($lv) && looks_like_number($rv); | |||
| 456 | 5 | 15 | $lv * $rv; | |||
| 457 | 5 | 13 | }; | |||
| 458 | } else { | |||||
| 459 | $left = sub { | |||||
| 460 | 3 3 | 9 3 | my $lv = $l->($_[0]); my $rv = $r->($_[0]); | |||
| 461 | 3 | 4 | return undef unless defined $lv && defined $rv; | |||
| 462 | 3 | 7 | return undef unless looks_like_number($lv) && looks_like_number($rv); | |||
| 463 | 3 | 2 | return undef if $rv == 0; | |||
| 464 | 2 | 4 | $lv / $rv; | |||
| 465 | 5 | 16 | }; | |||
| 466 | } | |||||
| 467 | } | |||||
| 468 | ||||||
| 469 | 199 | 674 | return $left; | |||
| 470 | } | |||||
| 471 | ||||||
| 472 | # factor ::= unary | '(' cmp_expr ')' | number | string | column | call | |||||
| 473 | sub _parse_factor { | |||||
| 474 | 234 | 136 | my ($tokens, $pos_ref, $depth_ref, $cols) = @_; | |||
| 475 | 234 | 130 | my $tok = $tokens->[$$pos_ref]; | |||
| 476 | ||||||
| 477 | # unary minus | |||||
| 478 | 234 | 165 | if ($tok->{type} eq $T_MINUS) { | |||
| 479 | 4 | 7 | $$pos_ref++; | |||
| 480 | 4 | 4 | my $inner = _parse_factor($tokens, $pos_ref, $depth_ref, $cols); | |||
| 481 | return sub { | |||||
| 482 | 4 | 4 | my $v = $inner->($_[0]); | |||
| 483 | 4 | 8 | return undef unless defined $v && looks_like_number($v); | |||
| 484 | 4 | 4 | -$v; | |||
| 485 | 4 | 5 | }; | |||
| 486 | } | |||||
| 487 | ||||||
| 488 | # unary plus (no-op) | |||||
| 489 | 230 | 429 | if ($tok->{type} eq $T_PLUS) { | |||
| 490 | 1 | 2 | $$pos_ref++; | |||
| 491 | 1 | 2 | return _parse_factor($tokens, $pos_ref, $depth_ref, $cols); | |||
| 492 | } | |||||
| 493 | ||||||
| 494 | # parenthesized sub-expression: allow full cmp_expr so (qty > 0) works | |||||
| 495 | 229 | 408 | if ($tok->{type} eq $T_LPAREN) { | |||
| 496 | 13 | 20 | $$pos_ref++; | |||
| 497 | 13 | 23 | my $inner = _parse_cmp($tokens, $pos_ref, $depth_ref, $cols); | |||
| 498 | 3 | 3 | _expect($tokens, $pos_ref, $T_RPAREN); | |||
| 499 | 3 | 2 | return $inner; | |||
| 500 | } | |||||
| 501 | ||||||
| 502 | # number literal | |||||
| 503 | 216 | 376 | if ($tok->{type} eq $T_NUM) { | |||
| 504 | 91 | 169 | my $val = $tok->{val} + 0; | |||
| 505 | 91 | 49 | $$pos_ref++; | |||
| 506 | 91 85 | 103 81 | return sub { $val }; | |||
| 507 | } | |||||
| 508 | ||||||
| 509 | # string literal | |||||
| 510 | 125 | 219 | if ($tok->{type} eq $T_STR) { | |||
| 511 | 5 | 10 | my $val = $tok->{val}; | |||
| 512 | 5 | 2 | $$pos_ref++; | |||
| 513 | 5 7 | 7 37 | return sub { $val }; | |||
| 514 | } | |||||
| 515 | ||||||
| 516 | # identifier -- function call or column reference | |||||
| 517 | 120 | 217 | if ($tok->{type} eq $T_IDENT) { | |||
| 518 | 120 | 202 | my $name = $tok->{val}; | |||
| 519 | 120 | 69 | $$pos_ref++; | |||
| 520 | return $tokens->[$$pos_ref]{type} eq $T_LPAREN | |||||
| 521 | ? _parse_call($tokens, $pos_ref, $depth_ref, $cols, $name, $tok->{pos}) | |||||
| 522 | 120 | 98 | : _parse_colref($cols, $name, $tok->{pos}); | |||
| 523 | } | |||||
| 524 | ||||||
| 525 | 0 | 0 | croak _fmt('error_unexpected_tok', $tok->{val}, $tok->{pos}); | |||
| 526 | } | |||||
| 527 | ||||||
| 528 | # Parse a function call. 'if' is a special form (short-circuit branches). | |||||
| 529 | # Arguments may be full cmp_expr (e.g. IF(qty > 0, amount/qty, 0)). | |||||
| 530 | sub _parse_call { | |||||
| 531 | 51 | 126 | my ($tokens, $pos_ref, $depth_ref, $cols, $name, $name_pos) = @_; | |||
| 532 | 51 | 35 | my $lc = lc($name); | |||
| 533 | ||||||
| 534 | croak _fmt('error_unknown_fn', $name) | |||||
| 535 | 51 | 66 | unless exists $FUNCTIONS{$lc} || $lc eq 'if'; | |||
| 536 | ||||||
| 537 | 47 | 33 | $$pos_ref++; # consume '(' | |||
| 538 | 47 | 21 | my @arg_fns; | |||
| 539 | 47 | 38 | if ($tokens->[$$pos_ref]{type} ne $T_RPAREN) { | |||
| 540 | 46 | 97 | push @arg_fns, _parse_cmp($tokens, $pos_ref, $depth_ref, $cols); | |||
| 541 | 46 | 41 | while ($tokens->[$$pos_ref]{type} eq $T_COMMA) { | |||
| 542 | 35 | 69 | $$pos_ref++; | |||
| 543 | 35 | 25 | push @arg_fns, _parse_cmp($tokens, $pos_ref, $depth_ref, $cols); | |||
| 544 | } | |||||
| 545 | } | |||||
| 546 | 47 | 110 | _expect($tokens, $pos_ref, $T_RPAREN); | |||
| 547 | ||||||
| 548 | # 'if' special form: short-circuit true/false branches | |||||
| 549 | 47 | 39 | if ($lc eq 'if') { | |||
| 550 | 8 | 8 | my ($cond_fn, $true_fn, $false_fn) = @arg_fns; | |||
| 551 | return sub { | |||||
| 552 | 11 | 6 | $cond_fn->($_[0]) ? $true_fn->($_[0]) : $false_fn->($_[0]); | |||
| 553 | 8 | 14 | }; | |||
| 554 | } | |||||
| 555 | ||||||
| 556 | 39 | 22 | my $fn = $FUNCTIONS{$lc}; | |||
| 557 | return sub { | |||||
| 558 | 45 | 25 | my $row = $_[0]; | |||
| 559 | 45 63 | 37 39 | $fn->(map { $_->($row) } @arg_fns); | |||
| 560 | 39 | 66 | }; | |||
| 561 | } | |||||
| 562 | ||||||
| 563 | # Parse a column reference; validates against the whitelist at compile time. | |||||
| 564 | # Underscores in the expression may match spaces in the actual column name, | |||||
| 565 | # and the comparison is case-insensitive, so per_unit_price can address | |||||
| 566 | # a column named "Per Unit Price". | |||||
| 567 | sub _parse_colref { | |||||
| 568 | 69 | 155 | my ($cols, $name, $name_pos) = @_; | |||
| 569 | ||||||
| 570 | # 1. Exact match | |||||
| 571 | 69 76 | 126 65 | return sub { $_[0]->{$name} } if $cols->{$name}; | |||
| 572 | ||||||
| 573 | # 2. Underscore-to-space alias, case-insensitive | |||||
| 574 | 8 | 14 | (my $spaced = $name) =~ s/_/ /g; | |||
| 575 | 8 | 10 | my $lc_spaced = lc($spaced); | |||
| 576 | 8 8 | 6 10 | for my $col (keys %{$cols}) { | |||
| 577 | 11 | 11 | if (lc($col) eq $lc_spaced) { | |||
| 578 | 2 | 2 | my $lookup = $col; | |||
| 579 | 2 2 | 4 3 | return sub { $_[0]->{$lookup} }; | |||
| 580 | } | |||||
| 581 | } | |||||
| 582 | ||||||
| 583 | 6 | 12 | croak _fmt('error_unknown_col', $name); | |||
| 584 | } | |||||
| 585 | ||||||
| 586 | # --------------------------------------------------------------------------- | |||||
| 587 | # Standalone date-normalisation helper (used by Dashboard::_apply_date_normalization) | |||||
| 588 | # --------------------------------------------------------------------------- | |||||
| 589 | ||||||
| 590 | Readonly my %_MONTH_NAME_TO_NUM => ( | |||||
| 591 | jan => 1, feb => 2, mar => 3, apr => 4, may => 5, jun => 6, | |||||
| 592 | jul => 7, aug => 8, sep => 9, oct => 10, nov => 11, dec => 12, | |||||
| 593 | ); | |||||
| 594 | ||||||
| 595 | # date_normalise($value, $pattern) -> 'YYYY-MM-DD' or undef | |||||
| 596 | # | |||||
| 597 | # Converts $value from the given strptime-style $pattern to ISO-8601. | |||||
| 598 | # Returns undef when $value does not match the pattern or the pattern is | |||||
| 599 | # unknown. No external modules required. | |||||
| 600 | # | |||||
| 601 | # Supported patterns: | |||||
| 602 | # %m/%d/%Y -- US slash-date (01/15/2026) | |||||
| 603 | # %d/%m/%Y -- EU slash-date (15/01/2026) | |||||
| 604 | # %d-%b-%Y -- DD-Mon-YYYY (15-Jan-2026, case-insensitive month abbrev) | |||||
| 605 | sub date_normalise { | |||||
| 606 | 3 | 3 | my ($val, $pattern) = @_; | |||
| 607 | 3 | 8 | return undef unless defined $val && defined $pattern && length $val; | |||
| 608 | 3 | 3 | if ($pattern eq '%m/%d/%Y') { | |||
| 609 | 3 | 7 | return undef unless $val =~ /\A(\d{1,2})\/(\d{1,2})\/(\d{4})\z/; | |||
| 610 | 3 | 9 | return sprintf('%04d-%02d-%02d', $3, $1, $2); | |||
| 611 | } elsif ($pattern eq '%d/%m/%Y') { | |||||
| 612 | 0 | return undef unless $val =~ /\A(\d{1,2})\/(\d{1,2})\/(\d{4})\z/; | ||||
| 613 | 0 | return sprintf('%04d-%02d-%02d', $3, $2, $1); | ||||
| 614 | } elsif ($pattern eq '%d-%b-%Y') { | |||||
| 615 | 0 | return undef unless $val =~ /\A(\d{1,2})-([A-Za-z]{3})-(\d{4})\z/; | ||||
| 616 | 0 | my $mon = $_MONTH_NAME_TO_NUM{lc($2)}; | ||||
| 617 | 0 | return undef unless defined $mon; | ||||
| 618 | 0 | return sprintf('%04d-%02d-%02d', $3, $mon, $1); | ||||
| 619 | } | |||||
| 620 | 0 | return undef; | ||||
| 621 | } | |||||
| 622 | ||||||
| 623 | 1; | |||||
| 624 | ||||||