From 75fe26c5f39cc717e342901e63c22536355ec5ea Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:03:10 +0800 Subject: [PATCH] fix(driver-sql): AND the contents of each `$or` branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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; 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. `{$or:[{a,b}]}` compiled to `a = ? OR b = ?`, and `{$or:[{d:{$gte,$lt}}]}` to `d >= ? OR d < ?` (which matches every row). Every such miscompile widens the result set, so scoping filters written in that shape returned rows outside the intended scope, and the abutting-window pattern recommended for scheduled flows matched the whole table instead of one tier. The `$and` arm now honors `logicalOp` too, as `$or`/`$not` already did. Nothing reaches it with 'or' once the propagation is fixed, but the two changes are only correct together. driver-memory, driver-mongodb, the analytics read-scope-sql compiler and the write-side matchesFilterCondition evaluator were all already correct; conformance tests now pin the same shapes across the three in-repo evaluators so they cannot drift apart again. Co-Authored-By: Claude --- .../driver-sql-or-branch-and-semantics.md | 52 ++++ .../src/matches-filter-or-semantics.test.ts | 78 ++++++ .../src/memory-matcher-or-semantics.test.ts | 75 ++++++ .../src/sql-driver-or-filter.test.ts | 244 ++++++++++++++++++ packages/plugins/driver-sql/src/sql-driver.ts | 19 +- 5 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 .changeset/driver-sql-or-branch-and-semantics.md create mode 100644 packages/formula/src/matches-filter-or-semantics.test.ts create mode 100644 packages/plugins/driver-memory/src/memory-matcher-or-semantics.test.ts create mode 100644 packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts diff --git a/.changeset/driver-sql-or-branch-and-semantics.md b/.changeset/driver-sql-or-branch-and-semantics.md new file mode 100644 index 0000000000..02c1561052 --- /dev/null +++ b/.changeset/driver-sql-or-branch-and-semantics.md @@ -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. diff --git a/packages/formula/src/matches-filter-or-semantics.test.ts b/packages/formula/src/matches-filter-or-semantics.test.ts new file mode 100644 index 0000000000..185cf7a014 --- /dev/null +++ b/packages/formula/src/matches-filter-or-semantics.test.ts @@ -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']); + }); +}); diff --git a/packages/plugins/driver-memory/src/memory-matcher-or-semantics.test.ts b/packages/plugins/driver-memory/src/memory-matcher-or-semantics.test.ts new file mode 100644 index 0000000000..789e1137cb --- /dev/null +++ b/packages/plugins/driver-memory/src/memory-matcher-or-semantics.test.ts @@ -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']); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts b/packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts new file mode 100644 index 0000000000..226bba6343 --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `$or` filter semantics on a real SQL engine (in-memory better-sqlite3). + * + * The invariant this file defends is the Filter Protocol's (Mongo's) core rule: + * + * **Everything inside ONE filter object is AND-ed, at every nesting depth** — + * both its field keys and the operators of a single field. A `$or` array + * OR-s its BRANCHES together; it does not change how the contents *within* a + * branch combine. + * + * `applyFilterCondition` used to pass `logicalOp='or'` down into each `$or` + * branch's recursive call, so the branch's own contents were joined with + * `orWhere` too: `{$or:[{a,b}]}` compiled to `a = ? OR b = ?`, and + * `{$or:[{d:{$gte:X,$lt:Y}}]}` to `d >= ? OR d < ?` (which matches every row). + * + * Every such miscompile *widens* the result set, never narrows it, which is + * why it matters beyond tidiness: these shapes are how scoping filters and + * scheduled-flow windows are written. The last two describe blocks pin the two + * that occur in real metadata — a parent-scope branch (`{parent_object, + * parent_id:{$in}}`) and the abutting `$gte`/`$lt` window the automation skill + * docs and the CLI flow linter both tell authors to write. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +describe('SqlDriver $or filter semantics (SQLite)', () => { + let driver: SqlDriver; + let knexInstance: any; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + knexInstance = (driver as any).knex; + + await knexInstance.schema.createTable('t', (t: any) => { + t.string('id').primary(); + t.string('a'); + t.string('b'); + t.string('c'); + }); + + // The 2x2 truth table over (a, b) — every combination appears exactly once, + // so a wrongly-OR-ed pair of predicates is always visible as extra rows. + await knexInstance('t').insert([ + { 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' }, + ]); + }); + + afterEach(async () => { + await knexInstance.destroy(); + }); + + const ids = async (where: any): Promise => + (await driver.find('t', { where })).map((r: any) => r.id).sort(); + + describe('keys within a $or branch are AND-ed', () => { + it('ANDs the keys of a single multi-key branch', async () => { + // Was: `a = 'x' OR b = 'y'` → ['1','2','3']. + expect(await ids({ $or: [{ a: 'x', b: 'y' }] })).toEqual(['1']); + }); + + it('ANDs the keys of each branch independently', async () => { + expect(await ids({ $or: [{ a: 'x', b: 'y' }, { a: 'qq', b: 'zz' }] })).toEqual(['1', '4']); + }); + + it('ANDs operator-object keys within a branch', async () => { + // Exercises the `{ field: { $op: v } }` path rather than the bare-value one. + expect(await ids({ $or: [{ a: { $eq: 'x' }, b: { $ne: 'zz' } }] })).toEqual(['1']); + }); + + it('ANDs keys inside a $or nested in a $or branch', async () => { + expect(await ids({ $or: [{ $or: [{ a: 'x', b: 'zz' }] }] })).toEqual(['2']); + }); + + it('ANDs multiple operators on ONE field within a branch', async () => { + // A single-key branch is still miscompilable: the operator map is looped + // with the same `logicalOp`, so `{a:{$ne,$ne}}` became `OR` of the two. + expect(await ids({ $or: [{ a: { $ne: 'qq', $eq: 'x' }, b: 'y' }] })).toEqual(['1']); + }); + + it('ANDs a $not with its sibling keys inside a branch', async () => { + // (a <> 'x' AND b = 'zz') → row 4 only. + expect(await ids({ $or: [{ c: 'nope' }, { $not: { a: 'x' }, b: 'zz' }] })).toEqual(['4']); + }); + }); + + describe('$and nested inside $or', () => { + it('OR-s a $and branch against a sibling multi-key branch', async () => { + expect( + await ids({ $or: [{ $and: [{ a: 'x' }, { b: 'y' }] }, { a: 'qq', b: 'zz' }] }), + ).toEqual(['1', '4']); + }); + + it('OR-s a multi-key branch against a sibling $and branch', async () => { + expect( + await ids({ $or: [{ a: 'x', b: 'y' }, { $and: [{ a: 'qq' }, { b: 'zz' }] }] }), + ).toEqual(['1', '4']); + }); + + it('ANDs a $and with a key that FOLLOWS it in the same branch', async () => { + // Key order must not matter: `$and` first, plain key second. + expect(await ids({ $or: [{ c: 'nope' }, { $and: [{ a: 'qq' }], b: 'y' }] })).toEqual(['3']); + }); + + it('ANDs a $and with a key that PRECEDES it in the same branch', async () => { + // The mirror of the above — this ordering was already correct; it stays a + // regression guard so a future refactor cannot flip only one of the two. + expect(await ids({ $or: [{ c: 'nope' }, { b: 'y', $and: [{ a: 'qq' }] }] })).toEqual(['3']); + }); + }); + + describe('unaffected shapes stay unaffected (controls)', () => { + it('keeps single-key $or branches as a plain OR', async () => { + expect(await ids({ $or: [{ a: 'x' }, { b: 'y' }] })).toEqual(['1', '2', '3']); + }); + + it('keeps top-level multi-key AND', async () => { + expect(await ids({ a: 'x', b: 'y' })).toEqual(['1']); + }); + + it('keeps a $or nested under a top-level $and', async () => { + expect(await ids({ $and: [{ $or: [{ a: 'x' }, { b: 'y' }] }, { b: 'y' }] })).toEqual(['1', '3']); + }); + + it('keeps a $or sibling of a top-level field key AND-ed', async () => { + expect(await ids({ $or: [{ a: 'x' }, { b: 'y' }], b: 'zz' })).toEqual(['2']); + }); + }); + + /** + * Read scopes are ordinary FilterConditions, and the shapes that express + * "rows I own, or rows shared with me" and "rows under a parent I can see" + * both put a multi-key object inside a `$or` branch. A widening miscompile + * there returns rows the scope was written to exclude, so these shapes get + * their own explicit guards rather than relying on the abstract cases above. + */ + describe('read-scope shapes', () => { + beforeEach(async () => { + await knexInstance.schema.createTable('doc', (t: any) => { + t.string('id').primary(); + t.string('owner'); + t.string('status'); + t.string('shared_with'); + }); + await knexInstance('doc').insert([ + { 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' }, + ]); + }); + + it('does not widen an "own AND active, OR shared" scope', async () => { + const scope = { $or: [{ owner: 'u1', status: 'active' }, { shared_with: 'u1' }] }; + const rows = await driver.find('doc', { where: scope }); + // Was: `owner='u1' OR status='active' OR shared_with='u1'` — which also + // returned `own-archived` (excluded by the scope's AND) and + // `other-active` (another user's row). + expect(rows.map((r: any) => r.id).sort()).toEqual(['own-active', 'shared']); + }); + + it('does not widen a scope whose branch uses $in + status', async () => { + const scope = { $or: [{ owner: { $in: ['u1'] }, status: 'active' }, { shared_with: 'u1' }] }; + const rows = await driver.find('doc', { where: scope }); + expect(rows.map((r: any) => r.id).sort()).toEqual(['own-active', 'shared']); + }); + + it('keeps a multi-parent-type scope pinned to its own parent ids', async () => { + // "Rows under parent c1 of type case, or under parent t1 of type todo" — + // one branch per parent type, each pairing a type with its own id list. + // The pairing is the whole point of the branch and must survive compile. + await knexInstance.schema.createTable('att', (t: any) => { + t.string('id').primary(); + t.string('parent_object'); + t.string('parent_id'); + }); + await knexInstance('att').insert([ + { id: 'ok-case', parent_object: 'case', parent_id: 'c1' }, + { id: 'ok-todo', parent_object: 'todo', parent_id: 't1' }, + { id: 'other-case', parent_object: 'case', parent_id: 'c2' }, + { id: 'cross-type', parent_object: 'todo', parent_id: 'c1' }, + ]); + + const scope = { + $or: [ + { parent_object: 'case', parent_id: { $in: ['c1'] } }, + { parent_object: 'todo', parent_id: { $in: ['t1'] } }, + ], + }; + const rows = await driver.find('att', { where: scope }); + expect(rows.map((r: any) => r.id).sort()).toEqual(['ok-case', 'ok-todo']); + }); + }); + + /** + * The abutting-window pattern the automation skill docs recommend and the CLI + * flow linter blesses (`lint-flow-patterns`): each tier is one field carrying + * two operators. "Windows tile the timeline so each record matches exactly + * one tier" only holds if those operators AND — under the old compile every + * tier degenerated to `d >= lo OR d < hi`, i.e. matched every row. + */ + describe('multi-operator date windows inside $or', () => { + beforeEach(async () => { + await knexInstance.schema.createTable('task', (t: any) => { + t.string('id').primary(); + t.string('end_date'); + }); + await knexInstance('task').insert([ + { id: 'd07', end_date: '2026-08-07' }, + { id: 'd15', end_date: '2026-08-15' }, + { id: 'd30', end_date: '2026-08-30' }, + { id: 'd60', end_date: '2026-09-29' }, + ]); + }); + + it('matches only the rows inside the abutting windows', async () => { + const rows = await driver.find('task', { + where: { + $or: [ + { end_date: { $gte: '2026-08-07', $lt: '2026-08-08' } }, + { end_date: { $gte: '2026-08-30', $lt: '2026-08-31' } }, + ], + }, + }); + expect(rows.map((r: any) => r.id).sort()).toEqual(['d07', 'd30']); + }); + + it('keeps a window AND-ed with a sibling key in the same branch', async () => { + const rows = await driver.find('task', { + where: { $or: [{ id: 'nope' }, { end_date: { $gte: '2026-08-07', $lt: '2026-08-31' }, id: 'd15' }] }, + }); + expect(rows.map((r: any) => r.id)).toEqual(['d15']); + }); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index f0c6674b14..d6ec125e2d 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -3574,9 +3574,14 @@ export class SqlDriver implements IDataDriver { for (const [key, value] of Object.entries(condition)) { if (key === '$and' && Array.isArray(value)) { - builder.where((qb) => { + // Attach this group to the parent the way `logicalOp` asks, matching + // `$or`/`$not` below. Nothing passes 'or' today (the sole caller uses + // 'and' and no branch propagates 'or' any more), but leaving one of the + // four combinators deaf to the flag is how the rules drift apart again. + const method = logicalOp === 'or' ? 'orWhere' : 'where'; + (builder as any)[method]((qb: any) => { for (const sub of value) { - qb.where((subQb) => { + qb.where((subQb: any) => { this.applyFilterCondition(subQb, sub, 'and', table); }); } @@ -3585,8 +3590,16 @@ export class SqlDriver implements IDataDriver { const method = logicalOp === 'or' ? 'orWhere' : 'where'; (builder as any)[method]((qb: any) => { for (const sub of value) { + // The `orWhere` on THIS line is what OR-s the branches together. + // The branch body is still compiled with 'and', because every key + // inside one filter object is AND-ed at every depth (Filter + // Protocol / Mongo). Passing 'or' down instead made a branch's own + // field keys OR each other, so `{$or:[{a,b}]}` compiled to + // `a = ? OR b = ?`. That widens the result set, so an RLS/sharing + // read scope of the shape `{$or:[{owner,status},{shared_with}]}` + // returned rows the scope excluded — see sql-driver-or-filter.test.ts. qb.orWhere((subQb: any) => { - this.applyFilterCondition(subQb, sub, 'or', table); + this.applyFilterCondition(subQb, sub, 'and', table); }); } });