Skip to content

Commit 399ecad

Browse files
claude[bot]claude
andauthored
fix(service-analytics): refuse a cross-object per-measure filter on both ObjectQL doors (#12022)
`ObjectQLStrategy` judges every query by one member view (`filterMemberView`), documented as "two producers, one inventory" (#10861): the caller's `where` and the compiled dataset's definition-level `filter`. #10413 phase 2 added a third producer with the same reach and none of the coverage — a compiled measure's own `filter`, lowered onto that measure's `aggregations[].filter` entry (#10576) — and `planCrossObject`'s `query.measures` arm reads only each measure's resolved FIELD, never its filter. So a cross-object leaf there reached `engine.aggregate` unrefused on both doors. Reproduced first (the card was code-read, not executed), one fixture, an honest in-memory engine that applies `aggregations[].filter` as a property match — all `engine.aggregate` can do, since it cannot join: BEFORE execute() ACCEPTED, engine reached once with {field:"*",method:"count",alias:"west_count", filter:{"account.region":"West"}} and answered west_count 0 where the truth is 2 — beside a correct total_count 3, so the wrong number came back in the same response shape as the right one generateSql() ACCEPTED, rendering COUNT(CASE WHEN account.region = $1 THEN 1 END) over a FROM with no join in it at all AFTER both doors REFUSED, INVALID_FIELD/400, engine never reached Mirrors #10861 exactly, on the same maintainer ruling (2026-08-22, Option A — refuse at query time, folding the leaves into the one member view): the origin becomes a record so provenance can carry the MEASURE the field cannot, only the REQUESTED measures are folded (both aggregation loops read `measureFilters[m]` for `m of query.measures` and nothing else), and insertion order keeps every shape refused before this card on the exact message it already had. Beyond the internal inconsistency this squares the door with a published promise: `content/docs/api/data-api.mdx` documents that a bad field in an `aggregations` entry answers 400 INVALID_FIELD. The same `aggregations` object kept that promise in the `field` position and broke it in the `filter` position — 200 with a silent 0, the exact failure class that page's preamble names as its reason for existing. The page needs no edit: no sentence in it describes the exception, so it is simply true again. Fixes #11461 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 911da5f commit 399ecad

3 files changed

Lines changed: 411 additions & 32 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
`ObjectQLStrategy` now refuses a cross-object leaf in a compiled measure's own `filter`, on both of its doors, instead of sending it to an engine that cannot join (#11461). This is the third producer of a predicate on that path — after the caller's `where` and the dataset's definition-level `filter` (#10861) — and the one `filterMemberView` did not fold in: #10413 phase 2 lowers `measureFilters[m]` onto that measure's `aggregations[].filter` entry (#10576), and the envelope check enumerated only two origins while its `query.measures` arm read each measure's resolved *field* and never its filter.
6+
7+
Measured on one fixture before the change, both doors in one run: a measure declaring `filter: { 'account.region': 'West' }` on a cube with `include: ['account']` was ACCEPTED, `engine.aggregate` received `{field:"*",method:"count",alias:"west_count",filter:{"account.region":"West"}}`, and an honest evaluator answered `west_count: 0` where the truthful answer was `2` — beside a correct `total_count: 3`, so the wrong number came back wearing the same response shape as the right one. The `/analytics/sql` echo rendered `COUNT(CASE WHEN account.region = $1 THEN 1 END)` over a `FROM` carrying no join at all. Both doors now answer `INVALID_FIELD`/400 before the engine is reached, naming the offending field, the dataset, and — the locator neither sibling refusal has — the measure whose declaration holds the leaf.
8+
9+
Ordinary per-measure filters are unaffected and still reach the engine carrying their own `aggregations[].filter`, and a cross-object filter declared on a measure a query does not ask for changes nothing: only the measures in `query.measures` are judged, which is exactly the set both doors lower. The same definition remains valid on a native-SQL driver, which the refusal says.

0 commit comments

Comments
 (0)