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/driver-sql-or-branch-and-semantics.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/driver-sql": patch
---

fix(driver-sql): `$or` branches AND their own contents again — every `$or` filter was widened

`applyFilterCondition` passed `logicalOp='or'` *into* each `$or` branch's
recursive call. That flag is meant to decide only how a branch attaches to its
parent builder, but inside the branch it also selected `orWhere` for the
branch's own contents. So a branch's field keys — and the operators of a single
field — OR-ed each other instead of AND-ing:

| Filter | Compiled to | Should be |
|---|---|---|
| `{$or:[{a:'x', b:'y'}]}` | `a = 'x' OR b = 'y'` | `a = 'x' AND b = 'y'` |
| `{$or:[{d:{$gte:X, $lt:Y}}]}` | `d >= X OR d < Y` | `d >= X AND d < Y` |
| `{$or:[{$and:[A,B]}, {c,d}]}` | `(A AND B) OR c OR d` | `(A AND B) OR (c AND d)` |

The Filter Protocol rule this breaks is Mongo's: **everything inside one filter
object is AND-ed, at every depth.** A `$or` array OR-s its *branches*; it does
not change how the contents *within* a branch combine.

Every miscompile widens the result set, never narrows it, so affected queries
returned **more** rows than the filter allowed. Two shapes to re-check in your
own metadata after upgrading:

- **Scoping filters** that pair a discriminator with an id list per branch —
`{$or:[{parent_object, parent_id:{$in:[…]}}, …]}` and similar — were not
holding the pairing. Where such a filter decides visibility, it was returning
rows outside the intended scope.
- **Sharing-rule `criteria_json`** containing a `$or` whose branches carry more
than one key (what a "match ANY of these groups" criteria builder emits). That
path *writes* `sys_record_share` grants, so any over-match materialized
durable grants that outlive this fix — **re-reconcile those rules after
upgrading**; the driver fix alone does not retract grants already written.

Also affected: the abutting `$gte`/`$lt` window pattern the automation docs and
CLI flow linter recommend for scheduled flows. Each tier degenerated to
`d >= lo OR d < hi`, which matches every row, so multi-tier reminder flows fired
on the whole table instead of one window.

`driver-sql` was the sole divergent backend — `driver-memory`,
`driver-mongodb`, the analytics `read-scope-sql` compiler and the write-side
`matchesFilterCondition` evaluator all already AND-ed per node. Conformance
tests now pin the same shapes across the three in-repo evaluators so they cannot
drift apart again. `driver-sqlite-wasm` inherits the fix (it extends
`SqlDriver`); Postgres, MySQL, SQLite and sqlite-wasm were all affected.

The `$and` arm also now honors `logicalOp`, as `$or`/`$not` already did. Nothing
reaches it with `'or'` once the propagation above is fixed, but the two changes
are only correct together — leaving one combinator deaf to the flag is how the
rules drifted apart in the first place.
78 changes: 78 additions & 0 deletions packages/formula/src/matches-filter-or-semantics.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `$or` semantics conformance for the record-at-a-time filter 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.
*
* 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).
*/

import { describe, expect, it } from 'vitest';

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']);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `$or` semantics 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.
*/

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']);
});

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) => match(d, scope)).map((d) => d.id)).toEqual(['own-active', 'shared']);
});
});
Loading
Loading