diff --git a/.changeset/analytics-measure-filter-crossobject-refusal.md b/.changeset/analytics-measure-filter-crossobject-refusal.md new file mode 100644 index 0000000000..b65f7c5a32 --- /dev/null +++ b/.changeset/analytics-measure-filter-crossobject-refusal.md @@ -0,0 +1,9 @@ +--- +"@objectstack/service-analytics": minor +--- + +`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. + +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. + +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. diff --git a/packages/services/service-analytics/src/__tests__/crossobject-conjunct-refusal.test.ts b/packages/services/service-analytics/src/__tests__/crossobject-conjunct-refusal.test.ts index 2780348bad..d93ce427a3 100644 --- a/packages/services/service-analytics/src/__tests__/crossobject-conjunct-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/crossobject-conjunct-refusal.test.ts @@ -45,7 +45,49 @@ * leaves into the same one member view. Both producers are now judged by one * check, which is why they are pinned in one file. * - * ## Why this file pins SIX directions, not one + * ## [#11461] The third producer, and the door it left open on BOTH doors + * + * #10413 phase 2 added a THIRD route to `engine.aggregate`'s predicate: a + * compiled MEASURE's own `filter`, lowered onto that measure's + * `aggregations[].filter` entry (#10576). `filterMemberView` enumerated exactly + * two origins, and `planCrossObject`'s `query.measures` arm reads only each + * measure's resolved FIELD — so this producer was outside every check. + * + * The card was CODE-READ, not executed, so it was reproduced before it was + * fixed, over one fixture with an honest in-memory engine (one that applies + * `aggregations[].filter` as a property match on the base row, which is all + * `engine.aggregate` can do — it cannot join): + * + * ``` + * BEFORE execute() ACCEPTED -> engine.aggregate reached once with + * {field:"*",method:"count",alias:"west_count", + * filter:{"account.region":"West"}} + * -> rows [{stage:"won",total_count:3,west_count:0}, + * {stage:"lost",total_count:1,west_count:0}] + * THE SILENT 0 — the truthful west_count for + * stage "won" is 2, and total_count 3 is + * RIGHT, so the wrong number came back inside + * the same response shape as the right one + * generateSql() ACCEPTED -> SELECT stage AS "stage", COUNT(*) AS + * "total_count", COUNT(CASE WHEN + * account.region = $1 THEN 1 END) AS + * "west_count" FROM "opportunity" GROUP BY + * stage — a conditional aggregate over + * a column no FROM in that statement joins + * AFTER both doors REFUSED INVALID_FIELD / 400, member "account.region", + * cube "…", engine never reached (0 calls) + * ``` + * + * There is a published promise behind this beyond the internal inconsistency. + * `content/docs/api/data-api.mdx` documents a Request|Result table in which + * `aggregations: [{function:"sum", field:"no_such_field", …}]` 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 — which is precisely the failure class that page's preamble names as its + * reason for existing ("answer `200` with something that looked exactly like a + * served query"). This block is what makes the page true again. + * + * ## Why this file pins EIGHT directions, not one * * Pinning only the new refusals would go green on an implementation that * refuses every combinator, or every dataset scope — which would break every @@ -67,8 +109,15 @@ * ⑥ an ORDINARY dataset-level `filter` still reaches the engine CARRYING its * predicate — the load-bearing half of ⑤, and the pin a * "refuse every dataset scope" implementation fails + * ⑦ a CROSS-OBJECT per-measure `filter` is REFUSED on both doors, naming the + * measure whose declaration holds the leaf (#11461) + * ⑧ an ORDINARY per-measure `filter` still reaches the engine CARRYING its + * own `aggregations[].filter`, and a cross-object one on a measure the + * query does NOT ask for changes nothing — the two load-bearing halves of + * ⑦, and the pins a "refuse every measure filter" and a "refuse on the + * dataset's whole `measureFilters` map" implementation each fail * - * ①–④ are #10759's and are re-run unchanged here; ⑤–⑥ are #10861's. + * ①–④ are #10759's, re-run unchanged; ⑤–⑥ are #10861's; ⑦–⑧ are #11461's. */ import { describe, it, expect } from 'vitest'; @@ -80,7 +129,15 @@ import { AnalyticsService } from '../analytics-service.js'; const ctxA = { tenantId: 'org_A', userId: 'u_a' } as ExecutionContext; interface Refusal extends Error { code?: string; status?: number; member?: string; param?: string; cube?: string } -interface AggCall { object: string; filter?: unknown } +interface AggSpec { field: string; method: string; alias: string; filter?: Record } +/** + * [#11461] `aggregations` is captured too, not just the whole-call `filter`. + * The third producer never lands in the whole-call filter — it lands on ONE + * aggregation's own `filter` — so a harness that only watched `options.filter` + * could not have seen this card's defect at all, and ⑧'s "still carries its + * predicate" half would have had nothing to read. + */ +interface AggCall { object: string; filter?: unknown; aggregations?: AggSpec[] } /** A cube with one base dimension, one cross-object dimension, one base measure. */ const SALES_BY_ACCOUNT: Dataset = DatasetSchema.parse({ @@ -137,6 +194,36 @@ const MIXED_SCOPED_SALES: Dataset = DatasetSchema.parse({ measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], }) as Dataset; +/** + * [#11461] ONE dataset carrying all three of ⑦/⑧'s directions, so the + * distinctions are structural rather than three fixtures that happen to differ. + * + * `revenue` no filter at all — the neighbour every other measure is + * read against + * `won_revenue` an ORDINARY per-measure filter — must still be SERVED, and + * must still reach the engine carrying its own predicate + * `west_revenue` a CROSS-OBJECT per-measure filter — must be REFUSED + * + * `won_revenue` and `west_revenue` are one character apart in shape and travel + * the identical `measureFilters` → `aggregations[].filter` route, which is what + * makes ⑧ a pin on this refusal rather than a restatement of it. And because + * `west_revenue` is declared on the SAME dataset as `won_revenue`, a query that + * asks only for `won_revenue` is the pin that the check reads + * `query.measures`, not the dataset's whole `measureFilters` map. + */ +const MEASURE_FILTER_SALES: Dataset = DatasetSchema.parse({ + name: 'measure_filter_sales', + label: 'Measure-filtered sales', + object: 'opportunity', + include: ['account'], + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [ + { name: 'revenue', aggregate: 'sum', field: 'amount' }, + { name: 'won_revenue', aggregate: 'sum', field: 'amount', filter: { stage: 'won' } }, + { name: 'west_revenue', aggregate: 'sum', field: 'amount', filter: { 'account.region': 'West' } }, + ], +}) as Dataset; + /** * `nativeSql: false` makes `NativeSQLStrategy` decline, so every query below * routes to `ObjectQLStrategy` — the door this card is about. @@ -149,8 +236,8 @@ function serviceFor(defs: Dataset[]) { const calls: AggCall[] = []; const svc = new AnalyticsService({ queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), - executeAggregate: async (object: string, options: { filter?: unknown }) => { - calls.push({ object, filter: options.filter }); + executeAggregate: async (object: string, options: { filter?: unknown; aggregations?: AggSpec[] }) => { + calls.push({ object, filter: options.filter, aggregations: options.aggregations }); return [{ stage: 'won', revenue: 42 }]; }, }); @@ -189,6 +276,17 @@ const CROSS_OBJECT_MESSAGE = /cannot evaluate a cross-object filter \("account\. const DATASET_SCOPE_MESSAGE = /cannot evaluate the cross-object filter \("account\.region"\) that dataset "[^"]+" declares at its definition level/; +/** + * [#11461] A THIRD distinct message. The two above name where the member came + * from; this one has to name something neither can — WHICH MEASURE's own + * declaration holds the leaf. A dataset can declare two measures filtering the + * same field and mean two different edits, so a rewording that dropped the + * measure name would leave the refusal pointing at a document without saying + * where in it to look. Matched on exactly that substring. + */ +const MEASURE_FILTER_MESSAGE = + /cannot evaluate the cross-object filter \("account\.region"\) that dataset "[^"]+" declares on its measure "west_revenue"/; + // ───────────────────────────────────────────────────────────────────────────── // ① the refusal that was missing on the execution door // ───────────────────────────────────────────────────────────────────────────── @@ -464,3 +562,150 @@ describe('[#10861] a CROSS-OBJECT definition-level filter is refused on BOTH doo expect(execute?.param).toBe('where'); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// ⑦ + ⑧ [#11461] the CROSS-OBJECT per-measure filter — the third producer +// ──────────────────────────────────────────────────────────────────────────── + +/** + * THE REFUSAL IS INTENDED, on the same ruling ⑤ cites (maintainer, 2026-08-22, + * Option A — refuse at query time, folding the leaves into the one member + * view). Same hazard class, same physical verdict, one more producer. + * + * The BEFORE numbers are in this file's header block: `west_count` came back + * `0` where the truth was `2`, beside a `total_count` of `3` that was correct — + * so nothing about the response said anything had gone wrong. The echo door + * rendered `COUNT(CASE WHEN account.region = $1 THEN 1 END)` over a `FROM` with + * no join in it. Neither door refused. + * + * ⑧ below is load-bearing twice over. "Refuse a cross-object measure filter" + * has two trivially green wrong implementations — refuse EVERY measure filter, + * which breaks every conditional aggregate shipping today, and judge the + * dataset's whole `measureFilters` map rather than the measures the query + * actually asks for, which refuses a query on the strength of a declaration it + * was never going to evaluate. Both are pinned against, on the same fixture, + * one measure name away from the refused case. + */ +describe('[#11461] a CROSS-OBJECT per-measure filter is refused on BOTH doors', () => { + it('execute() refuses with the ADR-0112 envelope, before the engine is asked', async () => { + const { execute, calls } = await bothDoors('measure_filter_sales', { + dimensions: ['stage'], measures: ['revenue', 'west_revenue'], + }, [MEASURE_FILTER_SALES]); + + expect(execute, 'accepted — the measure’s own filter was invisible to the envelope check') + .toBeInstanceOf(Error); + // Read exactly as `rest-server.ts`'s catch reads them: a 4xx status AND a + // code, or the route falls through to 500 ANALYTICS_QUERY_FAILED. Asserting + // only that it throws would pass on a bare `Error` and report the platform + // broken for what is a dataset-authoring mistake on this deployment. + expect(execute?.code, 'no `code` ⇒ 500 ANALYTICS_QUERY_FAILED').toBe('INVALID_FIELD'); + expect(execute?.status, 'no `status` ⇒ 500 ANALYTICS_QUERY_FAILED').toBe(400); + // The member as the MEASURE'S FILTER spelled it, and the dataset named as + // the document to open — plus the measure inside it, in the message, which + // is the locator neither of the other two refusals has an equivalent of. + expect(execute?.member).toBe('account.region'); + expect(execute?.cube).toBe('measure_filter_sales'); + expect(String(execute?.message)).toMatch(MEASURE_FILTER_MESSAGE); + // `param` ABSENT, and this assertion is the pin on that choice. `measures` + // IS a request key and the caller did name `west_revenue` — but `member` + // here is `account.region`, and the pair `member: 'account.region'` + + // `param: 'measures'` would send a reader to look for that field inside + // `measures`, where it is not and cannot be. What is wrong is the dataset's + // DECLARATION of the measure; `cube` plus the message carry that. + expect(execute?.param, '`param` must not name a key the member cannot be found under') + .toBeUndefined(); + // The assertion this whole card is about: the BEFORE run reached the engine + // once and got a wrong number back that looked exactly like a right one. + expect(calls, 'engine.aggregate was reached — it cannot join').toEqual([]); + }); + + it('both doors agree', async () => { + const { execute, generateSql } = await bothDoors('measure_filter_sales', { + dimensions: ['stage'], measures: ['revenue', 'west_revenue'], + }, [MEASURE_FILTER_SALES]); + // One fact about one query, not two independent expectations — the same + // shape ① and ⑤ use. The echo door had its OWN lowering of this producer + // (`conditionalAggregateSql`), so "the preview accepts/rejects the same + // set" is exactly the sentence that was false here. + expect( + [execute === undefined, generateSql === undefined], + 'the preview and the execution door accept/reject the same set', + ).toEqual([false, false]); + expect(generateSql?.code).toBe('INVALID_FIELD'); + expect(generateSql?.status).toBe(400); + expect(String(generateSql?.message)).toMatch(MEASURE_FILTER_MESSAGE); + }); + + it('the still-served neighbour: an ORDINARY per-measure filter still reaches the engine CARRYING its own predicate', async () => { + // LOAD-BEARING. An implementation that refused every per-measure filter + // would go green on the two tests above and break every conditional + // aggregate shipping today (#10413 phase 2 / #10576). `stage` is one + // measure away from `account.region` in the same fixture and travels the + // identical `measureFilters` → `aggregations[].filter` route. + const { execute, generateSql, calls } = await bothDoors('measure_filter_sales', { + dimensions: ['stage'], measures: ['revenue', 'won_revenue'], + }, [MEASURE_FILTER_SALES]); + expect( + [execute === undefined, generateSql === undefined], + 'both doors must still SERVE an ordinary per-measure filter', + ).toEqual([true, true]); + expect(calls, 'the engine was not reached at all — the measure filter was refused, not served') + .toHaveLength(1); + // Dropping the predicate is as wrong as refusing it, and silently wider: + // `won_revenue` would come back equal to `revenue` and read as a real + // number. The pin is on the AGGREGATION's own filter, which is where this + // producer lands — it never touches the whole-call filter. + const won = calls[0].aggregations?.find((a) => a.alias === 'won_revenue'); + expect(won?.filter, 'reached the engine with the measure filter DROPPED — silently wider').toEqual({ stage: 'won' }); + // …and its unfiltered neighbour in the same call must NOT have acquired one. + expect(calls[0].aggregations?.find((a) => a.alias === 'revenue')?.filter).toBeUndefined(); + }); + + it('a cross-object filter on a measure the query does NOT ask for changes nothing', async () => { + // LOAD-BEARING, and the counter-shape for the test above: `west_revenue` is + // declared on THIS dataset, with the same cross-object leaf that is refused + // at the top of this block — but this query never asks for it, so neither + // door's aggregation loop ever reads its filter and no predicate the engine + // cannot join is ever built. Judging the dataset's whole `measureFilters` + // map instead of `query.measures` would refuse this query for a member that + // was never going to be evaluated, and would take every other measure on a + // dataset down with one unserveable one. + const { execute, generateSql, calls } = await bothDoors('measure_filter_sales', { + dimensions: ['stage'], measures: ['revenue'], + }, [MEASURE_FILTER_SALES]); + expect([execute === undefined, generateSql === undefined]).toEqual([true, true]); + expect(calls).toHaveLength(1); + expect( + JSON.stringify(calls[0].aggregations), + 'a filter for an unrequested measure was lowered anyway', + ).not.toContain('account.region'); + }); + + it('the KNOWN-PRESENT control: a cross-object member in the CALLER’s where keeps its own diagnostic on this fixture too', async () => { + // The counter-check for every "refused" above, on the SAME cube — so the + // refusal ⑦ adds cannot be mistaken for the fixture simply being unable to + // serve anything, and #11461 is shown not to have repainted the refusal + // #10759 restored. Refused before this card and after it, with the OTHER + // message and with `param: 'where'`. + const { execute, generateSql, calls } = await bothDoors('measure_filter_sales', { + dimensions: ['stage'], measures: ['revenue'], where: { 'account.region': 'West' }, + }, [MEASURE_FILTER_SALES]); + expect(String(execute?.message)).toMatch(CROSS_OBJECT_MESSAGE); + expect(String(generateSql?.message)).toMatch(CROSS_OBJECT_MESSAGE); + expect(execute?.param).toBe('where'); + expect(calls).toEqual([]); + }); + + it('the caller’s own where wins the diagnostic when BOTH name the same member', async () => { + // The ordering pin. `filterMemberView` inserts measure-filter leaves FIRST + // and `where` last, last write wins — so a member named by the request too + // keeps the provenance the caller can act on directly, and every shape + // refused before #11461 keeps the exact message it had. + const { execute } = await bothDoors('measure_filter_sales', { + dimensions: ['stage'], measures: ['revenue', 'west_revenue'], + where: { 'account.region': 'West' }, + }, [MEASURE_FILTER_SALES]); + expect(String(execute?.message)).toMatch(CROSS_OBJECT_MESSAGE); + expect(execute?.param).toBe('where'); + }); +}); diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index a65490a2b6..293e0004bf 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -29,14 +29,29 @@ import { } from './cross-object-rebucket.js'; /** - * [#10861] Where a member in the cross-object envelope's inventory came from. + * [#10861 / #11461] Where a member in the cross-object envelope's inventory + * came from. * - * Two producers put predicates in front of `engine.aggregate` on this path: the - * caller's own `where`, and — since PR #10758 — the compiled dataset's - * definition-level `filter`. Both are judged by the same envelope check; only - * the DIAGNOSTIC differs, because only one of them names a key the caller sent. + * THREE producers put predicates in front of `engine.aggregate` on this path: + * the caller's own `where`; the compiled dataset's definition-level `filter` + * (PR #10758); and — since #10413 phase 2 — a compiled measure's OWN `filter`, + * lowered onto that measure's `aggregations[].filter` entry (the #10576 contract + * field). All three are judged by the same envelope check; only the DIAGNOSTIC + * differs, because they differ in what the reader can go and fix: a request key, + * a dataset document, or ONE named measure inside that document. + * + * A record rather than a bare string tag, because the third producer's + * diagnostic needs a locator the KEY cannot carry. The view is keyed by RESOLVED + * FIELD NAME (`account.region`) and the actionable thing for a measure filter is + * the MEASURE whose filter named it (`west_count`) — two measures in one dataset + * can name the same field and mean two different edits. Carrying provenance was + * always this slot's job (#10861); this widens what provenance is allowed to + * say. It still never reaches a driver. */ -type FilterMemberOrigin = 'where' | 'dataset-filter'; +type FilterMemberOrigin = + | { kind: 'where' } + | { kind: 'dataset-filter' } + | { kind: 'measure-filter'; measure: string }; /** Scalar analytics operators → their SQL spelling (display SQL only). */ const SCALAR_SQL_OPS: Record = { @@ -612,7 +627,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * the members inside were unreadable from the outside and the envelope check * could not reject what it could not see. * - * ## Two producers, one inventory (#10861) + * ## Three producers, one inventory (#10861, #11461) * * The caller's `where` is not the only thing that reaches `engine.aggregate` * as a predicate. Since PR #10758 the compiled dataset's own definition-level @@ -627,6 +642,36 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * which driver will serve the dataset and would refuse a dataset that is * perfectly legal on a native-SQL deployment. * + * [#11461] #10413 phase 2 then 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). This view enumerated two + * origins, so the third was invisible to the envelope check and the arm of + * `planCrossObject` that inspects `query.measures` reads only each measure's + * resolved FIELD, never its filter. Measured on the unfixed tree, one fixture, + * both doors: + * + * ``` + * BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count", + * alias:"west_count", + * filter:{"account.region":"West"}}] + * -> rows [{stage:"won",total_count:3,west_count:0}] + * (the truthful west_count is 2; total_count + * is right, so the wrong number arrived in + * the same response shape as the right one) + * generateSql() ACCEPTED -> 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 + * ``` + * + * The same maintainer ruling covers it — same hazard, same physical verdict, + * one more producer — so it folds in HERE for the #10861 reason and not into + * `dataset-compiler.ts`, which still cannot see which driver will serve the + * dataset. Only the REQUESTED measures are folded: both doors' aggregation + * loops read `measureFilters[m]` for `m of query.measures` and nothing else, + * so a filter declared on a measure this query never asks for reaches no + * engine, and refusing on it would reject a query for a member that was never + * going to be evaluated. + * * Structure is discarded on purpose — a member is cross-object or it is not, * and which branch of a disjunction it sits in cannot make * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it @@ -636,9 +681,12 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * `planCrossObject`. The value slot carries that and nothing else; it never * reaches a driver. * - * Dataset leaves are inserted FIRST so a member named by BOTH producers keeps - * the caller's provenance (last write wins on a duplicate key): if it is in - * the request too, the request is the actionable place to fix it. + * Insertion order is measure-filter, then dataset-filter, then `where`, and + * last write wins on a duplicate key. Two things follow, in that order of + * importance. A member named by the request too keeps the CALLER's provenance, + * because if it is in the request that is the actionable place to fix it. And + * every shape that was refused before #11461 keeps the exact message it had: + * the new origin can only ever win a key no older producer names. * * Time-dimension WINDOWS are deliberately absent (they live in * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object @@ -654,16 +702,31 @@ export class ObjectQLStrategy implements AnalyticsStrategy { ): Record { // Read from the SAME channel both doors lower the scope from, so the view // and the predicate cannot disagree about what the engine will receive. - const datasetFilter = (ctx as DatasetScopedStrategyContext).getDatasetScope?.(query.cube!)?.filter; + // [#11461] The whole scope now, not just `.filter` — the per-measure filters + // travel the identical channel to the identical engine call. + const datasetScope = (ctx as DatasetScopedStrategyContext).getDatasetScope?.(query.cube!); const leaves = (node: ReturnType, origin: FilterMemberOrigin) => collectFilterLeaves(node).map( (f) => [this.resolveFieldName(cube, f.member, 'any'), origin] as const, ); + // [#11461] Keyed by measure so the refusal can name the measure to go and + // edit; `query.measures` is the iteration order both aggregation loops use, + // so the view covers exactly the filters that will be lowered. + const measureLeaves = (query.measures ?? []).flatMap((m) => { + const measureFilter = datasetScope?.measureFilters?.[m]; + return measureFilter + ? leaves( + normalizeAnalyticsFilterTree({ where: measureFilter }), + { kind: 'measure-filter', measure: m }, + ) + : []; + }); return Object.fromEntries([ - ...(datasetFilter - ? leaves(normalizeAnalyticsFilterTree({ where: datasetFilter }), 'dataset-filter') + ...measureLeaves, + ...(datasetScope?.filter + ? leaves(normalizeAnalyticsFilterTree({ where: datasetScope.filter }), { kind: 'dataset-filter' }) : []), - ...leaves(normalizeAnalyticsFilterTree(query), 'where'), + ...leaves(normalizeAnalyticsFilterTree(query), { kind: 'where' }), ]); } @@ -679,18 +742,20 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER * (needs a real join to evaluate), a cross-object leaf in the DATASET's own * definition-level `filter` (#10861 — same join it does not have, arriving - * from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a - * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values - * cannot be merged). A loud error beats the silent mis-bucket #3654 kills. + * from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own + * `filter` (#11461 — the same join again, arriving from the producer #10413 + * phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable + * measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged). + * A loud error beats the silent mis-bucket #3654 kills. * `generateSql()` calls this too, so the preview accepts/rejects the same set * — and since #10759 both callers derive `filter` from the one * {@link filterMemberView}, so that sentence is enforced by construction * instead of restated at two call sites. * - * [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` / + * [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` / * 400, naming the member — and the four that predate #10861 keep their * MESSAGES unchanged (they are good diagnostics, and #5923's tests read - * them). Each is decided by two facts and nothing else: a member that will + * them); so does #10861's own, which #11461 left untouched beside it. Each is decided by two facts and nothing else: a member that will * reach the engine's predicate, and whether that member resolves across a * join. Neither is an internal invariant — a cube where the member exists and * a driver that could serve it are both perfectly ordinary, which is exactly @@ -699,14 +764,15 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * because the fix is always to change or drop ONE named member, and because * four of them fire on `/analytics/query` where no dataset exists. * - * [#10861] The fifth is the exception that proves the rule and is written to - * it: it can only fire where a dataset DOES exist, and it is the one refusal - * here whose member no request key named — so it carries `cube` and no - * `param`, and says in its own words which document to go and edit. It stays + * [#10861, #11461] The fifth and sixth are the exceptions that prove the rule + * and are written to it: they can only fire where a dataset DOES exist, and + * they are the two refusals here whose member no request key named — so each + * carries `cube` and no `param`, and says in its own words which document to + * go and edit, the sixth naming the MEASURE inside it as well. Both stay * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict - * is the same physical one as its neighbour — this engine cannot join this + * is the same physical one as their neighbours — this engine cannot join this * member — and splitting the code by PROVENANCE would make a caller branch on - * two wire shapes for one capability limit. + * three wire shapes for one capability limit. * * Detection is on RESOLVED field names, so a dotted dimension the cube * flattens to a real column is treated as base, not cross-object. @@ -743,7 +809,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { where: 'measure', member: m, field: this.resolveMeasureAggregation(cube, m).field, })), ...Object.entries(filter) - .filter(([, origin]) => origin === 'where') + .filter(([, origin]) => origin.kind === 'where') .map(([f]) => ({ where: 'filter', member: f, field: f })), ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject)); if (nonDim.length > 0) { @@ -782,7 +848,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // locator that IS actionable: the dataset whose definition holds the leaf. const scopeCross = Object.entries(filter) .filter(([field, origin]) => - origin === 'dataset-filter' && this.isCrossObjectField(cube, field, baseObject)) + origin.kind === 'dataset-filter' && this.isCrossObjectField(cube, field, baseObject)) .map(([field]) => field); if (scopeCross.length > 0) { throw invalidMemberError( @@ -797,6 +863,65 @@ export class ObjectQLStrategy implements AnalyticsStrategy { ); } + // [#11461] The THIRD producer, and the same physical verdict a third time: + // a leaf of one compiled MEASURE's own `filter`, lowered onto that measure's + // `aggregations[].filter` entry (#10413 phase 2 / #10576). Checked last, so + // every shape refused before this card is refused with the message it + // already had. + // + // What it closes, measured on the unfixed tree over one fixture, both doors + // in one run — the card was code-read, so this was reproduced first: + // + // ``` + // BEFORE execute() ACCEPTED, engine.aggregate 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 wearing + // the shape of the right one + // generateSql() ACCEPTED, rendering + // COUNT(CASE WHEN account.region = $1 THEN 1 END) + // over a FROM carrying no join whatsoever + // ``` + // + // Same family and same envelope as its two neighbours — `INVALID_FIELD` / + // 400 — because it is the same physical fact about the same member: this + // engine has no join. It is ALSO the arm that squares this 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, answering `200` with a silent 0 — which is + // the exact failure mode that page's own preamble says it exists to rule + // out. + // + // `param` is ABSENT for the #10861 reason, and the reason bites harder here + // rather than less. `measures` IS a request key and the caller did name the + // measure — but `member` is the cross-object FIELD, and `member` + + // `param: 'measures'` would send a reader to look for `account.region` + // inside `measures`, where it is not and cannot be. What is wrong is the + // dataset's DECLARATION of that measure, so the message names the measure + // and `cube` carries the document to open. Widening the envelope with a + // `measure` field of its own would be a new wire shape for one diagnostic; + // the message is where a locator with no request key belongs. + const measureCross = Object.entries(filter).flatMap(([field, origin]) => + origin.kind === 'measure-filter' && this.isCrossObjectField(cube, field, baseObject) + ? [{ field, measure: origin.measure }] + : [], + ); + if (measureCross.length > 0) { + const { field, measure } = measureCross[0]; + throw invalidMemberError( + `[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ` + + `("${field}") that dataset "${cube.name}" declares on its measure ` + + `"${measure}" — the engine cannot join in an aggregate, so this measure ` + + `would be counted over a predicate that matches nothing and would answer ` + + `0 rather than the scoped number. Remove the cross-object leaf from that ` + + `measure's own \`filter\`, or serve this dataset on a native-SQL driver, ` + + `where the same definition is valid.`, + { member: field, cube: cube.name }, + ); + } + // Collect cross-object DIMENSIONS (single-hop only). const crossDims: CrossObjectPlanDim[] = []; for (const dim of query.dimensions ?? []) {