Skip to content

Commit e2e6ec7

Browse files
fix(driver-sql): by-id writes, aggregate() and distinct() honour external.columnMap (#31349)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiCJSKjyyyiryKpcYHe8ewtb)_ Fixes #31340 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:外部对象用 `external.columnMap` 把主键映射到远端列(如 `CUST_NO: 'id'`)时,`update()`、`delete()`、`bulkDelete()` 现在按映射后的列匹配行,不再写死 `WHERE "id" = ?`;`aggregate()`(分组、日期分桶、聚合字段)和 `distinct()` 也改为按远端列取值,输出键和展示仍用本地字段名。 - 为什么改:远端表恰好另有一个叫 `id` 的普通列时,旧语句在 SQLite / PostgreSQL / MySQL 上按错误的列匹配:更新返回空,删除会删掉没人指定的行,而且不报错。聚合和去重则直接报「列不存在」。 - 风险与回滚:没有 `columnMap` 的对象和轮转分片,生成的 SQL 与之前完全一致。只改 `packages/drivers/driver-sql`,两个 patch changeset。回滚就是还原这个 PR。 - 先看:`sql-driver-sequence-semantics.ts` 的 `update` / `delete`,`sql-driver-bulk-operations.ts` 的 `bulkDelete`;`sql-driver-aggregation.ts` 的 `columnOf`;两个新测试文件。 - 两分钟验证:`pnpm --filter @objectstack/driver-sql exec vitest run src/sql-driver-external-columnmap-writes.test.ts src/sql-driver-external-columnmap-aggregate.test.ts`,预期 13 个用例全部通过。 - 需要你做的:无,席位落地。`upsert` 同样没走映射,已另立 #31342,不在本 PR。 ## What changes Two defects, one commit and one patch changeset each. Both fixes were first written on the Oracle federation card's branch; this PR lands them alone off `main`, with nothing Oracle-specific (no spec change, no dialect arm). **By-id writes.** `update()` (the statement and its read-back), `delete()` and `bulkDelete()` resolve the key column through `remoteColumn(object, 'id', 'id')`, the same mapping `create()`'s read-back and the write payload (`applyWriteColumnMap`) already use. Rotation shards are never federated and keep `id`. **Analytic reads.** `aggregate()` spells every identifier (GROUP BY, the structured group key, the date-bucket expression, the aggregand) as the remote column, and `distinct()` selects and reads back the remote column. Output keys, presentation (`readPresentationKind`, `presentReadValue`) and the per-field registries stay keyed by the FIELD. `buildDateBucketExpr` gains an optional `column` parameter: the field still drives `temporalFieldKind` and the SQLite repair, and the bindings name the column. An object without a `columnMap` compiles the statement it compiled before. ## Why ADR-0015 federation maps a local field to a remote column; `find()`, `count()` and every WHERE honoured that, these five doors did not. The silent half: a remote table with its own unmapped `id` column, where the unmapped `WHERE "id" = ?` matched the surrogate, so an update answered `null` and a delete removed a row nobody named. ## Verification Measured on this branch (sqlite, the in-tree driver), before the merge of main at 0ceca39 and re-run on the merge head: - New pins red with the fix reverted, green with it applied: - `sql-driver-external-columnmap-writes.test.ts`: 5 of 5 fail with `sql-driver-sequence-semantics.ts` and `sql-driver-bulk-operations.ts` restored to `origin/main`; 5 pass with the fix. - `sql-driver-external-columnmap-aggregate.test.ts`: 8 of 8 fail with `sql-driver-aggregation.ts`, `sql-driver-distinct.ts` and `sql-driver-date-bucket.ts` restored to `origin/main`; 8 pass with the fix. - `pnpm --filter @objectstack/driver-sql test`: 240 files passed, 12 skipped; 3880 tests passed, 218 skipped (the live PG and MySQL cells, which run in `Temporal Conformance (live PG + MySQL)` on `main`). - `pnpm --filter @objectstack/driver-sql run typecheck`: exit 0. - eslint over the eight changed source and test files: exit 0. - `dispatch-gates --commands`: 68 derived; all 68 run, every one exit 0; `dispatch-gates --ran`: 68 accounted, 0 NOT-MEASURED, 0 UNRUN. Out of scope, measured and filed: `upsert()` on a columnMap-ed object uses the unmapped `id` as its conflict target and read-back key (#31342). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint over the changed files, exit 0.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (`@objectstack/driver-sql` patch, two changesets.) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2euiyuLXizr6FW32R3)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8b90442 commit e2e6ec7

10 files changed

Lines changed: 327 additions & 22 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): `aggregate()` and `distinct()` honour `external.columnMap` on every dialect
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A federated object whose `external.columnMap` names a remote column different from the field could not be aggregated or listed for distinct values. `find()`, `count()` and every WHERE already read through the map, but `aggregate()` named the field verbatim in its GROUP BY, its date-bucket expression and its aggregand, and `distinct()` named it in `SELECT DISTINCT`. The remote table was asked for a column it does not have. On SQLite, PostgreSQL and MySQL that surfaced as the driver's unresolvable-column refusal.
11+
12+
Both doors now spell every identifier as the remote column and keep the field everywhere the caller reads: as the output key, in the presentation (`min`/`max` over a mapped date, a mapped boolean's distinct values) and in the per-field registries. An object without a `columnMap` compiles exactly the statement it compiled before.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): `update()`, `delete()` and `bulkDelete()` find their row through `external.columnMap` on every dialect
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A federated object whose `external.columnMap` maps its key to a remote column (`CUST_NO: 'id'`) could be created and read, but not updated or deleted by id. The payload was already mapped, but the by-id doors wrote `WHERE "id" = ?` whatever the map said. The same held on SQLite, PostgreSQL and MySQL. On a remote table without an `id` column the statement failed. On a remote table that also has its own `id` column, which is not the mapped key, it matched rows by that other column: an update answered `null` or a delete removed a row nobody named.
11+
12+
The three doors now match the key's mapped column, the column the insert read-back already used. An object without a `columnMap`, and every rotation shard, compiles the statement it compiled before.

‎packages/drivers/driver-sql/src/sql-driver-aggregation.ts‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,24 @@ export interface AggregationHost {
6666
readonly numericFields: SqlDriver['numericFields'];
6767
presentReadColumns: SqlDriver['presentReadColumns'];
6868
readPresentationKind: SqlDriver['readPresentationKind'];
69+
remoteColumn: SqlDriver['remoteColumn'];
6970
}
7071

7172
/** The bodies of the "Aggregation" section of `SqlDriver`: a stateless facet, called by the class's stubs. */
7273
export class AggregationFacet {
7374
/** The body of `SqlDriver#aggregate`; the method's docblock there is its contract. */
74-
static async aggregate(this: Pick<AggregationHost, 'aggregandColumnClassOf' | 'aggregateBackendFault' | 'aliasIdentifierSql' | 'applyFilters' | 'applyTenantScope' | 'buildDateBucketExpr' | 'coercionKey' | 'config' | 'dateGranularityCapabilities' | 'dialectName' | 'foldEmptyAggregateAnswers' | 'getBuilder' | 'knex' | 'mapAggregateFunc' | 'presentReadColumns' | 'readPresentationKind'>, object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown>[]> {
75+
static async aggregate(this: Pick<AggregationHost, 'aggregandColumnClassOf' | 'aggregateBackendFault' | 'aliasIdentifierSql' | 'applyFilters' | 'applyTenantScope' | 'buildDateBucketExpr' | 'coercionKey' | 'config' | 'dateGranularityCapabilities' | 'dialectName' | 'foldEmptyAggregateAnswers' | 'getBuilder' | 'knex' | 'mapAggregateFunc' | 'presentReadColumns' | 'readPresentationKind' | 'remoteColumn'>, object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown>[]> {
7576
const builder = this.getBuilder(object, options);
77+
// The column a FIELD names in this statement: its `external.columnMap`
78+
// remote column on a federated object, the field itself otherwise. Every
79+
// identifier below is spelled through it, while every registry lookup
80+
// (`readPresentationKind`, `aggregandColumnClassOf`, the bucket's
81+
// `temporalFieldKind`) and every output key stays the FIELD — the name the
82+
// caller asked for and reads back. `find()` / `count()` / the WHERE have
83+
// always mapped (`remoteColumn` in `findRows`, `applyFilters`); this door
84+
// named the field verbatim, so a mapped object's GROUP BY / aggregand /
85+
// `distinct` asked the remote table for a column it does not have.
86+
const columnOf = (field: string): string => this.remoteColumn(object, field, field);
7687
this.applyTenantScope(builder, object, options);
7788

7889
if (query.where) {
@@ -115,8 +126,9 @@ export class AggregationFacet {
115126
// what forced the `as any` on the `buildDateBucketExpr` call below.
116127
for (const g of query.groupBy) {
117128
if (typeof g === 'string') {
118-
builder.groupBy(g);
119-
builder.select(g);
129+
const column = columnOf(g);
130+
builder.groupBy(column);
131+
builder.select(column === g ? g : this.knex.raw(`?? as ${this.aliasIdentifierSql(g)}`, [column]));
120132
const kind = this.readPresentationKind(table, g);
121133
if (kind) presentedOutput.set(g, kind);
122134
} else if (g && typeof g === 'object' && g.field) {
@@ -133,7 +145,7 @@ export class AggregationFacet {
133145
// moves.
134146
const outKey = g.alias ?? g.field;
135147
if (g.dateGranularity) {
136-
const bucket = this.buildDateBucketExpr(g.field, g.dateGranularity, table);
148+
const bucket = this.buildDateBucketExpr(g.field, g.dateGranularity, table, columnOf(g.field));
137149
if (!bucket) {
138150
// [#6212] Was a bare `throw new Error(...)`; see
139151
// {@link refuseDateBucketedGroupBy} for why it now carries the
@@ -147,14 +159,15 @@ export class AggregationFacet {
147159
builder.groupByRaw(bucket.sql, bucket.bindings);
148160
builder.select(this.knex.raw(`${bucket.sql} as ${this.aliasIdentifierSql(outKey)}`, [...bucket.bindings]));
149161
} else {
150-
builder.groupBy(g.field);
162+
const column = columnOf(g.field);
163+
builder.groupBy(column);
151164
// Aliased only when the name actually moves: an alias equal to
152-
// the field would otherwise rewrite `select "region"` into
165+
// the column would otherwise rewrite `select "region"` into
153166
// `select "region" as "region"` on every dialect for no gain.
154167
// [#13714] The alias half is {@link aliasIdentifierSql}, never a
155-
// `??` binding — the FIELD stays a `??` reference (it may be
168+
// `??` binding — the COLUMN stays a `??` reference (it may be
156169
// qualified), the alias is one identifier by definition.
157-
builder.select(outKey === g.field ? g.field : this.knex.raw(`?? as ${this.aliasIdentifierSql(outKey)}`, [g.field]));
170+
builder.select(outKey === column ? column : this.knex.raw(`?? as ${this.aliasIdentifierSql(outKey)}`, [column]));
158171
// Keyed by the OUTPUT column, like the aggregation branch below —
159172
// `presentReadColumns` matches on the name the row actually
160173
// carries, so an aliased group value went unpresented before.
@@ -230,7 +243,7 @@ export class AggregationFacet {
230243
if (fieldExpr === '*') {
231244
builder.select(this.knex.raw(`${lowering.sql}(*) as ${this.aliasIdentifierSql(agg.alias)}`));
232245
} else {
233-
builder.select(this.knex.raw(`${rawFunc} as ${this.aliasIdentifierSql(agg.alias)}`, [fieldExpr]));
246+
builder.select(this.knex.raw(`${rawFunc} as ${this.aliasIdentifierSql(agg.alias)}`, [columnOf(fieldExpr)]));
234247
}
235248
// [#15546] What this aggregate answers over NOTHING, read from the
236249
// policy rather than restated: `sum` (and the two counts, which never
@@ -279,7 +292,7 @@ export class AggregationFacet {
279292
if (fieldExpr === '*') {
280293
builder.select(this.knex.raw(`${lowering.sql}(*)`));
281294
} else {
282-
builder.select(this.knex.raw(rawFunc, [fieldExpr]));
295+
builder.select(this.knex.raw(rawFunc, [columnOf(fieldExpr)]));
283296
}
284297
}
285298
}

‎packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export interface BulkOperationsHost {
6262
readonly knex: SqlDriver['knex'];
6363
readBackInsertedRows: SqlDriver['readBackInsertedRows'];
6464
resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax'];
65+
remoteColumn: SqlDriver['remoteColumn'];
6566
rotationShardsOf: SqlDriver['rotationShardsOf'];
6667
rotationWriteTarget: SqlDriver['rotationWriteTarget'];
6768
stampInsertTimestamps: SqlDriver['stampInsertTimestamps'];
@@ -256,10 +257,12 @@ export class BulkOperationsFacet {
256257
}
257258

258259
/** The body of `SqlDriver#bulkDelete`; the method's docblock there is its contract. */
259-
static async bulkDelete(this: Pick<BulkOperationsHost, 'applyTenantScope' | 'auditMissingTenant' | 'getBuilder' | 'rotationShardsOf'>, object: string, ids: Array<string | number>, options?: DriverOptions): Promise<void> {
260+
static async bulkDelete(this: Pick<BulkOperationsHost, 'applyTenantScope' | 'auditMissingTenant' | 'getBuilder' | 'remoteColumn' | 'rotationShardsOf'>, object: string, ids: Array<string | number>, options?: DriverOptions): Promise<void> {
260261
this.auditMissingTenant(object, 'bulkDelete', options);
261262
for (const target of this.rotationShardsOf(object) ?? [object]) {
262-
const builder = this.getBuilder(target, options).whereIn('id', ids);
263+
// The primary key's column: the `external.columnMap` remote column on a
264+
// federated object, `id` otherwise (a rotation shard is never federated).
265+
const builder = this.getBuilder(target, options).whereIn(this.remoteColumn(object, 'id', 'id'), ids);
263266
this.applyTenantScope(builder, object, options);
264267
await builder.delete();
265268
}

‎packages/drivers/driver-sql/src/sql-driver-date-bucket.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,17 @@ export interface DateBucketHost {
5959
}
6060

6161
/** The body of `SqlDriver#buildDateBucketExpr`; the method's docblock there is its contract. */
62-
export function buildDateBucketExpr(this: Pick<DateBucketHost, 'dateGranularityCapabilities' | 'isMysql' | 'isPostgres' | 'isSqlite' | 'sqliteTemporalArg' | 'temporalFieldKind'>, field: string, granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', table?: string | null): { sql: string; bindings: any[] } | null {
62+
export function buildDateBucketExpr(this: Pick<DateBucketHost, 'dateGranularityCapabilities' | 'isMysql' | 'isPostgres' | 'isSqlite' | 'sqliteTemporalArg' | 'temporalFieldKind'>, field: string, granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', table?: string | null, column: string = field): { sql: string; bindings: any[] } | null {
6363
if (!this.dateGranularityCapabilities[granularity]) return null;
6464

65+
// `field` is the FIELD — the registries (`temporalFieldKind`, the SQLite
66+
// repair) are keyed by it; `column` is the identifier the statement names,
67+
// the remote column an `external.columnMap` maps the field to.
68+
if (column !== field) {
69+
const bucket = buildDateBucketExpr.call(this, field, granularity, table);
70+
return bucket && { sql: bucket.sql, bindings: bucket.bindings.map((b) => (b === field ? column : b)) };
71+
}
72+
6573
const calendarDay = this.temporalFieldKind(table, field) === 'date';
6674

6775
if (this.isPostgres) {

‎packages/drivers/driver-sql/src/sql-driver-distinct.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@ export interface DistinctHost {
4646
getBuilder: SqlDriver['getBuilder'];
4747
presentReadValue: SqlDriver['presentReadValue'];
4848
readPresentationKind: SqlDriver['readPresentationKind'];
49+
remoteColumn: SqlDriver['remoteColumn'];
4950
}
5051

5152
/** The bodies of the "Distinct" section of `SqlDriver`: a stateless facet, called by the class's stubs. */
5253
export class DistinctFacet {
5354
/** The body of `SqlDriver#distinct`; the method's docblock there is its contract. */
54-
static async distinct(this: Pick<DistinctHost, 'applyFilters' | 'applyTenantScope' | 'coercionKey' | 'distinctBackendFault' | 'getBuilder' | 'presentReadValue' | 'readPresentationKind'>, object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise<any[]> {
55+
static async distinct(this: Pick<DistinctHost, 'applyFilters' | 'applyTenantScope' | 'coercionKey' | 'distinctBackendFault' | 'getBuilder' | 'presentReadValue' | 'readPresentationKind' | 'remoteColumn'>, object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise<any[]> {
5556
const builder = this.getBuilder(object, options);
5657
// The third read door that skipped the chokepoint, and the one #6792 does
5758
// NOT name — the card asserts the opposite ("called at 13 sites — …,
@@ -70,7 +71,12 @@ export class DistinctFacet {
7071
this.applyFilters(builder, filters, options);
7172
}
7273

73-
builder.distinct(field);
74+
// The column the field names on this table — its `external.columnMap`
75+
// remote column on a federated object (the mapping `find()` and the WHERE
76+
// above already apply), the field itself otherwise. The values are read
77+
// back under that column, and presented by the FIELD's registry entry.
78+
const column = this.remoteColumn(object, field, field);
79+
builder.distinct(column);
7480
// [commit 7c2c5aedd] The THIRD read door joins the other two. `find()` and `count()`
7581
// have carried the terminal envelope since #8931 and `aggregate()` since
7682
// #11455; this one still executed BARE, so any dialect refusal the
@@ -123,7 +129,7 @@ export class DistinctFacet {
123129
} catch (error) {
124130
throw this.distinctBackendFault(object, field, error, filters);
125131
}
126-
const values = results.map((row: any) => row[field]);
132+
const values = results.map((row: any) => row[column]);
127133

128134
// Same presentation `find()` gives the column (#3797, #3849) — a caller
129135
// listing a datetime's values should not get epoch integers here and ISO
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `aggregate()` and `distinct()` honour `external.columnMap` (ADR-0015 §18).
5+
*
6+
* `find()`, `count()` and every WHERE have mapped a federated object's local
7+
* field to its remote column since columnMap landed (`remoteColumn` in
8+
* `findRows`, `applyFilters`). The two analytic doors did not: they named the
9+
* FIELD verbatim in GROUP BY, in the aggregand and in `SELECT DISTINCT`, so
10+
* the remote table was asked for a column it does not have. Measured before
11+
* the fix on SQLite (this file's fixture) as the driver's unattributed-column
12+
* refusal.
13+
*
14+
* The rule pinned here: identifiers are the REMOTE column; output keys,
15+
* presentation and the per-field registries stay the FIELD.
16+
*/
17+
18+
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
19+
import { rmSync } from 'node:fs';
20+
import { tmpdir } from 'node:os';
21+
import { join } from 'node:path';
22+
import { SqlDriver } from './index.js';
23+
24+
const REMOTE = {
25+
name: 'legacy_deal',
26+
fields: {
27+
deal_no: { type: 'text' },
28+
region_code: { type: 'text' },
29+
amt: { type: 'number' },
30+
closed_dt: { type: 'date' },
31+
won_flag: { type: 'boolean' },
32+
},
33+
};
34+
35+
const COLUMN_MAP = { deal_no: 'id', region_code: 'region', amt: 'amount', closed_dt: 'closed_on', won_flag: 'won' };
36+
const LOCAL_FIELDS = {
37+
region: { type: 'text' },
38+
amount: { type: 'number' },
39+
closed_on: { type: 'date' },
40+
won: { type: 'boolean' },
41+
};
42+
43+
let file: string;
44+
let driver: any;
45+
46+
beforeAll(async () => {
47+
file = join(tmpdir(), `os-cm-agg-${process.pid}-${Date.now()}.db`);
48+
const fx = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true }) as any;
49+
fx.name = 'fx';
50+
await fx.connect();
51+
await fx.initObjects([REMOTE]);
52+
await fx.create('legacy_deal', { deal_no: 'd1', region_code: 'NA', amt: 100, closed_dt: '2026-01-10', won_flag: true });
53+
await fx.create('legacy_deal', { deal_no: 'd2', region_code: 'EU', amt: 40, closed_dt: '2026-01-20', won_flag: false });
54+
await fx.create('legacy_deal', { deal_no: 'd3', region_code: 'EU', amt: 60, closed_dt: '2026-04-02', won_flag: true });
55+
await fx.disconnect();
56+
57+
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true, schemaMode: 'external' } as any) as any;
58+
driver.name = 'extds';
59+
await driver.connect();
60+
driver.registerExternalObject({ name: 'deal', external: { remoteName: 'legacy_deal', columnMap: COLUMN_MAP }, fields: LOCAL_FIELDS });
61+
});
62+
63+
afterAll(async () => {
64+
await driver?.disconnect();
65+
if (file) rmSync(file, { force: true });
66+
});
67+
68+
const byRegion = (rows: any[]) => [...rows].sort((a, b) => String(a.region).localeCompare(String(b.region)));
69+
70+
describe('aggregate() over a columnMap-ed object', () => {
71+
it('groups by the remote column and keys the group under the field', async () => {
72+
const rows = await driver.aggregate('deal', {
73+
groupBy: ['region'],
74+
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }, { function: 'count', alias: 'n' }],
75+
});
76+
expect(byRegion(rows)).toEqual([
77+
{ region: 'EU', total: 100, n: 2 },
78+
{ region: 'NA', total: 100, n: 1 },
79+
]);
80+
});
81+
82+
it('an aliased structured group key lands under its alias', async () => {
83+
const rows = await driver.aggregate('deal', {
84+
groupBy: [{ field: 'region', alias: 'r' }],
85+
aggregations: [{ function: 'max', field: 'amount', alias: 'top' }],
86+
});
87+
expect([...rows].sort((a: any, b: any) => a.r.localeCompare(b.r))).toEqual([
88+
{ r: 'EU', top: 60 },
89+
{ r: 'NA', top: 100 },
90+
]);
91+
});
92+
93+
it('a date-bucketed group reads the remote column and keeps the field registry (calendar day)', async () => {
94+
const rows = await driver.aggregate('deal', {
95+
groupBy: [{ field: 'closed_on', dateGranularity: 'month' }],
96+
aggregations: [{ function: 'count', alias: 'n' }],
97+
});
98+
expect([...rows].sort((a: any, b: any) => a.closed_on.localeCompare(b.closed_on))).toEqual([
99+
{ closed_on: '2026-01', n: 2 },
100+
{ closed_on: '2026-04', n: 1 },
101+
]);
102+
});
103+
104+
it('min/max over a mapped column take the field presentation; count_distinct maps too', async () => {
105+
const [row] = await driver.aggregate('deal', {
106+
aggregations: [
107+
{ function: 'min', field: 'closed_on', alias: 'first' },
108+
{ function: 'count_distinct', field: 'region', alias: 'regions' },
109+
],
110+
});
111+
expect(row).toEqual({ first: '2026-01-10', regions: 2 });
112+
});
113+
114+
it('the WHERE and the GROUP BY agree on the mapping', async () => {
115+
const rows = await driver.aggregate('deal', {
116+
where: { won: true },
117+
groupBy: ['region'],
118+
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
119+
});
120+
expect(byRegion(rows)).toEqual([
121+
{ region: 'EU', total: 60 },
122+
{ region: 'NA', total: 100 },
123+
]);
124+
});
125+
});
126+
127+
describe('distinct() over a columnMap-ed object', () => {
128+
it('reads the remote column and answers its values', async () => {
129+
expect([...(await driver.distinct('deal', 'region'))].sort()).toEqual(['EU', 'NA']);
130+
});
131+
132+
it('presents by the field registry (a mapped boolean reads as a boolean)', async () => {
133+
expect([...(await driver.distinct('deal', 'won'))].sort()).toEqual([false, true]);
134+
});
135+
136+
it('with a filter on another mapped field', async () => {
137+
expect(await driver.distinct('deal', 'region', { amount: { $gt: 50 } })).toEqual(expect.arrayContaining(['EU', 'NA']));
138+
expect(await driver.distinct('deal', 'region', { amount: { $lt: 50 } })).toEqual(['EU']);
139+
});
140+
});

0 commit comments

Comments
 (0)