diff --git a/.changeset/20263-having-temporal-comparand-door.md b/.changeset/20263-having-temporal-comparand-door.md index 5766e23d76e..f5b71c19cb3 100644 --- a/.changeset/20263-having-temporal-comparand-door.md +++ b/.changeset/20263-having-temporal-comparand-door.md @@ -28,7 +28,7 @@ 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. +- 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` resolves placeholders from the same release (#20334), after this door, so the refusal's remedy on a `date` or `datetime` column is the `where` refusal's and names them, e.g. `{30_days_ago}` / `{current_month_start}`. - 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. @@ -36,6 +36,6 @@ The refusal follows the `where` door's words. It names the `having` path, the co **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. +**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, either one with a relative-date placeholder the resolver knows (`{30_days_ago}`, `{current_month_start}`; `having` resolves them from the same release, #20334), 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/.changeset/20334-aggregate-positions.md b/.changeset/20334-aggregate-positions.md new file mode 100644 index 00000000000..8056e4f071f --- /dev/null +++ b/.changeset/20334-aggregate-positions.md @@ -0,0 +1,33 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql)!: `having` on `engine.aggregate` resolves `{placeholder}` tokens through the resolver `where` uses, so an unknown one is refused `FILTER_TOKEN_UNKNOWN` / 400 instead of keeping no group with a 200; and the per-aggregation `filter`'s temporal and text-operator refusals name `aggregations[i].filter` instead of `where` (#20334) + +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 `{placeholder}` in `having` is now resolved by the same resolver, with the same refusals, as one in `where`, 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. + +Measured before and after through `engine.aggregate` and `POST /data/:object/query`, on InMemoryDriver, SqlDriver on SQLite and SqlDriver on PostgreSQL 16, on both `having` paths, four groups whose `max(placed_on)` falls between 2026-01-02 and 2026-03-01: + +| `having` | before | now | the same token in `where` | +|:--|:--|:--|:--| +| an unknown token: `{ last_placed: { $gte: '{not_a_token}' } }`, or a near miss such as `'{TODAY}'`, on any column, under `$and` / `$or` / `$not` or as an `$in` member | 200, keeps no group | `FILTER_TOKEN_UNKNOWN` / 400, no read | the same refusal, in the same words | +| `'{current_user_id}'` with no user on the request, `'{current_org_id}'` with no active organization, `'{record_id}'` always | 200, keeps no group | `FILTER_TOKEN_UNRESOLVED` / 400, no read (a REST request with no user is answered 401 before it reaches the engine, as before) | the same refusal | +| a known token: `{ last_placed: { $gt: '{current_year_start}' } }` on `max(placed_on)` | compared as its own text, which sorts after every digit: keeps no group | compares as `'2026-01-01'`: keeps all four | resolved | +| any known token, as a comparand, an `$in` member or a `$between` endpoint, under `$and` / `$or` / `$not` | compared as its own text (on a date or datetime column, `$gt` / `$gte` kept no group and `$lt` / `$lte` every group) | the groups the resolved value keeps, the same as that value written out | resolved | +| `{ customer_id: '{current_user_id}' }` on a groupBy key, as user `c2` | keeps no group | keeps `c2` | resolved | + +Tokens resolve the way they do in `where`: a date macro to a `YYYY-MM-DD` day (a sub-day macro to an ISO instant) in the request's timezone, `{current_user_id}` and `{current_org_id}` from the request. A string that only contains braces (`'a{b}c'`) is not a placeholder and compares as written, as in `where`. The `having` doors run first, as `where`'s do: a clause an earlier `having` door refuses (an unknown key, an unknown operator, a comparand its temporal column cannot read) keeps that refusal, in that door's words. + +**The per-aggregation `filter`'s refusals name their position.** A comparand a declared temporal field cannot read, and a text operator aimed at a field that never holds a string, in `aggregations[1].filter` said `at where.placed_on.$gt` / `at where.amount.$contains`, a `where` the author did not write. They now say `at aggregations[1].filter.placed_on.$gt` / `at aggregations[1].filter.amount.$contains`, as that filter's list-shape and comparand-type refusals already did. The code (`INVALID_FILTER`), the status (400) and every other word are unchanged at the engine. Over REST, the message keeps its existing 500-character bound: a `date` field's refusal of a string such as `'not-a-date'`, which fitted within it, now loses the end of its remedy (`"{current_month_s…` at the shortest path), and the refusals that already exceeded the bound still do. + +**The `having` temporal refusal's remedy is `where`'s.** A string a `date` or `datetime` aggregated column cannot read (`{ last_placed: { $lt: 'last_30_days' } }` on `max(placed_on)`) was refused with a remedy that named the literal forms only (`Write a "YYYY-MM-DD" calendar day.` on a `date` column), written when `having` resolved no placeholder. Now that it resolves them, the refusal ends in the remedy the same comparand gets in `where`, which names the placeholder: `Write a "YYYY-MM-DD" calendar day, or a relative-date placeholder the resolver knows, e.g. "{30_days_ago}" / "{current_month_start}".` on a `date` column, and the `where` remedy for a `datetime` field on a `datetime` column. The code (`INVALID_FILTER`), the status (400), what is refused and every word before the remedy are unchanged, and so are a `time` column's refusal and the refusal of a number or `Date` whose year falls outside 0000 to 9999. Over REST the message keeps its 500-character bound, which the longer remedy now reaches, measured with an object named `ledger_having`. A `date` column's refusal still arrives whole in every cell measured, the longest at 498 characters (`'+010000-01-01T00:00:00.000Z'` on `max(placed_on)`), and it stays whole while the object name, the column, what it aggregates, the comparand as quoted and its path take at most 90 characters together. A `datetime` column's refusal no longer fits whatever those are, because its fixed words and remedy alone take 502 characters: over REST it now ends inside the remedy, before the placeholder it names (`…epoch milliseconds, or a relative-date pl…` on `min(opened_at)`), as the `where` refusal for a `datetime` field already did. A preset name such as `'last_30_days'` does not reach this refusal over REST: the query schema refuses it first (`VALIDATION_FAILED`), before and after. + +**Who is affected.** `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. The five `having` clauses in this repository's docs and published skills compare a numeric aggregation alias with a number, and none carries a placeholder; no runtime code or example app in this repository composes a `having`. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. + +**Fix.** For an unknown token, write one the resolver knows (the refusal lists them: `{today}`, `{current_quarter_start}`, `{30_days_ago}`, `{current_user_id}`, …) or the literal value. For `{current_user_id}` / `{current_org_id}`, send the request with a user or an active organization. + +**Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` answer, placeholders and refusals included; every `having` that carries no placeholder, and its refusals in their words other than the `date` / `datetime` temporal refusal's remedy above; every per-aggregation `filter` answer other than the two refusals' paths above, placeholders included. diff --git a/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts b/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts index 4574f2bb242..32e70d5ee0e 100644 --- a/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts +++ b/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts @@ -247,9 +247,32 @@ describe('[#20263] having — a comparand its column cannot read is refused befo 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}'); + // [#20334] `having` resolves placeholders through `where`'s resolver, so its + // remedy is `where`'s: it names the relative-date placeholder that a caller + // holding a preset name (`last_30_days`) needs, on the two kinds that take one. + it('the remedy names the placeholder the resolver knows, in the where twin\'s words', async () => { + const CASES: ReadonlyArray, Record, string]> = [ + [{ last_placed: { $lt: 'last_30_days' } }, { placed_on: { $lt: 'last_30_days' } }, + "`having` on 'last_placed' (max(placed_on), a date column) compares against \"last_30_days\" at " + + 'having.last_placed.$lt, which is not a date value this platform can interpret.'], + [{ first_opened: { $lt: 'last_30_days' } }, { opened_at: { $lt: 'last_30_days' } }, + "`having` on 'first_opened' (min(opened_at), a datetime column) compares against \"last_30_days\" at " + + 'having.first_opened.$lt, which is not a datetime value this platform can interpret.'], + ]; + const after = (message: string, marker: string): string => { + expect(message).toContain(marker); + return message.slice(message.indexOf(marker) + marker.length); + }; + for (const [having, where, firstSentence] of CASES) { + // INVALID_FILTER / 400 on both paths, empty or populated, no read. + const message = await expectHavingRefusal(() => having); + expect(message.startsWith(`aggregate('${OBJECT}'): ${firstSentence} `)).toBe(true); + const remedy = after(message, 'The `having` was NOT applied. '); + expect(remedy).toContain('"{30_days_ago}"'); + const twin = await whereTwinOf(where); + expect(twin.err?.code).toBe('INVALID_FILTER'); + expect(twin.err?.status).toBe(400); + expect(remedy).toBe(after(twin.err!.message, 'The filter was NOT applied. ')); } }); }); @@ -306,7 +329,6 @@ describe('[#20263] having — what the door leaves alone answers exactly as befo ['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']], @@ -325,6 +347,19 @@ describe('[#20263] having — what the door leaves alone answers exactly as befo }); } + // [#20334] An unknown one is stepped around by this door too, and is then + // refused one layer down by the token resolver, in its own code, as on + // `where` (it kept no group with a 200 before `having` resolved tokens). + it('an unknown {placeholder} is not this door\'s verdict: FILTER_TOKEN_UNKNOWN from the resolver, before any read', async () => { + for (const path of ['native', 'rows'] as const) { + const { engine, reads } = await makeEngine(path, ROWS); + const { err } = await outcome(() => engine.aggregate(OBJECT, query(path, { last_placed: { $gte: '{not_a_token}' } }))); + expect(err?.code, path).toBe('FILTER_TOKEN_UNKNOWN'); + expect(err?.status, path).toBe(400); + expect(reads, path).toEqual({ aggregate: 0, find: 0 }); + } + }); + it('a coarser bucket is a text label, and is not judged', async () => { const kept = await keptGroups( { m: { $gt: 'not-a-date' } }, diff --git a/packages/objectql/src/engine-aggregate-positions.test.ts b/packages/objectql/src/engine-aggregate-positions.test.ts new file mode 100644 index 00000000000..ffae736e1bd --- /dev/null +++ b/packages/objectql/src/engine-aggregate-positions.test.ts @@ -0,0 +1,338 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20334] Two `where` behaviours the other filter positions of +// `engine.aggregate` lacked. +// +// 1. `having` resolves `{placeholder}` tokens through the resolver `where` +// uses (`ObjectQL.resolveWhereTokens`, parameterised by the AST slot). It +// resolved none: a relative-date token compared as its own text and an +// unknown one kept no group with a 200, where `where` answers +// `FILTER_TOKEN_UNKNOWN` / 400. +// 2. The per-aggregation `filter`'s temporal and text-operator refusals are +// rooted at `aggregations[i].filter`, as its list-shape and comparand-type +// refusals already were. They said `at where.…`, a `where` the author did +// not write. +// +// Measured on the base (`26daf0b036`) and the head 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 (the three drivers and both doors agree on every cell below): +// +// | position · input | base | head | `where` twin | +// |:--|:--|:--|:--| +// | `having` `{ last_placed: { $gt: '{current_year_start}' } }` | 200, no group | 200, c1–c4 (as `'2026-01-01'`) | resolves | +// | `having` `{ last_placed: { $gte: '{not_a_token}' } }` | 200, no group | 400 `FILTER_TOKEN_UNKNOWN` | 400 `FILTER_TOKEN_UNKNOWN` | +// | `having` `{ customer_id: '{current_user_id}' }`, no user | 200, no group | 400 `FILTER_TOKEN_UNRESOLVED` | 400 `FILTER_TOKEN_UNRESOLVED` | +// | `aggregations[1].filter` `{ placed_on: { $gt: 'not-a-date' } }` | 400, `at where.placed_on.$gt` | 400, `at aggregations[1].filter.placed_on.$gt` | 400, `at where.placed_on.$gt` | +// | `aggregations[1].filter` `{ amount: { $contains: '5' } }` | 400, `at where.amount.$contains` | 400, `at aggregations[1].filter.amount.$contains` | 400, `at where.amount.$contains` | +// +// No driver reads `having` and every refusal precedes the driver, so this +// file holds the engine to it with a counting driver of each `having` path's +// shape; the REST door is held over a real SqlDriver in +// `packages/rest/src/rest-aggregate-positions.test.ts`. + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +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' }, +}; + +// 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' }, + { id: 'o2', customer_id: 'c1', amount: 400, placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z' }, + { id: 'o3', customer_id: 'c2', amount: 900, placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z' }, + { id: 'o4', customer_id: 'c2', amount: 300, placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z' }, + { id: 'o5', customer_id: 'c3', amount: 50, placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z' }, + { id: 'o6', customer_id: 'c4', amount: 20, placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z' }, +]; + +// c1: last_placed 2026-01-10 · first_opened 2026-01-01T10:00Z · total 500 +// c2: last_placed 2026-03-01 · first_opened 2026-02-01T10:00Z · total 1200 +// c3: last_placed 2026-01-15 · first_opened 2026-02-06T10:00Z · total 50 +// c4: last_placed 2026-02-01 · first_opened 2026-03-01T10:00Z · total 20 +const AGGREGATIONS: NonNullable = [ + { function: 'max', field: 'placed_on', alias: 'last_placed' }, + { function: 'min', field: 'opened_at', alias: 'first_opened' }, + { function: 'sum', field: 'amount', alias: 'total' }, + { function: 'count', alias: 'n' }, +]; + +// The resolver's reference instant, pinned: `{today}` is 2026-02-20, +// `{current_month_start}` 2026-02-01, `{current_year_start}` 2026-01-01, +// `{30_days_ago}` 2026-01-21 (UTC; the context carries no timezone). +const PINNED_NOW = new Date('2026-02-20T12:00:00.000Z'); +beforeAll(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(PINNED_NOW); +}); +afterAll(() => { + vi.useRealTimers(); +}); + +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> = ROWS) { + 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 }; +} + +type Context = EngineAggregateOptions['context']; + +function havingQuery(path: Path, having: unknown, context?: Context): EngineAggregateOptions { + const aggregations = path === 'native' + ? AGGREGATIONS + : [...AGGREGATIONS, { function: 'count' as const, alias: 'fb', filter: { customer_id: { $ne: '' } } }]; + return { groupBy: ['customer_id'], aggregations, having: having as FilterCondition, ...(context ? { context } : {}) }; +} + +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 }; + } +} + +/** The groups a `having` keeps: identical on both paths, one read each. */ +async function keptGroups(having: unknown, context?: Context): Promise { + let kept: string[] | undefined; + for (const path of ['native', 'rows'] as const) { + const { engine, reads } = await makeEngine(path); + const rows = await engine.aggregate(OBJECT, havingQuery(path, having, context)); + expect(reads.aggregate + reads.find, path).toBe(1); + const got = rows.map((r: any) => String(r.customer_id)).sort(); + kept ??= got; + expect(got, path).toEqual(kept); + } + return kept!; +} + +/** + * 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 refusal. + */ +async function expectHavingRefusal(having: () => unknown, acrossPaths = true): Promise { + const first: 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, havingQuery(path, having()))); + expect(err, cell).toBeInstanceOf(Error); + expect(reads, cell).toEqual({ aggregate: 0, find: 0 }); + first[path] ??= err!; + expect(err!.code, cell).toBe(first[path]!.code); + expect(err!.status, cell).toBe(first[path]!.status); + expect(err!.message, cell).toBe(first[path]!.message); + } + } + expect(first.rows!.code).toBe(first.native!.code); + expect(first.rows!.status).toBe(first.native!.status); + if (acrossPaths) expect(first.rows!.message).toBe(first.native!.message); + return first.native!; +} + +/** The same condition as a `where` on the object's fields — the twin. */ +async function whereTwinOf(where: Record, context?: Context): Promise<{ err?: Refusal; reads: number }> { + const { engine, reads } = await makeEngine('rows'); + const { err } = await outcome(() => engine.find(OBJECT, { where: where as FilterCondition, ...(context ? { context } : {}) })); + return { err, reads: reads.find }; +} + +describe('[#20334] having — a known placeholder resolves, and compares as the value it names', () => { + // name · having with a token · the same having with the token's value written out · groups kept + const RESOLVED: ReadonlyArray = [ + ['{current_year_start} $gt on max(date) — kept no group, by text order', + { last_placed: { $gt: '{current_year_start}' } }, { last_placed: { $gt: '2026-01-01' } }, ['c1', 'c2', 'c3', 'c4']], + ['{current_month_start} $gte on max(date)', + { last_placed: { $gte: '{current_month_start}' } }, { last_placed: { $gte: '2026-02-01' } }, ['c2', 'c4']], + ['{today} $lt on max(date) — kept every group, by text order', + { last_placed: { $lt: '{today}' } }, { last_placed: { $lt: '2026-02-20' } }, ['c1', 'c3', 'c4']], + ['{30_days_ago} $gt on max(date)', + { last_placed: { $gt: '{30_days_ago}' } }, { last_placed: { $gt: '2026-01-21' } }, ['c2', 'c4']], + ['both $between endpoints', + { last_placed: { $between: ['{current_year_start}', '{current_month_start}'] } }, + { last_placed: { $between: ['2026-01-01', '2026-02-01'] } }, ['c1', 'c3', 'c4']], + ['an $in member', + { last_placed: { $in: ['{current_month_start}', '2026-03-01'] } }, { last_placed: { $in: ['2026-02-01', '2026-03-01'] } }, ['c2', 'c4']], + ['a bare-day token on min(datetime)', + { first_opened: { $gte: '{current_month_start}' } }, { first_opened: { $gte: '2026-02-01' } }, ['c2', 'c3', 'c4']], + ['under $or, beside a numeric arm', + { $or: [{ total: { $gt: 1000 } }, { last_placed: { $lt: '{current_month_start}' } }] }, + { $or: [{ total: { $gt: 1000 } }, { last_placed: { $lt: '2026-02-01' } }] }, ['c1', 'c2', 'c3']], + ['under $and', + { $and: [{ total: { $gt: 30 } }, { last_placed: { $gte: '{current_year_start}' } }] }, + { $and: [{ total: { $gt: 30 } }, { last_placed: { $gte: '2026-01-01' } }] }, ['c1', 'c2', 'c3']], + ['under $not', + { $not: { last_placed: { $gte: '{current_month_start}' } } }, { $not: { last_placed: { $gte: '2026-02-01' } } }, ['c1', 'c3']], + ]; + + for (const [name, having, literal, kept] of RESOLVED) { + it(`${name}: keeps ${kept.join(', ') || 'no group'} on both paths, exactly as the value written out does`, async () => { + expect(await keptGroups(having)).toEqual(kept); + expect(await keptGroups(literal)).toEqual(kept); + }); + } + + it('{current_user_id} resolves from the execution context, on a groupBy key', async () => { + expect(await keptGroups({ customer_id: '{current_user_id}' }, { userId: 'c2' })).toEqual(['c2']); + expect(await keptGroups({ customer_id: 'c2' }, { userId: 'c2' })).toEqual(['c2']); + }); + + it("resolves on the engine's copy: the caller's having keeps its placeholder", async () => { + const having = { last_placed: { $gt: '{current_year_start}' } }; + for (const path of ['native', 'rows'] as const) { + const { engine } = await makeEngine(path); + await engine.aggregate(OBJECT, havingQuery(path, having)); + expect(having).toEqual({ last_placed: { $gt: '{current_year_start}' } }); + } + }); +}); + +describe('[#20334] having — a placeholder the resolver cannot resolve is refused before any read, as its where twin is', () => { + // name · having · its `where` twin · code + const REFUSED: ReadonlyArray unknown, Record, string]> = [ + ["the card's row: an unknown token on max(date), which kept no group", + () => ({ last_placed: { $gte: '{not_a_token}' } }), { placed_on: { $gte: '{not_a_token}' } }, 'FILTER_TOKEN_UNKNOWN'], + ['an unknown token on count, a column no temporal door judges', + () => ({ n: { $gte: '{not_a_token}' } }), { amount: { $gte: '{not_a_token}' } }, 'FILTER_TOKEN_UNKNOWN'], + ['a near-miss spelling ({TODAY})', + () => ({ last_placed: { $gte: '{TODAY}' } }), { placed_on: { $gte: '{TODAY}' } }, 'FILTER_TOKEN_UNKNOWN'], + ['an unknown token under $and, beside an arm that holds', + () => ({ $and: [{ total: { $gt: 0 } }, { last_placed: { $gte: '{not_a_token}' } }] }), + { $and: [{ amount: { $gt: 0 } }, { placed_on: { $gte: '{not_a_token}' } }] }, 'FILTER_TOKEN_UNKNOWN'], + ['an unknown token as an $in member under $not', + () => ({ $not: { last_placed: { $in: ['2026-01-10', '{not_a_token}'] } } }), + { $not: { placed_on: { $in: ['2026-01-10', '{not_a_token}'] } } }, 'FILTER_TOKEN_UNKNOWN'], + ['{current_user_id} with no user in the context', + () => ({ customer_id: '{current_user_id}' }), { customer_id: '{current_user_id}' }, 'FILTER_TOKEN_UNRESOLVED'], + ['{record_id}, which no server path can resolve', + () => ({ customer_id: '{record_id}' }), { customer_id: '{record_id}' }, 'FILTER_TOKEN_UNRESOLVED'], + ]; + + for (const [name, having, where, code] of REFUSED) { + it(`${name}: ${code} / 400 on both paths, empty or populated, no read — the where twin's refusal`, async () => { + const err = await expectHavingRefusal(having); + expect(err.code).toBe(code); + expect(err.status).toBe(400); + const twin = await whereTwinOf(where); + expect(twin.reads).toBe(0); + expect(twin.err?.code).toBe(code); + expect(twin.err?.status).toBe(400); + expect(err.message).toBe(twin.err?.message); + }); + } +}); + +describe('[#20334] having — the doors in front of the resolver keep their verdicts', () => { + it('an earlier having door answers first, in its own words (#20123, a key naming no column)', async () => { + const err = await expectHavingRefusal(() => ({ totl: { $gt: 1 }, last_placed: { $gte: '{not_a_token}' } }), false); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain("`having` filters on 'totl' at having.totl"); + }); + + it('the temporal-comparand door answers before the resolver, as on where (#20263)', async () => { + const err = await expectHavingRefusal(() => ({ last_placed: { $lt: 'not-a-date' }, first_opened: { $gte: '{not_a_token}' } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('at having.last_placed.$lt'); + }); + + it('a string that only contains braces is not a placeholder, and compares as written', async () => { + expect(await keptGroups({ customer_id: { $ne: 'a{b}c' } })).toEqual(['c1', 'c2', 'c3', 'c4']); + }); +}); + +describe('[#20334] per-aggregation filter — the temporal and text-operator refusals name the position they sit in', () => { + /** A filter in aggregation `index`, and its `where` twin through the same verb. */ + async function perAggregationAndWhere(filter: Record, index = 1) { + const aggregations: NonNullable = [ + { function: 'count', alias: 'n' }, + ...(index === 2 ? [{ function: 'sum' as const, field: 'amount', alias: 'total' }] : []), + { function: 'count', alias: 'm', filter: filter as FilterCondition }, + ]; + const { engine, reads } = await makeEngine('native'); + const perAgg = await outcome(() => engine.aggregate(OBJECT, { groupBy: ['customer_id'], aggregations })); + const twin = await outcome(() => engine.aggregate(OBJECT, { + where: filter as FilterCondition, groupBy: ['customer_id'], aggregations: [{ function: 'count', alias: 'n' }], + })); + expect(reads, 'every refusal precedes the driver').toEqual({ aggregate: 0, find: 0 }); + return { perAgg: perAgg.err!, twin: twin.err! }; + } + + // name · filter · the path the refusal names under `aggregations[i].filter` + const REFUSED: ReadonlyArray, string]> = [ + ["the card's row: a bad date under $gt (temporal door)", { placed_on: { $gt: 'not-a-date' } }, '.placed_on.$gt'], + ['a bad date behind a $or branch (temporal door)', { $or: [{ amount: { $gt: 0 } }, { placed_on: { $lt: 'not-a-date' } }] }, '.$or[1].placed_on.$lt'], + ['the number for 10000-01-01 on a date (temporal door, year class)', { placed_on: { $gt: 253402300800000 } }, '.placed_on.$gt'], + ['$contains on a number field (text-operator door)', { amount: { $contains: '5' } }, '.amount.$contains'], + ['$startsWith on a date field (text-operator door)', { placed_on: { $startsWith: '2026' } }, '.placed_on.$startsWith'], + ]; + + for (const [name, filter, at] of REFUSED) { + it(`${name}: at aggregations[1].filter${at}, the where twin's refusal with only its root moved`, async () => { + const { perAgg, twin } = await perAggregationAndWhere(filter); + expect(perAgg.code).toBe('INVALID_FILTER'); + expect(perAgg.status).toBe(400); + expect(perAgg.message).toContain(`at aggregations[1].filter${at}`); + expect(perAgg.message).not.toContain(' at where.'); + // The control: `where` keeps its own root, and the two refusals differ in that root alone. + expect(twin.code).toBe('INVALID_FILTER'); + expect(twin.status).toBe(400); + expect(twin.message).toContain(`at where${at}`); + expect(perAgg.message).toBe(twin.message.replace(` at where${at}`, ` at aggregations[1].filter${at}`)); + }); + } + + it('the index is the aggregation that carries the filter', async () => { + const { perAgg } = await perAggregationAndWhere({ placed_on: { $gt: 'not-a-date' } }, 2); + expect(perAgg.message).toContain('at aggregations[2].filter.placed_on.$gt'); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0cc05ee8a8f..1c4f6d0a41c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -10995,11 +10995,22 @@ export class ObjectQL implements IObjectQLEngine { * reference when the tree holds no placeholder, which is every internal query. * An unresolvable placeholder throws (see the resolver's module doc) — the one * outcome an author can act on. + * + * [#20334] `position` names the AST's filter slot: `where` (every read verb) + * or `having` (`aggregate`). One resolver for both, so a placeholder reads the + * same in either position: `{current_year_start}` resolves, and an unknown one + * is `FILTER_TOKEN_UNKNOWN` / 400. The resolver needs no field type — it walks + * values, never keys — so a `having` keyed by aggregate aliases resolves as a + * `where` keyed by fields does. */ - private resolveWhereTokens(ast: QueryAST | undefined, execCtx?: ExecutionContext): void { - if (!ast || ast.where == null) return; + private resolveWhereTokens( + ast: QueryAST | undefined, + execCtx?: ExecutionContext, + position: 'where' | 'having' = 'where', + ): void { + if (!ast || ast[position] == null) return; // [#20157] Through the stage function the judge also calls. - ast.where = resolveWhereFilterTokens(ast.where, execCtx); + ast[position] = resolveWhereFilterTokens(ast[position], execCtx); } /** @@ -16286,8 +16297,12 @@ export class ObjectQL implements IObjectQLEngine { // `$contains` over a numeric column in ONE aggregation's filter is // the same silent zero at a second filter position, and a door that // spoke on `where` alone would answer one mistake two ways within a - // single verb. - assertTextOperatorTargetsAreStringCapable(object, 'aggregate', this._registry.getObject(object), aggFilter); + // single verb. [#20334] Rooted at this position, as the shape door + // above is: the refusal said `at where.amount.$contains`, a `where` + // the author did not write. + assertTextOperatorTargetsAreStringCapable( + object, 'aggregate', this._registry.getObject(object), aggFilter, `aggregations[${i}].filter`, + ); // [#20148] …and the TEMPORAL-comparand door, fourth here as it is // fourth on `where`'s seam (#8690) — the same function, against the // object's declared fields, since this filter reads the object's @@ -16299,7 +16314,11 @@ export class ObjectQL implements IObjectQLEngine { // driver-memory and driver-sql, through the engine and REST. // Before the token resolver below, as there, so a `{placeholder}` // is stepped around and resolved (or refused) a moment later. - assertTemporalComparandsInterpretable(object, 'aggregate', this._registry.getObject(object), aggFilter); + // [#20334] Rooted at this position too: the refusal said + // `at where.placed_on.$gt`. + assertTemporalComparandsInterpretable( + object, 'aggregate', this._registry.getObject(object), aggFilter, `aggregations[${i}].filter`, + ); // [#20122] …and the two doors `having` took at its own entry // (#20099), so a refusal here is the FILTER's, never the data's: // 1. the comparand-TYPE door `where` takes in @@ -16469,6 +16488,16 @@ export class ObjectQL implements IObjectQLEngine { }); } } + // [#20334] …and in `having`, through the resolver `where` takes, so a + // relative-date token compares as the day it names rather than as its own + // text, and an unknown one is `FILTER_TOKEN_UNKNOWN` / 400 (it kept no + // group with a 200). Both `applyHaving` doors below read `ast.having`, so + // one call covers the native and the rows path, before any driver read. + // After every `having` door above, as `where`'s resolution follows its + // doors: the temporal door steps around a `{placeholder}` exactly as + // `where`'s does (so, as there, the resolved value is not judged again), + // and the other doors judged a string that resolves to a string. + this.resolveWhereTokens(opCtx.ast as QueryAST, opCtx.context, 'having'); await this.executeWithMiddleware(opCtx, async () => { const ast = opCtx.ast as QueryAST; diff --git a/packages/objectql/src/temporal-comparand-door.ts b/packages/objectql/src/temporal-comparand-door.ts index 05c2b337cce..3ee7abf0c72 100644 --- a/packages/objectql/src/temporal-comparand-door.ts +++ b/packages/objectql/src/temporal-comparand-door.ts @@ -154,7 +154,7 @@ export interface UninterpretableTemporalComparand { * whose UTC year falls outside 0..9999. */ value: unknown; - /** The `where.…` (or `having.…`) key path the offending comparand sits at. */ + /** The `where.…` (or `having.…`, `aggregations[i].filter.…`) key path the offending comparand sits at. */ path: string; } @@ -352,14 +352,19 @@ const DATE_YEAR_REMEDY = * reason its neighbour records: an injected read filter is the platform's own, * not a declaration the caller can fix, and refusing one would turn a policy * into a 400 nobody can act on. + * + * [#20334] `path` roots the refusal at the position the filter sits in: + * `where` by default, `aggregations[i].filter` for a per-aggregation filter, + * the root the list-shape and comparand-type doors already name there. */ export function assertTemporalComparandsInterpretable( object: string, operation: string, schema: unknown, where: unknown, + path = 'where', ): void { - const hit = findUninterpretableTemporalComparand(schema, where); + const hit = findUninterpretableTemporalComparand(schema, where, path); if (!hit) return; // [#20240] A number or `Date` is judged on a `date` field for one reason // only — its day's year has no four-digit spelling — so it gets words that @@ -385,20 +390,6 @@ export function assertTemporalComparandsInterpretable( ); } -/** - * [#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 @@ -465,6 +456,6 @@ export function assertHavingTemporalComparandsInterpretable( `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]}`, + + `The \`having\` was NOT applied. ${REMEDY[hit.kind]}`, ); } diff --git a/packages/objectql/src/text-operator-declared-type-door.ts b/packages/objectql/src/text-operator-declared-type-door.ts index 2cdfe493e2b..e8e340c5335 100644 --- a/packages/objectql/src/text-operator-declared-type-door.ts +++ b/packages/objectql/src/text-operator-declared-type-door.ts @@ -144,7 +144,7 @@ export interface TextOperatorOverNonTextField { returnType?: string; /** The text operator aimed at it. */ operator: TextFilterOperator; - /** The `where.…` key path the offending operator sits at. */ + /** The `where.…` (or `aggregations[i].filter.…`) key path the offending operator sits at. */ path: string; } @@ -245,14 +245,19 @@ export function findTextOperatorOverNonTextField( * envelope (#5869 / #7047), naming the field and its declared type as the * ruling requires. No code is minted: `INVALID_FILTER` already exists * (`StandardErrorCode`, `packages/spec/src/api/errors.zod.ts`). + * + * [#20334] `path` roots the refusal at the position the filter sits in: + * `where` by default, `aggregations[i].filter` for a per-aggregation filter, + * the root the list-shape and comparand-type doors already name there. */ export function assertTextOperatorTargetsAreStringCapable( object: string, operation: string, schema: unknown, where: unknown, + path = 'where', ): void { - const hit = findTextOperatorOverNonTextField(schema, where); + const hit = findTextOperatorOverNonTextField(schema, where, path); if (!hit) return; const declared = hit.returnType === undefined ? `${hit.declaredType} field` diff --git a/packages/rest/src/rest-aggregate-positions.test.ts b/packages/rest/src/rest-aggregate-positions.test.ts new file mode 100644 index 00000000000..50d6f23b959 --- /dev/null +++ b/packages/rest/src/rest-aggregate-positions.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20334] `having` resolves `{placeholder}` tokens through `where`'s resolver, + * and the per-aggregation `filter`'s temporal and text-operator refusals name + * `aggregations[i].filter` — through `engine.aggregate` and + * `POST /api/v1/data/:object/query`, over a real sqlite `SqlDriver`, on both + * `having` paths. + * + * Measured on the base (`26daf0b036`) and the head 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: + * + * | position · input | base | head | + * |:--|:--|:--| + * | `having` `{ last_placed: { $gt: '{current_year_start}' } }` | 200, no group | 200, c1–c4, as `'2026-01-01'` | + * | `having` `{ last_placed: { $gte: '{not_a_token}' } }` | 200, no group | 400 `FILTER_TOKEN_UNKNOWN`, as its `where` twin | + * | `aggregations[1].filter` `{ placed_on: { $gt: 'not-a-date' } }` | 400 `at where.placed_on.$gt` | 400 `at aggregations[1].filter.placed_on.$gt` | + * | `aggregations[1].filter` `{ amount: { $contains: '5' } }` | 400 `at where.amount.$contains` | 400 `at aggregations[1].filter.amount.$contains` | + * + * The engine-side cells (both path shapes with a counting driver, `$between`, + * `$in`, `$and` / `$or` / `$not`, the unresolved context tokens, the door + * order) are pinned in `@objectstack/objectql`'s + * `engine-aggregate-positions.test.ts`. + */ + +import { describe, it, expect, afterAll, beforeAll, vi } 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_positions'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger Positions', + 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 }, + }, +}; + +const ROWS = [ + { id: 'o1', customer_id: 'c1', amount: 100, placed_on: '2026-01-10' }, + { id: 'o2', customer_id: 'c1', amount: 400, placed_on: '2026-01-02' }, + { id: 'o3', customer_id: 'c2', amount: 900, placed_on: '2026-03-01' }, + { id: 'o4', customer_id: 'c2', amount: 300, placed_on: '2026-02-01' }, + { id: 'o5', customer_id: 'c3', amount: 50, placed_on: '2026-01-15' }, + { id: 'o6', customer_id: 'c4', amount: 20, placed_on: '2026-02-01' }, +]; + +// The resolver's reference instant, pinned: `{current_year_start}` is +// 2026-01-01 and `{current_month_start}` 2026-02-01. +beforeAll(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-02-20T12: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 */ } + } + vi.useRealTimers(); +}); + +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); + let userId = 'test-user'; + (rest as any).resolveExecCtx = async () => ({ userId }); + 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, asUser = 'test-user') => { + userId = asUser; + 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: '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(); + +describe('[#20334] having — a placeholder resolves, at the engine and over REST, on both paths', () => { + // having with a token · the same having with the value written out · groups kept + const RESOLVED: ReadonlyArray, Record, string[]]> = [ + [{ last_placed: { $gt: '{current_year_start}' } }, { last_placed: { $gt: '2026-01-01' } }, ['c1', 'c2', 'c3', 'c4']], + [{ last_placed: { $gte: '{current_month_start}' } }, { last_placed: { $gte: '2026-02-01' } }, ['c2', 'c4']], + ]; + for (const [having, literal, kept] of RESOLVED) { + it(`${JSON.stringify(having)}: keeps ${kept.join(', ')}, as ${JSON.stringify(literal)} does`, async () => { + const { engine, post } = await boot(); + for (const path of ['native', 'rows'] as const) { + for (const h of [having, literal]) { + expect(groupsOf(await engine.aggregate(OBJECT, grouped(path, h))), `engine, ${path}`).toEqual(kept); + const res = await post(grouped(path, h) as Record); + expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200); + expect(groupsOf(res._json.records), `REST, ${path}`).toEqual(kept); + } + } + }); + } + + it('{current_user_id} resolves from the request identity over REST', async () => { + const { post } = await boot(); + for (const path of ['native', 'rows'] as const) { + const res = await post(grouped(path, { customer_id: '{current_user_id}' }) as Record, 'c2'); + expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200); + expect(groupsOf(res._json.records), path).toEqual(['c2']); + } + }); +}); + +describe('[#20334] having — an unknown placeholder is refused before any read, as its where twin is', () => { + it("{ $gte: '{not_a_token}' } on max(date): FILTER_TOKEN_UNKNOWN / 400 at both doors, both paths — it kept no group with a 200", async () => { + const { engine, post, reads } = await boot(); + const twin = await refusalOf(engine.find(OBJECT, { where: { placed_on: { $gte: '{not_a_token}' } } })); + expect(twin?.code).toBe('FILTER_TOKEN_UNKNOWN'); + expect(twin?.status).toBe(400); + const twinRes = await post({ where: { placed_on: { $gte: '{not_a_token}' } } }); + expect(twinRes._status).toBe(400); + expect(twinRes._json.code).toBe('FILTER_TOKEN_UNKNOWN'); + for (const path of ['native', 'rows'] as const) { + const having = { last_placed: { $gte: '{not_a_token}' } }; + const err = await refusalOf(engine.aggregate(OBJECT, grouped(path, having))); + expect(err?.code, `engine, ${path}`).toBe('FILTER_TOKEN_UNKNOWN'); + expect(err?.status).toBe(400); + expect(err?.message).toBe(twin?.message); + const res = await post(grouped(path, having) as Record); + expect(res._status, `REST, ${path}`).toBe(400); + expect(res._json.code).toBe('FILTER_TOKEN_UNKNOWN'); + expect(res._json.error).toBe(twinRes._json.error); + } + expect(reads.n, 'no read of the object — every refusal precedes the driver').toBe(0); + }); +}); + +describe('[#20334] per-aggregation filter — the refusal names aggregations[1].filter, the where twin names where', () => { + // filter · the key path under the position's root + const REFUSED: ReadonlyArray, string]> = [ + [{ placed_on: { $gt: 'not-a-date' } }, '.placed_on.$gt'], + [{ amount: { $contains: '5' } }, '.amount.$contains'], + ]; + for (const [filter, at] of REFUSED) { + it(`${JSON.stringify(filter)}: INVALID_FILTER / 400 at aggregations[1].filter${at}, at both doors`, async () => { + const { engine, post, reads } = await boot(); + const body: EngineAggregateOptions = { + groupBy: ['customer_id'], + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: filter as FilterCondition }], + }; + const err = await refusalOf(engine.aggregate(OBJECT, body)); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + expect(err?.message).toContain(`at aggregations[1].filter${at}`); + expect(err?.message).not.toContain(' at where.'); + const res = await post(body as Record); + expect(res._status).toBe(400); + expect(res._json.code).toBe('INVALID_FILTER'); + expect(res._json.error).toContain(`at aggregations[1].filter${at}`); + // The control: the same condition as a `where` keeps its own root. + const twinRes = await post({ where: filter }); + expect(twinRes._status).toBe(400); + expect(twinRes._json.code).toBe('INVALID_FILTER'); + expect(twinRes._json.error).toContain(`at where${at}`); + expect(reads.n, 'no read of the object').toBe(0); + }); + } +});