From 5d3ef00797d4e44b27022ce5e18decb0a8c9c697 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:41:45 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix(objectql)!:=20having=20takes=20where's?= =?UTF-8?q?=20remaining=20filter=20doors=20=E2=80=94=20type=20door,=20row-?= =?UTF-8?q?independent=20refusals,=20resolved=20$field,=20array/scalar=20r?= =?UTF-8?q?efused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP: implementation; tests and changeset follow. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx --- packages/objectql/src/engine.ts | 36 ++- packages/objectql/src/having-filter.ts | 353 ++++++++++++++++++++++++- 2 files changed, 387 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a9ec1306938..b9c0c8dea1d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -256,7 +256,12 @@ import { type EngineUpdateDispatchData, type EngineUpdateDispatchInput, } from './engine-update-dispatch.js'; -import { applyHaving } from './having-filter.js'; +import { + applyHaving, + aggregatedRowColumns, + assertHavingIsEvaluable, + assertHavingIsFilterCondition, +} from './having-filter.js'; import { auditDanglingReferences, type AuditableObject, @@ -15722,7 +15727,36 @@ export class ObjectQL implements IObjectQLEngine { // the `INVALID_FILTER` / 400 envelope are the face's own, so a `having` // refusal reads byte for byte as the `where` refusal of the same shape, // path aside — and whatever arm the face gains next, `having` gains too. + // + // [#20099] …and the rest of the doors `where` takes, at the same seam, so + // the whole clause is judged once, before any row exists. In order: + // 1. `having` is a filter condition at all — `QuerySchema.having` and + // `EngineAggregateOptions.having` declare `FilterConditionSchema`, + // which refuses an array or a scalar. The walker read an array's index + // keys as columns (`[['total','>',100]]` kept no group) and a scalar + // as no condition (every group). ⛔ Not lowered: the `FilterArray` + // sugar is declared on `where` alone, and every later door steps + // silently around a non-node, so this runs first. + // 2. the comparand-shape face (above); + // 3. the comparand-TYPE door — the same `normalizeFilterComparandTypes` + // `lowerWhereFilterArray` runs on `where`, path rooted at `having`, so + // `{ total: { $eq: { v: 1 } } }` is the `where` refusal of that shape + // rather than a silent `[]`, and an exact-range bigint is narrowed + // copy-on-write exactly as it is there; + // 4. the walker's own refusals plus the `{ $field }` reference's + // position and name, judged against the aggregated row's column set + // read off THIS query (`assertHavingIsEvaluable`). The walker raised + // them per aggregated row, so an empty grouped set answered `200 []` + // for a `having` a populated one refused. + // A reference that passes is then RESOLVED against each aggregated row + // (having-filter.ts `compareWithReference`) on both doors below. + assertHavingIsFilterCondition(query.having); assertListComparandShapes(object, 'aggregate', query.having, 'having'); + { + const having = normalizeFilterComparandTypes(query.having, `aggregate('${object}')`, 'having'); + assertHavingIsEvaluable(having, aggregatedRowColumns(query.groupBy, query.aggregations)); + if (having !== query.having) query = { ...query, having }; + } const driver = this.getDriver(object); this.logger.debug(`Aggregate on ${object} using ${driver.name}`, query); diff --git a/packages/objectql/src/having-filter.ts b/packages/objectql/src/having-filter.ts index 2514be70bbc..7d9f8ace642 100644 --- a/packages/objectql/src/having-filter.ts +++ b/packages/objectql/src/having-filter.ts @@ -62,8 +62,33 @@ // [#7158] A THIRD divergence has been REMOVED rather than added: this face had // no comparand-shape gate, which is what the five sibling faces refuse an // unevaluable `$icontains` comparand with. See {@link icontainsComparandError}. +// +// [#20099] THE VERDICT IS THE FILTER'S, NOT THE DATA'S. This walker runs once +// per aggregated row, so every refusal it raises used to depend on the rows: an +// empty grouped set never reached it, a `$or` whose first branch held never +// reached its second, and a column the row does not carry left the walk at the +// no-value exit before the operator was ever read. So `{ total: { $median: 1 } }` +// was a 400 on a populated set and a 200 `[]` on an empty one. The engine now +// judges the whole clause ONCE, before any driver is asked for a row — see +// {@link assertHavingIsFilterCondition} and {@link assertHavingIsEvaluable}, +// called from `ObjectQL.aggregate` beside the shared comparand-shape face and +// the comparand-TYPE door that `where` also takes. The refusals below keep their +// words; they are only raised earlier. The per-row throws stay as the floor for +// a caller that evaluates rows directly. +// +// [#20099] A `{ $field }` reference is RESOLVED against the row, as the whole +// comparand of the six scalar comparisons — the one position the Filter +// Protocol declares for it (`FieldReferenceSchema`) and the one the +// `{ $field }` `$between` refusal prescribes. See {@link compareWithReference}. import type { FilterCondition } from '@objectstack/spec/data'; +// [#20099] The reference's own declaration, so a malformed `addDays` is refused +// in the spec's words rather than in a second copy of them. +import { FieldReferenceSchema } from '@objectstack/spec/data'; +// [#20099] The in-memory evaluator the SQL family's cross-field compiler is +// held to row for row (`cross-field-conformance-cases.ts`): the NULL totality +// and the whole-day `addDays` arithmetic of a reference live there once. +import { matchesFilterCondition } from '@objectstack/formula'; // [#5702] The retired operators and the prescription a refusal prints. HAVING is // the fifth of the five refusal sites `RETIRED_FILTER_OPERATORS`' own doc names, // and reads the table for the same reason the four driver sites do: one @@ -139,6 +164,44 @@ const CONDITION_OPERATORS = [ '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', ] as const; +/** + * [#20099] The operators whose WHOLE comparand may be a `{ $field }` reference — + * the six scalar comparisons `FieldReferenceSchema` names, and the only + * positions the SQL family compiles one in. Every other position (a list + * member, a text pattern, `$exists` / `$null`) is refused by + * {@link assertHavingIsEvaluable} rather than compared against the reference + * OBJECT, which matched nothing. + */ +const REFERENCE_COMPARISON_OPERATORS: ReadonlySet = new Set([ + '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', +]); + +/** + * [#20099] A `{ $field: … }` reference by SHAPE — a non-array object carrying a + * `$field` key, the test `@objectstack/formula`'s evaluator applies. Whether + * the `$field` value is a string is the comparand-TYPE door's question, and it + * runs first: `{ $field: 42 }` in a scalar slot is refused there as the plain + * object it is. + */ +function isFieldReferenceShape(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) + && !(value instanceof Date) + && '$field' in value; +} + +/** A bounded rendering of an offending value, for a human reading a 400. */ +function preview(value: unknown): string { + let text: string; + try { + text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + return text.length > 60 ? `${text.slice(0, 59)}…` : text; +} + /** * The refusal this face raises for an operator it will not evaluate — retired * (`$regex`, `$options`) or simply unknown (`$nand`, `$median`). @@ -250,6 +313,100 @@ function icontainsComparandError(field: string, value: unknown, path: string): E ); } +/** + * [#20099] `having` is not a filter condition at all — an array, a string, a + * number, a class instance. + * + * `QuerySchema.having` and `EngineAggregateOptions.having` declare + * `FilterConditionSchema`, which refuses every one of these. The walker read + * them anyway: an array's index keys became column names (`"0"`, `"1"`), so + * `[['total', '>', 100]]` kept no group, and anything that is not an object + * was taken as NO condition, so `'total > 100'` kept every group. + * + * The array half names the `FilterArray` sugar on purpose. The spec declares + * that input-only form on `where` alone — the transport widens that one slot, + * and `parseFilterAST` lowers it there — so an author carrying it over from a + * `where` is told which slot takes it rather than that it is malformed. + */ +function havingNotAConditionError(having: unknown): Error { + const shape = 'a filter condition OBJECT over the aggregated rows — { "ALIAS": { "$gt": 100 } }'; + if (Array.isArray(having)) { + return invalidFilterError( + `\`having\` received an array (${preview(having)}), and \`having\` is ${shape}. The array ` + + `form — [field, operator, value] tuples and ["and", …] groups — is input-only sugar that is ` + + `lowered on \`where\` alone; \`having\` does not declare it, and it was never applied as the ` + + `filter it spells. Write the object form.`, + ); + } + const kind = typeof having === 'object' ? 'an object that is not a plain filter node' : `a ${typeof having}`; + return invalidFilterError( + `\`having\` received ${kind} (${preview(having)}), which is not a filter condition. \`having\` is ` + + `${shape}. A value of any other kind was answered as NO condition, returning every group. ` + + `Write the object form, or omit \`having\`.`, + ); +} + +/** + * [#20099] `{ field: { $field: 'other' } }` — a reference with no operator. + * + * Refused, not read as equality: the in-memory evaluator the SQL family is held + * to answers this shape `false` for every row (its `$field` is an unknown + * operator there), and `driver-sql` refuses it naming `$eq`. So the spelling is + * given back with the operator written in. The columns are the author's own — + * `having` carries no policy subtree — so the corrected filter names them. + */ +function bareFieldReferenceError(field: string, spec: Record, path: string): Error { + return invalidFilterError( + `Field "${field}" at ${path} is constrained by a bare ${preview(spec)} reference with no operator. ` + + `Write the comparison explicitly — { "${field}": { "$eq": ${preview(spec)} } } — or put $ne, $gt, ` + + `$gte, $lt or $lte in place of $eq. A reference is a comparand, never a condition on its own.`, + ); +} + +/** + * [#20099] A `{ $field }` reference outside the six scalar comparisons — a + * `$in` / `$nin` member, a text pattern, an `$exists` / `$null` operand. + * Before this, the walker compared the reference OBJECT itself and matched + * nothing (`$nin` kept everything). `$between` endpoints never get here: the + * shared comparand-shape face refuses them first, in its own words. + */ +function fieldReferencePositionError(field: string, op: string, path: string, index?: number): Error { + const at = index === undefined ? '' : ` at index ${index} of its value list`; + return invalidFilterError( + `Operator "${op}" on field "${field}" at ${path} compares against a { "$field": … } reference${at}. ` + + `A reference is evaluated only as the WHOLE comparand of a scalar comparison — $eq, $ne, $gt, ` + + `$gte, $lt or $lte — never as a list member or a text pattern. Compare against a literal here, or ` + + `write the test as one of those comparisons.`, + ); +} + +/** + * [#20099] A reference that names no column of the aggregated row. + * + * The aggregated row's columns are known before any row exists — the groupBy + * projections and the aggregation aliases — so a reference that cannot resolve + * is refused on the filter, never answered per row as "no value". The column + * list is printed because it is the author's own query, and it is the answer. + */ +function unresolvedFieldReferenceError( + name: string, + path: string, + columns: readonly string[], +): Error { + const known = columns.length ? columns.join(', ') : '(none — the query projects no column)'; + return invalidFilterError( + `The { "$field": "${name}" } reference at ${path} names no column of the aggregated row. A ` + + `reference in \`having\` resolves against that row's own columns — the groupBy projections and ` + + `the aggregation aliases — which here are: ${known}. A reference that cannot resolve is refused ` + + `rather than compared against nothing.`, + ); +} + +/** [#20099] A reference its own declaration refuses — today, a malformed `addDays`. */ +function malformedFieldReferenceError(path: string, detail: string): Error { + return invalidFilterError(`The { "$field" } reference at ${path} is malformed: ${detail}`); +} + /** * [#5905] Operators whose answer for a column with NO VALUE is decided by the * operator's own arm below, not by the early exit in {@link checkCondition}. @@ -271,6 +428,188 @@ const NO_VALUE_ANSWERED_BY_OPERATOR: ReadonlySet = new Set([ '$exists', '$ne', '$null', '$nin', '$notContains', ]); +/** + * [#20099] The aggregated row's column set — the namespace `having` filters and + * a `{ $field }` reference in it resolves against — read off the QUERY, so it + * is known before any row exists: every groupBy projection (the field name, or + * a structured item's `alias` — the name `applyInMemoryAggregation` projects) + * and every aggregation alias. Malformed entries contribute nothing; their own + * validation is elsewhere. + */ +export function aggregatedRowColumns(groupBy: unknown, aggregations: unknown): string[] { + const columns = new Set(); + for (const g of Array.isArray(groupBy) ? groupBy : []) { + const item = g as { alias?: unknown; field?: unknown } | null; + const name = typeof g === 'string' ? g : (item?.alias ?? item?.field); + if (typeof name === 'string') columns.add(name); + } + for (const a of Array.isArray(aggregations) ? aggregations : []) { + const alias = (a as { alias?: unknown } | null)?.alias; + if (typeof alias === 'string') columns.add(alias); + } + return [...columns]; +} + +/** + * [#20099] Refuse a `having` that is not a filter condition at all: anything but + * `null` / `undefined` (no clause) and a plain object. See + * {@link havingNotAConditionError} for what each shape used to answer. + * + * Called by `ObjectQL.aggregate` FIRST on the clause, before the comparand + * faces, because every later door walks a filter node and steps silently + * around anything that is not one. + */ +export function assertHavingIsFilterCondition(having: unknown): void { + if (having == null) return; + if (typeof having === 'object' && !Array.isArray(having)) { + const proto = Object.getPrototypeOf(having); + if (proto === Object.prototype || proto === null) return; + } + throw havingNotAConditionError(having); +} + +/** + * [#20099] Judge a whole `having` clause ONCE, independent of the rows — every + * refusal the per-row walk can raise, plus the `{ $field }` reference's + * position and name. + * + * `ObjectQL.aggregate` calls this after the shared comparand-shape face and the + * comparand-TYPE door, the order `where` takes the same doors in, and before + * `getDriver`, the middleware chain and both `applyHaving` doors. So an empty + * grouped set refuses what a populated one refuses, a `$or` refuses a branch it + * would have short-circuited past, and a condition on a column the row does not + * carry is still read for its operator. + * + * What it refuses, in the order the per-row walk would meet it: + * - an unknown or retired `$` key, at node or condition level — the walker's + * own {@link unknownOperator} words; + * - an `$icontains` comparand that is not a non-empty string — + * {@link icontainsComparandError}; + * - a bare `{ field: { $field } }` — {@link bareFieldReferenceError}; + * - a reference outside the six scalar comparisons — + * {@link fieldReferencePositionError}; + * - a reference its declaration refuses (a malformed `addDays`), or one naming + * no column of the aggregated row — {@link unresolvedFieldReferenceError}. + * + * Read-only; `having` is assumed to have passed + * {@link assertHavingIsFilterCondition}. + */ +export function assertHavingIsEvaluable(having: unknown, columns: readonly string[]): void { + assertNodeIsEvaluable(having, 'having', columns); +} + +/** One node: the `$and` / `$or` / `$not` walk {@link matchesHaving} takes. */ +function assertNodeIsEvaluable(cond: unknown, path: string, columns: readonly string[]): void { + if (!cond || typeof cond !== 'object') return; + for (const [key, value] of Object.entries(cond)) { + const here = `${path}.${key}`; + if (key === '$and' || key === '$or') { + const branches = Array.isArray(value) ? value : [value]; + branches.forEach((c, i) => assertNodeIsEvaluable(c, `${here}[${i}]`, columns)); + continue; + } + if (key === '$not') { + assertNodeIsEvaluable(value, here, columns); + continue; + } + if (key.startsWith('$')) throw unknownOperator(key, 'logical'); + assertConditionIsEvaluable(value, key, here, columns); + } +} + +/** One column's condition: the arms {@link checkCondition} would refuse. */ +function assertConditionIsEvaluable( + condition: unknown, + field: string, + path: string, + columns: readonly string[], +): void { + // Implicit equality with a literal: nothing to refuse here. An array in this + // slot is the shared comparand-shape face's, and it has already run. + if ( + typeof condition !== 'object' + || condition === null + || condition instanceof Date + || Array.isArray(condition) + ) return; + const spec = condition as Record; + const keys = Object.keys(spec); + if (!keys.some((k) => k.startsWith('$'))) return; + for (const op of keys) { + const target = spec[op]; + if (op === '$field') throw bareFieldReferenceError(field, spec, path); + if (op === '$icontains' && (typeof target !== 'string' || target === '')) { + throw icontainsComparandError(field, target, `${path}.${op}`); + } + if (!(CONDITION_OPERATORS as readonly string[]).includes(op)) { + throw unknownOperator(op, 'condition', keys); + } + if (REFERENCE_COMPARISON_OPERATORS.has(op)) { + if (isFieldReferenceShape(target)) assertReferenceResolves(target, `${path}.${op}`, columns); + continue; + } + if (Array.isArray(target)) { + const index = target.findIndex(isFieldReferenceShape); + if (index !== -1) throw fieldReferencePositionError(field, op, `${path}.${op}`, index); + } else if (isFieldReferenceShape(target)) { + throw fieldReferencePositionError(field, op, `${path}.${op}`); + } + } +} + +/** + * [#20099] A reference in a scalar comparison: well-formed by its own + * declaration, and naming columns the aggregated row has — the `$field`, and an + * `addDays` offset's nested `$field` when it carries one. + */ +function assertReferenceResolves( + reference: Record, + path: string, + columns: readonly string[], +): void { + const parsed = FieldReferenceSchema.safeParse(reference); + if (!parsed.success) { + throw malformedFieldReferenceError(path, parsed.error.issues[0]?.message ?? 'it does not parse'); + } + const names = [reference.$field]; + if (isFieldReferenceShape(reference.addDays)) names.push(reference.addDays.$field); + for (const name of names) { + if (!columns.includes(String(name))) throw unresolvedFieldReferenceError(String(name), path, columns); + } +} + +/** + * [#20099] Evaluate `value { $field }` on one row, with the reference + * resolved against that row. + * + * The comparison is `@objectstack/formula`'s `matchesFilterCondition`, not a + * third copy of it. That evaluator is the cross-field semantics the SQL family + * compiles to row for row (NULL-total: an ordering against a missing value is + * false, `$eq` against one is "both have none"), and it owns the whole-day + * `addDays` arithmetic. It is handed a three-column PROBE rather than the row: + * a row here is flat — aggregation aliases and groupBy projections, read by + * direct key like every other column in this walker — while that evaluator + * walks dotted paths, so re-keying keeps a column name from ever being read as + * a path. + */ +function compareWithReference( + row: Record, + value: unknown, + op: string, + reference: Record, +): boolean { + const probe: Record = { value, referent: row?.[String(reference.$field)] }; + const resolved: Record = { $field: 'referent' }; + const offset = reference.addDays; + if (isFieldReferenceShape(offset)) { + probe.offset = row?.[String(offset.$field)]; + resolved.addDays = { $field: 'offset' }; + } else if (offset !== undefined) { + resolved.addDays = offset; + } + return matchesFilterCondition(probe, { value: { [op]: resolved } } as FilterCondition); +} + /** * Filter aggregated rows by the query's `having` condition. An absent or empty * condition returns the rows unchanged (same vacuous-filter convention as @@ -316,7 +655,9 @@ export function matchesHaving( // Aggregated rows are flat (aliases + group projections) — direct access, // no dotted-path resolution. [#10576] The per-aggregation filter walks the // same way on purpose: it reads `driver.find()` rows, which are flat too. - if (!checkCondition(row?.[key], value, key, here, clause)) return false; + // [#20099] The row itself goes down too: a `{ $field }` comparand resolves + // against it. + if (!checkCondition(row?.[key], value, key, here, clause, row)) return false; } return true; } @@ -352,6 +693,7 @@ function checkCondition( field: string, path: string, clause: FilterClause = HAVING_CLAUSE, + row: Record = {}, ): boolean { // Implicit equality (primitives, null, Date, array exact-match) — loose `==` // to mirror the Filter Protocol's memory evaluation. @@ -389,6 +731,15 @@ function checkCondition( throw icontainsComparandError(field, target, `${path}.${op}`); } if (value === undefined && !NO_VALUE_ANSWERED_BY_OPERATOR.has(op)) return false; + // [#20099] A `{ $field }` reference as the whole comparand of a scalar + // comparison is RESOLVED against this row. The arms below would compare the + // reference OBJECT itself — `500 > { $field: 'cap' }` is false, and `$ne` + // against it is true for every row — so the declared form answered + // nothing, or everything, silently. + if (REFERENCE_COMPARISON_OPERATORS.has(op) && isFieldReferenceShape(target)) { + if (!compareWithReference(row, value, op, target)) return false; + continue; + } switch (op) { case '$eq': if (value != target) return false; break; case '$ne': if (value == target) return false; break; From ce22319645129df39a79a65f940f94150827eb34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:53:26 +0000 Subject: [PATCH 2/5] test(objectql): extend the where/having parity table to the type door, the condition-object check, row-independent walker refusals and $field resolution Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx --- ...e-aggregate-having-comparand-shape.test.ts | 347 +++++++++++++++++- 1 file changed, 335 insertions(+), 12 deletions(-) diff --git a/packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts b/packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts index b9f71f3a262..62bfb2cc6e0 100644 --- a/packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts +++ b/packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts @@ -42,6 +42,31 @@ * * Every refusal asserts the ADR-0112 envelope (`code` + `status`); a bare * `toThrow()` would be satisfied by any uncoded error. + * + * ## [#20099] The rest of `where`'s doors, on the same table + * + * The face above was the only door `having` took. Measured on the base + * (aa04ea2964) through `engine.aggregate` on driver-memory and + * driver-sqlite-wasm, both doors, the four others answered like this — and the + * blocks after the face's extend the same table to each of them: + * + * | `having` | `where` (same shape) | `having` before | + * |:--|:--|:--| + * | `{ total: { $gt: { $field: 'max_cap' } } }` | sqlite: the rows, resolved | no group — the reference never read | + * | `{ total: { $eq: { v: 1 } } }` | 400, the comparand-TYPE door | no group | + * | `[['total', '>', 100]]` | lowered sugar, the rows | no group — index keys as columns | + * | `{ total: { $median: 1 } }` | 400 | 400 on a populated set, `200 []` on an empty one | + * + * 5. the comparand-TYPE door — its own where/having parity table, and the + * `FILTER_COMPARAND_TYPE_CASES` rows that are that door's, now driven down + * the `having` path too; + * 6. `having` is a filter condition OBJECT — an array (the `FilterArray` sugar + * is declared on `where` alone) or a scalar is refused, never read; + * 7. the walker's own refusals are row-independent — each on an empty and a + * populated grouped set, in the walker's own words; + * 8. a `{ $field }` reference resolves against the aggregated row in the six + * scalar comparisons, `addDays` included, and is refused in every other + * position and when it names no column. */ import { describe, it, expect } from 'vitest'; @@ -54,6 +79,7 @@ import { type FilterCondition, } from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; +import { applyHaving } from './having-filter.js'; const OBJECT = 'order'; @@ -110,18 +136,29 @@ function makeDriver(rows: ReadonlyArray>, native: boolea async commit() {}, async rollback() {}, }; if (native) { - // Groups and sums itself, ignores `ast.having` — as every real native - // driver does today; the engine's post-filter is what makes it live. - driver.aggregate = async () => { + // Groups by `customer_id` and computes the requested aggregations itself, + // ignoring `ast.having` — as every real native driver does today; the + // engine's post-filter is what makes it live. [#20099] It reads the + // requested aliases rather than hard-coding two, so a `{ $field }` + // reference can name a third column (`count` / `sum` / `min` / `max`, with + // `applyInMemoryAggregation`'s null-for-an-empty-min/max reading). + driver.aggregate = async (_object: string, ast: any) => { calls.aggregate += 1; - const groups = new Map(); - for (const r of rows as Array<{ customer_id: string; amount: number }>) { - const g = groups.get(r.customer_id) ?? { customer_id: r.customer_id, order_count: 0, total: 0 }; - g.order_count += 1; - g.total += r.amount; - groups.set(r.customer_id, g); + const groups = new Map>>(); + for (const r of rows as Array>) { + groups.set(r.customer_id, [...(groups.get(r.customer_id) ?? []), r]); } - return Array.from(groups.values()); + return Array.from(groups.entries()).map(([customerId, members]) => { + const out: Record = { customer_id: customerId }; + for (const a of ast.aggregations as Array<{ function: string; field?: string; alias: string }>) { + const values = members.map((m) => m[a.field ?? '']).filter((v) => v != null); + if (a.function === 'count') out[a.alias] = members.length; + else if (a.function === 'sum') out[a.alias] = values.reduce((s, v) => s + v, 0); + else if (a.function === 'min') out[a.alias] = values.length ? values.reduce((m, v) => (v < m ? v : m)) : null; + else if (a.function === 'max') out[a.alias] = values.length ? values.reduce((m, v) => (v > m ? v : m)) : null; + } + return out; + }); }; } return { driver, calls }; @@ -134,7 +171,16 @@ async function makeEngine(rows: ReadonlyArray>, native: await engine.init(); engine.registry.registerObject({ name: OBJECT, - fields: { customer_id: { type: 'text' }, amount: { type: 'number' } }, + fields: { + customer_id: { type: 'text' }, + amount: { type: 'number' }, + // [#20099] The columns the `{ $field }` rows aggregate — declared so the + // `where` doors a per-aggregation filter takes judge a real object. + cap: { type: 'number' }, + placed_on: { type: 'date' }, + due_on: { type: 'date' }, + grace: { type: 'number' }, + }, } as any); return { engine, calls }; } @@ -284,7 +330,14 @@ describe('[#19974] having — driven from the shared FILTER_COMPARAND_TYPE_CASES } }); - for (const c of shapeRows) { + // [#20099] …and those rows are now `having`'s too: the engine runs the same + // comparand-TYPE door on the clause, path rooted at `having`, so the table + // drives BOTH partitions down the having path. + it('the type door\'s partition is not empty either — its leg below can never pass on zero rows', () => { + expect(otherRows.length).toBeGreaterThanOrEqual(3); + }); + + for (const c of [...shapeRows, ...otherRows]) { it(`${c.name} — on the having path`, async () => { for (const [door, native] of DOORS) { const { engine, calls } = await makeEngine(ROWS, native); @@ -344,3 +397,273 @@ describe('[#19974] having — the verdict belongs to the filter, not to the data } }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// [#20099] The rest of `where`'s doors +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Refused on BOTH doors, on an EMPTY and on a populated grouped set, with one + * message, before any driver is asked for a row. Returns the message, so a row + * can hold it to the words it owes. + */ +async function expectRowIndependentRefusal( + query: EngineAggregateOptions, + rows: ReadonlyArray>, +): Promise { + let message: string | undefined; + for (const [door, native] of DOORS) { + for (const [population, data] of [['empty', []], ['populated', rows]] as const) { + const { engine, calls } = await makeEngine(data, native); + const err = await refusalOf(() => engine.aggregate(OBJECT, query)); + expectEnvelope(err); + expect(calls, `${door}, ${population}`).toEqual({ aggregate: 0, find: 0 }); + if (message === undefined) message = err.message; + expect(err.message, `${door}, ${population}`).toBe(message); + } + } + return message!; +} + +describe('[#20099] having — the comparand-TYPE door, the same where/having parity table', () => { + // Before: every row answered `having` with no group (the walker compared the + // object, the Map, the function… and matched nothing), while `where` refused + // each one at the type door. + const TYPE_REFUSED: ReadonlyArray Record]> = [ + ['a plain object under $eq (the triage shape)', () => ({ total: { $eq: { v: 1 } } })], + ['undefined in the implicit-equality slot', () => ({ total: undefined })], + ['a Map under $eq', () => ({ total: { $eq: new Map() } })], + ['a function under $gt', () => ({ total: { $gt: () => 1 } })], + ['a Symbol under $ne', () => ({ total: { $ne: Symbol('x') } })], + ['an undefined $in member', () => ({ total: { $in: [undefined] } })], + ['a bigint beyond 2^53', () => ({ total: { $gt: 2n ** 60n } })], + ['a { $field } whose $field is not a string', () => ({ total: { $gt: { $field: 5 } } })], + ['a plain object nested in $or', () => ({ $or: [{ order_count: 99 }, { total: { $eq: { v: 1 } } }] })], + ['undefined under $not', () => ({ $not: { total: undefined } })], + ]; + + for (const [name, filter] of TYPE_REFUSED) { + it(`${name}: refused as a where AND as a having, one envelope, one wording, both doors, any population`, async () => { + const { engine: whereEngine, calls: whereCalls } = await makeEngine(ROWS, true); + const whereErr = await refusalOf(() => + whereEngine.aggregate(OBJECT, offContract({ ...AGG_QUERY, where: filter() }))); + expectEnvelope(whereErr); + expect(whereCalls).toEqual({ aggregate: 0, find: 0 }); + // The type door's own sentence — not some other gate refusing the input. + const door = syncRefusalOf(() => normalizeFilterComparandTypes(filter(), `aggregate('${OBJECT}')`)); + expect(door, 'the type door must refuse this row directly').toBeDefined(); + expect(whereErr.message).toBe(door!.message); + + const havingMessage = await expectRowIndependentRefusal(offContract({ ...AGG_QUERY, having: filter() }), ROWS); + expect(havingMessage).toBe(whereErr.message.replaceAll('where.', 'having.')); + }); + } + + it('an exact-range bigint is NARROWED, as it is in where — and the caller\'s clause is not edited', async () => { + // Before: `{ $in: [500n, 20n] }` kept no group — `[500n].includes(500)` is + // false — while the same list in `where` is narrowed to numbers first. + for (const [door, native] of DOORS) { + const having = { total: { $in: [500n, 20n] } }; + const { engine } = await makeEngine(ROWS, native); + const rows = await engine.aggregate(OBJECT, offContract({ ...AGG_QUERY, having })); + expect(groups(rows), door).toEqual(['c1', 'c3']); + expect(having.total.$in, door).toEqual([500n, 20n]); + } + }); +}); + +describe('[#20099] having — a filter condition OBJECT, never an array or a scalar', () => { + // Before: an array answered no group (its index keys were read as columns), + // `[]` and every scalar answered EVERY group (no condition at all). + const NOT_A_CONDITION: ReadonlyArray unknown]> = [ + ['the FilterArray sugar, a list of comparisons', () => [['total', '>', 100]]], + ['the FilterArray sugar, one comparison', () => ['total', '>', 100]], + ['the FilterArray sugar, a logical group', () => ['and', ['total', '>', 100], ['order_count', '>=', 2]]], + ['an empty array', () => []], + ['a string', () => 'total > 100'], + ['a number', () => 100], + ['a boolean', () => true], + ['a Map', () => new Map([['total', 500]])], + ['a Date', () => new Date(0)], + ]; + + for (const [name, having] of NOT_A_CONDITION) { + it(`${name}: refused, whatever the rows`, async () => { + const message = await expectRowIndependentRefusal(offContract({ ...AGG_QUERY, having: having() }), ROWS); + expect(message).toContain('`having`'); + }); + } + + it('the FilterArray sugar is still lowered on where — the refusal is about the slot, not the shape', async () => { + // Control: the stand-in driver does not filter, so what is pinned is that + // `where` ACCEPTS the sugar (lowered, handed on) where `having` refuses it. + const { engine } = await makeEngine(ROWS, true); + await expect(engine.aggregate(OBJECT, offContract({ ...AGG_QUERY, where: [['amount', '>', 100]] }))) + .resolves.toBeInstanceOf(Array); + }); + + const NO_CLAUSE: ReadonlyArray = [ + ['null', null], + ['undefined', undefined], + ['{}', {}], + ['a null-prototype empty node', Object.create(null)], + ]; + for (const [name, having] of NO_CLAUSE) { + it(`${name} is still no clause: every group, both doors`, async () => { + for (const [door, native] of DOORS) { + const { engine } = await makeEngine(ROWS, native); + const rows = await engine.aggregate(OBJECT, offContract({ ...AGG_QUERY, having })); + expect(groups(rows), door).toEqual(['c1', 'c2', 'c3']); + } + }); + } +}); + +describe('[#20099] having — the walker\'s own refusals belong to the filter, not to the data', () => { + // Each row: the filter, and a row that walks the per-row evaluator INTO the + // refused arm — the floor whose words the engine-level refusal must keep. + // Before: every row answered `200 []` on an empty grouped set; the column- + // absent row answered `[]` on a populated one too (the no-value exit sat + // before the operator switch); and the `$or` row answered EVERY group, + // because its first branch held and the walk never reached the second. + const WALKER_REFUSED: ReadonlyArray Record, Record]> = [ + ['an unknown condition operator', () => ({ total: { $median: 1 } }), { total: 1 }], + ['an unknown logical operator', () => ({ $nand: [{ total: 1 }] }), { total: 1 }], + ['a retired operator', () => ({ customer_id: { $regex: 'c' } }), { customer_id: 'c1' }], + ['a retired operator with its retired sibling', () => ({ customer_id: { $regex: 'c', $options: 'i' } }), { customer_id: 'c1' }], + ['an empty $icontains', () => ({ customer_id: { $icontains: '' } }), { customer_id: 'c1' }], + ['a non-string $icontains', () => ({ customer_id: { $icontains: 5 } }), { customer_id: 'c1' }], + ['a non-$ key beside an operator', () => ({ total: { $gt: 1, foo: 2 } }), { total: 5 }], + ['an unknown operator on a column the row does not carry', () => ({ nope: { $median: 1 } }), { nope: 1 }], + ['an unknown operator behind a $or branch that already held', () => ({ $or: [{ total: { $gt: 0 } }, { total: { $median: 1 } }] }), { total: -1 }], + ['an unknown operator under $not', () => ({ $not: { total: { $median: 1 } } }), { total: 1 }], + ]; + + for (const [name, filter, floorRow] of WALKER_REFUSED) { + it(`${name}: refused on an empty and a populated set, in the walker's own words`, async () => { + const floor = syncRefusalOf(() => applyHaving([floorRow], filter() as FilterCondition)); + expect(floor, 'the per-row walker must refuse this row when it reaches it').toBeDefined(); + const message = await expectRowIndependentRefusal(offContract({ ...AGG_QUERY, having: filter() }), ROWS); + expect(message).toBe(floor!.message); + }); + } +}); + +describe('[#20099] having — a { $field } reference resolves against the aggregated row', () => { + const REF_ROWS = [ + { customer_id: 'c1', amount: 100, cap: 50, placed_on: '2026-01-10', due_on: '2026-01-05', grace: 3 }, + { customer_id: 'c1', amount: 400, cap: 10, placed_on: '2026-01-02', due_on: '2026-01-20', grace: 10 }, + { customer_id: 'c2', amount: 900, cap: 5000, placed_on: '2026-03-01', due_on: '2026-01-01', grace: 1 }, + { customer_id: 'c2', amount: 300, cap: 1, placed_on: '2026-02-01', due_on: '2026-02-01', grace: 1 }, + { customer_id: 'c2', amount: 50, cap: 2, placed_on: '2026-01-15', due_on: '2026-03-01', grace: 1 }, + { customer_id: 'c3', amount: 20, cap: 20, placed_on: '2026-02-01', due_on: '2026-01-31', grace: 0 }, + ]; + // Grouped: total max_cap last_placed first_due max_grace + // c1 500 50 2026-01-10 2026-01-05 10 + // c2 1250 5000 2026-03-01 2026-01-01 1 + // c3 20 20 2026-02-01 2026-01-31 0 + const REF_QUERY: EngineAggregateOptions = { + groupBy: ['customer_id'], + aggregations: [ + { function: 'count', alias: 'order_count' }, + { function: 'sum', field: 'amount', alias: 'total' }, + { function: 'max', field: 'cap', alias: 'max_cap' }, + { function: 'max', field: 'placed_on', alias: 'last_placed' }, + { function: 'min', field: 'due_on', alias: 'first_due' }, + { function: 'max', field: 'grace', alias: 'max_grace' }, + ], + }; + + // Before: `$eq` / `$gt` / `$gte` / `$lt` / `$lte` kept NO group and `$ne` + // kept EVERY group — the reference object itself was the comparand. + const RESOLVED: ReadonlyArray = [ + ['$gt', { total: { $gt: { $field: 'max_cap' } } }, ['c1']], + ['$gte', { total: { $gte: { $field: 'max_cap' } } }, ['c1', 'c3']], + ['$lt', { total: { $lt: { $field: 'max_cap' } } }, ['c2']], + ['$lte', { total: { $lte: { $field: 'max_cap' } } }, ['c2', 'c3']], + ['$eq', { total: { $eq: { $field: 'max_cap' } } }, ['c3']], + ['$ne', { total: { $ne: { $field: 'max_cap' } } }, ['c1', 'c2']], + ['the two-bound spelling the $between refusal prescribes', { total: { $gte: { $field: 'max_cap' }, $lte: 1000 } }, ['c1', 'c3']], + ['a groupBy projection as the referent', { customer_id: { $eq: { $field: 'customer_id' } } }, ['c1', 'c2', 'c3']], + ['under $not', { $not: { total: { $gt: { $field: 'max_cap' } } } }, ['c2', 'c3']], + ['a calendar day with no offset', { last_placed: { $lte: { $field: 'first_due' } } }, []], + ['a whole-day addDays literal', { last_placed: { $lte: { $field: 'first_due', addDays: 7 } } }, ['c1', 'c3']], + ['an addDays offset read from a column', { last_placed: { $lte: { $field: 'first_due', addDays: { $field: 'max_grace' } } } }, ['c1']], + ]; + + for (const [name, having, expected] of RESOLVED) { + it(`${name} answers ${JSON.stringify(expected)} on both doors`, async () => { + for (const [door, native] of DOORS) { + const { engine } = await makeEngine(REF_ROWS, native); + const rows = await engine.aggregate(OBJECT, { ...REF_QUERY, having }); + expect(groups(rows), door).toEqual([...expected]); + } + }); + } + + it('a missing value on either side follows the declared cross-field reading', () => { + // The in-memory evaluator the SQL family is held to: an ordering against a + // missing value is false, `$eq` is "both have none", `$ne` is its + // complement. Evaluated on rows directly — no aggregate produces them here. + const rows = [ + { g: 'a', t: 5, r: null }, + { g: 'b', t: null, r: null }, + { g: 'c', t: 5, r: 5 }, + ]; + const kept = (having: FilterCondition) => applyHaving(rows, having).map((row) => row.g); + expect(kept({ t: { $gt: { $field: 'r' } } })).toEqual([]); + expect(kept({ t: { $gte: { $field: 'r' } } })).toEqual(['c']); + expect(kept({ t: { $eq: { $field: 'r' } } })).toEqual(['b', 'c']); + expect(kept({ t: { $ne: { $field: 'r' } } })).toEqual(['a']); + }); + + it('a per-aggregation filter resolves one too, against the SOURCE row — the same walker', async () => { + // Before: `amount > { $field: 'cap' }` compared the reference object and + // counted no row in any group. The per-aggregation filter forces the + // in-memory door, whatever the driver offers. + for (const [door, native] of DOORS) { + const { engine } = await makeEngine(REF_ROWS, native); + const rows = await engine.aggregate(OBJECT, { + groupBy: ['customer_id'], + aggregations: [{ function: 'count', alias: 'over_cap', filter: { amount: { $gt: { $field: 'cap' } } } }], + }); + const counts = Object.fromEntries(rows.map((r: any) => [r.customer_id, r.over_cap])); + expect(counts, door).toEqual({ c1: 2, c2: 2, c3: 0 }); + } + }); + + // Before: the bare form was refused only when a grouped row carried the + // column (an empty set answered `[]`); every other row answered no group, or + // every group under `$nin`, whatever the data. + const REFERENCE_REFUSED: ReadonlyArray Record, string]> = [ + ['a bare reference with no operator', () => ({ total: { $field: 'max_cap' } }), 'having.total'], + ['a bare reference carrying addDays', () => ({ last_placed: { $field: 'first_due', addDays: 1 } }), 'having.last_placed'], + ['a reference as an $in member', () => ({ total: { $in: [{ $field: 'max_cap' }] } }), 'having.total.$in'], + ['a reference as a $nin member', () => ({ total: { $nin: [1, { $field: 'max_cap' }] } }), 'having.total.$nin'], + ['a reference as a $contains pattern', () => ({ customer_id: { $contains: { $field: 'customer_id' } } }), 'having.customer_id.$contains'], + ['a reference as a $startsWith pattern', () => ({ customer_id: { $startsWith: { $field: 'customer_id' } } }), 'having.customer_id.$startsWith'], + ['a reference under $exists', () => ({ total: { $exists: { $field: 'max_cap' } } }), 'having.total.$exists'], + ['a reference under $null', () => ({ total: { $null: { $field: 'max_cap' } } }), 'having.total.$null'], + ['a reference naming no column', () => ({ total: { $gt: { $field: 'nope' } } }), 'having.total.$gt'], + ['a dotted reference (a relation path, not a column)', () => ({ total: { $gt: { $field: 'order.max_cap' } } }), 'having.total.$gt'], + ['an addDays offset naming no column', () => ({ last_placed: { $lte: { $field: 'first_due', addDays: { $field: 'nope' } } } }), 'having.last_placed.$lte'], + ['a fractional addDays', () => ({ last_placed: { $lte: { $field: 'first_due', addDays: 1.5 } } }), 'having.last_placed.$lte'], + ['a string addDays', () => ({ last_placed: { $lte: { $field: 'first_due', addDays: '7' } } }), 'having.last_placed.$lte'], + ['an unresolvable reference deep in $or', () => ({ $or: [{ total: { $gt: 0 } }, { total: { $lt: { $field: 'nope' } } }] }), 'having.$or[1].total.$lt'], + ]; + + for (const [name, having, path] of REFERENCE_REFUSED) { + it(`${name}: refused at ${path}, whatever the rows`, async () => { + const message = await expectRowIndependentRefusal(offContract({ ...REF_QUERY, having: having() }), REF_ROWS); + expect(message).toContain(path); + }); + } + + it('the unresolved-reference refusal names the columns the aggregated row has', async () => { + const message = await expectRowIndependentRefusal( + offContract({ ...REF_QUERY, having: { total: { $gt: { $field: 'nope' } } } }), REF_ROWS); + for (const column of ['customer_id', 'order_count', 'total', 'max_cap', 'last_placed', 'first_due', 'max_grace']) { + expect(message).toContain(column); + } + }); +}); From 98abdf4ee3606a9c52517a7a3951a12b3206b72b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:58:09 +0000 Subject: [PATCH 3/5] chore(changeset): objectql having takes where's remaining filter doors Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx --- .changeset/20099-having-where-doors.md | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/20099-having-where-doors.md diff --git a/.changeset/20099-having-where-doors.md b/.changeset/20099-having-where-doors.md new file mode 100644 index 00000000000..4da817759b0 --- /dev/null +++ b/.changeset/20099-having-where-doors.md @@ -0,0 +1,33 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql)!: `engine.aggregate({ having })` takes the rest of the filter doors `where` takes — the comparand-type door, a check that `having` is a filter condition at all, refusals that no longer depend on the rows, and `{ $field }` references resolved against the aggregated row (#20099) + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows what `having` accepts on `engine.aggregate` (and on the REST aggregate query that forwards it there). Every refusal below is `INVALID_FILTER` / 400, raised once per query before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback. It ships as `minor` under the launch-window convention for accept-set narrowings. + +`having` took one of `where`'s filter doors, the comparand-shape face. The engine evaluates `having` itself, once per aggregated row, and that walker answered the shapes the other doors refuse — usually with no group and no error. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sqlite-wasm`, both paths, over groups with totals 500, 900 and 20 and a `max_cap` of 50, 5000 and 20: + +| you wrote in `having` | what it did before | write instead | +|:--|:--|:--| +| `{ total: { $eq: { v: 1 } } }`, `{ total: undefined }`, a `Map`, a function, a `Symbol`, a bigint beyond 2^53, or `{ $field: 5 }` as a comparand | kept no group. The same comparand in `where` is refused by the comparand-type door, and `having` now gets that door's refusal, with the path rooted at `having` | a string, number, bigint, boolean, `null` or `Date`; `{ total: { $eq: null } }` for "has no value" | +| `[['total', '>', 100]]`, `['total', '>', 100]` or `['and', …]` | kept no group: the array's index keys were read as column names | `{ total: { $gt: 100 } }`. The array form is input-only sugar declared on `where` alone, and `having` is declared as a filter condition object | +| `[]`, a string such as `'total > 100'`, a number, a boolean, a `Map` or a `Date` | kept every group, as if there were no `having` | the object form, or no `having` | +| an unknown or retired operator (`$median`, `$regex`), or an empty or non-string `$icontains` | refused only when a grouped row reached it: an empty grouped set, a condition on a column the row does not carry, or a `$or` whose earlier branch held all answered without an error | the operator the refusal names | +| `{ total: { $field: 'max_cap' } }` (a reference with no operator) | refused as an unsupported operator, again only when a grouped row carried `total` | `{ total: { $eq: { $field: 'max_cap' } } }`, or `$ne` / `$gt` / `$gte` / `$lt` / `$lte` | +| a `{ $field }` reference as an `$in` / `$nin` member, a `$contains` / `$startsWith` / `$endsWith` / `$notContains` pattern, or an `$exists` / `$null` operand | compared the reference object itself, so the answer never depended on the column it named: no group under `$in` / `$contains`, every group under `$nin` / `$notContains` / `$exists` | a literal there, or the comparison as one of the six scalar operators | +| a `{ $field }` reference naming a column the aggregated row does not have, or carrying an `addDays` that is not an integer or a `{ $field }` | kept no group | a groupBy projection or an aggregation alias of the same query (the refusal lists them); a whole-day `addDays` | + +Not refused, but answering differently: + +- **A `{ $field }` reference as the whole comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` is now resolved against the aggregated row.** Before, it was compared as an object: `{ total: { $gt: { $field: 'max_cap' } } }` kept no group, and the same reference under `$ne` kept every group. It now keeps the groups whose `total` exceeds their own `max_cap`. The two-bound spelling the `{ $field }` `$between` refusal prescribes, `{ total: { $gte: { $field: 'max_cap' }, $lte: 1000 } }`, now works on `having`. The reference names another column of the same aggregated row: a groupBy projection or an aggregation alias. The comparison is the one the platform's in-memory filter evaluator makes and the SQL cross-field compiler matches row for row: an ordering against a missing value is false, `$eq` holds when both sides have no value, and `addDays` adds whole days to a date column. +- **The same resolution applies to a per-aggregation `filter`** (`aggregations[i].filter`), which the engine evaluates with the same walker against the source rows. `{ function: 'count', filter: { amount: { $gt: { $field: 'cap' } } } }` used to count no row. It now counts the rows whose `amount` exceeds their `cap`. +- **An exact-range bigint comparand is narrowed to a number, as it is in `where`.** `{ total: { $in: [500n, 20n] } }` kept no group, because `[500n].includes(500)` is false. It now keeps the 500 and 20 groups. The caller's `having` object is not edited. + +Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills is a scalar comparison against an aggregation alias (`{ order_count: { $gt: 5 } }` and the like), which answers exactly as before. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. + +Not changed: scalars, `null` in the equality slot, `$in` / `$nin` lists, a two-bound `$between`, scalar ordering bounds, `{}`, `null` and an omitted `having`, on both paths. The `$like` / `$ilike` operators are still refused on `having` (they are staged out of `FILTER_OPERATORS`), and `$ne` with a list is still answered until the shared face judges it. A `having` key that names no column still keeps no group rather than being refused. From 01f091f5234c4152c61f749c6828bde64743dd73 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:58:02 +0000 Subject: [PATCH 4/5] chore(changeset): state rows 1 and 7's before-answers per operator, measured at base Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx --- .changeset/20099-having-where-doors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/20099-having-where-doors.md b/.changeset/20099-having-where-doors.md index 4da817759b0..fceadc46d2c 100644 --- a/.changeset/20099-having-where-doors.md +++ b/.changeset/20099-having-where-doors.md @@ -14,13 +14,13 @@ Clause-②: no (narrowing) | you wrote in `having` | what it did before | write instead | |:--|:--|:--| -| `{ total: { $eq: { v: 1 } } }`, `{ total: undefined }`, a `Map`, a function, a `Symbol`, a bigint beyond 2^53, or `{ $field: 5 }` as a comparand | kept no group. The same comparand in `where` is refused by the comparand-type door, and `having` now gets that door's refusal, with the path rooted at `having` | a string, number, bigint, boolean, `null` or `Date`; `{ total: { $eq: null } }` for "has no value" | +| `{ total: { $eq: { v: 1 } } }`, `{ total: undefined }`, a `Map`, a function, a `Symbol`, a bigint beyond 2^53, or `{ $field: 5 }` as a comparand | depended on the operator. On the numeric `total`: in the implicit slot (the non-object values) and under `$eq`, `$gt` and `$gte` it kept no group; under `$ne` it kept every group; under `$lt` / `$lte` it kept no group, except the bigint, which kept every group. A `Symbol` under an ordering operator threw a raw `TypeError` with no `code` and no `status` on a populated grouped set. The same comparand in `where` is refused by the comparand-type door, and `having` now gets that door's refusal, with the path rooted at `having` | a string, number, bigint, boolean, `null` or `Date`; `{ total: { $eq: null } }` for "has no value" | | `[['total', '>', 100]]`, `['total', '>', 100]` or `['and', …]` | kept no group: the array's index keys were read as column names | `{ total: { $gt: 100 } }`. The array form is input-only sugar declared on `where` alone, and `having` is declared as a filter condition object | | `[]`, a string such as `'total > 100'`, a number, a boolean, a `Map` or a `Date` | kept every group, as if there were no `having` | the object form, or no `having` | | an unknown or retired operator (`$median`, `$regex`), or an empty or non-string `$icontains` | refused only when a grouped row reached it: an empty grouped set, a condition on a column the row does not carry, or a `$or` whose earlier branch held all answered without an error | the operator the refusal names | | `{ total: { $field: 'max_cap' } }` (a reference with no operator) | refused as an unsupported operator, again only when a grouped row carried `total` | `{ total: { $eq: { $field: 'max_cap' } } }`, or `$ne` / `$gt` / `$gte` / `$lt` / `$lte` | | a `{ $field }` reference as an `$in` / `$nin` member, a `$contains` / `$startsWith` / `$endsWith` / `$notContains` pattern, or an `$exists` / `$null` operand | compared the reference object itself, so the answer never depended on the column it named: no group under `$in` / `$contains`, every group under `$nin` / `$notContains` / `$exists` | a literal there, or the comparison as one of the six scalar operators | -| a `{ $field }` reference naming a column the aggregated row does not have, or carrying an `addDays` that is not an integer or a `{ $field }` | kept no group | a groupBy projection or an aggregation alias of the same query (the refusal lists them); a whole-day `addDays` | +| a `{ $field }` reference naming a column the aggregated row does not have, or carrying an `addDays` that is not an integer or a `{ $field }` | compared the reference object itself, so the answer depended on the operator: no group under `$eq`, `$gt` and `$gte`; every group under `$ne`; under `$lt` / `$lte`, every group against a text or date column (the object compares as the text `[object Object]`) and no group against a number column | a groupBy projection or an aggregation alias of the same query (the refusal lists them); a whole-day `addDays` | Not refused, but answering differently: From f08f8953fca34b5ae8f59eebfeab5fea52072862 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:36:20 +0000 Subject: [PATCH 5/5] chore(changeset): row 7's before-answer follows each value's string order on a text column, measured on lowercase, uppercase and mixed text Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx --- .changeset/20099-having-where-doors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20099-having-where-doors.md b/.changeset/20099-having-where-doors.md index fceadc46d2c..a94697169c1 100644 --- a/.changeset/20099-having-where-doors.md +++ b/.changeset/20099-having-where-doors.md @@ -20,7 +20,7 @@ Clause-②: no (narrowing) | an unknown or retired operator (`$median`, `$regex`), or an empty or non-string `$icontains` | refused only when a grouped row reached it: an empty grouped set, a condition on a column the row does not carry, or a `$or` whose earlier branch held all answered without an error | the operator the refusal names | | `{ total: { $field: 'max_cap' } }` (a reference with no operator) | refused as an unsupported operator, again only when a grouped row carried `total` | `{ total: { $eq: { $field: 'max_cap' } } }`, or `$ne` / `$gt` / `$gte` / `$lt` / `$lte` | | a `{ $field }` reference as an `$in` / `$nin` member, a `$contains` / `$startsWith` / `$endsWith` / `$notContains` pattern, or an `$exists` / `$null` operand | compared the reference object itself, so the answer never depended on the column it named: no group under `$in` / `$contains`, every group under `$nin` / `$notContains` / `$exists` | a literal there, or the comparison as one of the six scalar operators | -| a `{ $field }` reference naming a column the aggregated row does not have, or carrying an `addDays` that is not an integer or a `{ $field }` | compared the reference object itself, so the answer depended on the operator: no group under `$eq`, `$gt` and `$gte`; every group under `$ne`; under `$lt` / `$lte`, every group against a text or date column (the object compares as the text `[object Object]`) and no group against a number column | a groupBy projection or an aggregation alias of the same query (the refusal lists them); a whole-day `addDays` | +| a `{ $field }` reference naming a column the aggregated row does not have, or carrying an `addDays` that is not an integer or a `{ $field }` | compared the reference object itself, so the answer depended on the operator: no group under `$eq`; every group under `$ne`; under an ordering operator, no group against a number column, and against a text or date column an answer that follows each value's string order against the text `[object Object]` | a groupBy projection or an aggregation alias of the same query (the refusal lists them); a whole-day `addDays` | Not refused, but answering differently: