Skip to content

Commit d5f4c2a

Browse files
fix(service-analytics): an all-NULL sum group sorts and pages as the 0 it answers, the same on SQLite and PostgreSQL (#25953)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D6RGhxHE9XKMJQY9XoRwmZW)_ Fixes #25907 Clause-②: no Metadata-protocol: none Every query accepted today is still accepted, and no key, code or schema changes. The answer becomes the same on every named dialect and agrees with the identity `emptyGroupValueFor` already declares. Nothing under `packages/spec/**`, and no new error code. ## What was wrong (reproduced red first, on both dialects) The #21042 fold rewrote a NULL `sum` to `0` in JavaScript, after `executeRawSql`, so the database had already ordered and paged on the NULL. Measured at `AnalyticsService.query` (what `POST /analytics/query` calls) on base `f00e8d5c`, with the new test's fixture (`total` per group: d -3, c all-NULL, a 5, NULL-group 7, b 10), `order { total: 'asc' }, limit 2`: | | native SQLite | native PostgreSQL 16 | ObjectQL face, both | |:--|:--|:--|:--| | page | `c 0`, `d -3` (a `0` before `-3`) | `d -3`, `a 5` (`c` cut from the page) | `d -3`, `c 0` | The base run of the new file: 24 of 29 red across both cells. ## What changed `packages/services/service-analytics/src/strategies/native-sql-strategy.ts` - **The identity is in the statement.** `withEmptyGroupIdentity` wraps a measure's aggregate in `COALESCE(…, 0)` when `emptyGroupValueFor` declares an identity for it, plain and measure-scoped (conditional) forms alike. So `ORDER BY`, `LIMIT` and `OFFSET` read the value the group answers. `count` / `count_distinct` are not wrapped: SQL `COUNT` already answers `0` over nothing, on every dialect. The JS fold loop after `executeRawSql` is removed. The statement is now the one place, and the echoed `sql` / `/analytics/sql` show it. - **NULL placement is stated.** `avg`, `min` and `max` keep NULL over nothing (no identity), and a dimension can hold NULL. A new module export, `orderClauseSql(order, dialect)`, next to `windowClauseSql`, writes `NULLS LAST` on every key, in both directions, on `sqlite` and `postgres`. That is `applyOrdering`'s rule: the ObjectQL face applies it, and so does the dataset door's post-pass, which re-sorts a pushed-down page. On SQLite the pushed-down window was being cut on one order and then shown in another. - **MySQL and `unknown` keep the driver's placement.** MySQL has no `NULLS LAST`, and `unknown` covers it. An `x IS NULL` sort expression is not a portable substitute: PostgreSQL resolves a name inside an expression against the input columns, not the select alias. ⚠️ **MySQL is NOT MEASURED**: there is no MySQL server here, and its arm is asserted as text only. - `orderClauseSql` is not re-exported from `src/index.ts`, so the package's public surface is unchanged. `packages/services/service-analytics/src/strategies/objectql-strategy.ts` (surface amendment 6408215361) - `generateSql`'s echo builds its `ORDER BY` through the same `orderClauseSql`, the way it already builds the window through `windowClauseSql`. The echo now states the nulls-last order the face really applies. The `orderAndWindow` doc comment no longer says the native face leaves NULL placement to the driver. ## Tests New: `src/__tests__/native-sql-empty-sum-order-window.test.ts`, through the real `ObjectQL` engine and `SqlDriver`, with a SQLite cell and a live PostgreSQL cell (`OS_TEST_POSTGRES_URL`; the live PostgreSQL cell ran locally, on PostgreSQL 16, because CI's Temporal Conformance job was path-filtered off this PR): - Nine literal pages on both faces: the card's `total asc, limit 2` → `d -3, c 0`, offset and offset-only windows, `avg_score` / `min_score` with the NULL group last in both directions, and the NULL dimension value last in both directions. - A sweep over every order key × both directions × four windows: the native page equals the ObjectQL page, row for row. - The statement carries `COALESCE(SUM(…), 0)`, carries no `COALESCE` around `AVG` / `MIN`, and ends with `ORDER BY "total" ASC NULLS LAST, "avg_score" DESC NULLS LAST LIMIT 2`. The echo and `generateSql` are byte-equal to what ran. - At the dataset door, the pushed-down `ORDER BY … NULLS LAST LIMIT 2` answers `d, c` on both faces. - The `unknown` arm (no `sqlDialect` hook), executed on both engines, answers the same `sum` page, with no placement spelled. - `orderClauseSql` text per dialect, MySQL included as text. Pins flipped to the new bytes, each asserting the new semantics: `measure-expression-sql`, `measure-field-and-filter-compilation`, `objectql-dataset-filter` (the two native-door lines; the ObjectQL echo line is unchanged), `native-sql-offset-only-window`, `objectql-echo-offset-only-window` (it keeps its full cross-face comparison from ` ORDER BY ` on), and `objectql-face-order-limit` (its header now names where NULL placement is pinned). Runs at head `acc8e8b0`, local PostgreSQL 16: - `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2` with `OS_TEST_POSTGRES_URL` set: **Test Files 186 passed (186), Tests 4806 passed | 2 skipped (4808)**. One environment note: `read-scope-temporal-coercion`'s premise cell ("the server is not on UTC") is red against a UTC PostgreSQL. It went green (15 passed, 1 skipped) once the test database's zone was set off UTC, which matches CI's skewed-zone job. It is unrelated to this diff. - `pnpm --filter @objectstack/service-analytics typecheck`: exit 0 (the package's `tsc` includes `src/__tests__`). - Ablations, each through `scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty: - The `COALESCE` return replaced with the bare aggregate: the new file goes 24 failed / 5 passed of 29. - `NULLS LAST` set to `null` for sqlite and postgres: 12 failed / 17 passed. Every `sum` page stays green, because `COALESCE` alone fixes the card's rows. Every placement page goes red in the direction its driver differs. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands` at `acc8e8b0` derives 68 families. Run reconciliation (`--ran`): **66 run, all exit 0; 2 NOT MEASURED; 0 unrun.** The two NOT MEASURED, each exit 3 (`PREREQUISITE NOT MET`): - `node scripts/check-plugin-teardown-shape.mjs --self-test`: shallow clone, the pinned positive-control commit is unreachable. - `pnpm check:dual-build-cjs-loads`: needs every package's `dist/`. - CI runs both. - `pnpm lint` (the seat's addition): exit 0. - The remaining families: the PR's check-runs. ## Merge note: PR #25903 (#25778) touches the same block PR #25903 edits the fold loop this PR removes (duration measures fold by `durationOf(member)?.aggregate`). It also adds `resolveDurationMeasureSql`, which returns `AGGREGATE_SQL[duration.aggregate](operand)` and its conditional form without the identity. The two PRs conflict textually in `execute()`, so neither merges silently. Whichever lands second should route the duration aggregate through `withEmptyGroupIdentity(duration.aggregate, …)` instead of restoring the JS fold. Otherwise an all-NULL duration `sum` answers `null`. #25903's `dataset-duration-measure.test.ts` can then take back the all-NULL group it excludes for this reason. ## Acceptance notes - `''` in an ordered column: `applyOrdering` treats `''` as empty and sorts it last, while SQL sorts `''` as text. The objectql-strategy doc comment already names this difference, and it is unchanged here. Carrier: none. - The ObjectQL face's echo still prints a bare `SUM(…)`, while that face answers `0` for an empty group through the driver's `foldEmptyAggregateAnswers`. The echo runs and orders right, but would print `NULL` for that cell. This is unchanged. Carrier: none. - #25906 and #25814 (same file and axis) remain open and untouched here. --- _Generated by [Claude Code](https://claude.ai/code/session_016NB4rZT58RvxS6vBGLcXqz)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016NB4rZT58RvxS6vBGLcXqz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 59d9181 commit d5f4c2a

10 files changed

Lines changed: 471 additions & 63 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): an all-NULL `sum` group sorts and pages as the `0` it answers, the same on SQLite and PostgreSQL
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** The native-SQL strategy folded a NULL `sum` (a group whose aggregand is NULL in every row, or a measure-scoped `sum` with no admitted row) to `0` in JavaScript, after the statement had already ordered and paged on the NULL. SQLite sorts NULL lowest and PostgreSQL highest, so `order { total: 'asc' }, limit 2` answered `0` before `-3` on SQLite and cut the `0` group from the page on PostgreSQL. The ObjectQL face answered `-3`, `0`.
11+
- **Now.** The identity `emptyGroupValueFor` declares is in the statement, `COALESCE(SUM(…), 0)`, so `ORDER BY`, `LIMIT` and `OFFSET` read the value the group answers, on every dialect. The echoed `sql` and `/analytics/sql` show it. `count` is not wrapped: SQL `COUNT` already answers `0` over nothing.
12+
- **NULL placement is stated.** `avg`, `min` and `max` keep NULL over nothing, and a dimension can hold NULL. On SQLite and PostgreSQL every `ORDER BY` key now carries `NULLS LAST`, in both directions: the ordering the ObjectQL face and the dataset door's post-pass already apply. A host whose dialect is MySQL or unnamed keeps the driver's placement, because no one spelling parses on all of them.
13+
- **The ObjectQL face's echo states it too.** That face already orders NULL last (`applyOrdering`); its echoed `sql` and `/analytics/sql` now render the same `ORDER BY` as the native face, `NULLS LAST` included, so the printed statement runs to the order the face answered.

‎packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ describe('a retired custom-SQL metric type is refused, never emitted', () => {
7878
it('still wraps the aggregate types', async () => {
7979
const sql = await sqlFor({ cube: 'orders', measures: ['count', 'total'] });
8080
expect(sql).toContain('COUNT(*) AS "count"');
81-
expect(sql).toContain('SUM(amount) AS "total"');
81+
// [#25907] A `sum` answers its empty-group identity in the statement.
82+
expect(sql).toContain('COALESCE(SUM(amount), 0) AS "total"');
8283
});
8384
});
8485

@@ -105,14 +106,14 @@ describe('an expression containing a dot is not mistaken for a join path', () =>
105106

106107
it('emits the expression intact and registers no phantom join', async () => {
107108
const sql = await dottedSql({ cube: 'orders', measures: ['acct_total'] });
108-
expect(sql).toContain('SUM(COALESCE(account.amount, 0) / 2) AS "acct_total"');
109+
expect(sql).toContain('COALESCE(SUM(COALESCE(account.amount, 0) / 2), 0) AS "acct_total"');
109110
expect(sql).not.toContain('"COALESCE(account"');
110111
expect(sql).not.toContain('LEFT JOIN "COALESCE(account"');
111112
});
112113

113114
it('still lowers a real relationship path into a qualified column and a join', async () => {
114115
const sql = await dottedSql({ cube: 'orders', measures: ['acct_amount'] });
115-
expect(sql).toContain('SUM("account"."amount") AS "acct_amount"');
116+
expect(sql).toContain('COALESCE(SUM("account"."amount"), 0) AS "acct_amount"');
116117
expect(sql).toContain('LEFT JOIN "account" ON "orders"."account" = "account"."id"');
117118
});
118119
});

‎packages/services/service-analytics/src/__tests__/measure-field-and-filter-compilation.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ describe('[#10298] `/api/v1/analytics/query` compiles every per-measure `filter`
185185
// …and each filtered one carries its own predicate.
186186
expect(sql).toContain('COUNT(CASE WHEN stage = $1 THEN 1 END) AS "won_count"');
187187
expect(sql).toContain('COUNT(CASE WHEN stage = $2 THEN 1 END) AS "lost_count"');
188-
expect(sql).toContain('SUM(CASE WHEN stage = $3 THEN amount END) AS "won_amount"');
188+
// [#25907] A conditional `sum` answers `0` over no admitted row, in the statement.
189+
expect(sql).toContain('COALESCE(SUM(CASE WHEN stage = $3 THEN amount END), 0) AS "won_amount"');
189190

190191
// Comparands are BOUND, in the order their placeholders appear. The SELECT
191192
// list precedes the WHERE clause and `$n` is positional, so a filter
@@ -239,7 +240,7 @@ describe('[#10298] `/api/v1/analytics/query` compiles every per-measure `filter`
239240
}],
240241
});
241242
const { sql, params } = await sqlFor(svc, { cube: 'crm_case', measures: ['count', 'amount_sum'] });
242-
expect(sql).toBe('SELECT COUNT(*) AS "count", SUM(amount) AS "amount_sum" FROM "crm_case"');
243+
expect(sql).toBe('SELECT COUNT(*) AS "count", COALESCE(SUM(amount), 0) AS "amount_sum" FROM "crm_case"');
243244
expect(params).toEqual([]);
244245
});
245246
});

‎packages/services/service-analytics/src/__tests__/native-sql-empty-sum-order-window.test.ts‎

Lines changed: 334 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/services/service-analytics/src/__tests__/native-sql-offset-only-window.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ for (const cell of CELLS) {
222222
it("the card's row: ordered, offset 1, no limit — both faces answer every row after the first", async () => {
223223
const native = await ask('native', OFFSET_ONLY);
224224
expect(native.statements, 'the native face ran ONE statement').toHaveLength(1);
225-
expect(native.statements[0].endsWith(`ORDER BY "note" ASC${cell.noLimit} OFFSET 1`), native.statements[0]).toBe(true);
225+
// [#25907] The order states its NULL placement on this dialect.
226+
expect(native.statements[0].endsWith(`ORDER BY "note" ASC NULLS LAST${cell.noLimit} OFFSET 1`), native.statements[0]).toBe(true);
226227
expect(tuples(native.res.rows, ['note', 'amount_sum']), 'native face').toEqual(AFTER_FIRST);
227228

228229
const objectql = await ask('objectql', OFFSET_ONLY);
@@ -253,7 +254,7 @@ for (const cell of CELLS) {
253254
it('CONTROL: an integer window keeps its bytes — `LIMIT 2 OFFSET 1` — and both faces agree', async () => {
254255
const query = { ...OFFSET_ONLY, limit: 2 };
255256
const native = await ask('native', query);
256-
expect(native.statements[0].endsWith('ORDER BY "note" ASC LIMIT 2 OFFSET 1'), native.statements[0]).toBe(true);
257+
expect(native.statements[0].endsWith('ORDER BY "note" ASC NULLS LAST LIMIT 2 OFFSET 1'), native.statements[0]).toBe(true);
257258
expect(tuples(native.res.rows, ['note', 'amount_sum'])).toEqual([['x', 15], ['y', 20]]);
258259
const objectql = await ask('objectql', query);
259260
expect(tuples(objectql.res.rows, ['note', 'amount_sum'])).toEqual([['x', 15], ['y', 20]]);

‎packages/services/service-analytics/src/__tests__/objectql-dataset-filter.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ describe('[#10413] the three doors on one dataset', () => {
234234
expect(sql).toContain('is_deleted = $');
235235
// ...and one conditional aggregate per measure-scoped filter.
236236
expect(sql).toContain('THEN 1 END) AS "won_count"');
237-
expect(sql).toContain('THEN amount END) AS "won_amount"');
237+
expect(sql).toContain('THEN amount END), 0) AS "won_amount"');
238238
});
239239

240240
it('the DASHBOARD door answers the fully-declared numbers (it always did)', async () => {
@@ -326,7 +326,7 @@ describe('[#10413 phase 2] per-measure filters lower into the aggregation they b
326326
const svc = sqlService([OPPORTUNITY_METRICS]);
327327
const { sql } = await svc.generateSql({ cube: 'opportunity_metrics', measures: MEASURES });
328328
expect(sql).toContain('THEN 1 END) AS "won_count"');
329-
expect(sql).toContain('THEN amount END) AS "won_amount"');
329+
expect(sql).toContain('THEN amount END), 0) AS "won_amount"');
330330
});
331331
});
332332

‎packages/services/service-analytics/src/__tests__/objectql-echo-offset-only-window.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,8 @@ for (const cell of CELLS) {
208208
expect(tuples(objectql.res.rows, ['note', 'amount_sum']), 'ObjectQL face').toEqual(AFTER_FIRST);
209209

210210
const echo = objectql.res.sql!;
211-
expect(echo.endsWith(`ORDER BY "note" ASC${cell.noLimit} OFFSET 1`), echo).toBe(true);
211+
// [#25907] The echo states the NULL placement the face applies, on this dialect.
212+
expect(echo.endsWith(`ORDER BY "note" ASC NULLS LAST${cell.noLimit} OFFSET 1`), echo).toBe(true);
212213
// `generateSql` is the body `POST /analytics/sql` answers with.
213214
const dryRun = await services.objectql!.generateSql!(OFFSET_ONLY as any);
214215
expect(dryRun.sql).toBe(echo);
@@ -237,7 +238,7 @@ for (const cell of CELLS) {
237238
it('CONTROL: an integer window keeps its bytes — `LIMIT 2 OFFSET 1` — and the echo runs', async () => {
238239
const query = { ...OFFSET_ONLY, limit: 2 };
239240
const { res } = await ask('objectql', query);
240-
expect(res.sql!.endsWith('ORDER BY "note" ASC LIMIT 2 OFFSET 1'), res.sql).toBe(true);
241+
expect(res.sql!.endsWith('ORDER BY "note" ASC NULLS LAST LIMIT 2 OFFSET 1'), res.sql).toBe(true);
241242
expect(tuples(res.rows, ['note', 'amount_sum'])).toEqual([['x', 15], ['y', 20]]);
242243
expect(tuples(await run(res.sql!), ['note', 'amount_sum'])).toEqual([['x', 15], ['y', 20]]);
243244
});

‎packages/services/service-analytics/src/__tests__/objectql-face-order-limit.test.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@
2929
* ## What "equal to the native face" covers
3030
*
3131
* The face orders with the package's one row comparator (`applyOrdering`).
32-
* The native face's `ORDER BY` follows the driver: SQLite sorts NULL lowest and
33-
* PostgreSQL highest, and text follows the column collation. The fixtures
34-
* below therefore carry no NULL, no `''`, no numeric text and no mixed case in
35-
* an ordered column, which is where the two faces agree on both drivers.
32+
* The native face's `ORDER BY` text follows the column collation, and its NULL
33+
* placement is stated (`NULLS LAST` on SQLite and PostgreSQL, #25907, pinned
34+
* in `native-sql-empty-sum-order-window.test.ts`). The fixtures below carry no
35+
* NULL, no `''`, no numeric text and no mixed case in an ordered column, which
36+
* is where the two faces agree on both drivers.
3637
*
3738
* ## The dialect axis of THIS file
3839
*
@@ -315,7 +316,8 @@ for (const cell of CELLS) {
315316
it('the echoed sql, run on the same driver, answers the rows the ObjectQL face answered', async () => {
316317
const query = { cube: CUBE, measures: ['amount_sum'], dimensions: ['note'], order: { amount_sum: 'desc' }, offset: 1, limit: 2 };
317318
const { res } = await ask('objectql', query);
318-
expect(res.sql).toContain('ORDER BY "amount_sum" DESC LIMIT 2 OFFSET 1');
319+
// [#25907] The echo states the NULL placement the face applies.
320+
expect(res.sql).toContain('ORDER BY "amount_sum" DESC NULLS LAST LIMIT 2 OFFSET 1');
319321
const ran = (await driver.execute(res.sql)) as unknown;
320322
const echoed = Array.isArray(ran) ? ran : (ran as { rows: Row[] }).rows;
321323
expect(tuples(res.rows, ['note', 'amount_sum'])).toEqual(tuples(echoed, ['note', 'amount_sum']));

‎packages/services/service-analytics/src/strategies/native-sql-strategy.ts‎

Lines changed: 83 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,76 @@ export function windowClauseSql(
245245
return sql;
246246
}
247247

248+
/**
249+
* [#25907] The NULL placement an `ORDER BY` key states, per dialect — `null`
250+
* where the dialect has no spelling for it, so the driver's placement stands.
251+
*
252+
* A key can still order a NULL after the empty-group identity moved into the
253+
* statement ({@link withEmptyGroupIdentity}): an aggregate with no identity
254+
* (`avg`, `min`, `max`) over nothing, and a NULL dimension value. Left to the
255+
* driver, SQLite sorts it lowest and PostgreSQL highest, so the same sorted,
256+
* paged query answered a different page per dialect; and the dataset door's
257+
* post-pass re-sorts a pushed-down page with `applyOrdering`, which keeps NULL
258+
* last in both directions, so on SQLite the window was cut on one order and
259+
* shown in another. The stated placement is that comparator's: last, in both
260+
* directions, the order the ObjectQL face answers too.
261+
*
262+
* MySQL has no `NULLS LAST`, and `unknown` covers it among others (see
263+
* {@link OFFSET_ONLY_LIMIT_SQL}); no one spelling parses on every dialect it
264+
* covers, so those two keep the driver's placement. ⛔ An `IS NULL` sort
265+
* expression is not that spelling: PostgreSQL resolves a name inside an
266+
* expression against the INPUT columns, never the select alias, so a measure
267+
* aliased like a column would sort on that column. ⚠️ The `mysql` cell is NOT
268+
* MEASURED, the same declared skip as {@link OFFSET_ONLY_LIMIT_SQL}'s.
269+
*/
270+
const NULLS_LAST_SQL: Readonly<Record<AnalyticsSqlDialect, string | null>> = {
271+
sqlite: 'NULLS LAST',
272+
postgres: 'NULLS LAST',
273+
mysql: null,
274+
unknown: null,
275+
};
276+
277+
/**
278+
* [#25907] The `ORDER BY` clause a statement carries for `order`, for
279+
* `dialect`, or `''` for no order. Each key names a select alias and states
280+
* the dialect's NULL placement ({@link NULLS_LAST_SQL}).
281+
*/
282+
export function orderClauseSql(
283+
order: Readonly<Record<string, 'asc' | 'desc'>> | undefined,
284+
dialect: AnalyticsSqlDialect,
285+
): string {
286+
const keys = Object.entries(order ?? {});
287+
if (keys.length === 0) return '';
288+
const nulls = NULLS_LAST_SQL[dialect];
289+
return ` ORDER BY ${keys
290+
.map(([member, dir]) => `"${member}" ${dir.toUpperCase()}${nulls ? ` ${nulls}` : ''}`)
291+
.join(', ')}`;
292+
}
293+
294+
/**
295+
* [#25907, #21042] An aggregate's SQL, answering the identity the platform
296+
* declares for it over NOTHING (`emptyGroupValueFor`, spec
297+
* `data/aggregation-policy.ts`) where SQL answers NULL.
298+
*
299+
* `SUM` over a group whose aggregand is NULL in every row, or over no row a
300+
* measure-scoped filter admits, is NULL in SQL; summing nothing is `0`, the
301+
* answer the engine and the ObjectQL face give (#15546). This face folded the
302+
* NULL in JavaScript after the rows came back, by which time `ORDER BY` and
303+
* `LIMIT` / `OFFSET` had placed and paged it as a NULL: the folded `0` sat
304+
* where no `0` sorts, and the page differed per dialect. `COALESCE` in the
305+
* select list puts the identity where the sort and the window read it, on
306+
* every dialect, and the echoed statement says so.
307+
*
308+
* Read from the policy, never restated, so `avg` / `min` / `max` (no identity)
309+
* keep their NULL. `count` / `count_distinct` are not wrapped: SQL `COUNT`
310+
* answers `0` over nothing on every dialect, the identity already.
311+
*/
312+
function withEmptyGroupIdentity(aggregate: string, aggregateSql: string): string {
313+
const identity = emptyGroupValueFor(aggregate);
314+
if (identity === undefined || aggregate === 'count' || aggregate === 'count_distinct') return aggregateSql;
315+
return `COALESCE(${aggregateSql}, ${identity})`;
316+
}
317+
248318
/**
249319
* A dot-separated chain of bare identifiers — `amount`, `account.amount`,
250320
* `account.owner.region`. Distinguishes a relationship PATH, which
@@ -1087,33 +1157,13 @@ export class NativeSQLStrategy implements AnalyticsStrategy {
10871157

10881158
const rows = await ctx.executeRawSql!(objectName, sql, params);
10891159

1090-
// [#21042, #15546] A measure whose aggregate answers NULL over nothing — SQL
1091-
// `SUM` over a group whose aggregand is NULL in every row, or over no row a
1092-
// measure-scoped filter admits — answers the identity the platform declares
1093-
// for that aggregate over NOTHING (`emptyGroupValueFor`, spec
1094-
// `data/aggregation-policy.ts`): summing nothing is `0`, a measured fact.
1095-
// The engine and the ObjectQL face fold it (`driver-sql`'s
1096-
// `foldEmptyAggregateAnswers`, the rows path); this face answered `null` for
1097-
// the same group. Read from the policy, never restated, for EVERY measure —
1098-
// a measure-scoped one carries its aggregate in the same `type` — so
1099-
// `avg` / `min` / `max` (no identity, `undefined`) keep their NULL. Only `null` folds, before the
1100-
// presenter, in `driver-sql`'s order: an `undefined` would be a column
1101-
// never projected, a different defect that must stay visible. The dataset
1160+
// [#21042, #15546 → #25907] A measure whose aggregate answers NULL over
1161+
// nothing answers the identity the platform declares for it
1162+
// (`emptyGroupValueFor`) IN the statement — `withEmptyGroupIdentity`, at the
1163+
// measure's compile — never here: folded after the rows came back, the
1164+
// `ORDER BY` and window had already placed and paged the NULL. The dataset
11021165
// door's `DatasetExecutor` fill still runs after this and is idempotent on
1103-
// a folded row.
1104-
const folds: Array<readonly [string, number]> = [];
1105-
for (const member of query.measures ?? []) {
1106-
const identity = emptyGroupValueFor(this.lookupMember(cube, member, 'measure')?.type);
1107-
if (identity !== undefined) folds.push([member, identity]);
1108-
}
1109-
if (folds.length > 0 && Array.isArray(rows)) {
1110-
for (const row of rows) {
1111-
if (!row || typeof row !== 'object') continue;
1112-
for (const [member, identity] of folds) {
1113-
if (row[member] === null) row[member] = identity;
1114-
}
1115-
}
1116-
}
1166+
// an answered identity.
11171167

11181168
// [#20889] A measure column `fields[]` declares `number` answers a number,
11191169
// on every dialect. The SQL client hands an aggregate back as the wire type
@@ -1443,13 +1493,12 @@ export class NativeSQLStrategy implements AnalyticsStrategy {
14431493
if (groupByClauses.length > 0) {
14441494
sql += ` GROUP BY ${groupByClauses.join(', ')}`;
14451495
}
1446-
if (query.order && Object.keys(query.order).length > 0) {
1447-
const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
1448-
sql += ` ORDER BY ${orderClauses.join(', ')}`;
1449-
}
14501496
// [#21365] The dialect of the driver `execute()` hands this statement to —
1451-
// the base object's, the one `executeRawSql` is called with.
1452-
sql += windowClauseSql(query.limit, query.offset, sqlDialectFor(ctx, this.extractObjectName(cube)));
1497+
// the base object's, the one `executeRawSql` is called with. [#25907] The
1498+
// order states its NULL placement in it.
1499+
const dialect = sqlDialectFor(ctx, this.extractObjectName(cube));
1500+
sql += orderClauseSql(query.order, dialect);
1501+
sql += windowClauseSql(query.limit, query.offset, dialect);
14531502

14541503
return { sql, params };
14551504
}
@@ -1744,7 +1793,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy {
17441793

17451794
if (predicate !== null) {
17461795
const wrapConditional = CONDITIONAL_AGGREGATE_SQL[aggregate];
1747-
if (wrapConditional) return wrapConditional(col, predicate);
1796+
if (wrapConditional) return withEmptyGroupIdentity(aggregate, wrapConditional(col, predicate));
17481797
// [#10298] Deliberately BARE — an undeclared 500, the tier
17491798
// {@link aggregateOfMeasure} answers in. The conditional table is keyed
17501799
// identically to {@link AGGREGATE_SQL} (`aggregation-lockstep.test.ts`),
@@ -1763,7 +1812,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy {
17631812
// {@link aggregateOfMeasure} admitted `aggregate` FROM this table, and
17641813
// refused (bare, undeclared 500, the spec's own words) every type it does
17651814
// not key, before anything was lowered.
1766-
return AGGREGATE_SQL[aggregate](col);
1815+
return withEmptyGroupIdentity(aggregate, AGGREGATE_SQL[aggregate](col));
17671816
}
17681817

17691818
private resolveFieldSql(

0 commit comments

Comments
 (0)