diff --git a/.changeset/shared-filter-verdict-reduction.md b/.changeset/shared-filter-verdict-reduction.md new file mode 100644 index 0000000000..3aeb9a91a9 --- /dev/null +++ b/.changeset/shared-filter-verdict-reduction.md @@ -0,0 +1,45 @@ +--- +"@objectstack/spec": minor +"@objectstack/driver-sql": patch +"@objectstack/driver-mongodb": patch +"@objectstack/driver-memory": patch +"@objectstack/lint": patch +--- + +refactor(spec,drivers,lint): one implementation of the filter identity reduction (#5659) + +`{ $and: [] }` matches every row, `{ $or: [] }` matches none, `{}` is a TRUE +disjunct that absorbs its `$or`, `{ $not: {} }` is FALSE. That is a ruling +(#5322/#5134) pinned for every backend by the four identity cases in +`FILTER_LOGIC_CASES` — and it was implemented four times over: `reduceFilterNode` +in `driver-sql`, the same function again in `driver-mongodb`, the +`every`/`some`/truthiness algebra of `driver-memory`'s matcher, and nearly a +fifth hand-written copy inside `@objectstack/lint`, which declined to write one +and filed this issue instead. + +**New in `@objectstack/spec` (`@objectstack/spec/data`): `reduceFilterVerdict`**, +beside the case table that proves it. It answers `'true' | 'false' | 'clause'` +for a filter node and never throws on its own; each backend's own refusals — the +undeclared `$`-combinator and the `undefined` comparand in `driver-sql`, the +query-level keys and the `$null` comparand in `driver-mongodb` — are passed in as +`FilterVerdictHooks` and are invoked from exactly the positions they were invoked +from before. `reduceFilterKeyVerdict` answers the same question for one key, which +is what both SQL and MongoDB emitters consult while walking a node. + +**No behaviour changes in the three drivers.** The move is mechanical: the shared +algebra replaces each private copy, the refusals stay where they were, and the +`FILTER_LOGIC_CASES` conformance suites are green on both sides of the change — +including the SQL-inheriting `driver-sqlite-wasm` and `driver-turso`. + +**`@objectstack/lint` gains two warnings it was structurally blind to.** The +`multi: true` unbounded-bulk-write rule (#5482) asked "does this filter have zero +keys", so a `delete_record` bounded by `filter: { $and: [] }` or +`filter: { $or: [{}] }` — a whole-object write by the ruling every driver executes +— passed silently. It now asks the reduction, and it warns about both while +staying quiet on `{ $or: [] }` and `{ $not: {} }`, which match nothing. The +message names the shape it saw (`a filter that REDUCES TO TRUE ({"$and":[]})`) +rather than calling a non-empty filter "empty". + +If you have a flow declaring a bulk write bounded by one of those two shapes, the +lint will now tell you so — the write was already unbounded at run time; only the +feedback is new. diff --git a/packages/drivers/driver-memory/src/memory-matcher.ts b/packages/drivers/driver-memory/src/memory-matcher.ts index c6f8509a5f..afc09bb79b 100644 --- a/packages/drivers/driver-memory/src/memory-matcher.ts +++ b/packages/drivers/driver-memory/src/memory-matcher.ts @@ -16,6 +16,11 @@ * comparand is not `[min, max]` (#5328). */ +// [#5659] The Filter Protocol's boolean identity reduction, shared with +// driver-sql, driver-mongodb and the flow linter. See `evaluate` for why a +// record-at-a-time matcher consults a record-INDEPENDENT verdict first. +import { reduceFilterVerdict } from '@objectstack/spec/data'; + import { assertFilterConditionShape } from './filter-refusal.js'; type RecordType = Record; @@ -41,9 +46,34 @@ export function match(record: RecordType, filter: any): boolean { return evaluate(record, filter); } +/** + * [#5659] The boolean identities come from the SHARED reduction; everything + * record-dependent is still decided here. + * + * This matcher used to answer `{ $and: [] }`, `{ $or: [] }`, `{}` and + * `{ $not: {} }` as a by-product of the JS it happens to be written in — + * `every` over an empty array is `true`, `some` is `false`, an empty node falls + * through to the trailing `return true`. Those four answers are not an accident + * of `Array.prototype`, they are a ruling (#5322/#5134) that `driver-sql` and + * `driver-mongodb` each spell out in a `reduceFilterNode` of their own, and + * "emergent from the evaluator" is not a place a ruling can be read from or + * held to. Asking the shared predicate makes this backend's identity answers + * the same OBJECT as theirs rather than a third agreeing coincidence. + * + * It is a pre-pass and not a rewrite of the loops below, because the verdict is + * record-independent by construction and the evaluation is not: `'clause'` — + * the answer for every filter that carries a real predicate — falls straight + * through to the untouched code. And a `'true'` verdict can only be reached by + * a filter whose keys are ALL combinators that resolved (a field key always + * contributes `'clause'`), so returning early on it skips no field constraint. + */ function evaluate(record: RecordType, filter: any): boolean { if (!filter || Object.keys(filter).length === 0) return true; - + + const verdict = reduceFilterVerdict(filter); + if (verdict === 'true') return true; + if (verdict === 'false') return false; + // 1. Handle Top-Level Logical Operators ($and, $or, $not) // These usually appear at the root or nested. diff --git a/packages/drivers/driver-mongodb/src/mongodb-filter.ts b/packages/drivers/driver-mongodb/src/mongodb-filter.ts index 9564595732..0a0d6e0d77 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-filter.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-filter.ts @@ -26,6 +26,16 @@ import type { Filter } from 'mongodb'; import { nextUtcCalendarDay } from '@objectstack/core'; import { StandardErrorCode } from '@objectstack/spec/api'; +// [#5659] The Filter Protocol's boolean identity reduction, shared with +// driver-sql, driver-memory and the flow linter and proven against the same +// `FILTER_LOGIC_CASES` table this driver's conformance suite runs. This file +// supplies only its own refusals — see `reduceFilterNode` below. +import { + reduceFilterVerdict, + reduceFilterKeyVerdict, + type FilterVerdict as SharedFilterVerdict, + type FilterVerdictHooks, +} from '@objectstack/spec/data'; import { coerceTemporalValue, type TemporalFieldKind, @@ -64,8 +74,12 @@ function matchNothing(): Filter { * - `'true'` — matches every document; the translator emits no condition. * - `'false'` — matches no document; the translator emits {@link matchNothing}. * - `'clause'` — carries at least one real predicate; translate it normally. + * + * [#5659] The vocabulary is `@objectstack/spec`'s now, because the REDUCTION + * that produces it is — see {@link reduceFilterNode}. Kept as a local alias so + * every use site below still reads `FilterVerdict`. */ -type FilterVerdict = 'true' | 'false' | 'clause'; +type FilterVerdict = SharedFilterVerdict; /** * [#5239] Is `value` a Filter Protocol NODE — the shape `FilterConditionSchema` @@ -135,51 +149,56 @@ function assertFilterNodeList(value: unknown, key: string, path: string): assert * emitted document came out empty, is the point. "Nothing was emitted" cannot * distinguish "the author wrote an empty group" from "something failed to * translate"; a structural verdict has no such blind spot. + * + * ## [#5659] The algebra is `@objectstack/spec`'s; the REFUSALS are this driver's + * + * Every paragraph above describes a ruling four consumers had to agree on and + * implemented four times — here, in `driver-sql` (whose copy this one was + * written to mirror, down to the variable names), in `driver-memory`'s matcher, + * and nearly a fifth time inside `@objectstack/lint`. It lives once now, in + * {@link reduceFilterVerdict}, proven against the same `FILTER_LOGIC_CASES` + * table this driver's conformance suite runs. What stays here is WHICH shapes + * this translator refuses and how it words them, handed over as + * {@link MONGO_FILTER_VERDICT_HOOKS} and invoked from exactly the positions + * they were invoked from before. */ function reduceFilterNode(node: Record, path: string): FilterVerdict { - let sawFalse = false; - let sawClause = false; - for (const [key, value] of Object.entries(node)) { - const verdict = reduceFilterKey(key, value, path); - if (verdict === 'false') sawFalse = true; - else if (verdict === 'clause') sawClause = true; - } - return sawFalse ? 'false' : sawClause ? 'clause' : 'true'; + return reduceFilterVerdict(node, { ...MONGO_FILTER_VERDICT_HOOKS, path }); } /** [#5239] The verdict of ONE key of a filter node. */ function reduceFilterKey(key: string, value: unknown, path: string): FilterVerdict { - const here = path ? `${path}.${key}` : key; - - if (key === '$and' || key === '$or') { - assertFilterNodeList(value, key, here); - let sawTrue = false; - let sawFalse = false; - let sawClause = false; - value.forEach((element, index) => { - const elementPath = `${here}[${index}]`; - assertFilterNode(element, elementPath); - const verdict = reduceFilterNode(element, elementPath); - if (verdict === 'true') sawTrue = true; - else if (verdict === 'false') sawFalse = true; - else sawClause = true; - }); - // `$and: []` → no FALSE, no clause → TRUE (the AND identity). - if (key === '$and') return sawFalse ? 'false' : sawClause ? 'clause' : 'true'; - // `$or: []` → no TRUE, no clause → FALSE (the OR identity). MongoDB itself - // answers neither: it rejects the empty array outright - // (`$and/$or/$nor must be a nonempty array`), so this filter used to be a - // 500-shaped throw rather than a verdict. - return sawTrue ? 'true' : sawClause ? 'clause' : 'false'; - } + return reduceFilterKeyVerdict(key, value, { ...MONGO_FILTER_VERDICT_HOOKS, path }); +} - if (key === '$not') { - assertFilterNode(value, here); - const inner = reduceFilterNode(value, here); - // NOT TRUE ≡ FALSE — so `{ $not: {} }` matches nothing. - return inner === 'true' ? 'false' : inner === 'false' ? 'true' : 'clause'; - } +/** + * [#5659] This translator's half of the reduction: the shape refusals, at the + * positions the shared walk visits them. + * + * The two `assert*` functions are wrapped in arrows rather than passed by + * reference because they are TypeScript assertion functions, whose narrowing is + * meaningless through a property reference. Nothing else about the call changes. + */ +const MONGO_FILTER_VERDICT_HOOKS: FilterVerdictHooks = { + assertNodeList: (value, key, path) => assertFilterNodeList(value, key, path), + assertNode: (value, path) => assertFilterNode(value, path), + classifyKey: (key, value, here) => classifyFilterKey(key, value, here), +}; +/** + * [#5239] The verdict of ONE **non-combinator** key — and this translator's gate + * on what a field constraint may not be. + * + * `here` is the already-joined path of the key, exactly as the reduction hands + * it over; the three combinator arms this used to open with are the shared + * walk's now. + * + * MongoDB's own answer to an empty combinator is worth keeping written down, + * because the reduction is what spares this driver from it: `$and`/`$or` with an + * empty array is rejected outright (`$and/$or/$nor must be a nonempty array`), + * so `{ $or: [] }` used to be a 500-shaped throw rather than a verdict. + */ +function classifyFilterKey(key: string, value: unknown, here: string): FilterVerdict { // Query-level keys carry no predicate; the emitter skips them and so does the // verdict, so the two never disagree about what this node is worth. if (QUERY_LEVEL_KEYS.has(key)) return 'true'; diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a02f35a9ab..e1d748329d 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -14,6 +14,17 @@ import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyD // `AggregationNodeSchema.function` actually admits. import { AggregationFunction } from '@objectstack/spec/data'; import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; +// [#5659] The Filter Protocol's boolean identity reduction — `$and: []` is TRUE, +// `$or: []` is FALSE, `{}` is a TRUE disjunct, `$not: {}` is FALSE. One +// implementation for all four consumers, proven against the same +// `FILTER_LOGIC_CASES` table this driver's conformance suite runs; this file +// supplies only its own refusals. See `reduceFilterNode` below. +import { + reduceFilterVerdict, + reduceFilterKeyVerdict, + type FilterVerdict as SharedFilterVerdict, + type FilterVerdictHooks, +} from '@objectstack/spec/data'; // `defaultValue` runtime tokens (#4560). The DDL below asks the SPEC — not a // list of its own — which `defaultValue`s are instructions rather than literals, // so the engine and this driver can never disagree about what may become a @@ -992,8 +1003,12 @@ function safeShapePreview(value: unknown): string { * - `'true'` — matches every row; the compiler emits NO clause for it. * - `'false'` — matches no row; the compiler emits the dialect FALSE constant. * - `'clause'` — carries at least one real predicate; compile it normally. + * + * [#5659] The vocabulary is `@objectstack/spec`'s now, because the REDUCTION + * that produces it is — see {@link reduceFilterNode}. Kept as a local alias so + * every use site below still reads `FilterVerdict`. */ -type FilterVerdict = 'true' | 'false' | 'clause'; +type FilterVerdict = SharedFilterVerdict; /** * [#5134] Is `value` a Filter Protocol NODE — the shape `FilterConditionSchema` @@ -1351,52 +1366,59 @@ function assertFilterNodeList(value: unknown, key: string, path: string): assert * "empty because the author wrote nothing" from "empty because something failed * to compile". A structural verdict has no such blind spot, and it lets the * emitter guarantee that every group it opens receives at least one clause. + * + * ## [#5659] The algebra is `@objectstack/spec`'s; the REFUSALS are this driver's + * + * Everything above describes a ruling (#5322/#5134) that four consumers had to + * agree on and implemented four times — here, in `driver-mongodb`, in + * `driver-memory`'s matcher, and nearly a fifth time inside `@objectstack/lint`, + * which declined to hand-write it and filed #5659 instead. The reduction now + * lives once, in {@link reduceFilterVerdict}, proven against the same + * `FILTER_LOGIC_CASES` table this driver's conformance suite runs. + * + * What stays here is what is genuinely this driver's: WHICH shapes it refuses + * and with which message. They are handed to the shared walk as + * {@link SQL_FILTER_VERDICT_HOOKS} and are invoked from exactly the positions + * they were invoked from before, so no wording, code or status moved — the + * conformance case-set is green on both sides of the change. */ function reduceFilterNode(node: Record, path: string): FilterVerdict { - let sawFalse = false; - let sawClause = false; - for (const [key, value] of Object.entries(node)) { - const verdict = reduceFilterKey(key, value, path); - if (verdict === 'false') sawFalse = true; - else if (verdict === 'clause') sawClause = true; - } - // AND over the node's keys: FALSE dominates, then a real predicate, else TRUE. - return sawFalse ? 'false' : sawClause ? 'clause' : 'true'; + return reduceFilterVerdict(node, { ...SQL_FILTER_VERDICT_HOOKS, path }); } /** [#5134] The verdict of ONE key of a filter node. */ function reduceFilterKey(key: string, value: unknown, path: string): FilterVerdict { - const here = path ? `${path}.${key}` : key; - - if (key === '$and' || key === '$or') { - assertFilterNodeList(value, key, here); - let sawTrue = false; - let sawFalse = false; - let sawClause = false; - value.forEach((element, index) => { - const elementPath = `${here}[${index}]`; - assertFilterNode(element, elementPath); - const verdict = reduceFilterNode(element, elementPath); - if (verdict === 'true') sawTrue = true; - else if (verdict === 'false') sawFalse = true; - else sawClause = true; - }); - // `$and: []` → no FALSE, no clause → TRUE (the AND identity). - if (key === '$and') return sawFalse ? 'false' : sawClause ? 'clause' : 'true'; - // `$or: []` → no TRUE, no clause → FALSE (the OR identity). This is the - // half the old compile got backwards: it answered the whole table. - return sawTrue ? 'true' : sawClause ? 'clause' : 'false'; - } + return reduceFilterKeyVerdict(key, value, { ...SQL_FILTER_VERDICT_HOOKS, path }); +} - if (key === '$not') { - assertFilterNode(value, here); - const inner = reduceFilterNode(value, here); - // NOT TRUE ≡ FALSE — so `{ $not: {} }` matches nothing. - return inner === 'true' ? 'false' : inner === 'false' ? 'true' : 'clause'; - } +/** + * [#5659] This driver's half of the reduction: the shape refusals, at the + * positions the shared walk visits them. + * + * `assertFilterNodeList` / `assertFilterNode` are wrapped in arrows rather than + * passed by reference because they are TypeScript assertion functions, whose + * narrowing is meaningless — and whose declaration requirements are a nuisance + * — through a property reference. Nothing else about the call changes. + */ +const SQL_FILTER_VERDICT_HOOKS: FilterVerdictHooks = { + assertNodeList: (value, key, path) => assertFilterNodeList(value, key, path), + assertNode: (value, path) => assertFilterNode(value, path), + classifyKey: (key, value, here) => classifyFilterKey(key, value, here), +}; +/** + * [#5134] The verdict of ONE **non-combinator** key — and this driver's gate on + * everything a field constraint may not be. + * + * `here` is the already-joined path of the key, exactly as the reduction hands + * it over; the three combinator arms this used to open with are the shared + * walk's now, and the refusals below are unchanged from when they sat under + * them. + */ +function classifyFilterKey(key: string, value: unknown, here: string): FilterVerdict { // [#5348] Everything still `$`-prefixed at this point is an UNDECLARED - // combinator — the three declared ones each returned above. Refused here and + // combinator — the shared walk resolved the three declared ones before this + // key ever reached the hook (#5659). Refused here and // not in the emitter for exactly the reason the two lines below are here, and // the reason #5327 gave for `{ field: {} }`: this walk is exhaustive and does // not short-circuit, while the emitter is skipped wholesale by a boolean diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index d0f8f3b58f..0bb289c69f 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest'; import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, FlowSchema } from '@objectstack/spec/automation'; +// [#5659] The shared identity reduction, asserted beside the rule that consumes +// it — the rule's verdict and the drivers' verdict are one object now. +import { reduceFilterVerdict } from '@objectstack/spec/data'; import { AUTHORING_RULES } from './authoring-rules.js'; import { lintFlowPatterns, @@ -1595,22 +1598,115 @@ describe('lintFlowPatterns — unbounded bulk write (#5482)', () => { ).toHaveLength(0); }); - it('an empty COMBINATOR array — deliberately out of range, both directions', () => { - // #5322/#5134 ruled these and every driver implements the ruling: `$and: []` - // is TRUE (this one IS a whole-object write and goes unwarned — filed as a - // follow-up), `$or: []` is FALSE (matches nothing — warning about it would - // be a false alarm). Telling them apart needs the identity REDUCTION, which - // already exists three times producer-side; a fourth hand-written copy in a - // linter is the divergence `engine-delete-dispatch.ts` exists to prevent. + /** + * #5659 — the FALSE half of the identity family, and the reason this rule + * consumes the shared reduction instead of testing "is it an empty + * combinator". Both shapes below match NOTHING: warning that they write the + * whole object would be the exact false alarm a hand-written test produces. + */ + it('a filter that reduces to FALSE — matches no row, so nothing to warn about', () => { expect( - lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: { $and: [] }, multi: true })), + lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: { $or: [] }, multi: true })), ).toHaveLength(0); expect( - lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: { $or: [] }, multi: true })), + lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: { $not: {} }, multi: true })), + ).toHaveLength(0); + // FALSE dominates the AND of a node, so a TRUE combinator beside it does + // not make the write unbounded. + expect( + lintFlowPatterns( + purgeFlow('delete_record', { objectName: 'lead', filter: { $and: [], $or: [] }, multi: true }), + ), + ).toHaveLength(0); + }); + + it('a filter the reduction cannot resolve — `clause`, the conservative answer', () => { + // A non-array combinator operand is refused by name at the driver door and + // by the schema; the shared predicate answers `'clause'` for it, and this + // rule must not turn "I cannot reduce this" into "it writes every row". + expect( + lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: { $and: 'x' }, multi: true })), + ).toHaveLength(0); + expect( + lintFlowPatterns( + purgeFlow('delete_record', { objectName: 'lead', filter: { $or: [{ status: 'closed' }] }, multi: true }), + ), ).toHaveLength(0); }); }); + /** + * #5659 — the two shapes this rule was BLIND to until the identity reduction + * became a shared predicate. + * + * Measured on `origin/main` before this change, both returned `[]`: the rule + * answered "does the filter have zero keys", and each of these has one. They + * are whole-object writes by the same ruling (#5322/#5134) every driver + * executes, and the rule now asks that ruling's own implementation + * (`@objectstack/spec` `reduceFilterVerdict`) rather than a fourth hand copy. + */ + describe('a filter that REDUCES to TRUE (#5659)', () => { + it('flags `{ $and: [] }` — the AND identity, i.e. the whole object', () => { + const fnds = lintFlowPatterns( + purgeFlow('delete_record', { objectName: 'lead', filter: { $and: [] }, multi: true }), + ); + expect(fnds).toHaveLength(1); + expect(fnds[0].rule).toBe(FLOW_MULTI_WRITE_UNFILTERED); + expect(fnds[0].where).toBe("flow 'nightly_purge' · node 'purge' (delete_record)"); + expect(fnds[0].severity).toBeUndefined(); + // Named as what it is. Calling a non-empty filter "EMPTY" would send the + // author looking for a typo instead of reading the identity. + expect(fnds[0].message).toContain('REDUCES TO TRUE'); + expect(fnds[0].message).toContain('{"$and":[]}'); + expect(fnds[0].message).not.toContain('an EMPTY `filter`'); + expect(fnds[0].message).toContain("every row of 'lead' is deleted"); + }); + + it('flags `{ $or: [{}] }` — a TRUE disjunct absorbs its `$or`', () => { + const fnds = lintFlowPatterns( + purgeFlow('delete_record', { objectName: 'lead', filter: { $or: [{}] }, multi: true }), + ); + expect(fnds).toHaveLength(1); + expect(fnds[0].rule).toBe(FLOW_MULTI_WRITE_UNFILTERED); + expect(fnds[0].message).toContain('REDUCES TO TRUE'); + expect(fnds[0].message).toContain('{"$or":[{}]}'); + }); + + it('flags the shape at depth, and on update_record too', () => { + // `{}` is a TRUE disjunct at any nesting, and `$not` of a FALSE group is + // TRUE — the reduction is a walk, not a top-level shape test. + expect( + lintFlowPatterns( + purgeFlow('delete_record', { objectName: 'lead', filter: { $or: [{ $and: [] }] }, multi: true }), + ), + ).toHaveLength(1); + expect( + lintFlowPatterns( + purgeFlow('update_record', { + objectName: 'lead', fields: { status: 'stale' }, filter: { $not: { $or: [] } }, multi: true, + }), + ), + ).toHaveLength(1); + }); + + it('answers the same verdict the drivers execute', () => { + // The point of the shared predicate, asserted directly: this rule's + // "carries no condition" IS `reduceFilterVerdict(...) === 'true'`. + for (const filter of [{}, { $and: [] }, { $or: [{}] }, { $or: [{ $and: [] }] }]) { + expect(reduceFilterVerdict(filter), JSON.stringify(filter)).toBe('true'); + expect( + lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter, multi: true })), + ).toHaveLength(1); + } + for (const filter of [{ $or: [] }, { $not: {} }, { status: 'closed' }]) { + expect(reduceFilterVerdict(filter), JSON.stringify(filter)).not.toBe('true'); + expect( + lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter, multi: true })), + ).toHaveLength(0); + } + }); + }); + /** * The rule's main habitat. A scheduled sweep whose per-item work sits in a * `loop` body is the standard shape for a janitor flow, so a rule that only diff --git a/packages/lint/src/lint-flow-patterns.ts b/packages/lint/src/lint-flow-patterns.ts index 48c4413801..86972b321e 100644 --- a/packages/lint/src/lint-flow-patterns.ts +++ b/packages/lint/src/lint-flow-patterns.ts @@ -142,6 +142,10 @@ import { collectFlowGraphs, } from '@objectstack/spec/automation'; import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation'; +// [#5659] The Filter Protocol's boolean identity reduction — the same predicate +// driver-sql, driver-mongodb and driver-memory execute. This linter asks it +// rather than hand-writing a fourth copy; see {@link filterCarriesNoCondition}. +import { reduceFilterVerdict } from '@objectstack/spec/data'; import { stripRegions } from './flow-walk.js'; export interface FlowLintFinding { @@ -779,46 +783,90 @@ function scanBranchRouting( * #5482 — is this AUTHORED `filter` provably carrying no condition at all, so * that the write it is supposed to bound is bounded by nothing? * - * Exactly two shapes answer `true`, and the narrowness is the point — a warning - * that says "this is the whole object" has to be right about it: + * The question is "does this filter reduce to TRUE", and #5659 stopped it being + * answered by hand. Three shapes answer `true`: * * - the key is **absent** (`undefined` / `null`). The executor substitutes `{}` * (`resolveNodeFilter(cfg.filter ?? {}, …)` in `crud-nodes.ts`). * - a plain object with **zero own keys** (`{}`), which the executor passes * through unchanged. + * - anything the shared identity reduction resolves to TRUE — `{ $and: [] }` + * (the AND identity: a conjunction of zero conditions constrains nothing) and + * `{ $or: [{}] }` (a TRUE disjunct absorbs its `$or`) are the two that occur + * in authored metadata, and both were INVISIBLE here until #5659. * - * Both arrive at the engine as `where: {}`, which `resolveEngineDeleteDispatch` - * classifies as `multi` (its case-set lists `multi with no predicate at all` as - * legal) and the driver reads as every row — `driver-memory`'s matcher opens - * with `if (!filter || Object.keys(filter).length === 0) return true`. + * All of them arrive at the engine as an unbounded write: + * `resolveEngineDeleteDispatch` classifies `where: {}` as `multi` (its case-set + * lists `multi with no predicate at all` as legal) and every driver reads a + * TRUE-reducing filter as every row. + * + * ## Why the third bullet is a CALL and not a third `if` + * + * This function used to end at the second bullet, and said so in a paragraph + * that is now this change's own justification: the empty combinators were left + * alone not because their answer was unclear — #5322/#5134 ruled it — but + * because deciding which is which requires the identity REDUCTION, and that + * reduction existed three times over (`reduceFilterNode` in driver-sql, in + * driver-mongodb, and the matcher's algebra in driver-memory). "Hand-writing a + * fourth copy inside a linter is how the scan and the validator come to answer + * with two different predicates." #5659 removed the reason to choose: the + * reduction is `@objectstack/spec`'s {@link reduceFilterVerdict} now, proven + * against `FILTER_LOGIC_CASES`, and all four consumers read it. + * + * What the reduction does NOT warn about matters as much as what it does: + * `{ $or: [] }` is FALSE (matches NOTHING — the opposite of a whole-object + * write) and `{ $not: {} }` is FALSE likewise. A hand-written "is it an empty + * combinator" test would have warned about both; the shared verdict cannot, + * because it is the same verdict the drivers execute. * * Everything else is left alone, deliberately: * - * - **any object with ≥1 key** — including an authored `{token}` that will - * interpolate to nothing. That is the #3810 guard's fact, judged at run time - * against the interpolation result, and this rule must not pre-empt it: at - * authoring time the condition IS written. - * - **an empty combinator array** (`{ $and: [] }`, `{ $or: [] }`). Not because - * the answer is unclear — #5322/#5134 ruled it and every driver implements - * it: empty `$and` is TRUE (so `{ $and: [] }` on a `multi` write IS the whole - * object), empty `$or` is FALSE (so `{ $or: [] }` matches NOTHING and must - * never be warned about), `$not` of an empty group is FALSE. Deciding which - * is which requires the identity REDUCTION, and that reduction already exists - * three times (`reduceFilterNode` in driver-sql, driver-mongodb, and the - * matcher/refusal walk in driver-memory). Hand-writing a fourth copy inside a - * linter is how the scan and the validator come to answer with two different - * predicates — the failure `engine-delete-dispatch.ts` was extracted to - * prevent. Filed separately, to be done from one shared predicate. + * - **any node carrying a real predicate** (verdict `'clause'`) — including an + * authored `{token}` that will interpolate to nothing. That is the #3810 + * guard's fact, judged at run time against the interpolation result, and this + * rule must not pre-empt it: at authoring time the condition IS written. * - **a non-object `filter`** (string, array, number). `DeleteRecordConfigSchema` * /`UpdateRecordConfigSchema` type it `z.record(z.string(), z.unknown())`, so * the node is refused BY NAME at execute time (`parseNodeConfig`). Warning * "the object is unbounded" about metadata the schema already rejects would - * describe a run that never happens. + * describe a run that never happens. Kept as a guard HERE rather than left to + * the reduction: the shared predicate takes a node, and a linter that hands + * it a string would be asking a question the schema already answered. */ function filterCarriesNoCondition(filter: unknown): boolean { if (filter === undefined || filter === null) return true; if (typeof filter !== 'object' || Array.isArray(filter)) return false; - return Object.keys(filter as AnyRec).length === 0; + // Hookless: this linter refuses nothing. A shape the reduction cannot resolve + // answers `'clause'`, i.e. no warning — the conservative direction for a rule + // whose message asserts "every row of this object is written". + return reduceFilterVerdict(filter as AnyRec) === 'true'; +} + +/** + * #5659 — name the shape the author actually wrote, for a message that has to be + * right about "this is the whole object". + * + * The two combinator spellings get their own wording rather than being folded + * into "an EMPTY `filter`": `{ $and: [] }` is not empty, it is a conjunction of + * zero conditions, and an author told their non-empty filter is "empty" will + * look for a typo instead of reading the identity. Only reached for a filter + * {@link filterCarriesNoCondition} already resolved to TRUE. + */ +function describeUnboundedFilter(filter: unknown): string { + if (filter === undefined || filter === null) return 'no `filter` key'; + if (Object.keys(filter as AnyRec).length === 0) return 'an EMPTY `filter`'; + return `a \`filter\` that REDUCES TO TRUE (\`${previewFilter(filter)}\`)`; +} + +/** A short, non-throwing rendering of the offending filter for the message. */ +function previewFilter(filter: unknown): string { + try { + const json = JSON.stringify(filter); + if (typeof json !== 'string') return typeof filter; + return json.length > 80 ? `${json.slice(0, 77)}...` : json; + } catch { + return typeof filter; + } } /** @@ -877,13 +925,14 @@ function scanUnboundedBulkWrites( if (!filterCarriesNoCondition(cfg.filter)) continue; const objectName = typeof cfg.objectName === 'string' && cfg.objectName ? cfg.objectName : '(unnamed object)'; - const filterState = cfg.filter === undefined || cfg.filter === null ? 'no `filter` key' : 'an EMPTY `filter`'; + const filterState = describeUnboundedFilter(cfg.filter); findings.push({ where: `${at} · node '${String(node.id)}' (${nodeType})`, message: `declares \`multi: true\` with ${filterState} — this is a WHOLE-OBJECT write, by declaration: every ` + - `row of '${objectName}' is ${consequence.verb} on every run. The executor forwards \`where: {}\` plus the ` + - `bulk intent, ${consequence.dispatchNote}, and it lands on \`${consequence.engineCall}\` with no predicate. ` + + `row of '${objectName}' is ${consequence.verb} on every run. The executor forwards the filter as \`where\` ` + + `(an absent key becomes \`{}\`) plus the bulk intent, ${consequence.dispatchNote}, and it lands on ` + + `\`${consequence.engineCall}\` bounded by nothing — a filter that reduces to TRUE constrains no row. ` + `Nothing refuses it at run time, so the only feedback is the step's \`acted\` row count — reported ` + `AFTER the rows are gone.`, hint: diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index ba22d16e62..42dc4ac892 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -252,6 +252,9 @@ "FilterTextRejectionCase (interface)", "FilterTextRow (interface)", "FilterTextRowsCase (interface)", + "FilterVerdict (type)", + "FilterVerdictHooks (interface)", + "FilterVerdictOptions (interface)", "FormatValidation (type)", "FormatValidationParsed (type)", "FormatValidationSchema (const)", @@ -603,6 +606,8 @@ "parseFilterAST (function)", "percentScaleOf (function)", "provisionPrimary (function)", + "reduceFilterKeyVerdict (function)", + "reduceFilterVerdict (function)", "referenceTargetOf (function)", "referencedFields (function)", "renderAutonumber (function)", diff --git a/packages/spec/src/data/filter-verdict.test.ts b/packages/spec/src/data/filter-verdict.test.ts new file mode 100644 index 0000000000..82aba9efe2 --- /dev/null +++ b/packages/spec/src/data/filter-verdict.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5659] The shared identity reduction, proven against the same table its + * consumers are proven against. + * + * The first suite is the load-bearing one: {@link FILTER_LOGIC_CASES} is the + * standard every filter backend answers, and a verdict is a CLAIM about that + * answer — `'true'` claims the filter matches every fixture row, `'false'` + * claims it matches none. Checking the claim against the table's own `expected` + * is what makes this function provable rather than merely tested, and it is why + * the module lives beside the table: a case added to the table judges the + * reduction on the same commit. + * + * The implication is asserted one way only. `'true'` ⇒ every row and `'false'` + * ⇒ no row are facts about what the verdict MEANS; the converse ("a case that + * matches every row must reduce to TRUE") is not — `{ c: 'z' }` matches all + * four rows and is a real predicate. Asserting the converse would forbid a + * future case for being data-dependent, so the four identity cases are pinned + * by NAME instead, which catches an identity that regresses to `'clause'` + * without constraining anything else the table may grow. + */ + +import { describe, expect, it } from 'vitest'; + +import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from './filter-logic-conformance'; +import { + reduceFilterKeyVerdict, + reduceFilterVerdict, + type FilterVerdict, + type FilterVerdictHooks, +} from './filter-verdict'; + +const ALL_ROW_IDS = FILTER_LOGIC_ROWS.map((row) => row.id); + +/** The four identity cases of the shared table, by the names it gives them. */ +const IDENTITY_CASE_VERDICTS: Record = { + 'empty $and is TRUE — the AND identity': 'true', + 'empty $or is FALSE — the OR identity': 'false', + 'a {} branch is a TRUE disjunct and absorbs its $or': 'true', + '$not of {} is FALSE — NOT TRUE': 'false', +}; + +describe('reduceFilterVerdict against FILTER_LOGIC_CASES', () => { + for (const c of FILTER_LOGIC_CASES) { + it(`agrees with the table on: ${c.name}`, () => { + const verdict = reduceFilterVerdict(c.filter as Record); + if (verdict === 'true') { + expect(c.expected, `${c.name} reduced to TRUE, so it must match every row`).toEqual( + ALL_ROW_IDS, + ); + } else if (verdict === 'false') { + expect(c.expected, `${c.name} reduced to FALSE, so it must match no row`).toEqual([]); + } + }); + } + + it('pins the four identity cases by name', () => { + const pinned = FILTER_LOGIC_CASES.filter((c) => c.name in IDENTITY_CASE_VERDICTS); + // Guards against the pin silently emptying if a case is renamed. + expect(pinned).toHaveLength(Object.keys(IDENTITY_CASE_VERDICTS).length); + for (const c of pinned) { + expect(reduceFilterVerdict(c.filter as Record), c.name).toBe( + IDENTITY_CASE_VERDICTS[c.name], + ); + } + }); + + it('answers clause for every case that carries a real predicate', () => { + const identityNames = new Set(Object.keys(IDENTITY_CASE_VERDICTS)); + for (const c of FILTER_LOGIC_CASES) { + if (identityNames.has(c.name)) continue; + expect(reduceFilterVerdict(c.filter as Record), c.name).toBe('clause'); + } + }); +}); + +describe('reduceFilterVerdict — the boolean algebra', () => { + it('reads an empty node as the empty conjunction', () => { + expect(reduceFilterVerdict({})).toBe('true'); + }); + + it('lets FALSE dominate the AND of a node', () => { + expect(reduceFilterVerdict({ a: 'x', $or: [] })).toBe('false'); + }); + + it('lets a clause survive a TRUE sibling', () => { + expect(reduceFilterVerdict({ a: 'x', $and: [] })).toBe('clause'); + }); + + it('absorbs a $or whose disjunct is TRUE, at depth', () => { + expect(reduceFilterVerdict({ $or: [{ a: 'x' }, { $and: [] }] })).toBe('true'); + }); + + it('reduces $and of a FALSE member to FALSE', () => { + expect(reduceFilterVerdict({ $and: [{ a: 'x' }, { $or: [] }] })).toBe('false'); + }); + + it('negates a resolved inner node and passes a clause through', () => { + expect(reduceFilterVerdict({ $not: { $or: [] } })).toBe('true'); + expect(reduceFilterVerdict({ $not: { a: 'x' } })).toBe('clause'); + }); + + it('is insensitive to key order', () => { + expect(reduceFilterVerdict({ $and: [], $or: [] })).toBe('false'); + expect(reduceFilterVerdict({ $or: [], $and: [] })).toBe('false'); + }); +}); + +describe('reduceFilterVerdict — hookless defaults are conservative', () => { + // Every shape here is refused by the drivers' hooks. With no hooks the answer + // must be `'clause'` — "carries a predicate", i.e. the caller learns nothing + // — and never `'true'`, which would promote an unreducible shape to match-all. + it.each([ + ['a non-array $and', { $and: 'x' }], + ['a non-array $or', { $or: { a: 'x' } }], + ['a non-node $and element', { $and: [new Date()] }], + ['a non-node $or element', { $or: [42] }], + ['a null $not operand', { $not: null }], + ['a Date $not operand', { $not: new Date() }], + ['a class-instance element that enumerates to nothing', { $or: [new Map()] }], + ])('answers clause for %s', (_label, filter) => { + expect(reduceFilterVerdict(filter as Record)).toBe('clause'); + }); + + it('reads a field key as a predicate', () => { + expect(reduceFilterVerdict({ status: 'active' })).toBe('clause'); + // Including one the schema would reject: the linter runs on metadata that + // has not been accepted yet, and "unreducible" is not "matches nothing". + expect(reduceFilterVerdict({ status: {} })).toBe('clause'); + }); +}); + +describe('reduceFilterVerdict — hooks', () => { + const calls: string[] = []; + const recording: FilterVerdictHooks = { + assertNodeList: (_value, key, path) => void calls.push(`list:${key}@${path}`), + assertNode: (_value, path) => void calls.push(`node:@${path}`), + classifyKey: (key, _value, path) => { + calls.push(`key:${key}@${path}`); + return 'clause'; + }, + }; + + it('reports the path of every position it walks', () => { + calls.length = 0; + reduceFilterVerdict({ $or: [{ a: 'x' }, { $not: { b: 'y' } }] }, { ...recording, path: 'filter' }); + expect(calls).toEqual([ + 'list:$or@filter.$or', + 'node:@filter.$or[0]', + 'key:a@filter.$or[0].a', + 'node:@filter.$or[1]', + 'node:@filter.$or[1].$not', + 'key:b@filter.$or[1].$not.b', + ]); + }); + + it('does not short-circuit — a resolved sibling still reaches later nodes', () => { + // The reason the drivers' refusals live on this walk: `$or: []` resolves the + // whole key, and an emitter-side gate would then judge the malformed node + // after it depending on ITS SIBLINGS. + const seen: string[] = []; + reduceFilterVerdict( + { $or: [], $and: [{ a: 'x' }, { b: 'y' }] }, + { classifyKey: (key) => (seen.push(key), 'clause') }, + ); + expect(seen).toEqual(['a', 'b']); + }); + + it('propagates a hook refusal instead of answering', () => { + const refusing: FilterVerdictHooks = { + assertNode: (value, path) => { + if (value instanceof Date) throw new Error(`refused at ${path}`); + }, + }; + expect(() => reduceFilterVerdict({ $or: [{ a: 'x' }, new Date()] }, refusing)).toThrow( + 'refused at $or[1]', + ); + }); + + it('lets classifyKey resolve a key that carries no predicate', () => { + // `driver-mongodb`'s query-level keys: the emitter skips them, so the + // verdict must too or the two disagree about what the node is worth. + const hooks: FilterVerdictHooks = { + classifyKey: (key) => (key === 'limit' ? 'true' : 'clause'), + }; + expect(reduceFilterVerdict({ limit: 10 }, hooks)).toBe('true'); + expect(reduceFilterVerdict({ limit: 10, a: 'x' }, hooks)).toBe('clause'); + }); +}); + +describe('reduceFilterKeyVerdict', () => { + it('answers for one key without rebuilding a node', () => { + expect(reduceFilterKeyVerdict('$and', [])).toBe('true'); + expect(reduceFilterKeyVerdict('$or', [])).toBe('false'); + expect(reduceFilterKeyVerdict('$not', {})).toBe('false'); + expect(reduceFilterKeyVerdict('a', 'x')).toBe('clause'); + }); + + it('agrees with the node reduction on a single-key node', () => { + for (const [key, value] of [ + ['$and', [{ a: 'x' }]], + ['$or', [{}, { a: 'x' }]], + ['$not', { $or: [] }], + ['status', 'active'], + ] as const) { + expect(reduceFilterKeyVerdict(key, value)).toBe(reduceFilterVerdict({ [key]: value })); + } + }); +}); diff --git a/packages/spec/src/data/filter-verdict.ts b/packages/spec/src/data/filter-verdict.ts new file mode 100644 index 0000000000..39ce8e007e --- /dev/null +++ b/packages/spec/src/data/filter-verdict.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5659] The Filter Protocol's **boolean identity reduction** — what a filter + * node is worth before any backend translates it. + * + * ## Why this is here and not in each backend + * + * `{ $and: [] }` matches every row, `{ $or: [] }` matches none, `{}` is a TRUE + * disjunct that absorbs its `$or`, and `{ $not: {} }` is FALSE. Those four + * answers are a RULING (#5322/#5134), pinned for every backend by the four + * identity cases in {@link FILTER_LOGIC_CASES} — and until this module existed + * the ruling was implemented four times over: `reduceFilterNode` in + * `driver-sql`, the same function again in `driver-mongodb`, the + * `every`/`some`/truthiness algebra of `driver-memory`'s matcher, and — nearly + * — a fifth hand-written copy inside `@objectstack/lint`, which + * `lint-flow-patterns.ts` declined to write and filed instead: + * + * > Deciding which is which requires the identity REDUCTION, and that + * > reduction already exists three times […]. Hand-writing a fourth copy + * > inside a linter is how the scan and the validator come to answer with two + * > different predicates. + * + * One concept, one implementation. A backend that needs the verdict asks for it + * here; the answer cannot drift from the table that proves it, because + * `filter-verdict.test.ts` drives {@link FILTER_LOGIC_CASES} through this very + * function and asserts that a `'true'` verdict means the case expects EVERY + * fixture row and a `'false'` verdict means it expects none. + * + * ## What this deliberately is NOT + * + * It is not a validator, and it never throws on its own. Each backend refuses a + * different set of shapes with its own error envelope and its own wording — the + * `$`-prefixed unknown combinator and the `undefined` comparand in `driver-sql`, + * the query-level keys and the `$null` comparand in `driver-mongodb`, nothing at + * all in a linter that must survive metadata the schema has not accepted yet. + * Those refusals are supplied as {@link FilterVerdictHooks} and are invoked from + * exactly the positions the drivers already invoked them from, so folding the + * shared algebra in changes no message, no code, no status. + * + * The hooks are also why this is a walk and not a predicate over a pre-validated + * tree: the drivers' refusals must fire on EVERY node, including nodes an + * identity would otherwise let the emitter skip. So the reduction does not + * short-circuit — a `$or: []` sibling must not stop the walk from reaching, and + * refusing, a malformed node further along, or the shape gate becomes + * conditional on key order. + * + * ## Hookless defaults are conservative, never optimistic + * + * With no hooks (the linter's use), every shape this module cannot reduce + * answers `'clause'` — a non-array `$and`, a non-plain-object element, a + * `$not` operand that is not a node. `'clause'` means "carries a real + * predicate", i.e. the caller learns nothing and must not conclude "matches + * everything". Answering `'true'` for an unreducible shape is the one direction + * that turns garbage into a match-all, which is the failure #5239 named when it + * put the non-node refusal in front of MongoDB's identity reduction. + * + * @see FILTER_LOGIC_CASES — the standard this reduction is proven against. + */ + +/** + * What a filter node is worth as a boolean, before any backend emits anything. + * + * - `'true'` — matches every row; a compiler emits no clause for it. + * - `'false'` — matches no row; a compiler emits its dialect's FALSE constant. + * - `'clause'` — carries at least one real predicate; compile it normally. + */ +export type FilterVerdict = 'true' | 'false' | 'clause'; + +/** + * The per-backend refusals, invoked from the positions the reduction walks. + * + * Every hook may throw — that is their whole purpose — and a hook that returns + * normally leaves the reduction's own defaults in charge. Omitting all three + * gives the pure predicate described in this module's header. + */ +export interface FilterVerdictHooks { + /** + * The operand of `$and` / `$or`, before it is iterated. `driver-sql` and + * `driver-mongodb` refuse a non-array here rather than coercing it; a caller + * with no hook gets `'clause'` for the same shape. + */ + assertNodeList?: (value: unknown, key: string, path: string) => void; + /** + * One element of a `$and` / `$or` list, or the operand of `$not`, before it + * is reduced. This is the gate that gives "this group reduced to empty" + * exactly one cause (#5239): without it a `Date` — an object that enumerates + * to nothing — would read as the empty conjunction, i.e. TRUE. + */ + assertNode?: (value: unknown, path: string) => void; + /** + * The verdict of a key that is NOT one of the three declared combinators. + * Defaults to `'clause'`: a field key always contributes a predicate. + * + * Backends override it to refuse (an unknown `$`-prefixed combinator, an + * empty field constraint, an `undefined` comparand) or to classify + * (`driver-mongodb`'s query-level keys carry no predicate, so they answer + * `'true'` and the verdict cannot disagree with the emitter that skips them). + */ + classifyKey?: (key: string, value: unknown, path: string) => FilterVerdict; +} + +/** {@link FilterVerdictHooks} plus the path prefix used in hook messages. */ +export interface FilterVerdictOptions extends FilterVerdictHooks { + /** Prefix for the paths handed to the hooks. Defaults to `''`. */ + path?: string; +} + +/** + * Is `value` a Filter Protocol NODE — the shape `FilterConditionSchema` + * declares for every element of `$and`/`$or` and for the operand of `$not`? + * + * The prototype check is the load-bearing half. The reduction turns "this node + * has no predicates" into "matches every row", so any object whose own + * enumerable keys are empty reads as TRUE. A `Date`, a `RegExp`, a `Map` or a + * class instance all satisfy `typeof x === 'object' && !Array.isArray(x)` while + * enumerating to nothing, and reading one as TRUE would promote garbage to + * match-all. A filter condition always arrives as JSON or as a compiler's + * output, i.e. a plain object, so requiring one costs nothing real. + */ +function isFilterNode(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Reduce one filter node to its boolean verdict. + * + * A node is the AND of its entries, so FALSE dominates and a node with no + * entries at all is TRUE (the empty conjunction) — which is why `{}` is a TRUE + * disjunct inside `$or` and why `{ $not: {} }` is FALSE. + */ +export function reduceFilterVerdict( + node: Record, + options: FilterVerdictOptions = {}, +): FilterVerdict { + return reduceNode(node, options, options.path ?? ''); +} + +/** + * Reduce ONE key of a filter node to its verdict. + * + * Exported because both SQL and MongoDB emitters consult a single key's verdict + * while walking a node they are already inside — `if (verdict === 'true') + * continue` is how an emitter skips a key the reduction resolved — and a second + * entry point is cheaper than making them rebuild a one-key object. + */ +export function reduceFilterKeyVerdict( + key: string, + value: unknown, + options: FilterVerdictOptions = {}, +): FilterVerdict { + return reduceKey(key, value, options, options.path ?? ''); +} + +function reduceNode( + node: Record, + hooks: FilterVerdictHooks, + path: string, +): FilterVerdict { + let sawFalse = false; + let sawClause = false; + for (const [key, value] of Object.entries(node)) { + const verdict = reduceKey(key, value, hooks, path); + if (verdict === 'false') sawFalse = true; + else if (verdict === 'clause') sawClause = true; + } + // AND over the node's keys: FALSE dominates, then a real predicate, else TRUE. + return sawFalse ? 'false' : sawClause ? 'clause' : 'true'; +} + +function reduceKey( + key: string, + value: unknown, + hooks: FilterVerdictHooks, + path: string, +): FilterVerdict { + const here = path ? `${path}.${key}` : key; + + if (key === '$and' || key === '$or') { + hooks.assertNodeList?.(value, key, here); + // Hookless callers reach this line with whatever the author wrote. A + // non-array cannot be reduced, and `'clause'` is the answer that claims + // nothing about it. + if (!Array.isArray(value)) return 'clause'; + let sawTrue = false; + let sawFalse = false; + let sawClause = false; + value.forEach((element, index) => { + const elementPath = `${here}[${index}]`; + hooks.assertNode?.(element, elementPath); + const verdict = isFilterNode(element) ? reduceNode(element, hooks, elementPath) : 'clause'; + if (verdict === 'true') sawTrue = true; + else if (verdict === 'false') sawFalse = true; + else sawClause = true; + }); + // `$and: []` → no FALSE, no clause → TRUE (the AND identity). + if (key === '$and') return sawFalse ? 'false' : sawClause ? 'clause' : 'true'; + // `$or: []` → no TRUE, no clause → FALSE (the OR identity). + return sawTrue ? 'true' : sawClause ? 'clause' : 'false'; + } + + if (key === '$not') { + hooks.assertNode?.(value, here); + if (!isFilterNode(value)) return 'clause'; + const inner = reduceNode(value, hooks, here); + // NOT TRUE ≡ FALSE — so `{ $not: {} }` matches nothing. + return inner === 'true' ? 'false' : inner === 'false' ? 'true' : 'clause'; + } + + return hooks.classifyKey?.(key, value, here) ?? 'clause'; +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index ddbbeedc2b..03e357d264 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -7,6 +7,14 @@ export * from './filter.zod'; // against, so they cannot drift apart again (#3774; the fifth — MongoDB's // `translateFilter` — was enrolled by #4405). export * from './filter-logic-conformance'; +// The boolean identity reduction those cases pin (#5659) — `$and: []` is TRUE, +// `$or: []` is FALSE, `{}` is a TRUE disjunct, `$not: {}` is FALSE. One +// implementation, consumed by driver-sql, driver-mongodb, driver-memory and the +// flow linter, so the scan and the compilers cannot answer with two different +// predicates. Deliberately a sibling of the case table rather than a member of +// it: the table is data every backend is checked against, this is the shared +// answer three of them now compute WITH. +export * from './filter-verdict'; // Canonical conformance cases for the filter TEXT operators — case folding // (ASCII-only, #4706 Q1), literal comparands (no LIKE wildcards, no regex // metacharacters), and the refusal of the retired `$regex`/`$options`. A