From 439e7745c4270e73c2301529da4376ec4a45370c Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Mon, 1 Jun 2026 21:18:05 +0200 Subject: [PATCH 1/6] docs(sql-orm-many-to-many): slice 2 spec + plan (TML-2786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter slice: some/every/none EXISTS through the junction. 2 dispatches (filter code / integration tests). Reuses slice 1 User↔Tag fixture. Signed-off-by: Alexey Orlenko's AI Agent --- .../02-filter-exists-through-junction/plan.md | 24 +++++++++ .../02-filter-exists-through-junction/spec.md | 53 +++++++++++++++++++ projects/sql-orm-many-to-many/trace.jsonl | 2 + 3 files changed, 79 insertions(+) create mode 100644 projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/plan.md create mode 100644 projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/spec.md 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..2562f02967 100644 --- a/projects/sql-orm-many-to-many/trace.jsonl +++ b/projects/sql-orm-many-to-many/trace.jsonl @@ -46,3 +46,5 @@ {"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} From 6ff9d2d0b78382d777d6ffa0c6b8a86ff05a01fa Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Mon, 1 Jun 2026 21:27:36 +0200 Subject: [PATCH 2/6] feat(sql-orm-client): M:N relation filters via junction EXISTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add some/every/none filter support for many-to-many relations, generating correlated EXISTS/NOT EXISTS subqueries that join through the junction table. Both correlation sides are correctly threaded: - junction→target: JOIN junction ON junction.childColumns = target.targetColumns - junction→parent: WHERE junction.parentColumns = parent.{localFields→columns} (parent anchor columns resolved via resolveFieldToColumn, mirroring slice-1 read path in buildManyToManyJunctionArtifacts) Shapes: - some → EXISTS(SELECT 1 FROM target JOIN junction ON WHERE [AND pred]) - none → NOT EXISTS(… [AND pred]) - every → NOT EXISTS(… AND NOT(pred)); vacuously true (AndExpr.true()) when no pred Composite keys are AND-ed across all column pairs via buildPairedColumnExprs. A hasThrough type guard narrows ResolvedModelRelation at the dispatch site, keeping the narrowed type in buildManyToManyExistsExpr explicit without bare casts. Unit tests cover single-key and composite-key junction shapes for all three modes, plus AND-ed predicate variants and the vacuous-true edge case. FK relation filter tests are unaffected. Signed-off-by: Alexey Orlenko's AI Agent --- .../sql-orm-client/src/model-accessor.ts | 102 ++++++++++ .../test/model-accessor.test.ts | 182 +++++++++++++++++- 2 files changed, 283 insertions(+), 1 deletion(-) 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 62c6dc3a10..83180c2acc 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, } from '@prisma-next/sql-relational-core/ast'; @@ -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) @@ -233,6 +241,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, @@ -270,6 +289,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 d2a7e6657b..d367d49cc7 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(); From c7574ae5f2f25d803d3115716e1e08bd547e02ee Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Mon, 1 Jun 2026 21:38:03 +0200 Subject: [PATCH 3/6] test(sql-orm-client): M:N junction filter integration tests (some/none/every) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PGlite integration tests for User.tags M:N filter operators. Tests seed users/tags/junction rows via existing runtime-helpers and assert the exact filtered user set as whole rows (toEqual), covering: - some: users with ≥1 matching tag; multiple users; single match - none: users with no matching tag (including tag-less users) - every: all tags match + vacuous case (no-tags user qualifies) + partial-match exclusion - empty-match edge: some/none/every against a predicate no tag satisfies - implicit selection: one test without .select asserts full default row shape Signed-off-by: Alexey Orlenko's AI Agent --- .../test/sql-orm-client/mn-filter.test.ts | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 test/integration/test/sql-orm-client/mn-filter.test.ts 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, + ); +}); From 68ea77c3acfeea4763d5f0e827f9240aa773a3a7 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Mon, 1 Jun 2026 21:41:35 +0200 Subject: [PATCH 4/6] docs(sql-orm-many-to-many): slice 2 dispatch briefs, trace, learnings (TML-2786) Filter slice orchestrator artifacts (filter-code dispatch took 2 rounds incl. a truncation recovery; integration-tests dispatch one round). Review log gitignored. Signed-off-by: Alexey Orlenko's AI Agent --- projects/sql-orm-many-to-many/learnings.md | 2 + .../dispatches/01-filter-code.md | 45 +++++++++++++++++++ .../dispatches/01-filter-code.r2.md | 39 ++++++++++++++++ .../dispatches/02-integration-tests.md | 42 +++++++++++++++++ projects/sql-orm-many-to-many/trace.jsonl | 13 ++++++ 5 files changed, 141 insertions(+) create mode 100644 projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/01-filter-code.md create mode 100644 projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/01-filter-code.r2.md create mode 100644 projects/sql-orm-many-to-many/slices/02-filter-exists-through-junction/dispatches/02-integration-tests.md 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/trace.jsonl b/projects/sql-orm-many-to-many/trace.jsonl index 2562f02967..6fd8546bea 100644 --- a/projects/sql-orm-many-to-many/trace.jsonl +++ b/projects/sql-orm-many-to-many/trace.jsonl @@ -48,3 +48,16 @@ {"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} From 4c46541781b941d97304c399318409f92a2e8aa3 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Tue, 2 Jun 2026 18:12:54 +0200 Subject: [PATCH 5/6] fix(sql-orm-client): namespace-qualify M:N filter EXISTS after TML-2605 rebase The EXISTS-through-junction subquery built bare table sources, which no longer compiled (main dropped the raw TableSource import) and rendered unqualified SQL. Qualify the target via tableSourceForContract and the junction via the carried through.namespaceId. Signed-off-by: Alexey Orlenko's AI Agent --- .../3-extensions/sql-orm-client/src/model-accessor.ts | 10 ++++++++-- .../sql-orm-client/test/model-accessor.test.ts | 6 +++--- 2 files changed, 11 insertions(+), 5 deletions(-) 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 83180c2acc..05f5a68c7e 100644 --- a/packages/3-extensions/sql-orm-client/src/model-accessor.ts +++ b/packages/3-extensions/sql-orm-client/src/model-accessor.ts @@ -11,6 +11,7 @@ import { JoinAst, ProjectionItem, SelectAst, + TableSource, } from '@prisma-next/sql-relational-core/ast'; import { codecRefForStorageColumn } from '@prisma-next/sql-relational-core/codec-descriptor-registry'; import type { Expression, ScopeField } from '@prisma-next/sql-relational-core/expression'; @@ -341,8 +342,13 @@ function buildManyToManyExistsExpr>( } const firstTargetCol = through.targetColumns[0] ?? 'id'; - const subquery = SelectAst.from(TableSource.named(relatedTableName)) - .withJoins([JoinAst.inner(TableSource.named(junctionTable), junctionJoinOn)]) + const subquery = SelectAst.from(tableSourceForContract(context.contract, relatedTableName)) + .withJoins([ + JoinAst.inner( + TableSource.named(junctionTable, undefined, through.namespaceId), + junctionJoinOn, + ), + ]) .withProjection([ProjectionItem.of('_exists', ColumnRef.of(relatedTableName, firstTargetCol))]) .withWhere(subqueryWhere); 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 d367d49cc7..185bd8ef99 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 @@ -501,10 +501,10 @@ describe('createModelAccessor', () => { const expr = accessor['children']!.some() as ExistsExpr; expect(expr.notExists).toBe(false); - expect(expr.subquery.from).toEqual(TableSource.named('children')); + expect(expr.subquery.from).toEqual(TableSource.named('children', undefined, 'public')); expect(expr.subquery.joins).toEqual([ JoinAst.inner( - TableSource.named('parent_children'), + TableSource.named('parent_children', undefined, 'public'), BinaryExpr.eq( ColumnRef.of('parent_children', 'child_id'), ColumnRef.of('children', 'id'), @@ -631,7 +631,7 @@ describe('createModelAccessor', () => { expect(expr.subquery.joins).toEqual([ JoinAst.inner( - TableSource.named('parent_children'), + TableSource.named('parent_children', undefined, 'public'), AndExpr.of([ BinaryExpr.eq( ColumnRef.of('parent_children', 'tenant_id'), From bd47c000850a7fcc4f47b3f7854680ac5e5b7c7e Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Thu, 4 Jun 2026 17:37:08 +0200 Subject: [PATCH 6/6] test(sql-orm-client): declare junction namespaceId in the shared M:N contract helper resolveThrough now requires the contract through block to carry the junction's namespaceId; without it the relation resolves as non-M:N and the filter EXISTS tests assert the wrong shape. Signed-off-by: Alexey Orlenko's AI Agent --- packages/3-extensions/sql-orm-client/test/helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/3-extensions/sql-orm-client/test/helpers.ts b/packages/3-extensions/sql-orm-client/test/helpers.ts index a8d956053e..7a5131f144 100644 --- a/packages/3-extensions/sql-orm-client/test/helpers.ts +++ b/packages/3-extensions/sql-orm-client/test/helpers.ts @@ -375,6 +375,7 @@ export function buildManyToManyContract(opts: { on: { localFields, targetFields: targetColumns }, through: { table: junctionTable, + namespaceId: 'public', parentColumns, childColumns, targetColumns,