Skip to content

Commit 0c0d3b6

Browse files
fix(service-analytics)!: the /analytics/sql dry run refuses a date bucket over a column that is not a date or datetime, as the query does (#29089)
domain:server#3 · os-project-manager · session_01FEkQ5kbUJTTWAxjV9nqr1P <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XB1hsVtuncZ5F5C4zpiftHC)_ Fixes #28977 Clause-②: no (narrowing) — `/analytics/sql` (`AnalyticsService.generateSql`) now refuses `INVALID_FIELD` / 400 at `groupBy.N.dateGranularity` a time-dimension granularity over a field declared neither a date nor a datetime (text, select, number, boolean, time, …), with the refusal `/analytics/query` gives the same selection, field for field. It used to render a statement for it (or, at a non-UTC zone, answer `NOT_IMPLEMENTED` / 501 saying the query is served). Nothing is widened: no package-root export, public member, answer member, error code or status is added. Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs` says the `/analytics/sql` dry run renders a date bucket over a non-date column. **BREAKING** accept-set narrowing on the `/analytics/sql` dry run (`@objectstack/service-analytics`). `/analytics/query` is unchanged. Card thread: `session_01FEkQ5kbUJTTWAxjV9nqr1P`. ## 维护者速读 - **改了什么**: - `/analytics/sql`(只出 SQL、不执行的预演口)遇到把非日期列按时间粒度分桶的选择(文本、选项、数字、布尔、time),现在和 `/analytics/query` 一样拒绝:`INVALID_FIELD` / 400,路径 `groupBy.N.dateGranularity`,报错逐字段一致。以前预演口照样给出 `strftime('%Y-%m', region)` 这样的 SQL;在非 UTC 时区下还会答 501,说「查询本身能服务」,其实查询会拒绝。 - 顺带修复(#28976 评审的两条 should-fix): - 草稿预览把行过滤(`where` 和 `dateRange` 窗口)提前到「同一列要两次」和「非日期列分桶」两道门之前,只降一次,和线上的拒绝优先级一致。 - 钉住三个以前没钉的分支:`formula` 跳过、`time` 类型、数据集维度声明的默认粒度落在文本列上。 - **为什么改**:卡 #28977(p3),是我在 #28852 测量时发现的范围外问题。席位裁定:预演口复用 #28976 加的那份判读,用 `execute` 构建的同一组键,按 `query()` 的拒绝顺序;不加第三份文字,线上两面不加门,不碰 objectql。 - **风险与回滚**:这是收窄,以前能出 SQL 的预演现在被拒,但只限查询本身就拒绝的选择。日期、日期时间列的分桶 SQL 一字不差(已钉住)。回滚即 revert。 - **先看**: - `strategies/objectql-strategy.ts` 的 `generateSql`:列重复门后面新加的一次调用; - `dataset-date-bucket-door.ts`(由 `preview-date-bucket-door.ts` 改名,现在两面共用); - `preview-evaluator.ts` 的新函数 `lowerPreviewRowFilters`,以及 `analytics-service.ts` 预览门清单里的调用位置。 - **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/objectql-echo-non-date-bucket.test.ts src/__tests__/preview-date-bucket-type.test.ts`,预期 14 + 14 passed。 - **需要你做的**:无。席位跑合约评审并落地。 ## What changes - **`generateSql` refuses through the service's one reading.** Right after the column-asked-twice door (#28841), before any bucket renders, the echo calls `assertDatasetDateBucketsTemporal`. - It runs on the same keys as that door: `aggregateGroupKeys(query, cube)` on the direct path, and `crossObjectGroupKeys(query, cube, crossByDim)` when `plan` routes the selection to the cross-object path, as `execute()` builds them. So `N` is the engine's position. - It runs on the same object (`tableName`, the object `execute()` aggregates). - It reads the type through the strategy context's `declaredFieldType`, which the service answers from `sourceFieldMeta`, the hook the preview reads. A host that wires no reader is not judged, as the engine judges no undeclared field. - The order is `query()`'s: the `dateRange` windows are read first (#28852), then the column refusal, then this one. - **`preview-date-bucket-door.ts` → `dataset-date-bucket-door.ts`, and `assertPreviewDateBucketsTemporal` → `assertDatasetDateBucketsTemporal`.** The body is unchanged. Only the header now names both callers, as `dataset-group-column-door.ts` was renamed when the strategy began calling it (#28798). The engine's sentence still has exactly two copies (engine, this module), and no live face gains a gate. - **Ride-along 1, the preview's precedence.** `preview-evaluator.ts` gains `lowerPreviewRowFilters(query, cube, declaredType, whereColumns)`. It is the evaluator's step 1 (the `where`, then each `dateRange` window), moved verbatim and returning the row matcher. - `evaluateAnalyticsQueryOverRows` takes it as an optional last argument. Without it, the evaluator lowers them itself, first, as before. - The preview calls it once, after its `ensureCube` / `query()` gate list and before its two doors, where the ObjectQL strategy lowers its filter and its windows, and hands the result to the evaluator. - Why the `where` moves too: lowering only the windows there would have answered a refused filter after an unrecognised `dateRange`. Measured, both live faces answer the filter first (pinned). - **Ride-along 2, the pins.** In `preview-date-bucket-type.test.ts`: - a `time` column on every face; - a dataset dimension's declared `dateGranularity` over a text column; - the `formula` skip: no face answers the date-bucket refusal for a formula bucket, and the engine does not judge a formula either. - **Census.** The new `objectql-echo-non-date-bucket.test.ts` calls `analytics.query` twice. So `packages/client/src/envelope-caller-census.test.ts` (#13079) gains its ledger row (2), the service sites move 15 → 17 and `NOT_SDK` 15 → 17. The file is declared in `scripts/cross-package-test-inputs.mjs` and `turbo.json`'s `@objectstack/client#test` inputs. - Tests: new `src/__tests__/objectql-echo-non-date-bucket.test.ts` (14 cases), plus 7 cases in `preview-date-bucket-type.test.ts` (7 → 14). Changeset: `.changeset/28977-sql-dry-run-refuses-date-bucket-over-non-date.md`, `@objectstack/service-analytics` patch. ## Why Measured on `main` `ab085a04` with a probe over real SQLite, comparing `query()` with `generateSql()` on both compositions (default, and ObjectQL-only). The two compositions answered alike throughout. | selection | `/analytics/query` | `/analytics/sql` on `main` | |:--|:--|:--| | text `region` by month | `INVALID_FIELD` / 400 `groupBy.0.dateGranularity` | `SELECT strftime('%Y-%m', \`region\`) … GROUP BY strftime(…)` | | select, number (year), boolean | the same | renders over that column | | `time` column by day | the same | renders `strftime('%Y-%m-%d', \`opened_time\`)` | | `account.name` beside a text bucket (cross-object path) | the same at `groupBy.1` | the join and the bucket | | raw date, datetime bucket, text bucket | the same at `groupBy.2` | all three columns | | a cube time dimension over `region` declaring one granularity, asked raw | the same | renders the bucket | | text bucket at `America/New_York` | the same | `NOT_IMPLEMENTED` / 501, "the query itself is served" | | date / datetime bucket | served | renders, the same statement the query echoes | The draft preview, on the same `main`, compared with both live faces of `queryDataset`: | selection | native / ObjectQL | draft preview | |:--|:--|:--| | unrecognised or one-entry `dateRange` on a text bucket | `ANALYTICS_DATE_RANGE_UNRECOGNIZED` | `INVALID_FIELD` (the bucket) | | unrecognised `dateRange` beside one column asked twice | `ANALYTICS_DATE_RANGE_UNRECOGNIZED` | `INVALID_QUERY` (the column) | | refused `runtimeFilter` (`$lt: null`) beside a bad `dateRange` on a text bucket | `INVALID_FILTER` | `INVALID_FIELD` (the bucket) | | `time` column, dataset-default granularity over text | `INVALID_FIELD` | already `INVALID_FIELD` (now pinned) | ## Tests <details><summary>Red first, green, ablations</summary> - **Red on base.** `analytics-service.ts`, `strategies/objectql-strategy.ts`, `preview-evaluator.ts` and `preview-date-bucket-door.ts` were restored to `ab085a04` (the renamed door moved aside), with both test files at HEAD. Result: `Tests 13 failed | 15 passed (28)`. - All 9 echo refusal cases failed on the dry run resolving, or on the 501. - The 4 preview precedence cases failed on the door's refusal. - The served pins and the three new preview pins (time, dataset-default granularity, formula) passed: those arms already held. Restored with `git checkout HEAD --`, then `git status` was clean. - **Green.** Both files pass (14 + 14). The full service-analytics suite gave `Test Files 206 passed (206)`, `Tests 4919 passed | 282 skipped`. The client census file gave 21 passed. `tsc --noEmit` and scoped eslint exited 0. - **Ablations** used `node scripts/ablation-replace.mjs` with the fix committed first. Each run covered both files plus `objectql-echo-column-twice.test.ts` and `preview-time-filter-fk-parity.test.ts` (80 cases). Each anchor went 1 → 0, and each restore came back with blob == HEAD and an empty `git diff HEAD`: - D1: make the echo's call a no-op. Result: `9 failed | 71 passed`, every echo refusal case. - D2: judge the bucket before the echo's column door. Result: `1 failed`, the column-asked-twice precedence case. - D3: judge the bucket before the echo's `dateRange` read. Result: `3 failed`, the three precedence cases. - D4: hand the evaluator no lowered filters, so it lowers them after the doors again. Result: `4 failed`, the four preview precedence cases. - D5: drop the `formula` skip. Result: `1 failed`, the formula pin. - D6: exempt `time`. Result: `2 failed`, the time case on the echo and on the preview. - D7: make the preview's call a no-op. Result: `7 failed`, the four #28852 types, `time`, the later position and the dataset-default granularity. </details> - **Gates.** After a full turbo build (lock VERDICT command-exit 0), all 81 families `dispatch-gates --commands` derives for this diff ran with stdin `/dev/null` and the exit captured before any pipe. `--ran` gave 81 derived, 81 run, 0 NOT-MEASURED, all exit 0. - **Dogfood: NOT MEASURED.** The battery was running on `ec15d7c4` under the verify lock when the seat asked to wrap up, and I did not wait for its verdict. It is owed (the title carries `!`). ## Out of scope (reported to the seat, not fixed) - A date bucket over a `formula` field that the table stores no column for: both live faces refuse `INVALID_FIELD` / 400 from the SQL driver ("no column"). The engine does not judge a formula's value class, so the date-bucket door does not either. Meanwhile, `/analytics/sql` renders `strftime('%Y-%m', calc)` for it, and the draft preview serves one `null` group holding the total. This is the virtual-formula family, not the date-bucket one, and it was not touched here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kbUJTTWAxjV9nqr1P --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kbUJTTWAxjV9nqr1P)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 45e9b2f commit 0c0d3b6

10 files changed

Lines changed: 417 additions & 58 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics)!: the /analytics/sql dry run refuses a date bucket over a column that is not a date or datetime, as the query does, instead of rendering it
6+
7+
Clause-②: no (narrowing) — `/analytics/sql` (`AnalyticsService.generateSql`) now refuses `INVALID_FIELD` / 400 at `groupBy.N.dateGranularity` a time-dimension granularity over a field declared neither a date nor a datetime (text, select, number, boolean, time, …), with the refusal `/analytics/query` gives the same selection, field for field; it rendered a statement for it (or, at a non-UTC zone, answered `NOT_IMPLEMENTED` / 501 saying the query is served). Nothing is widened: no export, public member, answer member, error code or status is added.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type of the package root changes: a dry run that rendered a statement the query refuses is refused with the query's refusal, already on the wire. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing on the `/analytics/sql` dry run (`@objectstack/service-analytics`). `/analytics/query` is unchanged.
13+
14+
- **Before.** Measured on real SQLite on `main` `ab085a04`. `/analytics/query` refused a month bucket over a text, select or boolean column, a year bucket over a number and a day bucket over a `time` column with the engine's `INVALID_FIELD` / 400 at `groupBy.N.dateGranularity`. The dry run rendered `strftime('%Y-%m', region)` and its kin, on the direct and the cross-object path, and for a cube dimension's one declared granularity asked raw. At `America/New_York` it answered `NOT_IMPLEMENTED` / 501.
15+
- **Now.** The dry run judges the keys `execute()` hands the engine (its cross-object path's when the selection routes there) with the reading the draft preview already used (#28852), now `dataset-date-bucket-door.ts`. It runs after the dry run's `dateRange` read and its column-asked-twice refusal, as `query()` refuses, and gives the engine's refusal: the same message, `object`, `param`, `path`, `field` and `fields`. A field with no declared type, or a `formula`, is not judged, as in the engine.
16+
- **Unchanged.** A date or datetime bucket renders byte for byte, the statement the query echoes.
17+
- **Who sees it.** A caller of the dry run for a selection the query refuses: the query's refusal, instead of SQL for a statement that never runs.
18+
19+
**The remedy.** Group by the column without a granularity, or bucket a date or datetime field.
20+
21+
Also in this change, the draft preview of `queryDataset` lowers its row filters (the `where`, then the time-dimension windows) once, before its column-asked-twice and date-bucket refusals, where the ObjectQL strategy lowers them. So a selection carrying an unrecognised `dateRange` beside one column asked twice or beside a bucket over a non-date column is refused about the `dateRange` (`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400) on every face, and a refused filter beside a non-date bucket is refused about the filter. The preview answered the other refusal.

‎packages/client/src/envelope-caller-census.test.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,12 @@ const LEDGER: readonly LedgerRow[] = [
531531
method: 'analytics.query', receiver: 'service', count: 4, verdict: 'NOT_SDK',
532532
why: 'the real AnalyticsService that AnalyticsServicePlugin registers over a live engine, called directly (no HTTP, no dispatcher envelope) to compare the query\'s refusal with the dry run\'s (one column asked twice, and a bad dateRange beside it, #28852), and to read the `sql` and rows the query serves',
533533
},
534+
// ── [#28977] the /analytics/sql dry run vs the query for a date bucket over a column that is not a date: producer reads only ──
535+
{
536+
file: 'packages/services/service-analytics/src/__tests__/objectql-echo-non-date-bucket.test.ts',
537+
method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK',
538+
why: 'the real AnalyticsService that AnalyticsServicePlugin registers over a live engine, called directly (no HTTP, no dispatcher envelope) to compare the query\'s refusal with the dry run\'s (a date bucket over a text, select, number, boolean or time column), and to read the `sql` the query serves for a date and a datetime bucket',
539+
},
534540
{
535541
file: 'packages/client/src/analytics-automation-json-erasure.test.ts',
536542
method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT',
@@ -771,14 +777,19 @@ describe('#13079 §2 — positive controls on the matcher itself', () => {
771777
// plugin-registered AnalyticsService to compare the query's refusal
772778
// and served `sql` with `generateSql`'s. [#28852] A fourth site there:
773779
// the refusal precedence beside an unrecognised `dateRange`.
774-
expect(service.length, literalNote()).toBe(15);
780+
// [#28977] A seventh, two sites: the dry-run parity pin for a date
781+
// bucket over a column that is not a date calls the same
782+
// plugin-registered AnalyticsService to compare the query's refusal
783+
// and served `sql` with `generateSql`'s.
784+
expect(service.length, literalNote()).toBe(17);
775785
expect([...new Set(service.map((s) => s.file))].sort()).toEqual([
776786
'packages/client/src/analytics-automation-json-erasure.test.ts',
777787
'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts',
778788
'packages/rest/src/analytics-nested-relation-filter.test.ts',
779789
'packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts',
780790
'packages/services/service-analytics/src/__tests__/objectql-echo-column-twice.test.ts',
781791
'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts',
792+
'packages/services/service-analytics/src/__tests__/objectql-echo-non-date-bucket.test.ts',
782793
]);
783794
expect(service.every((s) => s.method === 'analytics.query')).toBe(true);
784795
});
@@ -825,14 +836,15 @@ describe('#13079 §3 — every call site is classified', () => {
825836
expect(production, literalNote()).toEqual([]);
826837
});
827838

828-
it('records the split: 18 payload pins, 10 result-insensitive, 15 not-SDK', () => {
839+
it('records the split: 18 payload pins, 10 result-insensitive, 17 not-SDK', () => {
829840
expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18);
830841
expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10);
831842
// [#21441] 8 -> 9: the date-bucket echo pin's producer call (§2).
832843
// [#21647] 9 -> 11: the bucket echo enumeration's two producer calls (§2).
833844
// [#28841] 11 -> 14: the dry-run parity pin's three producer calls (§2).
834845
// [#28852] 14 -> 15: its dateRange-precedence call.
835-
expect(verdictTotal('NOT_SDK')).toBe(15);
846+
// [#28977] 15 -> 17: the non-date bucket dry-run parity pin's two producer calls (§2).
847+
expect(verdictTotal('NOT_SDK')).toBe(17);
836848
// The three above are LEDGER sums and cannot move on a census reading;
837849
// this one is census-derived, so it carries the note. [#13874]
838850
expect(sdkSites.length, literalNote()).toBe(28);
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28977] The `/analytics/sql` dry run refuses a date bucket over a column
5+
* declared neither a date nor a datetime, as `/analytics/query` does, on real
6+
* SQLite (the #28841 harness: real ObjectQL + SqlDriver, the default
7+
* composition).
8+
*
9+
* ## The shape this closes
10+
*
11+
* The query hands `engine.aggregate` the keys the ObjectQL strategy builds
12+
* (native declines every bucketed selection to it), and the engine refuses a
13+
* bucket over any other declared type: `INVALID_FIELD` / 400 at
14+
* `groupBy.N.dateGranularity`. The dry run rendered the bucket without reading
15+
* the column's type, so on `main` `ab085a04` it printed a statement for each:
16+
*
17+
* | selection | the dry run rendered |
18+
* |:--|:--|
19+
* | text `region` by month | `strftime('%Y-%m', \`region\`)` |
20+
* | select, number, boolean, `time` | the same over that column |
21+
* | `account.name` beside a text bucket (the cross-object path) | the join and the bucket |
22+
* | a cube dimension over `region` declaring one granularity, asked raw | the bucket |
23+
* | text `region` by month at `America/New_York` | `NOT_IMPLEMENTED` / 501 saying the query is served |
24+
*
25+
* It now judges the keys `execute()` hands the engine with the service's one
26+
* reading of it (`dataset-date-bucket-door.ts`, #28852), after the `dateRange`
27+
* read and the column-asked-twice door as `query()` refuses, so it answers the
28+
* query's refusal field for field. A date and a datetime bucket still render
29+
* byte for byte (pinned below).
30+
*/
31+
32+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
33+
import { ObjectQL } from '@objectstack/objectql';
34+
import { SqlDriver } from '@objectstack/driver-sql';
35+
import type { Cube } from '@objectstack/spec/data';
36+
import type { AnalyticsService } from '../analytics-service.js';
37+
import { AnalyticsServicePlugin } from '../plugin.js';
38+
import { installDefaultOrganization } from './default-organization.testkit.js';
39+
40+
const DEAL = 'os28977_deal';
41+
const ACCOUNT = 'os28977_account';
42+
const CUBE = 'os28977_cube';
43+
44+
const CUBES = [
45+
{
46+
name: CUBE,
47+
title: 'Date bucket cube',
48+
sql: DEAL,
49+
public: true,
50+
measures: { amount_sum: { type: 'sum', sql: 'amount', label: 'Amount' } },
51+
dimensions: {
52+
region: { type: 'string', sql: 'region', label: 'Region' },
53+
// A time dimension over a text column, declaring one granularity: asked
54+
// raw, it is bucketed by that granularity (`withDeclaredGranularityDefaults`).
55+
region_month: { type: 'time', sql: 'region', label: 'Region month', granularities: ['month'] },
56+
},
57+
},
58+
] as unknown as Cube[];
59+
60+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
61+
62+
type Query = Record<string, unknown>;
63+
const td = (dimension: string, granularity: string, extra: Query = {}) => ({ dimension, granularity, ...extra });
64+
/** The inferred cube over the deal object, which serves a lookup path as a cross-object dimension. */
65+
const onObject = (extra: Query): Query => ({ cube: DEAL, measures: ['amount_sum'], ...extra });
66+
const onCube = (extra: Query): Query => ({ cube: CUBE, measures: ['amount_sum'], ...extra });
67+
68+
/** The engine's refusal for a bucket over `field`, declared `type`, at `path`. */
69+
const refusalFor = (field: string, type: string, granularity: string, path: string) => ({
70+
message:
71+
`aggregate('${DEAL}'): ${path} buckets '${field}' by '${granularity}', but '${field}' is a `
72+
+ `declared ${type} field — only a date or datetime field can be date-bucketed. The query was NOT run. Group by `
73+
+ `'${field}' without 'dateGranularity', or bucket a date or datetime field. A value that is not a date has no `
74+
+ 'calendar position: every row fell into one null bucket (or, for a number, an epoch bucket) while the answer '
75+
+ 'looked served.',
76+
code: 'INVALID_FIELD', status: 400, httpStatus: 400,
77+
object: DEAL, param: 'groupBy', path, field, fields: [field],
78+
});
79+
80+
describe('[#28977] the /analytics/sql dry run refuses a date bucket over a column that is not a date, as the query does', () => {
81+
let engine: ObjectQL;
82+
let analytics: AnalyticsService;
83+
84+
beforeAll(async () => {
85+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never);
86+
engine = new ObjectQL({ logger: quiet } as never);
87+
engine.registerDriver(driver as never, true);
88+
await engine.init();
89+
engine.registry.registerObject({ name: ACCOUNT, label: 'Account', fields: { name: { name: 'name', type: 'text' } } } as never);
90+
engine.registry.registerObject({
91+
name: DEAL,
92+
label: 'Deal',
93+
fields: {
94+
region: { name: 'region', type: 'text' },
95+
stage: { name: 'stage', type: 'select', options: [{ label: 'Won', value: 'won' }, { label: 'Lost', value: 'lost' }] },
96+
amount: { name: 'amount', type: 'number' },
97+
flag: { name: 'flag', type: 'boolean' },
98+
opened_time: { name: 'opened_time', type: 'time' },
99+
closed_at: { name: 'closed_at', type: 'datetime' },
100+
closed_on: { name: 'closed_on', type: 'date' },
101+
account: { name: 'account', type: 'lookup', reference: ACCOUNT },
102+
},
103+
} as never);
104+
await engine.syncSchemas();
105+
await installDefaultOrganization(engine as never);
106+
await engine.insert(ACCOUNT, { id: 'a1', name: 'Acme' } as never);
107+
await engine.insert(DEAL, {
108+
id: 'd1', region: 'amer', stage: 'won', amount: 10, flag: true, opened_time: '10:00',
109+
closed_at: '2026-03-01T00:00:00.000Z', closed_on: '2026-03-02', account: 'a1',
110+
} as never);
111+
112+
// The default composition: no `queryCapabilities` override.
113+
const registered: Record<string, unknown> = {};
114+
await new AnalyticsServicePlugin({ cubes: CUBES, debugSql: true } as never).init({
115+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
116+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
117+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
118+
hook: () => {},
119+
logger: quiet,
120+
} as never);
121+
analytics = registered.analytics as AnalyticsService;
122+
});
123+
124+
afterAll(async () => {
125+
try { await engine?.destroy(); } catch { /* noop */ }
126+
});
127+
128+
/** The refusal `p` rejects with, as the keys a caller reads; a resolution fails the case. */
129+
const refusalOf = async (face: string, p: Promise<unknown>) => {
130+
const err = (await p.then(
131+
() => { throw new Error(`${face}: expected a refusal, but it resolved`); },
132+
(e: unknown) => e,
133+
)) as Error & Record<string, unknown>;
134+
return {
135+
message: err.message, code: err.code, status: err.status, httpStatus: err.httpStatus,
136+
object: err.object, param: err.param, path: err.path, field: err.field, fields: err.fields,
137+
};
138+
};
139+
140+
/** The query's refusal and the dry run's, for one selection. */
141+
const bothRefusals = async (query: Query) => ({
142+
asQuery: await refusalOf('query', analytics.query({ ...query } as never)),
143+
asSql: await refusalOf('sql', analytics.generateSql({ ...query } as never)),
144+
});
145+
146+
it.each<[string, Query, [field: string, type: string, granularity: string, path: string]]>([
147+
['text', onObject({ timeDimensions: [td('region', 'month')] }), ['region', 'text', 'month', 'groupBy.0.dateGranularity']],
148+
['select', onObject({ timeDimensions: [td('stage', 'month')] }), ['stage', 'select', 'month', 'groupBy.0.dateGranularity']],
149+
['number', onObject({ timeDimensions: [td('amount', 'year')] }), ['amount', 'number', 'year', 'groupBy.0.dateGranularity']],
150+
['boolean', onObject({ timeDimensions: [td('flag', 'month')] }), ['flag', 'boolean', 'month', 'groupBy.0.dateGranularity']],
151+
['time', onObject({ timeDimensions: [td('opened_time', 'day')] }), ['opened_time', 'time', 'day', 'groupBy.0.dateGranularity']],
152+
])('refuses a bucket over a %s column with the query\'s refusal, field for field', async (_label, query, expected) => {
153+
const { asQuery, asSql } = await bothRefusals(query);
154+
expect(asQuery).toEqual(refusalFor(...expected));
155+
expect(asSql, 'the dry run vs the query').toEqual(asQuery);
156+
});
157+
158+
it.each<[string, Query, string]>([
159+
["beside a cross-object 'account.name' (its FK column first)", onObject({ dimensions: ['account.name'], timeDimensions: [td('region', 'month')] }), 'groupBy.1.dateGranularity'],
160+
['after a raw date column and a datetime bucket', onObject({ dimensions: ['closed_on'], timeDimensions: [td('closed_at', 'month'), td('region', 'month')] }), 'groupBy.2.dateGranularity'],
161+
["by a cube dimension's one declared granularity, asked raw", onCube({ dimensions: ['region_month'] }), 'groupBy.0.dateGranularity'],
162+
['at a non-UTC zone, where the dry run said the query is served (501)', onObject({ timeDimensions: [td('region', 'month')], timezone: 'America/New_York' }), 'groupBy.0.dateGranularity'],
163+
])('refuses a text bucket %s with the query\'s refusal, field for field', async (_label, query, path) => {
164+
const { asQuery, asSql } = await bothRefusals(query);
165+
expect(asQuery).toEqual(refusalFor('region', 'text', 'month', path));
166+
expect(asSql, 'the dry run vs the query').toEqual(asQuery);
167+
});
168+
169+
it.each<[string, Query, string]>([
170+
['an unrecognised dateRange on the bucket itself', onObject({ timeDimensions: [td('region', 'month', { dateRange: 'not a range' })] }), 'ANALYTICS_DATE_RANGE_UNRECOGNIZED'],
171+
['a one-entry dateRange on another entry', onObject({ timeDimensions: [td('region', 'month'), td('closed_at', 'year', { dateRange: ['2026-01-01'] })] }), 'ANALYTICS_DATE_RANGE_UNRECOGNIZED'],
172+
['the column asked twice', onObject({ timeDimensions: [td('region', 'month'), td('region', 'year')] }), 'INVALID_QUERY'],
173+
])('answers %s before a text bucket, as the query does', async (_label, query, code) => {
174+
const { asQuery, asSql } = await bothRefusals(query);
175+
expect(asQuery).toMatchObject({ code, status: 400 });
176+
expect(asSql, 'the dry run vs the query').toEqual(asQuery);
177+
});
178+
179+
it.each<[string, string]>([
180+
['date', 'closed_on'],
181+
['datetime', 'closed_at'],
182+
])('still renders a %s bucket byte for byte, the statement the query echoes', async (_label, column) => {
183+
const query = onObject({ timeDimensions: [td(column, 'month')] });
184+
const sql = `SELECT strftime('%Y-%m', \`${column}\`) AS "${column}", SUM(amount) AS "amount_sum" FROM "${DEAL}" `
185+
+ `GROUP BY strftime('%Y-%m', \`${column}\`)`;
186+
expect(await analytics.generateSql({ ...query } as never)).toEqual({ sql, params: [] });
187+
expect((await analytics.query({ ...query } as never)).sql).toBe(sql);
188+
});
189+
});

0 commit comments

Comments
 (0)