Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/boolean-aggregands-numeric-min-max.md
Original file line numberDiff line numberDiff line change
@@ -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.
18 changes: 15 additions & 3 deletions packages/drivers/driver-memory/src/memory-analytics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand DownExpand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 });
});
});

/**
Expand All@@ -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' },
},
Expand DownExpand Up@@ -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<string, unknown>;
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);
Expand Down
18 changes: 16 additions & 2 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]);
}
Expand Down
Loading
Loading