diff --git a/.changeset/20745-nested-object-door.md b/.changeset/20745-nested-object-door.md new file mode 100644 index 0000000000..07880d67ae --- /dev/null +++ b/.changeset/20745-nested-object-door.md @@ -0,0 +1,35 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql)!: a plain object with no `$` operator beneath a relation field, a structured-JSON field or an undeclared `id` column is refused with `INVALID_FILTER` / 400 at `where`, a per-aggregation `filter` and `having`, on every driver + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows what a filter may put beneath a relation or JSON-valued field. A plain object with no `$`-operator key — `{ "owner": { "region": "NA" } }` beneath a `lookup`, `{ "meta": { "a": 1 } }` beneath a `json` field, `{}` included — is refused by the engine before any driver is asked. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. + +**What an author sees now.** `400 INVALID_FILTER`, naming the field, its declared type, the object's keys (never its values), the position (`where.owner`, `aggregations[1].filter.owner`, `having.owner`) and the route that works, inside the first 500 characters the REST door keeps: + +- **A relation field** — `lookup`, `master_detail`, `user`, `tree`, single or multiple (the nested-relation form, a condition on the related record's own fields). No data-path driver follows a relation: the field stores the related record's id. Filter the related object first, then match the field against the ids it returns — `{ "owner": { "$in": ["ID", "..."] } }` on a single-valued field, `{ "owners": { "$contains": "ID" } }` per id on a multi-valued one (an `$or` of those for several ids; the SQL driver refuses `$in` on a multi-valued column). A dotted path (`"owner.region"`) is no route: it was already refused with `INVALID_FIELD` on every driver. +- **A structured-JSON field** — `json`, `composite`, `repeater`, `record`, `location`, `address`, `vector` (a whole-value match). The drivers share no meaning for it: the in-memory driver compared documents, the SQL driver refused the bind. Test the whole value's presence with `{ "meta": { "$null": false } }`, or store the part you filter on in a field of its own and filter that field. `$contains` is no route: it was already refused over a JSON value on every driver. +- **`id`, `created_at` or `updated_at` absent from the declared field map** — the platform provisions these columns, so they are judged by the type they store (text, datetime), in the scalar-field words: compare with a value or an operator. + +No mechanical rewrite exists, because which related records or which part of the JSON value the caller meant is not in the object; the fix is by hand, as above. + +Measured through `POST /api/v1/data/:object/query`, three rows (owner `u1`, region NA, on `d1` and `d3`): + +| position | filter | before: memory · SQLite · PostgreSQL 16 | now, on all three | +|:--|:--|:--|:--| +| `where` | `{ owner: { region: "NA" } }` on a `lookup`, and its `master_detail`, multiple-lookup, `user` and `tree` twins | no records (`d1`, `d3` were meant) · the driver's 400 · the driver's 400 | `INVALID_FILTER` / 400, the engine's words | +| `where` | `{ meta: { a: 1 } }` on a `json` field, and its `address` and `composite` twins | the deep-equal records · the driver's 400 · the driver's 400 | `INVALID_FILTER` / 400 | +| `where` | `{ id: { a: 1 } }` | no records · the driver's 400 · the driver's 400 | `INVALID_FILTER` / 400 | +| per-aggregation `filter` | `{ owner: { region: "NA" } }` | count 0 on all three | `INVALID_FILTER` / 400 | +| per-aggregation `filter` | `{ meta: { a: 1 } }` | count 1 on all three (the engine's own deep equality) | `INVALID_FILTER` / 400, one answer per filter at every position | +| `having` | `{ owner: { region: "NA" } }` over a lookup groupBy | no group on all three | `INVALID_FILTER` / 400 | +| `where` | route `{ owner: { $in: ["u1"] } }`; `{ owners: { $contains: "u1" } }` | `d1`, `d3` on all three | unchanged | + +**Who is affected.** A caller that sends the nested-relation form or a JSON object comparand to the in-memory driver — a test suite, a local or embedded deployment on `InMemoryDriver`, a flow or hook calling the engine in-process — and read the empty (or deep-equal) answer as a real one; and a per-aggregation `filter` that matched a JSON value by deep equality. On `SqlDriver` the `where` forms were already a 400, now in the engine's words. + +**Supersedes** the "Unchanged" paragraph of the scalar-field refusal entry (`20546-no-operator-object-on-scalar`) for relation and structured-JSON fields: they are judged now, in words of their own. File and media fields (a legacy stored value is an inline object), `formula` (refused one door earlier, `INVALID_FIELD`), any other undeclared key, a `{ $field }` reference and every operator bag are still not judged by this refusal. diff --git a/.changeset/20745-nested-relation-prose.md b/.changeset/20745-nested-relation-prose.md new file mode 100644 index 0000000000..4f7cfbc4a8 --- /dev/null +++ b/.changeset/20745-nested-relation-prose.md @@ -0,0 +1,7 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): the `FilterCondition` docblock says the query engine refuses the nested-relation form + +`FilterCondition`'s form 4, `{ relation: { field: value } }`, stays in the type and the schema (nothing is narrowed: `FilterConditionSchema` parses it as before), and its docblock now states what the engine answers: `INVALID_FILTER` / 400 on every driver, because no data-path driver follows a relation into the related object. It names the route that works — filter the related object first, then match the relation field against the ids it returns (`$in`, or `$contains` per id on a multi-valued relation). The `QueryFilter` example no longer teaches the form, and the `Filter` nested arm's comment points at the refusal. diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index d55e1fa937..306ea894ea 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -178,12 +178,19 @@ where: { ], } -// Nested relation filter -where: { - account: { industry: 'tech' }, -} +// A condition on a related record's fields: filter the related object first, +// then match the lookup against the ids it returns +const tech = await engine.find('account', { where: { industry: 'tech' }, fields: ['id'] }); +where: { account: { $in: tech.map((a) => a.id) } } ``` +A plain object with no `$` operator beneath a field — `{ account: { industry: 'tech' } }` +under a lookup, `{ meta: { a: 1 } }` under a `json` field — is refused with +`INVALID_FILTER` / 400 on every driver: no driver follows a relation into the related +object, and a whole-value match on a JSON value means something different on each +backend. On a multi-valued lookup (`multiple: true`), match each id with `$contains` +(an `$or` of those for several ids) instead of `$in`. + **Supported operators:** `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$between`, `$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$null`, `$exists` **Logical operators:** `$and`, `$or`, `$not` diff --git a/packages/objectql/src/engine-nested-object-door.test.ts b/packages/objectql/src/engine-nested-object-door.test.ts new file mode 100644 index 0000000000..4251b5f963 --- /dev/null +++ b/packages/objectql/src/engine-nested-object-door.test.ts @@ -0,0 +1,369 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20745] A plain object with no `$`-operator key beneath a RELATION field + * (`lookup`, `master_detail`, `user`, `tree`, single or multiple) or a + * STRUCTURED-JSON field (`json`, `composite`, `address`, …), or beneath a + * platform-provisioned column the declared map omits (`id`), is refused + * `INVALID_FILTER` / 400 in the engine's words by the no-operator-object arm + * of the number-comparand door's walk — the arm #20546 opened for scalar + * columns, extended — at every position the engine judges: `where` (object + * form and `FilterArray` sugar, on every verb and the judge), + * `aggregations[i].filter` and `having`. + * + * Measured on the base (`origin/main` `a51920f5fb`) through + * `POST /api/v1/data/:object/query`, three rows (owner `u1`, region NA, on + * `d1` and `d3`): + * + * | position · filter | InMemoryDriver | SqlDriver, SQLite | SqlDriver, PostgreSQL 16 | + * |:--|:--|:--|:--| + * | `where` `{ owner: { region: 'NA' } }` (lookup; master-detail, multiple lookup, user, tree alike) | 200, no rows | 400, the driver's words | 400, the driver's words | + * | `where` `{ meta: { a: 1 } }` (json; address, composite alike) | 200, the deep-equal rows | 400 | 400 | + * | `where` `{ id: { a: 1 } }` | 200, no rows | 400 | 400 | + * | `aggregations[1].filter` `{ owner: { region: 'NA' } }` | count 0 | count 0 | count 0 | + * | `having` `{ owner: { region: 'NA' } }` over a lookup groupBy | no group | no group | no group | + * + * The InMemoryDriver cell is this suite's recording driver by construction: + * the arm answers before any driver is resolved, so no read runs. The SQL + * cells and the named routes over a real driver live in `@objectstack/rest`'s + * `data-nested-object-door.test.ts`. The routes on InMemoryDriver were + * measured (`d1`, `d3` for `$in` on a lookup and `$contains` on a multiple + * lookup) and are not pinned in a new suite: that driver's test consumers are + * a ruled, closed census (`check:driver-memory-census`). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + FieldType, + FILE_REFERENCE_TYPES, + REFERENCE_VALUE_TYPES, + STRUCTURED_JSON_TYPES, + type EngineAggregateOptions, + type EngineQueryOptions, + type FilterCondition, +} from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; +import { + holdsScalarValues, + noOperatorObjectColumnKind, + provisionedNoOperatorObjectColumn, +} from './no-operator-object-door.js'; + +const OBJECT = 'nested_object_probe'; +const OWNER = 'nested_object_owner'; + +const PROBE = { + name: OBJECT, + label: 'Nested object probe', + fields: { + title: { name: 'title', type: 'text' }, + amount: { name: 'amount', type: 'number' }, + owner: { name: 'owner', type: 'lookup', reference: OWNER }, + owners: { name: 'owners', type: 'lookup', reference: OWNER, multiple: true }, + boss: { name: 'boss', type: 'master_detail', reference: OWNER }, + assignee: { name: 'assignee', type: 'user' }, + parent: { name: 'parent', type: 'tree', reference: OBJECT }, + meta: { name: 'meta', type: 'json' }, + ship_to: { name: 'ship_to', type: 'address' }, + spec: { name: 'spec', type: 'composite' }, + // Never judged: the #8371 file carve-out. + photo: { name: 'photo', type: 'image' }, + }, +}; + +const OWNER_OBJECT = { name: OWNER, label: 'Owner', fields: { region: { name: 'region', type: 'text' } } }; + +/** field · declared type · the related object the words name · whether the route is `$contains`. */ +const RELATIONS: ReadonlyArray = [ + ['owner', 'lookup', OWNER, false], + ['owners', 'lookup', OWNER, true], + ['boss', 'master_detail', OWNER, false], + ['assignee', 'user', 'sys_user', false], + ['parent', 'tree', OBJECT, false], +]; + +/** field · declared type — structured-JSON columns. */ +const JSONS: ReadonlyArray = [ + ['meta', 'json'], + ['ship_to', 'address'], + ['spec', 'composite'], +]; + +interface SeenRead { ast: any } + +/** Minimal recording driver — the same witness shape as the sibling door suites. */ +function makeRecordingDriver() { + const rows = new Map>(); + const reads: SeenRead[] = []; + const writes: SeenRead[] = []; + const run = (_ast: any) => [...rows.values()]; + const driver: any = { + name: 'recording', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(_o: string, ast: any) { reads.push({ ast }); return run(ast); }, + async findOne(_o: string, ast: any) { reads.push({ ast }); return run(ast)[0] ?? null; }, + async count(_o: string, ast: any) { reads.push({ ast }); return run(ast).length; }, + async create(_o: string, data: Record) { + const id = (data.id as string) ?? `r_${rows.size + 1}`; + const row = { ...data, id }; rows.set(id, row); return row; + }, + async update(_o: string, id: string, data: Record) { + const cur = rows.get(id) ?? {}; + const up = { ...cur, ...data, id }; rows.set(id, up); return up; + }, + async updateMany(_o: string, ast: any) { writes.push({ ast }); return 0; }, + async delete(_o: string, id: string) { return rows.delete(id); }, + async deleteMany(_o: string, ast: any) { writes.push({ ast }); return 0; }, + async bulkCreate(o: string, batch: Record[]) { + return Promise.all(batch.map((r) => this.create(o, r))); + }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, reads, writes }; +} + +type Thrown = (Error & { code?: string; status?: number; httpStatus?: number }) | null; + +const refusalOf = async (p: Promise): Promise => + p.then(() => null, (e: any) => e as Error & { code?: string; status?: number }); + +const ENVELOPE = { code: 'INVALID_FILTER', status: 400 }; + +const envelopeOf = (err: Thrown) => ({ code: err?.code, status: err?.status }); + +describe('[#20745] a no-operator object beneath a relation, structured-JSON or provisioned column, at the engine collection point', () => { + let engine: ObjectQL; + let reads: SeenRead[]; + let writes: SeenRead[]; + + beforeEach(async () => { + const rec = makeRecordingDriver(); + reads = rec.reads; + writes = rec.writes; + engine = new ObjectQL(); + engine.registerDriver(rec.driver, true); + await engine.init(); + engine.registry.registerObject(OWNER_OBJECT as any, 'test'); + engine.registry.registerObject(PROBE as any, 'test'); + reads.length = 0; + writes.length = 0; + }); + + // ── where ──────────────────────────────────────────────────────────────── + + it('refuses the nested-relation form beneath every relation type, single or multiple, naming the route that works — no read', async () => { + for (const [field, type, related, multiple] of RELATIONS) { + const err = await refusalOf(engine.find(OBJECT, { where: { [field]: { region: 'NA' } } as FilterCondition })); + expect(envelopeOf(err), field).toEqual(ENVELOPE); + expect(err!.httpStatus, field).toBe(400); + expect(err!.message, field).toMatch(/^find\('nested_object_probe'\): /); + expect(err!.message, field).toContain(`filter on '${field}'`); + expect(err!.message, field).toContain(`at where.${field},`); + expect(err!.message, field).toContain(`beneath the declared ${type} field '${field}'`); + expect(err!.message, field).toContain('nested-relation form'); + expect(err!.message, field).toContain('NOT applied'); + expect(err!.message, field).toContain(`Filter the related object '${related}' first`); + expect(err!.message, field).toContain( + multiple ? `{ "${field}": { "$contains": ID } }` : `{ "${field}": { "$in": [ID, …] } }`, + ); + } + expect(reads).toHaveLength(0); + }); + + it('refuses a whole-value object beneath every structured-JSON type, naming what every driver answers alike — no read', async () => { + for (const [field, type] of JSONS) { + const err = await refusalOf(engine.find(OBJECT, { where: { [field]: { a: 1 } } as FilterCondition })); + expect(envelopeOf(err), field).toEqual(ENVELOPE); + expect(err!.message, field).toContain(`at where.${field},`); + expect(err!.message, field).toContain(`as the value of the declared ${type} field '${field}'`); + expect(err!.message, field).toContain('whole-value match'); + expect(err!.message, field).toContain(`{ "${field}": { "$null": false } }`); + expect(err!.message, field).not.toContain('$contains'); + } + expect(reads).toHaveLength(0); + }); + + it('refuses it beneath the platform-provisioned id column the declared map omits, in the scalar words', async () => { + const err = await refusalOf(engine.find(OBJECT, { where: { id: { a: 1 } } as FilterCondition })); + expect(envelopeOf(err)).toEqual(ENVELOPE); + expect(err!.message).toContain('at where.id,'); + expect(err!.message).toContain("the platform-provisioned text column 'id'"); + expect(err!.message).toContain('holds scalar values'); + expect(reads).toHaveLength(0); + }); + + it('refuses {} beneath a relation and a JSON column too, in the engine\'s words rather than each driver\'s', async () => { + for (const [where, words] of [ + [{ owner: {} }, 'nested-relation form'], + [{ meta: {} }, 'whole-value match'], + ] as const) { + const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition })); + expect(envelopeOf(err), JSON.stringify(where)).toEqual(ENVELOPE); + expect(err!.message, JSON.stringify(where)).toContain('an empty object {}'); + expect(err!.message, JSON.stringify(where)).toContain(words); + } + expect(reads).toHaveLength(0); + }); + + it('covers every engine verb that collects a filter — read and write sides — and the judge', async () => { + for (const where of [{ owner: { region: 'NA' } }, { meta: { a: 1 } }] as FilterCondition[]) { + const path = `at where.${Object.keys(where)[0]},`; + for (const call of [ + () => engine.find(OBJECT, { where }), + () => engine.findOne(OBJECT, { where }), + () => engine.count(OBJECT, { where }), + () => engine.aggregate(OBJECT, { where, aggregations: [{ function: 'count', alias: 'n' }] } as EngineAggregateOptions), + () => engine.update(OBJECT, { title: 'x' }, { where, multi: true }), + () => engine.delete(OBJECT, { where, multi: true }), + ]) { + const err = await refusalOf(call()); + expect(envelopeOf(err), path).toEqual(ENVELOPE); + expect(err!.message, path).toContain(path); + } + expect(engine.judgeFilter(OBJECT, where)).toMatchObject({ ok: false, ...ENVELOPE }); + } + expect(reads).toHaveLength(0); + expect(writes).toHaveLength(0); + }); + + it('reaches inside $and / $or / $not, and answers the FilterArray sugar alike', async () => { + const cases: ReadonlyArray = [ + [{ $and: [{ title: 'a' }, { owner: { region: 'NA' } }] }, 'where.$and[1].owner'], + [{ $or: [{ meta: { a: 1 } }, { amount: 30 }] }, 'where.$or[0].meta'], + [{ $not: { boss: { region: 'NA' } } }, 'where.$not.boss'], + ]; + for (const [where, path] of cases) { + const err = await refusalOf(engine.find(OBJECT, { where })); + expect(envelopeOf(err), path).toEqual(ENVELOPE); + expect(err!.message, path).toContain(`at ${path},`); + } + const sugar = await refusalOf( + engine.find(OBJECT, { where: [['owner', '=', { region: 'NA' }]] } as unknown as EngineQueryOptions), + ); + expect(envelopeOf(sugar)).toEqual(ENVELOPE); + expect(sugar!.message).toContain('at where.owner,'); + expect(reads).toHaveLength(0); + }); + + it('CONTROL the named routes, a file field and an unknown key reach the driver exactly as written', async () => { + for (const where of [ + { owner: { $in: ['u1'] } }, + { owners: { $contains: 'u1' } }, + { $or: [{ owners: { $contains: 'u1' } }, { owners: { $contains: 'u2' } }] }, + { owner: 'u1' }, + { meta: { $null: false } }, + { id: 'd1' }, + { id: { $in: ['d1', 'd3'] } }, + // The #8371 carve-out: a legacy stored file value is an inline object. + { photo: { url: 'x' } }, + // The registry-less tolerance: no second opinion about a name. + { not_a_field: { a: 1 } }, + ] as FilterCondition[]) { + reads.length = 0; + await expect(engine.find(OBJECT, { where }), JSON.stringify(where)).resolves.toBeDefined(); + expect(reads, JSON.stringify(where)).toHaveLength(1); + expect(reads[0]?.ast?.where, JSON.stringify(where)).toEqual(where); + } + }); + + // ── the per-aggregation `filter` and `having` ──────────────────────────── + + it('refuses it in ONE aggregation\'s own filter, rooted at that position — no read', async () => { + for (const [field, spec, words] of [ + ['owner', { region: 'NA' }, 'nested-relation form'], + ['meta', { a: 1 }, 'whole-value match'], + ] as const) { + reads.length = 0; + const err = await refusalOf(engine.aggregate(OBJECT, { + aggregations: [ + { function: 'count', alias: 'all' }, + { function: 'count', alias: 'bad', filter: { [field]: spec } }, + ], + } as EngineAggregateOptions)); + expect(envelopeOf(err), field).toEqual(ENVELOPE); + expect(err!.message, field).toMatch(/^aggregate\('nested_object_probe'\): /); + expect(err!.message, field).toContain(`at aggregations[1].filter.${field},`); + expect(err!.message, field).toContain(words); + expect(reads, field).toHaveLength(0); + } + }); + + it('refuses it in having over a relation or JSON column, naming the aggregated column — no read', async () => { + const cases: ReadonlyArray = [ + [{ groupBy: ['owner'], aggregations: [{ function: 'count', alias: 'n' }], having: { owner: { region: 'NA' } } }, 'owner', 'lookup', 'nested-relation form'], + [{ groupBy: ['title'], aggregations: [{ function: 'max', field: 'boss', alias: 'top' }], having: { top: { region: 'NA' } } }, 'top', 'master_detail', 'nested-relation form'], + [{ groupBy: ['meta'], aggregations: [{ function: 'count', alias: 'n' }], having: { meta: { a: 1 } } }, 'meta', 'json', 'whole-value match'], + ] as ReadonlyArray; + for (const [query, column, type, words] of cases) { + reads.length = 0; + const err = await refusalOf(engine.aggregate(OBJECT, query)); + expect(envelopeOf(err), column).toEqual(ENVELOPE); + expect(err!.message, column).toContain(`at having.${column},`); + expect(err!.message, column).toContain(`the aggregated column '${column}', which carries a ${type} value`); + expect(err!.message, column).toContain(words); + expect(reads, column).toHaveLength(0); + } + }); + + it('CONTROL a file field stays unjudged in the per-aggregation filter and in having', async () => { + await expect(engine.aggregate(OBJECT, { + aggregations: [ + { function: 'count', alias: 'all' }, + { function: 'count', alias: 'p', filter: { photo: { url: 'x' } } }, + ], + } as EngineAggregateOptions)).resolves.toBeDefined(); + await expect(engine.aggregate(OBJECT, { + groupBy: ['photo'], + aggregations: [{ function: 'count', alias: 'n' }], + having: { photo: { url: 'x' } }, + } as EngineAggregateOptions)).resolves.toBeDefined(); + }); + + // ── the REST doors that reach findData ────────────────────────────────── + + describe('the REST doors — one answer however the query arrived', () => { + let protocol: ObjectStackProtocolImplementation; + + beforeEach(() => { + protocol = new ObjectStackProtocolImplementation(engine); + }); + + const DOORS: ReadonlyArray<{ door: string; query: Record }> = [ + { door: 'where object', query: { where: { owner: { region: 'NA' } } } }, + { door: '$filter string', query: { $filter: JSON.stringify({ meta: { a: 1 } }) } }, + { door: 'filter AST', query: { filter: [['owner', '=', { region: 'NA' }]] } }, + ]; + + it.each(DOORS)('the $door door refuses it', async ({ query }) => { + const err = await refusalOf(protocol.findData({ object: OBJECT, query } as any)); + expect(envelopeOf(err)).toEqual(ENVELOPE); + expect(err!.message).toContain('is filter structure, not a value'); + expect(reads).toHaveLength(0); + }); + }); + + // ── the classification ─────────────────────────────────────────────────── + + it('GUARD the three judged kinds are the spec\'s classes, and file and media types, formula and unknown types are never judged', () => { + for (const type of FieldType.options) { + const expected = holdsScalarValues(type) + ? 'scalar' + : REFERENCE_VALUE_TYPES.has(type) ? 'relation' : STRUCTURED_JSON_TYPES.has(type) ? 'json' : null; + expect(noOperatorObjectColumnKind(type), type).toBe(expected); + } + for (const type of [...FILE_REFERENCE_TYPES, 'formula', 'not_a_type']) { + expect(noOperatorObjectColumnKind(type), type).toBeNull(); + } + }); + + it('GUARD only the three platform-provisioned columns are judged when the declared map omits them', () => { + expect(provisionedNoOperatorObjectColumn('id')).toMatchObject({ kind: 'scalar', type: 'text', provisioned: true }); + expect(provisionedNoOperatorObjectColumn('created_at')).toMatchObject({ kind: 'scalar', type: 'datetime' }); + expect(provisionedNoOperatorObjectColumn('updated_at')).toMatchObject({ kind: 'scalar', type: 'datetime' }); + for (const key of ['owner_id', 'organization_id', 'not_a_field', '_id']) { + expect(provisionedNoOperatorObjectColumn(key), key).toBeNull(); + } + }); +}); diff --git a/packages/objectql/src/engine-no-operator-object-door.test.ts b/packages/objectql/src/engine-no-operator-object-door.test.ts index fe7f2d32e2..7d5549ee5e 100644 --- a/packages/objectql/src/engine-no-operator-object-door.test.ts +++ b/packages/objectql/src/engine-no-operator-object-door.test.ts @@ -24,9 +24,11 @@ * the two controls over a real driver live in `@objectstack/rest`'s * `data-no-operator-object-door.test.ts`. * - * The two controls triage named stay accepted: a relation field's nested - * relation filter and a JSON-typed field's object comparand reach the driver - * exactly as written. + * The two controls triage named here — a relation field's nested relation + * filter and a JSON-typed field's object comparand — were judged by the same + * arm in #20745, in words of their own (`engine-nested-object-door.test.ts` + * pins them). What stays accepted is a file or media field (the #8371 + * carve-out), which reaches the driver exactly as written. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -64,7 +66,8 @@ const PROBE = { placed_on: { name: 'placed_on', type: 'date' }, seen_at: { name: 'seen_at', type: 'datetime' }, code: { name: 'code', type: 'autonumber' }, - // The accepted side. + // Judged by #20745 in their own words (`engine-nested-object-door.test.ts`); + // only the file field is still the accepted side. owner: { name: 'owner', type: 'lookup', reference: OWNER }, owners: { name: 'owners', type: 'lookup', reference: OWNER, multiple: true }, boss: { name: 'boss', type: 'master_detail', reference: OWNER }, @@ -92,11 +95,6 @@ const JUDGED: ReadonlyArray = [ /** name · a field on the accepted side · the no-operator object it may carry. */ const ACCEPTED: ReadonlyArray]> = [ - ['owner', { region: 'NA' }], - ['owners', { region: 'NA' }], - ['boss', { region: 'NA' }], - ['meta', { a: 1 }], - ['ship_to', { city: 'Paris' }], ['photo', { url: 'x' }], ]; @@ -236,7 +234,7 @@ describe('[#20546] a no-operator object where a scalar column\'s value belongs, expect(reads).toHaveLength(0); }); - it('CONTROL the accepted side reaches the driver exactly as written: relation, JSON-bearing and file fields', async () => { + it('CONTROL the accepted side reaches the driver exactly as written: a file field', async () => { for (const [field, spec] of ACCEPTED) { reads.length = 0; const where = { [field]: spec } as FilterCondition; @@ -244,8 +242,7 @@ describe('[#20546] a no-operator object where a scalar column\'s value belongs, expect(reads, field).toHaveLength(1); expect(reads[0]?.ast?.where, field).toEqual(where); } - expect(engine.judgeFilter(OBJECT, { owner: { region: 'NA' } })).toEqual({ ok: true }); - expect(engine.judgeFilter(OBJECT, { meta: { a: 1 } })).toEqual({ ok: true }); + expect(engine.judgeFilter(OBJECT, { photo: { url: 'x' } })).toEqual({ ok: true }); }); it('CONTROL an operator bag, a { $field } reference and a scalar comparand are not this arm\'s', async () => { @@ -314,20 +311,14 @@ describe('[#20546] a no-operator object where a scalar column\'s value belongs, await expect(engine.aggregate(OBJECT, { aggregations: [ { function: 'count', alias: 'all' }, - { function: 'count', alias: 'na', filter: { owner: { region: 'NA' } } }, - { function: 'count', alias: 'm', filter: { meta: { a: 1 } } }, + { function: 'count', alias: 'p', filter: { photo: { url: 'x' } } }, ], } as EngineAggregateOptions)).resolves.toBeDefined(); - for (const [groupBy, having] of [ - ['meta', { meta: { a: 1 } }], - ['owner', { owner: { region: 'NA' } }], - ] as const) { - await expect(engine.aggregate(OBJECT, { - groupBy: [groupBy], - aggregations: [{ function: 'count', alias: 'n' }], - having, - } as EngineAggregateOptions), groupBy).resolves.toBeDefined(); - } + await expect(engine.aggregate(OBJECT, { + groupBy: ['photo'], + aggregations: [{ function: 'count', alias: 'n' }], + having: { photo: { url: 'x' } }, + } as EngineAggregateOptions)).resolves.toBeDefined(); }); // ── the REST doors that reach findData ────────────────────────────────── diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 4614dc910a..df76c26cb0 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1026,6 +1026,9 @@ function lowerWhereFilterArray( // plain object with no `$` key where a scalar column's value belongs // (`{ amount: { a: 1 } }`) is refused `INVALID_FILTER` / 400. Memory // answered it with no rows (every row under `$not`), SQL with its own 400. + // [#20745] …and beneath a relation column (the nested-relation form no + // driver serves), a structured-JSON column (a whole-value match the + // drivers share no meaning for) and an undeclared `id`, in words per kind. const numeric = narrowNumberComparands(object, operation, schema, where); // [#7872] The comparand-type door, on the OBJECT form. `parseFilterAST` // runs the same walk on everything it lowers or passes through, but @@ -16368,7 +16371,9 @@ export class ObjectQL implements IObjectQLEngine { // narrowed to its number, copy-on-write, before the in-memory // evaluator compares it. Rooted at this position. [#20546] Its // walk's no-operator-object arm too: `{ amount: { a: 1 } }` here - // counted no row, silently, on every driver. + // counted no row, silently, on every driver. [#20745] So did + // `{ owner: { region: 'NA' } }` beneath a lookup; a JSON object + // here is refused alike, one answer per filter at every position. const numeric = narrowNumberComparands( object, 'aggregate', this._registry.getObject(object), aggFilter, `aggregations[${i}].filter`, ); @@ -16505,7 +16510,9 @@ export class ObjectQL implements IObjectQLEngine { // [#20546] The same walk's no-operator-object arm judges every column // whose TYPE holds scalar values (hence the types, beside the // classes): `{ total: { a: 1 } }` kept no group, silently, on every - // driver, where its `where` twin answered two ways. + // driver, where its `where` twin answered two ways. [#20745] A + // relation or JSON column's type is judged too (a lookup groupBy's + // nested-relation `having` kept no group on every driver). const numeric = narrowHavingNumberComparands( object, having, havingColumnClasses, aggregatedRowColumnTypes(query.groupBy, query.aggregations, declaredFields), diff --git a/packages/objectql/src/having-filter.ts b/packages/objectql/src/having-filter.ts index f78ca55920..5dbf911113 100644 --- a/packages/objectql/src/having-filter.ts +++ b/packages/objectql/src/having-filter.ts @@ -720,6 +720,8 @@ function declaredFieldClass( * the number-comparand door's walk reads it at `having`: the `text` class * lumps a `json` or `lookup` groupBy in with a real text column, and only the * type tells a column that holds scalar values from one that does not. + * [#20745] The same type tells the arm's other two kinds apart — a relation + * column and a structured-JSON column — each refused in words of its own. */ export function aggregatedRowColumnTypes( groupBy: unknown, diff --git a/packages/objectql/src/no-operator-object-door.ts b/packages/objectql/src/no-operator-object-door.ts index da382e4169..248297a9d5 100644 --- a/packages/objectql/src/no-operator-object-door.ts +++ b/packages/objectql/src/no-operator-object-door.ts @@ -51,7 +51,7 @@ * spelling does not: `{ tags: { 0: 'x' } }` over a `multiple: true` select * answered 200 with no rows on InMemoryDriver and 400 on both SQL dialects, * the same split as the scalar case (measured on the base above). - * - **The accepted side, never judged:** a relation (`lookup`, + * - **The accepted side, never judged here:** a relation (`lookup`, * `master_detail`, `user`, `tree`, single or multiple) — `{ owner: { region: * 'NA' } }` is a nested-relation condition, the form `FilterCondition` * declares; a structured-JSON type (`json`, `composite`, `address`, …) — an @@ -59,6 +59,8 @@ * stored value is an inline metadata object); `formula` (refused one door * earlier, `INVALID_FIELD`); and a type this module has not met. That is * the fail-open direction every neighbour takes: a hole, not a false 400. + * [#20745] The relation and structured-JSON rows are judged now, each in + * words of its own — see the section below. * - **A `{ $field }` reference is never this arm's**: it carries a `$` key. So * does every operator bag, however malformed — the drivers and the * comparand doors answer those. @@ -74,21 +76,144 @@ * words one call later, and a `Date` or an array is a comparand too: none of * them is filter structure. * + * ## [#20745] Two more judged kinds: relation and structured-JSON columns + * + * The accepted side above was accepted by direction, not because anything + * served it. Measured on `origin/main` `a51920f5fb` through + * `POST /api/v1/data/:object/query`, three rows (owner `u1` in region NA on + * `d1` and `d3`): + * + * | `where` | InMemoryDriver | SqlDriver, SQLite | SqlDriver, PostgreSQL 16 | + * |:--|:--|:--|:--| + * | `{ owner: { region: 'NA' } }` under a `lookup`, and its `master_detail`, `multiple: true` lookup, `user` and `tree` twins | 200, **no rows** (`d1` and `d3` were meant) | 400 `INVALID_FILTER`, the driver's words | same as SQLite | + * | `{ meta: { a: 1 } }` under a `json` field; `{ ship_to: { city: 'Paris' } }` under an `address`, and a `composite` twin | 200, the deep-equal rows | 400, the driver's words | same | + * | `{ id: { a: 1 } }` — `id` is absent from the declared map | 200, no rows | 400, the driver's words | same | + * | `aggregations[1].filter` `{ owner: { region: 'NA' } }` | count 0 | count 0 | count 0 | + * | `having` `{ owner: { region: 'NA' } }` over a `lookup` groupBy | no group | no group | no group | + * + * So the arm now judges three kinds of column, each with its own words + * ({@link noOperatorObjectColumnKind}, a closed definition from the spec's + * classes again): + * + * - **`scalar`** — as above, and now also a PLATFORM-PROVISIONED column the + * declared map omits (`id`, `created_at`, `updated_at`: the three every + * record carries and `find` / `findOne` / the write gate admit + * unconditionally, {@link provisionedNoOperatorObjectColumn}). The declared + * map deciding "is this a column" would have left `id` to the drivers. + * - **`relation`** — {@link REFERENCE_VALUE_TYPES} (`lookup`, + * `master_detail`, `user`, `tree`), single or multiple. The object is the + * nested-relation form `FilterCondition` declares, and no data-path driver + * serves it: the column stores the related record's id. The words name the + * route every driver serves today — filter the related object, then match + * the ids it returns: `$in` for a single-valued field, `$contains` per id + * for a multi-valued one, whose JSON column the SQL driver refuses `$in` on + * (both measured on all three cells: rows `d1` and `d3`). A dotted path + * (`'owner.region'`) is no route: the #8371 dotted verdict refuses it on + * every driver, one door earlier. + * - **`json`** — {@link STRUCTURED_JSON_TYPES} (`json`, `composite`, + * `address`, `location`, …). The object is a whole-value match, and the + * drivers share no meaning for one: memory compares documents, SQL refuses + * the bind. The words name what every driver answers alike: `$null` / + * `$exists` over the whole value, or a stored field holding the part the + * filter is about. `$contains` is no route here — the text-operator door + * refuses it over a JSON-valued column on every driver. + * + * **Still never judged:** file and media types (the #8371 carve-out — a + * legacy stored value is an inline metadata object), `formula` (refused one + * door earlier, `INVALID_FIELD`), a type this module has not met, and an + * undeclared key that is not platform-provisioned (the engine's registry-less + * tolerance: no second opinion about a name). + * * @see https://github.com/objectstack-ai/objectstack/issues/20546 + * @see https://github.com/objectstack-ai/objectstack/issues/20745 */ -import { MULTI_OPTION_TYPES, SCALAR_FILTER_HEAD_TYPES } from '@objectstack/spec/data'; +import { + isMultiValueField, + MULTI_OPTION_TYPES, + REFERENCE_VALUE_TYPES, + referenceTargetOf, + SCALAR_FILTER_HEAD_TYPES, + STRUCTURED_JSON_TYPES, +} from '@objectstack/spec/data'; /** * Does a column of this declared type hold scalar values — one, or a list of * scalar members — so that an object beneath it can match nothing? The arm's - * one classification; see the module header for the closed definition and the + * `scalar` kind; see the module header for the closed definition and the * accepted side. */ export function holdsScalarValues(type: string): boolean { return SCALAR_FILTER_HEAD_TYPES.has(type) || MULTI_OPTION_TYPES.has(type); } +/** + * [#20745] The three kinds of column the arm judges, each refused in words of + * its own: a column holding scalar values, a relation column (it stores the + * related record's id), and a structured-JSON column (a whole-value match has + * no meaning the drivers share). + */ +export type NoOperatorObjectColumnKind = 'scalar' | 'relation' | 'json'; + +/** + * [#20745] Which kind of column the arm judges a declared type as, or `null` + * for a type it never judges (file and media, `formula`, a type it has not + * met). One closed definition, from the spec's classes; see the module header. + */ +export function noOperatorObjectColumnKind(type: string): NoOperatorObjectColumnKind | null { + if (holdsScalarValues(type)) return 'scalar'; + if (REFERENCE_VALUE_TYPES.has(type)) return 'relation'; + if (STRUCTURED_JSON_TYPES.has(type)) return 'json'; + return null; +} + +/** What the arm knows about one judged column — what its words are written from. */ +export interface NoOperatorObjectColumn { + readonly kind: NoOperatorObjectColumnKind; + /** The declared `FieldType` — for `having`, the type the aggregated column carries. */ + readonly type: string; + /** A field declaration to read the relation route from; absent for an aggregated or provisioned column. */ + readonly def?: unknown; + /** The column is platform-provisioned and absent from the declared map (`id`, …). */ + readonly provisioned?: boolean; +} + +/** + * [#20745] The judged column a field declaration names, or `null` when the + * arm does not judge its type. Reads the declaration's `type` only; the + * relation words read the rest of it, and only when a refusal is written. + */ +export function declaredNoOperatorObjectColumn(def: unknown): NoOperatorObjectColumn | null { + if (typeof def !== 'object' || def === null) return null; + const type = (def as { type?: unknown }).type; + if (typeof type !== 'string') return null; + const kind = noOperatorObjectColumnKind(type); + return kind === null ? null : { kind, type, def }; +} + +/** + * [#20745] The columns every record carries whether or not the declared map + * lists them, with the type each stores: the same three names `find` / + * `findOne` add to their known set and the write gate admits unconditionally + * (`PLATFORM_PROVISIONED_COLUMNS` in `engine.ts`), because the platform + * provisions them rather than the author declaring them. + */ +const PLATFORM_PROVISIONED_COLUMN_TYPES: ReadonlyMap = new Map([ + ['id', 'text'], + ['created_at', 'datetime'], + ['updated_at', 'datetime'], +]); + +/** + * [#20745] The judged column a key names when the declared map omits it: a + * platform-provisioned column, judged by the type it stores — or `null` for + * any other undeclared key, which keeps the engine's registry-less tolerance. + */ +export function provisionedNoOperatorObjectColumn(key: string): NoOperatorObjectColumn | null { + const type = PLATFORM_PROVISIONED_COLUMN_TYPES.get(key); + return type === undefined ? null : { kind: 'scalar', type, provisioned: true }; +} + /** * Is this field spec a PLAIN object with no `$`-operator key — filter * structure where a value belongs? `{}` included; a `Map`, a class instance, a @@ -101,12 +226,12 @@ export function isNoOperatorObject(spec: unknown): spec is Record key.startsWith('$')); } -/** What the arm found: the column, its declaration, where it sits, and the object's keys. */ +/** What the arm found: the column, its kind and type, where it sits, and the object's keys. */ export interface NoOperatorObjectRefusal { /** The filter key, which names the column. */ readonly field: string; - /** Its declared `FieldType` — for `having`, the type the aggregated column carries. */ - readonly declaredType: string; + /** The judged column: its kind, its type and — for a declared field — its declaration. */ + readonly column: NoOperatorObjectColumn; /** The key path the object sits at (`where.amount`, `having.total`, …). */ readonly path: string; /** The object's own keys, in order — `[]` for `{}`. */ @@ -123,23 +248,90 @@ function describeObject(keys: readonly string[]): string { return `an object with no operator key (keys ${shown}${more})`; } +/** The column, as the words name it at its position. */ +function describeColumn(refusal: NoOperatorObjectRefusal): string { + const { field, column } = refusal; + if (refusal.aggregated) return `the aggregated column '${field}', which carries a ${column.type} value`; + if (column.provisioned) return `the platform-provisioned ${column.type} column '${field}'`; + return `the declared ${column.type} field '${field}'`; +} + /** - * The refusal's words. Every position reads the same, less the subject: a - * declared field at `where` and `aggregations[i].filter`, an aggregated column - * at `having`. No tracker id: the lesson is in the sentence. + * The scalar kind's words (#20546), less the context and the object. + * + * [#20745] Every kind's words put the verdict and the route FIRST and the + * reasoning after: the REST door bounds a 4xx message at 500 characters by + * truncation (`CLIENT_MESSAGE_MAX`, `@objectstack/rest`'s + * `error-response.ts`), so what a caller must do next has to land inside it. */ -export function noOperatorObjectRefusalMessage(refusal: NoOperatorObjectRefusal, context: string): string { - const subject = refusal.aggregated - ? `the aggregated column '${refusal.field}', which carries a ${refusal.declaredType} value` - : `the declared ${refusal.declaredType} field '${refusal.field}'`; +function scalarWords(refusal: NoOperatorObjectRefusal): string { + const { field, column } = refusal; + return ( + `where a value of ${describeColumn(refusal)} belongs. An object with no "$" operator is filter ` + + 'structure, not a value. The filter was NOT applied. Compare ' + + `'${field}' with a value ({ "${field}": VALUE }) or an operator ({ "${field}": { "$eq": VALUE } }). ` + + `A ${column.type} column holds scalar values — one, or a list of them — so no record can match an ` + + 'object there, and an empty answer would read exactly like a real one.' + ); +} + +/** + * [#20745] The relation kind's words. The route is the one every data-path + * driver serves today (measured): the related object's own query, then its + * ids — `$in` on a single-valued column, `$contains` per id on a multi-valued + * one. An aggregated column names no declaration to read either from, so it + * is given the single-valued spelling. + */ +function relationWords(refusal: NoOperatorObjectRefusal): string { + const { field, column } = refusal; + const def = column.def as { type: string; multiple?: boolean } | undefined; + const target = def === undefined ? undefined : referenceTargetOf(def); + const related = target === undefined ? 'the related object' : `the related object '${target}'`; + const multiple = def !== undefined && isMultiValueField(def); + const match = multiple + ? `{ "${field}": { "$contains": ID } } for one id, an $or of those for several` + : `{ "${field}": { "$in": [ID, …] } }`; return ( - `${context}: filter on '${refusal.field}' puts ${describeObject(refusal.keys)} at ${refusal.path}, ` - + `where a value of ${subject} belongs. An object with no "$" operator is filter structure, not ` - + 'a value: beneath a field it is a nested-relation condition, which only a relation field (lookup, ' - + 'master-detail, user, tree) can carry, or a whole-value match, which only a JSON-bearing field ' - + `can hold. A ${refusal.declaredType} column holds scalar values — one, or a list of them — so no ` - + 'record can match an object there, and an empty answer would read exactly like a real one. The ' - + `filter was NOT applied. Compare '${refusal.field}' with a value ({ "${refusal.field}": VALUE }) ` - + `or an operator ({ "${refusal.field}": { "$eq": VALUE } }).` + `beneath ${describeColumn(refusal)} — the nested-relation form, which the engine does not serve. ` + + `The filter was NOT applied. Filter ${related} first, then match '${field}' against the ids it ` + + `returns: ${match}. An object with no "$" operator is filter structure, not a value: '${field}' ` + + 'stores the related record\'s id, no driver follows it into the related object, and an empty ' + + 'answer would read exactly like a real one.' ); } + +/** + * [#20745] The structured-JSON kind's words. The route is what every driver + * answers alike: presence of the whole value, or a stored field of its own for + * the part the filter is about (the dotted path into a JSON value is live on + * some backends and silently empty on others, so it is not offered). + */ +function jsonWords(refusal: NoOperatorObjectRefusal): string { + const { field } = refusal; + return ( + `as the value of ${describeColumn(refusal)} — a whole-value match, which the engine does not ` + + `serve. The filter was NOT applied. Test the whole value's presence with { "${field}": { "$null": ` + + 'false } }, or store the part you filter on in a field of its own and filter that field. An object ' + + 'with no "$" operator is filter structure, not a value, and the drivers share no meaning for a ' + + 'whole-value match: one compares the documents, another refuses the bind.' + ); +} + +/** + * The refusal's words. Every position reads the same, less the subject: a + * declared (or platform-provisioned) field at `where` and + * `aggregations[i].filter`, an aggregated column at `having`. Each kind of + * column has its own middle and its own route (#20745). No tracker id: the + * lesson is in the sentence. + */ +export function noOperatorObjectRefusalMessage(refusal: NoOperatorObjectRefusal, context: string): string { + const head = `${context}: filter on '${refusal.field}' puts ${describeObject(refusal.keys)} at ${refusal.path}, `; + switch (refusal.column.kind) { + case 'relation': + return head + relationWords(refusal); + case 'json': + return head + jsonWords(refusal); + default: + return head + scalarWords(refusal); + } +} diff --git a/packages/objectql/src/number-comparand-declared-type-door.ts b/packages/objectql/src/number-comparand-declared-type-door.ts index 16151b29f6..ea19d19592 100644 --- a/packages/objectql/src/number-comparand-declared-type-door.ts +++ b/packages/objectql/src/number-comparand-declared-type-door.ts @@ -135,7 +135,11 @@ * such column, not only a numeric one — is refused with `INVALID_FILTER` / * 400, naming the field and the path. It is asked first at every field key; * the number arm reads what it lets through. That module holds the arm's - * classification and words; ⛔ nothing there walks a filter. + * classification and words; ⛔ nothing there walks a filter. [#20745] The + * same arm now judges a relation column (the nested-relation form no driver + * serves) and a structured-JSON column (a whole-value match the drivers share + * no meaning for), and a platform-provisioned column the declared map omits + * (`id`, …): the same walk and the same three positions, words per kind. * * @see numberComparandDoorVerdict — the pure verdict (lane 1, `@objectstack/spec`). * @see https://github.com/objectstack-ai/objectstack/issues/20336 (the contract) @@ -155,9 +159,12 @@ import { import { invalidFilterError } from './filter-comparand-shape.js'; import type { AggregatedColumnClass } from './having-filter.js'; import { - holdsScalarValues, + declaredNoOperatorObjectColumn, isNoOperatorObject, + noOperatorObjectColumnKind, noOperatorObjectRefusalMessage, + provisionedNoOperatorObjectColumn, + type NoOperatorObjectColumn, type NoOperatorObjectRefusal, } from './no-operator-object-door.js'; @@ -178,10 +185,11 @@ interface KeyFacts { /** The number arm's field meta — `null` when that arm has nothing to judge here. */ readonly number: NumberComparandDoorFieldMeta | null; /** - * [#20546] The column's declared type when it holds scalar values - * (`holdsScalarValues`), so the no-operator-object arm judges it — else `null`. + * [#20546] The column the no-operator-object arm judges — else `null`. + * [#20745] Any of its three kinds (a scalar-valued, a relation or a + * structured-JSON column), each refused in words of its own. */ - readonly scalarType: string | null; + readonly column: NoOperatorObjectColumn | null; } /** What one filter position supplies to the walk: the facts a KEY names, or `null`. */ @@ -369,13 +377,16 @@ function walkCondition(factsOf: FactsOf, node: unknown, path: string, depth: num if (!facts) continue; // [#20546] The no-operator-object arm, first: filter structure where a // scalar column's value belongs can match no record on any backend, so - // it is refused whichever arm would otherwise read the value. - if (facts.scalarType !== null && isNoOperatorObject(value)) { + // it is refused whichever arm would otherwise read the value. [#20745] + // Beneath a relation or a structured-JSON column too: no driver serves + // the nested-relation form, and none shares a meaning for a whole-value + // match. + if (facts.column !== null && isNoOperatorObject(value)) { return { ok: false, refusal: { arm: 'no-operator-object', - site: { field: key, declaredType: facts.scalarType, path: here, keys: Object.keys(value), aggregated: ctx.aggregated }, + site: { field: key, column: facts.column, path: here, keys: Object.keys(value), aggregated: ctx.aggregated }, }, }; } @@ -399,10 +410,16 @@ function declaredFactsOf(schema: unknown): FactsOf | null { const fields = (schema as { fields?: Record } | undefined)?.fields; if (!fields || typeof fields !== 'object') return null; return (key) => { - if (!Object.prototype.hasOwnProperty.call(fields, key)) return null; + if (!Object.prototype.hasOwnProperty.call(fields, key)) { + // [#20745] A platform-provisioned column the map omits (`id`, …) is a + // column all the same, and the arm judges it by the type it stores; + // every other undeclared key keeps the registry-less tolerance. + const provisioned = provisionedNoOperatorObjectColumn(key); + return provisioned === null ? null : { number: null, column: provisioned }; + } const meta = fieldMetaOf(fields[key]); if (!meta) return null; - return { number: meta, scalarType: holdsScalarValues(meta.type) ? meta.type : null }; + return { number: meta, column: declaredNoOperatorObjectColumn(fields[key]) }; }; } @@ -486,7 +503,11 @@ export function narrowNumberComparands( * [#20546] `types` (`aggregatedRowColumnTypes`, from the same reading of the * query as `classes`) is what the walk's no-operator-object arm reads here: * the column's type, since the class lumps a `json` or `lookup` groupBy in - * with a text column. Its refusal names the aggregated column too. + * with a text column. Its refusal names the aggregated column too. [#20745] + * A `json` or `lookup` groupBy (or a `min` / `max` of one) is judged now, by + * that same type: the engine evaluates `having` itself, and a relation column + * there carries the related record's id, never the record — measured, a + * nested-relation `having` kept no group on every driver. */ export function narrowHavingNumberComparands( object: string, @@ -497,9 +518,10 @@ export function narrowHavingNumberComparands( const walked = walkCondition( (key) => { const type = types.get(key); + const kind = type === undefined ? null : noOperatorObjectColumnKind(type); return { number: classes.get(key) === 'numeric' ? { type: 'number' } : null, - scalarType: type !== undefined && holdsScalarValues(type) ? type : null, + column: kind === null ? null : { kind, type: type as string }, }; }, having, diff --git a/packages/objectql/src/protocol-explicit-filter-field-gate.test.ts b/packages/objectql/src/protocol-explicit-filter-field-gate.test.ts index f6218b84b3..c52552679e 100644 --- a/packages/objectql/src/protocol-explicit-filter-field-gate.test.ts +++ b/packages/objectql/src/protocol-explicit-filter-field-gate.test.ts @@ -393,7 +393,14 @@ describe('#7534 — unknown field on the EXPLICIT filter axes (real ObjectQL eng // `{owner_id: {region: 'NA'}}` names `owner_id` on THIS object and // `region` on the related one. Judging `region` against this object's // field map would refuse a legitimate relation filter. - await expect(find({ where: { owner_id: { region: 'NA' } } })).resolves.toBeDefined(); + // [#20745] The engine refuses the nested-relation form itself (no + // driver serves it) with INVALID_FILTER in its own words. This gate + // still never descends: the answer is not its INVALID_FIELD about + // `region`. + const err: any = await find({ where: { owner_id: { region: 'NA' } } }).then(() => null, (e: any) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err.message).toContain('nested-relation form'); + expect(err.field).toBeUndefined(); }); it('GUARD an unknown object stays a 404 — the filter gate must not turn it into a 400', async () => { diff --git a/packages/objectql/src/query-expression-conformance.test.ts b/packages/objectql/src/query-expression-conformance.test.ts index e4a997b4c9..f482d3daf6 100644 --- a/packages/objectql/src/query-expression-conformance.test.ts +++ b/packages/objectql/src/query-expression-conformance.test.ts @@ -1221,17 +1221,22 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin // unjudged — the ruling's carve-out, pinned as a control below. // ───────────────────────────────────────────────────────────── - it('CONTROL — the nested-relation OBJECT form still passes both doors: the refusal targets the dotted-STRING spelling alone', async () => { - // `{ project_id: { name: 'x' } }` is a legitimate nested-relation - // condition whose inner keys belong to ANOTHER object — the exact - // shape the collectors refuse to descend into. If this control goes - // red, the verdict has started judging comparand VALUES, which is a - // different (and wrong) gate. - await expect(protocol.findData({ - object: 'showcase_task', query: { where: { project_id: { name: 'Apollo' } } }, - })).resolves.toMatchObject({ records: expect.any(Array) }); - await expect(engine.find('showcase_task', { where: { project_id: { name: 'Apollo' } } })) - .resolves.toEqual(expect.any(Array)); + it('CONTROL — the nested-relation OBJECT form is not the dotted verdict\'s at either door: the refusal targets the dotted-STRING spelling alone', async () => { + // `{ project_id: { name: 'x' } }` is a nested-relation condition whose + // inner keys belong to ANOTHER object — the exact shape the collectors + // refuse to descend into. If this control answers the dotted verdict's + // INVALID_FIELD, that verdict has started judging comparand VALUES, + // which is a different (and wrong) gate. [#20745] The engine refuses + // the form itself — no driver serves it — so both doors answer the + // no-operator-object arm's INVALID_FILTER, in its own words. + for (const run of [ + () => protocol.findData({ object: 'showcase_task', query: { where: { project_id: { name: 'Apollo' } } } }), + () => engine.find('showcase_task', { where: { project_id: { name: 'Apollo' } } }), + ]) { + const err: any = await run().then(() => null, (e: any) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err.message).toContain('nested-relation form'); + } }); it('⛔ CONTROL — the structured/JSON head stays UNJUDGED at both doors: the ruled carve-out', async () => { diff --git a/packages/rest/src/data-nested-object-door.test.ts b/packages/rest/src/data-nested-object-door.test.ts new file mode 100644 index 0000000000..b14f5d5a9c --- /dev/null +++ b/packages/rest/src/data-nested-object-door.test.ts @@ -0,0 +1,263 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20745] A plain object with no `$`-operator key beneath a relation field + * (the nested-relation form), a structured-JSON field (a whole-value match) or + * the platform-provisioned `id` column is refused at the public door — + * `POST /api/v1/data/:object/query` answers `400 INVALID_FILTER` in the + * engine's words, naming the field and the path, before any read — over a + * real `SqlDriver`; and the route the refusal names answers the rows the + * nested form meant. + * + * Measured on the base (`origin/main` `a51920f5fb`) through this door, three + * rows (owner `u1`, region NA, on `d1` and `d3`): + * + * | `where` | InMemoryDriver | SQLite | PostgreSQL 16 | + * |:--|:--|:--|:--| + * | `{ owner: { region: 'NA' } }` (lookup; master-detail, multiple lookup, user, tree alike) | 200, no rows | 400 `INVALID_FILTER`, the driver's words | same as SQLite | + * | `{ meta: { a: 1 } }` (json; address, composite alike) | 200, the deep-equal rows | 400, the driver's words | same | + * | `{ id: { a: 1 } }` | 200, no rows | 400, the driver's words | same | + * | route `{ owner: { $in: ['u1'] } }` | `d1`, `d3` | `d1`, `d3` | `d1`, `d3` | + * | route `{ owners: { $contains: 'u1' } }` (multiple lookup) | `d1`, `d3` | `d1`, `d3` | `d1`, `d3` | + * | `{ owners: { $in: ['u1'] } }` (multiple lookup) | `d1`, `d3` | 400, the driver's JSON-column words | same | + * + * The arm sits in the engine, in front of every driver, so one verdict holds + * on each cell. InMemoryDriver's refusal row is `@objectstack/objectql`'s + * `engine-nested-object-door.test.ts` by construction (the arm answers before + * a driver is resolved). Its route readings above were measured, not pinned + * here: this package does not depend on the in-memory driver, and that + * driver's test consumers are a ruled, closed census + * (`check:driver-memory-census`). + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL and MySQL cells run where + * `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set and are a named skip + * otherwise. ⚠️ No CI job provisions those variables for this package (the + * `Temporal Conformance (live PG + MySQL)` job runs `driver-sql`, + * `metadata-protocol` and one `runtime` file), so the live cells are + * red-capable and un-run in CI; the PR that landed this file carries their + * local PostgreSQL run. Each live cell owns its tables, dropped before and + * after. + */ + +import { describe, it, expect, beforeAll, 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 = 'rest_nested_obj_20745'; +const OWNER = 'rest_nested_own_20745'; + +const OWNER_OBJECT = { + name: OWNER, + label: 'Owner 20745', + fields: { region: { name: 'region', type: 'text' as const } }, +}; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20745', + fields: { + title: { name: 'title', type: 'text' as const }, + owner: { name: 'owner', type: 'lookup' as const, reference: OWNER }, + boss: { name: 'boss', type: 'master_detail' as const, reference: OWNER }, + owners: { name: 'owners', type: 'lookup' as const, reference: OWNER, multiple: true }, + assignee: { name: 'assignee', type: 'user' as const }, + parent: { name: 'parent', type: 'tree' as const, reference: OBJECT }, + meta: { name: 'meta', type: 'json' as const }, + ship_to: { name: 'ship_to', type: 'address' as const }, + photo: { name: 'photo', type: 'image' as const }, + }, +}; + +const OWNERS = [{ id: 'u1', region: 'NA' }, { id: 'u2', region: 'EU' }]; + +const ROWS = [ + { id: 'd1', title: 'a', owner: 'u1', boss: 'u1', owners: ['u1'], assignee: 'u1', meta: { a: 1 }, ship_to: { city: 'Paris' } }, + { id: 'd2', title: 'b', owner: 'u2', boss: 'u2', owners: ['u2'], assignee: 'u2', parent: 'd1', meta: { a: 2 }, ship_to: { city: 'Rome' } }, + { id: 'd3', title: 'c', owner: 'u1', boss: 'u1', owners: ['u1', 'u2'], assignee: 'u1', parent: 'd1', meta: { b: 1 }, ship_to: { city: 'Paris' } }, +]; + +interface Cell { + id: 'sqlite' | 'pg' | 'mysql'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, + { + id: 'mysql', + label: 'live mysql', + env: 'OS_TEST_MYSQL_URL', + config: () => (process.env.OS_TEST_MYSQL_URL ? { client: 'mysql2', connection: process.env.OS_TEST_MYSQL_URL } : null), + }, +]; + +/** + * name · the `where` · the field · the path · words only this kind's refusal + * prints · the route it names. Both are asserted on the REST body, so the + * route is pinned to land inside the door's 500-character message bound. + */ +const REFUSED: ReadonlyArray = [ + ['a lookup (the card)', { owner: { region: 'NA' } }, 'owner', 'where.owner', 'nested-relation form', `Filter the related object '${OWNER}' first, then match 'owner' against the ids it returns: { "owner": { "$in": [ID, …] } }.`], + ['a master-detail', { boss: { region: 'NA' } }, 'boss', 'where.boss', 'nested-relation form', '{ "boss": { "$in": [ID, …] } }'], + ['a multiple lookup', { owners: { region: 'NA' } }, 'owners', 'where.owners', 'nested-relation form', '{ "owners": { "$contains": ID } } for one id, an $or of those for several'], + ['a user field', { assignee: { region: 'NA' } }, 'assignee', 'where.assignee', 'nested-relation form', `Filter the related object 'sys_user' first`], + ['a tree field', { parent: { title: 'a' } }, 'parent', 'where.parent', 'nested-relation form', `Filter the related object '${OBJECT}' first`], + ['a json field (the card)', { meta: { a: 1 } }, 'meta', 'where.meta', 'whole-value match', '{ "meta": { "$null": false } }, or store the part you filter on in a field of its own'], + ['an address field', { ship_to: { city: 'Paris' } }, 'ship_to', 'where.ship_to', 'whole-value match', '{ "ship_to": { "$null": false } }'], + ['the id column (the card)', { id: { a: 1 } }, 'id', 'where.id', "the platform-provisioned text column 'id'", `Compare 'id' with a value ({ "id": VALUE })`], + ['inside $not', { $not: { owner: { region: 'NA' } } }, 'owner', 'where.$not.owner', 'nested-relation form', '{ "owner": { "$in": [ID, …] } }'], +]; + +/** The arm's own words, in every refusal it raises — a control must never be answered in them. */ +const ARM_WORDS = 'is filter structure, not a value'; + +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 idsOf = (body: any): string[] => (body?.records ?? []).map((r: any) => r.id).sort(); + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#20745] a no-operator object beneath a relation, JSON or id column at the public door — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let engine: ObjectQL; + let driver: any; + const reads = { n: 0 }; + let query: (body: Record, object?: string) => Promise<{ status: number; body: any }>; + + const dropTables = async () => { + if (cell.id === 'sqlite') return; + await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + await driver?.execute(`drop table if exists ${OWNER}`).catch(() => {}); + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTables(); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(OWNER_OBJECT as any); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + for (const row of OWNERS) await engine.insert(OWNER, { ...row } as any); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + // Reads of THIS object — the protocol's own metadata traffic is not the question. + 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(); + query = async (body, object = OBJECT) => { + const res = makeRes(); + // What the wire carries: JSON. + await route!.handler({ params: { object }, body: JSON.parse(JSON.stringify(body)), query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }); + + afterAll(async () => { + await dropTables(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('where: every row of the card answers 400 INVALID_FILTER in the engine\'s words, naming the field and the path — no read', async () => { + const before = reads.n; + for (const [name, where, field, path, words, route] of REFUSED) { + const res = await query({ where }); + expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(400); + expect(res.body.code, name).toBe('INVALID_FILTER'); + expect(res.body.error, name).toContain(`filter on '${field}'`); + expect(res.body.error, name).toContain(`at ${path},`); + expect(res.body.error, name).toContain('The filter was NOT applied.'); + expect(res.body.error, name).toContain(words); + expect(res.body.error, name).toContain(route); + const err = await engine.find(OBJECT, { where }).then(() => null, (e: any) => e); + expect({ code: err?.code, status: err?.status }, `engine.find, ${name}`).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(err?.message, `engine.find, ${name}`).toContain(ARM_WORDS); + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + + it('the per-aggregation filter and having: 400 INVALID_FILTER at their own positions — no read', async () => { + const before = reads.n; + const filter = await query({ + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: { owner: { region: 'NA' } } }], + } satisfies EngineAggregateOptions as Record); + expect(filter.status, JSON.stringify(filter.body)).toBe(400); + expect(filter.body.code).toBe('INVALID_FILTER'); + expect(filter.body.error).toContain('at aggregations[1].filter.owner,'); + const having = await query({ + groupBy: ['owner'], + aggregations: [{ function: 'count', alias: 'n' }], + having: { owner: { region: 'NA' } }, + } satisfies EngineAggregateOptions as Record); + expect(having.status, JSON.stringify(having.body)).toBe(400); + expect(having.body.code).toBe('INVALID_FILTER'); + expect(having.body.error).toContain('at having.owner,'); + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + + it('the named route answers the rows the nested form meant: the related object\'s ids, then $in (single) or $contains (multiple)', async () => { + const na = await query({ where: { region: 'NA' } }, OWNER); + expect(na.status, JSON.stringify(na.body)).toBe(200); + const ids = idsOf(na.body); + expect(ids).toEqual(['u1']); + for (const field of ['owner', 'boss', 'assignee']) { + const res = await query({ where: { [field]: { $in: ids } } }); + expect(res.status, `${field}: ${JSON.stringify(res.body)}`).toBe(200); + expect(idsOf(res.body), field).toEqual(['d1', 'd3']); + } + const multiple = await query({ where: { owners: { $contains: ids[0] } } }); + expect(multiple.status, JSON.stringify(multiple.body)).toBe(200); + expect(idsOf(multiple.body)).toEqual(['d1', 'd3']); + const anyOf = await query({ where: { $or: [{ owners: { $contains: 'u1' } }, { owners: { $contains: 'u9' } }] } }); + expect(idsOf(anyOf.body)).toEqual(['d1', 'd3']); + const tree = await query({ where: { parent: { $in: ['d1'] } } }); + expect(idsOf(tree.body)).toEqual(['d2', 'd3']); + const present = await query({ where: { meta: { $null: false } } }); + expect(idsOf(present.body)).toEqual(['d1', 'd2', 'd3']); + }); + + it('CONTROL a file field\'s object reaches the driver, never the arm\'s refusal', async () => { + const before = reads.n; + const res = await query({ where: { photo: { url: 'x' } } }); + expect(reads.n - before, 'the driver was asked').toBe(1); + expect(JSON.stringify(res.body)).not.toContain(ARM_WORDS); + }); + }, + ); +} diff --git a/packages/rest/src/data-no-operator-object-door.test.ts b/packages/rest/src/data-no-operator-object-door.test.ts index 04546d72cf..834b33b0fa 100644 --- a/packages/rest/src/data-no-operator-object-door.test.ts +++ b/packages/rest/src/data-no-operator-object-door.test.ts @@ -4,9 +4,11 @@ * [#20546] A plain object with no `$`-operator key where a scalar field's * value belongs is refused at the public door — `POST /api/v1/data/:object/query` * and `engine.find` answer `400 INVALID_FILTER` in the engine's words, naming - * the field and the path, before any read — over a real `SqlDriver`, with the - * two controls triage named reaching the driver as written: a `lookup` field's - * nested relation filter and a `json` field's object comparand. + * the field and the path, before any read — over a real `SqlDriver`, with a + * file field's object reaching the driver as written. The two controls triage + * first named here — a `lookup` field's nested relation filter and a `json` + * field's object comparand — are refused by the same arm since #20745, in + * words of their own (`data-nested-object-door.test.ts` pins them). * * Measured on the base (`origin/main` `fbec216e2d`) through this door and the * engine, three rows: @@ -14,15 +16,14 @@ * | `where` | InMemoryDriver | SQLite | PostgreSQL 16 | * |:--|:--|:--|:--| * | `{ amount: { a: 1 } }` (number), `{ title: { a: 1 } }` (text) | 200, no rows | 400 `INVALID_FILTER`, the driver's words | same as SQLite | - * | control `{ owner: { region: 'NA' } }` (lookup) | 200, no rows | 400, the driver's words | same | - * | control `{ meta: { a: 1 } }` (json) | 200, one row | 400, the driver's words | same | + * | `{ owner: { region: 'NA' } }` (lookup), `{ meta: { a: 1 } }` (json) | 200, no rows / one row | 400, the driver's words | same — refused since #20745 | * | `aggregations[1].filter` `{ amount: { a: 1 } }` / `having` `{ total: { a: 1 } }` | count 0 / no group | same | same | * * The arm sits in the engine, in front of every driver, so one verdict holds * on each cell; InMemoryDriver's row is `@objectstack/objectql`'s * `engine-no-operator-object-door.test.ts` by construction (the arm answers - * before a driver is resolved). The controls are this door's to let through, - * not to fix: what a driver answers for them afterwards is its own, and is + * before a driver is resolved). The control is this door's to let through, + * not to fix: what a driver answers for it afterwards is its own, and is * pinned here only as "the driver was asked, and the words are not the arm's". * * ## The dialect axis of THIS file @@ -61,6 +62,7 @@ const LEDGER = { amount: { name: 'amount', type: 'number' as const }, owner: { name: 'owner', type: 'lookup' as const, reference: OWNER }, meta: { name: 'meta', type: 'json' as const }, + photo: { name: 'photo', type: 'image' as const }, }, }; @@ -103,10 +105,9 @@ const REFUSED: ReadonlyArray ['inside $not (every row on memory, before)', { $not: { amount: { a: 1 } } }, 'amount', 'where.$not.amount'], ]; -/** name · the `where` — the two controls triage named. */ +/** name · the `where` — the accepted side: a file field (the #8371 carve-out). */ const CONTROLS: ReadonlyArray = [ - ["a lookup field's nested relation filter", { owner: { region: 'NA' } }], - ["a json field's object comparand", { meta: { a: 1 } }], + ["a file field's object", { photo: { url: 'x' } }], ]; /** The arm's own words, in every refusal it raises — a control must never be answered in them. */ @@ -218,7 +219,7 @@ for (const cell of CELLS) { expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); }); - it('CONTROL the lookup nested relation filter and the json object comparand reach the driver, never the arm\'s refusal', async () => { + it('CONTROL a file field\'s object reaches the driver, never the arm\'s refusal', async () => { for (const [name, where] of CONTROLS) { const before = reads.n; const res = await query({ where }); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index ea5f03d95b..3d2d4d4b2f 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1922,7 +1922,8 @@ function checkFilterConditionComparands( const hasOperatorKeys = Object.keys(value).some((k) => k.startsWith('$')); if (!hasOperatorKeys) { // Nested relation / deep equality — the schema does not re-parse these, - // so the walk descends itself. + // so the walk descends itself. (The query engine refuses both at query + // time, `INVALID_FILTER`; see form 4 on {@link FilterCondition}.) checkFilterConditionComparands(value, ctx, [...path, key], depth + 1); continue; } @@ -1979,13 +1980,21 @@ function checkFilterConditionComparands( * 1. Implicit equality: { field: value } * 2. Explicit operators: { field: { $op: value } } * 3. Logical combinations: { $and: [...], $or: [...], $not: {...} } - * 4. Nested relations: { relation: { field: value } } + * 4. Nested relations: { relation: { field: value } } — the type and the schema + * accept this form, and the query engine REFUSES it: a plain object with no + * `$` operator beneath a relation field answers `INVALID_FILTER` / 400 on + * every driver, because no data-path driver follows a relation into the + * related object (the field stores the related record's id). Filter the + * related object first, then match the relation field against the ids it + * returns: `{ relation: { $in: [id, …] } }`, or `{ relation: { $contains: id } }` + * per id on a multi-valued relation. The same object beneath a JSON-valued + * field (a whole-value match) and beneath a scalar field is refused too. */ export type FilterCondition = { [key: string]: | any // Implicit equality: key: value | z.infer // Explicit operators: key: { $op: value } - | FilterCondition; // Nested relation: key: { nested: ... } + | FilterCondition; // Nested relation: key: { nested: ... } — accepted here, refused by the engine (form 4 above) } & { /** Logical AND - combines all conditions that must be true */ $and?: FilterCondition[]; @@ -2121,10 +2130,7 @@ export const FilterConditionSchema: z.ZodType * $or: [ // Logical combination * { role: "admin" }, * { email: { $contains: "@company.com" } } - * ], - * profile: { // Nested relation - * verified: true - * } + * ] * } * } * ``` @@ -2214,7 +2220,7 @@ export type Filter = { $null?: boolean; $exists?: boolean; } - | (T[K] extends object ? Filter : never); // Nested relation + | (T[K] extends object ? Filter : never); // Nested relation — typed here, refused by the engine (see FilterCondition) } & { $and?: Filter[]; $or?: Filter[];