Skip to content

Commit 097ef80

Browse files
fix(analytics): the native-SQL strategy applies the engine's aggregate policies — double accumulation, the PostgreSQL boolean cast and the empty-sum fold, hoisted into core (#21042) (#21209)
Fixes #21042 Clause-②: yes (widening) ## What this changes The analytics native-SQL strategy (`NativeSQLStrategy`, the default on a SQL driver) skipped three aggregate policies that `SqlDriver.aggregate()` applies. So one route answered different numbers, or a `500`, depending on which strategy served it. This PR follows the route ruling on #21042 (comment `5925613967`): the operand policies are hoisted into `@objectstack/core`, beside `AGGREGATE_ANSWER_KIND`, and both faces read them from there. - **`packages/core/src/utils/aggregate-answer.ts`** (`@objectstack/core`, `minor`). It takes: - `AGGREGATE_ACCUMULATION`, moved from `driver-sql` with its docblock (the docblock and the table are byte-identical to the base, apart from the `export` keyword); - `aggregandColumnClass({ type, multiple })`, the one column-class predicate (`'fractional'`, `'integral'`, `'boolean'`, or none); - `POSTGRES_BOOLEAN_AGGREGAND_CAST`, the #11635 cast, as a `Record` over `AggregationFunction`: `sum` / `avg` / `min` / `max` are cast, the counts never are; - `doubleAccumulationOperand(operand, dialect)`, the double operand with the dialect as a parameter; - `aggregandOperandSql(func, columnClass, dialect, operand)`, the one composition both faces emit (the cast inside, the double operand around it). No `index.ts` line was added: the module is already exported. - **`packages/drivers/driver-sql/src/sql-driver.ts`** (`patch`), in the ruled regions only. The `AGGREGATE_ACCUMULATION` table becomes a pointer plus an import. `isFractionalNumericType` is deleted. The two registry fills fill `fractionalNumericFields` through the predicate. `accumulatesInDouble` / `doubleAccumulationOperand` are replaced by `aggregandColumnClassOf`, which maps the driver's registries onto the predicate's classes. In `aggregate()`, the private boolean-cast condition and the accumulation call become one `aggregandOperandSql(funcName, class, this.dialectName, '??')`. - **`packages/services/service-analytics/src/strategies/native-sql-strategy.ts`** (`patch`), in two places. - **`resolveMeasureSql`** wraps the column it hands `AGGREGATE_SQL` / `CONDITIONAL_AGGREGATE_SQL` in `aggregandOperandSql`. The column class comes from the declaration the host already relays (`declaredValueShape`), on the object the column lives on (`columnObjectOf`, the one hop resolver). The dialect comes from `sqlDialect`, which the strategy already reads. - **The `execute` shaping point** that PR #21040 added now folds a `null` measure answer to `emptyGroupValueFor(measure.type)` (`@objectstack/spec`). It does this for every measure, measure-scoped ones included, before the number presenter, in `driver-sql`'s order. The dataset door's `DatasetExecutor` fill stays, and it is idempotent on a folded row. - The one line outside those two regions is the `generateSql` call site, which now passes `ctx` to `resolveMeasureSql`. - No native copy of any policy, and no runtime hook asks the driver: the rejected (C) route was not taken. `canHandle`, `buildFieldMeta`, the hop-object sites, the filter / text-match rendering, `analytics-service.ts` and `field-read-admission.ts` are untouched. ## The card's table, before and after Measured through `AnalyticsService.query` (the cube door, which `POST /api/v1/analytics/query` relays verbatim) and `AnalyticsService.queryDataset` (the dataset door), on `AnalyticsServicePlugin` over a real ObjectQL engine and `SqlDriver`. **Native** is the plugin's own composition (`NativeSQLStrategy` answered, with one raw statement and no engine aggregate). **ObjectQL** is the same composition narrowed to `engine.aggregate`. "Before" is the strategy file at the base `d34aa58a2a`; "after" is this branch at `61aab5013a`. Neither merge since then touches the aggregate code paths, and the pins below are green at `ef1f9d8484`. Fixture: - group `f`: `frac` (a `number` column) holds 0.1 and 0.2, and `flag` holds true and false; - group `i`: `stars` (a `rating` column) holds seven 1s and two 2s, and `flag` holds 7 trues and 2 falses; - group `n`: every aggregand is NULL in all three rows. The measure-scoped measures filter on `tag = x`, which only group `f` holds. **PostgreSQL 16.13** (a private local server; the ObjectQL column is the same before and after): | measure | group | door | native before | native after | ObjectQL | |:--|:--|:--|:--|:--|:--| | `sum(frac)` | f | cube, dataset | `0.3` | `0.30000000000000004` | `0.30000000000000004` | | `avg(frac)` | f | cube, dataset | `0.15` | `0.15000000000000002` | `0.15000000000000002` | | `avg(stars)`, an integer column | i | cube, dataset | `1.222222222222222` | `1.2222222222222223` | `1.2222222222222223` | | `sum(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `7` | `7` | | `avg(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `0.7777777777777778` | `0.7777777777777778` | | `min(flag)` / `max(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `0` / `1` | `0` / `1` | | `sum(frac)`, all-NULL group | n | cube | `null` | `0` | `0` | | `sum(frac)`, all-NULL group | n | dataset | `0` (executor fill) | `0` | `0` | | `sum(flag)`, all-NULL group | n | cube | `500 DATABASE_ERROR` | `0` | `0` | | `avg(frac)`, all-NULL group | n | cube, dataset | `null` | `null` | `null` | | measure-scoped `sum(frac)`, no admitted row | i | cube | `null` | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i | dataset | `0` (executor fill) | `0` | `0` | | measure-scoped `avg(frac)`, no admitted row | i | cube | `null` | `null` | `null` | | `count` control | n | cube, dataset | `3` | `3` | `3` | | measure-scoped `count` control | i | cube, dataset | `0` | `0` | `0` | **SQLite** (better-sqlite3): accumulation and the boolean answers already agreed on every face (`0.30000000000000004`, `0.15000000000000002`, `1.2222222222222223`, `7`, `0.7777777777777778`, `0` / `1`). The fold is the policy that diverged there: | measure | group | door | native before | native after | ObjectQL | |:--|:--|:--|:--|:--|:--| | `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | cube | `null` | `0` | `0` | | `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | dataset | `0` (executor fill) | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i, n | cube | `null` | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i, n | dataset | `0` (executor fill) | `0` | `0` | After the fix, the native and ObjectQL faces **differ in 0 of 144 cells** (2 drivers × 2 doors × 12 measures × 3 groups). **MySQL is NOT MEASURED**: there is no MySQL server in this container. The MySQL operand text is pinned offline: by `core`'s `aggregate-answer.test.ts`, and by the `driver-sql` move proof for the driver's own statements. ## The move proof `driver-sql`'s aggregate statements were dumped at the base, before any consumer changed. The dump covered `SqlDriver.aggregate()` for every function (`count`, `count_distinct`, `sum`, `avg`, `min`, `max`, and `count(*)`), aliased and unaliased, over 23 columns: every fractional, integral and boolean type, the `float` / `integer` / `int` aliases, multi-valued and untyped columns, and text / date / lookup / formula. It ran on SQLite, PostgreSQL and MySQL, through both registration paths (`registerObjectMetadata` and `registerExternalObject`), offline (knex `toSQL()`). The policies were then hoisted, `driver-sql` was switched to the imports, and the same dump was run again: - base dump: 1668 entries, 0 errors, md5 `8eee668372a28a7568f3eb1cc5a2bc9b`; - after dump (at `bc8aa0cc2f`): 1668 entries, md5 `8eee668372a28a7568f3eb1cc5a2bc9b`. `cmp` printed nothing: the two dumps are **byte-identical**. `sql-driver.ts` and `aggregate-answer.ts` are unchanged between `bc8aa0cc2f` and `61aab5013a`. The committed move-proof pin, `packages/drivers/driver-sql/src/sql-driver-21042-aggregate-policy-move.test.ts`, holds the captured expressions for one column of each class, on each dialect and through each registration path. It passed at the base (`192fc0010b`: 54 / 54) and passes after (54 / 54). ## Pins (committed red first, then the fix) | file | at the pins commit (`192fc0010b`, base code) | after | |:--|:--|:--| | `core` `aggregate-answer.test.ts` | 16 red (the exports did not exist) | 22 / 22 | | `service-analytics` `native-sql-aggregate-policies.test.ts` (each measure on both faces at both doors, against the engine's arithmetic; SQLite and live PostgreSQL cells) | SQLite: the cube-door folds red. PostgreSQL: accumulation, boolean `500`, folds red | 49 / 49 | | `service-analytics` `cube-measure-field-type-door.test.ts`, **the lifted skip** | PostgreSQL native `max(boolean)` red (`500`) | 23 / 23 | | `rest` `analytics-dataset-aggregate-policies-door.test.ts` (the route, both strategies) | PostgreSQL: 7 red (accumulation and booleans). SQLite green (that door already folded) | 19 / 19 | | `driver-sql` move proof | 54 / 54 | 54 / 54 | **The lifted skip:** `it.skipIf(cell.id === 'pg' && face === 'native')` in `cube-measure-field-type-door.test.ts` (from PR #21128) is gone. Its comment now says why the cell runs on every cell and face. The PostgreSQL native `max_flag` cell answers `1`. `native-sql-measure-number-presentation.test.ts` gets a comment-only edit: its header said the native statement does not carry #20387's accumulation, and it now points at the new pin. ## Ablations There was one ablation per policy, each predicted in writing before it ran. Each mutation was planted through `scripts/ablation-replace.mjs`, which checks that the anchor hit and that the blob changed. Each mutation was confirmed in the built `dist/` (`ablation-dist-preflight.mjs`: marker present). Each restore ran by absolute path (`git checkout HEAD`), and the file's blob was proven equal to its `HEAD` blob with `git diff HEAD` empty. After each restore the package was rebuilt, and the marker was proven absent from `dist/` with the tree clean. Every prediction held exactly. | ablation | mutation | predicted red | observed red | |:--|:--|:--|:--| | A1 accumulation | `core` `accumulatesInDouble`'s dialect gate never admits PostgreSQL or MySQL | `core` 3; `driver-sql` move proof 24 (pg and mysql × both fills × the six numeric / boolean columns); `service-analytics` 10, PostgreSQL only (both doors × `sum` / `avg(frac)`, `avg(stars)`, measure-scoped `sum` / `avg`); `rest` 3, PostgreSQL only | the same 3 / 24 / 10 / 3; SQLite cells green; `avg(flag)` green as predicted | | A2 boolean cast | `core` `aggregandOperandSql` never casts | `core` 1; move proof 4 (pg × both fills × `boolean` / `toggle`); `service-analytics` 8, PostgreSQL only; the lifted cell, PostgreSQL native and ObjectQL, 2; `rest` 4, PostgreSQL only | the same 1 / 4 / 8 / 2 / 4 | | A3 fold | the native shaping point never folds | `service-analytics` 8, cube door only (SQLite and PostgreSQL × the three all-NULL `sum`s and the measure-scoped `sum`); everything else green, the `rest` dataset-door route included, because the executor fill folds there | the same 8; `rest` 19 / 19 green | A1 and A2 show one policy reaching both faces. Each one turned `driver-sql`'s own statements red. Under A2 the ObjectQL face's `max(boolean)` cell failed too, with the driver's refusal. Under A1 the ObjectQL face answered the same exact decimal as the native face for the plain measures: the failing assertion was the engine's number, while native and ObjectQL still agreed. A **reverse type check** also ran. Passing a dialect the new type rejects (`'oracle'`) to `aggregandOperandSql` turned `service-analytics`' typecheck red (`TS2345 ... not assignable to parameter of type 'AggregandSqlDialect'`), which shows the rebuilt `core` `.d.ts` was read. The file was restored byte-identical. ## Verification (at `ef1f9d8484`, after merging `origin/main` at `cb45469e67`, which carries PR #21170 and PR #21173) Everything below ran as one locked script, at `ef1f9d8484`, with each exit code captured before any pipe. The live PostgreSQL 16.13 server ran at `timezone = Asia/Shanghai`, and the `driver-sql` suite ran under `TZ=America/New_York`, which are its own non-vacuity preconditions. - **Refresh after the merge:** `pnpm turbo run build --filter='!@objectstack/docs' --concurrency=1` exit 0, and `pnpm --filter @objectstack/spec check:generated` exit 0. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands from the real diff (10 paths). All 67 exited 0. Reconciliation with `--ran` and the recorded exit codes printed: `Run reconciliation — 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.` - **Typecheck:** `pnpm --filter … typecheck` exit 0 for `@objectstack/core`, `@objectstack/driver-sql`, `@objectstack/service-analytics` and `@objectstack/rest`. - **Tests, every vitest project of every touched package** (`vitest run --maxWorkers=2`, with `OS_TEST_POSTGRES_URL` set so the live cells ran): | package / project | files | tests | |:--|:--|:--| | `core` local | 74 passed | 2120 passed | | `core` repo | 3 passed | 48 passed | | `driver-sql` | 216 passed, 3 skipped | 4300 passed, 96 skipped | | `service-analytics` | 161 passed | 3765 passed | | `rest` local | 256 passed | 5027 passed, 127 skipped | | `rest` repo | 5 passed | 179 passed, 1 skipped | - **The five pin files, run explicitly:** move proof 54 / 54, `core` 22 / 22, policies 49 / 49 (24 SQLite + 24 PostgreSQL + the oracle), field-type door 23 / 23, `rest` route 19 / 19 (9 SQLite + 9 PostgreSQL + the oracle). - **Lint, narrowed and proven:** `eslint --no-inline-config --format json` over the 9 changed code files answered 9 results, 0 errors and 0 warnings. The population is read from eslint's own config: `ESLint.isPathIgnored` answers `false` for each of the 9. The narrowing excludes nothing that could move, because `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot change the verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - **The `driver-sql` live preconditions:** the first gate run used a private server at UTC, and the suite's four timezone non-vacuity cells failed by design (`… start it with timezone=Asia/Shanghai`). With the server at `Asia/Shanghai` and the process at `America/New_York` the suite is green, as listed above. - **`main` after the final merge:** `origin/main` moved 4 commits past `cb45469e67` before this PR opened (#21149, #21188, #21195, #21192). None of them touches `core`, `driver-sql`, `service-analytics` or the analytics `rest` tests. The one `packages/spec` file in the analytics area, `ui/dataset.zod.ts`, changes a comment only. They are not merged here; CI runs on the merge ref. ## Acceptance notes - **MySQL is NOT MEASURED** (no server in this container). The MySQL operand text is pinned offline in `core` and in the `driver-sql` move proof. - **The live PostgreSQL cells are not run in CI.** No CI step sets `OS_TEST_POSTGRES_URL` for `service-analytics` or `rest`. The cells above ran against a private PostgreSQL 16.13 started for this run and removed afterwards. In CI the SQLite cells run, and so do the offline `core` / move-proof pins. - **Phase 0's note on the dataset door, which is no divergence:** a measure-scoped `avg` is **absent** from a row its supplementary query reported no row for. That is `x_avg_frac` for groups `i` and `n`, on both strategies and before and after. It is not `null`. The new service pin holds this cell only to "both faces agree", not to a value. Relatedly, a dataset-door selection made only of measure-scoped measures reports only the groups their filter admits, so the pin asks each one beside the base count. - **Residual, as stated in the ruling:** a host that relays no field declarations (`declaredValueShape`), or names no SQL dialect, gets no column class or no policy. It keeps the native arithmetic it had, and a PostgreSQL boolean `sum` there still answers `500`. The plugin's own composition wires both. - **How `driver-sql` reads the class:** it reads its own registries rather than calling the predicate per column. `fractionalNumericFields` is filled by the predicate. `booleanFields` and `numericFields` are filled by the driver's coercion rules, whose populations equal the predicate's `'boolean'` and `'fractional'` ∪ `'integral'` classes. The move proof pins that equality per column class. Asking the predicate per column through the driver's `valueShapeFields` would retire `fractionalNumericFields`, but its declaration and shard-alias regions are outside the ruled surface, so this PR does not do it. - **The scan-order residual is unchanged.** On PostgreSQL and MySQL the double sums are added without compensation (`AGGREGATE_ACCUMULATION`'s docblock), so three or more fractions can still differ in the last place from SQLite and the rows path. The pins use two addends. - #21129 (the presenter for a relationship-path `min` / `max`) is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 862f12c commit 097ef80

10 files changed

Lines changed: 1309 additions & 139 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/driver-sql': patch
4+
'@objectstack/service-analytics': patch
5+
---
6+
7+
fix: the analytics native-SQL path aggregates with the engine's own aggregate policies, so one query answers one number whichever strategy serves it: `sum` / `avg` accumulate in double, a PostgreSQL boolean aggregand is cast, and an all-NULL `sum` answers `0`. The operand policies move from `@objectstack/driver-sql` to `@objectstack/core` (#21042)
8+
9+
Clause-②: yes (widening)
10+
11+
**New exports.** `@objectstack/core` exports the aggregate operand policies, moved here from `@objectstack/driver-sql`, where they were module-private. The driver now imports them and emits byte-identical statements.
12+
13+
- `AGGREGATE_ACCUMULATION`: what each declared aggregate function accumulates in on PostgreSQL and MySQL. `avg` accumulates in double; `sum` accumulates in double over a fractional column; the counts, `min` and `max` take the column as stored.
14+
- `aggregandColumnClass(shape)`: the one column-class predicate those policies read, over a column's declared `{ type, multiple }`. It answers `'fractional'`, `'integral'`, `'boolean'`, or `undefined` for every other column, a multi-valued one included. The type `AggregandColumnClass` names the three classes.
15+
- `POSTGRES_BOOLEAN_AGGREGAND_CAST`: the functions whose boolean aggregand is cast to `int` on PostgreSQL. These are `sum`, `avg`, `min` and `max`; the two counts are never cast.
16+
- `doubleAccumulationOperand(operand, dialect)`: the column's text, parsed as a double, spelled for `'postgres'` or `'mysql'`.
17+
- `aggregandOperandSql(func, columnClass, dialect, operand)`: the operand an aggregate wraps, with the cast inside the double operand. The type `AggregandSqlDialect` names its dialects (`'sqlite'`, `'postgres'`, `'mysql'`, `'unknown'`).
18+
19+
**What changed.** `POST /api/v1/analytics/query` and `POST /api/v1/analytics/dataset/query` served by `NativeSQLStrategy` (the default on a SQL driver) skipped three policies `SqlDriver.aggregate()` applies. So the ObjectQL strategy and `engine.aggregate` answered differently for the same query. Measured on SQLite and PostgreSQL 16.13:
20+
21+
- On PostgreSQL, `sum` / `avg` over an exact-decimal column, and `avg` over an integer one, added exact decimals. For example, `0.1 + 0.2` answered `0.3` and `11 / 9` answered `1.222222222222222`, where the engine answers `0.30000000000000004` and `1.2222222222222223`. The native statement now accumulates in double, as the driver does.
22+
- On PostgreSQL, `sum` / `avg` / `min` / `max` over a boolean field answered `500` (`function sum(boolean) does not exist`). The native statement now casts the boolean aggregand to `int`, as the driver does, and answers the numbers the engine answers.
23+
- On every dialect, a group whose aggregand is NULL in every row, and a measure-scoped `sum` that admits no row, answered `sum` `null` at the cube door. The strategy now folds a `null` answer to `emptyGroupValueFor` (`@objectstack/spec`) for every measure, so that `sum` answers `0`. `avg`, `min` and `max` over nothing stay `null`. The dataset door already answered `0`.
24+
25+
This is no narrowing: each answer moves to the value the platform already declared for the same query.
26+
27+
**What did not move.** `@objectstack/driver-sql`'s statements and answers are unchanged: a move-proof test compiles each aggregate function over each column class on SQLite, PostgreSQL and MySQL, and the statements equal the ones captured before the move. SQLite's native statement is unchanged, because neither operand policy applies there. A host that relays no field declarations to the analytics service, or names no SQL dialect, gets today's native arithmetic.

‎packages/core/src/utils/aggregate-answer.test.ts‎

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,30 @@
1111
* `numeric` to strings), and the large totals are the ones #20335 pinned at the
1212
* driver door. Each face pins the same presentation through its own door, in
1313
* its own package, on SQLite and PostgreSQL.
14+
*
15+
* [#21042] The operand policies, moved here from `driver-sql` so the
16+
* analytics native-SQL face applies them too: `AGGREGATE_ACCUMULATION`
17+
* (#20387), the column class it and the boolean cast read
18+
* (`aggregandColumnClass`), the PostgreSQL boolean-aggregand cast (#11635) and
19+
* the double operand. The SQL text below is the text `driver-sql` emitted
20+
* before the move (its move proof, `sql-driver-21042-aggregate-policy-move
21+
* .test.ts`, pins the whole statements); each face pins the answers through
22+
* its own door.
1423
*/
1524

1625
import { describe, it, expect } from 'vitest';
17-
import { AggregationFunction } from '@objectstack/spec/data';
18-
import { AGGREGATE_ANSWER_KIND, presentAsNumber } from './aggregate-answer';
26+
import { AggregationFunction, FieldType } from '@objectstack/spec/data';
27+
import {
28+
AGGREGATE_ACCUMULATION,
29+
AGGREGATE_ANSWER_KIND,
30+
POSTGRES_BOOLEAN_AGGREGAND_CAST,
31+
aggregandColumnClass,
32+
aggregandOperandSql,
33+
doubleAccumulationOperand,
34+
presentAsNumber,
35+
type AggregandColumnClass,
36+
type AggregandSqlDialect,
37+
} from './aggregate-answer';
1938

2039
describe('[#20889] AGGREGATE_ANSWER_KIND — what each declared aggregate function answers', () => {
2140
it('has exactly one row per declared aggregate function', () => {
@@ -74,3 +93,148 @@ describe("[#20889] presentAsNumber — the 'number' presenter", () => {
7493
expect(presentAsNumber(date)).toBe(date);
7594
});
7695
});
96+
97+
describe('[#20387, #21042] AGGREGATE_ACCUMULATION — what each declared aggregate function accumulates in', () => {
98+
it('has exactly one row per declared aggregate function', () => {
99+
expect(Object.keys(AGGREGATE_ACCUMULATION).sort()).toEqual([...AggregationFunction.options].sort());
100+
});
101+
102+
it('avg accumulates in double, sum in double over a fractional column, the rest as stored', () => {
103+
expect(AGGREGATE_ACCUMULATION).toEqual({
104+
count: 'as-stored',
105+
count_distinct: 'as-stored',
106+
sum: 'double-over-fractional',
107+
avg: 'double',
108+
min: 'as-stored',
109+
max: 'as-stored',
110+
});
111+
});
112+
});
113+
114+
describe('[#11635, #21042] POSTGRES_BOOLEAN_AGGREGAND_CAST — the functions a PostgreSQL boolean aggregand is cast for', () => {
115+
it('has exactly one row per declared aggregate function', () => {
116+
expect(Object.keys(POSTGRES_BOOLEAN_AGGREGAND_CAST).sort()).toEqual([...AggregationFunction.options].sort());
117+
});
118+
119+
it('sum, avg, min and max cast; the two counts never do', () => {
120+
expect(POSTGRES_BOOLEAN_AGGREGAND_CAST).toEqual({
121+
count: false,
122+
count_distinct: false,
123+
sum: true,
124+
avg: true,
125+
min: true,
126+
max: true,
127+
});
128+
});
129+
});
130+
131+
describe('[#20387, #11635, #21042] aggregandColumnClass — the one column-class predicate over a declared shape', () => {
132+
const FRACTIONAL = ['number', 'currency', 'percent', 'slider', 'progress', 'summary'];
133+
const INTEGRAL = ['rating'];
134+
const BOOLEAN = ['boolean', 'toggle'];
135+
136+
it('classes every declared field type: fractional, integral, boolean, or none', () => {
137+
for (const type of FieldType.options) {
138+
const want: AggregandColumnClass | undefined = FRACTIONAL.includes(type)
139+
? 'fractional'
140+
: INTEGRAL.includes(type)
141+
? 'integral'
142+
: BOOLEAN.includes(type)
143+
? 'boolean'
144+
: undefined;
145+
expect(aggregandColumnClass({ type }), type).toBe(want);
146+
}
147+
});
148+
149+
it('every member of the three classes is a declared field type, so none of them is a typo', () => {
150+
for (const type of [...FRACTIONAL, ...INTEGRAL, ...BOOLEAN]) expect(FieldType.options, type).toContain(type);
151+
});
152+
153+
it("classes driver-sql's internal column aliases: float is fractional, integer and int integral", () => {
154+
expect(aggregandColumnClass({ type: 'float' })).toBe('fractional');
155+
expect(aggregandColumnClass({ type: 'integer' })).toBe('integral');
156+
expect(aggregandColumnClass({ type: 'int' })).toBe('integral');
157+
});
158+
159+
it('a multi-valued column (a JSON list) is in no class', () => {
160+
expect(aggregandColumnClass({ type: 'select', multiple: true })).toBeUndefined();
161+
expect(aggregandColumnClass({ type: 'tags' })).toBeUndefined();
162+
expect(aggregandColumnClass({ type: 'lookup', multiple: true })).toBeUndefined();
163+
});
164+
165+
it('`multiple` on a type that cannot be multi-valued changes nothing, as storage ignores it', () => {
166+
expect(aggregandColumnClass({ type: 'number', multiple: true })).toBe('fractional');
167+
expect(aggregandColumnClass({ type: 'rating', multiple: true })).toBe('integral');
168+
expect(aggregandColumnClass({ type: 'boolean', multiple: true })).toBe('boolean');
169+
});
170+
171+
it('no declaration is no class', () => {
172+
expect(aggregandColumnClass(undefined)).toBeUndefined();
173+
expect(aggregandColumnClass(null)).toBeUndefined();
174+
expect(aggregandColumnClass({})).toBeUndefined();
175+
expect(aggregandColumnClass({ type: 'string' })).toBeUndefined();
176+
});
177+
});
178+
179+
describe('[#20387, #21042] doubleAccumulationOperand — the column text, parsed as a double', () => {
180+
it('PostgreSQL and MySQL each spell it their way', () => {
181+
expect(doubleAccumulationOperand('"amount"', 'postgres')).toBe('cast(cast("amount" as text) as double precision)');
182+
expect(doubleAccumulationOperand('`amount`', 'mysql')).toBe('cast(cast(`amount` as char) as double)');
183+
});
184+
});
185+
186+
describe('[#20387, #11635, #21042] aggregandOperandSql — the operand each face aggregates', () => {
187+
const X = 'COL';
188+
const pg = (f: AggregationFunction, c: AggregandColumnClass | undefined) => aggregandOperandSql(f, c, 'postgres', X);
189+
const my = (f: AggregationFunction, c: AggregandColumnClass | undefined) => aggregandOperandSql(f, c, 'mysql', X);
190+
const PG_DOUBLE = (x: string) => `cast(cast(${x} as text) as double precision)`;
191+
const MY_DOUBLE = (x: string) => `cast(cast(${x} as char) as double)`;
192+
193+
it('PostgreSQL: sum over a fractional column and avg over every class accumulate in double', () => {
194+
expect(pg('sum', 'fractional')).toBe(PG_DOUBLE(X));
195+
expect(pg('avg', 'fractional')).toBe(PG_DOUBLE(X));
196+
expect(pg('avg', 'integral')).toBe(PG_DOUBLE(X));
197+
expect(pg('sum', 'integral'), 'an integer total stays exact').toBe(X);
198+
});
199+
200+
it('PostgreSQL: a boolean aggregand is cast to int for sum / avg / min / max, inside the double operand', () => {
201+
expect(pg('sum', 'boolean')).toBe(`cast(${X} as int)`);
202+
expect(pg('avg', 'boolean')).toBe(PG_DOUBLE(`cast(${X} as int)`));
203+
expect(pg('min', 'boolean')).toBe(`cast(${X} as int)`);
204+
expect(pg('max', 'boolean')).toBe(`cast(${X} as int)`);
205+
});
206+
207+
it('PostgreSQL: the counts and min / max over a numeric column take the column as stored', () => {
208+
for (const c of ['fractional', 'integral', 'boolean'] as const) {
209+
expect(pg('count', c), `count ${c}`).toBe(X);
210+
expect(pg('count_distinct', c), `count_distinct ${c}`).toBe(X);
211+
}
212+
expect(pg('min', 'fractional')).toBe(X);
213+
expect(pg('max', 'integral')).toBe(X);
214+
});
215+
216+
it('MySQL: the same accumulation, and no boolean cast (a boolean is tinyint(1) there)', () => {
217+
expect(my('sum', 'fractional')).toBe(MY_DOUBLE(X));
218+
expect(my('avg', 'integral')).toBe(MY_DOUBLE(X));
219+
expect(my('avg', 'boolean')).toBe(MY_DOUBLE(X));
220+
expect(my('sum', 'boolean')).toBe(X);
221+
expect(my('min', 'boolean')).toBe(X);
222+
expect(my('sum', 'integral')).toBe(X);
223+
});
224+
225+
it('a column in no class is aggregated as stored on every dialect', () => {
226+
for (const d of ['postgres', 'mysql', 'sqlite', 'unknown'] as const) {
227+
for (const f of AggregationFunction.options) expect(aggregandOperandSql(f, undefined, d, X), `${d} ${f}`).toBe(X);
228+
}
229+
});
230+
231+
it('SQLite and an unnamed dialect apply neither policy: SQLite already adds doubles and stores booleans as 0 / 1', () => {
232+
for (const d of ['sqlite', 'unknown'] as const satisfies readonly AggregandSqlDialect[]) {
233+
for (const f of AggregationFunction.options) {
234+
for (const c of ['fractional', 'integral', 'boolean'] as const) {
235+
expect(aggregandOperandSql(f, c, d, X), `${d} ${f} ${c}`).toBe(X);
236+
}
237+
}
238+
}
239+
});
240+
});

0 commit comments

Comments
 (0)