diff --git a/packages/3-extensions/sql-orm-client/src/model-accessor.ts b/packages/3-extensions/sql-orm-client/src/model-accessor.ts index 2b3ec2c8eb..585be29b15 100644 --- a/packages/3-extensions/sql-orm-client/src/model-accessor.ts +++ b/packages/3-extensions/sql-orm-client/src/model-accessor.ts @@ -8,6 +8,7 @@ import { type CodecRef, ColumnRef, ExistsExpr, + JoinAst, ProjectionItem, SelectAst, TableSource, @@ -30,6 +31,13 @@ import { } from './types'; type ResolvedModelRelation = ReturnType[string]; +type ResolvedModelRelationWithThrough = ResolvedModelRelation & { + through: NonNullable; +}; + +function hasThrough(relation: ResolvedModelRelation): relation is ResolvedModelRelationWithThrough { + return relation.through !== undefined; +} type RelationPredicateInput, ModelName extends string> = | ((model: ModelAccessor) => AnyExpression) @@ -230,6 +238,17 @@ function buildExistsExpr>( readonly predicate: RelationPredicateInput | undefined; }, ): AnyExpression { + if (hasThrough(relation)) { + return buildManyToManyExistsExpr( + context, + parentModelName, + parentTableName, + relatedTableName, + relation, + options, + ); + } + const joinWhere = buildJoinWhere( context.contract, parentModelName, @@ -267,6 +286,89 @@ function buildExistsExpr>( return existsNot ? ExistsExpr.notExists(subquery) : ExistsExpr.exists(subquery); } +function buildManyToManyExistsExpr>( + context: ExecutionContext, + parentModelName: string, + parentTableName: string, + relatedTableName: string, + relation: ResolvedModelRelationWithThrough, + options: { + readonly mode: 'some' | 'every' | 'none'; + readonly predicate: RelationPredicateInput | undefined; + }, +): AnyExpression { + const { through } = relation; + const junctionTable = through.table; + + const junctionJoinOn = buildPairedColumnExprs( + junctionTable, + through.childColumns, + relatedTableName, + through.targetColumns, + ); + + const parentLocalColumns = relation.on.localFields.map((field) => + resolveFieldToColumn(context.contract, parentModelName, field), + ); + const junctionCorrelation = buildPairedColumnExprs( + junctionTable, + through.parentColumns, + parentTableName, + parentLocalColumns, + ); + + const childWhere = toRelationWhereExpr(context, relation.to, options.predicate); + + let subqueryWhere: AnyExpression = junctionCorrelation; + let existsNot = false; + + if (options.mode === 'every') { + if (!childWhere) { + return AndExpr.true(); + } + existsNot = true; + subqueryWhere = and(junctionCorrelation, not(childWhere)); + } else if (options.mode === 'none') { + existsNot = true; + if (childWhere) { + subqueryWhere = and(junctionCorrelation, childWhere); + } + } else if (childWhere) { + subqueryWhere = and(junctionCorrelation, childWhere); + } + + const firstTargetCol = through.targetColumns[0] ?? 'id'; + const subquery = SelectAst.from(TableSource.named(relatedTableName)) + .withJoins([JoinAst.inner(TableSource.named(junctionTable), junctionJoinOn)]) + .withProjection([ProjectionItem.of('_exists', ColumnRef.of(relatedTableName, firstTargetCol))]) + .withWhere(subqueryWhere); + + return existsNot ? ExistsExpr.notExists(subquery) : ExistsExpr.exists(subquery); +} + +function buildPairedColumnExprs( + leftTable: string, + leftColumns: readonly string[], + rightTable: string, + rightColumns: readonly string[], +): AnyExpression { + const count = Math.min(leftColumns.length, rightColumns.length); + if (count === 0) { + throw new Error('Relation metadata is missing join columns'); + } + const exprs: AnyExpression[] = []; + for (let i = 0; i < count; i++) { + const left = leftColumns[i]; + const right = rightColumns[i]; + if (!left || !right) continue; + exprs.push(BinaryExpr.eq(ColumnRef.of(leftTable, left), ColumnRef.of(rightTable, right))); + } + if (exprs.length === 1 && exprs[0]) { + return exprs[0]; + } + return and(...exprs); +} + function toRelationWhereExpr>( context: ExecutionContext, relatedModelName: string, diff --git a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts index 871d0028bb..5ecb4cde62 100644 --- a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts +++ b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts @@ -5,6 +5,7 @@ import { BinaryExpr, ColumnRef, ExistsExpr, + JoinAst, ListExpression, NotExpr, NullCheckExpr, @@ -17,7 +18,12 @@ import { } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { createModelAccessor } from '../src/model-accessor'; -import { getTestContext, getTestContract, withPatchedDomainModels } from './helpers'; +import { + buildManyToManyContract, + getTestContext, + getTestContract, + withPatchedDomainModels, +} from './helpers'; import { unboundTables } from './unbound-tables'; describe('createModelAccessor', () => { @@ -479,6 +485,180 @@ describe('createModelAccessor', () => { }); }); + describe('M:N relation filters via junction', () => { + it('some() emits EXISTS through junction (single-key)', () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_children', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); + const accessor = createModelAccessor( + { ...getTestContext(), contract } as never, + 'Parent', + ) as unknown as Record unknown }>; + + const expr = accessor['children']!.some() as ExistsExpr; + + expect(expr.notExists).toBe(false); + expect(expr.subquery.from).toEqual(TableSource.named('children')); + expect(expr.subquery.joins).toEqual([ + JoinAst.inner( + TableSource.named('parent_children'), + BinaryExpr.eq( + ColumnRef.of('parent_children', 'child_id'), + ColumnRef.of('children', 'id'), + ), + ), + ]); + expect(expr.subquery.where).toEqual( + BinaryExpr.eq(ColumnRef.of('parent_children', 'parent_id'), ColumnRef.of('parents', 'id')), + ); + }); + + it('some(pred) AND-s junction correlation with predicate', () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_children', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); + const accessor = createModelAccessor( + { ...getTestContext(), contract } as never, + 'Parent', + ) as unknown as Record unknown) => unknown }>; + + const expr = accessor['children']!.some((c: unknown) => + (c as Record unknown }>)['id']!.eq(42), + ) as ExistsExpr; + + expect(expr.notExists).toBe(false); + expect(expr.subquery.where).toEqual( + AndExpr.of([ + BinaryExpr.eq( + ColumnRef.of('parent_children', 'parent_id'), + ColumnRef.of('parents', 'id'), + ), + BinaryExpr.eq( + ColumnRef.of('children', 'id'), + ParamRef.of(42, { codec: { codecId: 'pg/int4@1' } }), + ), + ]), + ); + }); + + it('none() emits NOT EXISTS through junction', () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_children', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); + const accessor = createModelAccessor( + { ...getTestContext(), contract } as never, + 'Parent', + ) as unknown as Record unknown }>; + + const expr = accessor['children']!.none() as ExistsExpr; + expect(expr.notExists).toBe(true); + expect(expr.subquery.where).toEqual( + BinaryExpr.eq(ColumnRef.of('parent_children', 'parent_id'), ColumnRef.of('parents', 'id')), + ); + }); + + it('every(pred) emits NOT EXISTS(… AND NOT(pred)) through junction', () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_children', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); + const accessor = createModelAccessor( + { ...getTestContext(), contract } as never, + 'Parent', + ) as unknown as Record unknown) => unknown }>; + + const expr = accessor['children']!.every((c: unknown) => + (c as Record unknown }>)['id']!.eq(99), + ) as ExistsExpr; + + expect(expr.notExists).toBe(true); + expect(expr.subquery.where).toEqual( + AndExpr.of([ + BinaryExpr.eq( + ColumnRef.of('parent_children', 'parent_id'), + ColumnRef.of('parents', 'id'), + ), + new NotExpr( + BinaryExpr.eq( + ColumnRef.of('children', 'id'), + ParamRef.of(99, { codec: { codecId: 'pg/int4@1' } }), + ), + ), + ]), + ); + }); + + it('every({}) is vacuously true for M:N relations', () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_children', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); + const accessor = createModelAccessor( + { ...getTestContext(), contract } as never, + 'Parent', + ) as unknown as Record unknown }>; + + expect(accessor['children']!.every({})).toEqual(AndExpr.true()); + }); + + it('some() emits EXISTS with composite-key AND-ed junction join', () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_children', + parentColumns: ['tenant_id', 'parent_id'], + childColumns: ['tenant_id', 'child_id'], + targetColumns: ['tenant_id', 'id'], + localFields: ['tenant_id', 'id'], + }); + const accessor = createModelAccessor( + { ...getTestContext(), contract } as never, + 'Parent', + ) as unknown as Record unknown }>; + + const expr = accessor['children']!.some() as ExistsExpr; + + expect(expr.subquery.joins).toEqual([ + JoinAst.inner( + TableSource.named('parent_children'), + AndExpr.of([ + BinaryExpr.eq( + ColumnRef.of('parent_children', 'tenant_id'), + ColumnRef.of('children', 'tenant_id'), + ), + BinaryExpr.eq( + ColumnRef.of('parent_children', 'child_id'), + ColumnRef.of('children', 'id'), + ), + ]), + ), + ]); + expect(expr.subquery.where).toEqual( + AndExpr.of([ + BinaryExpr.eq( + ColumnRef.of('parent_children', 'tenant_id'), + ColumnRef.of('parents', 'tenant_id'), + ), + BinaryExpr.eq( + ColumnRef.of('parent_children', 'parent_id'), + ColumnRef.of('parents', 'id'), + ), + ]), + ); + }); + }); + describe('extension operations', () => { it('attaches trait-targeted op only when codec traits are a superset of required traits', () => { const queryOperations = createSqlOperationRegistry(); diff --git a/projects/sql-orm-many-to-many/learnings.md b/projects/sql-orm-many-to-many/learnings.md index 3d45eeaf62..c803d04e39 100644 --- a/projects/sql-orm-many-to-many/learnings.md +++ b/projects/sql-orm-many-to-many/learnings.md @@ -17,3 +17,5 @@ Running the whole sql-orm-client integration suite at once (`cd test/integration ## Dispatch truncation recovery (no subagent resume) A substantial dispatch can exhaust the implementer's budget mid-work and return a truncated report with **uncommitted WIP** (happened on the slice-1 read path). Recovery: inspect `git status`/`git diff`, then dispatch a fresh continuation implementer pointed at the WIP with a focused completion brief (it commits the WIP + completion as one commit). Keep dispatches tight and tell implementers to implement-then-test-then-gate rather than over-explore (over-exploration is what burned the budget). + +**Recurrence (2×):** both the slice-1 read-path dispatch and the slice-2 filter dispatch (the "junction-correlation code + unit tests" judgment dispatches) truncated around 70–135k implementer tokens. The combination of (read corpus) + (design the SQL shape) + (write + iterate tests) reliably exceeds one sonnet budget here. The continue-from-WIP recovery works every time, but for future projects of this shape, consider splitting "implement the builder/accessor branch" and "write its unit tests" into two dispatches, or routing these to a higher-budget tier. diff --git a/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/01-filter-code.md b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/01-filter-code.md new file mode 100644 index 0000000000..9adf4f590b --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/01-filter-code.md @@ -0,0 +1,45 @@ +# Brief: S2-D1 — filter EXISTS walks the junction + +## Task + +Teach the relation-filter accessor to walk the junction for M:N relations. `db.orm.User.filter((u) => u.tags.some/every/none((t) => …))` must emit an EXISTS / NOT EXISTS subquery that goes through the `UserTag` junction. Today `buildJoinWhere` (`packages/3-extensions/sql-orm-client/src/model-accessor.ts`) reads only `relation.on.localFields/targetFields` — for an M:N relation that emits a wrong-shape EXISTS that skips the junction. + +When the resolved relation carries `through` (slice 0 added it to `resolveModelRelations`'s output), build the M:N shape in `buildExistsExpr`/`buildJoinWhere`: +- **`some(pred)`** → `EXISTS (SELECT 1 FROM target JOIN junction ON junction.childColumns = target.targetColumns WHERE junction.parentColumns = parent.anchor AND )`. +- **`none(pred)`** → `NOT EXISTS (… AND )`. +- **`every(pred)`** → `NOT EXISTS (… AND NOT())` — mirror the existing FK `every` shape, just through the junction. + +The parent correlation is on the **junction** side; the target is reached via the junction join; composite keys AND-ed across all pairs. The child predicate is unchanged. + +**First confirm** the relation reaching `buildJoinWhere` carries `through` — it should, via `resolveModelRelations` (slice 0). If the filter path uses a relation shape that drops `through`, surface it onto that path (one field; mirror how slice 1 surfaced `through` onto `IncludeExpr`). + +**Write unit tests first** asserting the compiled EXISTS AST for `some`/`every`/`none` on an M:N relation joins through the junction (composite-key AND-ed), and that FK relation filters are unchanged. + +## Scope + +**In:** the M:N branch in `buildExistsExpr`/`buildJoinWhere` (`model-accessor.ts`) for some/every/none; surfacing `through` onto the filter relation if dropped; unit tests for the EXISTS AST. + +**Out:** integration tests (S2-D2); include reads (slice 1); nested writes (slice 3); the `through` shape (slice 0). Don't regress FK filters. + +## Completed when + +- [ ] `some`/`every`/`none` on an M:N relation compile to a correctly-shaped EXISTS/NOT EXISTS through the junction (composite-key AND-ed); unit test asserts the AST. +- [ ] FK relation filters unchanged (existing model-accessor unit tests pass). +- [ ] Gate: `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green. + +## Standing instruction + +Stay focused on the junction EXISTS. The judgment site is the junction hop in `buildJoinWhere` and the `every` = `NOT EXISTS(… NOT(pred))` shape; mirror the FK path. No bare `as` casts (use `castAs`/`blindCast` if unavoidable — a sibling slice was bounced for bare casts twice; don't add new ones). + +## References + +- Slice spec: `projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/spec.md`. +- `model-accessor.ts`: `createRelationFilterAccessor` (~190), `buildExistsExpr` (~222), `buildJoinWhere` (~331) — the FK path to extend. +- Slice 0 `ResolvedRelation.through` in `collection-contract.ts`. + +## Operational metadata + +- **Model tier:** sonnet — bounded judgment (the junction EXISTS + every/none shapes). +- **Branch:** `tml-2786-slice-2-filter`. Explicit staging + `-s` sign-off. **Do not push.** +- **Time-box:** ~75 min — implement `some` first + its test, then `none`/`every`, then gate; don't over-explore. +- **Halt + surface to me:** if `buildJoinWhere`'s EXISTS construction can't host the junction join without a structural change beyond the FK path's shape (surface the obstacle); if `through` is genuinely unavailable on the filter relation and surfacing it touches an out-of-scope consumer. diff --git a/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/01-filter-code.r2.md b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/01-filter-code.r2.md new file mode 100644 index 0000000000..b27acdc5d8 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/01-filter-code.r2.md @@ -0,0 +1,39 @@ +# Brief: S2-D1 R2 — finish the filter EXISTS (continue from WIP) + +## Situation + +The R1 implementer ran out of budget mid-work and **did not commit**. Uncommitted WIP is in the tree (`git status` + `git diff`): `src/model-accessor.ts` (+96) and `test/model-accessor.test.ts` (+182). It was mid-fix on the **parent-anchor correlation** — it had just realised the junction→parent side must resolve from `relation.on.localFields` (the parent's anchor columns), not from `through.parentColumns`, and was about to thread `contract` + `parentModelName` into a `buildManyToManyExistsExpr` helper to resolve them. + +## Task + +**Read the uncommitted diff first.** Then finish: + +1. Complete the M:N EXISTS for `some`/`every`/`none` in `model-accessor.ts`. The correlation has two distinct sides — get both right (this is exactly what slice 1's read path established, mirror it for consistency): + - **junction → parent:** `junction.{through.parentColumns} = parent.{on.localFields resolved to columns}` (e.g. `user_tags.user_id = users.id`). Resolve the parent anchor columns via `resolveFieldToColumn(contract, parentModelName, localField)` — thread `contract`/`parentModelName` into the helper as the WIP was starting to do. + - **junction → target:** `junction.{through.childColumns} = target.{through.targetColumns}` (e.g. `user_tags.tag_id = tags.id`). + - Shapes: `some` = `EXISTS(SELECT 1 FROM target JOIN junction ON WHERE AND )`; `none` = `NOT EXISTS(… AND )`; `every` = `NOT EXISTS(… AND NOT())`. Composite-key AND-ed across all pairs. +2. Reconcile R1's WIP unit tests so they pass and assert the AST (junction join + both correlation sides; some/every/none). Fix any incoherent WIP test. +3. Don't regress FK relation filters. + +## Completed when + +- [ ] `some`/`every`/`none` on M:N compile to the correct junction EXISTS/NOT EXISTS (both correlation sides correct, composite-key AND-ed); unit tests pass. +- [ ] FK filter tests pass. +- [ ] `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green. +- [ ] Committed as **one coherent commit** (WIP + completion), explicit staging + `-s` sign-off, **no push**. No bare `as` casts. + +## Standing instruction + +Finish the goal; keep R1's coherent WIP. Implement → get the targeted test green → run the package gate; don't re-explore. + +## References + +- R1 brief: `./01-filter-code.md`. Slice spec: `../spec.md`. +- **Slice 1 read path** (`query-plan-select.ts` `buildManyToManyJunctionArtifacts`, commit `e587b433c`) resolved the same two-sided junction correlation — mirror its parent-anchor resolution for consistency. + +## Operational metadata + +- **Model tier:** sonnet. +- **Branch:** `tml-2786-slice-2-filter` (WIP already on it). Explicit staging + `-s`; **no push**. Don't commit under `projects/`. +- **Time-box:** ~50 min. +- **Halt + surface to me:** if R1's WIP is incoherent in a way you can't reconcile (describe it); if the junction EXISTS needs a structural change beyond the FK EXISTS shape. diff --git a/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/02-integration-tests.md b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/02-integration-tests.md new file mode 100644 index 0000000000..7d1b261bb3 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/02-integration-tests.md @@ -0,0 +1,42 @@ +# Brief: S2-D2 — M:N filter integration tests (operator standard) + +## Task + +Prove M:N relation filters work end-to-end against the database, following the project's **integration-test standard**. D1 made `.some`/`.every`/`.none` emit a junction EXISTS; slice 1's fixture has `User.tags` (→ `Tag` via `UserTag`) and `seedTags`/`seedUserTags` helpers. Add integration tests under `test/integration/test/sql-orm-client/` (PGlite, `withCollectionRuntime`). Seed users/tags/junction rows, then assert `db.orm.User.filter((u) => u.tags.some/every/none(...))` returns the right users. + +**Cases (all required):** +- **`some`** — users having ≥1 tag matching a predicate (e.g. `t.name.eq('x')`). +- **`none`** — users with no matching tag. +- **`every`** — users all of whose tags match (include a user with a non-matching tag to prove they're excluded; and verify the vacuous case — a user with **no** tags satisfies `every`). +- **empty-match edge** — a predicate no tag matches → `some` returns none, `none`/`every` behave correctly. + +**Standard (all three):** (1) whole-row `toEqual` on the **filtered result set** (assert exactly which users come back, full row shape); (2) explicit `.select(...)` in **most** tests; (3) **≥1** test uses implicit/default selection (no `.select`, asserts full default row shape of the returned users). + +## Scope + +**In:** new integration test file under `test/integration/test/sql-orm-client/`; reuse slice 1's seed helpers (extend if a filter test needs more seed data). + +**Out:** filter code (D1); include reads (slice 1); writes (slice 3); production changes (if a test reveals a filter bug, surface it — don't patch production here). Don't modify the fixture contract. + +## Completed when + +- [ ] Integration tests pass on PGlite covering `some`, `none`, `every` (incl. the vacuous no-tags case) and an empty-match edge, asserting the exact filtered user set as **whole rows** (`toEqual`). +- [ ] Most tests use explicit `.select`; **≥1** uses implicit/default selection. +- [ ] Gate: `cd test/integration && pnpm test test/sql-orm-client/` green (the in-sandbox path for this suite). + +## Standing instruction + +Match the existing integration corpus style. If a test surfaces a real filter bug (wrong users returned), **surface it to me** with the failing assertion — that's a `must-fix` against D1, not something to patch in the test. + +## References + +- Slice spec: `projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/spec.md` (§ done conditions — the standard). +- Slice 1's `mn-include.test.ts` + `runtime-helpers.ts` (`seedTags`/`seedUserTags`) — the harness + seed pattern to reuse. +- Existing filter integration tests (if any) for `.some/.every/.none` on FK relations — mirror their structure. + +## Operational metadata + +- **Model tier:** sonnet. +- **Branch:** `tml-2786-slice-2-filter`. Explicit staging + `-s` sign-off. **Do not push.** +- **Time-box:** ~60 min — core `some`/`none`/`every` whole-row tests first, then the implicit-selection + empty-match cases; don't over-explore. +- **Halt + surface to me:** if the integration harness can't run in-sandbox (PGlite spin-up failure unrelated to your tests — describe it, don't fake green); if a filter returns the wrong user set (D1 bug). diff --git a/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/plan.md b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/plan.md new file mode 100644 index 0000000000..6a76d432e7 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/plan.md @@ -0,0 +1,24 @@ +# Slice 2: filter EXISTS through the junction — Dispatch plan + +**Spec:** `projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/spec.md` +**Linear:** [TML-2786](https://linear.app/prisma-company/issue/TML-2786) + +Two dispatches: filter code (judgment) then integration tests (verification). No fixture dispatch — slice 1's `User ↔ Tag` is reused. + +### Dispatch 1: filter EXISTS walks the junction + +- **Outcome:** `some`/`every`/`none` on an M:N relation compile to a correctly-shaped EXISTS / NOT EXISTS that walks the junction (target JOIN junction correlated to parent on the junction side; composite-key AND-ed); FK filters unchanged. Unit-tested at the AST level. +- **Builds on:** slice 0's `ResolvedRelation.through` (carried by `resolveModelRelations`). +- **Hands to:** correctly-shaped M:N relation filters — the behaviour D2 verifies on the DB. +- **Focus:** the M:N branch in `buildExistsExpr`/`buildJoinWhere` (`model-accessor.ts`); surface `through` onto the filter relation if it's dropped; unit tests for the EXISTS AST (some/every/none through junction). No integration tests here. + +### Dispatch 2: filter integration tests (operator standard) + +- **Outcome:** integration tests prove `.filter(u => u.tags.some/every/none(...))` returns the right users on PGlite, following the standard — whole-row `toEqual` on the filtered set, explicit `.select` in most, **≥1** implicit/default-selection case; `some`, `every`, `none`, and an empty-match edge covered. +- **Builds on:** D1's filter code + slice 1's fixture/seed helpers (`seedUserTags`). +- **Hands to:** the slice-DoD-satisfying M:N filter coverage. +- **Focus:** new integration test file under `test/integration/test/sql-orm-client/`, PGlite via `withCollectionRuntime`; reuse the `seedTags`/`seedUserTags` helpers slice 1 added. Run via `cd test/integration && pnpm test test/sql-orm-client/`. + +## Handoff completeness + +Slice-DoD reachable: correctly-shaped junction EXISTS (D1 unit) + filter behaviour on DB per standard (D2 integration) + FK filters unchanged (D1). diff --git a/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/spec.md b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/spec.md new file mode 100644 index 0000000000..ba095838c0 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/spec.md @@ -0,0 +1,53 @@ +# Slice 2: relation filters (some/every/none) through the junction + +_Parent project: `projects/sql-orm-many-to-many/`. Outcome: `.filter(u => u.tags.some/every/none(...))` emits an EXISTS that walks the junction._ + +## At a glance + +`db.orm.User.filter((u) => u.tags.some((t) => t.name.eq('x')))` (and `.every` / `.none`) must produce an EXISTS subquery that walks the **junction** for M:N relations. Today `buildJoinWhere` (`model-accessor.ts`) reads only `relation.on.localFields/targetFields`, so an M:N filter would emit a wrong-shape EXISTS that skips the junction. This slice adds the junction hop, reusing slice 0's `through` descriptor. + +## Chosen design + +**Add an M:N branch to the EXISTS builder.** `createRelationFilterAccessor` → `buildExistsExpr` → `buildJoinWhere` (`model-accessor.ts`). When the resolved relation carries `through`: + +- **`some(pred)`** → `EXISTS (SELECT 1 FROM target JOIN junction ON junction.childColumns = target.targetColumns WHERE junction.parentColumns = parent.anchor AND )`. +- **`none(pred)`** → `NOT EXISTS (… AND )`. +- **`every(pred)`** → `NOT EXISTS (… AND NOT ())` (no related row that fails the predicate), mirroring the existing FK `every` shape. + +The parent correlation moves to the **junction** side (`junction.parentColumns = parent.anchor`); the target is reached via the junction join (`junction.childColumns = target.targetColumns`); composite keys AND-ed. The child predicate (``) is unchanged — it still applies to the target columns. + +The relation passed to `buildJoinWhere` comes from `resolveModelRelations`, which slice 0 extended with `through`; **confirm** the filter path's relation type carries `through` (if it uses a relation shape that drops it, plumb it through — same one-field surfacing slice 1 did for `IncludeExpr`). + +## Coherence rationale + +One reviewable story: "M:N relation filters walk the junction." The `some`/`every`/`none` cases share the single junction-EXISTS shape; they're one coherent change to `buildJoinWhere`/`buildExistsExpr`, not separable. + +## Scope + +**In:** the M:N branch in `buildExistsExpr`/`buildJoinWhere` (`model-accessor.ts`) for `some`/`every`/`none`; surfacing `through` onto the filter path's relation if needed; unit tests (EXISTS AST through junction); integration tests per the standard. + +**Out:** include reads (slice 1, done); nested writes (slice 3); non-relation filters; any `through` shape change (slice 0 owns it). No fixture change — reuse slice 1's `User ↔ Tag`. + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +|---|---|---| +| Composite-key junction | AND across all column pairs in both the junction→parent correlation and junction→target join | slice 0 arrays | +| `every` semantics | `NOT EXISTS (… junction … AND NOT(pred))` — mirror the existing FK `every`, just through the junction | don't invent a new shape | +| Relation type may drop `through` | If the filter path's resolved-relation type doesn't carry `through`, surface/plumb it (one field), don't approximate | grounding for the implementer | + +## Slice-specific done conditions + +- [ ] `.some/.every/.none` on an M:N relation emit a correctly-shaped EXISTS/NOT EXISTS that joins through the junction (composite-key AND-ed); unit test asserts the AST. +- [ ] Integration tests (PGlite) per the standard: whole-row `toEqual` on the filtered result set; explicit `.select` in most; **≥1** implicit/default-selection case; cover `some`, `every`, `none`, and an empty-match edge. +- [ ] FK relation filters unchanged (existing tests pass). + +## Open Questions + +1. **`through` availability on the filter relation.** Working position: `resolveModelRelations` already carries `through` (slice 0); the filter path reuses it directly. If grounding shows otherwise, plumb the one field (no design change). + +## References + +- Parent project: `projects/sql-orm-many-to-many/spec.md` (§ Cross-cutting — integration-test standard). +- Slice 0 `ResolvedRelation.through`; slice 1 fixture (`User ↔ Tag`). +- Linear: [TML-2786](https://linear.app/prisma-company/issue/TML-2786) diff --git a/projects/sql-orm-many-to-many/trace.jsonl b/projects/sql-orm-many-to-many/trace.jsonl index aa9bca7a50..6fd8546bea 100644 --- a/projects/sql-orm-many-to-many/trace.jsonl +++ b/projects/sql-orm-many-to-many/trace.jsonl @@ -46,3 +46,18 @@ {"event_id":"b5530bd5-2ada-44bc-b65e-880aaff74187","schema_version":"1","ts":"2026-06-01T18:53:04.232Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"88c9c264-8edc-4584-b440-7d51eeae37e4","round_id":"a405f9a4-2407-4328-93f9-476c31d88c0e","brief_byte_length":4062,"brief_content_hash":"c0f7aa67226f403d7c10b578156ae5b5dd8141de924ed80e3b0e1aeec917ae3e","brief_disposition":"initial"} {"event_id":"7f4f9ac3-eaf8-41df-b5df-cc72314030de","schema_version":"1","ts":"2026-06-01T19:14:43.687Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"88c9c264-8edc-4584-b440-7d51eeae37e4","round_id":"a405f9a4-2407-4328-93f9-476c31d88c0e","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1299311} {"event_id":"10edc1f4-e070-4dc6-b0ac-bf8eaf2565a7","schema_version":"1","ts":"2026-06-01T19:14:44.090Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"88c9c264-8edc-4584-b440-7d51eeae37e4","result":"completed","wall_clock_ms":1299697} +{"event_id":"c766ebb8-dfa9-4f3a-b033-3ce51a3594cc","schema_version":"1","ts":"2026-06-01T19:18:04.849Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"spec-authored","spec_path":"projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/spec.md","spec_kind":"slice","byte_length":4110,"edge_cases_count":3,"open_questions_count":1,"dod_items_count":3} +{"event_id":"c358128a-8d02-451d-b2d6-ee791793231e","schema_version":"1","ts":"2026-06-01T19:18:05.325Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"plan-authored","plan_path":"projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/plan.md","plan_kind":"slice","byte_length":2068,"dispatch_count":2,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":2,"L":0,"XL":0},"open_items_count":0} +{"event_id":"1f4269f6-b643-49cc-b578-486bde519cb2","schema_version":"1","ts":"2026-06-01T19:18:55.231Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"e9a8e215-dd56-4083-8da7-7a662426285d","dispatch_name":"S2-D1 filter EXISTS through junction","subagent_type":"general-purpose","model":"sonnet","parent_dispatch_id":null} +{"event_id":"0204eb47-f7db-4946-baaf-9ac8e44efa7f","schema_version":"1","ts":"2026-06-01T19:18:55.645Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"e9a8e215-dd56-4083-8da7-7a662426285d","round_id":"c1a6d082-e1ff-4946-85b5-ffdc629907f7","round_number":1} +{"event_id":"8acf055e-4669-437a-b8d0-c9116fb33f7a","schema_version":"1","ts":"2026-06-01T19:18:56.042Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"e9a8e215-dd56-4083-8da7-7a662426285d","round_id":"c1a6d082-e1ff-4946-85b5-ffdc629907f7","brief_byte_length":3636,"brief_content_hash":"4315f8e52c313f3c635deb90d4cfabd34a09475ac2e1f5ddb8c2e283397c93af","brief_disposition":"initial"} +{"event_id":"d3da2730-50fa-4a39-a20c-0104eac93e3e","schema_version":"1","ts":"2026-06-01T19:24:24.824Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"e9a8e215-dd56-4083-8da7-7a662426285d","round_id":"c1a6d082-e1ff-4946-85b5-ffdc629907f7","verdict":"another-round-needed","findings_filed":0,"wall_clock_ms":328774} +{"event_id":"f2f19a37-be1f-4344-9bf4-dae9f9325c35","schema_version":"1","ts":"2026-06-01T19:24:25.231Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"e9a8e215-dd56-4083-8da7-7a662426285d","round_id":"42b0293b-0378-47f7-a45b-8ee88eabba48","round_number":2} +{"event_id":"01685cd6-220a-4ffc-a954-f6f50c0c8e53","schema_version":"1","ts":"2026-06-01T19:24:25.606Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"e9a8e215-dd56-4083-8da7-7a662426285d","round_id":"42b0293b-0378-47f7-a45b-8ee88eabba48","brief_byte_length":3019,"brief_content_hash":"78194f31856d295021e8c57a90d3e795311b3aae0c276348a87154e3d31a301f","brief_disposition":"amended"} +{"event_id":"8d180a85-decb-4192-a567-ee2ecb295f69","schema_version":"1","ts":"2026-06-01T19:32:16.305Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"e9a8e215-dd56-4083-8da7-7a662426285d","round_id":"42b0293b-0378-47f7-a45b-8ee88eabba48","verdict":"satisfied","findings_filed":0,"wall_clock_ms":470687} +{"event_id":"7a0516c4-2017-4f7c-abf9-a56cb3de8ce8","schema_version":"1","ts":"2026-06-01T19:32:16.709Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"e9a8e215-dd56-4083-8da7-7a662426285d","result":"completed","wall_clock_ms":800687} +{"event_id":"78b3ccd1-b852-4db1-8345-7473066dd2c6","schema_version":"1","ts":"2026-06-01T19:32:50.761Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"cf687dda-ed46-4038-aa28-ad94ba17dd8f","dispatch_name":"S2-D2 M:N filter integration tests","subagent_type":"general-purpose","model":"sonnet","parent_dispatch_id":null} +{"event_id":"1ba55c50-3aac-4fa3-83c5-33068f6a3412","schema_version":"1","ts":"2026-06-01T19:32:51.145Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"cf687dda-ed46-4038-aa28-ad94ba17dd8f","round_id":"1f3a01e1-6941-4453-808b-b833c553c03e","round_number":1} +{"event_id":"2d3e4bc6-9ee3-4c31-a31a-acbdb6aa2281","schema_version":"1","ts":"2026-06-01T19:32:51.521Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"cf687dda-ed46-4038-aa28-ad94ba17dd8f","round_id":"1f3a01e1-6941-4453-808b-b833c553c03e","brief_byte_length":3285,"brief_content_hash":"2221c822875da35648cb846f12094465a23c2565d55c8e10f6690482e3bce2e7","brief_disposition":"initial"} +{"event_id":"5e42b22f-745d-4b15-8352-f874f1873552","schema_version":"1","ts":"2026-06-01T19:41:15.103Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"cf687dda-ed46-4038-aa28-ad94ba17dd8f","round_id":"1f3a01e1-6941-4453-808b-b833c553c03e","verdict":"satisfied","findings_filed":0,"wall_clock_ms":503436} +{"event_id":"b61b7318-eee7-458f-ad44-0873e860b3c4","schema_version":"1","ts":"2026-06-01T19:41:15.494Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"cf687dda-ed46-4038-aa28-ad94ba17dd8f","result":"completed","wall_clock_ms":503820} diff --git a/test/integration/test/sql-orm-client/mn-filter.test.ts b/test/integration/test/sql-orm-client/mn-filter.test.ts new file mode 100644 index 0000000000..c760a4e943 --- /dev/null +++ b/test/integration/test/sql-orm-client/mn-filter.test.ts @@ -0,0 +1,363 @@ +// Integration coverage for M:N relation filters via junction EXISTS. +// +// `User.tags` is a many-to-many relation to `Tag` through the `user_tags` +// junction table. `.some`/`.none`/`.every` on M:N relations emit correlated +// EXISTS/NOT EXISTS subqueries against the junction. These tests prove +// end-to-end correctness against a real database. +// +// Test data shape: +// +// User(id, name, email, invitedById?, address?) +// tags: N:M Tag through user_tags (via user_id / tag_id) +// +// Tag(id: text, name: text) +// +// UserTag(userId, tagId) — junction +// +// Standard: +// 1. Whole-row toEqual assertions on the exact filtered user set. +// 2. Explicit .select() used in most tests. +// 3. At least one test uses implicit/default selection (no .select()). + +import { describe, expect, it } from 'vitest'; +import { createUsersCollection, timeouts, withCollectionRuntime } from './integration-helpers'; +import { seedTags, seedUsers, seedUserTags } from './runtime-helpers'; + +const TAG_RUST = 'tag-rust'; +const TAG_TS = 'tag-typescript'; +const TAG_DB = 'tag-database'; + +describe('integration/mn-filter', () => { + // =========================================================================== + // some — users having ≥1 tag matching the predicate. + // =========================================================================== + + it( + 'some: returns only users that have at least one matching tag (explicit select, whole-row toEqual)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + { id: 3, name: 'Cara', email: 'cara@example.com' }, + ]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + // Alice: Rust + TypeScript, Bob: TypeScript only, Cara: no tags. + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_RUST }, + { userId: 1, tagId: TAG_TS }, + { userId: 2, tagId: TAG_TS }, + ]); + + const rows = await users + .select('id', 'name') + .where((u) => u.tags.some((t) => t.name.eq('Rust'))) + .orderBy((u) => u.id.asc()) + .all(); + + // Only Alice has Rust. + expect(rows).toEqual([{ id: 1, name: 'Alice' }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'some: multiple users each having the matching tag are all returned (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + { id: 3, name: 'Cara', email: 'cara@example.com' }, + ]); + await seedTags(runtime, [ + { id: TAG_TS, name: 'TypeScript' }, + { id: TAG_DB, name: 'Database' }, + ]); + // Alice and Bob both have TypeScript; Cara only has Database. + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_TS }, + { userId: 2, tagId: TAG_TS }, + { userId: 3, tagId: TAG_DB }, + ]); + + const rows = await users + .select('id', 'name') + .where((u) => u.tags.some((t) => t.name.eq('TypeScript'))) + .orderBy((u) => u.id.asc()) + .all(); + + expect(rows).toEqual([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // none — users with no tag matching the predicate. + // =========================================================================== + + it( + 'none: returns only users with no tag matching the predicate (explicit select, whole-row toEqual)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + { id: 3, name: 'Cara', email: 'cara@example.com' }, + ]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + // Alice: Rust only, Bob: TypeScript only, Cara: no tags. + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_RUST }, + { userId: 2, tagId: TAG_TS }, + ]); + + const rows = await users + .select('id', 'name') + .where((u) => u.tags.none((t) => t.name.eq('Rust'))) + .orderBy((u) => u.id.asc()) + .all(); + + // Bob has no Rust tag; Cara has no tags at all (also satisfies none). + expect(rows).toEqual([ + { id: 2, name: 'Bob' }, + { id: 3, name: 'Cara' }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // every — users all of whose tags match the predicate, including vacuous + // case (user with no tags satisfies every) and exclusion of partial match. + // =========================================================================== + + it( + 'every: returns users whose tags all match the predicate, excludes partial match (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + { id: 3, name: 'Cara', email: 'cara@example.com' }, + ]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + // Alice: Rust only — all her tags are Rust → qualifies. + // Bob: Rust + TypeScript — not all tags are Rust → excluded. + // Cara: no tags — vacuously true → qualifies. + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_RUST }, + { userId: 2, tagId: TAG_RUST }, + { userId: 2, tagId: TAG_TS }, + ]); + + const rows = await users + .select('id', 'name') + .where((u) => u.tags.every((t) => t.name.eq('Rust'))) + .orderBy((u) => u.id.asc()) + .all(); + + // Alice: qualifies (only Rust). Cara: qualifies (vacuous). Bob: excluded. + expect(rows).toEqual([ + { id: 1, name: 'Alice' }, + { id: 3, name: 'Cara' }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'every: vacuous case — user with no tags satisfies every (explicit select, isolated)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]); + await seedTags(runtime, [{ id: TAG_TS, name: 'TypeScript' }]); + // Alice has TypeScript; Bob has no tags. + await seedUserTags(runtime, [{ userId: 1, tagId: TAG_TS }]); + + const rows = await users + .select('id', 'name') + .where((u) => u.tags.every((t) => t.name.eq('Rust'))) + .orderBy((u) => u.id.asc()) + .all(); + + // Alice has TypeScript which is NOT Rust → excluded. + // Bob has no tags → vacuously satisfies every → included. + expect(rows).toEqual([{ id: 2, name: 'Bob' }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // Implicit/default selection (standard requirement: ≥1 test without .select). + // =========================================================================== + + it( + 'some: no .select returns full default user row shape (implicit selection)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]); + await seedTags(runtime, [{ id: TAG_DB, name: 'Database' }]); + // Only Alice has Database. + await seedUserTags(runtime, [{ userId: 1, tagId: TAG_DB }]); + + const rows = await users + .where((u) => u.tags.some((t) => t.name.eq('Database'))) + .orderBy((u) => u.id.asc()) + .all(); + + // Full User row shape for Alice only. + expect(rows).toEqual([ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + invitedById: null, + address: null, + }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // Empty-match edge — predicate that no tag satisfies. + // =========================================================================== + + it( + 'some with no matching tag returns empty result set (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_RUST }, + { userId: 2, tagId: TAG_TS }, + ]); + + // 'Go' tag does not exist at all — some returns no users. + const rows = await users + .select('id', 'name') + .where((u) => u.tags.some((t) => t.name.eq('Go'))) + .all(); + + expect(rows).toEqual([]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'none with no matching tag (all users pass) returns all users (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + // Neither user has a 'Go' tag. + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_RUST }, + { userId: 2, tagId: TAG_TS }, + ]); + + // 'Go' matches nothing → none(Go) is satisfied by every user. + const rows = await users + .select('id', 'name') + .where((u) => u.tags.none((t) => t.name.eq('Go'))) + .orderBy((u) => u.id.asc()) + .all(); + + expect(rows).toEqual([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'every with predicate that no tag satisfies excludes all tagged users (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createUsersCollection(runtime); + + await seedUsers(runtime, [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_RUST }, + { userId: 2, tagId: TAG_TS }, + ]); + + // every(name='Go') lowers to NOT EXISTS(… AND NOT(name='Go')). + // Alice's Rust tag fails the predicate → NOT(pred) is true → EXISTS fires → excluded. + // Bob's TypeScript tag fails the predicate → same → excluded. + const rows = await users + .select('id', 'name') + .where((u) => u.tags.every((t) => t.name.eq('Go'))) + .orderBy((u) => u.id.asc()) + .all(); + + expect(rows).toEqual([]); + }); + }, + timeouts.spinUpPpgDev, + ); +});