Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/filter-logic-conformance-single-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/spec": minor
---

feat(spec): one canonical conformance table for the filter logical combinators

`FilterCondition` is evaluated by four independent implementations, and nothing
held them to a shared standard:

| Backend | Where |
|---|---|
| SQL compiler | `driver-sql` `applyFilterCondition` |
| In-memory matcher | `driver-memory` `memory-matcher` |
| Record-at-a-time evaluator | `formula` `matchesFilterCondition` (RLS write-side `check`) |
| Read-scope SQL lowering | `service-analytics` `read-scope-sql` |

In #3774 the SQL compiler OR-ed the contents *within* a `$or` branch instead of
AND-ing them, so every `$or` filter matched more rows than it should. The other
three were correct — but that was luck, not enforcement, and the divergence was
invisible until someone ran a real query. The fix for #3774 left three
near-identical shape tables copied across packages and the fourth backend
unlocked entirely, which is the same drift setup one step later.

`@objectstack/spec/data` now exports the table itself:

- `FILTER_LOGIC_ROWS` — a 2x2 truth table over two columns (so a wrongly-OR-ed
pair always shows up as extra ids rather than by luck of the data), plus the
record-scope columns real read scopes are written against.
- `FILTER_LOGIC_CASES` — 17 cases, each a `FilterCondition` and the ids it must
match: keys within a branch, multiple operators on one field, `$and`/`$or`/
`$not` nesting in both key orders, and the scope shapes that occur in shipped
metadata.

Each backend now has a thin test that feeds the rows through its own evaluator
and asserts the shared expectations. **Adding a case to the table adds it to all
four at once** — that is the point.

Two things this bought immediately:

- `read-scope-sql` — the compiler that lowers RLS read scopes for the analytics
path — is now verified by **executing** its SQL against a real engine and
comparing rows. It was previously only checked by asserting the emitted SQL
string, whose ceiling is the author's own reading of SQL. It passes unchanged.
- The table is a public export, so a third-party driver author can check a new
backend against the same standard.

**Deliberate scope:** logical combinators only. The predicates are boring on
purpose — string equality, `$in`, `$ne`, `$gte`/`$lt`. Nothing here exercises
null handling, dates, numeric coercion, `LIKE` escaping or case sensitivity,
because those legitimately differ between a SQL engine and a JS matcher; folding
them in would make the table unpassable rather than more useful. A case belongs
in it only if **every** backend must agree.
85 changes: 19 additions & 66 deletions packages/formula/src/matches-filter-or-semantics.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,31 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `$or` semantics conformance for the record-at-a-time filter evaluator.
* Filter logical-combinator conformance for the record-at-a-time evaluator.
*
* Companion to `driver-sql`'s `sql-driver-or-filter.test.ts` and
* `driver-memory`'s `memory-matcher-or-semantics.test.ts`: same shapes, same
* 2x2 fixture, same expected ids. driver-sql used to OR the field keys within a
* `$or` branch, widening the match set; this evaluator was already correct.
* The cases come from `@objectstack/spec/data` so this backend, `driver-sql`,
* `driver-memory` and `read-scope-sql` are all held to one standard — see
* `filter-logic-conformance.ts` for why that standard exists (#3774). Adding a
* case there adds it to all four at once; that is the point.
*
* Agreement here is load-bearing rather than cosmetic: this evaluator decides
* the RLS `check` clause on writes while the SQL compiler decides the `where`
* on reads. If they disagree on `$or`, a record can be writable but unreadable
* (or worse, readable when the scope says otherwise).
* Agreement matters here beyond tidiness: this evaluator decides the RLS
* `check` clause on writes while the SQL compilers decide the `where` on reads.
* If they disagree, a record can be writable but unreadable — or readable when
* the scope says otherwise.
*/

import { describe, expect, it } from 'vitest';
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data';

import { matchesFilterCondition as m } from './matches-filter';

const ROWS = [
{ id: '1', a: 'x', b: 'y', c: 'z' },
{ id: '2', a: 'x', b: 'zz', c: 'z' },
{ id: '3', a: 'qq', b: 'y', c: 'z' },
{ id: '4', a: 'qq', b: 'zz', c: 'z' },
];

const ids = (filter: any): string[] => ROWS.filter((r) => m(r, filter)).map((r) => r.id);

describe('matchesFilterCondition — $or semantics', () => {
it('ANDs the keys of a single multi-key branch', () => {
expect(ids({ $or: [{ a: 'x', b: 'y' }] })).toEqual(['1']);
});

it('ANDs the keys of each branch independently', () => {
expect(ids({ $or: [{ a: 'x', b: 'y' }, { a: 'qq', b: 'zz' }] })).toEqual(['1', '4']);
});

it('ANDs operator-object keys within a branch', () => {
expect(ids({ $or: [{ a: { $eq: 'x' }, b: { $ne: 'zz' } }] })).toEqual(['1']);
});

it('ANDs keys inside a $or nested in a $or branch', () => {
expect(ids({ $or: [{ $or: [{ a: 'x', b: 'zz' }] }] })).toEqual(['2']);
});

it('ANDs multiple operators on ONE field within a branch', () => {
expect(ids({ $or: [{ a: { $ne: 'qq', $eq: 'x' }, b: 'y' }] })).toEqual(['1']);
});

it('OR-s a $and branch against a sibling multi-key branch', () => {
expect(ids({ $or: [{ $and: [{ a: 'x' }, { b: 'y' }] }, { a: 'qq', b: 'zz' }] })).toEqual(['1', '4']);
});

it('ANDs a $and with a sibling key in the same branch, either order', () => {
expect(ids({ $or: [{ c: 'nope' }, { $and: [{ a: 'qq' }], b: 'y' }] })).toEqual(['3']);
expect(ids({ $or: [{ c: 'nope' }, { b: 'y', $and: [{ a: 'qq' }] }] })).toEqual(['3']);
});

it('keeps single-key $or branches as a plain OR', () => {
expect(ids({ $or: [{ a: 'x' }, { b: 'y' }] })).toEqual(['1', '2', '3']);
});

it('ANDs a $or with a sibling top-level field key', () => {
expect(ids({ $or: [{ a: 'x' }, { b: 'y' }], b: 'zz' })).toEqual(['2']);
});

it('does not widen an "own AND active, OR shared" read scope', () => {
const docs = [
{ id: 'own-active', owner: 'u1', status: 'active', shared_with: null },
{ id: 'own-archived', owner: 'u1', status: 'archived', shared_with: null },
{ id: 'other-active', owner: 'u2', status: 'active', shared_with: null },
{ id: 'shared', owner: 'u2', status: 'active', shared_with: 'u1' },
];
const scope = { $or: [{ owner: 'u1', status: 'active' }, { shared_with: 'u1' }] };
expect(docs.filter((d) => m(d, scope)).map((d) => d.id)).toEqual(['own-active', 'shared']);
});
describe('matchesFilterCondition — filter logic conformance', () => {
for (const c of FILTER_LOGIC_CASES) {
it(c.name, () => {
const got = FILTER_LOGIC_ROWS.filter((r) => m(r as unknown as Record<string, unknown>, c.filter)).map(
(r) => r.id,
);
expect(got, c.note).toEqual(c.expected);
});
}
});
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,24 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `$or` semantics conformance for the in-memory matcher.
* Filter logical-combinator conformance for the in-memory matcher.
*
* Companion to `driver-sql`'s `sql-driver-or-filter.test.ts`: the same filter
* shapes, the same 2x2 fixture, the same expected ids. driver-sql used to
* compile a `$or` branch's own field keys with OR instead of AND, so
* `{$or:[{a,b}]}` matched strictly more rows than the Filter Protocol allows.
* This matcher was already correct — these cases exist so the two backends
* cannot silently drift apart again, since a read scope evaluated by one and
* pushed down by the other must agree.
* The cases come from `@objectstack/spec/data` so this backend, `driver-sql`,
* `formula`'s `matchesFilterCondition` and `read-scope-sql` are all held to one
* standard — see `filter-logic-conformance.ts` for why that standard exists
* (#3774). Adding a case there adds it to all four at once; that is the point.
*/

import { describe, it, expect } from 'vitest';
import { match } from './memory-matcher.js';

const ROWS = [
{ id: '1', a: 'x', b: 'y', c: 'z' },
{ id: '2', a: 'x', b: 'zz', c: 'z' },
{ id: '3', a: 'qq', b: 'y', c: 'z' },
{ id: '4', a: 'qq', b: 'zz', c: 'z' },
];

const ids = (filter: any): string[] => ROWS.filter((r) => match(r, filter)).map((r) => r.id);

describe('memory-matcher $or semantics', () => {
it('ANDs the keys of a single multi-key branch', () => {
expect(ids({ $or: [{ a: 'x', b: 'y' }] })).toEqual(['1']);
});

it('ANDs the keys of each branch independently', () => {
expect(ids({ $or: [{ a: 'x', b: 'y' }, { a: 'qq', b: 'zz' }] })).toEqual(['1', '4']);
});

it('ANDs operator-object keys within a branch', () => {
expect(ids({ $or: [{ a: { $eq: 'x' }, b: { $ne: 'zz' } }] })).toEqual(['1']);
});
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data';

it('ANDs keys inside a $or nested in a $or branch', () => {
expect(ids({ $or: [{ $or: [{ a: 'x', b: 'zz' }] }] })).toEqual(['2']);
});

it('ANDs multiple operators on ONE field within a branch', () => {
expect(ids({ $or: [{ a: { $ne: 'qq', $eq: 'x' }, b: 'y' }] })).toEqual(['1']);
});

it('OR-s a $and branch against a sibling multi-key branch', () => {
expect(ids({ $or: [{ $and: [{ a: 'x' }, { b: 'y' }] }, { a: 'qq', b: 'zz' }] })).toEqual(['1', '4']);
});

it('ANDs a $and with a sibling key in the same branch, either order', () => {
expect(ids({ $or: [{ c: 'nope' }, { $and: [{ a: 'qq' }], b: 'y' }] })).toEqual(['3']);
expect(ids({ $or: [{ c: 'nope' }, { b: 'y', $and: [{ a: 'qq' }] }] })).toEqual(['3']);
});

it('keeps single-key $or branches as a plain OR', () => {
expect(ids({ $or: [{ a: 'x' }, { b: 'y' }] })).toEqual(['1', '2', '3']);
});

it('ANDs a $or with a sibling top-level field key', () => {
expect(ids({ $or: [{ a: 'x' }, { b: 'y' }], b: 'zz' })).toEqual(['2']);
});
import { match } from './memory-matcher.js';

it('does not widen an "own AND active, OR shared" read scope', () => {
const docs = [
{ id: 'own-active', owner: 'u1', status: 'active', shared_with: null },
{ id: 'own-archived', owner: 'u1', status: 'archived', shared_with: null },
{ id: 'other-active', owner: 'u2', status: 'active', shared_with: null },
{ id: 'shared', owner: 'u2', status: 'active', shared_with: 'u1' },
];
const scope = { $or: [{ owner: 'u1', status: 'active' }, { shared_with: 'u1' }] };
expect(docs.filter((d) => match(d, scope)).map((d) => d.id)).toEqual(['own-active', 'shared']);
});
describe('memory-matcher — filter logic conformance', () => {
for (const c of FILTER_LOGIC_CASES) {
it(c.name, () => {
const got = FILTER_LOGIC_ROWS.filter((r) => match(r, c.filter)).map((r) => r.id);
expect(got, c.note).toEqual(c.expected);
});
}
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -323,7 +323,14 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
},
});

expect(results.length).toBeGreaterThan(0);
// Was `toBeGreaterThan(0)`, which any non-empty result satisfies. This
// particular shape was never miscompiled by #3774 — each `$or` branch
// holds a single `$and` key, so there were no sibling keys to wrongly OR
// — but the assertion was too weak to have noticed either way, which is
// the only reason it is worth tightening. Branch one is completed AND
// over 100 (Laptop 1200, Monitor 350); branch two is Alice AND pending
// (Keyboard).
expect(results.map((r: any) => r.id).sort()).toEqual(['1', '3', '4']);
});

it('should handle contains filter', async () => {
Expand Down
Loading
Loading