diff --git a/.changeset/driver-memory-array-comparand-refusal-date-equality.md b/.changeset/driver-memory-array-comparand-refusal-date-equality.md new file mode 100644 index 0000000000..87962b7072 --- /dev/null +++ b/.changeset/driver-memory-array-comparand-refusal-date-equality.md @@ -0,0 +1,19 @@ +--- +"@objectstack/driver-memory": minor +--- + +fix(driver-memory)!: an array comparand is refused instead of compared by reference, and a `Date` comparand is compared by time value + + + +`checkCondition` routed both `Date` and `Array` into `value == condition`, under a comment two lines above calling that "exact match" — and a comment seven lines below giving `==`'s reasons, all three of which are about primitives ("undefined/null mismatch", "string/number coercion", "convenient in weakly typed queries"). Between two objects `==` does no conversion at all: it compares REFERENCES. So the arm was neither exact nor a match, and the two adjacent comments described something the code could not do. + +**BREAKING** accept-set narrowing on a published driver, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`): a purely additive widening takes at least `minor`, and a narrowing is not the additive bucket, so `patch` is refused here — the public surface really does move. The two halves take DIFFERENT dispositions, and the difference is the contract's rather than this driver's: + +**An ARRAY comparand is refused.** `@objectstack/spec`'s comparand door names an array outside `$in`/`$nin`/`$between` as a position it deliberately does not rule, leaving it "to the layers that already answer it"; `ACCEPTED_FILTER_COMPARAND_TYPES` has no array member, and `driver-sql` refuses one with its own message. This driver now answers the same way, in the ADR-0112 envelope its every other filter refusal speaks (`code: 'INVALID_FILTER'`, `status: 400`), with a message naming the field, the received shape, the accepted set and the operators that DO take a list. The refusal is raised from `assertFilterConditionShape` — the one gate every face of this package runs before it evaluates anything — so the live query path, the reference matcher and the analytics face cannot disagree about it. + +Why refused rather than made to work, measured on one row `{ tags: ['a','b'] }` with the filter `{ tags: ['a','b'] }` before this change: the live query path returned the row (mingo deep-equals arrays) and the reference matcher returned none (`==` compared references). One filter, one package, two answers, neither reported — the same shape as this package's `{ field: {} }`, malformed-`$between` and non-boolean-`$null` refusals. Converging UP to deep equality was available and is not what landed: the contract declines to rule the cell, and every sibling that has answered it declines the semantics. + +**A `Date` comparand is EVALUATED, not refused** — the opposite disposition, because the contract does rule this one. `Date` is a member of `ACCEPTED_FILTER_COMPARAND_TYPES`, and `FILTER_COMPARAND_TYPE_CASES` requires that a Date comparand "must pass the door and execute everywhere". It is now compared by time value, arm for arm with `@objectstack/formula`'s `looseEq` — the sibling record-at-a-time matcher this face's conformance suites are held against — which also settles the case that actually reaches a stored row: a declared `datetime` is canonicalised to ISO TEXT on write (ADR-0053 D-B1), so a `Date` comparand meets a STRING, and `==` stringified the Date to `"Wed Jan 01 2026 …"`, which no ISO value equals. `$eq` / `$ne` take the same equality, so one predicate no longer answers two ways depending on which spelling the author used. An Invalid Date has no time value and so equals nothing, itself included. + +**What is deliberately NOT changed**, and is now pinned so a later edit cannot take it away by accident: a SCALAR comparand against a stored ARRAY. `{ tags: 'a' }` against a row storing `['a','b']` still answers false, and `{ tags: 'a,b' }` against that row still answers true, because `==` stringifies the stored array to `"a,b"`. That is a third bad direction of the same operator, but it is on the VALUE side, and the comparand door judges comparands. It is recorded, not repaired. The list operators (`$in` / `$nin` / `$between`), for which an array is the declared comparand, are untouched; so is the text family, whose comparand disposition this package's refusal gate had already recorded as deliberately fail-closed. diff --git a/.changeset/spec-comparand-door-array-parenthetical.md b/.changeset/spec-comparand-door-array-parenthetical.md new file mode 100644 index 0000000000..4d13b62f6d --- /dev/null +++ b/.changeset/spec-comparand-door-array-parenthetical.md @@ -0,0 +1,9 @@ +--- +"@objectstack/spec": minor +--- + +docs(spec): the comparand door's array parenthetical states what the drivers do today + +`filter-comparand-type.ts`'s list of the cases its door deliberately does not rule described the array cell as "`driver-sql` refuses it with its own message; the document stores give it array-equality semantics". Measured on this tree, that second clause is no longer a true statement about `@objectstack/driver-memory`: its reference matcher compared an array comparand by REFERENCE (matching nothing), while its live query path deep-equalled it (matching the row) — one package, two answers, neither of them a stable "array-equality semantics" a reader could build on. With that driver's cell now refused, the sentence names the two dispositions that exist: `driver-sql` and `driver-memory` refuse it, each with its own message, and `driver-mongodb` hands it to MongoDB and inherits that engine's array semantics. + +The paragraph's point is unchanged and deliberately kept: the door does not rule this position, the matrix did not measure it, and it is left to the layers that already answer it. Only the description of what those layers do is corrected — a stale factual clause is how the next reader re-derives "the spec promises array-equality" from a passage that explicitly promises nothing. The neighbouring pass-through pin's comment carried the same stale characterisation and is corrected the same way; its assertion (that `parseFilterAST` leaves `{ tags: ['a','b'] }` untouched) is unchanged and still pins the door not judging. diff --git a/packages/drivers/driver-memory/src/filter-refusal.ts b/packages/drivers/driver-memory/src/filter-refusal.ts index 9e712231ae..fd9491707c 100644 --- a/packages/drivers/driver-memory/src/filter-refusal.ts +++ b/packages/drivers/driver-memory/src/filter-refusal.ts @@ -31,6 +31,10 @@ */ import { FILTER_OPERATORS, LOGICAL_OPERATORS, RETIRED_FILTER_OPERATORS } from '@objectstack/spec/data'; +// [#16810] The accepted comparand-type set's own sentence, quoted rather than +// hand-copied — the same single-sourcing `driver-sql`'s refusals use, so the +// two backends cannot describe the accepted set differently. +import { ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE } from '@objectstack/spec/data'; // [#7536] The `$like` pattern language's shared gate, so this driver refuses // the same malformed patterns as every other face. import { hasDanglingLikeEscape } from '@objectstack/spec/data'; @@ -553,6 +557,85 @@ export function retiredFilterOperatorError( ); } +/** + * [#16810] The comparison operators whose comparand is a SINGLE value. + * + * The list operators (`$in` / `$nin` / `$between`) are deliberately absent: an + * array is their DECLARED comparand. The text family (`$contains`, + * `$startsWith`, `$icontains`, `$like`, …) is absent too, and that absence is a + * decision this gate already recorded — "a stringified comparand for the LIKE + * family" is on its own "deliberately NOT refused" list, fail-closed and left + * alone. `driver-sql` refuses an array there as well (it cannot bind one); this + * driver keeps its recorded disposition rather than widening a refusal past the + * cell that was ruled. + */ +const SINGLE_VALUE_COMPARISON_OPERATORS: ReadonlySet = new Set([ + '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', +]); + +/** + * [#16810] An ARRAY where a comparison expects one comparable value — + * `{ tags: ['a','b'] }` and `{ tags: { $eq: ['a','b'] } }`. + * + * ## The cell, and who already answers it + * + * `@objectstack/spec`'s comparand door names this position and steps around it: + * an array outside `$in`/`$nin`/`$between` "is answered per driver today … the + * ruling does not name it, so the door leaves it to the layers that already + * answer it". `ACCEPTED_FILTER_COMPARAND_TYPES` — the six types the door DOES + * rule — has no array member, and `driver-sql` refuses one with + * `unbindableComparandError`. This is that same answer, in this package's + * envelope. + * + * ## Why refused rather than deep-equalled — the two answers, measured + * + * This package's faces did not agree, and the disagreement is the whole reason + * a refusal beats either silent answer. On one row `{ tags: ['a','b'] }`, with + * the filter `{ tags: ['a','b'] }`: + * + * | face | answer | + * |---|---| + * | the live query path (`InMemoryDriver.find` → mingo) | the row — mingo deep-equals arrays | + * | the reference matcher (`memory-matcher.ts`) | NO row | + * + * The matcher's arm routed an array into `value == condition`, and `==` between + * two objects compares REFERENCES: a deep-equal array is never that reference, + * so the predicate could not match any row it had not been handed literally. + * Fail-closed and silent — fewer rows, no error, no warning — which is the + * `#5240` / `#5328` / `#5347` shape exactly: one filter, one package, two + * answers, and `if (!rows.length)` cannot tell "genuinely none" from "the + * predicate never ran". + * + * Converging them UP (deep equality on both faces) was available and is not + * what landed: the spec's door does not rule the cell, and every sibling that + * has ruled it declines the semantics — `driver-sql` refuses it, + * `@objectstack/formula`'s record-at-a-time matcher answers `false` by an + * explicit "a bare array value is not a valid field spec" arm, and objectui's + * `ValueDataSource` was resolved the same way. A driver that calls itself a + * Reference Implementation inventing array-equality alone, on a cell the + * contract declined to rule, is how backends drift apart. + * + * ⚠️ The refusal is about the COMPARAND, never the stored value: + * `{ tags: 'a' }` against a row storing `['a','b']` is a scalar comparand and + * is untouched by this rule — the value side is not a position this door + * judges. + */ +export function arrayComparandError(field: string, value: unknown, path: string, op?: string): Error { + const position = op + ? `Operator "${op}" on field "${field}"` + : `The implicit-equality comparand on field "${field}"`; + return unsupportedFilterError( + `${position} requires a single comparable value, but received an array ` + + `(${safeShapePreview(value)}) at ${path}. Use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}; ` + + `for a list use $in/$nin, and for a range use $between. It is refused rather than compared ` + + `because this package's two faces answered it differently and neither said so — the live ` + + `query path deep-equalled the array and returned the row, while the reference matcher ` + + `compared it by REFERENCE (== between two objects is a reference test) and returned none. ` + + `@objectstack/spec's comparand door leaves this position to the driver, and driver-sql ` + + `refuses it too.`, + ); +} + /** [#5324] `$and`/`$or` take a list of nodes; anything else is refused. */ export function filterNodeListExpectedError(key: string, value: unknown, path: string): Error { return unsupportedFilterError( @@ -669,6 +752,11 @@ function assertFieldConstraintShape( path: string, capabilities: FilterFaceCapabilities, ): void { + // [#16810] The IMPLICIT-equality position, checked before the plain-object + // test below because an array is not a filter node and would otherwise leave + // this walk unjudged — which is how it reached the matcher's `==` arm and the + // live path's deep equality with nobody reconciling the two. + if (Array.isArray(spec)) throw arrayComparandError(field, spec, path); if (!isFilterNode(spec)) return; // [#5240] The zero-operator constraint keeps its own predicate rather than an // inlined `keys.length === 0`, so the reasoning for what does and does not @@ -702,6 +790,15 @@ function assertFieldConstraintShape( if (op === '$null' && typeof spec[op] !== 'boolean') { throw nonBooleanNullComparandError(field, spec[op], `${path}.$null`); } + // [#16810] An ARRAY comparand on a single-value comparison — the operator + // spelling of the implicit-equality position refused at the top of this + // function, and the same cell `@objectstack/spec`'s comparand door leaves + // to the driver. The fourth comparand-shape rule this gate makes, for the + // reason the three above it give: a shape the operator cannot evaluate was + // being answered silently, and differently, by each face. + if (SINGLE_VALUE_COMPARISON_OPERATORS.has(op) && Array.isArray(spec[op])) { + throw arrayComparandError(field, spec[op], `${path}.${op}`, op); + } // [#6520] `$icontains`' comparand is a NON-EMPTY string, the third // comparand-shape rule and the twin of `driver-sql`'s // `icontainsComparandError` — deliberately the same two rejections in one diff --git a/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts b/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts index c6cf19d374..39e7bb5433 100644 --- a/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts @@ -568,7 +568,11 @@ const OPERATOR_CASES: Array<[name: string, where: FilterCondition, expected: str // the operator table's inability to say "this operator takes the whole list". ['an empty $in selects nothing', { code: { $in: [] } } as FilterCondition, []], ['an empty $nin selects everything', { code: { $nin: [] } } as FilterCondition, ['1', '2', '3']], - ['an empty implicit-equality list selects nothing', { code: [] } as unknown as FilterCondition, []], + // [#16810] `{ code: [] }` — the empty implicit-equality ARRAY — left this + // table when the cell was ruled. It is not a row-set case any more: an + // array in an implicit-equality position is REFUSED on every face, and the + // property this entry carried (the faces agree about it) is asserted below + // as an agreement about the REFUSAL instead of about the empty row set. ['a non-empty $in still selects its members', { code: { $in: ['100'] } } as FilterCondition, ['1', '3']], ]; @@ -652,6 +656,42 @@ describe('[#5374] operator semantics — the analytics face against the live que }); } + /** + * [#16810] An ARRAY comparand in an implicit-equality position — refused, and + * refused by BOTH faces with one envelope. + * + * This replaces the row-set case `{ code: [] }` held in `OPERATOR_CASES` + * above. The empty array was the harmless-looking member of the cell: its + * two silent answers happened to coincide at "no rows", which is why nobody + * noticed that a NON-empty one did not. Measured before the refusal landed, + * on a row storing `['a','b']` with the filter `{ tags: ['a','b'] }`: the + * live query path returned the row (mingo deep-equals arrays) and the + * reference matcher returned nothing (`==` between two objects is a + * reference test). One filter, one package, two answers — the #5240 shape. + * + * The assertion is the AGREEMENT, which is what the retired entry was for. + */ + it('an implicit-equality ARRAY comparand is refused, and both faces refuse it alike', async () => { + const where = { code: [] } as unknown as FilterCondition; + const nonEmpty = { code: ['100'] } as unknown as FilterCondition; + + for (const [label, filter] of [['empty', where], ['non-empty', nonEmpty]] as const) { + await expect(findIds(filter), `${label}: the live query path did not refuse it`).rejects.toMatchObject({ + code: 'INVALID_FILTER', + status: 400, + }); + await expect(analyticsIds(filter), `${label}: the analytics face did not refuse it`).rejects.toMatchObject({ + code: 'INVALID_FILTER', + status: 400, + }); + } + + // The list operators are the declared spelling and are untouched — the + // refusal must not read as "this driver stopped accepting arrays". + expect(await findIds({ code: { $in: [] } } as FilterCondition)).toEqual([]); + expect(await findIds({ code: { $nin: [] } } as FilterCondition)).toEqual(['1', '2', '3']); + }); + /** * `contains` and `notContains` partition the table, stated as a predicate over * every comparand rather than case by case. An inert `$notContains` fails this diff --git a/packages/drivers/driver-memory/src/memory-matcher-array-and-date-comparand.test.ts b/packages/drivers/driver-memory/src/memory-matcher-array-and-date-comparand.test.ts new file mode 100644 index 0000000000..8d48022631 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-matcher-array-and-date-comparand.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16810] The implicit-equality arm's two non-primitive comparand types, and + * the third behaviour of the same operator that must NOT move. + * + * `checkCondition` routed `Date` and `Array` into `value == condition` and + * called the result "exact match" two lines above. `==` between two objects + * compares REFERENCES, so it is neither exact nor a match: a deep-equal array + * and an equal-instant `Date` both answered `false`, fail-closed and silent — + * fewer rows, no error, no warning, on a published driver that calls itself a + * Reference Implementation. + * + * The two halves get DIFFERENT dispositions, and the difference is the + * contract's, not this suite's: + * + * - **`Date` is EVALUATED.** It is a member of `ACCEPTED_FILTER_COMPARAND_TYPES` + * — the six types the spec's comparand door does rule — and + * `FILTER_COMPARAND_TYPE_CASES` requires that "a Date comparand must pass the + * door and execute everywhere". Refusing it would contradict a ruled cell. So + * it is compared by time value, exactly as `@objectstack/formula`'s `looseEq` + * does. + * - **An array is REFUSED.** The door names that position and steps around it + * ("the ruling does not name it, so the door leaves it to the layers that + * already answer it"), `ACCEPTED_FILTER_COMPARAND_TYPES` has no array member, + * and `driver-sql` refuses one. The refusal lives in + * `assertFilterConditionShape`, so the live query path, this matcher and the + * analytics face answer it identically. + * + * ⚠️ The third behaviour, pinned here so a later edit cannot take it away by + * accident: a SCALAR comparand against a stored ARRAY is untouched. `==` + * stringifies the stored array (`['a','b']` becomes `"a,b"`), which is a third + * bad direction of the same operator — but it is on the VALUE side, and the + * comparand door judges comparands. It is recorded, not repaired, and the + * numbers below are the record. + */ + +import { describe, it, expect } from 'vitest'; +import { match } from './memory-matcher.js'; + +const INSTANT = '2026-01-01T00:00:00.000Z'; + +describe('[#16810] Date comparands are compared by time value', () => { + it('a distinct Date object of the same instant matches', () => { + expect(match({ created_at: new Date(INSTANT) }, { created_at: new Date(INSTANT) })).toBe(true); + }); + + it('a Date of a DIFFERENT instant does not match — the control for the case above', () => { + expect(match({ created_at: new Date(INSTANT) }, { created_at: new Date('2026-06-01T00:00:00.000Z') })) + .toBe(false); + }); + + it('a Date comparand matches a stored ISO STRING of the same instant', () => { + // The case that actually reaches a stored row: a declared `datetime` is + // canonicalised to ISO text on write (ADR-0053 D-B1, `memory-temporal.ts`), + // so a `Date` comparand meets a string. `==` stringified the Date to + // "Wed Jan 01 2026 …", which no ISO value equals. + expect(match({ created_at: INSTANT }, { created_at: new Date(INSTANT) })).toBe(true); + }); + + it('a stored Date matches an ISO STRING comparand of the same instant — the same rule, mirrored', () => { + expect(match({ created_at: new Date(INSTANT) }, { created_at: INSTANT })).toBe(true); + }); + + it('an ISO string of a different instant does not match', () => { + expect(match({ created_at: new Date(INSTANT) }, { created_at: '2026-06-01T00:00:00.000Z' })).toBe(false); + }); + + it('a non-temporal string does not become a match by way of Date parsing', () => { + expect(match({ created_at: new Date(INSTANT) }, { created_at: 'active' })).toBe(false); + }); + + it('an Invalid Date equals nothing, itself included', () => { + // JS `Date` convention (NaN time value), `formula`'s `looseEq` answer, and + // ADR-0053 D-F1's reading that an Invalid Date has no canonical text. + expect(match({ created_at: new Date('nope') }, { created_at: new Date('nope') })).toBe(false); + }); + + it('$eq and $ne take the SAME equality as the implicit spelling', () => { + // One predicate must not answer two ways depending on which spelling the + // author used. + expect(match({ created_at: new Date(INSTANT) }, { created_at: { $eq: new Date(INSTANT) } })).toBe(true); + expect(match({ created_at: new Date(INSTANT) }, { created_at: { $ne: new Date(INSTANT) } })).toBe(false); + expect(match({ created_at: INSTANT }, { created_at: { $eq: new Date(INSTANT) } })).toBe(true); + }); + + it('the ordering operators keep answering a Date comparand', () => { + expect(match({ created_at: new Date(INSTANT) }, { created_at: { $gte: new Date(INSTANT) } })).toBe(true); + expect(match({ created_at: new Date(INSTANT) }, { created_at: { $gt: new Date(INSTANT) } })).toBe(false); + }); +}); + +describe('[#16810] an ARRAY comparand is refused, in the ADR-0112 envelope', () => { + const envelope = { code: 'INVALID_FILTER', status: 400 }; + + it('refuses the implicit-equality position', () => { + expect(() => match({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })).toThrow( + expect.objectContaining(envelope), + ); + }); + + it('refuses an EMPTY array too — the member of the cell whose two silent answers coincided', () => { + expect(() => match({ tags: [] }, { tags: [] })).toThrow(expect.objectContaining(envelope)); + }); + + it('refuses the $eq / $ne spelling of the same position', () => { + expect(() => match({ tags: ['a', 'b'] }, { tags: { $eq: ['a', 'b'] } })).toThrow( + expect.objectContaining(envelope), + ); + expect(() => match({ tags: ['a', 'b'] }, { tags: { $ne: ['a', 'b'] } })).toThrow( + expect.objectContaining(envelope), + ); + }); + + it('refuses an array on the ordering operators', () => { + expect(() => match({ qty: 5 }, { qty: { $gt: [1, 2] } })).toThrow(expect.objectContaining(envelope)); + }); + + it('names the position, the received shape and the accepted set', () => { + // The message is specific rather than generic: an author who wrote + // `{ tags: ['a','b'] }` must be told which field, what arrived, what is + // accepted, and which operators DO take a list. + let message = ''; + try { + match({ tags: ['a', 'b'] }, { tags: ['a', 'b'] }); + } catch (err) { + message = String((err as Error).message); + } + expect(message).toContain('tags'); + expect(message).toContain('a string, number, bigint, boolean, null or Date'); + expect(message).toContain('$in/$nin'); + expect(message).toContain('$between'); + }); + + it('leaves the LIST operators alone — an array is their declared comparand', () => { + expect(match({ tags: 'a' }, { tags: { $in: ['a', 'z'] } })).toBe(true); + expect(match({ tags: 'a' }, { tags: { $nin: ['a', 'z'] } })).toBe(false); + expect(match({ qty: 5 }, { qty: { $between: [1, 10] } })).toBe(true); + }); + + it('leaves the TEXT family alone — its comparand disposition is recorded elsewhere', () => { + // `filter-refusal.ts` lists "a stringified comparand for the LIKE family" + // among the shapes it deliberately does not refuse, fail-closed. This + // refusal covers the ruled cell and does not widen past it. + expect(() => match({ name: 'alpha' }, { name: { $contains: ['a'] } })).not.toThrow(); + }); +}); + +describe('[#16810] the value side is NOT the comparand side — recorded, not repaired', () => { + it('a scalar comparand against a stored array keeps the answers it had', () => { + // `==` stringifies the stored array. All three lines are the behaviour + // BEFORE this change as well; they are pinned so the refusal above cannot + // silently take the third direction of `==` with it. + expect(match({ tags: ['a', 'b'] }, { tags: 'a' })).toBe(false); + expect(match({ tags: ['a', 'b'] }, { tags: 'a,b' })).toBe(true); // ⚠️ the coercion, still here + expect(match({ tags: ['a'] }, { tags: 'a' })).toBe(true); // ⚠️ and its single-element form + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-matcher.ts b/packages/drivers/driver-memory/src/memory-matcher.ts index 4abcecf2d4..364870cae7 100644 --- a/packages/drivers/driver-memory/src/memory-matcher.ts +++ b/packages/drivers/driver-memory/src/memory-matcher.ts @@ -244,21 +244,83 @@ function valueWithinRange(value: any, min: any, max: any): boolean { return value >= min && value <= max; } +/** + * [#16810] Equality for a comparand this face compares by VALUE. + * + * `==` is kept for everything it was chosen for — the reasons the arm below + * records, "undefined/null mismatch" and "string/number coercion", both of + * which are about PRIMITIVES and both of which still hold. What `==` cannot do + * is the one type the contract's accepted comparand set names that is not a + * primitive: between two objects `==` compares REFERENCES, so a `Date` + * comparand matched only the very object it was handed and never an equal + * instant. + * + * The rule is `@objectstack/formula`'s `looseEq`, arm for arm — the sibling + * record-at-a-time matcher this face's conformance suites are held against, so + * the two agree by construction rather than by coincidence: + * + * - Date vs Date — equal when their time values are equal; + * - Date vs string / number — the counterpart is read as an instant first, + * which is the case that actually reaches a stored row here: this driver + * canonicalises a declared `datetime` to ISO TEXT on write + * (`memory-temporal.ts`, ADR-0053 D-B1), so a `Date` comparand meets a + * STRING in storage and `==` stringified the Date to `"Wed Jan 01 2026 …"`, + * which no ISO value equals; + * - everything else — the unchanged `==`. + * + * An Invalid Date has no time value (`NaN`), so it equals nothing, itself + * included — JS `Date` convention, `formula`'s answer, and ADR-0053 D-F1's + * reading that an Invalid Date has no canonical text. + */ +function comparandEquals(value: any, condition: any): boolean { + if (value instanceof Date && condition instanceof Date) { + return value.getTime() === condition.getTime(); + } + if (value instanceof Date && (typeof condition === 'string' || typeof condition === 'number')) { + return value.getTime() === new Date(condition).getTime(); + } + if (condition instanceof Date && (typeof value === 'string' || typeof value === 'number')) { + return new Date(value).getTime() === condition.getTime(); + } + // Loose equality to handle undefined/null mismatch or string/number coercion if desired. + // But stick to == for JS loose equality which is often convenient in weakly typed queries. + return value == condition; +} + /** * Evaluate a specific condition against a value */ function checkCondition(value: any, condition: any): boolean { + // [#16810] An ARRAY comparand is REFUSED, by `assertFilterConditionShape` in + // `filter-refusal.ts` — the one gate every face of this package runs before + // it evaluates anything, so the live query path and this matcher cannot + // answer it differently. It used to fall into the equality arm below and be + // compared by REFERENCE, which no deep-equal array satisfies; mingo, one + // file away, deep-equalled the same filter and returned the row. + // + // This arm is the totality floor for a DIRECT call that skips the gate, the + // same role `$between`'s surviving `Array.isArray` guard plays below and the + // same answer `@objectstack/formula`'s matcher gives ("a bare array value is + // not a valid field spec"). ⛔ It must not be deleted in favour of falling + // through: the operator-object arm further down would then read `['a','b']` + // as a key bag and JSON-compare it, inventing the array-equality semantics + // this cell was ruled AGAINST. + if (Array.isArray(condition)) return false; + // Case A: Implicit Equality (e.g. status: 'active') - // If condition is a primitive or Date/Array (exact match), treat as equality. + // If condition is a primitive or Date (exact match), treat as equality. + // + // [#16810] `Date` stays in this arm — the contract's accepted comparand set + // (`ACCEPTED_FILTER_COMPARAND_TYPES`) names it, and the conformance table + // requires that "a Date comparand must pass the door and execute + // everywhere" — but the comparison is {@link comparandEquals}, which + // compares an instant rather than an object identity. if ( typeof condition !== 'object' || condition === null || - condition instanceof Date || - Array.isArray(condition) + condition instanceof Date ) { - // Loose equality to handle undefined/null mismatch or string/number coercion if desired. - // But stick to == for JS loose equality which is often convenient in weakly typed queries. - return value == condition; + return comparandEquals(value, condition); } // Case B: Operator Object (e.g. { $gt: 10, $lt: 20 }) @@ -379,11 +441,17 @@ function checkCondition(value: any, condition: any): boolean { } switch (op) { + // [#16810] `$eq` / `$ne` are the operator spelling of the implicit + // comparand above, so they take the same equality — otherwise one + // predicate would answer two ways depending on which spelling the + // author used, on the very type (`Date`) the accepted comparand set + // names. An ARRAY target never reaches either arm: the shape gate + // refuses it for both spellings. case '$eq': - if (value != target) return false; + if (!comparandEquals(value, target)) return false; break; case '$ne': - if (value == target) return false; + if (comparandEquals(value, target)) return false; break; // Numeric / Date diff --git a/packages/spec/src/data/filter-comparand-shape.test.ts b/packages/spec/src/data/filter-comparand-shape.test.ts index 42db6129ca..426ca0ec89 100644 --- a/packages/spec/src/data/filter-comparand-shape.test.ts +++ b/packages/spec/src/data/filter-comparand-shape.test.ts @@ -362,7 +362,9 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922 expect(parseFilterAST({ author: { name: 'x' } })).toEqual({ author: { name: 'x' } }); // A scalar operator carrying an array is answered per driver, not here. expect(parseFilterAST({ tags: { $eq: ['a', 'b'] } })).toEqual({ tags: { $eq: ['a', 'b'] } }); - // An implicit-equality array comparand keeps its array-equality semantics. + // An implicit-equality array comparand is passed through for the driver to + // answer; what it MEANS is not this door's ruling (see the comparand door's + // own list of the cases it deliberately does not rule). expect(parseFilterAST({ tags: ['a', 'b'] })).toEqual({ tags: ['a', 'b'] }); // An unknown `$` key at node level belongs to the by-name refusals // downstream, which carry the specific prescription. diff --git a/packages/spec/src/data/filter-comparand-type.ts b/packages/spec/src/data/filter-comparand-type.ts index f449a25074..fdb633a2a4 100644 --- a/packages/spec/src/data/filter-comparand-type.ts +++ b/packages/spec/src/data/filter-comparand-type.ts @@ -84,10 +84,11 @@ * #7597 pins the hand-authored implicit form's fate). The door steps around * every one of those decisions. * - **Arrays outside `$in`/`$nin`/`$between`.** An array in an implicit or - * scalar-operator position is answered per driver today (`driver-sql` refuses - * it with its own message; the document stores give it array-equality - * semantics); the matrix did not measure it and the ruling does not name it, - * so the door leaves it to the layers that already answer it. + * scalar-operator position is answered per driver today (`driver-sql` and + * `driver-memory` refuse it, each with its own message; `driver-mongodb` + * hands it to MongoDB and inherits that engine's array semantics); the matrix + * did not measure it and the ruling does not name it, so the door leaves it + * to the layers that already answer it. * - **An operator outside the declared vocabulary** (`$wat`, retired `$regex`): * the unknown-/retired-operator refusals downstream carry the specific * prescriptions (`RETIRED_FILTER_OPERATORS`), which a generic type refusal