feat(aggregation): conditional metrics, and refuse unknown filter operators - #2904
Merged
rubenvdlinde merged 1 commit intoAug 27, 2026
Merged
Conversation
…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.
Contributor
Quality Report — ConductionNL/openregister @
|
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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[].conditionscopes one figure to a subset of the grouped rows.That is what a debit/credit split needs —
totalDebitandtotalCreditare thesame
SUMover the same field, separated only byside.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.
metrics[].asnames the response key, and conditional metrics require it:two conditional sums over one field both derive
sum_amount, so the second wouldoverwrite 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 andkeys 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
conditionandasbefore the runner ever saw them. Must-failcontrol: revert that line and
openTotalcomes back 35.0 — theunconditioned total — instead of 30.0. Not an error; a plausible wrong number.
Validation
The validator refuses a string
condition, an emptyas, and a condition naminga 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()'sdefault => truelet an unrecognised operator match every row,so the filter widened instead of narrowing.
Measured in shillinq:
{"not-in": [...]}— the implemented spelling isnotIn— 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 iswrong today. The others (
equals,not,between,gteOrNull,notStartsWith,not_in) sit on aggregations that compute nothing yet, so theywill now fail loudly when someone gives them a metric — rather than returning a
quietly widened set.
Verified
phpcsandphpmdclean on the changed filesconditional spec on the native path →
sqlitenotphp-fallback)notInstillexcluding, and four validator refusals