diff --git a/.changeset/19974-having-comparand-shape-face.md b/.changeset/19974-having-comparand-shape-face.md index b934433aa27..9a40646a3b2 100644 --- a/.changeset/19974-having-comparand-shape-face.md +++ b/.changeset/19974-having-comparand-shape-face.md @@ -27,4 +27,4 @@ The gate is ONE call in `engine.aggregate`, ahead of both `having` evaluations, 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 (`{ order_count: { $gt: 5 } }` and the like), and none authors a refused shape. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. -Not changed: scalars, `null` in the equality slot (the has-no-value predicate), `$in` / `$nin` lists including the empty list, a two-bound `$between`, and scalar ordering bounds all answer exactly as before, on both paths. `$ne` with a list is not judged by the face yet, so `having` still answers it. Neither the comparand-TYPE door nor the unknown-field and declared-type gates that `where` also passes are run on `having`; this change adds the comparand-shape face only. +Not changed: scalars, `null` in the equality slot (the has-no-value predicate), `$in` / `$nin` lists including the empty list, a two-bound `$between`, and scalar ordering bounds all answer exactly as before, on both paths. `$ne` with a list is not judged by the face yet, so `having` still answers it. Neither the comparand-TYPE door nor the unknown-field and declared-type gates that `where` also passes are run on `having` by this change, which adds the comparand-shape face only (the comparand-TYPE door, #20099, and the temporal-comparand door, #20263, reach `having` in the same release). diff --git a/.changeset/20240-date-year-four-digits.md b/.changeset/20240-date-year-four-digits.md index 7ec207b61e3..7dc31be7785 100644 --- a/.changeset/20240-date-year-four-digits.md +++ b/.changeset/20240-date-year-four-digits.md @@ -35,4 +35,4 @@ What changes: **Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year. -**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included; every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 still reads back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch. `having` does not reach the temporal-comparand door for any comparand, so a number or `Date` outside 0..9999 there is still compared as written. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included; every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 still reads back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. diff --git a/.changeset/20263-having-temporal-comparand-door.md b/.changeset/20263-having-temporal-comparand-door.md new file mode 100644 index 00000000000..5766e23d76e --- /dev/null +++ b/.changeset/20263-having-temporal-comparand-door.md @@ -0,0 +1,41 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql)!: `having` on `engine.aggregate` takes the temporal-comparand door `where` and the per-aggregation `filter` take, so a comparand its aggregated column cannot read is refused `INVALID_FILTER` / 400 instead of keeping no group or every group (#20263) + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A comparand the aggregated column's storage rule cannot read used to answer 200; it is now refused with `INVALID_FILTER` / 400, once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback, on an empty and on a populated object. It ships as `minor` under the launch-window convention for accept-set narrowings. + +Measured through `engine.aggregate` and `POST /data/:object/query`, on `driver-memory`, `driver-sql` on SQLite and `driver-sql` on PostgreSQL 16, on both paths, with `groupBy` customer over four groups. The three drivers gave the same answer in every cell: + +| `having` | before | now | its `where` twin | +|:--|:--|:--|:--| +| `{ last_placed: { $lt: 'not-a-date' } }`, `last_placed` = `max(placed_on)` of a `date` field | 200, every group | 400 | 400 | +| the same under `$gt` | 200, no group | 400 | 400 | +| `'+010000-01-01T00:00:00.000Z'` on the same column, `$gt` | 200, every group | 400 | 400 | +| the number for 10000-01-01 on the same column, `$gt` (and its `Date`, in-process) | 200, every group | 400 | 400 | +| `'not-a-date'` on `min` of a `datetime` field or `max` of a `time` field, `$lt` | 200, every group | 400 | 400 | +| `'not-a-date'` on a groupBy key that is a `date` or `datetime` field or on a `day` bucket, `'noon'` on one that is a `time` field, `$gt` | 200, no group | 400 | 400 | +| the number for 10000-01-01 on a `day` bucket, `$gt` | 200, every group | 400 | 400 | + +Each one read the object once before; each is now refused with no read. + +What is judged: + +- The same walk and the same predicate, `isUninterpretableTemporalComparand` in `@objectstack/core`, that the door runs on `where` and on each per-aggregation `filter`. A change to that rule reaches `having` with it. +- The kind is the aggregated column's class, the one the `addDays` rule already reads: `min` / `max` of a `date`, `datetime` or `time` field keeps that kind, a groupBy projection of such a field takes its kind, and a `day` bucket is a `date`. `count`, `count_distinct`, `sum` and `avg`, a `week` / `month` / `quarter` / `year` bucket, and every other column are not temporal, so they are not judged. +- Every comparison and set operator's comparand, each `$in` / `$nin` member and `$between` endpoint, and the implicit-equality slot, under `$and`, `$or` and `$not`. As on `where`, a `{placeholder}` string, the empty string, `null` and a `{ $field }` reference are not judged. `having` does not resolve placeholders, and did not before, so the refusal's remedy names none. +- The text operators (`$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$icontains`) are not judged. On `where` the text-operator declared-type door answers them first, and that door does not front `having`. +- The door runs after every other `having` door, so a clause one of them refuses (an unknown operator, a key naming no column, a comparand of no comparable type, an `addDays` pair, an array in the equality slot) keeps that refusal and its words. + +The refusal follows the `where` door's words. It names the `having` path, the column, what the column aggregates and its kind, and the comparand, for example: `` `having` on 'last_placed' (max(placed_on), a date column) compares against "not-a-date" at having.last_placed.$lt ``. Like the `where` refusal, it names the column's kind, and otherwise only what the query carries. + +**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 compares a numeric aggregation alias, which is not judged. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. + +**Fix.** Compare a `date` column with a `YYYY-MM-DD` day, a `datetime` column with an ISO-8601 instant, a bare day or epoch milliseconds, and a `time` column with an `HH:MM` or `HH:MM:SS` wall clock. + +**Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` and per-aggregation `filter` answer; every `having` on a temporal column whose comparand the rule reads (a `YYYY-MM-DD` day, an ISO instant, an epoch-millisecond number or string, an in-range `Date`, a zone-naive instant, a wall clock, an extended-year instant on a `datetime` column, which that rule reads); `{today}`-style placeholders, known or not; the empty and the whitespace-only string; `null`, `$exists`, `$in` / `$nin`, `$between`, `$not` / `$or` / `$and` and `{ $field }` references; `$contains` and `$startsWith`; every `count` / `sum` / `avg` column, a string comparand included; a `month` bucket; and every existing `having` refusal, in its words. diff --git a/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts b/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts new file mode 100644 index 00000000000..4574f2bb242 --- /dev/null +++ b/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20263] `having` takes the temporal-comparand door `where` (#8690) and the +// per-aggregation `filter` (#20148) take: the same walk, the same +// `@objectstack/core` predicate (`isUninterpretableTemporalComparand`), with +// each aggregated column's kind read from the class #20127 derives — +// `min` / `max` of a temporal field keeps its kind, a groupBy projection takes +// its field's, a `day` bucket is a `date`, and `count` / `sum` / `avg` are not +// temporal. +// +// Measured on the base (`89f87f2344`) through `engine.aggregate` and +// `POST /api/v1/data/:object/query`, on InMemoryDriver, SqlDriver on SQLite and +// SqlDriver on PostgreSQL 16, on both `having` paths, four groups c1–c4: +// +// | `having` | base, all three drivers, both paths | its `where` twin | +// |:--|:--|:--| +// | `{ last_placed: { $lt: 'not-a-date' } }` on `max(placed_on)` | 200, keeps c1–c4 | 400 | +// | the same under `$gt` | 200, keeps no group | 400 | +// | `'+010000-01-01T00:00:00.000Z'` on `max(placed_on)`, `$gt` | 200, keeps c1–c4 | 400 | +// | the number for 10000-01-01 on `max(placed_on)`, `$gt` | 200, keeps c1–c4 | 400 | +// | `'not-a-date'` on `min(opened_at)` / `max(slot)`, `$lt` | 200, keeps c1–c4 | 400 | +// | `'not-a-date'` on a `placed_on` groupBy key or a `day` bucket | 200, keeps no group | 400 | +// +// Each read the driver once. Now each is refused `INVALID_FILTER` / 400 before +// any driver is asked for a row, on both paths. The drivers are not the +// question — no driver reads `having` — so this file holds the engine to it +// with a counting driver of each path's shape; the REST door is held over a +// real SqlDriver in `packages/rest/src/data-query-having-temporal-door.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { isUninterpretableTemporalComparand, type TemporalComparandKind } from '@objectstack/core'; +import type { EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; +import { applyInMemoryAggregation } from './in-memory-aggregation.js'; + +const OBJECT = 'ledger_order'; + +const FIELDS = { + customer_id: { type: 'text' }, + amount: { type: 'number' }, + placed_on: { type: 'date' }, + opened_at: { type: 'datetime' }, + slot: { type: 'time' }, +}; + +// In the storage form every driver presents a row in (ADR-0053). +const ROWS = [ + { id: 'o1', customer_id: 'c1', amount: 100, placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z', slot: '09:00:00' }, + { id: 'o2', customer_id: 'c1', amount: 400, placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z', slot: '10:30:00' }, + { id: 'o3', customer_id: 'c2', amount: 900, placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z', slot: '11:00:00' }, + { id: 'o4', customer_id: 'c2', amount: 300, placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z', slot: '12:00:00' }, + { id: 'o5', customer_id: 'c3', amount: 50, placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z', slot: '13:00:00' }, + { id: 'o6', customer_id: 'c4', amount: 20, placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z', slot: '14:00:00' }, +]; + +// c1: last_placed 2026-01-10 · first_opened 2026-01-01T10:00Z · last_slot 10:30:00 · total 500 +// c2: last_placed 2026-03-01 · first_opened 2026-02-01T10:00Z · last_slot 12:00:00 · total 1200 +// c3: last_placed 2026-01-15 · first_opened 2026-02-06T10:00Z · last_slot 13:00:00 · total 50 +// c4: last_placed 2026-02-01 · first_opened 2026-03-01T10:00Z · last_slot 14:00:00 · total 20 +const AGGREGATIONS: NonNullable = [ + { function: 'max', field: 'placed_on', alias: 'last_placed' }, + { function: 'min', field: 'opened_at', alias: 'first_opened' }, + { function: 'max', field: 'slot', alias: 'last_slot' }, + { function: 'sum', field: 'amount', alias: 'total' }, + { function: 'count', alias: 'n' }, + { function: 'avg', field: 'amount', alias: 'mean' }, +]; + +const Y10000 = 253402300800000; // +010000-01-01T00:00:00.000Z + +type Path = 'native' | 'rows'; + +/** + * The two `having` paths. `native`: the driver aggregates and the engine + * applies `having` to what it returns. `rows`: the engine asks for rows and + * aggregates them itself (a filtered aggregation forces that path). Both count + * every read of the object. + */ +function makeDriver(path: Path, rows: ReadonlyArray>) { + const reads = { aggregate: 0, find: 0 }; + const driver: any = { + name: `${path}-recorder`, + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find() { reads.find += 1; return rows.map((r) => ({ ...r })); }, + async findOne() { return null; }, + async create(_o: string, d: any) { return d; }, + async update(_o: string, _id: string, d: any) { return d; }, + async delete() { return true; }, + async count() { return rows.length; }, + async bulkCreate(_o: string, r: any[]) { return r; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + if (path === 'native') { + driver.aggregate = async (_o: string, ast: any) => { reads.aggregate += 1; return applyInMemoryAggregation([...rows], ast); }; + } + return { driver, reads }; +} + +async function makeEngine(path: Path, rows: ReadonlyArray>) { + const { driver, reads } = makeDriver(path, rows); + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + (engine.registry as any).registerObject({ name: OBJECT, fields: FIELDS }); + return { engine, reads }; +} + +function query(path: Path, having: unknown, groupBy: EngineAggregateOptions['groupBy'] = ['customer_id']): EngineAggregateOptions { + const aggregations = path === 'native' + ? AGGREGATIONS + : [...AGGREGATIONS, { function: 'count' as const, alias: 'fb', filter: { customer_id: { $ne: '' } } }]; + return { groupBy, aggregations, having: having as FilterCondition }; +} + +interface Refusal extends Error { code?: unknown; status?: unknown } + +async function outcome(run: () => Promise): Promise<{ rows?: any[]; err?: Refusal }> { + try { + return { rows: (await run()) as any[] }; + } catch (e) { + return { err: e as Refusal }; + } +} + +/** + * Refused on both paths, on an empty and a populated object, with no read of + * the object and one message per path — one message across the two paths as + * well unless `acrossPaths` is false (a refusal that lists the aggregated + * row's columns names the rows path's extra aggregation). Returns the native + * path's message. + */ +async function expectHavingRefusal( + having: () => unknown, + groupBy?: EngineAggregateOptions['groupBy'], + acrossPaths = true, +): Promise { + const messages: Partial> = {}; + for (const path of ['native', 'rows'] as const) { + for (const [population, rows] of [['empty', []], ['populated', ROWS]] as const) { + const cell = `${path}, ${population}`; + const { engine, reads } = await makeEngine(path, rows); + const { err } = await outcome(() => engine.aggregate(OBJECT, query(path, having(), groupBy))); + expect(err, cell).toBeInstanceOf(Error); + expect(err!.code, cell).toBe('INVALID_FILTER'); + expect(err!.status, cell).toBe(400); + expect(reads, cell).toEqual({ aggregate: 0, find: 0 }); + messages[path] ??= err!.message; + expect(err!.message, cell).toBe(messages[path]); + } + } + if (acrossPaths) expect(messages.rows).toBe(messages.native); + return messages.native!; +} + +/** The groups a `having` keeps, identical on both paths. */ +async function keptGroups(having: unknown, key = 'customer_id', groupBy?: EngineAggregateOptions['groupBy']): Promise { + let kept: string[] | undefined; + for (const path of ['native', 'rows'] as const) { + const { engine, reads } = await makeEngine(path, ROWS); + const rows = await engine.aggregate(OBJECT, query(path, having, groupBy)); + expect(reads.aggregate + reads.find, path).toBe(1); + const got = rows.map((r: any) => String(r[key])).sort(); + kept ??= got; + expect(got, path).toEqual(kept); + } + return kept!; +} + +/** The same comparand in a `where` on the aggregated field — the twin. */ +async function whereTwinOf(where: Record): Promise<{ err?: Refusal; reads: number }> { + const { engine, reads } = await makeEngine('rows', ROWS); + const { err } = await outcome(() => engine.find(OBJECT, { where: where as FilterCondition })); + return { err, reads: reads.find }; +} + +describe('[#20263] having — a comparand its column cannot read is refused before any read, as its where twin is', () => { + // name · having · the where twin · what the message names (column, source, kind, value, path) + const REFUSED: ReadonlyArray Record, Record, readonly string[]]> = [ + ['the card\'s row: "not-a-date" $lt on max(date), which kept every group', + () => ({ last_placed: { $lt: 'not-a-date' } }), { placed_on: { $lt: 'not-a-date' } }, + ["`having` on 'last_placed' (max(placed_on), a date column)", '"not-a-date"', 'at having.last_placed.$lt', 'not a date value']], + ['"not-a-date" $gt on max(date), which kept no group', + () => ({ last_placed: { $gt: 'not-a-date' } }), { placed_on: { $gt: 'not-a-date' } }, + ['at having.last_placed.$gt']], + ['an extended-year ISO string on max(date)', + () => ({ last_placed: { $gt: '+010000-01-01T00:00:00.000Z' } }), { placed_on: { $gt: '+010000-01-01T00:00:00.000Z' } }, + ['"+010000-01-01T00:00:00.000Z"', 'not a date value']], + ['the number for 10000-01-01 on max(date), in the year class\'s own words', + () => ({ last_placed: { $gt: Y10000 } }), { placed_on: { $gt: Y10000 } }, + ['253402300800000', 'outside the years 0000 to 9999', 'keep the wrong groups']], + ['the Date for 10000-01-01 on max(date)', + () => ({ last_placed: { $lt: new Date(Y10000) } }), { placed_on: { $lt: new Date(Y10000) } }, + ['Date +010000-01-01T00:00:00.000Z', 'outside the years 0000 to 9999']], + ['"not-a-date" on min(datetime)', + () => ({ first_opened: { $lt: 'not-a-date' } }), { opened_at: { $lt: 'not-a-date' } }, + ["`having` on 'first_opened' (min(opened_at), a datetime column)", 'not a datetime value']], + ['a preset name on min(datetime)', + () => ({ first_opened: { $gte: 'last_30_days' } }), { opened_at: { $gte: 'last_30_days' } }, + ['"last_30_days"']], + ['"noon" on max(time)', + () => ({ last_slot: { $gt: 'noon' } }), { slot: { $gt: 'noon' } }, + ["`having` on 'last_slot' (max(slot), a time column)", 'not a time value']], + ['an $in member', () => ({ last_placed: { $in: ['2026-02-01', 'not-a-date'] } }), { placed_on: { $in: ['2026-02-01', 'not-a-date'] } }, + ['at having.last_placed.$in[1]']], + ['a $nin member', () => ({ last_placed: { $nin: ['not-a-date'] } }), { placed_on: { $nin: ['not-a-date'] } }, + ['at having.last_placed.$nin[0]']], + ['a $between endpoint', () => ({ last_placed: { $between: ['2026-01-01', 'not-a-date'] } }), { placed_on: { $between: ['2026-01-01', 'not-a-date'] } }, + ['at having.last_placed.$between[1]']], + ['the implicit-equality slot', () => ({ last_placed: 'not-a-date' }), { placed_on: 'not-a-date' }, + ['at having.last_placed,']], + ['behind a $or branch that holds', () => ({ $or: [{ total: { $gt: 0 } }, { last_placed: { $gt: 'not-a-date' } }] }), + { $or: [{ amount: { $gt: 0 } }, { placed_on: { $gt: 'not-a-date' } }] }, + ['at having.$or[1].last_placed.$gt']], + ['under $not', () => ({ $not: { last_placed: { $gt: 'not-a-date' } } }), { $not: { placed_on: { $gt: 'not-a-date' } } }, + ['at having.$not.last_placed.$gt']], + ]; + + for (const [name, having, where, named] of REFUSED) { + it(`${name}: INVALID_FILTER / 400 on both paths, empty or populated, no read — and the where twin agrees`, async () => { + const message = await expectHavingRefusal(having); + expect(message.startsWith(`aggregate('${OBJECT}'): `)).toBe(true); + for (const part of named) expect(message).toContain(part); + expect(message).toContain('The `having` was NOT applied.'); + const twin = await whereTwinOf(where); + expect(twin.err?.code).toBe('INVALID_FILTER'); + expect(twin.err?.status).toBe(400); + expect(twin.reads).toBe(0); + }); + } + + it('a groupBy key that is itself a date field is judged as a date column', async () => { + const message = await expectHavingRefusal(() => ({ placed_on: { $gt: 'not-a-date' } }), ['placed_on']); + expect(message).toContain("`having` on 'placed_on' (the groupBy field placed_on, a date column)"); + }); + + it('a day bucket is judged as a date column, whatever the field it buckets', async () => { + const message = await expectHavingRefusal( + () => ({ d: { $gt: 'not-a-date' } }), + [{ field: 'opened_at', dateGranularity: 'day', alias: 'd' }], + ); + expect(message).toContain("`having` on 'd' (the day bucket of opened_at, a date column)"); + // The number for 10000-01-01 kept every day at the base: a date column's year rule applies. + await expectHavingRefusal(() => ({ d: { $gt: Y10000 } }), [{ field: 'opened_at', dateGranularity: 'day', alias: 'd' }]); + }); + + it('the remedy names no placeholder: having resolves none, so it would send the author to a literal', async () => { + for (const having of [{ last_placed: { $lt: 'x' } }, { first_opened: { $lt: 'x' } }]) { + expect(await expectHavingRefusal(() => having)).not.toContain('{30_days_ago}'); + } + }); +}); + +describe('[#20263] having — one predicate: refused exactly when @objectstack/core calls the comparand uninterpretable, as where is', () => { + const COLUMNS: ReadonlyArray = [ + ['date', 'last_placed', 'placed_on'], + ['datetime', 'first_opened', 'opened_at'], + ['time', 'last_slot', 'slot'], + ]; + const VALUES: readonly unknown[] = [ + 'not-a-date', '2026-02-01', '2026-02-01T10:00:00.000Z', '2026-02-01 10:00', '+010000-01-01T00:00:00.000Z', + '10:00', '10:00:00', '25:00', 'last_30_days', '1769940000000', '{today}', '{not_a_token}', '', ' ', + 1769940000000, Y10000, -62198755200000, new Date(1769940000000), new Date(Y10000), null, true, + ]; + for (const [kind, column, field] of COLUMNS) { + it(`${kind}: ${column} and its where twin on ${field}, over ${VALUES.length} comparands`, async () => { + for (const value of VALUES) { + const expected = isUninterpretableTemporalComparand(kind, value); + const label = `${kind} ${String(value instanceof Date ? value.toISOString() : JSON.stringify(value))}`; + const { engine, reads } = await makeEngine('native', ROWS); + const having = await outcome(() => engine.aggregate(OBJECT, query('native', { [column]: { $eq: value } }))); + expect(having.err?.code === 'INVALID_FILTER', `having, ${label}`).toBe(expected); + if (expected) expect(reads.aggregate, label).toBe(0); + const twin = await whereTwinOf({ [field]: { $eq: value } }); + // A `{placeholder}` the resolver does not know is refused on `where` + // one layer down, by its own code — not this door's verdict. + const twinRefusedByDoor = twin.err?.code === 'INVALID_FILTER'; + expect(twinRefusedByDoor, `where twin, ${label}`).toBe(expected); + } + }); + } +}); + +describe('[#20263] having — what the door leaves alone answers exactly as before', () => { + // name · having · groups kept — each measured identical on the base, all three drivers, both paths + const UNCHANGED: ReadonlyArray = [ + ['a 2026 day $gt on max(date) (the control)', { last_placed: { $gt: '2026-02-01' } }, ['c2']], + ['a 2026 day $lt on max(date)', { last_placed: { $lt: '2026-02-01' } }, ['c1', 'c3']], + ['an ISO instant on max(date)', { last_placed: { $gte: '2026-02-01T00:00:00.000Z' } }, ['c2', 'c4']], + ['an in-range number on max(date)', { last_placed: { $gt: 1769940000000 } }, ['c2']], + ['an in-range Date on max(date)', { last_placed: { $gt: new Date(1769940000000) } }, ['c2']], + ['a 2026 instant on min(datetime)', { first_opened: { $gt: '2026-02-01T00:00:00.000Z' } }, ['c2', 'c3', 'c4']], + ['a bare day as the upper bound of min(datetime)', { first_opened: { $lte: '2026-02-01' } }, ['c1', 'c2']], + // The `datetime` rule reads an extended-year instant, so the predicate calls + // it interpretable and its `where` twin is not refused by the door either. + // Its text orders below every four-digit year, so `$lt` keeps no group, on + // `having` as before; which years a comparand may name is #20264's to + // decide, in the predicate, and `having` follows it with no second edit. + ['an extended-year ISO on min(datetime) — read by the datetime rule', { first_opened: { $lt: '+010000-01-01T00:00:00.000Z' } }, []], + ['a wall clock on max(time)', { last_slot: { $gte: '12:00' } }, ['c2', 'c3', 'c4']], + ['the number for 10000-01-01 on max(time) — not judged on time', { last_slot: { $gt: Y10000 } }, []], + ['a string on sum — not temporal', { total: { $gt: 'not-a-date' } }, []], + ['a string on count — not temporal', { n: { $gt: 'not-a-date' } }, []], + ['a string on avg — not temporal', { mean: { $gt: 'not-a-date' } }, []], + ['a {placeholder} is stepped around, as on where', { last_placed: { $lte: '{today}' } }, ['c1', 'c2', 'c3', 'c4']], + ['an unknown {placeholder} too', { last_placed: { $gte: '{not_a_token}' } }, []], + ['the empty string (its own card)', { last_placed: { $gt: '' } }, ['c1', 'c2', 'c3', 'c4']], + ['null in the equality slot', { last_placed: null }, []], + ['$exists', { last_placed: { $exists: true } }, ['c1', 'c2', 'c3', 'c4']], + ['$in of days', { last_placed: { $in: ['2026-02-01', '2026-03-01'] } }, ['c2', 'c4']], + ['$between of days', { last_placed: { $between: ['2026-01-01', '2026-02-01'] } }, ['c1', 'c3', 'c4']], + ['$not', { $not: { last_placed: { $gt: '2026-02-01' } } }, ['c1', 'c3', 'c4']], + ['$or', { $or: [{ total: { $gt: 1000 } }, { last_placed: { $lt: '2026-01-12' } }] }, ['c1', 'c2']], + ['a { $field } reference', { last_placed: { $gte: { $field: 'last_placed' } } }, ['c1', 'c2', 'c3', 'c4']], + // #15661: a text operator on a temporal column stays beneath every door on `having`. + ['$contains on max(date) — a text operator, not judged', { last_placed: { $contains: '2026' } }, ['c1', 'c2', 'c3', 'c4']], + ['$startsWith on max(date) — a text operator, not judged', { last_placed: { $startsWith: 'not-a-date' } }, []], + ]; + for (const [name, having, kept] of UNCHANGED) { + it(`${name}: keeps ${kept.join(', ') || 'no group'} on both paths`, async () => { + expect(await keptGroups(having)).toEqual(kept); + }); + } + + it('a coarser bucket is a text label, and is not judged', async () => { + const kept = await keptGroups( + { m: { $gt: 'not-a-date' } }, + 'm', + [{ field: 'opened_at', dateGranularity: 'month', alias: 'm' }], + ); + expect(kept).toEqual([]); + }); +}); + +describe('[#20263] having — a clause another door refuses keeps that refusal and its words', () => { + // Each carries an uninterpretable temporal comparand AND the shape an earlier + // `having` door refuses; the earlier door answers, in its own words. + const EARLIER: ReadonlyArray unknown, string]> = [ + ['an unknown operator (#20099)', () => ({ last_placed: { $median: 'not-a-date' } }), "Unsupported operator '$median' in `having`"], + ['a key naming no column (#20123)', () => ({ totl: { $gt: 1 }, last_placed: { $lt: 'not-a-date' } }), "`having` filters on 'totl' at having.totl"], + ['a comparand of no comparable type (#20099)', () => ({ total: { $eq: { v: 1 } }, last_placed: { $lt: 'not-a-date' } }), 'is a plain object'], + ['an addDays pair on numeric columns (#20127)', () => ({ total: { $gt: { $field: 'total', addDays: 1 } }, last_placed: { $lt: 'not-a-date' } }), 'addDays adds whole days'], + ['an array in the equality slot (#19974)', () => ({ last_placed: ['not-a-date'] }), 'requires a single comparable value'], + ]; + for (const [name, having, words] of EARLIER) { + it(`${name}: refused in that door's words, not this one's`, async () => { + const message = await expectHavingRefusal(having, undefined, false); + expect(message).toContain(words); + expect(message).not.toContain('which is not a date value this platform can interpret'); + }); + } +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 8f21a12acbb..9bb4a1fd172 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -43,7 +43,10 @@ import { MAX_BULK_PER_ROW_HOOK_ROWS, resolveBulkPerRowHookBudget } from '@object // stores and the engine-held projection the dispatch doors consult. import { ActionActivationProjection, type ActionActivationRow, type ActionActivationStore } from './action-activation.js'; import { assertListComparandShapes, assertFilterIsMaterializable, invalidFilterError } from './filter-comparand-shape.js'; -import { assertTemporalComparandsInterpretable } from './temporal-comparand-door.js'; +import { + assertHavingTemporalComparandsInterpretable, + assertTemporalComparandsInterpretable, +} from './temporal-comparand-door.js'; import { assertTextOperatorTargetsAreStringCapable } from './text-operator-declared-type-door.js'; // Seek pagination for the walks that must read EVERY row — the autonumber seed // scan is one (#6249). Shared with `summary-backfill` rather than re-rolled: @@ -16389,6 +16392,19 @@ export class ObjectQL implements IObjectQLEngine { aggregatedRowColumns(query.groupBy, query.aggregations), havingColumnClasses, ); + // [#20263] …and last, the TEMPORAL-comparand door `where` (#8690) and + // the per-aggregation `filter` (#20148) take: the same walk and the + // same `@objectstack/core` predicate, with each column's kind read + // from the class #20127 derived above (`min` / `max` of a temporal + // field keeps its kind, a `day` bucket is a date, `count` / `sum` / + // `avg` are not temporal). A comparand the column's storage rule + // cannot read (`'not-a-date'`, a `date` year outside 0..9999) was + // compared as written and kept no group or every group, on both + // `applyHaving` doors, while its `where` twin answered 400. After + // every other `having` door, so a clause one of them refuses keeps + // that refusal's words; on the caller's own clause, before the + // bigint narrowing, which is what `where`'s object form judges. + assertHavingTemporalComparandsInterpretable(object, query.having, havingColumnClasses, query); if (having !== query.having) query = { ...query, having }; } const driver = this.getDriver(object); diff --git a/packages/objectql/src/having-filter.ts b/packages/objectql/src/having-filter.ts index 77400359cc9..a0e3b850859 100644 --- a/packages/objectql/src/having-filter.ts +++ b/packages/objectql/src/having-filter.ts @@ -722,9 +722,11 @@ export function declaredFieldClasses(fields: unknown): Map TemporalComparandKind | null; + judgesOperator: (op: string) => boolean; +} + /** * A plain object — filter STRUCTURE rather than a comparand. Same * classification the #5869 gate and `driver-memory`'s own gate make: a `Date` @@ -172,30 +212,51 @@ export function findUninterpretableTemporalComparand( // see — the same early return `assertFilterIsMaterializable` makes. const fields = (schema as { fields?: Record } | undefined)?.fields; if (!fields || typeof fields !== 'object') return null; + return walkCondition( + { + kindOf: (key) => temporalComparandKind((fields[key] as { type?: unknown } | undefined)?.type), + judgesOperator: () => true, + }, + where, + path, + depth, + ); +} + +/** + * The walk itself, shared by every position: the node structure is judged the + * same way wherever the condition sits; only the {@link WalkScope} differs. + */ +function walkCondition( + scope: WalkScope, + node: unknown, + path: string, + depth: number, +): UninterpretableTemporalComparand | null { if (depth > 32) return null; - if (!isFilterNode(where)) return null; + if (!isFilterNode(node)) return null; - for (const [key, value] of Object.entries(where)) { + for (const [key, value] of Object.entries(node)) { const here = `${path}.${key}`; if (key === '$and' || key === '$or') { if (Array.isArray(value)) { for (const [index, arm] of value.entries()) { - const hit = findUninterpretableTemporalComparand(schema, arm, `${here}[${index}]`, depth + 1); + const hit = walkCondition(scope, arm, `${here}[${index}]`, depth + 1); if (hit) return hit; } } continue; } if (key === '$not') { - const hit = findUninterpretableTemporalComparand(schema, value, here, depth + 1); + const hit = walkCondition(scope, value, here, depth + 1); if (hit) return hit; continue; } if (key.startsWith('$')) continue; if (key.includes('.')) continue; - const kind = temporalComparandKind((fields[key] as { type?: unknown } | undefined)?.type); + const kind = scope.kindOf(key); if (!kind) continue; - const hit = judgeFieldComparands(kind, key, value, here); + const hit = judgeFieldComparands(kind, key, value, here, scope.judgesOperator); if (hit) return hit; } return null; @@ -207,6 +268,7 @@ function judgeFieldComparands( field: string, spec: unknown, path: string, + judgesOperator: (op: string) => boolean, ): UninterpretableTemporalComparand | null { // Not filter structure → an implicit-equality comparand, judged at this path. if (!isFilterNode(spec)) return judgeComparand(kind, field, spec, path); @@ -218,6 +280,7 @@ function judgeFieldComparands( if (isFieldReference(spec)) return null; for (const op of keys) { if (!op.startsWith('$')) continue; + if (!judgesOperator(op)) continue; const comparand = spec[op]; // Every MEMBER of a list operator is a comparand in its own right — the // same split the #7872 type door makes at the shared compile face. @@ -321,3 +384,87 @@ export function assertTemporalComparandsInterpretable( + `data". The filter was NOT applied. ${REMEDY[hit.kind]}`, ); } + +/** + * [#20263] The remedy on a `having` column. No relative-date placeholder: the + * engine does not resolve filter placeholders in `having` (only in `where` and + * a per-aggregation `filter`), so naming `{30_days_ago}` here would send the + * author to a spelling `having` compares as the literal text it is. + */ +const HAVING_REMEDY: Record = { + datetime: + 'Write an ISO-8601 instant ("2026-07-15T00:00:00.000Z"), a bare "YYYY-MM-DD" ' + + '(read as midnight UTC), or epoch milliseconds.', + date: 'Write a "YYYY-MM-DD" calendar day.', + time: REMEDY.time, +}; + +/** + * [#20263] Which aggregated column a `having` key names, in the words the + * author wrote it: `max(placed_on)`, `the day bucket of opened_at`, `the + * groupBy field placed_on`. For the message only — the column's CLASS comes + * from `aggregatedRowColumnClasses`, which reads an aggregation alias after the + * groupBy projections, so an aggregation is looked up first here too. + */ +function havingColumnSource(column: string, groupBy: unknown, aggregations: unknown): string { + for (const a of Array.isArray(aggregations) ? aggregations : []) { + const agg = a as { alias?: unknown; function?: unknown; field?: unknown } | null; + if (agg?.alias === column) return `${String(agg.function)}(${String(agg.field)})`; + } + for (const g of Array.isArray(groupBy) ? groupBy : []) { + if (g === column) return `the groupBy field ${column}`; + const item = g as { alias?: unknown; field?: unknown; dateGranularity?: unknown } | null; + if ((item?.alias ?? item?.field) !== column) continue; + return item?.dateGranularity == null + ? `the groupBy field ${String(item?.field)}` + : `the ${String(item.dateGranularity)} bucket of ${String(item?.field)}`; + } + return 'an aggregated column'; +} + +/** + * [#20263] Refuse every `having` comparand its aggregated column's storage rule + * cannot read, before any driver is asked for a row. + * + * The same walk and the same `@objectstack/core` predicate as `where` — see + * the module note's `having` section for the two differences: the kind is the + * column's CLASS (`classes`, #20127's `aggregatedRowColumnClasses`, handed in + * rather than derived again), and the text operators are stepped over. + * `ObjectQL.aggregate` calls it on the caller's own `having`, after every + * other `having` door, so a clause those refuse keeps their refusal. + */ +export function assertHavingTemporalComparandsInterpretable( + object: string, + having: unknown, + classes: ReadonlyMap, + query: { groupBy?: unknown; aggregations?: unknown }, +): void { + const hit = walkCondition( + { + kindOf: (key) => temporalKindOf(classes.get(key)) ?? null, + judgesOperator: (op) => !isTextFilterOperator(op), + }, + having, + 'having', + 0, + ); + if (!hit) return; + const column = `\`having\` on '${hit.field}' (${havingColumnSource(hit.field, query.groupBy, query.aggregations)}, ` + + `a ${hit.kind} column)`; + // The `date` year class, in its own words, as on `where` (#20240). + if (typeof hit.value !== 'string') { + throw invalidFilterError( + `aggregate('${object}'): ${column} compares against ${preview(hit.value)} at ${hit.path}, an ` + + 'instant whose UTC calendar day falls outside the years 0000 to 9999, the only years a ' + + '"YYYY-MM-DD" day can spell, so it is not a date value this platform can interpret. Compared ' + + 'with each group, it would order as no day does and keep the wrong groups. The `having` was ' + + `NOT applied. ${DATE_YEAR_REMEDY}`, + ); + } + throw invalidFilterError( + `aggregate('${object}'): ${column} compares against ${preview(hit.value)} at ${hit.path}, ` + + `which is not a ${hit.kind} value this platform can interpret. Compared with each group as ` + + 'written, it would keep no group or every group, a 200 indistinguishable from a real answer. ' + + `The \`having\` was NOT applied. ${HAVING_REMEDY[hit.kind]}`, + ); +} diff --git a/packages/rest/src/data-query-date-year-range.test.ts b/packages/rest/src/data-query-date-year-range.test.ts index 3db2b9f4fe0..ae4de6d47dd 100644 --- a/packages/rest/src/data-query-date-year-range.test.ts +++ b/packages/rest/src/data-query-date-year-range.test.ts @@ -27,9 +27,9 @@ * string for 0999-06-15 answer one count at every position; and a number or * `Date` whose day falls in a year below 0 or above 9999 is refused * `INVALID_FILTER` / 400 before any read, as its ISO string already was, at - * `where` and the per-aggregation `filter`. `having` does not reach the - * temporal-comparand door for any comparand, a string included, so its - * out-of-range cells are not this file's. + * `where` and the per-aggregation `filter`. [#20263] `having` reaches the same + * door since, by the same predicate; its out-of-range cells are pinned in + * `data-query-having-temporal-door.test.ts`, not in this file. */ import { describe, it, expect, afterAll } from 'vitest'; diff --git a/packages/rest/src/data-query-having-temporal-door.test.ts b/packages/rest/src/data-query-having-temporal-door.test.ts new file mode 100644 index 00000000000..e1c0b3dfb4d --- /dev/null +++ b/packages/rest/src/data-query-having-temporal-door.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20263] `having` takes the temporal-comparand door `where` takes — through + * `engine.aggregate` and `POST /api/v1/data/:object/query`, over a real + * sqlite `SqlDriver`, on both `having` paths. + * + * Measured on the base (`89f87f2344`) through this door and `engine.aggregate`, + * on InMemoryDriver, SqlDriver on SQLite and SqlDriver on PostgreSQL 16 (the + * three agreed on every cell below), `groupBy` customer, four groups c1–c4: + * + * | `having` | base | its `where` twin | + * |:--|:--|:--| + * | `{ last_placed: { $lt: 'not-a-date' } }`, `max(placed_on)` | 200, keeps c1–c4 | 400 | + * | `{ last_placed: { $gt: 'not-a-date' } }` | 200, keeps no group | 400 | + * | `{ last_placed: { $gt: '+010000-01-01T00:00:00.000Z' } }` | 200, keeps c1–c4 | 400 | + * | `{ last_placed: { $gt: 253402300800000 } }` (10000-01-01) | 200, keeps c1–c4 | 400 | + * | `'not-a-date'` on `min(opened_at)` or `max(slot)`, `$lt` | 200, keeps c1–c4 | 400 | + * + * Each read the object once. Now each is refused `INVALID_FILTER` / 400 with + * no read, on both paths and both doors, and a 2026 control answers the same + * groups it did. The engine-side cells (a `Date`, a groupBy key, a day bucket, + * `$in` / `$between` / `$or` / `$not`, and every cell the door leaves alone) + * are pinned in `@objectstack/objectql`'s + * `engine-aggregate-having-temporal-door.test.ts`. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import type { EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const OBJECT = 'ledger_having'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger Having', + fields: { + customer_id: { name: 'customer_id', type: 'text' as const }, + amount: { name: 'amount', type: 'number' as const }, + placed_on: { name: 'placed_on', type: 'date' as const }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + slot: { name: 'slot', type: 'time' as const }, + }, +}; + +const ROWS = [ + { id: 'o1', customer_id: 'c1', amount: 100, placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z', slot: '09:00:00' }, + { id: 'o2', customer_id: 'c1', amount: 400, placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z', slot: '10:30:00' }, + { id: 'o3', customer_id: 'c2', amount: 900, placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z', slot: '11:00:00' }, + { id: 'o4', customer_id: 'c2', amount: 300, placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z', slot: '12:00:00' }, + { id: 'o5', customer_id: 'c3', amount: 50, placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z', slot: '13:00:00' }, + { id: 'o6', customer_id: 'c4', amount: 20, placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z', slot: '14:00:00' }, +]; + +const Y10000 = 253402300800000; // +010000-01-01T00:00:00.000Z + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const engines: ObjectQL[] = []; +afterAll(async () => { + for (const e of engines) { + try { await e.destroy(); } catch { /* noop */ } + } +}); + +async function boot() { + const driver: any = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + await engine.insert(OBJECT, ROWS as any); + + // Reads of THIS object — the protocol's own metadata reads are not the question. + const reads = { n: 0 }; + for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) { + const real = driver[verb].bind(driver); + driver[verb] = (o: string, ...rest: unknown[]) => { if (o === OBJECT) reads.n += 1; return real(o, ...rest); }; + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query'); + expect(route).toBeDefined(); + const post = async (body: Record) => { + const res = makeRes(); + // What the wire carries: JSON. + await route!.handler({ params: { object: OBJECT }, body: JSON.parse(JSON.stringify(body)) } as any, res); + return res; + }; + return { engine, post, reads }; +} + +type Path = 'native' | 'rows'; + +/** + * `native`: SqlDriver aggregates and the engine applies `having` to its + * answer. `rows`: a filtered aggregation sends the engine to the rows path, + * where it aggregates itself and then applies `having`. + */ +function grouped(path: Path, having: Record): EngineAggregateOptions { + const aggregations: NonNullable = [ + { function: 'max', field: 'placed_on', alias: 'last_placed' }, + { function: 'min', field: 'opened_at', alias: 'first_opened' }, + { function: 'max', field: 'slot', alias: 'last_slot' }, + { function: 'sum', field: 'amount', alias: 'total' }, + ]; + if (path === 'rows') aggregations.push({ function: 'count', alias: 'fb', filter: { customer_id: { $ne: '' } } }); + return { groupBy: ['customer_id'], aggregations, having: having as FilterCondition }; +} + +const refusalOf = async (p: Promise) => + p.then(() => null, (e: any) => e as Error & { code?: string; status?: number }); +const groupsOf = (rows: any[]) => rows.map((r) => r.customer_id).sort(); + +// name · having · its `where` twin · the message's column clause +const REFUSED: ReadonlyArray, Record, string]> = [ + ['"not-a-date" $lt on max(date) — kept c1–c4', { last_placed: { $lt: 'not-a-date' } }, { placed_on: { $lt: 'not-a-date' } }, + "`having` on 'last_placed' (max(placed_on), a date column)"], + ['"not-a-date" $gt on max(date) — kept no group', { last_placed: { $gt: 'not-a-date' } }, { placed_on: { $gt: 'not-a-date' } }, + "`having` on 'last_placed' (max(placed_on), a date column)"], + ['an extended-year ISO $gt on max(date) — kept c1–c4', { last_placed: { $gt: '+010000-01-01T00:00:00.000Z' } }, + { placed_on: { $gt: '+010000-01-01T00:00:00.000Z' } }, "`having` on 'last_placed' (max(placed_on), a date column)"], + ['the number for 10000-01-01 $gt on max(date) — kept c1–c4', { last_placed: { $gt: Y10000 } }, { placed_on: { $gt: Y10000 } }, + "`having` on 'last_placed' (max(placed_on), a date column)"], + ['"not-a-date" $lt on min(datetime) — kept c1–c4', { first_opened: { $lt: 'not-a-date' } }, { opened_at: { $lt: 'not-a-date' } }, + "`having` on 'first_opened' (min(opened_at), a datetime column)"], + ['"not-a-date" $lt on max(time) — kept c1–c4', { last_slot: { $lt: 'not-a-date' } }, { slot: { $lt: 'not-a-date' } }, + "`having` on 'last_slot' (max(slot), a time column)"], +]; + +describe('[#20263] having — a comparand its column cannot read is refused before any read, at the engine and over REST', () => { + for (const [name, having, where, column] of REFUSED) { + it(`${name}: INVALID_FILTER / 400 on both paths and both doors, as its where twin is`, async () => { + const { engine, post, reads } = await boot(); + for (const path of ['native', 'rows'] as const) { + const err = await refusalOf(engine.aggregate(OBJECT, grouped(path, having))); + expect(err, `engine, ${path}`).not.toBeNull(); + expect(err!.code).toBe('INVALID_FILTER'); + expect(err!.status).toBe(400); + expect(err!.message).toContain(column); + expect(err!.message).toContain(`at having.${Object.keys(having)[0]}.`); + const res = await post(grouped(path, having) as Record); + expect(res._status, `REST, ${path}`).toBe(400); + expect(res._json.code).toBe('INVALID_FILTER'); + expect(res._json.error).toContain(column); + } + // The twin, at both doors: the same door, the same envelope. + const twin = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition })); + expect(twin?.code).toBe('INVALID_FILTER'); + expect(twin?.status).toBe(400); + const twinRes = await post({ where }); + expect(twinRes._status).toBe(400); + expect(twinRes._json.code).toBe('INVALID_FILTER'); + expect(reads.n, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + } +}); + +describe('[#20263] having — the 2026 control and the non-temporal columns answer as they did', () => { + // having · groups kept — measured identical on the base, all three drivers, both paths, both doors + const KEPT: ReadonlyArray, string[]]> = [ + ['a 2026 day $gt on max(date)', { last_placed: { $gt: '2026-02-01' } }, ['c2']], + ['a 2026 day $lt on max(date)', { last_placed: { $lt: '2026-02-01' } }, ['c1', 'c3']], + ['a 2026 instant $gt on min(datetime)', { first_opened: { $gt: '2026-02-01T00:00:00.000Z' } }, ['c2', 'c3', 'c4']], + ['a wall clock $lt on max(time)', { last_slot: { $lt: '12:00' } }, ['c1']], + ['a string on sum — not temporal, not judged', { total: { $gt: 'not-a-date' } }, []], + ]; + for (const [name, having, kept] of KEPT) { + it(`${name}: keeps ${kept.join(', ') || 'no group'}, engine and REST, both paths`, async () => { + const { engine, post } = await boot(); + for (const path of ['native', 'rows'] as const) { + expect(groupsOf(await engine.aggregate(OBJECT, grouped(path, having))), `engine, ${path}`).toEqual(kept); + const res = await post(grouped(path, having) as Record); + expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200); + expect(groupsOf(res._json.records), `REST, ${path}`).toEqual(kept); + } + }); + } +});