Skip to content

Commit e1383be

Browse files
committed
fix(analytics): declare the whole engine-path refusal set a declared default bucket reaches; pin the joined-cube case
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c8b38a commit e1383be

5 files changed

Lines changed: 120 additions & 24 deletions

File tree

‎.changeset/20282-analytics-cube-format-granularities-enforced.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Clause-②: yes (narrowing)
99

1010
<!-- adr-0087: registered analytics-cube-single-granularity-default-enforced -->
1111

12-
**BREAKING**: this narrows what `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` answer for one class of request. When an authored cube's time dimension declares exactly one granularity, a query that groups by that dimension without stating a granularity is now bucketed at the declared one — and a bucketed query is served by the engine aggregate path, which refuses a custom-SQL measure (a measure of type `number`, `string` or `boolean` whose `sql` is an expression) with `400 INVALID_FIELD`. Such a query was answered before this change, with one group per distinct timestamp; it is now refused, exactly as it already was when the caller stated that granularity by hand. A host that overrides `queryCapabilities` to offer raw SQL with no engine aggregate bridge (the plugin's default wires both) now answers such a query "No strategy can handle query" instead of grouping raw timestamps. The remedy: query the custom-SQL measure without grouping by that dimension, or, if the dimension is not meant to have one default bucket, declare the granularities it offers as a list of two or more (or omit the key). It ships as `minor` under the launch-window convention; the widening half is two authored keys taking effect.
12+
**BREAKING**: this narrows what `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` answer for one class of request. When an authored cube's time dimension declares exactly one granularity, a query that groups by that dimension without stating a granularity is now bucketed at the declared one. The raw-SQL path declines every bucketed query, so such a query now runs on the engine aggregate path, which answers `400 INVALID_FIELD` for every member it cannot evaluate: a custom-SQL measure (a measure of type `number`, `string` or `boolean` whose `sql` is an expression); and, on a cube whose members resolve through its `joins`, a measure or a `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a `dateRange` window, so grouping by a one-granularity time dimension over a joined object is refused too), a dimension that traverses more than one relationship, and an `avg` or `count_distinct` measure beside any dimension over a joined object. The raw-SQL path answers every one of these, with one group per distinct timestamp; each is now refused, exactly as it already was when the caller stated that granularity by hand. On a host that overrides `queryCapabilities` to offer raw SQL with no engine aggregate bridge (the plugin's default wires both), no strategy remains for a bucketed query, so every newly bucketed query, a plain `count` included, now answers "No strategy can handle query" instead of grouping raw timestamps. The remedy: run such a query without grouping by that dimension, or, if the dimension is not meant to have one default bucket, declare the granularities it offers as a list of two or more (or omit the key); on a raw-SQL-only host, add the engine aggregate bridge. It ships as `minor` under the launch-window convention; the widening half is two authored keys taking effect.
1313

1414
Until this change both keys were read on the compiled-dataset path only. One cube shape has three producers — cubes authored with `defineCube()` / `defineStack({ analyticsCubes })`, cubes the dataset compiler mints, and cubes inferred for an ad-hoc query — and only a compiled dataset's cube reached the two readers:
1515

‎packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,4 +321,70 @@ describe('analytics_cube.dimensions.granularities — the declared single granul
321321
await service.query({ cube: 'orders', measures: ['done_rate'], dimensions: ['shipped_at'] });
322322
expect(sqls).toHaveLength(1);
323323
});
324+
325+
it('DECLARED NARROWING, joined cube: a cross-object measure grouped by a declared-default dimension gets the refusal a stated granularity gets', async () => {
326+
// The engine path's other refusals are reached by the same re-route: on a
327+
// cube that declares `joins`, a member resolving through one (here a
328+
// measure over `account.balance`) is refused by
329+
// `ObjectQLStrategy.planCrossObject`, where the raw-SQL path joins it.
330+
const joined: Cube = CubeSchema.parse({
331+
...authored,
332+
measures: {
333+
...authored.measures,
334+
account_balance: { label: 'Account balance', type: 'sum', sql: 'account.balance' },
335+
},
336+
joins: { account: { name: 'crm_account' } },
337+
});
338+
const aggregated: string[] = [];
339+
const sqls: string[] = [];
340+
const service = new AnalyticsService({
341+
logger: silentLogger,
342+
cubes: [joined],
343+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
344+
executeRawSql: async (_o, sql) => {
345+
sqls.push(sql);
346+
return [];
347+
},
348+
executeAggregate: async (object) => {
349+
aggregated.push(object);
350+
return [];
351+
},
352+
});
353+
const envelope = (e: any) => ({
354+
code: e?.code,
355+
status: e?.status,
356+
message: e?.message,
357+
member: e?.member,
358+
param: e?.param,
359+
cube: e?.cube,
360+
keys: e instanceof Error ? Object.keys(e).sort() : undefined,
361+
});
362+
363+
const byDefault = await service
364+
.query({ cube: 'orders', measures: ['account_balance'], dimensions: ['placed_at'] })
365+
.catch((e: unknown) => e);
366+
const byHand = await service
367+
.query({
368+
cube: 'orders',
369+
measures: ['account_balance'],
370+
dimensions: ['placed_at'],
371+
timeDimensions: [{ dimension: 'placed_at', granularity: 'month' }],
372+
})
373+
.catch((e: unknown) => e);
374+
375+
expect({ code: envelope(byDefault).code, status: envelope(byDefault).status }).toEqual({
376+
code: 'INVALID_FIELD',
377+
status: 400,
378+
});
379+
// Not a new refusal: byte-equal to the one stating the granularity by hand already got.
380+
expect(envelope(byDefault)).toEqual(envelope(byHand));
381+
expect(aggregated).toEqual([]);
382+
expect(sqls).toEqual([]);
383+
384+
// Control: the same cross-object measure grouped by a dimension that
385+
// declares no single default is still answered, joined, on the raw-SQL path.
386+
await service.query({ cube: 'orders', measures: ['account_balance'], dimensions: ['shipped_at'] });
387+
expect(sqls).toHaveLength(1);
388+
expect(sqls[0]).toMatch(/JOIN/);
389+
});
324390
});

‎packages/spec/liveness/analytics_cube.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
"verifiedAt": "2026-09-29",
109109
"evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredGranularityDefaults — on `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`), before the source-field gates and strategy selection run, a time dimension the query GROUPS BY with no stated granularity is bucketed at the default its cube dimension declares; packages/services/service-analytics/src/dataset-executor.ts#declaredDefaultGranularity is that default — the one reading of this key, a single-entry list — shared with the compiled-dataset path's `granularityOf`, so both producers' cubes are read by one rule. Pinned against a compiled-dataset control in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over both routes in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.",
110110
"producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).",
111-
"note": "Dead until 2026-09-29: the one read (`granularityOf`) took a `CompiledDataset`, which an authored cube never becomes, so grouping by an authored time dimension grouped raw timestamps whatever it declared. What the key does now is exactly the compiled-dataset path's reading: a SINGLE-entry list is the dimension's default bucket; a stated `timeDimensions[].granularity` always wins; a multi-entry list (the compiler's five-entry 'all intervals' shape included) states no default; an entry carrying only a `dateRange` for a dimension the query does not group stays a filter. ⚠️ What it does NOT do: refuse a requested granularity the list does not name — the dataset path compares against no list, so neither does this — and whether a multi-entry list should narrow the accept set (the 'Supported Granularities' reading of the declaration) is an open question on #20282, not decided by this row. A declared default routes a grouped query to the engine path, because NativeSQL declines any bucketed query, and the engine path refuses a custom-SQL measure — the same answer a caller who states that granularity by hand gets. The dataset-side authoring key is still `dataset.dimensions[].dateGranularity` (dataset.json), which the compiler lowers into this slot. ⛔ Retirement was never the remedy."
111+
"note": "Dead until 2026-09-29: the one read (`granularityOf`) took a `CompiledDataset`, which an authored cube never becomes, so grouping by an authored time dimension grouped raw timestamps whatever it declared. What the key does now is exactly the compiled-dataset path's reading: a SINGLE-entry list is the dimension's default bucket; a stated `timeDimensions[].granularity` always wins; a multi-entry list (the compiler's five-entry 'all intervals' shape included) states no default; an entry carrying only a `dateRange` for a dimension the query does not group stays a filter. ⚠️ What it does NOT do: refuse a requested granularity the list does not name — the dataset path compares against no list, so neither does this — and whether a multi-entry list should narrow the accept set (the 'Supported Granularities' reading of the declaration) is an open question on #20282, not decided by this row. A declared default routes a grouped query to the engine path, because NativeSQL declines any bucketed query, and the engine path answers 400 `INVALID_FIELD` — the same answer a caller who states that granularity by hand gets — for every member it cannot evaluate: a custom-SQL measure (`ObjectQLStrategy.resolveMeasureAggregation`), and, from `ObjectQLStrategy.planCrossObject`, on a cube whose members resolve through its `joins`: a measure or `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a window), a dimension that traverses more than one relationship, and an `avg` / `count_distinct` measure beside a dimension over a joined object (planCrossObject's two dataset-definition arms cannot fire on an authored cube). NativeSQL serves all of them, so each goes from answered to refused when grouped by a one-interval dimension. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge (a hand override), no strategy remains, so every newly bucketed query answers \"No strategy can handle query\". The declared narrowing and its D3 entry `analytics-cube-single-granularity-default-enforced` name this class. The dataset-side authoring key is still `dataset.dimensions[].dateGranularity` (dataset.json), which the compiler lowers into this slot. ⛔ Retirement was never the remedy."
112112
}
113113
}
114114
},

‎packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import type { SemanticMigration } from '../../types.js';
44

5-
// An inert key made real, and the one request class that goes from answered to
6-
// refused because of it — the shape of
5+
// An inert key made real, and the request class that goes from answered to
6+
// refused because of it: the engine aggregate path's whole refusal set, which a
7+
// newly bucketed query reaches by leaving the raw-SQL path. The shape of
78
// `analytics-cube-public-default-visible-enforced`. There is no D2 conversion:
89
// no spelling moves, and whether a one-interval list means "bucket by this by
910
// default" or "the one interval I happened to list" is the author's intent,
@@ -20,8 +21,9 @@ export const entry: SemanticMigration = {
2021
replacement:
2122
'nothing, when that one interval is the bucket the dimension should be grouped at by default. '
2223
+ 'When it is not, list every interval the dimension serves (two or more state no default) or '
23-
+ 'omit the key. A dashboard or report that charts a custom-SQL measure over such a dimension '
24-
+ 'either stops grouping by it or groups by a dimension that declares no single interval',
24+
+ 'omit the key. A dashboard or report whose query the engine aggregate path cannot evaluate '
25+
+ '(a custom-SQL measure, or a member of a cube with `joins` that resolves through one) either '
26+
+ 'stops grouping by such a dimension or groups by one that declares no single interval',
2527
reason:
2628
'An inert key made real. A cube time dimension\'s `granularities` was read only for a cube the '
2729
+ 'dataset compiler minted, where a one-interval list is the dataset\'s default bucket. A cube '
@@ -33,11 +35,20 @@ export const entry: SemanticMigration = {
3335
+ 'A granularity the query states still wins, one the list does not name is not refused, and a '
3436
+ 'list of two or more states no default. Two holdings change on upgrade. A query grouping by '
3537
+ 'such a dimension answers one row per bucket where it answered one row per timestamp. And a '
36-
+ 'bucketed query is served by the engine aggregate path, which refuses a custom-SQL measure — a '
37-
+ '`number`, `string` or `boolean` measure whose `sql` is an expression — with 400 '
38-
+ '`INVALID_FIELD`, the same refusal, byte for byte, that the same query already got with that '
39-
+ 'granularity stated by hand; so such a measure grouped by such a dimension goes from answered '
40-
+ 'to refused. The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired '
38+
+ 'bucketed query leaves the raw-SQL path, which declines every bucketed query, for the engine '
39+
+ 'aggregate path, which answers 400 `INVALID_FIELD` for every member it cannot evaluate — the '
40+
+ 'same refusal, byte for byte, that the same query already got with that granularity stated by '
41+
+ 'hand. Those members are: a custom-SQL measure (a `number`, `string` or `boolean` measure '
42+
+ 'whose `sql` is an expression); and, on a cube whose members resolve through its `joins`, a '
43+
+ 'measure or a `where` field over a joined object, a `timeDimensions` entry over a joined object '
44+
+ '(bucketed or a window, so grouping by a one-interval time dimension over a joined object is '
45+
+ 'refused too), a dimension that traverses more than one relationship, and an `avg` or '
46+
+ '`count_distinct` measure beside any dimension over a joined object. The raw-SQL path serves '
47+
+ 'every one of these, so each such query grouped by such a dimension goes from answered to '
48+
+ 'refused. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge '
49+
+ '(a hand override: the analytics plugin wires both), no strategy remains for a bucketed '
50+
+ 'query, so every newly bucketed query, a plain count included, goes from answered to "No '
51+
+ 'strategy can handle query". The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired '
4152
+ 'sub-day intervals from every authored and stored cube, so a dimension that offered one '
4253
+ 'sub-day interval and one coarser interval now holds a one-interval list: a default bucket its '
4354
+ 'author never wrote.',
@@ -46,6 +57,10 @@ export const entry: SemanticMigration = {
4657
+ 'bucket at that interval by default: a query that groups by it on `/analytics/query` answers '
4758
+ 'one row per bucket, and `/analytics/sql` shows the bucketed statement. Every time dimension '
4859
+ 'that should have no default lists two or more intervals or omits the key. No dashboard or '
49-
+ 'report groups a custom-SQL measure by a one-interval dimension — or each one that did now '
50-
+ 'groups by a dimension without a single interval.',
60+
+ 'report groups by a one-interval dimension a query the engine aggregate path refuses — a '
61+
+ 'custom-SQL measure; a measure, `where` field or `timeDimensions` entry over a joined object; '
62+
+ 'a dimension that traverses more than one relationship; an `avg` or `count_distinct` measure '
63+
+ 'beside a dimension over a joined object — or each one that did now groups by a dimension '
64+
+ 'without a single interval. A host that overrides `queryCapabilities` to raw SQL only either '
65+
+ 'adds an engine aggregate bridge or groups by no one-interval dimension.',
5166
};

0 commit comments

Comments
 (0)