Skip to content

feat(aggregation): conditional metrics, and refuse unknown filter operators - #2904

Merged
rubenvdlinde merged 1 commit into
developmentfrom
feat/conditional-metric-and-strict-operators
Aug 27, 2026
Merged

feat(aggregation): conditional metrics, and refuse unknown filter operators#2904
rubenvdlinde merged 1 commit into
developmentfrom
feat/conditional-metric-and-strict-operators

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Unblocks the largest group in ConductionNL/shillinq#1261. Two changes to the same
machinery, both about a filter that silently answers the wrong question rather
than failing
.

Conditional metrics

metrics[].condition scopes one figure to a subset of the grouped rows.
That is what a debit/credit split needs — totalDebit and totalCredit are the
same SUM over the same field, separated only by side.

"metrics": [
  {"metric": "sum", "field": "amount", "condition": {"side": "debit"},  "as": "totalDebit"},
  {"metric": "sum", "field": "amount", "condition": {"side": "credit"}, "as": "totalCredit"}
]

Declaring two aggregations instead is not equivalent: it groups and scans the
table twice, and the two results can disagree if a row is written between the
calls — exactly what a trial balance must never do.

It is a filter object, deliberately, not a SQL string. The same shape as the
aggregation's own filter, through the same applyFilter(). One grammar, one
implementation. A second string-shaped grammar is how a consuming app ended up
with 265 declarations this engine could not read.

metrics[].as names the response key, and conditional metrics require it:
two conditional sums over one field both derive sum_amount, so the second would
overwrite the first and return one figure where the caller asked for two.

The native SQL path refuses a conditional spec rather than running it.
tryNativeMultiMetric() aggregates every entry over the same filtered rows and
keys from metric+field, so it would drop the condition and collide the aliases —
answering wrongly and fast.

Writing the test found a defect in the implementation

AggregationQuery::getMetrics() rebuilt each entry as {metric, field},
stripping condition and as before the runner ever saw them. Must-fail
control: revert that line and openTotal comes back 35.0 — the
unconditioned total — instead of 30.0. Not an error; a plausible wrong number.

Validation

The validator refuses a string condition, an empty as, and a condition naming
a property the schema does not declare. That last one matters most: at run time
such a filter is not an error — it matches nothing and returns an empty result,
which a page renders as "no data" over live rows.

Unknown filter operators now throw

checkOn()'s default => true let an unrecognised operator match every row,
so the filter widened instead of narrowing.

Measured in shillinq: {"not-in": [...]} — the implemented spelling is notIn
— meant an AR-ageing report silently included settled invoices.

Blast radius measured before changing it: exactly one metric-bearing
aggregation in shillinq uses an unknown operator (APInvoice.apAging), and it is
wrong today. The others (equals, not, between, gteOrNull,
notStartsWith, not_in) sit on aggregations that compute nothing yet, so they
will now fail loudly when someone gives them a metric — rather than returning a
quietly widened set.

Verified

  • 17506 tests, 0 failures
  • phpcs and phpmd clean on the changed files
  • both must-fail controls confirmed (condition stripped → 35.0 not 30.0;
    conditional spec on the native path → sqlite not php-fallback)
  • new tests cover: the split itself, the native-path refusal, notIn still
    excluding, and four validator refusals

…rators

Two changes to the same machinery, both about a filter that silently
answers the wrong question rather than failing.

CONDITIONAL METRICS

`metrics[].condition` scopes ONE figure to a subset of the grouped rows.
That is what a debit/credit split needs: `totalDebit` and `totalCredit`
are the same SUM over the same field, separated only by `side`. Declaring
two aggregations instead is not equivalent — it groups and scans the table
twice, and the two results can disagree if a row is written between the
calls, which is exactly what a trial balance must never do.

It is a FILTER OBJECT, deliberately, not a SQL string: the same shape as
the aggregation's own `filter`, through the same applyFilter(). One
grammar, one implementation. A second string-shaped grammar is how a
consuming app ended up with 265 declarations this engine could not read.

`metrics[].as` names the response key, and conditional metrics REQUIRE it:
two conditional sums over one field both derive `sum_amount`, so the
second would overwrite the first and return one figure where the caller
asked for two.

The native SQL path REFUSES a conditional spec rather than running it.
tryNativeMultiMetric() aggregates every entry over the same filtered rows
and keys from metric+field, so it would drop the condition and collide the
aliases — answering wrongly and fast.

🔑 Writing the test found a defect in the implementation:
AggregationQuery::getMetrics() rebuilt each entry as {metric, field},
STRIPPING condition and as before the runner ever saw them. Must-fail
control: revert that and `openTotal` comes back 35.0 — the unconditioned
total — instead of 30.0.

The validator refuses a string `condition`, an empty `as`, and a condition
naming a property the schema does not declare. That last one matters most:
at run time such a filter is not an error, it matches nothing and returns
an empty result, which a page renders as "no data" over live rows.

UNKNOWN FILTER OPERATORS NOW THROW

checkOn()'s `default => true` let an unrecognised operator match EVERY
row, so the filter widened instead of narrowing. Measured in shillinq:
`{"not-in": [...]}` — the implemented spelling is `notIn` — meant an
AR-ageing report silently included settled invoices.

Blast radius measured before changing it: exactly ONE metric-bearing
aggregation in shillinq uses an unknown operator (APInvoice.apAging), and
it is wrong today. The rest (`equals`, `not`, `between`, `gteOrNull`,
`notStartsWith`, `not_in`) sit on aggregations that compute nothing yet, so
they will now fail loudly when someone gives them a metric rather than
returning a quietly widened set.

Verified: 17506 tests, 0 failures; phpcs and phpmd clean on the changed
files; both must-fail controls confirmed.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 9196bdb

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
format
check-schema-l10n
check-l10n-js
composer ✅ 175/175
npm ✅ 545/545
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-27 07:08 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit d100f8e into development Aug 27, 2026
47 checks passed
@rubenvdlinde
rubenvdlinde deleted the feat/conditional-metric-and-strict-operators branch August 27, 2026 07:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant