Skip to content

Commit 97680bb

Browse files
fix(service-analytics)!: the /analytics/sql dry run refuses a selection asking one column twice, as /analytics/query does (#28878)
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 #28841 Clause-②: no (narrowing) — `/analytics/sql` (`AnalyticsService.generateSql`) now refuses `INVALID_QUERY` / 400 a selection that names one column twice in its groups (two granularities of one time dimension, the column raw beside its own bucket, or two spellings of it bucketed twice), with the refusal `/analytics/query` gives the same selection, field for field. It used to render a statement for each. 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 these selections. **BREAKING** accept-set narrowing on the `/analytics/sql` dry run (`@objectstack/service-analytics`). `/analytics/query` and `queryDataset` are unchanged. Card thread: `session_01FEkQ5kbUJTTWAxjV9nqr1P`. ## 维护者速读 - **改了什么**:`/analytics/sql`(只出 SQL、不执行的预演口)遇到同一列被要两次的选择,现在和 `/analytics/query` 一样拒绝,逐字段一致(`INVALID_QUERY` / 400)。涉及的选择有三种:同一时间维度两个粒度、原始列加它自己的时间桶、同一列两种写法各分一次桶。以前预演口照样给出一段 SQL,而真正的查询会拒绝。 - **为什么改**:卡 #28841(p3)。来源是 PR #28837 合约评审的 should-fix,也是我在 #28799 报告里的范围外发现。席位裁定:`generateSql` 通过 #28798 的同一个数据集门、按 `execute` 构建的同一组键拒绝;回显里「每维度只记一个粒度」的旧表删掉,而不是打补丁。 - **风险与回滚**:这是收窄,以前能出 SQL 的预演现在被拒,但只限查询本身就拒绝的选择。查询能服务的预演一字不差(已钉住)。回滚即 revert。 - **先看**:`packages/services/service-analytics/src/strategies/objectql-strategy.ts`,看 `generateSql` 里新增的 `assertDatasetGroupColumnsDistinct` 调用,以及新的模块级 `crossObjectGroupKeys`(从 `executeCrossObject` 原样移出)。 - **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/objectql-echo-column-twice.test.ts`,预期 13 passed。 - **需要你做的**:无。席位跑合约评审并落地。 ## What changes - **`generateSql` refuses through the one dataset door.** Right after `planCrossObject`, before any bucket expression renders, the echo calls `assertDatasetGroupColumnsDistinct` (`dataset-group-column-door.ts`, #28798). It judges the keys `execute()` would hand the engine: - `aggregateGroupKeys(query, cube)` on the direct path; - `crossObjectGroupKeys(query, cube, crossByDim)` when `plan` routes the query to the cross-object path, as `execute()` does. It runs on the same object and with the same member reader, so the refusal is the query's, message included. No third copy of the refusal is added. - **The one-granularity map is gone.** `granByDim` (`Map<string, string>`, last entry wins) is replaced by `granularitiesByDimension(query)`, the reader both key builders use. `dimExpr` reads a dimension's first granularity. That is equivalent on every selection the echo still renders, because the door has refused any dimension asked at two different granularities by then, and a granularity repeated is one bucket. - **`crossObjectGroupKeys`** is `executeCrossObject`'s key build, moved verbatim (comments included) to a module-level, non-exported function, so the cross-object path and the echo build the same keys. `executeCrossObject` now calls it. - **Untouched.** Nothing is exported or added to `ObjectQLStrategy`. `packages/objectql`, the door and `analytics-service.ts` are unchanged. - Tests: new `src/__tests__/objectql-echo-column-twice.test.ts` (13 cases, real SQLite, the default composition). Changeset: `.changeset/28841-sql-dry-run-refuses-column-twice.md`, `@objectstack/service-analytics` patch. ## Why Measured on `main` `7e60dc23` with a probe over real SQLite, default composition, comparing `query()` with `generateSql()` (the body `POST /analytics/sql` answers with). `query()` refused every selection below with `INVALID_QUERY` / 400. The other `generateSql` caller, `execute()`'s best-effort echo, runs after the engine and is caught, so `/analytics/query` was already right. | selection | `generateSql` on `main` | |:--|:--| | `closed_at` month and year | `SELECT strftime('%Y', …) AS "closed_at" … GROUP BY strftime('%Y', …)`: the month entry was dropped | | `closed_at` raw beside `CUBE.closed_at` month | `GROUP BY closed_at, strftime('%Y-%m', …)` | | `closed_day` (over `closed_at`) beside `closed_at` month | the same two columns | | `closed_at` month beside `CUBE.closed_at` year | both buckets | | the inferred cube, `closed_at` raw beside `OBJECT.closed_at` month | both columns | | `account.name` beside `closed_at` month and year | the join, with one `year` bucket | | `closed_at` month and year at `America/New_York` | `NOT_IMPLEMENTED` / 501, "The query itself is SERVED" | After the change, every row answers the query's refusal, field for field. The served echoes (one bucket; two columns; region beside a bucket asked twice at one granularity; a cross-object dimension beside one bucket) printed the same bytes before and after. ## Tests <details><summary>Red first, green, ablations</summary> - **Red on base.** `objectql-strategy.ts` was restored to `7e60dc23`, with the new test at HEAD. Result: `Tests 7 failed | 5 passed (12)` on the 12-case file. Every refusal case failed on the dry run resolving, and the five served pins passed. The 13th case (two dimensions through one relationship) was added afterwards for ablation B2. Restored, then `git status` was clean. - **Green.** `13 passed (13)`. The full service-analytics suite gave `Test Files 204 passed (204)`, `Tests 4889 passed | 282 skipped`. The REST analytics tests and `tsc --noEmit` passed, and scoped eslint exited 0. - **Ablations** used `node scripts/ablation-replace.mjs` with the fix committed first, running this file plus `preview-time-filter-fk-parity.test.ts`. Each anchor went 1 → 0, and each restore came back with blob == HEAD: - B1: make the echo's door call a no-op. Result: `8 failed | 42 passed`, every refusal case. - B2: judge `aggregateGroupKeys` on the cross-object route too. Result: `1 failed | 49 passed`. Two dimensions through one relationship share one FK key on that path, so the refusal names `groupBy.2.field` there, not `groupBy.3.field`. - B3: put the old one-granularity map back, with the door in place. Result: `50 passed`. This is expected: with the door in place, the map only feeds rendering of selections the door let through, where one granularity per dimension is all there is. With the door removed as well, that is the base tree, red above. </details> - **Dogfood** ran on `348838c8` under the verify lock. Result: `Test Files 4 failed | 377 passed | 3 skipped (384)`, `Tests 4 failed | 3130 passed | 28 skipped (3162)`. The 4 failures are the 18.0-database migration files: `audit-key-material`, `audit-mail-bodies`, `identity-mail-read-gate` (the `sys_activity` mirror) and `upgrade-18x-harness` (`no such table: sys_activity__r20261019`). The same 4 fail in `main`'s own Dogfood Regression Gate (1/3, 2/3, 3/3) at `3b920f6d`, the base of this branch. None of them reads analytics. 🤖 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 61de599 commit 97680bb

6 files changed

Lines changed: 290 additions & 41 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics)!: the `/analytics/sql` dry run refuses a selection asking one column twice, as `/analytics/query` does, instead of rendering a statement the query refuses
6+
7+
Clause-②: no (narrowing) — `/analytics/sql` (`AnalyticsService.generateSql`) now refuses `INVALID_QUERY` / 400 a selection that names one column twice in its groups (two granularities of one time dimension, the column raw beside its own bucket, or two spellings of it bucketed twice), with the refusal `/analytics/query` gives the same selection, field for field; it rendered a statement for each. 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` and `queryDataset` are unchanged.
13+
14+
- **Before.** Measured on real SQLite on `main` `7e60dc23` (native declines every bucketed selection, so the ObjectQL strategy's echo answers). `/analytics/query` refused each selection below, and the dry run did this instead:
15+
- `timeDimensions` `closed_at` by month and by year: rendered one `year` bucket, the month entry dropped.
16+
- `closed_at` raw beside `CUBE.closed_at` by month, or a second dimension over `closed_at` beside its bucket: rendered `GROUP BY closed_at, strftime('%Y-%m', …)`.
17+
- `closed_at` by month beside `CUBE.closed_at` by year: rendered both buckets.
18+
- Beside a cross-object `account.name`: rendered the join and one bucket.
19+
- At a non-UTC `timezone`: answered `NOT_IMPLEMENTED` / 501, saying the query is served.
20+
- **Now.** The echo judges the keys `execute()` hands the engine with the same dataset door (`dataset-group-column-door.ts`, #28798) before it renders a bucket, so it answers the query's `INVALID_QUERY` / 400 with the same message, `object`, `param`, `path` and `fields`. On a selection that routes to the cross-object path, those are that path's keys. The echo's one-granularity-per-dimension map is gone: it reads the granularities the query's key builders read.
21+
- **Unchanged.** Every echo the query serves renders byte for byte (pinned): one bucket of a column, buckets of two columns, one granularity asked twice, raw columns, a cross-object dimension. The 501 for a bucket the engine groups in memory is unchanged when the selection names no column twice. No new refusal is added; the query's own now reaches the dry run.
22+
- **Who sees it.** A caller of `/analytics/sql` sending one of those selections now gets the query's refusal, instead of SQL for a query that does not run.
23+
24+
**The remedy.** Ask each column once: drop the raw dimension or the bucket's granularity, or ask one granularity. The refusal names the members.

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,12 @@ const LEDGER: readonly LedgerRow[] = [
525525
method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK',
526526
why: 'the real AnalyticsService that AnalyticsServicePlugin registers over an ObjectQL engine with the driver\'s data doors spied, called directly (no HTTP, no dispatcher envelope) to read whether the ObjectQL face\'s answer carries an echoed `sql`, and its rows',
527527
},
528+
// ── [#28841] the /analytics/sql dry run vs the query for one column asked twice: producer reads only ──
529+
{
530+
file: 'packages/services/service-analytics/src/__tests__/objectql-echo-column-twice.test.ts',
531+
method: 'analytics.query', receiver: 'service', count: 3, verdict: 'NOT_SDK',
532+
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, and to read the `sql` and rows the query serves',
533+
},
528534
{
529535
file: 'packages/client/src/analytics-automation-json-erasure.test.ts',
530536
method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT',
@@ -761,12 +767,16 @@ describe('#13079 §2 — positive controls on the matcher itself', () => {
761767
// [#21647] A fifth, two sites: the bucket echo's enumeration pin calls
762768
// the same plugin-registered AnalyticsService, over an engine with the
763769
// driver's data doors spied, to read whether the answer carries `sql`.
764-
expect(service.length, literalNote()).toBe(11);
770+
// [#28841] A sixth, three sites: the dry-run parity pin calls the same
771+
// plugin-registered AnalyticsService to compare the query's refusal
772+
// and served `sql` with `generateSql`'s.
773+
expect(service.length, literalNote()).toBe(14);
765774
expect([...new Set(service.map((s) => s.file))].sort()).toEqual([
766775
'packages/client/src/analytics-automation-json-erasure.test.ts',
767776
'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts',
768777
'packages/rest/src/analytics-nested-relation-filter.test.ts',
769778
'packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts',
779+
'packages/services/service-analytics/src/__tests__/objectql-echo-column-twice.test.ts',
770780
'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts',
771781
]);
772782
expect(service.every((s) => s.method === 'analytics.query')).toBe(true);
@@ -814,12 +824,13 @@ describe('#13079 §3 — every call site is classified', () => {
814824
expect(production, literalNote()).toEqual([]);
815825
});
816826

817-
it('records the split: 18 payload pins, 10 result-insensitive, 11 not-SDK', () => {
827+
it('records the split: 18 payload pins, 10 result-insensitive, 14 not-SDK', () => {
818828
expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18);
819829
expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10);
820830
// [#21441] 8 -> 9: the date-bucket echo pin's producer call (§2).
821831
// [#21647] 9 -> 11: the bucket echo enumeration's two producer calls (§2).
822-
expect(verdictTotal('NOT_SDK')).toBe(11);
832+
// [#28841] 11 -> 14: the dry-run parity pin's three producer calls (§2).
833+
expect(verdictTotal('NOT_SDK')).toBe(14);
823834
// The three above are LEDGER sums and cannot move on a census reading;
824835
// this one is census-derived, so it carries the note. [#13874]
825836
expect(sdkSites.length, literalNote()).toBe(28);
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28841] The `/analytics/sql` dry run refuses what `/analytics/query`
5+
* refuses for one column asked twice under one name, on real SQLite.
6+
*
7+
* ## The shape this closes
8+
*
9+
* A selection asking one column for two values (two granularities of it, the
10+
* column raw beside its own bucket, or two spellings of it bucketed twice) is
11+
* refused by the query: the ObjectQL strategy's dataset door
12+
* (`dataset-group-column-door.ts`, #28798) judges the keys `execute()` hands
13+
* the engine, `INVALID_QUERY` / 400. Native declines every bucketed selection
14+
* to that strategy, so its `generateSql` is the dry run that answers. It built
15+
* its GROUP BY from its own map of one granularity per dimension and ran no
16+
* door, so on `main` `7e60dc23` it rendered a statement for each:
17+
*
18+
* | selection | the dry run rendered |
19+
* |:--|:--|
20+
* | `closed_at` month and year | one `strftime('%Y', …)` bucket (the month entry dropped) |
21+
* | `closed_at` raw beside `CUBE.closed_at` month | `GROUP BY closed_at, strftime('%Y-%m', …)` |
22+
* | `closed_day` (over `closed_at`) beside `closed_at` month | the same two columns |
23+
* | `closed_at` month beside `CUBE.closed_at` year | both buckets |
24+
* | `account.name` beside `closed_at` month and year | the join, one `year` bucket |
25+
* | `closed_at` month and year at `America/New_York` | `NOT_IMPLEMENTED` / 501 saying the query is served |
26+
*
27+
* It now judges the same keys with the same door before it renders anything
28+
* (the cross-object path's keys when the selection routes there), so it
29+
* answers the query's refusal field for field. Every echo the query serves
30+
* still renders byte for byte (pinned below).
31+
*/
32+
33+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
34+
import { ObjectQL } from '@objectstack/objectql';
35+
import { SqlDriver } from '@objectstack/driver-sql';
36+
import type { Cube } from '@objectstack/spec/data';
37+
import type { AnalyticsService } from '../analytics-service.js';
38+
import { AnalyticsServicePlugin } from '../plugin.js';
39+
import { installDefaultOrganization } from './default-organization.testkit.js';
40+
41+
const DEAL = 'os28841_deal';
42+
const ACCOUNT = 'os28841_account';
43+
const CUBE = 'os28841_cube';
44+
45+
const CUBES = [
46+
{
47+
name: CUBE,
48+
title: 'Column twice cube',
49+
sql: DEAL,
50+
public: true,
51+
measures: { amount_sum: { type: 'sum', sql: 'amount', label: 'Amount' } },
52+
dimensions: {
53+
region: { type: 'string', sql: 'region', label: 'Region' },
54+
closed_at: { type: 'time', sql: 'closed_at', label: 'Closed at' },
55+
closed_on: { type: 'time', sql: 'closed_on', label: 'Closed on' },
56+
closed_day: { type: 'time', sql: 'closed_at', label: 'Closed day' },
57+
},
58+
},
59+
] as unknown as Cube[];
60+
61+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
62+
63+
type Query = Record<string, unknown>;
64+
const td = (dimension: string, granularity: string) => ({ dimension, granularity });
65+
const onCube = (extra: Query): Query => ({ cube: CUBE, measures: ['amount_sum'], ...extra });
66+
/** The inferred cube over the deal object, which serves a lookup path as a cross-object dimension. */
67+
const onObject = (extra: Query): Query => ({ cube: DEAL, measures: ['amount_sum'], ...extra });
68+
69+
describe('[#28841] the /analytics/sql dry run refuses one column asked twice, as the query does', () => {
70+
let engine: ObjectQL;
71+
let analytics: AnalyticsService;
72+
73+
beforeAll(async () => {
74+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never);
75+
engine = new ObjectQL({ logger: quiet } as never);
76+
engine.registerDriver(driver as never, true);
77+
await engine.init();
78+
engine.registry.registerObject({ name: ACCOUNT, label: 'Account', fields: { name: { name: 'name', type: 'text' } } } as never);
79+
engine.registry.registerObject({
80+
name: DEAL,
81+
label: 'Deal',
82+
fields: {
83+
region: { name: 'region', type: 'text' },
84+
amount: { name: 'amount', type: 'number' },
85+
closed_at: { name: 'closed_at', type: 'datetime' },
86+
closed_on: { name: 'closed_on', type: 'date' },
87+
account: { name: 'account', type: 'lookup', reference: ACCOUNT },
88+
},
89+
} as never);
90+
await engine.syncSchemas();
91+
await installDefaultOrganization(engine as never);
92+
await engine.insert(ACCOUNT, { id: 'a1', name: 'Acme' } as never);
93+
await engine.insert(DEAL, { id: 'd1', region: 'amer', amount: 10, closed_at: '2026-03-01T00:00:00.000Z', closed_on: '2026-03-02', account: 'a1' } as never);
94+
95+
// The default composition: no `queryCapabilities` override.
96+
const registered: Record<string, unknown> = {};
97+
await new AnalyticsServicePlugin({ cubes: CUBES, debugSql: true } as never).init({
98+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
99+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
100+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
101+
hook: () => {},
102+
logger: quiet,
103+
} as never);
104+
analytics = registered.analytics as AnalyticsService;
105+
});
106+
107+
afterAll(async () => {
108+
try { await engine?.destroy(); } catch { /* noop */ }
109+
});
110+
111+
/** The refusal `p` rejects with, as the keys a caller reads; a resolution fails the case. */
112+
const refusalOf = async (face: string, p: Promise<unknown>) => {
113+
const err = (await p.then(
114+
() => { throw new Error(`${face}: expected a refusal, but it resolved`); },
115+
(e: unknown) => e,
116+
)) as Error & Record<string, unknown>;
117+
return {
118+
message: err.message, code: err.code, status: err.status, httpStatus: err.httpStatus,
119+
object: err.object, param: err.param, path: err.path, fields: err.fields,
120+
};
121+
};
122+
123+
it.each<[string, Query, string]>([
124+
['two granularities of one dimension', onCube({ timeDimensions: [td('closed_at', 'month'), td('closed_at', 'year')] }), 'groupBy.1.field'],
125+
['a column raw beside its own bucket, spelled CUBE.closed_at', onCube({ dimensions: ['closed_at'], timeDimensions: [td(`${CUBE}.closed_at`, 'month')] }), 'groupBy.1.field'],
126+
['a second dimension over the column, raw beside its bucket', onCube({ dimensions: ['closed_day'], timeDimensions: [td('closed_at', 'month')] }), 'groupBy.1.field'],
127+
['two spellings of one column at two granularities', onCube({ timeDimensions: [td('closed_at', 'month'), td(`${CUBE}.closed_at`, 'year')] }), 'groupBy.1.field'],
128+
['the column raw beside its bucket on the inferred cube', onObject({ dimensions: ['closed_at'], timeDimensions: [td(`${DEAL}.closed_at`, 'month')] }), 'groupBy.1.field'],
129+
["two granularities beside a cross-object 'account.name' (its FK column first)", onObject({ dimensions: ['account.name'], timeDimensions: [td('closed_at', 'month'), td('closed_at', 'year')] }), 'groupBy.2.field'],
130+
["two granularities beside two dimensions through one relationship (one FK key, so the later position)", onObject({ dimensions: ['account.id', 'account.name'], timeDimensions: [td('closed_at', 'month'), td('closed_at', 'year')] }), 'groupBy.2.field'],
131+
['two granularities at a non-UTC zone, where the dry run said the query is served (501)', onCube({ timeDimensions: [td('closed_at', 'month'), td('closed_at', 'year')], timezone: 'America/New_York' }), 'groupBy.1.field'],
132+
])('refuses %s with the query\'s refusal, field for field', async (_label, query, path) => {
133+
const asQuery = await refusalOf('query', analytics.query({ ...query } as never));
134+
const asSql = await refusalOf('sql', analytics.generateSql({ ...query } as never));
135+
expect(asQuery).toMatchObject({ code: 'INVALID_QUERY', status: 400, httpStatus: 400, object: DEAL, param: 'groupBy', path, fields: ['closed_at'] });
136+
expect(asSql, 'the dry run vs the query').toEqual(asQuery);
137+
});
138+
139+
it.each<[string, Query, string]>([
140+
[
141+
'one bucket of one column',
142+
onCube({ timeDimensions: [td('closed_at', 'month')] }),
143+
"SELECT strftime('%Y-%m', `closed_at`) AS \"closed_at\", SUM(amount) AS \"amount_sum\" FROM \"os28841_deal\" "
144+
+ "GROUP BY strftime('%Y-%m', `closed_at`)",
145+
],
146+
[
147+
'buckets of two columns',
148+
onCube({ timeDimensions: [td('closed_at', 'month'), td('closed_on', 'year')] }),
149+
"SELECT strftime('%Y-%m', `closed_at`) AS \"closed_at\", strftime('%Y', `closed_on`) AS \"closed_on\", "
150+
+ "SUM(amount) AS \"amount_sum\" FROM \"os28841_deal\" GROUP BY strftime('%Y-%m', `closed_at`), strftime('%Y', `closed_on`)",
151+
],
152+
[
153+
'a dimension that is its own bucket, asked twice at one granularity, beside a raw column',
154+
onCube({ dimensions: ['region', 'closed_at'], timeDimensions: [td('closed_at', 'month'), td('closed_at', 'month')] }),
155+
"SELECT region AS \"region\", strftime('%Y-%m', `closed_at`) AS \"closed_at\", SUM(amount) AS \"amount_sum\" "
156+
+ "FROM \"os28841_deal\" GROUP BY region, strftime('%Y-%m', `closed_at`)",
157+
],
158+
])('still renders %s byte for byte, the statement the query echoes', async (_label, query, sql) => {
159+
const dryRun = await analytics.generateSql({ ...query } as never);
160+
expect(dryRun).toEqual({ sql, params: [] });
161+
expect((await analytics.query({ ...query } as never)).sql).toBe(sql);
162+
});
163+
164+
it("still renders a cross-object 'account.name' beside one bucket byte for byte", async () => {
165+
const dryRun = await analytics.generateSql(onObject({ dimensions: ['account.name'], timeDimensions: [td('closed_at', 'month')] }) as never);
166+
expect(dryRun).toEqual({
167+
sql: "SELECT \"os28841_account\".\"name\" AS \"account.name\", strftime('%Y-%m', `closed_at`) AS \"closed_at\", "
168+
+ "SUM(amount) AS \"amount_sum\" FROM \"os28841_deal\" LEFT JOIN \"os28841_account\" ON \"os28841_deal\".\"account\" = "
169+
+ "\"os28841_account\".\"id\" GROUP BY \"os28841_account\".\"name\", strftime('%Y-%m', `closed_at`)",
170+
params: [],
171+
});
172+
});
173+
174+
it('still answers one bucket at a non-UTC zone with the echo refusal, while the query serves it', async () => {
175+
const query = onCube({ timeDimensions: [td('closed_at', 'month')], timezone: 'America/New_York' });
176+
expect(await refusalOf('sql', analytics.generateSql({ ...query } as never))).toMatchObject({ code: 'NOT_IMPLEMENTED', status: 501 });
177+
expect((await analytics.query({ ...query } as never)).rows).toHaveLength(1);
178+
});
179+
});

0 commit comments

Comments
 (0)