Skip to content

Commit f6fa22c

Browse files
Trumpclaude
andauthored
feat(spec): boolean aggregand column in the aggregation conformance fixture — ruled numeric min/max on every face (#12947)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 * chore: changeset for the ruled numeric boolean min/max Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 * test: flip the two remaining ruling-B boolean min/max pins to the ruled 0/1 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 945e91a commit f6fa22c

16 files changed

Lines changed: 472 additions & 199 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/driver-sql': patch
4+
'@objectstack/driver-memory': patch
5+
'@objectstack/driver-mongodb': patch
6+
'@objectstack/objectql': patch
7+
---
8+
9+
`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.
10+
11+
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).
12+
13+
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.

packages/drivers/driver-memory/src/memory-analytics.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,9 @@ function sizeDistinctSet(values: readonly unknown[]): number {
482482

483483
/**
484484
* [#11065] `path`, with a BOOLEAN rendered as the number it is worth — the
485-
* aggregand expression `$sum` and `$avg` consume on this face.
485+
* aggregand expression `$sum` and `$avg` consume on this face, and, since the
486+
* #11152 ruling (maintainer 2026-08-28: booleans aggregate as numbers on every
487+
* face, no per-aggregate exception), `$min` and `$max` as well.
486488
*
487489
* ## What it is for
488490
*
@@ -1241,10 +1243,20 @@ export class MemoryAnalyticsService implements IAnalyticsService {
12411243
return { $sum: numericAggregandExpr(`$${fieldPath}`) };
12421244
case 'avg':
12431245
return { $avg: numericAggregandExpr(`$${fieldPath}`) };
1246+
// [#11152] `min`/`max` take the SAME boolean coercion as `sum`/`avg` —
1247+
// maintainer ruling 2026-08-28 (superseding #11249's `false`/`true`):
1248+
// booleans aggregate as NUMBERS on every face, no per-aggregate
1249+
// exception, so a boolean measure's order statistics answer 0/1. mingo's
1250+
// `$min`/`$max` rank whatever the expression yields, so the coerced
1251+
// number is what gets ranked; every non-boolean value passes through the
1252+
// `$cond` untouched and is ranked exactly as before. The data face
1253+
// carries the identical rule in JavaScript (`memory-driver.ts`,
1254+
// `computeAggregate`) — one face aligned alone is how this package's
1255+
// faces come to disagree.
12441256
case 'min':
1245-
return { $min: `$${fieldPath}` };
1257+
return { $min: numericAggregandExpr(`$${fieldPath}`) };
12461258
case 'max':
1247-
return { $max: `$${fieldPath}` };
1259+
return { $max: numericAggregandExpr(`$${fieldPath}`) };
12481260
case 'count_distinct':
12491261
// Collects the distinct values; {@link sizeDistinctSet} turns the array
12501262
// into the NUMBER, excluding null — see the note there for why the

packages/drivers/driver-memory/src/memory-boolean-aggregand.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,32 @@ describe('[#11065] InMemoryDriver data face — a boolean aggregand is worth 1 o
215215
}) as any[];
216216
expect(row.rate).toBeNull();
217217
});
218+
219+
/**
220+
* [#11152] `min`/`max` join the numeric family — maintainer ruling
221+
* 2026-08-28 (superseding #11249's `false`/`true`): booleans aggregate as
222+
* NUMBERS on every face, no per-aggregate exception. Asserted STRICTLY
223+
* (`toBe(0)`/`toBe(1)`): the superseded booleans satisfy a `Number()`
224+
* reading, so a coerced comparison would pass on exactly the wrong
225+
* spelling. Grouped: the open group is all-true, so its `min` is `1` — a
226+
* whole-table computation or a sticky `0` fails there and only there.
227+
*/
228+
it('min/max over the boolean answer the NUMBERS 0/1, ungrouped and grouped', async () => {
229+
const [row] = await driver.aggregate(TABLE, {
230+
aggregations: [
231+
{ function: 'min', field: 'is_sla_violated', alias: 'lo' },
232+
{ function: 'max', field: 'is_sla_violated', alias: 'hi' },
233+
],
234+
}) as any[];
235+
expect(row.lo).toBe(0);
236+
expect(row.hi).toBe(1);
237+
const rows = await driver.aggregate(TABLE, {
238+
groupBy: ['is_closed'],
239+
aggregations: [{ function: 'min', field: 'is_sla_violated', alias: 'lo' }],
240+
}) as any[];
241+
const byClosed = Object.fromEntries(rows.map((r) => [String(r.is_closed), r.lo]));
242+
expect(byClosed).toEqual({ true: 0, false: 1 });
243+
});
218244
});
219245

220246
/**
@@ -235,6 +261,8 @@ describe('[#11065] the analytics face answers the same rate', () => {
235261
measures: {
236262
slaViolationRate: { name: 'sla_violation_rate', label: 'SLA Violation Rate', type: 'avg', sql: 'is_sla_violated' },
237263
slaViolations: { name: 'sla_violations', label: 'SLA Violations', type: 'sum', sql: 'is_sla_violated' },
264+
minViolated: { name: 'min_violated', label: 'Min violated', type: 'min', sql: 'is_sla_violated' },
265+
maxViolated: { name: 'max_violated', label: 'Max violated', type: 'max', sql: 'is_sla_violated' },
238266
count: { name: 'count', label: 'Cases', type: 'count', sql: 'id' },
239267
avgNote: { name: 'avg_note', label: 'Avg note', type: 'avg', sql: 'note' },
240268
},
@@ -288,6 +316,23 @@ describe('[#11065] the analytics face answers the same rate', () => {
288316
expect(byClosed).toEqual({ true: [0.25, 4], false: [1, 1] });
289317
});
290318

319+
/**
320+
* [#11152] The mingo route answers the same ruled numbers — the `$min`/
321+
* `$max` arms wrap `numericAggregandExpr` exactly as `$sum`/`$avg` do, and
322+
* one face aligned alone is how this package's faces come to disagree.
323+
* Strict for the data-face reason: the superseded `false`/`true` (#11249)
324+
* satisfies any coerced reading.
325+
*/
326+
it('min/max over the boolean answer the NUMBERS 0/1 here too', async () => {
327+
const result = await service.query({
328+
cube: 'cases',
329+
measures: ['cases.minViolated', 'cases.maxViolated'],
330+
} as any);
331+
const row = result.rows[0] as Record<string, unknown>;
332+
expect(row['cases.minViolated']).toBe(0);
333+
expect(row['cases.maxViolated']).toBe(1);
334+
});
335+
291336
/** The same narrowness guard the data face carries: text stays excluded. */
292337
it('a non-numeric text column is still excluded here too', async () => {
293338
const result = await service.query({ cube: 'cases', measures: ['cases.avgNote'] } as any);

packages/drivers/driver-memory/src/memory-driver.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,16 +1342,30 @@ export class InMemoryDriver implements IDataDriver {
13421342
return nums.length > 0 ? sum / nums.length : null;
13431343
}
13441344

1345+
// [#11152] `min`/`max` read a BOOLEAN as the number it is worth —
1346+
// maintainer ruling 2026-08-28 (superseding #11249's `false`/`true`):
1347+
// booleans aggregate as NUMBERS on every face, no per-aggregate
1348+
// exception, so the order statistics answer 0/1 in the same numeric
1349+
// domain the `sum`/`avg` arms above already answer in. The coercion
1350+
// is BOOLEAN-ONLY for the same reason theirs is: strings and dates
1351+
// reach the same raw comparison they always did. The analytics face
1352+
// carries the identical rule in its `$min`/`$max` mingo arms
1353+
// (`memory-analytics.ts`, `buildAggregator`) — one face aligned
1354+
// alone leaves the other free to keep its own answer.
13451355
case 'min': {
13461356
// Handle comparable values
1347-
const valid = values.filter(v => v !== null && v !== undefined);
1357+
const valid = values
1358+
.filter(v => v !== null && v !== undefined)
1359+
.map(v => (typeof v === 'boolean' ? (v ? 1 : 0) : v));
13481360
if (valid.length === 0) return null;
13491361
// Works for numbers and strings
13501362
return valid.reduce((min, v) => (v < min ? v : min), valid[0]);
13511363
}
13521364

13531365
case 'max': {
1354-
const valid = values.filter(v => v !== null && v !== undefined);
1366+
const valid = values
1367+
.filter(v => v !== null && v !== undefined)
1368+
.map(v => (typeof v === 'boolean' ? (v ? 1 : 0) : v));
13551369
if (valid.length === 0) return null;
13561370
return valid.reduce((max, v) => (v > max ? v : max), valid[0]);
13571371
}

0 commit comments

Comments
 (0)