Skip to content

Commit 798c877

Browse files
committed
fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face
The comparand-shape face gains its equality-slot arm: an array as an implicit-equality comparand ({ field: [...] }, what the equality spellings lower an array to) or under $eq is refused with the face's INVALID_FILTER / 400 envelope, naming $in and $contains as the remedies. $ne and the other scalar operators are not judged. Adds the conformance rows, re-judges the two fixtures that pinned the old accept set, and registers the ADR-0087 semantic entry filter-equality-array-comparand-refused. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2548ba5 commit 798c877

7 files changed

Lines changed: 521 additions & 14 deletions

‎packages/spec/src/data/filter-comparand-shape.test.ts‎

Lines changed: 182 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121

2222
import { describe, it, expect } from 'vitest';
2323
import { StandardErrorCode } from '../api/errors.zod';
24-
import { parseFilterAST, RangeOperatorSchema, VALID_AST_OPERATORS } from './filter.zod';
24+
import {
25+
FieldOperatorsSchema,
26+
parseFilterAST,
27+
RangeOperatorSchema,
28+
VALID_AST_OPERATORS,
29+
} from './filter.zod';
2530
import { assertListComparandShapes } from './filter-comparand-shape';
2631

2732
type Refusal = Error & { code?: string; status?: number };
@@ -39,10 +44,17 @@ const refusalOf = (run: () => unknown): Refusal => {
3944
* Which `$` operator an authoring spelling lowers to, derived by LOWERING one
4045
* rather than by reading a table this file would then be a second copy of.
4146
* A two-element array is legal for all three list operators, so the probe never
42-
* trips the door it is used to find.
47+
* trips the door it is used to find. The EQUALITY spellings do refuse it, since
48+
* the 2026-09-23 arm (#19757) — and they answer `undefined` either way: before
49+
* that arm they lowered it to the implicit form, which carries no `$` key.
4350
*/
4451
const loweredOperatorOf = (op: string): string | undefined => {
45-
const lowered = parseFilterAST([['probe', op, ['a', 'b']]]) as Record<string, unknown> | undefined;
52+
let lowered: Record<string, unknown> | undefined;
53+
try {
54+
lowered = parseFilterAST([['probe', op, ['a', 'b']]]) as Record<string, unknown> | undefined;
55+
} catch {
56+
return undefined;
57+
}
4658
const spec = lowered?.probe;
4759
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return undefined;
4860
return Object.keys(spec).find((key) => key.startsWith('$'));
@@ -462,6 +474,159 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
462474
.toMatch(/^Filter comparand at where\.n\.\$gt is undefined/);
463475
});
464476

477+
// ── the EQUALITY-slot arm, ruled 2026-09-23 (#19757) ───────────────────
478+
//
479+
// Ruling 5793368540, letter 乙: an array in the implicit-equality slot is
480+
// refused at this face, for every driver at once; ⛔ no alias, ⛔ no grace
481+
// window. Before it, `{ tags: ['a'] }` passed this door and was refused by
482+
// the SQL family and `driver-memory`, excluded every row on
483+
// `@objectstack/formula`, and was ANSWERED by `driver-mongodb` as MongoDB's
484+
// array equality — the pins below were each measured RED on the face as it
485+
// stood (see the PR's firing control).
486+
487+
it.each([
488+
['the lowered array form, "equals"', [['tags', 'equals', ['a']]], 'where.tags'],
489+
['the lowered array form, "="', [['tags', '=', ['a', 'b']]], 'where.tags'],
490+
['the object passthrough — implicit', { tags: ['a'] }, 'where.tags'],
491+
['an EMPTY array — still an array in this slot', { tags: [] }, 'where.tags'],
492+
['the object passthrough — explicit $eq', { tags: { $eq: ['a'] } }, 'where.tags.$eq'],
493+
['explicit $eq with an EMPTY array', { tags: { $eq: [] } }, 'where.tags.$eq'],
494+
['nested under $and (lowered)', ['and', ['amount', '>', 5], ['tags', 'eq', ['a']]], 'where.$and[1].tags'],
495+
['nested under $or', { $or: [{ stage: 'won' }, { tags: ['a'] }] }, 'where.$or[1].tags'],
496+
['nested under $not', { $not: { tags: { $eq: ['a'] } } }, 'where.$not.tags.$eq'],
497+
])('refuses an ARRAY in the equality slot — %s', (_label, where, path) => {
498+
const err = refusalOf(() => parseFilterAST(where));
499+
// ADR-0112 class 1, both halves: on the face as it stood, every one of
500+
// these RETURNED — there was no error at all to carry either field.
501+
expect(err.code).toBe(StandardErrorCode.enum.INVALID_FILTER);
502+
expect(err.status).toBe(400);
503+
expect(err.message).toContain(`at ${path}.`);
504+
});
505+
506+
it('the implicit-equality refusal names the slot and prescribes $in and $contains by their spec spellings', () => {
507+
const err = refusalOf(() => parseFilterAST([['tags', 'equals', ['a', 'b']]]));
508+
// The leading sentence is driver-memory's own for this condition, kept
509+
// verbatim (one condition, one wording across packages).
510+
expect(err.message).toMatch(
511+
/^The implicit-equality comparand on field "tags" requires a single comparable value, but received an array \(\["a","b"\]\) at where\.tags\./,
512+
);
513+
// The ruling's prescription: the declared list operator and the
514+
// array-containment operator, spec spelling AND authoring spelling.
515+
expect(err.message).toContain('"one of these values" use {"$in": […]} (authoring: in)');
516+
expect(err.message).toContain(
517+
'"the stored list holds a value" on a multi-value field, {"$contains": "…"} (authoring: contains)',
518+
);
519+
expect(err.message).toContain('an $or of those for any-of');
520+
expect(err.message).toMatch(/The filter was NOT applied, .*UNFILTERED result set\.$/);
521+
});
522+
523+
it('the $eq refusal names the operator it was written with, and the same two remedies', () => {
524+
const err = refusalOf(() => parseFilterAST({ tags: { $eq: ['a'] } }));
525+
expect(err.message).toMatch(
526+
/^Operator "\$eq" on field "tags" requires a single comparable value, but received an array \(\["a"\]\) at where\.tags\.\$eq\./,
527+
);
528+
expect(err.message).toContain('{"$in": […]} (authoring: in)');
529+
expect(err.message).toContain('{"$contains": "…"} (authoring: contains)');
530+
// A caller-supplied context keeps its prefix, as on every sibling arm.
531+
expect(refusalOf(() => assertListComparandShapes({ tags: ['a'] }, "find('deal')")).message)
532+
.toMatch(/^find\('deal'\): The implicit-equality comparand on field "tags"/);
533+
});
534+
535+
it('every AST spelling that lowers to EQUALITY refuses an array — the vocabulary, read at source', () => {
536+
// Derived by LOWERING a scalar rather than from a hand list: a spelling is
537+
// an equality spelling when `[f, op, 'x']` lowers to the implicit form.
538+
// (`in` / `between` refuse a scalar probe outright — they are list
539+
// operators, so a throw here is a "no", not a failure.)
540+
const equality = [...VALID_AST_OPERATORS].filter((op) => {
541+
try {
542+
const lowered = parseFilterAST([['probe', op, 'x']]) as Record<string, unknown> | undefined;
543+
return lowered?.probe === 'x';
544+
} catch {
545+
return false;
546+
}
547+
});
548+
// Guards the loop from passing vacuously.
549+
expect(equality.sort()).toEqual(['=', '==', 'eq', 'equals']);
550+
for (const op of equality) {
551+
const err = refusalOf(() => parseFilterAST([['tags', op, ['a']]]));
552+
expect(err.code, op).toBe(StandardErrorCode.enum.INVALID_FILTER);
553+
expect(err.status, op).toBe(400);
554+
expect(err.message, op).toMatch(/^The implicit-equality comparand on field "tags"/);
555+
}
556+
});
557+
558+
it('the two prescribed operators are DECLARED — the refusal invents no spelling', () => {
559+
// The message spells `$in` / `in` and `$contains` / `contains` by hand
560+
// (`filter.zod.ts` imports the door, so deriving them would be a cycle).
561+
// This is what keeps them the vocabulary's own: both are keys of the
562+
// enforced operator schema, and each authoring spelling lowers to its `$`
563+
// spelling through the one AST table.
564+
const declared = Object.keys(FieldOperatorsSchema.shape);
565+
expect(declared).toContain('$in');
566+
expect(declared).toContain('$contains');
567+
expect(loweredOperatorOf('in')).toBe('$in');
568+
expect(loweredOperatorOf('contains')).toBe('$contains');
569+
// …and the prescribed spellings actually lower and pass this door.
570+
expect(parseFilterAST([['tags', 'in', ['a', 'b']]])).toEqual({ tags: { $in: ['a', 'b'] } });
571+
expect(parseFilterAST([['tags', 'contains', 'a']])).toEqual({ tags: { $contains: 'a' } });
572+
expect(parseFilterAST({ $or: [{ tags: { $contains: 'a' } }, { tags: { $contains: 'b' } }] }))
573+
.toEqual({ $or: [{ tags: { $contains: 'a' } }, { tags: { $contains: 'b' } }] });
574+
});
575+
576+
it('LIT CONTROL — every scalar equality comparand keeps passing, null predicate first', () => {
577+
// `{ f: null }` and `$eq: null` ARE the has-no-value predicate (#5332);
578+
// the arm is array-shaped and nothing wider.
579+
expect(parseFilterAST({ tags: null })).toEqual({ tags: null });
580+
expect(parseFilterAST({ tags: { $eq: null } })).toEqual({ tags: { $eq: null } });
581+
expect(parseFilterAST([['tags', 'equals', null]])).toEqual({ tags: null });
582+
expect(parseFilterAST({ tags: 'a' })).toEqual({ tags: 'a' });
583+
expect(parseFilterAST({ n: 0 })).toEqual({ n: 0 });
584+
expect(parseFilterAST({ on: false })).toEqual({ on: false });
585+
expect(parseFilterAST({ tags: { $eq: '' } })).toEqual({ tags: { $eq: '' } });
586+
const day = new Date('2026-07-01T00:00:00.000Z');
587+
expect(parseFilterAST({ at: day })).toEqual({ at: day });
588+
expect(parseFilterAST({ at: { $eq: day } })).toEqual({ at: { $eq: day } });
589+
// #7597's promotion: a `{ $field }` comparand on an equality spelling is
590+
// lowered to `$eq` and is not an array — untouched.
591+
expect(parseFilterAST([['amount', '=', { $field: 'budget' }]]))
592+
.toEqual({ amount: { $eq: { $field: 'budget' } } });
593+
});
594+
595+
it('LIT CONTROL — every array-valued operator the vocabulary declares keeps its array', () => {
596+
// Read off the enforced schema rather than listed: the operators whose
597+
// declared comparand ACCEPTS an array. `$eq` / `$ne` are in that set only
598+
// because both are `z.any()` there — `$eq` is this arm's subject and
599+
// `$ne` is not judged at this door (see the todo below) — so the loop
600+
// covers exactly the list operators, and a fourth array-valued operator
601+
// added to the schema lands in it without an edit here.
602+
const arrayValued = Object.keys(FieldOperatorsSchema.shape).filter((op) =>
603+
FieldOperatorsSchema.safeParse({ [op]: ['a', 'b'] }).success);
604+
expect(arrayValued.sort()).toEqual(['$between', '$eq', '$in', '$ne', '$nin']);
605+
for (const op of arrayValued.filter((o) => o !== '$eq' && o !== '$ne')) {
606+
expect(parseFilterAST({ tags: { [op]: ['a', 'b'] } }), op).toEqual({ tags: { [op]: ['a', 'b'] } });
607+
}
608+
// The empty lists stay the declared predicates they are.
609+
expect(parseFilterAST({ tags: { $in: [] } })).toEqual({ tags: { $in: [] } });
610+
expect(parseFilterAST({ tags: { $nin: [] } })).toEqual({ tags: { $nin: [] } });
611+
// `$contains` is declared with a STRING comparand — the array-containment
612+
// operator takes ONE member, which is why the refusal says `"…"`.
613+
expect(FieldOperatorsSchema.safeParse({ $contains: ['a'] }).success).toBe(false);
614+
});
615+
616+
it('an array INSIDE a no-$-key field spec is still not descended into', () => {
617+
// The nested-relation / deep-equality boundary this door has always kept:
618+
// the arm judges the field's own slot, never the inside of a nested
619+
// condition it does not walk.
620+
expect(parseFilterAST({ author: { tags: ['a'] } })).toEqual({ author: { tags: ['a'] } });
621+
});
622+
623+
it.todo(
624+
'$ne carrying an ARRAY — equality\'s negation measured the same split (refused by the SQL family '
625+
+ 'and driver-memory, answered by driver-mongodb) but the 2026-09-23 ruling names implicit and '
626+
+ 'explicit equality only. Reported for its own ruling; ⛔ not pinned green here, because a green '
627+
+ 'pin would read as a ruling nobody made',
628+
);
629+
465630
// ── the wording contract (#5346 / #5348), unchanged by the move ────────
466631

467632
it('names the operator, the field, what arrived, where, and the fix', () => {
@@ -520,6 +685,14 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
520685
{ close_date: { $lte: null } },
521686
{ close_date: { $gt: null } },
522687
{ close_date: { $lt: null } },
688+
// The 2026-09-23 equality-slot arm (#19757): two prescriptions plus the
689+
// received list. The long list is the tallest this arm assembles — its
690+
// preview is cut at the shared 60-char bound — so it is the row that
691+
// proves the "NOT applied" tail survives the wire.
692+
{ close_date: ['a'] },
693+
{ close_date: { $eq: ['a'] } },
694+
{ close_date: ['aaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccc'] },
695+
{ $or: [{ close_date: [] }] },
523696
]) {
524697
const err = refusalOf(() => parseFilterAST(where, "find('deal')"));
525698
expect(err.message.length, JSON.stringify(where)).toBeLessThan(500);
@@ -608,12 +781,12 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
608781
// comparand, not an operator bag — descending into one would invent a
609782
// contract no backend agrees with.
610783
expect(parseFilterAST({ author: { name: 'x' } })).toEqual({ author: { name: 'x' } });
611-
// A scalar operator carrying an array is answered per driver, not here.
612-
expect(parseFilterAST({ tags: { $eq: ['a', 'b'] } })).toEqual({ tags: { $eq: ['a', 'b'] } });
613-
// An implicit-equality array comparand is passed through for the driver to
614-
// answer; what it MEANS is not this door's ruling (see the comparand door's
615-
// own list of the cases it deliberately does not rule).
616-
expect(parseFilterAST({ tags: ['a', 'b'] })).toEqual({ tags: ['a', 'b'] });
784+
// ⚠️ Two rows used to sit HERE, pinning `{ tags: { $eq: ['a', 'b'] } }` and
785+
// `{ tags: ['a', 'b'] }` as shapes this door passes through for the driver
786+
// to answer. Both are INVERTED by the 2026-09-23 ruling (#19757) — they
787+
// pinned exactly the open slot that ruling closes — and now live, refused,
788+
// in the equality-slot section below. ⛔ Not re-spelled into some other
789+
// passing shape: what they asserted is no longer true of this door.
617790
// An unknown `$` key at node level belongs to the by-name refusals
618791
// downstream, which carry the specific prescription.
619792
expect(parseFilterAST({ $wat: [{ stage: { $in: ['won'] } }] }))

0 commit comments

Comments
 (0)