diff --git a/.changeset/20121-engine-where-shape-refused.md b/.changeset/20121-engine-where-shape-refused.md new file mode 100644 index 00000000000..16f483e84a1 --- /dev/null +++ b/.changeset/20121-engine-where-shape-refused.md @@ -0,0 +1,69 @@ +--- +'@objectstack/objectql': minor +--- + +fix(objectql)!: an engine `where` that is not a filter — a string, a number, a `Map`, a boolean, a `Date` — is refused with `INVALID_FILTER` / 400 before any driver call, and a `multi: true` update or delete no longer rewrites or removes every row for it (#20121) + +Clause-②: no (narrowing) + + + +**BREAKING** — an accept-set narrowing on the engine's `where`, shipped as `minor` +under the launch-window convention (`check-changeset-no-major` refuses `major` +until GA; breaking-ness is carried by this banner and the ADR-0087 disposition +above, not by the level). + +**What changed.** `find`, `findOne`, `count`, `aggregate`, `update` and `delete` +on the engine now refuse a `where` that is neither absent, a filter object nor a +filter array. The refusal is thrown before a driver is asked for anything, as +`INVALID_FILTER` with `status` and `httpStatus` 400, and its message reads +"`('')`: 'where' must be a filter object or condition array, +received …. It was not applied, …" — the REST normalizer's words for the same +input. The refusal for an array that is not a filter (`[1, 2, 3]`, an infix +join) keeps its message and now carries the same `INVALID_FILTER` / 400 +envelope; it used to have no `code` and no `status`. + +**What it replaces.** A value with no filter keys fell through every check on +the seam and the driver ignored it. Measured on `driver-memory` and +`SqlDriver` (better-sqlite3) with four rows: + +- `find` / `count` / `aggregate` answered for every row, as if no `where` had + been given; `findOne` answered the first row (for a `Map`, its no-predicate + guard refused the call, with no `code`). +- `update(…, { where, multi: true })` rewrote all four rows, and + `delete({ where, multi: true })` deleted all four. That held without + `SecurityPlugin`, and with it under a system context. +- For a caller scoped by row-level security, it depended on the value. + - A string, a number, a `Map`, a `Date`, a `Set` or `true` was wrapped by the + security middleware into its `$and`, where the driver refused it + (`INVALID_FILTER`) and nothing was written. + - The empty string was not refused. The middleware's composition reads a + falsy `where` as absent and dropped it, so `find` answered all of the + member's rows, and `update(multi)` / `delete(multi)` rewrote or deleted + every row the member could reach (2 of 4 on both drivers). +- Such a `where` also stepped past the unscoped-write guard that a hook opts + into with `dispatchUnscopedMultiWrite`, because that guard treats only an + absent or `null` `where` as unscoped. + +**Unchanged.** An absent `where`, `null`, `{}` and `[]` still mean "no +filter". A filter object is accepted when it is a non-array object whose +built-in tag (`Object.prototype.toString`) is `[object Object]`. That covers a +plain object, an `Object.create(null)` object, an instance of your own class +carrying the filter on its own keys, a `Proxy` of one, and an object from +another realm, and each filters exactly as before. A well-formed filter array is +lowered as before. + +**One accepted shape is now refused.** An object that overrides +`Symbol.toStringTag`, as its own key or through its prototype chain, has a +different built-in tag. It is refused and named by that tag (for example +`received Criteria`). Before this change such an object filtered correctly on +its own keys. No producer in this repository creates one: the wire is JSON, and +the SDK builds arrays or plain objects. If yours does, pass its filter keys in a +plain object instead. The REST door already answered a non-filter `?filter=` with +`INVALID_FILTER` / 400 and is not touched. + +**Fix.** Pass the predicate you meant as a filter object, for example +`{ amount: { $gt: 100 } }`, or as a filter array (`[['amount', '>', 100]]`), and +leave `where` out when you mean every row. If the value came from somewhere +untyped, the refusal names what arrived (`received string "amount > 100"`, +`received Map`), and an un-awaited promise shows up as `received Promise`. diff --git a/packages/objectql/src/engine-where-shape-refusal.test.ts b/packages/objectql/src/engine-where-shape-refusal.test.ts new file mode 100644 index 00000000000..285177d8cbc --- /dev/null +++ b/packages/objectql/src/engine-where-shape-refusal.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20121 — a `where` that is neither absent, a filter object nor a filter array + * is refused at the engine's one filter seam, before any driver call. + * + * Measured on the base, through the real engine, on `driver-memory` and + * `SqlDriver` (better-sqlite3), four rows seeded: + * + * find('order', { where: 'amount > 100' }) -> 4 rows (all) + * update('order', {...}, { where: 42, multi: true }) -> 4 of 4 rows rewritten + * delete('order', { where: new Map(), multi: true }) -> 4 of 4 rows deleted + * find('order', { where: [1, 2, 3] }) -> refused, code/status undefined + * + * A string, number or `Map` has no keys for the seam's doors to walk, so every + * door stepped around it and the driver ignored it — and on a write that is a + * whole-table rewrite or delete. The same value also stepped past the + * unscoped-write guard, which reads only an absent or `null` `where` as + * unscoped. + * + * These cases assert what the ENGINE refuses and that the driver is never + * asked: a refusal the driver produces after the fact would already have read, + * and on a SQL driver the damage is done by the time anything answers. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { + EngineAggregateOptions, + EngineCountOptions, + EngineDeleteOptions, + EngineQueryOptions, + EngineUpdateOptions, +} from '@objectstack/spec/data'; +import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; +import { ObjectQL } from './engine.js'; + +const deal = { + name: 'deal', + label: 'Deal', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + stage: { name: 'stage', type: 'text' as const }, + amount: { name: 'amount', type: 'number' as const }, + }, +}; + +/** A verb the pin does not reach, present only because `IDataDriver` requires it. */ +const unexercised = (verb: string): never => { + throw new Error(`counting driver: ${verb}() is not exercised by this pin (#20121)`); +}; + +/** + * A driver double that COUNTS every call and executes only the filter-object + * form. A `where` it cannot read throws here rather than matching everything — + * so a refusal that is missing from the engine turns a case red here instead of + * passing on a double that quietly agreed with the dropped filter. + */ +function makeCountingDriver() { + const rows = new Map>(); + const calls: string[] = []; + const matches = (row: Record, where: unknown): boolean => { + if (where == null) return true; + if (typeof where !== 'object' || Array.isArray(where)) { + throw new Error(`counting driver: received a non-object 'where' (${String(where)})`); + } + for (const [k, v] of Object.entries(where)) { + // A combinator or an operator this double does not implement is REFUSED, + // never read as a field name or skipped (`check:where-matcher`). + if (k.startsWith('$')) throw new Error(`counting driver: unsupported combinator ${k}`); + if (v && typeof v === 'object' && !Array.isArray(v)) { + const ops = v as Record; + for (const op of Object.keys(ops)) { + if (op !== '$gt') throw new Error(`counting driver: unsupported operator ${op}`); + } + if (!((row[k] as number) > (ops.$gt as number))) return false; + continue; + } + if (row[k] !== v) return false; + } + return true; + }; + const run = (ast: DriverQuery | undefined) => [...rows.values()].filter((r) => matches(r, ast?.where)); + const driver: IDataDriver = { + name: 'counting', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(_o: string, ast: DriverQuery) { + calls.push('find'); + const hit = run(ast); + // The caller's bound, applied AFTER the filter and by presence + // (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? hit.slice(0, ast.limit) : hit; + }, + async findOne(_o: string, ast: DriverQuery) { calls.push('findOne'); return run(ast)[0] ?? null; }, + async count(_o: string, ast: DriverQuery) { calls.push('count'); return run(ast).length; }, + async aggregate(_o: string, ast: DriverQuery) { calls.push('aggregate'); return [{ n: run(ast).length }]; }, + async create(_o: string, data: Record) { + calls.push('create'); + const row = { ...data, id: data.id as string }; + rows.set(row.id, row); + return row; + }, + async update(_o: string, id: string, data: Record) { + calls.push('update'); + const up = { ...rows.get(id), ...data, id }; + rows.set(id, up); + return up; + }, + async updateMany(_o: string, ast: DriverQuery, data: Record) { + calls.push('updateMany'); + const hit = run(ast); + for (const r of hit) rows.set(r.id as string, { ...r, ...data }); + return hit.length; + }, + async delete(_o: string, id: string) { calls.push('delete'); return rows.delete(id); }, + async deleteMany(_o: string, ast: DriverQuery) { + calls.push('deleteMany'); + const hit = run(ast); + for (const r of hit) rows.delete(r.id as string); + return hit.length; + }, + async bulkCreate() { return unexercised('bulkCreate'); }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + async upsert() { return unexercised('upsert'); }, + async bulkUpdate() { return unexercised('bulkUpdate'); }, + async bulkDelete() { return unexercised('bulkDelete'); }, + async syncSchema() { return unexercised('syncSchema'); }, + async dropTable() { return unexercised('dropTable'); }, + }; + return { driver, rows, calls }; +} + +/** + * The six verbs that cross the seam, each driven with the caller's `where`. + * The writes carry `multi: true` — the predicate path, where a dropped `where` + * is a whole-table write. `where` is typed `unknown` and cast at the call: + * every bad shape here is type-illegal on purpose, and the cast names the + * contract being bypassed. + */ +const VERBS: ReadonlyArray Promise]> = [ + ['find', (e, where) => e.find('deal', { where } as unknown as EngineQueryOptions)], + ['findOne', (e, where) => e.findOne('deal', { where } as unknown as EngineQueryOptions)], + ['count', (e, where) => e.count('deal', { where } as unknown as EngineCountOptions)], + ['aggregate', (e, where) => e.aggregate('deal', { + where, + groupBy: ['stage'], + aggregations: [{ function: 'count', field: 'id', alias: 'n' }], + } as unknown as EngineAggregateOptions)], + ['update', (e, where) => e.update('deal', { stage: 'archived' }, { where, multi: true } as unknown as EngineUpdateOptions)], + ['delete', (e, where) => e.delete('deal', { where, multi: true } as unknown as EngineDeleteOptions)], +]; + +/** The four shapes the card names — each was dropped or refused without an envelope. */ +const BAD_SHAPES: ReadonlyArray unknown]> = [ + ['a string', () => 'amount > 100'], + ['a number', () => 42], + ['a Map', () => new Map([['amount', { $gt: 15 }]])], + ['a non-filter array', () => [1, 2, 3]], +]; + +/** The two shapes that answer correctly — the controls. Both select d2 and d3. */ +const GOOD_SHAPES: ReadonlyArray unknown]> = [ + ['a filter object', () => ({ amount: { $gt: 15 } })], + ['a filter array', () => [['amount', '>', 15]]], +]; + +interface Refusal { code?: unknown; status?: unknown; message?: unknown } + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e as Refusal; + } + throw new Error('expected the engine to refuse, and it answered'); +} + +describe('an engine `where` that is not a filter is refused at the seam, before any driver call (#20121)', () => { + let engine: ObjectQL; + let rows: Map>; + let calls: string[]; + + beforeEach(async () => { + const counting = makeCountingDriver(); + rows = counting.rows; + calls = counting.calls; + engine = new ObjectQL(); + engine.registerDriver(counting.driver, true); + await engine.init(); + engine.registry.registerObject(deal); + await engine.insert('deal', { id: 'd1', stage: 'open', amount: 10 }); + await engine.insert('deal', { id: 'd2', stage: 'open', amount: 20 }); + await engine.insert('deal', { id: 'd3', stage: 'won', amount: 30 }); + calls.length = 0; + }); + + for (const [verb, run] of VERBS) { + for (const [label, shape] of BAD_SHAPES) { + it(`${verb}: ${label} is INVALID_FILTER / 400, and the driver is never called`, async () => { + const err = await refusalOf(() => run(engine, shape())); + + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(String(err.message).startsWith(`${verb}('deal')`)).toBe(true); + expect(calls).toEqual([]); + // Nothing was written either — read past the engine, off the double. + expect([...rows.values()].map((r) => `${r.id}:${r.stage}`).sort()) + .toEqual(['d1:open', 'd2:open', 'd3:won']); + }); + } + + for (const [label, shape] of GOOD_SHAPES) { + it(`${verb}: ${label} still reaches the driver and selects exactly d2 and d3 (control)`, async () => { + const answer = await run(engine, shape()); + + expect(calls.length).toBeGreaterThan(0); + switch (verb) { + case 'find': + expect((answer as Array<{ id: string }>).map((r) => r.id).sort()).toEqual(['d2', 'd3']); + break; + case 'findOne': + expect(['d2', 'd3']).toContain((answer as { id: string }).id); + break; + case 'count': + expect(answer).toBe(2); + break; + case 'aggregate': + expect(answer).toEqual([{ n: 2 }]); + break; + case 'update': + expect([...rows.values()].filter((r) => r.stage === 'archived').map((r) => r.id).sort()) + .toEqual(['d2', 'd3']); + break; + case 'delete': + expect([...rows.keys()]).toEqual(['d1']); + break; + } + }); + } + } + + // ── a door that forwards to the seam: the scoped repository ─────────────── + + it('createContext().object(): find and delete(multi) forward to the seam and are refused the same way', async () => { + const repo = engine.createContext({ isSystem: true }).object('deal'); + + const read = await refusalOf(() => repo.find({ where: 'amount > 15' })); + const write = await refusalOf(() => repo.delete({ where: 42, multi: true })); + + expect([read.code, read.status, write.code, write.status]).toEqual(['INVALID_FILTER', 400, 'INVALID_FILTER', 400]); + expect(calls).toEqual([]); + expect(rows.size).toBe(3); + }); + + // ── the accept set's edges: nothing that answered correctly is refused ──── + + it.each<[string, () => unknown, number]>([ + ['null (no filter)', () => null, 3], + ['{} (match-all)', () => ({}), 3], + ['[] (no filter)', () => [], 3], + ['Object.create(null) carrying the filter', () => Object.assign(Object.create(null), { amount: { $gt: 15 } }), 2], + ['a class instance carrying the filter on its own keys', () => new (class Criteria { amount = { $gt: 15 }; })(), 2], + ])('find: %s is accepted and answers %i rows', async (_label, shape, expected) => { + const answer = await engine.find('deal', { where: shape() } as unknown as EngineQueryOptions); + expect(answer).toHaveLength(expected); + }); + + it.each<[string, () => unknown]>([ + ['a Date', () => new Date(0)], + ['a Set', () => new Set(['open'])], + ['a boolean', () => true], + ['an empty string', () => ''], + ['an un-awaited Promise', () => Promise.resolve({ stage: 'open' })], + ])('delete(multi): %s is refused the same way — it too was a whole-table delete', async (_label, shape) => { + const err = await refusalOf(() => + engine.delete('deal', { where: shape(), multi: true } as unknown as EngineDeleteOptions)); + + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(calls).toEqual([]); + expect(rows.size).toBe(3); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d1ed9ee6feb..4f5d28170b8 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -42,7 +42,7 @@ import { MAX_BULK_PER_ROW_HOOK_ROWS, resolveBulkPerRowHookBudget } from '@object // [ADR-0126 §8] The packaged-action activation ledger: its row contract, its // stores and the engine-held projection the dispatch doors consult. import { ActionActivationProjection, type ActionActivationRow, type ActionActivationStore } from './action-activation.js'; -import { assertListComparandShapes, assertFilterIsMaterializable } from './filter-comparand-shape.js'; +import { assertListComparandShapes, assertFilterIsMaterializable, invalidFilterError } from './filter-comparand-shape.js'; import { assertTemporalComparandsInterpretable } from './temporal-comparand-door.js'; import { assertTextOperatorTargetsAreStringCapable } from './text-operator-declared-type-door.js'; // Seek pagination for the walks that must read EVERY row — the autonumber seed @@ -812,6 +812,66 @@ function foldEngineOptionAliases( return folded as T; } +/** + * [#20121] Is this non-array `where` a filter OBJECT — the one non-array shape + * a driver reads as a predicate? + * + * The test is the object's built-in tag (`[object Object]`), not its + * prototype, and each edge shape lands where its measured answer puts it — + * nothing that answered correctly before this gate is refused by it: + * + * - a literal `{…}` and `{}` (match-all, a REAL query — the unscoped-write + * detector reads it that way too) → accepted; + * - `Object.create(null)` and an instance of an author's class with the filter + * on its own keys → accepted: both filtered correctly on `driver-memory` and + * `SqlDriver`, because a driver reads a filter's own enumerable keys; + * - `Map`, `Set`, `Date`, a boxed primitive, a `Promise` (an un-awaited filter + * builder) → refused: each carries its tag, has no own keys, and was + * silently read as "no filter". + * + * `undefined` and `null` never reach this test — the caller skips them, and + * both keep meaning "no filter" (`findOne`'s no-predicate guard and the + * unscoped-write detector both read `null` as absent). + */ +function isWhereFilterObject(where: unknown): where is Record { + return ( + typeof where === 'object' + && where !== null + && !Array.isArray(where) + && Object.prototype.toString.call(where) === '[object Object]' + ); +} + +/** + * [#20121] How a refused non-filter `where` is named in the refusal: the + * primitive's `typeof` plus its value (bounded), or the object's built-in tag. + * The `typeof` spelling is the wire door's (`received number`); the value is + * added because a direct caller has no request to look at. + */ +function describeNonFilterWhere(where: unknown): string { + if (typeof where === 'object' && where !== null) { + return Object.prototype.toString.call(where).slice('[object '.length, -1); + } + if (typeof where === 'function') return 'function'; + const text = typeof where === 'string' ? JSON.stringify(where) : String(where); + const bounded = text.length > 80 ? `${text.slice(0, 77)}...` : text; + return `${typeof where} ${bounded}`; +} + +/** + * [#20121] What a dropped `where` would have done, per verb. The read wording is + * the wire door's verbatim; a write has no result set, and "every row in + * scope" is what an unapplied predicate on `multi: true` rewrote or removed. + */ +const UNAPPLIED_WHERE_CONSEQUENCE: Readonly> = { + find: 'returned the unfiltered result set', + findOne: 'returned the unfiltered result set', + count: 'returned the unfiltered result set', + aggregate: 'returned the unfiltered result set', + update: 'updated every row in scope', + delete: 'deleted every row in scope', +}; + /** * **Door 2** — lower an arriving {@link FilterArray} on `where` to the * `FilterCondition` the AST actually declares (#5158, maintainer ruling C). @@ -856,6 +916,13 @@ function foldEngineOptionAliases( * wording, or — for the infix dialect — silently compiled by a second * implementation. Failing here names the caller's own value. * + * [#20121] A `where` that is not an array has to be a filter OBJECT, and that + * is checked FIRST, before any door below: a string, a number, a boolean, a + * `Map`, a `Date` — anything that is neither absent, a filter object nor an + * array — is refused with the same `INVALID_FILTER` / 400 envelope the array + * refusal (3) now carries. See {@link isWhereFilterObject} for the accept set + * and why each edge shape lands where it does. + * * Returns the SAME reference when `where` is not an array (the overwhelmingly * common path allocates nothing), otherwise a shallow copy: the bag belongs to * the caller and may be reused (view metadata, flow node config). @@ -868,6 +935,33 @@ function lowerWhereFilterArray( ): T { if (!bag) return bag; const where = (bag as Record).where; + // [#20121] The SHAPE gate, first on this seam and so first on all six verbs + // that call it (`find`, `findOne`, `count`, `aggregate`, `update`, + // `delete`) — each calls this before it resolves a driver, so a refusal + // here means no driver call is made at all. + // + // Before it, a string / number / `Map` `where` fell into the object branch + // below, every door there stepped around it (they walk the keys of a filter + // object, and it has none), and the driver ignored it: the READ answered the + // unfiltered set, and a `multi: true` UPDATE or DELETE rewrote or removed + // every row of the table (measured on `driver-memory` and `SqlDriver` + // without `SecurityPlugin`, and with it under a system context; with an + // RLS-scoped caller the RLS `$and` composition put the value where the + // driver's node gate refuses it). It also stepped past the unscoped-write + // guard (`dispatchUnscopedMultiWriteHooks`), which reads only an absent or + // `null` `where` as unscoped. + // + // The words are the wire door's (`metadata-protocol`'s normalizer answers + // `?filter=5` with "must be a filter object or condition array, received + // number … It was not applied"); the consequence is spelled per verb, + // because for a write it is not a result set. + if (where !== undefined && where !== null && !Array.isArray(where) && !isWhereFilterObject(where)) { + throw invalidFilterError( + `${operation}('${object}'): 'where' must be a filter object or condition array, ` + + `received ${describeNonFilterWhere(where)}. It was not applied, and an unapplied filter ` + + `would have ${UNAPPLIED_WHERE_CONSEQUENCE[operation] ?? UNAPPLIED_WHERE_CONSEQUENCE.find}.`, + ); + } if (!Array.isArray(where)) { // [#5869] Door 1 lands HERE, not below: the protocol face runs its own // `isFilterAST` → `parseFilterAST` and hands the engine an already-lowered @@ -928,8 +1022,12 @@ function lowerWhereFilterArray( } // (3) Not a shape `parseFilterAST` can express. + // [#20121] Through `invalidFilterError`, so this refusal carries the + // `INVALID_FILTER` / 400 envelope the wire door answers the same array with + // (`malformedFilterArrayError`) — it used to be a bare `Error`, `code` and + // `status` both `undefined`, a 400-class author error that read as a 500. if (!isFilterAST(where)) { - throw new Error( + throw invalidFilterError( `${operation}('${object}') received a 'where' array that is not a filter: ` + `${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], ` + `a logical node ["and"|"or", ...conditions], or a list of those — it is INPUT-ONLY ` + diff --git a/packages/qa/dogfood/test/engine-where-shape-refusal.test.ts b/packages/qa/dogfood/test/engine-where-shape-refusal.test.ts new file mode 100644 index 00000000000..a7b7b4892f3 --- /dev/null +++ b/packages/qa/dogfood/test/engine-where-shape-refusal.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20121 — the WRITE half, on a real SQL driver: a `multi: true` update or + * delete whose `where` is not a filter is refused by the engine, and the table + * is untouched. + * + * Measured on the base, `ObjectQL` over `SqlDriver` (better-sqlite3 + * `:memory:`), four rows seeded, a system context: + * + * update('probe_order', { name: 'Z' }, { where: 'amount > 100', multi: true }) -> 4 of 4 rewritten + * delete('probe_order', { where: 42, multi: true }) -> 4 of 4 deleted + * delete('probe_order', { where: new Map(...), multi: true }) -> 4 of 4 deleted + * + * The engine dropped the value and the driver's `updateMany` / `deleteMany` + * ran with no predicate. The unit pin in `packages/objectql` + * (`engine-where-shape-refusal.test.ts`) proves no driver call is made; this + * file proves the consequence that matters on the driver that ships: the rows, + * read straight off the table past the engine, are exactly the seeded ones. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; + +const SYS = { context: { isSystem: true, userId: 'usr_system' } }; + +const PROBE_ORDER = { + name: 'probe_order', + label: 'Probe order', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + amount: { name: 'amount', type: 'number' }, + }, +}; + +const SEEDED = ['o1:a:50', 'o2:b:150', 'o3:c:50', 'o4:d:150']; + +/** The four shapes the card names. Each is type-illegal on purpose. */ +const BAD_SHAPES: ReadonlyArray unknown]> = [ + ['a string', () => 'amount > 100'], + ['a number', () => 42], + ['a Map', () => new Map([['amount', { $gt: 100 }]])], + ['a non-filter array', () => [1, 2, 3]], +]; + +type Engine = { + update(object: string, data: Record, options: unknown): Promise; + delete(object: string, options: unknown): Promise; +}; + +let engine: ObjectQL; +let driver: SqlDriver; + +beforeEach(async () => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.dogfood.where-shape-20121', + name: 'Where shape', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [PROBE_ORDER], + } as never); + await engine.syncSchemas(); + for (const [id, name, amount] of [['o1', 'a', 50], ['o2', 'b', 150], ['o3', 'c', 50], ['o4', 'd', 150]] as const) { + await engine.insert('probe_order', { id, name, amount }, SYS as never); + } +}); + +afterEach(async () => { + await engine.destroy(); +}); + +/** Ground truth, read off the table through knex — past every engine layer. */ +async function stored(): Promise { + const knex = (driver as unknown as { knex: (t: string) => { select: (...c: string[]) => Promise>> } }).knex; + const rows = await knex('probe_order').select('id', 'name', 'amount'); + return rows.map((r) => `${r.id}:${r.name}:${r.amount}`).sort(); +} + +async function refusalOf(run: () => Promise): Promise<{ code?: unknown; status?: unknown }> { + try { + await run(); + } catch (e) { + return e as { code?: unknown; status?: unknown }; + } + throw new Error('expected the engine to refuse, and it answered'); +} + +describe('a multi-row write with a non-filter `where` leaves a SQL table untouched (#20121)', () => { + for (const [label, shape] of BAD_SHAPES) { + it(`update(multi): ${label} is INVALID_FILTER / 400 and rewrites nothing`, async () => { + const err = await refusalOf(() => + (engine as unknown as Engine).update('probe_order', { name: 'Z' }, { where: shape(), multi: true, ...SYS })); + + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(await stored()).toEqual(SEEDED); + }); + + it(`delete(multi): ${label} is INVALID_FILTER / 400 and deletes nothing`, async () => { + const err = await refusalOf(() => + (engine as unknown as Engine).delete('probe_order', { where: shape(), multi: true, ...SYS })); + + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(await stored()).toEqual(SEEDED); + }); + } + + it('control: a filter object rewrites exactly the two matching rows', async () => { + await (engine as unknown as Engine).update('probe_order', { name: 'Z' }, { where: { amount: { $gt: 100 } }, multi: true, ...SYS }); + expect(await stored()).toEqual(['o1:a:50', 'o2:Z:150', 'o3:c:50', 'o4:Z:150']); + }); + + it('control: a filter array deletes exactly the two matching rows', async () => { + await (engine as unknown as Engine).delete('probe_order', { where: [['amount', '>', 100]], multi: true, ...SYS }); + expect(await stored()).toEqual(['o1:a:50', 'o3:c:50']); + }); +});