From fc915ef7d412b79737d729598371ff67d4aa6ec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:02:43 +0000 Subject: [PATCH 1/3] feat(spec): boolean aggregand column in the aggregation conformance fixture, ruled numeric on every face Adds flag (3 true / 3 false, the FLAG_BY_ID distribution) to AGGREGATION_ROWS with seven ruled cases (sum=3, avg=0.5, min=0, max=1, count=6, count_distinct=2, grouped min east=1/west=0), extends the three SQL harness DDLs, aligns min/max over booleans to the numeric domain on every face per the 2026-08-28 maintainer ruling (option A, superseding #11249's false/true): objectql in-memory fallback, driver-memory data + analytics faces, driver-sql result presentation (boolean kind skipped for min/max), driver-mongodb lowering (numericAggregandExpr on min/max). FLAG_BY_ID private maps deleted in favour of the fixture column; ruling-B pins in the 11635/11151 suites flipped to the ruled 0/1. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --- .../driver-memory/src/memory-analytics.ts | 18 ++- .../src/memory-boolean-aggregand.test.ts | 45 ++++++ .../driver-memory/src/memory-driver.ts | 18 ++- ...db-11151-boolean-aggregand-answers.test.ts | 137 +++++++++--------- .../driver-mongodb/src/mongodb-aggregation.ts | 34 +++-- ...er-11635-boolean-aggregand-answers.test.ts | 100 +++++++------ ...sql-driver-aggregation-conformance.test.ts | 10 ++ packages/drivers/driver-sql/src/sql-driver.ts | 57 ++++---- ...qlite-wasm-aggregation-conformance.test.ts | 6 + ...rso-remote-aggregation-conformance.test.ts | 18 ++- .../in-memory-aggregation-conformance.test.ts | 20 +++ .../objectql/src/in-memory-aggregation.ts | 14 +- .../spec/src/data/aggregation-conformance.ts | 126 ++++++++++++++-- 13 files changed, 419 insertions(+), 184 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts index fcbc021080..2898b48618 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.ts @@ -482,7 +482,9 @@ function sizeDistinctSet(values: readonly unknown[]): number { /** * [#11065] `path`, with a BOOLEAN rendered as the number it is worth — the - * aggregand expression `$sum` and `$avg` consume on this face. + * aggregand expression `$sum` and `$avg` consume on this face, and, since the + * #11152 ruling (maintainer 2026-08-28: booleans aggregate as numbers on every + * face, no per-aggregate exception), `$min` and `$max` as well. * * ## What it is for * @@ -1241,10 +1243,20 @@ export class MemoryAnalyticsService implements IAnalyticsService { return { $sum: numericAggregandExpr(`$${fieldPath}`) }; case 'avg': return { $avg: numericAggregandExpr(`$${fieldPath}`) }; + // [#11152] `min`/`max` take the SAME boolean coercion as `sum`/`avg` — + // maintainer ruling 2026-08-28 (superseding #11249's `false`/`true`): + // booleans aggregate as NUMBERS on every face, no per-aggregate + // exception, so a boolean measure's order statistics answer 0/1. mingo's + // `$min`/`$max` rank whatever the expression yields, so the coerced + // number is what gets ranked; every non-boolean value passes through the + // `$cond` untouched and is ranked exactly as before. The data face + // carries the identical rule in JavaScript (`memory-driver.ts`, + // `computeAggregate`) — one face aligned alone is how this package's + // faces come to disagree. case 'min': - return { $min: `$${fieldPath}` }; + return { $min: numericAggregandExpr(`$${fieldPath}`) }; case 'max': - return { $max: `$${fieldPath}` }; + return { $max: numericAggregandExpr(`$${fieldPath}`) }; case 'count_distinct': // Collects the distinct values; {@link sizeDistinctSet} turns the array // into the NUMBER, excluding null — see the note there for why the diff --git a/packages/drivers/driver-memory/src/memory-boolean-aggregand.test.ts b/packages/drivers/driver-memory/src/memory-boolean-aggregand.test.ts index 22fa753b15..974c39898b 100644 --- a/packages/drivers/driver-memory/src/memory-boolean-aggregand.test.ts +++ b/packages/drivers/driver-memory/src/memory-boolean-aggregand.test.ts @@ -215,6 +215,32 @@ describe('[#11065] InMemoryDriver data face — a boolean aggregand is worth 1 o }) as any[]; expect(row.rate).toBeNull(); }); + + /** + * [#11152] `min`/`max` join the numeric family — maintainer ruling + * 2026-08-28 (superseding #11249's `false`/`true`): booleans aggregate as + * NUMBERS on every face, no per-aggregate exception. Asserted STRICTLY + * (`toBe(0)`/`toBe(1)`): the superseded booleans satisfy a `Number()` + * reading, so a coerced comparison would pass on exactly the wrong + * spelling. Grouped: the open group is all-true, so its `min` is `1` — a + * whole-table computation or a sticky `0` fails there and only there. + */ + it('min/max over the boolean answer the NUMBERS 0/1, ungrouped and grouped', async () => { + const [row] = await driver.aggregate(TABLE, { + aggregations: [ + { function: 'min', field: 'is_sla_violated', alias: 'lo' }, + { function: 'max', field: 'is_sla_violated', alias: 'hi' }, + ], + }) as any[]; + expect(row.lo).toBe(0); + expect(row.hi).toBe(1); + const rows = await driver.aggregate(TABLE, { + groupBy: ['is_closed'], + aggregations: [{ function: 'min', field: 'is_sla_violated', alias: 'lo' }], + }) as any[]; + const byClosed = Object.fromEntries(rows.map((r) => [String(r.is_closed), r.lo])); + expect(byClosed).toEqual({ true: 0, false: 1 }); + }); }); /** @@ -235,6 +261,8 @@ describe('[#11065] the analytics face answers the same rate', () => { measures: { slaViolationRate: { name: 'sla_violation_rate', label: 'SLA Violation Rate', type: 'avg', sql: 'is_sla_violated' }, slaViolations: { name: 'sla_violations', label: 'SLA Violations', type: 'sum', sql: 'is_sla_violated' }, + minViolated: { name: 'min_violated', label: 'Min violated', type: 'min', sql: 'is_sla_violated' }, + maxViolated: { name: 'max_violated', label: 'Max violated', type: 'max', sql: 'is_sla_violated' }, count: { name: 'count', label: 'Cases', type: 'count', sql: 'id' }, avgNote: { name: 'avg_note', label: 'Avg note', type: 'avg', sql: 'note' }, }, @@ -288,6 +316,23 @@ describe('[#11065] the analytics face answers the same rate', () => { expect(byClosed).toEqual({ true: [0.25, 4], false: [1, 1] }); }); + /** + * [#11152] The mingo route answers the same ruled numbers — the `$min`/ + * `$max` arms wrap `numericAggregandExpr` exactly as `$sum`/`$avg` do, and + * one face aligned alone is how this package's faces come to disagree. + * Strict for the data-face reason: the superseded `false`/`true` (#11249) + * satisfies any coerced reading. + */ + it('min/max over the boolean answer the NUMBERS 0/1 here too', async () => { + const result = await service.query({ + cube: 'cases', + measures: ['cases.minViolated', 'cases.maxViolated'], + } as any); + const row = result.rows[0] as Record; + expect(row['cases.minViolated']).toBe(0); + expect(row['cases.maxViolated']).toBe(1); + }); + /** The same narrowness guard the data face carries: text stays excluded. */ it('a non-numeric text column is still excluded here too', async () => { const result = await service.query({ cube: 'cases', measures: ['cases.avgNote'] } as any); diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index ed8d43aef7..ebccad624c 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -1342,16 +1342,30 @@ export class InMemoryDriver implements IDataDriver { return nums.length > 0 ? sum / nums.length : null; } + // [#11152] `min`/`max` read a BOOLEAN as the number it is worth — + // maintainer ruling 2026-08-28 (superseding #11249's `false`/`true`): + // booleans aggregate as NUMBERS on every face, no per-aggregate + // exception, so the order statistics answer 0/1 in the same numeric + // domain the `sum`/`avg` arms above already answer in. The coercion + // is BOOLEAN-ONLY for the same reason theirs is: strings and dates + // reach the same raw comparison they always did. The analytics face + // carries the identical rule in its `$min`/`$max` mingo arms + // (`memory-analytics.ts`, `buildAggregator`) — one face aligned + // alone leaves the other free to keep its own answer. case 'min': { // Handle comparable values - const valid = values.filter(v => v !== null && v !== undefined); + const valid = values + .filter(v => v !== null && v !== undefined) + .map(v => (typeof v === 'boolean' ? (v ? 1 : 0) : v)); if (valid.length === 0) return null; // Works for numbers and strings return valid.reduce((min, v) => (v < min ? v : min), valid[0]); } case 'max': { - const valid = values.filter(v => v !== null && v !== undefined); + const valid = values + .filter(v => v !== null && v !== undefined) + .map(v => (typeof v === 'boolean' ? (v ? 1 : 0) : v)); if (valid.length === 0) return null; return valid.reduce((max, v) => (v > max ? v : max), valid[0]); } diff --git a/packages/drivers/driver-mongodb/src/mongodb-11151-boolean-aggregand-answers.test.ts b/packages/drivers/driver-mongodb/src/mongodb-11151-boolean-aggregand-answers.test.ts index fb8f2ff788..fef17b92b2 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-11151-boolean-aggregand-answers.test.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-11151-boolean-aggregand-answers.test.ts @@ -9,21 +9,23 @@ * - **`sum` / `avg` answer arithmetic** — `3` / `0.5` over a 3-true/3-false * fixture. The #11065 family shape, landed on `driver-memory` and on every * SQL dialect (#11635). - * - **`min` / `max` answer `false` / `true`** — #11249 (maintainer 2026-08-23, - * recorded on that card's comment 5386670755, verbatim and untranslated: - * 「10950 不考虑存量,其他接受你的建议」). Order statistics return a member of - * the input domain, so the JSON boolean IS the contract and `0` / `1` is not - * a spelling of it. + * - **`min` / `max` answer `0` / `1`** — #11152 (maintainer 2026-08-28, + * applied on that card's comment 5448627494, ruling verbatim and + * untranslated: 「12745 A回,其他同意。」), SUPERSEDING #11249's + * `false` / `true`: booleans aggregate as NUMBERS on every face, with no + * per-aggregate exception, so one boolean column's aggregates answer in one + * numeric domain rather than three-numbers-two-booleans. * - * Measured on `origin/main` @ `23843d3f4` before the fix, through the harness - * below: `sum` = `0`, `avg` = `null`, `min` = `null`, `max` = `null` — all four - * wrong, whole-table and per group, while `count` = 6 and `count_distinct` = 2 - * already agreed. + * Measured on `origin/main` @ `23843d3f4` before the #11151 fix, through the + * harness below: `sum` = `0`, `avg` = `null`, `min` = `null`, `max` = `null` — + * all four wrong, whole-table and per group, while `count` = 6 and + * `count_distinct` = 2 already agreed. Between #11151 and #11152 the order + * statistics answered #11249's `false` / `true`; measured red against the + * ruled `0` / `1` (the #11152 conformance cases) before this file flipped. * - * ## The two independent defects behind those four cells + * ## The two independent defects behind the original four cells * - * They are NOT one fix applied twice, and each is asserted here against the - * half it governs: + * They were NOT one fix applied twice: * * 1. **The lowering** (`mongodb-aggregation.ts`) emitted a bare * `{$sum: '$flag'}` / `{$avg: '$flag'}`. MongoDB's arithmetic accumulators @@ -32,14 +34,18 @@ * coercion. * 2. **The instrument** (`mongodb-pipeline-evaluator.testkit.ts`) applied that * same "ignore non-numeric" rule to `$min` / `$max`, which are order - * statistics over BSON canonical order and rank booleans perfectly well. The - * `{$min: '$flag'}` lowering was, and remains, correct. + * statistics over BSON canonical order and rank booleans perfectly well. + * The bare `{$min: '$flag'}` lowering was faithful mongod semantics — and + * the #11152 ruling is exactly why faithfulness is not enough: bare, this + * face answers a member of the input domain (`false`/`true`) where the + * platform contract says `0`/`1`, so the lowering now wraps `min`/`max` in + * the SAME `$cond` coercion `sum`/`avg` use, and the numbers get ranked. * - * ⛔ Applying (1)'s coercion to `$min` / `$max` would answer `0` / `1` and break - * #11249 in the opposite direction. {@link describe} block "the emitted lowering - * keeps the two halves apart" pins that it was not, reading the emitted stages - * rather than trusting the values — the values alone cannot tell a `$min` over a - * boolean from a `$min` over a coerced `1`/`0` once the evaluator ranks both. + * The {@link describe} block "the emitted lowering carries the coercion on all + * four arms" pins the stages by reading them rather than trusting the values — + * the values alone cannot tell a `$min` over a coerced `1`/`0` from a + * post-processing conversion, and the stage is the contract surface a real + * mongod would execute. * * ## ⚠️ What this suite deliberately does NOT answer * @@ -52,11 +58,11 @@ * * ## The fixture * - * `AGGREGATION_ROWS` — the shared aggregate-vocabulary fixture — plus a boolean - * `flag` column. The distribution is {@link FLAG_BY_ID}, the one already landed - * on `main` in `driver-sql`'s #11635 suite, chosen over the other distribution - * in this card's record (`true,false,true,true,false,false`) so the two faces' - * grouped numbers are comparable rather than merely both 3-true/3-false. + * `AGGREGATION_ROWS` — the shared aggregate-vocabulary fixture, whose `flag` + * boolean column (added by #11152) carries the distribution this file + * previously held as a private `FLAG_BY_ID` map: 3 true / 3 false, `west` + * `[T,F,F,F]`, `east` `[T,T]`. The private map is deleted in favour of the + * fixture column so the faces can never silently disagree on grouped values. */ import { describe, it, expect } from 'vitest'; @@ -76,29 +82,13 @@ import { const MEASURE = 'measure'; /** - * The `flag` column, keyed by fixture row id: 3 true / 3 false, with `east` - * (rows 5–6) all-true. The per-group split is deliberately asymmetric — - * `west` is `[T,F,F,F]` and `east` `[T,T]` — so `east`'s grouped `min` is - * `true`: a measure computed over the whole table, or a sticky per-column - * constant, goes red on that cell rather than passing by symmetry. - */ -const FLAG_BY_ID: Record = { - '1': true, - '2': false, - '3': false, - '4': false, - '5': true, - '6': true, -}; - -/** - * The six shared rows plus `flag`, and two columns that exist only to drive the - * empty-input branch of an order statistic: `voidcol` is an explicit `null` on - * every row, and no row carries `absent` at all. + * The six shared rows — `flag` included, straight from the fixture — plus a + * column that exists only to drive the empty-input branch of an order + * statistic: `voidcol` is an explicit `null` on every row, and no row carries + * `absent` at all. */ const ROWS: Doc[] = (AGGREGATION_ROWS as unknown as Doc[]).map((row) => ({ ...row, - flag: FLAG_BY_ID[row.id as string], voidcol: null, })); @@ -148,29 +138,32 @@ describe('[#11151] the ruled arithmetic half — sum / avg count a boolean as 1 }); }); -describe('[#11151] the ruled order-statistic half — min / max answer JSON booleans', () => { - // Asserted STRICTLY. `0` / `1` satisfies a `Number()` reading and is exactly - // the answer #11249 ruled against, so a loose comparison here would pass on - // the one wrong value this half exists to exclude. - it('min(flag) answers false — the boolean, not 0 and not null', () => { - expect(measure('min', 'flag')).toBe(false); +describe('[#11152] the ruled order-statistic half — min / max answer JSON numbers', () => { + // Asserted STRICTLY. `false` / `true` — the #11249-era answer this face gave + // until #11152 superseded it — satisfies a loose reading, so a coerced + // comparison here would pass on the one wrong spelling this half exists to + // exclude. + it('min(flag) answers 0 — the number, not false and not null', () => { + expect(measure('min', 'flag')).toBe(0); }); - it('max(flag) answers true — the boolean, not 1 and not null', () => { - expect(measure('max', 'flag')).toBe(true); + it('max(flag) answers 1 — the number, not true and not null', () => { + expect(measure('max', 'flag')).toBe(1); }); - it('grouped min/max answer per-group members, and east’s min is true', () => { - // `east` is the load-bearing cell: all-true, so its `min` is `true`. A - // whole-table computation or a sticky `false` fails here and only here. - expect(byRegion('min', 'flag')).toEqual({ west: false, east: true }); - expect(byRegion('max', 'flag')).toEqual({ west: true, east: true }); + it('grouped min/max answer per-group numbers, and east’s min is 1', () => { + // `east` is the load-bearing cell: all-true, so its `min` is `1`. A + // whole-table computation or a sticky `0` fails here and only here. + expect(byRegion('min', 'flag')).toEqual({ west: 0, east: 1 }); + expect(byRegion('max', 'flag')).toEqual({ west: 1, east: 1 }); }); it('min/max over a column that is null or absent everywhere answer null', () => { // The manual's rule: null and missing are IGNORED, and a group left with - // nothing answers `null`. Not folded to `false`, which is what a boolean - // face that manufactured a default would do. + // nothing answers `null`. Not folded to `0`, which is what a boolean face + // that manufactured a default would do — the `$cond` coercion's else + // branch passes null/missing through untouched, so the accumulator's own + // rule still applies. expect(measure('min', 'voidcol'), 'explicit null on every row').toBeNull(); expect(measure('max', 'voidcol'), 'explicit null on every row').toBeNull(); expect(measure('min', 'absent'), 'a column no row carries').toBeNull(); @@ -178,7 +171,7 @@ describe('[#11151] the ruled order-statistic half — min / max answer JSON bool }); }); -describe('[#11151] the emitted lowering keeps the two halves apart', () => { +describe('[#11152] the emitted lowering carries the coercion on all four arms', () => { const emit = (func: string): unknown => buildAggregationPipeline({ aggregations: [{ function: func, field: 'flag', alias: MEASURE }] as AggregationInput[], @@ -193,16 +186,16 @@ describe('[#11151] the emitted lowering keeps the two halves apart', () => { expect(emit('avg')).toEqual({ $group: { _id: null, [MEASURE]: { $avg: COERCED } } }); }); - it('⛔ min and max are left BARE — the coercion is not applied to them', () => { - // The load-bearing pin of this file. `$min`/`$max` over a coerced aggregand - // would answer `0`/`1` — arithmetic where #11249 ruled for a member of the - // input domain — and the VALUES cannot catch it once the evaluator ranks - // booleans, because both spellings then produce an answer. Only the emitted - // stage distinguishes them. - expect(emit('min')).toEqual({ $group: { _id: null, [MEASURE]: { $min: '$flag' } } }); - expect(emit('max')).toEqual({ $group: { _id: null, [MEASURE]: { $max: '$flag' } } }); - expect(JSON.stringify(emit('min')), 'no $cond reached the min arm').not.toContain('$cond'); - expect(JSON.stringify(emit('max')), 'no $cond reached the max arm').not.toContain('$cond'); + it('min and max wrap the SAME coercion — the #11152 ruling, in the stage', () => { + // The load-bearing pin of this file, direction FLIPPED by the #11152 + // ruling (2026-08-28, superseding #11249): a bare `$min`/`$max` would + // answer `false`/`true` — a member of the input domain, where the ruled + // contract says `0`/`1` on every face — and the VALUES cannot catch a + // conversion smuggled in anywhere else (post-processing, the evaluator), + // because both spellings then produce the ruled number. The emitted stage + // is what a real mongod would execute, so the coercion is pinned THERE. + expect(emit('min')).toEqual({ $group: { _id: null, [MEASURE]: { $min: COERCED } } }); + expect(emit('max')).toEqual({ $group: { _id: null, [MEASURE]: { $max: COERCED } } }); }); it('a fieldless sum/avg is unchanged — the coercion needs a path to coerce', () => { @@ -267,6 +260,8 @@ describe('[#11151] the evaluator REFUSES a type it does not rank, rather than an it('the types it DOES rank all answer, so the refusal above is not blanket', () => { expect(measure('min', 'score'), 'number').toBe(10); expect(measure('min', 'stage'), 'string').toBe('lost'); - expect(measure('min', 'flag'), 'boolean').toBe(false); + // A boolean aggregand reaches the ranker AS the coerced number since + // #11152 — the answer proves the coercion path ranks, not bare booleans. + expect(measure('min', 'flag'), 'boolean, coerced to its number').toBe(0); }); }); diff --git a/packages/drivers/driver-mongodb/src/mongodb-aggregation.ts b/packages/drivers/driver-mongodb/src/mongodb-aggregation.ts index de6a296ef5..c40300b0c8 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-aggregation.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-aggregation.ts @@ -500,14 +500,21 @@ export function buildAggregationPipeline(opts: { * analytics query. Keeping the rule inside the `$group` expression leaves every * later stage looking at the number it expects. * - * ## ⛔ Why it is NOT applied to `min` / `max` - * - * `$min` / `$max` are ORDER STATISTICS over BSON canonical comparison order, - * not arithmetic accumulators: they rank booleans and return a MEMBER of the - * input domain. #11249 ruled (maintainer 2026-08-23) that a boolean aggregand - * answers `false` / `true` there — the JSON boolean, not `0` / `1` — so - * wrapping those two arms in this coercion would break the ruled contract in - * the opposite direction from the defect it fixes. + * ## Why it IS applied to `min` / `max` as well — the #11152 ruling + * + * `$min` / `$max` are ORDER STATISTICS over BSON canonical comparison order: + * bare, they rank booleans and return a MEMBER of the input domain + * (`false` / `true`), which is what #11249 ruled (maintainer 2026-08-23) and + * what this face answered between #11151's fix and #11152. **#11152 superseded + * that** (maintainer 2026-08-28, ruling verbatim on that card's record: + * 「12745 A回,其他同意。」): booleans aggregate as NUMBERS on every face, + * with NO per-aggregate exception — `min` / `max` over a boolean answer + * `0` / `1`, the same numeric domain `sum` / `avg` answer in, on this face + * and on every other. So the `min` / `max` arms wrap the same coercion: + * booleans rank as the numbers they are worth, and every other type reaches + * `$min` / `$max` exactly as before ($cond's else branch is the bare path). + * Null and missing still vanish under the accumulators' own rule, so the + * empty window still answers `null`, never a manufactured `0`. * * ## The narrowness is deliberate * @@ -544,9 +551,10 @@ function buildAccumulator(agg: AggregationInput): Document { ? { $sum: 1 } : { $sum: { $cond: [{ $eq: [{ $ifNull: [fieldRef, null] }, null] }, 0, 1] } }; - // [#11151] `sum` / `avg` coerce a BOOLEAN aggregand; see - // {@link numericAggregandExpr} for why, and for why `min` / `max` below - // deliberately do NOT. + // [#11151/#11152] All four arithmetic/order aggregates coerce a BOOLEAN + // aggregand; see {@link numericAggregandExpr} — `sum`/`avg` since #11151, + // `min`/`max` since the #11152 ruling (2026-08-28) pinned booleans as + // numbers on every face with no per-aggregate exception. case 'sum': return { $sum: fieldRef === null ? 0 : numericAggregandExpr(fieldRef) }; @@ -554,10 +562,10 @@ function buildAccumulator(agg: AggregationInput): Document { return { $avg: fieldRef === null ? 0 : numericAggregandExpr(fieldRef) }; case 'min': - return { $min: fieldRef ?? 0 }; + return { $min: fieldRef === null ? 0 : numericAggregandExpr(fieldRef) }; case 'max': - return { $max: fieldRef ?? 0 }; + return { $max: fieldRef === null ? 0 : numericAggregandExpr(fieldRef) }; case 'count_distinct': // Collect the distinct values here; {@link postProcessAggregation} sizes diff --git a/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts b/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts index 7a31f5ffec..38a7151a08 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts @@ -6,14 +6,17 @@ * * ## The ruling this suite pins * - * #11249 (maintainer 2026-08-23, recorded in its comment 5386670755, verbatim - * and untranslated: 「10950 不考虑存量,其他接受你的建议」) adopted: + * #11152 (maintainer 2026-08-28, applied in its comment 5448627494, ruling + * verbatim and untranslated: 「12745 A回,其他同意。」 — option A on that + * card) adopted, superseding #11249's `false`/`true` for the order + * statistics: * - * - **`min` / `max` over a boolean aggregand answer `false` / `true` in - * JSON** — order statistics return a member of the input domain, and SQL - * drivers convert at the driver boundary. + * - **Booleans aggregate as NUMBERS on every face, with no per-aggregate + * exception**: `min` / `max` over a boolean aggregand answer **`0` / `1`** + * — the same numeric domain `sum` / `avg` answer in, so one column's five + * aggregates answer in one domain rather than three-numbers-two-booleans. * - **`sum` / `avg` answer arithmetic** (`3` / `0.5` on the 3-true/3-false - * fixture) — the settled #11065 family shape. + * fixture) — the settled #11065 family shape, unchanged. * * ## The two measured gaps this suite exists to keep closed * @@ -26,30 +29,37 @@ * wrapping SQLSTATE `42883`. A face that refuses cannot satisfy the ruled * contract; the lowering now casts (`avg(cast("flag" as int))`) on PG only. * - **MySQL 8.0.46 answered `min` = `0`, `max` = `1`** over `tinyint(1)` — - * the backend computes, but the boolean read-presentation was gated to - * SQLite (mirroring `formatOutput`'s row reads), so the driver boundary - * leaked the storage form. `min`/`max` results over a declared boolean are - * now presented on every dialect. + * which under #11249 was the defect this suite went red on, and under the + * #11152 ruling is the CORRECT answer on every dialect: the boolean + * read-presentation `#11635` added for `min`/`max` results is removed + * again, so the numeric answer the backend computes is the answer. * * ## Assertion conventions, and why they differ per function * - * `min` / `max` are asserted STRICTLY (`toBe(false)` / `toBe(true)`): the JSON - * boolean IS the ruled contract, and `0`/`1` — the exact value this suite went - * red on — satisfies a `Number()` reading. `sum` / `avg` / `count` / + * `min` / `max` are asserted STRICTLY (`toBe(0)` / `toBe(1)` via + * `Object.is`-style `toBe` on the number): the JSON NUMBER is the ruled + * contract, and `false`/`true` — the #11249-era value this suite previously + * pinned — satisfies a loose equality reading. `sum` / `avg` / `count` / * `count_distinct` are asserted through `Number(...)`: node-pg and mysql2 both * hand EXACT-numeric results (`sum` → bigint/DECIMAL, `avg` → numeric) back as * strings — `"3"`, `"0.5000"` — for boolean and integer aggregands alike, so a * literal comparison would pin the dialect client's wire type, not this card's - * values (the same reading the #11455 suite's control records). + * values (the same reading the #11455 suite's control records). ⚠️ `min`/`max` + * over an INTEGER-family result are handed back as numbers by both clients + * (int4 / tinyint parse to JS numbers), so the strict spelling is assertable + * on every dialect. * * ## The fixture * - * `AGGREGATION_ROWS` — the shared aggregate-vocabulary fixture — plus a `flag` - * boolean column, 3 true / 3 false, declared `type: 'boolean'` (the #11635 - * acceptance shape). The per-group split is deliberately asymmetric: `west` - * holds `[T,F,F,F]` and `east` `[T,T]`, so `east`'s grouped `min` is TRUE — - * a presentation that computed over the whole table, or answered a sticky - * per-column constant, goes red on that cell rather than passing by symmetry. + * `AGGREGATION_ROWS` — the shared aggregate-vocabulary fixture, whose `flag` + * boolean column (added by #11152) IS the distribution this suite previously + * carried as a private `FLAG_BY_ID` map: 3 true / 3 false, declared + * `type: 'boolean'`. The private map is deleted in favour of the fixture + * column so the two can never silently disagree on grouped values. The + * per-group split is deliberately asymmetric: `west` holds `[T,F,F,F]` and + * `east` `[T,T]`, so `east`'s grouped `min` is `1` — a face that computed + * over the whole table, or answered a sticky per-column constant, goes red on + * that cell rather than passing by symmetry. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -60,19 +70,6 @@ import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dial const TABLE = 'bool_aggregand_answers'; -/** - * The `flag` column, keyed by fixture row id: 3 true / 3 false, with `east` - * (rows 5–6) all-true — see the head note for why the asymmetry is the point. - */ -const FLAG_BY_ID: Record = { - '1': true, - '2': false, - '3': false, - '4': false, - '5': true, - '6': true, -}; - const aggOn = (func: string, field = 'flag'): DriverQuery => ({ aggregations: [{ function: func, field, alias: 'n' }] }) as DriverQuery; @@ -94,12 +91,10 @@ describe(`[#11635] driver-sql — boolean aggregands answer the ruled values (${ }, }, ]); + // The fixture rows carry `flag` themselves since #11152 — seeded verbatim, + // so this suite and the conformance suites measure one distribution. for (const row of AGGREGATION_ROWS) { - await driver.create( - TABLE, - { ...row, flag: FLAG_BY_ID[row.id] }, - { bypassTenantAudit: true }, - ); + await driver.create(TABLE, { ...row }, { bypassTenantAudit: true }); } }); @@ -130,19 +125,22 @@ describe(`[#11635] driver-sql — boolean aggregands answer the ruled values (${ expect(Number(rows[0].n)).toBe(0.5); }); - // ─── The ruled order-statistic half — JSON booleans, STRICTLY ──────────── + // ─── The ruled order-statistic half — JSON NUMBERS, STRICTLY ───────────── + // [#11152 ruling, 2026-08-28] `0`/`1`, not `false`/`true` (#11249, + // superseded): booleans aggregate as numbers with no per-aggregate + // exception. - it('min(flag) answers false — the JSON boolean, not 0', async () => { + it('min(flag) answers 0 — the JSON number, not false', async () => { const rows = await driver.aggregate(TABLE, aggOn('min')); - expect(rows[0].n).toBe(false); + expect(rows[0].n).toBe(0); }); - it('max(flag) answers true — the JSON boolean, not 1', async () => { + it('max(flag) answers 1 — the JSON number, not true', async () => { const rows = await driver.aggregate(TABLE, aggOn('max')); - expect(rows[0].n).toBe(true); + expect(rows[0].n).toBe(1); }); - it('grouped min/max answer per-group members: west [T,F,F,F], east [T,T]', async () => { + it('grouped min/max answer per-group numbers: west [T,F,F,F], east [T,T]', async () => { const rows = await driver.aggregate(TABLE, { groupBy: ['region'], aggregations: [ @@ -156,18 +154,18 @@ describe(`[#11635] driver-sql — boolean aggregands answer the ruled values (${ { lo: r.lo, hi: r.hi }, ]), ); - // `east` is the load-bearing cell: all-true, so its `min` is `true` — a - // whole-table computation or a sticky `false` fails here and only here. - expect(byRegion.east, 'east').toEqual({ lo: true, hi: true }); - expect(byRegion.west, 'west').toEqual({ lo: false, hi: true }); + // `east` is the load-bearing cell: all-true, so its `min` is `1` — a + // whole-table computation or a sticky `0` fails here and only here. + expect(byRegion.east, 'east').toEqual({ lo: 1, hi: 1 }); + expect(byRegion.west, 'west').toEqual({ lo: 0, hi: 1 }); }); // ─── The empty window: null stays null, never a manufactured false ─────── // `min`/`max` over no rows is undefined — the same judgement - // `emptyGroupValueFor` (@objectstack/spec/data) records — and the boolean - // presentation must pass the backend's NULL through, not fold it to `false`. - it('min(flag) over an empty window answers null, not false', async () => { + // `emptyGroupValueFor` (@objectstack/spec/data) records — and the numeric + // answer must pass the backend's NULL through, never fold it to `0`. + it('min(flag) over an empty window answers null, not 0', async () => { const rows = await driver.aggregate(TABLE, { where: { region: 'north' }, aggregations: [{ function: 'min', field: 'flag', alias: 'n' }], diff --git a/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts index 3671175c5a..40a20f8e7e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts @@ -179,6 +179,11 @@ const CONFORMANCE_OBJECT = { // Nullable, and it must stay that way — see `AggregationRow.stage`. stage: { type: 'text', name: 'stage' }, score: { type: 'number', name: 'score' }, + // [#11152] Declared `type: 'boolean'` on purpose — see `AggregationRow.flag`: + // the ruled point of the boolean cases is that aggregation answers NUMBERS + // (min=0/max=1) even where the declared type would present a row read as a + // JSON boolean. + flag: { type: 'boolean', name: 'flag' }, }, }; @@ -249,6 +254,11 @@ describe(`[#6409] SqlDriver — aggregate vocabulary conformance (${cell.label}) // in place of a null would keep every `count_distinct` case green at the // wrong number. expect((rows as any[]).filter((r) => r.stage === null)).toHaveLength(2); + // [#11152] The property the boolean cases hang off: 3 true / 3 false. A + // seed that folded the flags turns every boolean case into a test of the + // wrong table. `Boolean(...)` because the ROW read is presentation-shaped + // per dialect (a real boolean on pg, 0/1 presented on sqlite/mysql). + expect((rows as any[]).filter((r) => Boolean(r.flag)).length, 'true flags').toBe(3); }); for (const c of AGGREGATION_CASES) { diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a6cd04d66b..89cb9e2f87 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -8209,17 +8209,17 @@ export class SqlDriver implements IDataDriver { // aggregates on Postgres — the one dialect that stores `Field.boolean` // as a real `boolean` column and defines no `sum`/`avg`/`min`/`max` // over it (SQLSTATE `42883`, measured on PG 16.13; SQLite stores 0/1 - // INTEGER and MySQL `tinyint(1)`, so both compute natively). #11249 - // ruled the answers (maintainer 2026-08-23): `sum`/`avg` answer - // arithmetic over 1/0, and `min`/`max` answer `false`/`true` — a - // member of the input domain — so this face must ANSWER; refusing - // cannot satisfy the ruled contract. `cast(?? as int)` keeps the - // column in a knex identifier binding exactly as the uncast form does. - // `count`/`count_distinct` are deliberately NOT cast (both lower to - // `count`, defined over boolean everywhere — their answers were - // correct before this and must not move). The 1/0 the cast computes - // for `min`/`max` is presented back as a JSON boolean below, where the - // result column is tracked. + // INTEGER and MySQL `tinyint(1)`, so both compute natively). The + // answers are ruled: this face must ANSWER — refusing cannot satisfy + // the contract — and [#11152] (maintainer 2026-08-28, superseding + // #11249's `false`/`true` for the order statistics) pins ALL FOUR as + // numbers: `sum`/`avg` arithmetic over 1/0, `min`/`max` the `0`/`1` + // the cast computes, presented as-is (see the presentation note + // below). `cast(?? as int)` keeps the column in a knex identifier + // binding exactly as the uncast form does. `count`/`count_distinct` + // are deliberately NOT cast (both lower to `count`, defined over + // boolean everywhere — their answers were correct before this and + // must not move). const castBooleanAggregand = this.isPostgres && lowering.sql !== 'count' && @@ -8244,24 +8244,23 @@ export class SqlDriver implements IDataDriver { // (`max("closed_at")` on SQLite, `max` on Postgres) and is defensive // only, so it is deliberately not tracked. if ((funcName === 'min' || funcName === 'max') && agg.field) { - // [#11249/#11635] A boolean aggregand presents on EVERY dialect, - // not only under `readPresentationKind`'s dialect gate. That gate - // mirrors `formatOutput`'s ROW reads (SQLite + MySQL since #11782; - // SQLite-only when this landed), where the storage-form dialects - // hand back a number — but on this door the backend ALSO answers - // `min`/`max` as 1/0 on Postgres (the `cast(?? as int)` above, - // over a column whose row reads need no presentation), and the - // ruled contract is `false` / `true` in JSON: order statistics - // return a member of the input domain, and SQL drivers convert at - // the driver boundary. The `??` fallback is what carries Postgres. - // `presentReadValue('boolean', …)` leaves `null` (no rows / all - // NULL) untouched and is idempotent on a value already boolean. - const kind = - this.readPresentationKind(table, agg.field) ?? - (table !== null && this.booleanFields[table]?.includes(agg.field) - ? ('boolean' as const) - : null); - if (kind) presentedOutput.set(agg.alias, kind); + // [#11152] A BOOLEAN aggregand is the ruled exception to "the + // result still needs the column's presentation": the maintainer's + // 2026-08-28 ruling (superseding #11249's `false`/`true`, which + // #11635 implemented here) pins that booleans aggregate as NUMBERS + // on every face, with no per-aggregate exception — `min`/`max` + // answer `0`/`1`, the same numeric domain `sum`/`avg` answer in. + // Every dialect's backend already computes exactly that number + // (SQLite 0/1 INTEGER storage, MySQL `tinyint(1)`, Postgres via + // the `cast(?? as int)` above), so the ruled answer is the value + // with NO boolean presentation — the `'boolean'` kind is skipped + // rather than mapped. ROW reads are untouched: `find()` still + // presents a declared boolean as a JSON boolean; it is the + // AGGREGATION context that is numeric by rule. Temporal kinds + // still present (`sql-driver-aggregate-temporal-output.test.ts`), + // and the SQLite-only numeric repair still applies. + const kind = this.readPresentationKind(table, agg.field); + if (kind && kind !== 'boolean') presentedOutput.set(agg.alias, kind); } } else { if (fieldExpr === '*') { diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts index 61a5d4fef8..8c64443924 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts @@ -66,6 +66,10 @@ describe('[#6409] driver-sqlite-wasm — aggregate vocabulary conformance', () = // column nullable, which is what the null-bearing rows need. stage: { type: 'string' }, score: { type: 'number' }, + // [#11152] Declared `type: 'boolean'` on purpose — see + // `AggregationRow.flag`: the ruled boolean cases answer NUMBERS + // (min=0/max=1) over the 0/1 INTEGER storage. + flag: { type: 'boolean' }, }, }, ]); @@ -82,6 +86,8 @@ describe('[#6409] driver-sqlite-wasm — aggregate vocabulary conformance', () = const rows = await driver.find(OBJECT, { orderBy: [{ field: 'id', order: 'asc' }] }); expect(rows).toHaveLength(6); expect((rows as any[]).filter((r) => r.stage === null)).toHaveLength(2); + // [#11152] 3 true / 3 false — the property every boolean case hangs off. + expect((rows as any[]).filter((r) => Boolean(r.flag)).length, 'true flags').toBe(3); }); for (const c of AGGREGATION_CASES) { diff --git a/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts b/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts index ec4fa636b4..be0c7fa72c 100644 --- a/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts @@ -73,6 +73,10 @@ const CONFORMANCE_OBJECT = { // Nullable, and it must stay that way — see `AggregationRow.stage`. stage: { type: 'string' }, score: { type: 'number' }, + // [#11152] Declared `type: 'boolean'` on purpose — see `AggregationRow.flag`. + // SQLite stores it 0/1 INTEGER, and the ruled boolean cases (min=0/max=1, + // sum=3, avg=0.5) are answered in exactly that numeric domain. + flag: { type: 'boolean' }, }, }; @@ -124,15 +128,21 @@ describe('[#6409] TursoDriver remote — aggregate vocabulary conformance', () = */ it('the fixture is all six rows, with the nulls stored AS nulls', () => { const rows = stub.raw - .prepare('select id, region, stage, score from conformance_agg order by id') - .all() as Array<{ id: string; region: string; stage: string | null; score: number }>; + .prepare('select id, region, stage, score, flag from conformance_agg order by id') + .all() as Array<{ id: string; region: string; stage: string | null; score: number; flag: number }>; expect(rows.map((r) => String(r.id))).toEqual(['1', '2', '3', '4', '5', '6']); for (const r of rows) { const seeded = AGGREGATION_ROWS.find((s) => s.id === String(r.id))!; - expect([r.region, r.stage, Number(r.score)], r.id) - .toEqual([seeded.region, seeded.stage, seeded.score]); + // [#11152] `flag` is read below the transport, so what this asserts is the + // STORAGE form — SQLite's 0/1 INTEGER — which is why the comparison goes + // through `Number(seeded.flag)` rather than the JSON boolean. + expect([r.region, r.stage, Number(r.score), Number(r.flag)], r.id) + .toEqual([seeded.region, seeded.stage, seeded.score, Number(seeded.flag)]); } expect(rows.filter((r) => r.stage === null)).toHaveLength(2); + // [#11152] 3 true / 3 false, as stored — the property every boolean case + // hangs off. + expect(rows.filter((r) => Number(r.flag) === 1)).toHaveLength(3); }); for (const c of AGGREGATION_CASES) { diff --git a/packages/objectql/src/in-memory-aggregation-conformance.test.ts b/packages/objectql/src/in-memory-aggregation-conformance.test.ts index 56258b7265..1c8c532e1e 100644 --- a/packages/objectql/src/in-memory-aggregation-conformance.test.ts +++ b/packages/objectql/src/in-memory-aggregation-conformance.test.ts @@ -113,6 +113,26 @@ describe('[#6401] in-memory aggregation — aggregate vocabulary conformance', ( expect(AGGREGATION_ROWS.filter((r) => r.stage === null)).toHaveLength(2); }); + /** + * [#11152] The SPELLING pin the value cases above cannot carry: this + * harness's `actualFor` coerces through `Number(...)` (a wire-type reading + * the SQL twins need), and `Number(false)` is `0` — so a face answering the + * superseded `false`/`true` (#11249) would pass every boolean value case + * while diverging from the ruled JSON domain. The 2026-08-28 ruling pins + * booleans as NUMBERS in aggregation on every face, so the raw answer is + * asserted here, uncoerced. + */ + it('min/max over the boolean column answer the NUMBERS 0/1, not JSON booleans', () => { + const rows = applyInMemoryAggregation([...AGGREGATION_ROWS], { + aggregations: [ + { function: 'min', field: 'flag', alias: 'lo' }, + { function: 'max', field: 'flag', alias: 'hi' }, + ], + } as any); + expect((rows as any[])[0].lo).toBe(0); + expect((rows as any[])[0].hi).toBe(1); + }); + for (const c of AGGREGATION_CASES) { it(c.name, () => { const rows = applyInMemoryAggregation([...AGGREGATION_ROWS], astFor(c)); diff --git a/packages/objectql/src/in-memory-aggregation.ts b/packages/objectql/src/in-memory-aggregation.ts index 8ccc36d053..f12515f615 100644 --- a/packages/objectql/src/in-memory-aggregation.ts +++ b/packages/objectql/src/in-memory-aggregation.ts @@ -221,13 +221,23 @@ function aggregateBucket(allRows: any[], aggregations: AggregationNode[]): Recor out[alias] = nums.length === 0 ? null : nums.reduce((a, b) => a + b, 0) / nums.length; break; } + // [#11152] `min`/`max` read a BOOLEAN as the number it is worth (0/1) — + // maintainer ruling 2026-08-28 (superseding #11249's `false`/`true`): + // booleans aggregate as NUMBERS on every face, with no per-aggregate + // exception, so the order statistics answer in the same numeric domain + // `sum`/`avg` already answer in (`toNumber`, `Number(true) === 1`). The + // coercion is BOOLEAN-ONLY, exactly like driver-memory's (#11065): + // strings, dates and numbers reach the same raw comparison they always + // did — widening it would change `min` over a text column. case 'min': { - const defined = values.filter((v) => v != null); + const defined = values.filter((v) => v != null) + .map((v) => (typeof v === 'boolean' ? Number(v) : v)); out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => (a < b ? a : b)); break; } case 'max': { - const defined = values.filter((v) => v != null); + const defined = values.filter((v) => v != null) + .map((v) => (typeof v === 'boolean' ? Number(v) : v)); out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => (a > b ? a : b)); break; } diff --git a/packages/spec/src/data/aggregation-conformance.ts b/packages/spec/src/data/aggregation-conformance.ts index 1af58a1989..983812e2d3 100644 --- a/packages/spec/src/data/aggregation-conformance.ts +++ b/packages/spec/src/data/aggregation-conformance.ts @@ -52,9 +52,16 @@ * and checked by `date-bucket-parity.test.ts`; folding it in would make the * table unpassable for a face that legitimately buckets nothing. * - **Presentation.** `min`/`max` hand back a value OF the column and the SQL - * driver re-presents it; the fixture's aggregated column is a plain number so - * that path is not exercised here. `sql-driver-aggregate-temporal-output.test.ts` - * owns it. + * driver re-presents it; the fixture's numeric aggregated column (`score`) + * does not exercise that path. `sql-driver-aggregate-temporal-output.test.ts` + * owns the temporal half. The BOOLEAN column below is the ruled exception to + * "a member of the input domain": #11152 (maintainer 2026-08-28, ruling + * verbatim 「12745 A回,其他同意。」, superseding #11249's `false`/`true`) + * pins that **booleans aggregate as numbers on every face, with no + * per-aggregate exception** — `min(flag)`/`max(flag)` answer `0`/`1`, the + * same numeric domain `sum`/`avg` already answer in (#11065). So a boolean + * aggregand takes NO boolean read-presentation on any face, and + * {@link AggregationExpectation.value} stays a `number` for every case. * * ## NULL is IN, and it is the point of the table * @@ -191,6 +198,27 @@ export interface AggregationRow { * where dedup and nulls both bite, one where neither does. */ score: number; + /** + * [#11152] The non-null BOOLEAN aggregand — 3 true / 3 false, so `sum` and + * `avg` cannot agree with a face that dropped the booleans (`0` / `null`, + * the #11065/#11151 defect) or that counted rows instead of trues. + * + * The distribution is the `FLAG_BY_ID` the #11635 suite landed, adopted here + * verbatim so the two never disagree on grouped values: `west` holds + * `[T,F,F,F]` and `east` `[T,T]`. The asymmetry is load-bearing — `east` is + * all-true, so its grouped `min(flag)` is `1`, and a face that computed the + * aggregate over the whole table (or answered a sticky per-column constant) + * fails on that cell rather than passing by symmetry. + * + * Harnesses MUST declare it `type: 'boolean'` — the driver-boundary read + * presentation keys off the declared type, and the ruled point of this + * column is that aggregation deliberately BYPASSES it: #11152 (maintainer + * 2026-08-28, superseding #11249) rules that booleans aggregate as numbers + * on every face — `sum`=3, `avg`=0.5, `min`=0, `max`=1 — with no + * per-aggregate exception. A harness that stored the flags as strings, or a + * face that answered `false`/`true`, is answering outside the ruled domain. + */ + flag: boolean; } /** @@ -199,12 +227,12 @@ export interface AggregationRow { * exclusion while only one exercises dedup. */ export const AGGREGATION_ROWS: readonly AggregationRow[] = [ - { id: '1', region: 'west', stage: 'won', score: 10 }, - { id: '2', region: 'west', stage: 'won', score: 20 }, - { id: '3', region: 'west', stage: 'lost', score: 30 }, - { id: '4', region: 'west', stage: null, score: 40 }, - { id: '5', region: 'east', stage: 'won', score: 50 }, - { id: '6', region: 'east', stage: null, score: 60 }, + { id: '1', region: 'west', stage: 'won', score: 10, flag: true }, + { id: '2', region: 'west', stage: 'won', score: 20, flag: false }, + { id: '3', region: 'west', stage: 'lost', score: 30, flag: false }, + { id: '4', region: 'west', stage: null, score: 40, flag: false }, + { id: '5', region: 'east', stage: 'won', score: 50, flag: true }, + { id: '6', region: 'east', stage: null, score: 60, flag: true }, ] as const; /** @@ -327,6 +355,86 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ expected: [{ group: null, value: 60 }], }, + // ── [#11152] the boolean aggregand: numbers on every face, by ruling ────── + // + // The whole vocabulary over `flag` (3 true / 3 false). Two rulings pin the + // values: #11065 settled `sum`/`avg` (a boolean is an aggregand worth 1 or + // 0 — driver-memory answered `0`/`null` while SQLite answered `2`/`0.4`, + // found from an application because no conformance cell could see it), and + // #11152 (maintainer 2026-08-28, superseding #11249's `false`/`true`) + // settled `min`/`max` the same way: booleans aggregate as NUMBERS on every + // face, no per-aggregate exception. + { + name: 'sum(flag) counts the true rows', + function: 'sum', + field: 'flag', + expected: [{ group: null, value: 3 }], + note: + '#11065/#11151: an arithmetic accumulator that drops booleans answers its ' + + 'identity 0 here — a plausible number, which is why this case exists.', + }, + { + name: 'avg(flag) is the true-rate', + function: 'avg', + field: 'flag', + expected: [{ group: null, value: 0.5 }], + note: + '#11065: the rate-over-a-flag-column shape (an SLA-violation rate, a win ' + + 'rate). A face that drops booleans answers null — a blank tile, ' + + 'indistinguishable from "no matching rows".', + }, + { + name: 'min(flag) answers the NUMBER 0', + function: 'min', + field: 'flag', + expected: [{ group: null, value: 0 }], + note: + '#11152 ruling (2026-08-28): booleans aggregate as numbers with no ' + + 'per-aggregate exception, so the order statistics answer 0/1 in the ' + + 'same domain sum/avg answer in — not false/true (#11249, superseded).', + }, + { + name: 'max(flag) answers the NUMBER 1', + function: 'max', + field: 'flag', + expected: [{ group: null, value: 1 }], + note: 'The twin of min(flag) — see its note.', + }, + { + name: 'count(flag) counts all six — the column is non-null', + function: 'count', + field: 'flag', + expected: [{ group: null, value: 6 }], + note: + 'Control: count is defined over booleans on every backend and must not ' + + 'move under any boolean coercion — 6, not the 3 a lowering that ' + + 'counted trues would answer.', + }, + { + name: 'count_distinct(flag) is 2 — false and true', + function: 'count_distinct', + field: 'flag', + expected: [{ group: null, value: 2 }], + note: + 'The dedup control on the boolean axis: 2 whatever the row count, so a ' + + 'face that folded the column to one storage value (or dropped it) ' + + 'cannot agree by coincidence.', + }, + { + name: 'min(flag) grouped by region — east is all-true', + function: 'min', + field: 'flag', + groupBy: 'region', + expected: [ + { group: 'east', value: 1 }, + { group: 'west', value: 0 }, + ], + note: + 'east [T,T] / west [T,F,F,F]: the asymmetric cell. A face computing the ' + + 'minimum over the whole table and repeating it per group answers 0/0, ' + + 'and the ungrouped case above cannot see that.', + }, + // ── grouped: the aggregate is computed PER GROUP ────────────────────────── { name: 'count_distinct(stage) grouped by region', From 1516d12245127a9bdde69c0dd988a24097f89ac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:14:46 +0000 Subject: [PATCH 2/3] chore: changeset for the ruled numeric boolean min/max Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --- .changeset/boolean-aggregands-numeric-min-max.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/boolean-aggregands-numeric-min-max.md diff --git a/.changeset/boolean-aggregands-numeric-min-max.md b/.changeset/boolean-aggregands-numeric-min-max.md new file mode 100644 index 0000000000..a36ad17e82 --- /dev/null +++ b/.changeset/boolean-aggregands-numeric-min-max.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +'@objectstack/driver-sql': patch +'@objectstack/driver-memory': patch +'@objectstack/driver-mongodb': patch +'@objectstack/objectql': patch +--- + +`min`/`max` over a **boolean** aggregand now answer the numbers `0`/`1` on every face — maintainer ruling 2026-08-28 (#11152, option A), superseding #11249's `false`/`true`: booleans aggregate as numbers, with no per-aggregate exception, so one flag column's `sum`/`avg`/`min`/`max` all answer in one numeric domain. + +FROM → TO, per face: `driver-sql` (every dialect, `driver-sqlite-wasm` included via the shared compiler) no longer re-presents `min`/`max` results over a declared boolean as JSON booleans — `false`/`true` → `0`/`1`; row reads (`find()`) still present booleans, and `min`/`max` over an empty window still answer `null`. `driver-memory` (data and analytics faces) and objectql's in-memory fallback compare booleans as the numbers they are worth — `false`/`true` → `0`/`1`; strings, dates and numbers reach the same comparison they always did. `driver-mongodb` wraps `$min`/`$max` in the same boolean-only `$cond` coercion `$sum`/`$avg` use — `false`/`true` → `0`/`1`; null/missing still pass through, so the empty window still answers `null`. A caller reading `min`/`max` over a boolean column as a JSON boolean should read the number (`0` is false-y, `1` truthy, so boolean coercion at the call site keeps working). + +The cross-driver aggregation conformance fixture (`AGGREGATION_ROWS`, `@objectstack/spec/data`) now carries the boolean column those rulings are pinned by: `flag` (3 true / 3 false), with cases for `sum`=3, `avg`=0.5, `min`=0, `max`=1, `count`=6, `count_distinct`=2 and a grouped `min` over the deliberately asymmetric groups — the reach gap #11065 and #11151 were both found through (a boolean aggregand no conformance cell could see) is closed. From 870e84442a1f9dd65829a6c2048086e1fe0af43c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:42:54 +0000 Subject: [PATCH 3/3] test: flip the two remaining ruling-B boolean min/max pins to the ruled 0/1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 11782 cross-door parity suite keeps every row-read boolean pin (find, distinct, group keys — NOT superseded) and asserts the ruled numbers on the two order-statistic cells; the mongodb pipeline-builder unit test pins the boolean-only coercion wrapper on min/max stages, the same shape it already pins for sum/avg. Pin sweep across driver-sql/mongodb/memory/objectql/rest/qa surfaced no third file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --- .../src/mongodb-aggregation.test.ts | 28 +++++++++++-------- ...1782-boolean-row-read-presentation.test.ts | 27 +++++++++++++++--- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/drivers/driver-mongodb/src/mongodb-aggregation.test.ts b/packages/drivers/driver-mongodb/src/mongodb-aggregation.test.ts index 6dfdcf8851..e51c4a3966 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-aggregation.test.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-aggregation.test.ts @@ -4,16 +4,20 @@ import { describe, it, expect } from 'vitest'; import { buildAggregationPipeline, postProcessAggregation } from './mongodb-aggregation.js'; /** - * [#11151] The aggregand `sum` and `avg` now consume: the field path, with a - * BOOLEAN rendered as the number it is worth. Spelled once here because the - * pins below are about ALIAS ROUTING and stage shape — which accumulator lands - * under which key — and the accumulator's own internals are pinned, with the - * reasons, in `mongodb-11151-boolean-aggregand-answers.test.ts`. + * [#11151/#11152] The aggregand all four arithmetic/order accumulators now + * consume: the field path, with a BOOLEAN rendered as the number it is worth. + * Spelled once here because the pins below are about ALIAS ROUTING and stage + * shape — which accumulator lands under which key — and the accumulator's own + * internals are pinned, with the reasons, in + * `mongodb-11151-boolean-aggregand-answers.test.ts`. * - * ⛔ `min` / `max` deliberately do NOT take this wrapper: they are order - * statistics and #11249 ruled they answer `false` / `true`, not `0` / `1`. The - * `builds min/max aggregations` case below reads their bare field path and is - * the pin that says so from this file. + * `sum`/`avg` have worn the wrapper since #11151; `min`/`max` wear it since + * the #11152 ruling (maintainer 2026-08-28, superseding #11249's + * `false`/`true`): booleans aggregate as NUMBERS on every face, no + * per-aggregate exception. The wrapper is applied at BUILD time to every + * `min`/`max` because the column's type is unknown statically; at RUN time the + * `$cond` coerces only actual booleans — every other value takes the + * pass-through branch and is ranked exactly as the bare path would rank it. */ const coerced = (path: string) => ({ $cond: [{ $eq: [{ $type: path }, 'bool'] }, { $cond: [path, 1, 0] }, path], @@ -95,8 +99,10 @@ describe('MongoDB Aggregation Pipeline Builder', () => { { function: 'max', field: 'price', alias: 'max_price' }, ], }); - expect(pipeline[0].$group.min_price).toEqual({ $min: '$price' }); - expect(pipeline[0].$group.max_price).toEqual({ $max: '$price' }); + // [#11152] The same wrapper `$sum`/`$avg` wear — build-time on every + // min/max, runtime pass-through for anything that is not a boolean. + expect(pipeline[0].$group.min_price).toEqual({ $min: coerced('$price') }); + expect(pipeline[0].$group.max_price).toEqual({ $max: coerced('$price') }); }); describe('postProcessAggregation', () => { diff --git a/packages/drivers/driver-sql/src/sql-driver-11782-boolean-row-read-presentation.test.ts b/packages/drivers/driver-sql/src/sql-driver-11782-boolean-row-read-presentation.test.ts index f75ccb3526..9dd2f87cdd 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11782-boolean-row-read-presentation.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11782-boolean-row-read-presentation.test.ts @@ -15,7 +15,9 @@ * - `find().flag` → `1` / `0` (`typeof number`) * - `distinct('flag')` → `[0, 1]` (`typeof number`) * - `aggregate` groupBy(`flag`) → keys `1`/`0` (`typeof number`) - * - `aggregate` `min`/`max` → `false`/`true` (correct since #11635/#11785) + * - `aggregate` `min`/`max` → `false`/`true` (the #11635/#11785-era + * presentation; see the #11152 note below — those two cells answer `0`/`1` + * now, BY RULING, not by regression) * * while SQLite and Postgres answered `true`/`false` on all four. The boolean * read coercion in `formatOutput` — and its per-column mirror @@ -29,6 +31,20 @@ * `tinyint(1)`); Postgres stores a real `boolean` node-pg parses, so its * stored form already IS the presented form and it stays ungated. * + * ## [#11152] The `min`/`max` CELLS are superseded — the row-read doors are NOT + * + * The maintainer's 2026-08-28 ruling on #11152 (applied in that card's comment + * 5448627494, verbatim 「12745 A回,其他同意。」, superseding #11249) pins that + * **booleans aggregate as numbers on every face**: `min(flag)`/`max(flag)` + * answer the JSON NUMBERS `0`/`1`, so the aggregate-result boolean + * presentation this suite once asserted is deliberately removed again. ⚠️ + * Everything ELSE this suite pins stands unchanged and load-bearing: `find()` + * rows, `distinct()` values and aggregate GROUP KEYS still present a declared + * boolean as a JSON boolean — it is the AGGREGATION result that is numeric by + * rule, not the column. The cross-door test below is now the pin that holds + * exactly that boundary: same column, boolean domain on the three row-value + * doors, ruled numbers on the two order-statistic cells. + * * ## Assertion conventions * * Booleans are asserted STRICTLY (`toBe(true)` / `toBe(false)`, `toEqual` on @@ -139,7 +155,7 @@ describe(`[#11782] driver-sql — boolean row reads answer JSON booleans (${cell // ─── Cross-door agreement — the assertion the triage note asked for ────── - it('find(), distinct() and aggregate() answer the SAME JSON booleans for the same column', async () => { + it('find(), distinct() and group keys answer the SAME JSON booleans; min/max answer the ruled numbers', async () => { const found = new Set( ((await driver.find(TABLE, {})) as any[]).map((r) => r.flag), ); @@ -160,8 +176,11 @@ describe(`[#11782] driver-sql — boolean row reads answer JSON booleans (${cell expect(found, 'find()').toEqual(domain); expect(listed, 'distinct()').toEqual(domain); expect(groupKeys, 'aggregate group keys').toEqual(domain); - expect(agg[0].lo, 'min(flag)').toBe(false); - expect(agg[0].hi, 'max(flag)').toBe(true); + // [#11152] The two order-statistic cells are the ruled exception (see the + // head note): numbers, strictly — `false`/`true`, the #11249-era answer, + // is exactly the wrong spelling these two lines exist to exclude. + expect(agg[0].lo, 'min(flag)').toBe(0); + expect(agg[0].hi, 'max(flag)').toBe(1); }); it('aggregate group keys carry per-group counts under the presented key', async () => {