diff --git a/packages/3-extensions/sql-orm-client/src/mutation-executor.ts b/packages/3-extensions/sql-orm-client/src/mutation-executor.ts index 39a574d18c..3ab10631ce 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-executor.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-executor.ts @@ -8,6 +8,7 @@ import { } from '@prisma-next/sql-relational-core/ast'; import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; import type { RuntimeScope } from '@prisma-next/sql-relational-core/types'; +import { blindCast } from '@prisma-next/utils/casts'; import { getColumnToFieldMap, resolveFieldToColumn, @@ -23,6 +24,8 @@ import { import { executeQueryPlan } from './execute-query-plan'; import { and, shorthandToWhereExpr } from './filters'; import { + compileDeleteCount, + compileInsertCount, compileInsertReturning, compileSelect, compileUpdateCount, @@ -44,6 +47,14 @@ import type { } from './types'; import { emptyState } from './types'; +interface JunctionThrough { + readonly table: string; + readonly parentColumns: readonly string[]; + readonly childColumns: readonly string[]; + readonly targetColumns: readonly string[]; + readonly requiredPayloadColumns: readonly string[]; +} + interface RelationDefinition { readonly relationName: string; readonly relatedModelName: string; @@ -51,6 +62,15 @@ interface RelationDefinition { readonly cardinality: RelationCardinalityTag | undefined; readonly localColumns: readonly string[]; readonly targetColumns: readonly string[]; + readonly through: JunctionThrough | undefined; +} + +interface JunctionRelationDefinition extends RelationDefinition { + readonly through: JunctionThrough; +} + +function hasThrough(relation: RelationDefinition): relation is JunctionRelationDefinition { + return relation.through !== undefined; } interface ParsedRelationMutation { @@ -164,7 +184,7 @@ async function createGraph( ): Promise> { const contract = context.contract; const parsed = parseMutationInput(contract, modelName, input); - const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations); + const { parentOwned, childOwned, junctionOwned } = partitionByOwnership(parsed.relationMutations); const scalarData = { ...parsed.scalarData }; @@ -200,6 +220,21 @@ async function createGraph( ); } + for (const relationMutation of junctionOwned) { + if (relationMutation.mutation.kind === 'disconnect') { + throw new Error('disconnect() is only supported in update() nested mutations'); + } + + await applyJunctionOwnedMutation( + scope, + context, + modelName, + parentRow, + relationMutation.relation, + relationMutation.mutation, + ); + } + return parentRow; } @@ -217,7 +252,7 @@ async function updateFirstGraph( } const parsed = parseMutationInput(contract, modelName, input as Record); - const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations); + const { parentOwned, childOwned, junctionOwned } = partitionByOwnership(parsed.relationMutations); const scalarData = { ...parsed.scalarData }; @@ -284,6 +319,17 @@ async function updateFirstGraph( ); } + for (const relationMutation of junctionOwned) { + await applyJunctionOwnedMutation( + scope, + context, + modelName, + parentRow, + relationMutation.relation, + relationMutation.mutation, + ); + } + return parentRow; } @@ -335,21 +381,31 @@ function parseMutationInput( }; } +interface JunctionParsedRelationMutation extends ParsedRelationMutation { + readonly relation: JunctionRelationDefinition; +} + function partitionByOwnership(relationMutations: readonly ParsedRelationMutation[]): { parentOwned: ParsedRelationMutation[]; childOwned: ParsedRelationMutation[]; + junctionOwned: JunctionParsedRelationMutation[]; } { const parentOwned: ParsedRelationMutation[] = []; const childOwned: ParsedRelationMutation[] = []; + const junctionOwned: JunctionParsedRelationMutation[] = []; for (const relationMutation of relationMutations) { - if (relationMutation.relation.cardinality === 'N:1') { - parentOwned.push(relationMutation); + if (hasThrough(relationMutation.relation)) { + junctionOwned.push({ + relation: relationMutation.relation, + mutation: relationMutation.mutation, + }); continue; } - if (relationMutation.relation.cardinality === 'N:M') { - throw new Error('N:M nested mutations are not supported yet'); + if (relationMutation.relation.cardinality === 'N:1') { + parentOwned.push(relationMutation); + continue; } childOwned.push(relationMutation); @@ -358,6 +414,7 @@ function partitionByOwnership(relationMutations: readonly ParsedRelationMutation return { parentOwned, childOwned, + junctionOwned, }; } @@ -527,6 +584,193 @@ async function applyChildOwnedMutation( } } +async function applyJunctionOwnedMutation( + scope: RuntimeScope, + context: ExecutionContext, + parentModelName: string, + parentRow: Record, + relation: JunctionRelationDefinition, + mutation: RelationMutation, string>, +): Promise { + const contract = context.contract; + const through = relation.through; + const parentPkValues = readJunctionParentValues(contract, parentModelName, relation, parentRow); + + if (mutation.kind === 'create' || mutation.kind === 'connect') { + if (through.requiredPayloadColumns.length > 0) { + const cols = through.requiredPayloadColumns.map((c) => `\`${c}\``).join(', '); + throw new Error( + `Cannot \`${mutation.kind}\` on relation \`${relation.relationName}\`: its junction \`${through.table}\` has required column(s) ${cols} the relation API can't populate. Use the \`${relation.relatedModelName}\` model directly or the SQL builder.`, + ); + } + } + + if (mutation.kind === 'create') { + for (const childInput of mutation.data) { + const relatedRow = await insertSingleRow( + scope, + context, + relation.relatedModelName, + blindCast, 'mutation create input is a plain object payload'>( + childInput, + ), + ); + const targetPkValues = readJunctionTargetValues(contract, relation, relatedRow); + await insertJunctionLink(scope, context, through, parentPkValues, targetPkValues); + } + return; + } + + if (mutation.kind === 'connect') { + for (const criterion of mutation.criteria) { + const targetPkValues = await resolveJunctionTargetValues( + scope, + context, + relation, + 'connect', + criterion, + ); + await insertJunctionLink(scope, context, through, parentPkValues, targetPkValues); + } + return; + } + + if (!mutation.criteria || mutation.criteria.length === 0) { + throw new Error( + `disconnect() nested mutation for relation "${relation.relationName}" requires criterion`, + ); + } + + for (const criterion of mutation.criteria) { + const targetPkValues = await resolveJunctionTargetValues( + scope, + context, + relation, + 'disconnect', + criterion, + ); + await deleteJunctionLink(scope, context, through, parentPkValues, targetPkValues); + } +} + +async function resolveJunctionTargetValues( + scope: RuntimeScope, + context: ExecutionContext, + relation: JunctionRelationDefinition, + kind: 'connect' | 'disconnect', + criterion: unknown, +): Promise> { + const relatedRow = await findRowByCriterion( + scope, + context, + relation.relatedModelName, + blindCast, 'connect/disconnect criterion is a plain object'>(criterion), + ); + if (!relatedRow) { + throw new Error( + `${kind}() nested mutation for relation "${relation.relationName}" did not find a matching row`, + ); + } + return readJunctionTargetValues(context.contract, relation, relatedRow); +} + +function readJunctionParentValues( + contract: Contract, + parentModelName: string, + relation: JunctionRelationDefinition, + parentRow: Record, +): Map { + const values = new Map(); + + for (let i = 0; i < relation.through.parentColumns.length; i++) { + const junctionColumn = relation.through.parentColumns[i]; + const parentColumn = relation.localColumns[i]; + if (!junctionColumn || !parentColumn) { + continue; + } + + const parentFieldName = toFieldName(contract, parentModelName, parentColumn); + const parentValue = parentRow[parentFieldName]; + if (parentValue === undefined) { + throw new Error( + `Nested mutation requires parent field "${parentFieldName}" to be present in returned row`, + ); + } + + values.set(junctionColumn, parentValue); + } + + return values; +} + +function readJunctionTargetValues( + contract: Contract, + relation: JunctionRelationDefinition, + relatedRow: Record, +): Map { + const values = new Map(); + + for (let i = 0; i < relation.through.childColumns.length; i++) { + const junctionColumn = relation.through.childColumns[i]; + const targetColumn = relation.through.targetColumns[i]; + if (!junctionColumn || !targetColumn) { + continue; + } + + const targetFieldName = toFieldName(contract, relation.relatedModelName, targetColumn); + const targetValue = relatedRow[targetFieldName]; + if (targetValue === undefined) { + throw new Error( + `Nested mutation requires target field "${targetFieldName}" to be present in returned row`, + ); + } + + values.set(junctionColumn, targetValue); + } + + return values; +} + +async function insertJunctionLink( + scope: RuntimeScope, + context: ExecutionContext, + through: JunctionThrough, + parentPkValues: Map, + targetPkValues: Map, +): Promise { + const junctionRow: Record = {}; + for (const [column, value] of parentPkValues.entries()) { + junctionRow[column] = value; + } + for (const [column, value] of targetPkValues.entries()) { + junctionRow[column] = value; + } + + const compiled = compileInsertCount(context.contract, through.table, [junctionRow]); + await executeQueryPlan>(scope, compiled).toArray(); +} + +async function deleteJunctionLink( + scope: RuntimeScope, + context: ExecutionContext, + through: JunctionThrough, + parentPkValues: Map, + targetPkValues: Map, +): Promise { + const exprs: AnyExpression[] = []; + for (const [column, value] of parentPkValues.entries()) { + exprs.push(BinaryExpr.eq(ColumnRef.of(through.table, column), LiteralExpr.of(value))); + } + for (const [column, value] of targetPkValues.entries()) { + exprs.push(BinaryExpr.eq(ColumnRef.of(through.table, column), LiteralExpr.of(value))); + } + + const first = exprs[0]; + const where = exprs.length === 1 && first !== undefined ? first : and(...exprs); + const compiled = compileDeleteCount(context.contract, through.table, [where]); + await executeQueryPlan>(scope, compiled).toArray(); +} + function readParentColumnValues( contract: Contract, parentModelName: string, @@ -701,6 +945,15 @@ function getRelationDefinitions( targetColumns: relation.on.targetFields.map((f) => resolveFieldToColumn(contract, relation.to, f), ), + through: relation.through + ? { + table: relation.through.table, + parentColumns: relation.through.parentColumns, + childColumns: relation.through.childColumns, + targetColumns: relation.through.targetColumns, + requiredPayloadColumns: relation.through.requiredPayloadColumns, + } + : undefined, })); perContract.set(modelName, definitions); diff --git a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts index d14d318a38..396a906d83 100644 --- a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts +++ b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts @@ -40,9 +40,9 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:e05ccf77cd36bcf34f470cae1638a9bda152e054c982497ee262f1f2d3d021d6'>; + StorageHashBase<'sha256:a42bc0e879425eb4d8f34f690a6446677911d3fdba85743e11cb71e60cda8291'>; export type ExecutionHash = - ExecutionHashBase<'sha256:a108e5f9b4a5af51635ffde3849836fee73cd71cd568e1f2daf236c5768bcb07'>; + ExecutionHashBase<'sha256:8e2e39df5e42d28f74fa7177b910ed570f3c04c4a41b9812c3d13c18b428507a'>; export type ProfileHash = ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; @@ -86,6 +86,7 @@ export type FieldOutputTypes = { readonly userId: CodecTypes['pg/int4@1']['output']; readonly bio: CodecTypes['pg/text@1']['output']; }; + readonly Role: { readonly id: Char<36>; readonly name: CodecTypes['pg/text@1']['output'] }; readonly Tag: { readonly id: Char<36>; readonly name: CodecTypes['pg/text@1']['output'] }; readonly User: { readonly id: CodecTypes['pg/int4@1']['output']; @@ -94,6 +95,11 @@ export type FieldOutputTypes = { readonly invitedById: CodecTypes['pg/int4@1']['output'] | null; readonly address: AddressOutput | null; }; + readonly UserRole: { + readonly userId: CodecTypes['pg/int4@1']['output']; + readonly roleId: CodecTypes['pg/text@1']['output']; + readonly level: CodecTypes['pg/int4@1']['output']; + }; readonly UserTag: { readonly userId: CodecTypes['pg/int4@1']['output']; readonly tagId: CodecTypes['pg/text@1']['output']; @@ -122,6 +128,10 @@ export type FieldInputTypes = { readonly userId: CodecTypes['pg/int4@1']['input']; readonly bio: CodecTypes['pg/text@1']['input']; }; + readonly Role: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly name: CodecTypes['pg/text@1']['input']; + }; readonly Tag: { readonly id: CodecTypes['sql/char@1']['input']; readonly name: CodecTypes['pg/text@1']['input']; @@ -133,6 +143,11 @@ export type FieldInputTypes = { readonly invitedById: CodecTypes['pg/int4@1']['input'] | null; readonly address: AddressInput | null; }; + readonly UserRole: { + readonly userId: CodecTypes['pg/int4@1']['input']; + readonly roleId: CodecTypes['pg/text@1']['input']; + readonly level: CodecTypes['pg/int4@1']['input']; + }; readonly UserTag: { readonly userId: CodecTypes['pg/int4@1']['input']; readonly tagId: CodecTypes['pg/text@1']['input']; @@ -301,6 +316,25 @@ type ContractBase = Omit< }, ]; }; + readonly roles: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly [{ readonly columns: readonly ['name'] }]; + indexes: readonly []; + foreignKeys: readonly []; + }; readonly tags: { columns: { readonly id: { @@ -320,6 +354,29 @@ type ContractBase = Omit< indexes: readonly []; foreignKeys: readonly []; }; + readonly user_roles: { + columns: { + readonly user_id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly role_id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly level: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['user_id', 'role_id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; readonly user_tags: { columns: { readonly user_id: { @@ -541,6 +598,30 @@ type ContractBase = Omit< }; }; }; + readonly Role: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'roles'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + }; + }; + }; readonly Tag: { readonly fields: { readonly id: { @@ -629,6 +710,14 @@ type ContractBase = Omit< readonly targetFields: readonly ['user_id']; }; }; + readonly roles: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Role' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['user_id']; + }; + }; }; readonly storage: { readonly table: 'users'; @@ -641,6 +730,31 @@ type ContractBase = Omit< }; }; }; + readonly UserRole: { + readonly fields: { + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly roleId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly level: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user_roles'; + readonly fields: { + readonly userId: { readonly column: 'user_id' }; + readonly roleId: { readonly column: 'role_id' }; + readonly level: { readonly column: 'level' }; + }; + }; + }; readonly UserTag: { readonly fields: { readonly userId: { @@ -675,6 +789,8 @@ type ContractBase = Omit< readonly articles: { readonly namespace: 'public' & NamespaceId; readonly model: 'Article' }; readonly tags: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; readonly user_tags: { readonly namespace: 'public' & NamespaceId; readonly model: 'UserTag' }; + readonly roles: { readonly namespace: 'public' & NamespaceId; readonly model: 'Role' }; + readonly user_roles: { readonly namespace: 'public' & NamespaceId; readonly model: 'UserRole' }; }; readonly domain: { readonly namespaces: { @@ -832,6 +948,30 @@ type ContractBase = Omit< }; }; }; + readonly Role: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'roles'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + }; + }; + }; readonly Tag: { readonly fields: { readonly id: { @@ -923,6 +1063,14 @@ type ContractBase = Omit< readonly targetFields: readonly ['user_id']; }; }; + readonly roles: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Role' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['user_id']; + }; + }; }; readonly storage: { readonly table: 'users'; @@ -935,6 +1083,31 @@ type ContractBase = Omit< }; }; }; + readonly UserRole: { + readonly fields: { + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly roleId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly level: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user_roles'; + readonly fields: { + readonly userId: { readonly column: 'user_id' }; + readonly roleId: { readonly column: 'role_id' }; + readonly level: { readonly column: 'level' }; + }; + }; + }; readonly UserTag: { readonly fields: { readonly userId: { @@ -1046,6 +1219,10 @@ type ContractBase = Omit< readonly executionHash: ExecutionHash; readonly mutations: { readonly defaults: readonly [ + { + readonly ref: { readonly table: 'roles'; readonly column: 'id' }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, { readonly ref: { readonly table: 'tags'; readonly column: 'id' }; readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; diff --git a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.json b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.json index a3f175cf41..8abb58e49f 100644 --- a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.json +++ b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.json @@ -20,10 +20,18 @@ "model": "Profile", "namespace": "public" }, + "roles": { + "model": "Role", + "namespace": "public" + }, "tags": { "model": "Tag", "namespace": "public" }, + "user_roles": { + "model": "UserRole", + "namespace": "public" + }, "user_tags": { "model": "UserTag", "namespace": "public" @@ -283,6 +291,39 @@ "table": "profiles" } }, + "Role": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "name": { + "column": "name" + } + }, + "table": "roles" + } + }, "Tag": { "fields": { "id": { @@ -415,6 +456,33 @@ "namespace": "public" } }, + "roles": { + "cardinality": "N:M", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "user_id" + ] + }, + "through": { + "childColumns": [ + "role_id" + ], + "parentColumns": [ + "user_id" + ], + "table": "user_roles", + "targetColumns": [ + "id" + ] + }, + "to": { + "model": "Role", + "namespace": "public" + } + }, "tags": { "cardinality": "N:M", "on": { @@ -464,6 +532,46 @@ "table": "users" } }, + "UserRole": { + "fields": { + "level": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "roleId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "level": { + "column": "level" + }, + "roleId": { + "column": "role_id" + }, + "userId": { + "column": "user_id" + } + }, + "table": "user_roles" + } + }, "UserTag": { "fields": { "tagId": { @@ -715,6 +823,37 @@ } ] }, + "roles": { + "columns": { + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "name": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [ + { + "columns": [ + "name" + ] + } + ] + }, "tags": { "columns": { "id": { @@ -746,6 +885,34 @@ } ] }, + "user_roles": { + "columns": { + "level": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "role_id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "user_id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "user_id", + "role_id" + ] + }, + "uniques": [] + }, "user_tags": { "columns": { "tag_id": { @@ -834,12 +1001,22 @@ } } }, - "storageHash": "sha256:e05ccf77cd36bcf34f470cae1638a9bda152e054c982497ee262f1f2d3d021d6" + "storageHash": "sha256:a42bc0e879425eb4d8f34f690a6446677911d3fdba85743e11cb71e60cda8291" }, "execution": { - "executionHash": "sha256:a108e5f9b4a5af51635ffde3849836fee73cd71cd568e1f2daf236c5768bcb07", + "executionHash": "sha256:8e2e39df5e42d28f74fa7177b910ed570f3c04c4a41b9812c3d13c18b428507a", "mutations": { "defaults": [ + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "table": "roles" + } + }, { "onCreate": { "id": "uuidv4", diff --git a/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts index 81dbc8e739..37f9294e9b 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts @@ -13,6 +13,7 @@ import { } from '../src/mutation-executor'; import type { MockRuntime, TestContract } from './helpers'; import { + buildManyToManyContract, createMockRuntime, getTestContext, getTestContract, @@ -392,40 +393,312 @@ describe('mutation-executor', () => { ).rejects.toThrow(/requires data/); }); - it('executeNestedCreateMutation() rejects M:N nested mutations', async () => { - const contract = getTestContract(); + function findJunctionDml( + runtime: MockRuntime, + kind: 'insert' | 'delete', + table: string, + ): { kind: string; table: { name: string }; rows?: unknown; where?: unknown } { + for (const execution of runtime.executions) { + const ast = (execution.plan as { ast?: { kind: string; table?: { name: string } } }).ast; + if (ast && ast.kind === kind && ast.table?.name === table) { + return ast as { kind: string; table: { name: string }; rows?: unknown; where?: unknown }; + } + } + throw new Error(`no ${kind} on "${table}" found in executions`); + } + + function collectLiterals(node: unknown): unknown[] { + if (!node || typeof node !== 'object') { + return []; + } + const expr = node as { + kind?: string; + value?: unknown; + left?: unknown; + right?: unknown; + exprs?: readonly unknown[]; + }; + if (expr.kind === 'literal') { + return [expr.value]; + } + return [ + ...collectLiterals(expr.left), + ...collectLiterals(expr.right), + ...(expr.exprs ?? []).flatMap(collectLiterals), + ]; + } + + it('executeNestedCreateMutation() routes M:N connect through a junction INSERT', async () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_child', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); const runtime = createMockRuntime(); - const withManyToMany = withPatchedDomainModels(contract, (models) => { - const user = models['User'] as { relations: { posts: Record } }; - return { - ...models, - User: { - ...user, - relations: { - ...user.relations, - posts: { - ...user.relations.posts, - cardinality: 'N:M', - }, - }, - }, - }; + runtime.setNextResults([[{ id: 1 }], [{ id: 10 }], []]); + + const created = await executeNestedCreateMutation({ + context: { ...getTestContext(), contract }, + runtime, + modelName: 'Parent', + data: { + id: 1, + children: (children: { connect: (criterion: Record) => unknown }) => + children.connect({ id: 10 }), + } as never, + }); + + expect(created).toEqual({ id: 1 }); + const insert = findJunctionDml(runtime, 'insert', 'parent_child'); + const junctionRow = (insert.rows as ReadonlyArray>)[0]!; + expect(Object.keys(junctionRow).sort()).toEqual(['child_id', 'parent_id']); + expect((runtime.executions.at(-1)!.plan as { params: readonly unknown[] }).params).toEqual([ + 1, 10, + ]); + }); + + it('executeNestedCreateMutation() routes M:N create through target INSERT then junction INSERT', async () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_child', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); + const runtime = createMockRuntime(); + runtime.setNextResults([[{ id: 1 }], [{ id: 20 }], []]); + + const created = await executeNestedCreateMutation({ + context: { ...getTestContext(), contract }, + runtime, + modelName: 'Parent', + data: { + id: 1, + children: (children: { create: (rows: readonly Record[]) => unknown }) => + children.create([{ id: 20 }]), + } as never, + }); + + expect(created).toEqual({ id: 1 }); + const targetInsert = findJunctionDml(runtime, 'insert', 'children'); + expect(targetInsert.kind).toBe('insert'); + const link = ( + findJunctionDml(runtime, 'insert', 'parent_child').rows as ReadonlyArray< + Record + > + )[0]!; + expect(Object.keys(link).sort()).toEqual(['child_id', 'parent_id']); + expect((runtime.executions.at(-1)!.plan as { params: readonly unknown[] }).params).toEqual([ + 1, 20, + ]); + }); + + it('executeNestedCreateMutation() AND-s composite keys in the junction INSERT', async () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_child', + parentColumns: ['tenant_id', 'parent_id'], + childColumns: ['tenant_id', 'child_id'], + targetColumns: ['tenant_id', 'id'], + localFields: ['tenant_id', 'id'], + }); + const runtime = createMockRuntime(); + runtime.setNextResults([[{ tenant_id: 7, id: 1 }], [{ tenant_id: 7, id: 10 }], []]); + + await executeNestedCreateMutation({ + context: { ...getTestContext(), contract }, + runtime, + modelName: 'Parent', + data: { + tenant_id: 7, + id: 1, + children: (children: { connect: (criterion: Record) => unknown }) => + children.connect({ id: 10 }), + } as never, + }); + + const link = ( + findJunctionDml(runtime, 'insert', 'parent_child').rows as ReadonlyArray< + Record + > + )[0]!; + expect(Object.keys(link).sort()).toEqual(['child_id', 'parent_id', 'tenant_id']); + }); + + it('executeNestedUpdateMutation() routes M:N connect through a junction INSERT', async () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_child', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); + const runtime = createMockRuntime(); + runtime.setNextResults([[{ id: 1 }], [{ id: 10 }], []]); + + await executeNestedUpdateMutation({ + context: { ...getTestContext(), contract }, + runtime, + modelName: 'Parent', + filters: [BinaryExpr.eq(ColumnRef.of('parents', 'id'), LiteralExpr.of(1))], + data: { + children: (children: { connect: (criterion: Record) => unknown }) => + children.connect({ id: 10 }), + } as never, + }); + + const insert = findJunctionDml(runtime, 'insert', 'parent_child'); + expect(insert.kind).toBe('insert'); + expect((runtime.executions.at(-1)!.plan as { params: readonly unknown[] }).params).toEqual([ + 1, 10, + ]); + }); + + it('executeNestedUpdateMutation() routes M:N disconnect through a junction DELETE', async () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_child', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], + }); + const runtime = createMockRuntime(); + runtime.setNextResults([[{ id: 1 }], [{ id: 10 }], []]); + + await executeNestedUpdateMutation({ + context: { ...getTestContext(), contract }, + runtime, + modelName: 'Parent', + filters: [BinaryExpr.eq(ColumnRef.of('parents', 'id'), LiteralExpr.of(1))], + data: { + children: (children: { + disconnect: (criteria: readonly Record[]) => unknown; + }) => children.disconnect([{ id: 10 }]), + } as never, + }); + + const del = findJunctionDml(runtime, 'delete', 'parent_child'); + expect(del.kind).toBe('delete'); + expect(collectLiterals(del.where).sort()).toEqual([1, 10]); + }); + + it('executeNestedCreateMutation() rejects M:N disconnect (update-only)', async () => { + const contract = buildManyToManyContract({ + junctionTable: 'parent_child', + parentColumns: ['parent_id'], + childColumns: ['child_id'], + targetColumns: ['id'], }); + const runtime = createMockRuntime(); + runtime.setNextResults([[{ id: 1 }]]); await expect( executeNestedCreateMutation({ - context: { ...getTestContext(), contract: withManyToMany }, + context: { ...getTestContext(), contract }, + runtime, + modelName: 'Parent', + data: { + id: 1, + children: (children: { + disconnect: (criteria: readonly Record[]) => unknown; + }) => children.disconnect([{ id: 10 }]), + } as never, + }), + ).rejects.toThrow(/disconnect\(\) is only supported in update\(\) nested mutations/); + }); + + it('executeNestedCreateMutation() rejects M:N create when junction has required payload columns', async () => { + const contract = getTestContract(); + const runtime = createMockRuntime(); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); + + await expect( + executeNestedCreateMutation({ + context: { ...getTestContext(), contract }, runtime, modelName: 'User', data: { id: 1, name: 'Alice', email: 'alice@example.com', - posts: (posts: { connect: (criterion: Record) => unknown }) => - posts.connect({ id: 10 }), + roles: (roles: { create: (rows: readonly Record[]) => unknown }) => + roles.create([{ id: 'admin' }]), + } as never, + }), + ).rejects.toThrow( + /Cannot `create` on relation `roles`: its junction `user_roles` has required column\(s\) `level`/, + ); + }); + + it('executeNestedCreateMutation() rejects M:N connect when junction has required payload columns', async () => { + const contract = getTestContract(); + const runtime = createMockRuntime(); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]); + + await expect( + executeNestedCreateMutation({ + context: { ...getTestContext(), contract }, + runtime, + modelName: 'User', + data: { + id: 1, + name: 'Alice', + email: 'alice@example.com', + roles: (roles: { connect: (criterion: Record) => unknown }) => + roles.connect({ id: 'admin' }), } as never, }), - ).rejects.toThrow(/N:M nested mutations are not supported yet/); + ).rejects.toThrow( + /Cannot `connect` on relation `roles`: its junction `user_roles` has required column\(s\) `level`/, + ); + }); + + it('executeNestedUpdateMutation() allows disconnect on junction with required payload columns', async () => { + const contract = getTestContract(); + const runtime = createMockRuntime(); + runtime.setNextResults([ + [{ id: 1, name: 'Alice', email: 'alice@example.com' }], + [{ id: 'admin' }], + [], + ]); + + await executeNestedUpdateMutation({ + context: { ...getTestContext(), contract }, + runtime, + modelName: 'User', + filters: [userIdFilter], + data: { + roles: (roles: { disconnect: (criteria: readonly Record[]) => unknown }) => + roles.disconnect([{ id: 'admin' }]), + } as never, + }); + + const del = findJunctionDml(runtime, 'delete', 'user_roles'); + expect(del.kind).toBe('delete'); + }); + + it('executeNestedCreateMutation() allows M:N create on pure junction (no required payload)', async () => { + const contract = getTestContract(); + const runtime = createMockRuntime(); + runtime.setNextResults([ + [{ id: 1, name: 'Alice', email: 'alice@example.com' }], + [{ id: 'ts' }], + [], + ]); + + const created = await executeNestedCreateMutation({ + context: { ...getTestContext(), contract }, + runtime, + modelName: 'User', + data: { + id: 1, + name: 'Alice', + email: 'alice@example.com', + tags: (tags: { create: (rows: readonly Record[]) => unknown }) => + tags.create([{ id: 'ts' }]), + } as never, + }); + + expect(created).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' }); + const insert = findJunctionDml(runtime, 'insert', 'user_tags'); + expect(insert.kind).toBe('insert'); }); it('executeNestedCreateMutation() supports parent-owned nested create() payloads', async () => { diff --git a/projects/sql-orm-many-to-many/learnings.md b/projects/sql-orm-many-to-many/learnings.md index c803d04e39..274314d0ec 100644 --- a/projects/sql-orm-many-to-many/learnings.md +++ b/projects/sql-orm-many-to-many/learnings.md @@ -10,6 +10,8 @@ This harness exposes no `SendMessage`/resume for spawned subagents — the `Agen `pnpm fixtures:check` fails at `fixtures:emit` in this sandbox (CLI not on PATH / "Failed to load config" for sql-builder + sql-orm-client emit scripts) — pre-existing, not introduced (matches the TML-2729 gotcha). Additivity is verified instead via a direct golden git-diff (`git diff -- ':(glob)**/contract.json' …`); CI runs the real gate. Don't treat the local `fixtures:check` red as a dispatch failure. +**Correction (slice 3):** the canonical CLI emit **does** work in this sandbox — run it **from the repo root**: `node packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/integration/test/sql-orm-client/fixtures/prisma-next.config.ts`, then `pnpm --filter @prisma-next/sql-orm-client emit` (package-local copy + pgvector strip). The earlier "config-load failure" was from running with the wrong cwd (`test/integration`). **Prefer the canonical emit from root over a `tsx` bypass** — no golden-stability risk. + ## PGlite/WASM JIT flakiness on broad integration runs Running the whole sql-orm-client integration suite at once (`cd test/integration && pnpm test test/sql-orm-client/`) can crash with V8 `jit_page.has_value()` (WASM JIT) failures — **pre-existing PGlite/Node env flakiness**, reproduces on the parent branch, not introduced by M:N work. Targeted reruns (per-file, or the same suite again) pass cleanly. Verify integration blast radius with targeted per-file runs; don't trust a single broad-run red. diff --git a/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/01-write-path.md b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/01-write-path.md new file mode 100644 index 0000000000..4e12f493ec --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/01-write-path.md @@ -0,0 +1,43 @@ +# Brief: S3-D1 — runtime junction write path + +## Task + +Lift the M:N nested-mutation guard and route nested writes through the junction. Today `partitionByOwnership` (`packages/3-extensions/sql-orm-client/src/mutation-executor.ts:~351`) throws `'N:M nested mutations are not supported yet'`. Replace that with a third bucket — **`junctionOwned`** (relations carrying `through`) — and execute junction mutations in the `create()` and `update()` graph flows **after the parent row exists** (parent PK known): + +- **`connect({criteria})`** → resolve the target row(s) by criteria, then `INSERT INTO junction (parentColumns…, childColumns…) VALUES (parentPk…, targetPk…)` per resolved target. +- **`disconnect({criteria})`** → `DELETE FROM junction WHERE parentColumns = parentPk AND childColumns = targetPk`. (Keep `disconnect` gated to the `update()` flow — the existing rule.) +- **`create(data)`** → insert the target row, then INSERT the junction link. (For THIS dispatch, the junction is the pure `User↔Tag` one — no required payload; the required-payload guard is D3. Don't build the guard here.) + +Composite keys: INSERT/DELETE across all `parentColumns`/`childColumns` pairs. Use slice 0's `ResolvedRelation.through`. + +**Flip the rejection unit test** (`mutation-executor.test.ts`, currently `.rejects.toThrow(/N:M nested mutations are not supported yet/)`) to a **positive** assertion that the M:N nested mutation now produces the expected junction write. Add unit tests for connect/disconnect/create junction DML (both flows). + +## Scope + +**In:** `partitionByOwnership` + the `junctionOwned` execution branch in the `create()`/`update()` graph flows (`mutation-executor.ts`); composite-key junction INSERT/DELETE; flip + extend the rejection unit test. + +**Out:** the required-payload guard (D3); the required-payload fixture (D2); integration tests (D4); `set`/`connectOrCreate`/nested-`update` kinds (TML-2781). Don't regress FK (parent/child-owned) nested writes. + +## Completed when + +- [ ] `connect`/`disconnect`/`create` over the pure M:N relation route to junction INSERT/DELETE (create = target-insert + link), under both `create()` and `update()`; the `'not supported yet'` guard is gone. +- [ ] The rejection unit test is flipped to a positive assertion; unit tests cover connect/disconnect/create junction DML (composite-key AND-ed). +- [ ] FK nested writes unchanged (existing mutation-executor tests pass). +- [ ] Gate: `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green. + +## Standing instruction + +Stay focused on the junction write routing. Mirror the existing parent/child-owned flow structure (ordering relative to the parent insert). No bare `as` casts (use `castAs`/`blindCast` or a type predicate — siblings were bounced for bare casts; a `hasThrough` predicate already exists in `model-accessor.ts` if useful). Implement → unit-test → gate; don't over-explore (sibling write/judgment dispatches truncated from over-exploration). + +## References + +- Slice spec: `projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md`. +- `mutation-executor.ts`: `partitionByOwnership` (~338), the `create()` graph flow (~159-200) + `update()` flow (~206-290) — the parent/child-owned execution to mirror. +- Slice 0 `ResolvedRelation.through`; slice 2's `hasThrough` type predicate (`model-accessor.ts`). + +## Operational metadata + +- **Model tier:** **opus** — complex routing across two graph flows + composite keys; the slice's main runtime judgment. +- **Branch:** `tml-2787-slice-3-write`. Explicit staging + `-s` sign-off. **Do not push.** +- **Time-box:** ~90 min. Commit when the gate is green even if you'd like to polish — bank the work. +- **Halt + surface to me:** if junction writes can't be ordered correctly within the existing graph-flow structure (parent PK not available where needed); if completing the routing requires touching the required-payload guard (that's D3) or an out-of-scope kind. diff --git a/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/02-required-payload-fixture.md b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/02-required-payload-fixture.md new file mode 100644 index 0000000000..832d76081f --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/02-required-payload-fixture.md @@ -0,0 +1,40 @@ +# Brief: S3-D2 — required-payload-junction fixture + +## Task + +Add a second M:N relation to the integration fixture whose junction has a **required non-FK payload column**, so D3 can test the `.create` disable. Add to the fixture **source** (`test/integration/test/sql-orm-client/fixtures/contract.ts`): + +- A `Role` model (`id`, `name`) — mirror the existing `Tag` shape. +- A `UserRole` junction with `userId`, `roleId`, **and a required non-FK column** — e.g. `level` (`int`/`text`, **NOT NULL, no default**) — composite PK `(userId, roleId)`, table `user_roles`. +- `User.roles = rel.manyToMany(() => Role, { through: () => UserRole, from: 'userId', to: 'roleId' })` (the `User.roles` direction only — the reverse is unnecessary, consistent with the project's one-directional convention). + +Re-emit `contract.json` + `contract.d.ts`. After emit, `resolveModelRelations` must resolve `User.roles`'s `through.requiredPayloadColumns` to **`['level']`** (the NOT-NULL no-default non-FK column) — this is what D3's disable keys on. + +## Scope + +**In:** the fixture source (`Role` + `UserRole` w/ required `level` + `User.roles`); the re-emitted `contract.json`/`contract.d.ts`. + +**Out:** the disable logic (D3); the write path (D1, done); integration tests (D4); the existing `User.tags` relation. Don't hand-edit generated files except as the emitter produces them. + +## Completed when + +- [ ] The fixture declares `User.roles` M:N via `UserRole(user_id, role_id, level NOT NULL no-default)`; emits `cardinality:'N:M'` + populated `through`. +- [ ] For `User.roles`, `resolveModelRelations(...).through.requiredPayloadColumns` resolves to `['level']` (verify with a tiny scratch assertion or by inspecting the junction storage in the emitted `contract.json` — `level` is NOT NULL, no default, not a FK col). +- [ ] Re-emitted `contract.json`/`.d.ts` committed; additive (existing models + `User.tags` unchanged). + +## Standing instruction + +Add exactly the `Role`/`UserRole`/`User.roles` shapes with one required payload column (`level`). No reverse relation, no extra columns beyond the required one. Same emit approach as slice 1's fixture dispatch. + +## References + +- Slice spec: `projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md`. +- Slice 1 fixture dispatch (commit `fcecac5b3`) added `User.tags`/`UserTag` + re-emitted the same way — mirror it (incl. the `tsx`-bypass emit; CLI `contract emit` fails on the known config-load env issue). +- Slice 0 `requiredPayloadColumns` derivation (NOT NULL ∧ no default ∧ not FK) in `collection-contract.ts`. + +## Operational metadata + +- **Model tier:** sonnet — schema authoring + re-emit (mechanical, mirrors slice 1). +- **Branch:** `tml-2787-slice-3-write`. Explicit staging + `-s` sign-off. **Do not push.** Commit the re-emit (don't leave uncommitted). +- **Time-box:** ~50 min. +- **Halt + known-env:** local `fixtures:emit`/CLI fails on the pre-existing config-load issue — emit via the same `tsx`-bypass slice 1 used; verify the generated `contract.json` by inspection + that `requiredPayloadColumns` resolves to `['level']`; note it. If re-emit is genuinely impossible in-sandbox, surface to me (don't hand-fabricate). Halt if adding the relation forces touching the disable logic (D3) or trips a type regression in existing tests (describe it). diff --git a/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/03-runtime-disable.md b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/03-runtime-disable.md new file mode 100644 index 0000000000..7953dfac90 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/03-runtime-disable.md @@ -0,0 +1,42 @@ +# Brief: S3-D3 — runtime `.create` disable on required-payload junctions (runtime half only) + +> **Scope note (orchestrator decision, unattended):** the **type-level** disable is **deferred** — it requires the contract `.d.ts` type emitter to carry `through` (it currently doesn't), which is a contract-surface decision for the operator (see `wip/unattended-decisions.md` #8). This dispatch does the **runtime** disable only. Do **not** attempt the type-level/conditional-type disable; do **not** change the contract `.d.ts` emitter. + +## Task + +In the `junctionOwned` `create` branch added in S3-D1 (`mutation-executor.ts`, `applyJunctionOwnedMutation` / the create path), guard against nested `.create` when the junction has required payload columns. The resolved relation's `through.requiredPayloadColumns` (slice 0, runtime) lists them. When a nested `create` targets an M:N relation whose `requiredPayloadColumns` is non-empty: + +- **Throw a clear, actionable error** naming the relation + the offending column(s), and pointing the user to the junction model's own relations / the SQL query builder (the supported path for payload-bearing junctions). E.g. *"Cannot nest `create` on relation `roles`: its junction `user_roles` has required column(s) `level` the relation API can't populate. Use the `UserRole` model directly or the SQL builder."* +- **`connect` and `disconnect` are unaffected** — they only touch the FK pair, never the payload columns; they must still work on a required-payload junction. + +Write a **runtime** test: nested `.create` on `User.roles` (the required-payload junction from S3-D2) throws the guard error; `connect`/`disconnect` on `User.roles` succeed (compile the expected junction DML). The pure `User.tags` junction's nested `create` is unaffected (still works). + +## Scope + +**In:** the runtime required-payload guard in the junction `create` branch (`mutation-executor.ts`); a unit/runtime test for the throw + connect/disconnect-still-work. + +**Out:** the **type-level** disable (deferred — operator decision; do not touch `.d.ts` emission or add conditional types); the write path (D1, done); integration tests (D4); other kinds. + +## Completed when + +- [ ] Nested `.create` on a required-payload junction (`User.roles`) throws a clear error naming the relation + required column(s) + the recommended alternative; uses runtime `requiredPayloadColumns`. +- [ ] `connect`/`disconnect` on the required-payload junction still produce correct junction DML (unaffected by the guard). +- [ ] Nested `.create` on the pure junction (`User.tags`, no required payload) is unaffected. +- [ ] Gate: `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green. + +## Standing instruction + +Runtime guard only. Do NOT attempt the type-level disable (it's blocked on an operator contract decision — see the scope note). No bare `as` casts. + +## References + +- Slice spec: `projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md` (note the type-level disable is deferred per decision #8). +- S3-D1 junction write path (`applyJunctionOwnedMutation`, commit `74a778816`); slice 0 `through.requiredPayloadColumns` (`collection-contract.ts`). +- S3-D2 fixture: `User.roles` via `UserRole` w/ required `level` (commit `926bdc849`). + +## Operational metadata + +- **Model tier:** sonnet — bounded runtime guard + test. +- **Branch:** `tml-2787-slice-3-write`. Explicit staging + `-s` sign-off. **Do not push.** Commit when green. +- **Time-box:** ~40 min. +- **Halt + surface to me:** if the runtime guard can't access `requiredPayloadColumns` at the create branch (it should — `RelationDefinition.through` carries it after D1); if anything pulls you toward the type-level disable. diff --git a/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/04-integration-tests.md b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/04-integration-tests.md new file mode 100644 index 0000000000..6d46ea07c0 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/04-integration-tests.md @@ -0,0 +1,43 @@ +# Brief: S3-D4 — M:N nested-write integration tests (operator standard) + +## Task + +Prove M:N nested writes work end-to-end against the database, following the project's **integration-test standard**. D1 added the junction write path; D2 added the `User.roles` required-payload fixture; D3 added the runtime `.create` disable. Add integration tests under `test/integration/test/sql-orm-client/` (PGlite, `withCollectionRuntime`). Reuse slice 1's `seedTags`/`seedUserTags` + add `Role`/`UserRole` seeds as needed. + +**Cases (all required):** +- **`connect`** — `db.orm.User.update({ ... tags: (t) => t.connect({ id }) })`; read back via `include('tags')` → the tag is linked. Also exercise `connect` in the **`create()`** parent flow. +- **`disconnect`** — link then `disconnect`; read back → gone. (`update()` flow — disconnect is update-only.) +- **`create`** (pure junction `User.tags`) — nested `create` inserts the target Tag + the junction link; read back → present. +- **Runtime disable** — nested `create` on `User.roles` (required-payload junction) **throws** the D3 guard error (`expect(...).rejects.toThrow(/required column.*level/)` or similar); `connect`/`disconnect` on `User.roles` **succeed** + read back. +- Cover **both `create()` and `update()`** parent flows for connect/create. + +**Standard (all three):** (1) whole-row `toEqual` on the readback (via `include('tags')`); (2) explicit `.select(...)` in most tests; (3) **≥1** implicit/default-selection readback. + +## Scope + +**In:** new integration test file under `test/integration/test/sql-orm-client/`; `Role`/`UserRole` seed helpers if needed (extend `runtime-helpers.ts`). + +**Out:** production changes (D1/D3 own the write path + guard — if a test reveals a write bug, surface it, don't patch production here); the **type-level** disable (deferred — only the **runtime** throw is testable now). Don't modify the fixture contract (D2 owns it). + +## Completed when + +- [ ] Integration tests pass on PGlite: connect (both flows) / disconnect / create (pure junction) with whole-row readback via `include('tags')`; the runtime `.create` disable on `User.roles` throws; connect/disconnect on `User.roles` succeed. +- [ ] Most tests explicit `.select`; **≥1** implicit/default-selection readback. +- [ ] Gate: `cd test/integration && pnpm test test/sql-orm-client/` green. + +## Standing instruction + +Match the existing integration corpus. The type-level disable is NOT testable here (deferred) — only assert the **runtime** throw for required-payload `create`. If a write returns the wrong state on readback, **surface it to me** (a D1/D3 bug — must-fix), don't patch the test around it. + +## References + +- Slice spec: `projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md` (note: type-level disable deferred per `wip/unattended-decisions.md` #8). +- Slice 1's `mn-include.test.ts` (readback-via-include pattern) + `runtime-helpers.ts` seeds; slice 2's `mn-filter.test.ts`. +- D1 write path (`74a778816`), D3 runtime guard (`3bccd80b3`), D2 fixture (`926bdc849`). + +## Operational metadata + +- **Model tier:** sonnet. +- **Branch:** `tml-2787-slice-3-write`. Explicit staging + `-s` sign-off. **Do not push.** Commit when green. +- **Time-box:** ~75 min — core connect/disconnect/create readback first, then both-flows + the runtime-disable + implicit-selection; don't over-explore. +- **Halt + surface to me:** if the harness can't run in-sandbox (PGlite spin-up failure unrelated to your tests — describe it, don't fake green); if a nested write produces the wrong readback state (D1/D3 bug). diff --git a/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/04-integration-tests.r2.md b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/04-integration-tests.r2.md new file mode 100644 index 0000000000..69f2a8ab87 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/dispatches/04-integration-tests.r2.md @@ -0,0 +1,38 @@ +# Brief: S3-D4 R2 — design correction (guard `connect` too) + finish write integration tests + +## Situation + +The R1 implementer ran out of budget but **correctly surfaced a real bug**: nested **`connect`** on a required-payload junction (`User.roles`, where `user_roles.level` is `NOT NULL` no-default) **fails with a DB not-null violation** — connect INSERTs a `(user_id, role_id)` junction row, leaving `level` unset. It was about to `it.skip` the test; **don't skip it**. There is **uncommitted WIP** (`git status`): `test/integration/test/sql-orm-client/mn-nested-write.test.ts` (the new tests, one `it.skip`'d) + `runtime-helpers.ts` (Role/UserRole seeds — keep these). + +**Orchestrator design correction (decision #9):** `connect` AND `create` both write a junction row the sugar can't complete, so **both are disabled** on required-payload junctions; **`disconnect` (DELETE) stays allowed**. (The original spec wrongly said connect was safe.) + +## Task + +1. **Production fix** — extend the runtime guard in `mutation-executor.ts` (`applyJunctionOwnedMutation`, the guard S3-D3 added to the `create` branch) to **also fire on the `connect` branch** when `through.requiredPayloadColumns` is non-empty. Same clean, actionable error (adjust wording so it fits both `connect` and `create`, e.g. *"Cannot `connect`/`create` on relation `roles`: its junction `user_roles` has required column(s) `level` …; use the `Role` model directly or the SQL builder."*). **`disconnect` stays allowed.** +2. **Flip the D3 unit test** — in `mutation-executor.test.ts`, the test that asserts *connect on `User.roles` succeeds* must become *connect on `User.roles` **rejects*** (asserts the guard throw). Keep the disconnect-succeeds and create-rejects tests. +3. **Finish the integration tests** (`mn-nested-write.test.ts`) — **remove the `it.skip`**; assert `connect` on `User.roles` **throws** the guard (`.rejects.toThrow(/required column.*\`level\`/)` style). Keep: connect/create on the pure `User.tags` junction work (readback via `include('tags')`), both `create()`+`update()` flows; `disconnect` on `User.roles` succeeds; whole-row `toEqual`; explicit `.select` in most + ≥1 implicit. +4. **Commit** the whole thing (production guard extension + unit-test flip + integration tests) as one coherent commit. + +## Completed when + +- [ ] Runtime guard rejects **both** `connect` and `create` on a required-payload junction (clear message); `disconnect` still works. +- [ ] `mutation-executor.test.ts`: connect-on-`User.roles` now asserts rejection; create-rejects + disconnect-succeeds intact. +- [ ] `mn-nested-write.test.ts`: no `it.skip`; connect-on-`User.roles` asserts throw; pure-junction connect/create/disconnect readback pass; both flows; standard (whole-row, explicit-most, ≥1 implicit). +- [ ] Gates: `pnpm --filter @prisma-next/sql-orm-client typecheck` + `test` green; `cd test/integration && pnpm test test/sql-orm-client/mn-nested-write.test.ts` green. + +## Standing instruction + +Make the production fix (guard connect too) + finish the tests with **no skips**. The **type-level** disable stays deferred (decision #8) — runtime only. No bare `as` casts. + +## References + +- Decision #9 (`wip/unattended-decisions.md`) + corrected slice-3 spec. +- S3-D3 guard (`mutation-executor.ts`, commit `3bccd80b3`) — extend it to `connect`. +- Slice 1 `mn-include.test.ts` readback pattern; the WIP `mn-nested-write.test.ts`. + +## Operational metadata + +- **Model tier:** sonnet — small production guard extension + test work. +- **Branch:** `tml-2787-slice-3-write` (WIP on it). Explicit staging + `-s` sign-off. **Do not push.** Commit when green. +- **Time-box:** ~60 min — production guard + unit-test flip first (fast), then finish/unskip the integration tests. +- **Halt + surface to me:** if disabling `connect` turns out to break a legitimate pure-junction connect path (it shouldn't — the guard keys on `requiredPayloadColumns` being non-empty, which is empty for `User.tags`); if a pure-junction write still returns the wrong readback state. diff --git a/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/plan.md b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/plan.md new file mode 100644 index 0000000000..7098704911 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/plan.md @@ -0,0 +1,38 @@ +# Slice 3: nested writes through the junction — Dispatch plan + +**Spec:** `projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md` +**Linear:** [TML-2787](https://linear.app/prisma-company/issue/TML-2787) + +Four dispatches. Runtime write path first (pure junction), then the required-payload fixture, then the type+runtime disable (the risk dispatch — operator-mandated in-slice), then integration tests. The two judgment-heavy dispatches (D1 routing, D3 conditional types) go to a higher model tier given the slice-1/2 truncation pattern + the type-level complexity. + +### Dispatch 1: runtime junction write path + +- **Outcome:** `connect`/`disconnect`/`create` over the pure M:N relation route to junction INSERT/DELETE (create = target-insert + link), under both `create()` and `update()`; the `partitionByOwnership` `'N:M not supported'` guard is removed and replaced with a `junctionOwned` bucket; the rejection unit test is flipped to a positive assertion. Unit-tested. +- **Builds on:** slice 0's `ResolvedRelation.through`; the existing parent/child ownership flows in `mutation-executor.ts`. +- **Hands to:** a working M:N write path (pure junction) — what D4 verifies on the DB and D3 layers the guard onto. +- **Focus:** `partitionByOwnership` + the junction-owned branch in the `create()`/`update()` graph flows (`mutation-executor.ts`); composite-key INSERT/DELETE; flip the rejection test. **Model: opus.** + +### Dispatch 2: required-payload-junction fixture + +- **Outcome:** the integration fixture gains a second M:N relation whose junction has a **required non-FK column** — e.g. `User ↔ Role` via `UserRole(user_id, role_id, level NOT NULL)` — re-emitted; `requiredPayloadColumns` resolves to `['level']` for it. +- **Builds on:** slice 0's `through` + `requiredPayloadColumns` derivation. +- **Hands to:** a required-payload junction — the fixture D3's disable + D4's disable test need. +- **Focus:** fixture source + re-emit (same `tsx`-bypass + golden-diff verification as slice 1's fixture dispatch; CI `fixtures:check` is the real gate). **Model: sonnet.** + +### Dispatch 3: type-level + runtime `.create` disable on required-payload junctions + +- **Outcome:** nested `.create` through an M:N relation whose junction has required payload columns is rejected **at the type level** (a negative type test proves `.create` input is `never` / unavailable) **and at runtime** (a clear error naming the columns + pointing to the junction model / SQL builder); `connect`/`disconnect` on that relation still work. +- **Builds on:** D1's junction write path + D2's required-payload fixture. +- **Hands to:** the safety rail — the slice's hard DoD item. +- **Focus:** the runtime guard in the junction-owned `create` branch (uses `requiredPayloadColumns`); the type-level disable on the relation-mutator `create` input. **Risk:** the `.d.ts` `through` type may not carry required-payload info — derive from junction field types, or **halt + surface** (possible slice-0 contract extension = operator decision). **Model: opus.** + +### Dispatch 4: write integration tests (operator standard) + +- **Outcome:** integration tests (PGlite) prove connect/disconnect/create on the pure junction (readback via `include('tags')`), under both flows, AND the runtime disable on the required-payload junction — whole-row `toEqual`, explicit `.select` in most, ≥1 implicit. +- **Builds on:** D1 (write path) + D2 (fixtures) + D3 (disable). +- **Hands to:** the slice-DoD-satisfying write coverage. +- **Focus:** new integration test file; reuse slice 1's seed helpers, add `Role`/`UserRole` seeds. Run via `cd test/integration && pnpm test test/sql-orm-client/`. **Model: sonnet.** + +## Handoff completeness + +Slice-DoD reachable: junction writes both flows + rejection-test flip (D1 + D4) · required-payload disable types+runtime (D3, fixture from D2) · standard integration coverage (D4). D3's type-disable is the operator's non-negotiable item; its feasibility risk is pre-named with a halt. diff --git a/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md new file mode 100644 index 0000000000..0be8029127 --- /dev/null +++ b/projects/sql-orm-many-to-many/slices/03-nested-write-through-junction/spec.md @@ -0,0 +1,57 @@ +# Slice 3: nested writes (connect/disconnect/create) through the junction + +_Parent project: `projects/sql-orm-many-to-many/`. Outcome: nested `connect`/`disconnect`/`create` over an M:N relation become junction-table writes; nested `.create` on a required-payload junction is disabled at types **and** runtime._ + +## At a glance + +`db.orm.User.update({ tags: (t) => t.connect({ id }) })` / `.disconnect(...)` / nested `create` must route to the `UserTag` junction (INSERT / DELETE / insert-target-then-link), under both `create()` and `update()`. Today `partitionByOwnership` (`mutation-executor.ts:351`) throws `'N:M nested mutations are not supported yet'`. This slice lifts that guard and adds a **junction-owned** write path. Separately, when a junction carries **required non-FK payload columns** (which the M:N sugar can't populate), nested `.create` through the sugar is **disabled at the type level and at runtime**, pointing users to the junction model's own relations / the SQL builder (per the project non-goals). + +## Chosen design + +**Runtime — a third ownership bucket.** `partitionByOwnership` gains `junctionOwned` (relations carrying `through`) alongside `parentOwned`/`childOwned`. The `create()` and `update()` graph flows execute junction mutations **after** the parent row exists (parent PK known): +- **`connect({criteria})`** → resolve target row(s) by criteria, `INSERT INTO junction (parentCols, childCols) VALUES (parentPk, targetPk)` per pair. +- **`disconnect({criteria})`** → `DELETE FROM junction WHERE parentCols = parentPk AND childCols = targetPk`. +- **`create(data)`** → insert the target row, then INSERT the junction link. (Only when the junction has no required payload — see the guard.) +- `disconnect` stays gated to `update()` (existing rule); `connect`/`create` work in both flows. The currently-passing **rejection unit test flips to a positive assertion**. + +**Required-payload guard — types + runtime.** When the junction has required non-FK columns (NOT NULL, no default, not a FK), the M:N sugar cannot supply them on any operation that **writes a junction row** — that's **both `create` and `connect`** (each INSERTs a `(parent, child)` junction row, leaving the required column unset → a DB NOT-NULL violation). `disconnect` (a DELETE) is unaffected. So both `create` and `connect` are disabled: +- **Runtime:** the junction-owned `create` **and `connect`** branches throw a clear error naming the offending columns + pointing to the junction model / SQL builder. Uses slice 0's `requiredPayloadColumns` (already on `ResolvedRelation`). `disconnect` stays allowed. _(Correction during execution — the original spec wrongly assumed `connect` was FK-pair-only safe; see `wip/unattended-decisions.md` #9.)_ +- **Type level (operator-mandated, in-slice):** the relation-mutator's `create` input resolves to `never` (or `create` is omitted) for an M:N relation whose junction has required payload columns. **Open risk** (see below): this requires the *type* level to know the junction's required-payload columns; slice 0 computes `requiredPayloadColumns` at *runtime* only — the contract `.d.ts` `through` type does **not** carry it. The type-disable dispatch must either derive "junction has a required non-FK field" from the junction model's field types in `contract.d.ts`, or — if that's infeasible — **halt and surface** (it may require extending slice 0's emitted `through` to carry the flag at the type level, which is a scope/▲contract decision for the operator). + +**Fixtures.** The pure `User ↔ Tag` junction (slice 1) covers connect/disconnect/create (no required payload). A **second** relation with a **required-payload junction** (e.g. `User ↔ Role` via `UserRole` with a required non-FK column like `level`) is added to the fixture to exercise the disable. + +## Coherence rationale + +One story: "M:N nested writes go through the junction, with the required-payload safety rail." The runtime routing, the rejection-test flip, the required-payload fixture, the type+runtime disable, and the integration tests are the connect/disconnect/create capability + its one guard — cohesive. The type-disable is its own dispatch (operator: type safety non-negotiable, kept in-slice). + +## Scope + +**In:** `partitionByOwnership` + the junction-owned write path in `mutation-executor.ts` (connect/disconnect/create, both flows); flipping the rejection unit test positive; the required-payload-junction fixture + re-emit; the type-level + runtime `.create` disable on required-payload junctions; write integration tests (per standard). + +**Out:** `set` / `connectOrCreate` / nested `update`/`upsert`/`delete` related-row kinds (TML-2781); reading/writing payload columns through the sugar (non-goal); the reverse `Tag.users` direction (deferred — see project decision log). + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +|---|---|---| +| `create()` parent flow vs `update()` | junction writes run after parent PK is known in **both**; `disconnect` stays `update()`-only (existing rule) | mirror the existing parent/child ownership flows | +| Composite-key junction | INSERT/DELETE across all `parentColumns`/`childColumns` pairs | slice 0 arrays | +| Required-payload junction | **`create` AND `connect` disabled** (both write a junction row that can't satisfy the required NOT-NULL column → DB violation); `disconnect` (DELETE) still allowed | corrected mid-flight — see decision #9 | +| **Type-level disable feasibility** | the `.d.ts` `through` type may not carry required-payload info — derive from junction field types, or **halt + surface** if infeasible (possible slice-0 contract extension) | the slice's key risk | + +## Slice-specific done conditions + +- [ ] `connect`/`disconnect`/`create` over the pure M:N relation route to junction INSERT/DELETE under both `create()` and `update()`; the `partitionByOwnership` guard is gone; the rejection unit test is flipped to a positive assertion. +- [ ] Nested `create` **and `connect`** on a **required-payload** junction are rejected **at runtime** (clear message) — and at the **type level** for `create` once the type-disable is unblocked (deferred, decision #8); `disconnect` on it still works. +- [ ] Integration tests (PGlite) per the standard: whole-row readback (via `include('tags')`) after connect/disconnect/create — whole-row `toEqual`, explicit `.select` in most, ≥1 implicit; cover both flows + the disable. + +## Open Questions + +1. **Type-level disable mechanism.** Working position: derive "junction has a required non-FK field" from the junction model's field types in `contract.d.ts` and resolve the mutator's `create` input to `never`. If `contract.d.ts` lacks the needed info, **halt and surface** — extending slice 0's emitted `through` (a contract-shape change) is an operator decision, not an unattended one. + +## References + +- Parent project: `projects/sql-orm-many-to-many/spec.md` (§ Cross-cutting — integration-test standard; § Non-goals). +- Slice 0 `ResolvedRelation.through.requiredPayloadColumns` (runtime); slice 1 fixture + read-back via `include`. +- `mutation-executor.ts` `partitionByOwnership` (~338) + the `create()`/`update()` graph flows; `relation-mutator.ts` `create` input type. +- Linear: [TML-2787](https://linear.app/prisma-company/issue/TML-2787) diff --git a/projects/sql-orm-many-to-many/trace.jsonl b/projects/sql-orm-many-to-many/trace.jsonl index 6fd8546bea..5cd353d216 100644 --- a/projects/sql-orm-many-to-many/trace.jsonl +++ b/projects/sql-orm-many-to-many/trace.jsonl @@ -61,3 +61,28 @@ {"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} +{"event_id":"cf6b86d5-679c-4c20-99bc-a1ca23f6f870","schema_version":"1","ts":"2026-06-01T19:44:35.966Z","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/03-nested-write-through-junction/spec.md","spec_kind":"slice","byte_length":6740,"edge_cases_count":4,"open_questions_count":1,"dod_items_count":3} +{"event_id":"77358ebb-6c83-4141-8dbf-05de5b68c26c","schema_version":"1","ts":"2026-06-01T19:44:36.344Z","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/03-nested-write-through-junction/plan.md","plan_kind":"slice","byte_length":4020,"dispatch_count":4,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":2,"L":2,"XL":0},"open_items_count":1} +{"event_id":"2bced176-0286-4bec-87af-83df67df1245","schema_version":"1","ts":"2026-06-01T19:45:28.604Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"a8fb9d1d-e322-4679-a9f4-5da6cbe4d96e","dispatch_name":"S3-D1 runtime junction write path","subagent_type":"general-purpose","model":"opus","parent_dispatch_id":null} +{"event_id":"737cf84b-fc5d-460d-b53b-7d33dc0063d1","schema_version":"1","ts":"2026-06-01T19:45:28.973Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"a8fb9d1d-e322-4679-a9f4-5da6cbe4d96e","round_id":"17fae268-c8f5-4415-9c16-9c12e7ef6e50","round_number":1} +{"event_id":"4ae1033f-63ba-4e64-8862-ca2f3fc0b33a","schema_version":"1","ts":"2026-06-01T19:45:29.369Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"a8fb9d1d-e322-4679-a9f4-5da6cbe4d96e","round_id":"17fae268-c8f5-4415-9c16-9c12e7ef6e50","brief_byte_length":3981,"brief_content_hash":"5f4473e6218438d399e2333c9da70cd37f9d625b6db08a18df81fe19e287b9e9","brief_disposition":"initial"} +{"event_id":"12100f64-f571-4c4a-afd5-1ce3249e6b4b","schema_version":"1","ts":"2026-06-01T20:00:10.103Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"a8fb9d1d-e322-4679-a9f4-5da6cbe4d96e","round_id":"17fae268-c8f5-4415-9c16-9c12e7ef6e50","verdict":"satisfied","findings_filed":0,"wall_clock_ms":880628} +{"event_id":"b89e5389-bcfc-4f7a-924e-97fe0d634b5c","schema_version":"1","ts":"2026-06-01T20:00:10.492Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"a8fb9d1d-e322-4679-a9f4-5da6cbe4d96e","result":"completed","wall_clock_ms":880997} +{"event_id":"e4b6beea-5cee-4084-bbf1-401fb560d8eb","schema_version":"1","ts":"2026-06-01T20:00:42.690Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"ec457eb4-b63a-4009-bfa5-453b7924decf","dispatch_name":"S3-D2 required-payload-junction fixture","subagent_type":"general-purpose","model":"sonnet","parent_dispatch_id":null} +{"event_id":"d831d10e-b277-472c-a50e-07a745efaf5c","schema_version":"1","ts":"2026-06-01T20:00:43.177Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"ec457eb4-b63a-4009-bfa5-453b7924decf","round_id":"bf24b947-bea2-406a-bfdf-56ba37f2af6b","round_number":1} +{"event_id":"c273c488-fcfb-4d64-92cb-951c13c07854","schema_version":"1","ts":"2026-06-01T20:00:43.575Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"ec457eb4-b63a-4009-bfa5-453b7924decf","round_id":"bf24b947-bea2-406a-bfdf-56ba37f2af6b","brief_byte_length":3331,"brief_content_hash":"52221c0072775421c95275828e70d24a6b3100778569f11361f81bfb8f9d694e","brief_disposition":"initial"} +{"event_id":"d90bb014-4574-4670-aa18-32e1b0a96b49","schema_version":"1","ts":"2026-06-01T20:13:43.698Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"ec457eb4-b63a-4009-bfa5-453b7924decf","round_id":"bf24b947-bea2-406a-bfdf-56ba37f2af6b","verdict":"satisfied","findings_filed":0,"wall_clock_ms":780060} +{"event_id":"c13d737f-cdab-48d2-b367-801dd2544969","schema_version":"1","ts":"2026-06-01T20:13:44.119Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"ec457eb4-b63a-4009-bfa5-453b7924decf","result":"completed","wall_clock_ms":780547} +{"event_id":"9aa5a154-ab84-4a8a-9f58-f86ad633d94e","schema_version":"1","ts":"2026-06-01T20:14:49.809Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"fc89a17f-77f2-4917-8974-a00ef6a1cf8d","dispatch_name":"S3-D3 runtime .create disable (runtime half only)","subagent_type":"general-purpose","model":"sonnet","parent_dispatch_id":null} +{"event_id":"8ebc46f7-f45d-4d03-b1b3-b63e58f63f9e","schema_version":"1","ts":"2026-06-01T20:14:50.182Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"fc89a17f-77f2-4917-8974-a00ef6a1cf8d","round_id":"5ebd6692-85a5-4388-b79f-dd9c457c0f8e","round_number":1} +{"event_id":"4963195b-42bd-4a43-9d5d-3543c6eb3b95","schema_version":"1","ts":"2026-06-01T20:14:50.555Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"fc89a17f-77f2-4917-8974-a00ef6a1cf8d","round_id":"5ebd6692-85a5-4388-b79f-dd9c457c0f8e","brief_byte_length":3719,"brief_content_hash":"d2e42400b4515eec866a6fc5d02127cf3c821f6bf101244fc2515f39e0dc93e8","brief_disposition":"initial"} +{"event_id":"859d2277-920b-4452-a343-1d06f0441232","schema_version":"1","ts":"2026-06-01T20:23:47.569Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"fc89a17f-77f2-4917-8974-a00ef6a1cf8d","round_id":"5ebd6692-85a5-4388-b79f-dd9c457c0f8e","verdict":"satisfied","findings_filed":0,"wall_clock_ms":536812} +{"event_id":"61e49aeb-b590-4edf-ab7b-c8b077f5f7f8","schema_version":"1","ts":"2026-06-01T20:23:47.967Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"fc89a17f-77f2-4917-8974-a00ef6a1cf8d","result":"completed","wall_clock_ms":537185} +{"event_id":"12566cee-3942-4d56-a39e-59bd718044c3","schema_version":"1","ts":"2026-06-01T20:24:22.988Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"f78a60c3-2652-41ca-a920-2c61814b3c19","dispatch_name":"S3-D4 M:N nested-write integration tests","subagent_type":"general-purpose","model":"sonnet","parent_dispatch_id":null} +{"event_id":"fec977b8-c0ea-4e0a-a591-4725bbe85ee4","schema_version":"1","ts":"2026-06-01T20:24:23.447Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"f78a60c3-2652-41ca-a920-2c61814b3c19","round_id":"88efdf46-ec77-42f1-8d7d-4ff2518fbd18","round_number":1} +{"event_id":"8585c87e-2329-48b2-a89c-b8c986504e48","schema_version":"1","ts":"2026-06-01T20:24:23.867Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"f78a60c3-2652-41ca-a920-2c61814b3c19","round_id":"88efdf46-ec77-42f1-8d7d-4ff2518fbd18","brief_byte_length":3610,"brief_content_hash":"ec291c17f874a182fe9ea4ddecd09514fc819ebaf327af708ba56ce63e34a50c","brief_disposition":"initial"} +{"event_id":"c7cf0490-ff51-4478-84fd-3e94f699cb03","schema_version":"1","ts":"2026-06-01T20:34:30.935Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"f78a60c3-2652-41ca-a920-2c61814b3c19","round_id":"88efdf46-ec77-42f1-8d7d-4ff2518fbd18","verdict":"another-round-needed","findings_filed":1,"wall_clock_ms":606952} +{"event_id":"8c412997-63b5-41cf-831b-b546a5d11934","schema_version":"1","ts":"2026-06-01T20:34:31.482Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"f78a60c3-2652-41ca-a920-2c61814b3c19","round_id":"a57b940c-6e2b-439f-b700-7cefdaa36435","round_number":2} +{"event_id":"0936eec8-acce-48bf-abd0-e77bf1a389d4","schema_version":"1","ts":"2026-06-01T20:34:31.954Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"f78a60c3-2652-41ca-a920-2c61814b3c19","round_id":"a57b940c-6e2b-439f-b700-7cefdaa36435","brief_byte_length":4052,"brief_content_hash":"8e746b6b4ff042d7ff46e9d4a3e780683c52dfd01ec752a7a0fb9f587a7d9fbe","brief_disposition":"amended"} +{"event_id":"c1cfaeb1-9b62-4a12-9160-ba2b6c2c203e","schema_version":"1","ts":"2026-06-01T20:49:31.895Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"f78a60c3-2652-41ca-a920-2c61814b3c19","round_id":"a57b940c-6e2b-439f-b700-7cefdaa36435","verdict":"satisfied","findings_filed":0,"wall_clock_ms":899788} +{"event_id":"1f1bbb4d-6db7-48f2-aa1d-f7379db16aad","schema_version":"1","ts":"2026-06-01T20:49:32.487Z","project_run_id":"sql-orm-many-to-many","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"f78a60c3-2652-41ca-a920-2c61814b3c19","result":"completed","wall_clock_ms":1508282} diff --git a/test/integration/test/sql-orm-client/fixtures/contract.ts b/test/integration/test/sql-orm-client/fixtures/contract.ts index eaefdf87b2..4cf50ce28d 100644 --- a/test/integration/test/sql-orm-client/fixtures/contract.ts +++ b/test/integration/test/sql-orm-client/fixtures/contract.ts @@ -75,6 +75,25 @@ const UserTag = model('UserTag', { })) .sql({ table: 'user_tags' }); +const Role = model('Role', { + fields: { + id: field.generated(uuidv4()).id(), + name: field.column(textColumn).unique(), + }, +}).sql({ table: 'roles' }); + +const UserRole = model('UserRole', { + fields: { + userId: field.column(int4Column).column('user_id'), + roleId: field.column(textColumn).column('role_id'), + level: field.column(int4Column), + }, +}) + .attributes(({ fields, constraints }) => ({ + id: constraints.id([fields.userId, fields.roleId]), + })) + .sql({ table: 'user_roles' }); + const Post = PostBase.relations({ comments: rel.hasMany(() => Comment, { by: 'postId' }), author: rel.belongsTo(UserBase, { from: 'userId', to: 'id' }).sql({ fk: {} }), @@ -90,6 +109,11 @@ const User = UserBase.relations({ from: 'userId', to: 'tagId', }), + roles: rel.manyToMany(() => Role, { + through: () => UserRole, + from: 'userId', + to: 'roleId', + }), }).sql({ table: 'users' }); const baseContract = defineContract({ @@ -102,6 +126,8 @@ const baseContract = defineContract({ Article, Tag, UserTag, + Role, + UserRole, }, }); diff --git a/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts b/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts index 14357a590d..69602447d2 100644 --- a/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts +++ b/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts @@ -36,9 +36,9 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:e05ccf77cd36bcf34f470cae1638a9bda152e054c982497ee262f1f2d3d021d6'>; + StorageHashBase<'sha256:a42bc0e879425eb4d8f34f690a6446677911d3fdba85743e11cb71e60cda8291'>; export type ExecutionHash = - ExecutionHashBase<'sha256:a108e5f9b4a5af51635ffde3849836fee73cd71cd568e1f2daf236c5768bcb07'>; + ExecutionHashBase<'sha256:8e2e39df5e42d28f74fa7177b910ed570f3c04c4a41b9812c3d13c18b428507a'>; export type ProfileHash = ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; @@ -82,6 +82,7 @@ export type FieldOutputTypes = { readonly userId: CodecTypes['pg/int4@1']['output']; readonly bio: CodecTypes['pg/text@1']['output']; }; + readonly Role: { readonly id: Char<36>; readonly name: CodecTypes['pg/text@1']['output'] }; readonly Tag: { readonly id: Char<36>; readonly name: CodecTypes['pg/text@1']['output'] }; readonly User: { readonly id: CodecTypes['pg/int4@1']['output']; @@ -90,6 +91,11 @@ export type FieldOutputTypes = { readonly invitedById: CodecTypes['pg/int4@1']['output'] | null; readonly address: AddressOutput | null; }; + readonly UserRole: { + readonly userId: CodecTypes['pg/int4@1']['output']; + readonly roleId: CodecTypes['pg/text@1']['output']; + readonly level: CodecTypes['pg/int4@1']['output']; + }; readonly UserTag: { readonly userId: CodecTypes['pg/int4@1']['output']; readonly tagId: CodecTypes['pg/text@1']['output']; @@ -118,6 +124,10 @@ export type FieldInputTypes = { readonly userId: CodecTypes['pg/int4@1']['input']; readonly bio: CodecTypes['pg/text@1']['input']; }; + readonly Role: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly name: CodecTypes['pg/text@1']['input']; + }; readonly Tag: { readonly id: CodecTypes['sql/char@1']['input']; readonly name: CodecTypes['pg/text@1']['input']; @@ -129,6 +139,11 @@ export type FieldInputTypes = { readonly invitedById: CodecTypes['pg/int4@1']['input'] | null; readonly address: AddressInput | null; }; + readonly UserRole: { + readonly userId: CodecTypes['pg/int4@1']['input']; + readonly roleId: CodecTypes['pg/text@1']['input']; + readonly level: CodecTypes['pg/int4@1']['input']; + }; readonly UserTag: { readonly userId: CodecTypes['pg/int4@1']['input']; readonly tagId: CodecTypes['pg/text@1']['input']; @@ -297,6 +312,25 @@ type ContractBase = Omit< }, ]; }; + readonly roles: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly [{ readonly columns: readonly ['name'] }]; + indexes: readonly []; + foreignKeys: readonly []; + }; readonly tags: { columns: { readonly id: { @@ -316,6 +350,29 @@ type ContractBase = Omit< indexes: readonly []; foreignKeys: readonly []; }; + readonly user_roles: { + columns: { + readonly user_id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly role_id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly level: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['user_id', 'role_id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; readonly user_tags: { columns: { readonly user_id: { @@ -537,6 +594,30 @@ type ContractBase = Omit< }; }; }; + readonly Role: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'roles'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + }; + }; + }; readonly Tag: { readonly fields: { readonly id: { @@ -625,6 +706,14 @@ type ContractBase = Omit< readonly targetFields: readonly ['user_id']; }; }; + readonly roles: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Role' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['user_id']; + }; + }; }; readonly storage: { readonly table: 'users'; @@ -637,6 +726,31 @@ type ContractBase = Omit< }; }; }; + readonly UserRole: { + readonly fields: { + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly roleId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly level: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user_roles'; + readonly fields: { + readonly userId: { readonly column: 'user_id' }; + readonly roleId: { readonly column: 'role_id' }; + readonly level: { readonly column: 'level' }; + }; + }; + }; readonly UserTag: { readonly fields: { readonly userId: { @@ -671,6 +785,8 @@ type ContractBase = Omit< readonly articles: { readonly namespace: 'public' & NamespaceId; readonly model: 'Article' }; readonly tags: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; readonly user_tags: { readonly namespace: 'public' & NamespaceId; readonly model: 'UserTag' }; + readonly roles: { readonly namespace: 'public' & NamespaceId; readonly model: 'Role' }; + readonly user_roles: { readonly namespace: 'public' & NamespaceId; readonly model: 'UserRole' }; }; readonly domain: { readonly namespaces: { @@ -828,6 +944,30 @@ type ContractBase = Omit< }; }; }; + readonly Role: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'roles'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + }; + }; + }; readonly Tag: { readonly fields: { readonly id: { @@ -919,6 +1059,14 @@ type ContractBase = Omit< readonly targetFields: readonly ['user_id']; }; }; + readonly roles: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Role' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['user_id']; + }; + }; }; readonly storage: { readonly table: 'users'; @@ -931,6 +1079,31 @@ type ContractBase = Omit< }; }; }; + readonly UserRole: { + readonly fields: { + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly roleId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly level: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user_roles'; + readonly fields: { + readonly userId: { readonly column: 'user_id' }; + readonly roleId: { readonly column: 'role_id' }; + readonly level: { readonly column: 'level' }; + }; + }; + }; readonly UserTag: { readonly fields: { readonly userId: { @@ -1042,6 +1215,10 @@ type ContractBase = Omit< readonly executionHash: ExecutionHash; readonly mutations: { readonly defaults: readonly [ + { + readonly ref: { readonly table: 'roles'; readonly column: 'id' }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, { readonly ref: { readonly table: 'tags'; readonly column: 'id' }; readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; diff --git a/test/integration/test/sql-orm-client/fixtures/generated/contract.json b/test/integration/test/sql-orm-client/fixtures/generated/contract.json index a3f175cf41..8abb58e49f 100644 --- a/test/integration/test/sql-orm-client/fixtures/generated/contract.json +++ b/test/integration/test/sql-orm-client/fixtures/generated/contract.json @@ -20,10 +20,18 @@ "model": "Profile", "namespace": "public" }, + "roles": { + "model": "Role", + "namespace": "public" + }, "tags": { "model": "Tag", "namespace": "public" }, + "user_roles": { + "model": "UserRole", + "namespace": "public" + }, "user_tags": { "model": "UserTag", "namespace": "public" @@ -283,6 +291,39 @@ "table": "profiles" } }, + "Role": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "name": { + "column": "name" + } + }, + "table": "roles" + } + }, "Tag": { "fields": { "id": { @@ -415,6 +456,33 @@ "namespace": "public" } }, + "roles": { + "cardinality": "N:M", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "user_id" + ] + }, + "through": { + "childColumns": [ + "role_id" + ], + "parentColumns": [ + "user_id" + ], + "table": "user_roles", + "targetColumns": [ + "id" + ] + }, + "to": { + "model": "Role", + "namespace": "public" + } + }, "tags": { "cardinality": "N:M", "on": { @@ -464,6 +532,46 @@ "table": "users" } }, + "UserRole": { + "fields": { + "level": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "roleId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "level": { + "column": "level" + }, + "roleId": { + "column": "role_id" + }, + "userId": { + "column": "user_id" + } + }, + "table": "user_roles" + } + }, "UserTag": { "fields": { "tagId": { @@ -715,6 +823,37 @@ } ] }, + "roles": { + "columns": { + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "name": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [ + { + "columns": [ + "name" + ] + } + ] + }, "tags": { "columns": { "id": { @@ -746,6 +885,34 @@ } ] }, + "user_roles": { + "columns": { + "level": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "role_id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "user_id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "user_id", + "role_id" + ] + }, + "uniques": [] + }, "user_tags": { "columns": { "tag_id": { @@ -834,12 +1001,22 @@ } } }, - "storageHash": "sha256:e05ccf77cd36bcf34f470cae1638a9bda152e054c982497ee262f1f2d3d021d6" + "storageHash": "sha256:a42bc0e879425eb4d8f34f690a6446677911d3fdba85743e11cb71e60cda8291" }, "execution": { - "executionHash": "sha256:a108e5f9b4a5af51635ffde3849836fee73cd71cd568e1f2daf236c5768bcb07", + "executionHash": "sha256:8e2e39df5e42d28f74fa7177b910ed570f3c04c4a41b9812c3d13c18b428507a", "mutations": { "defaults": [ + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "table": "roles" + } + }, { "onCreate": { "id": "uuidv4", diff --git a/test/integration/test/sql-orm-client/mn-nested-write.test.ts b/test/integration/test/sql-orm-client/mn-nested-write.test.ts new file mode 100644 index 0000000000..897c11cb54 --- /dev/null +++ b/test/integration/test/sql-orm-client/mn-nested-write.test.ts @@ -0,0 +1,381 @@ +// Integration coverage for M:N nested writes through the junction table. +// +// Two M:N relations on User are exercised: +// +// User.tags — pure junction (user_tags: user_id, tag_id). +// connect / disconnect / create are all supported. +// +// User.roles — required-payload junction (user_roles: user_id, role_id, +// level NOT NULL). connect and create both throw a runtime guard +// error; only disconnect is allowed. +// +// Standard: +// 1. Whole-row toEqual on the readback (via include('tags') / include('roles')). +// 2. Explicit .select() used in most tests. +// 3. At least one implicit/default-selection readback. + +import { describe, expect, it } from 'vitest'; +import { + createReturningUsersCollection, + timeouts, + withCollectionRuntime, +} from './integration-helpers'; +import { seedRoles, seedTags, seedUserRoles, seedUsers, seedUserTags } from './runtime-helpers'; + +const TAG_RUST = 'tag-rust'; +const TAG_TS = 'tag-typescript'; +const ROLE_ADMIN = 'role-admin'; +const ROLE_EDITOR = 'role-editor'; + +describe('integration/mn-nested-write', () => { + // =========================================================================== + // connect — create() parent flow + // =========================================================================== + + it( + 'create(): connect links an existing tag via junction; include("tags") readback reflects the link (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await seedTags(runtime, [{ id: TAG_RUST, name: 'Rust' }]); + + const created = await users + .select('id', 'name') + .include('tags', (tags) => tags.select('id', 'name')) + .create({ + id: 1, + name: 'Alice', + email: 'alice@example.com', + tags: (t) => t.connect({ id: TAG_RUST }), + }); + + expect(created).toEqual({ + id: 1, + name: 'Alice', + tags: [{ id: TAG_RUST, name: 'Rust' }], + }); + + const junctionRows = await runtime.query<{ user_id: number; tag_id: string }>( + 'select user_id, tag_id from user_tags', + ); + expect(junctionRows).toEqual([{ user_id: 1, tag_id: TAG_RUST }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'create(): connect links multiple existing tags; include("tags") readback contains all (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + + const created = await users + .select('id', 'name') + .include('tags', (tags) => tags.select('id', 'name').orderBy((t) => t.name.asc())) + .create({ + id: 2, + name: 'Bob', + email: 'bob@example.com', + tags: (t) => t.connect([{ id: TAG_RUST }, { id: TAG_TS }]), + }); + + expect(created).toEqual({ + id: 2, + name: 'Bob', + tags: [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ], + }); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // connect — update() parent flow + // =========================================================================== + + it( + 'update(): connect links an existing tag to an existing user; include("tags") readback reflects the link (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); + await seedTags(runtime, [{ id: TAG_RUST, name: 'Rust' }]); + + const updated = await users + .where({ id: 1 }) + .select('id', 'name') + .include('tags', (tags) => tags.select('id', 'name')) + .update({ + tags: (t) => t.connect({ id: TAG_RUST }), + }); + + expect(updated).toEqual({ + id: 1, + name: 'Alice', + tags: [{ id: TAG_RUST, name: 'Rust' }], + }); + + const junctionRows = await runtime.query<{ user_id: number; tag_id: string }>( + 'select user_id, tag_id from user_tags', + ); + expect(junctionRows).toEqual([{ user_id: 1, tag_id: TAG_RUST }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // disconnect — update() parent flow (only supported path) + // =========================================================================== + + it( + 'update(): disconnect removes the junction link; include("tags") readback no longer contains the tag (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); + await seedTags(runtime, [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]); + await seedUserTags(runtime, [ + { userId: 1, tagId: TAG_RUST }, + { userId: 1, tagId: TAG_TS }, + ]); + + const updated = await users + .where({ id: 1 }) + .select('id', 'name') + .include('tags', (tags) => tags.select('id', 'name')) + .update({ + tags: (t) => t.disconnect([{ id: TAG_RUST }]), + }); + + expect(updated).toEqual({ + id: 1, + name: 'Alice', + tags: [{ id: TAG_TS, name: 'TypeScript' }], + }); + + const junctionRows = await runtime.query<{ user_id: number; tag_id: string }>( + 'select user_id, tag_id from user_tags order by tag_id', + ); + expect(junctionRows).toEqual([{ user_id: 1, tag_id: TAG_TS }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'update(): disconnect all tags leaves an empty junction; include("tags") returns [] (implicit selection)', + async () => { + // Standard requirement: at least one test without .select() on the + // parent so the full default row shape is asserted. + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); + await seedTags(runtime, [{ id: TAG_RUST, name: 'Rust' }]); + await seedUserTags(runtime, [{ userId: 1, tagId: TAG_RUST }]); + + const updated = await users + .where({ id: 1 }) + .include('tags', (tags) => tags.orderBy((t) => t.name.asc())) + .update({ + tags: (t) => t.disconnect([{ id: TAG_RUST }]), + }); + + // Full User shape + tags: [] (junction row deleted, no .select() on parent). + expect(updated).toEqual({ + id: 1, + name: 'Alice', + email: 'alice@example.com', + invitedById: null, + address: null, + tags: [], + }); + + const junctionRows = await runtime.query<{ user_id: number; tag_id: string }>( + 'select user_id, tag_id from user_tags', + ); + expect(junctionRows).toEqual([]); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // nested create — pure junction (User.tags, no required payload columns) + // =========================================================================== + + it( + 'create(): nested create inserts the Tag row and the junction link; include("tags") readback reflects both (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + const created = await users + .select('id', 'name') + .include('tags', (tags) => tags.select('id', 'name')) + .create({ + id: 1, + name: 'Alice', + email: 'alice@example.com', + tags: (t) => t.create([{ id: TAG_RUST, name: 'Rust' }]), + }); + + expect(created).toEqual({ + id: 1, + name: 'Alice', + tags: [{ id: TAG_RUST, name: 'Rust' }], + }); + + const tagRows = await runtime.query<{ id: string; name: string }>( + 'select id, name from tags', + ); + expect(tagRows).toEqual([{ id: TAG_RUST, name: 'Rust' }]); + + const junctionRows = await runtime.query<{ user_id: number; tag_id: string }>( + 'select user_id, tag_id from user_tags', + ); + expect(junctionRows).toEqual([{ user_id: 1, tag_id: TAG_RUST }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'update(): nested create inserts Tag rows and junction links; include("tags") readback reflects all (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); + + const updated = await users + .where({ id: 1 }) + .select('id', 'name') + .include('tags', (tags) => tags.select('id', 'name').orderBy((t) => t.name.asc())) + .update({ + tags: (t) => + t.create([ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ]), + }); + + expect(updated).toEqual({ + id: 1, + name: 'Alice', + tags: [ + { id: TAG_RUST, name: 'Rust' }, + { id: TAG_TS, name: 'TypeScript' }, + ], + }); + + const tagRows = await runtime.query<{ id: string }>('select id from tags order by id'); + expect(tagRows).toEqual([{ id: TAG_RUST }, { id: TAG_TS }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // Runtime disable: nested create on required-payload junction (User.roles) + // =========================================================================== + + it( + 'create(): nested create on User.roles throws because the junction has a required payload column (level)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await expect( + users.create({ + id: 1, + name: 'Alice', + email: 'alice@example.com', + roles: (r) => r.create([{ id: ROLE_ADMIN, name: 'Admin' }]), + }), + ).rejects.toThrow(/required column.*`level`/); + }); + }, + timeouts.spinUpPpgDev, + ); + + // =========================================================================== + // connect/disconnect on required-payload junction (User.roles) — must succeed + // =========================================================================== + + it( + 'create(): connect on User.roles throws because the junction has a required payload column (level)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await seedRoles(runtime, [{ id: ROLE_ADMIN, name: 'Admin' }]); + + await expect( + users.create({ + id: 1, + name: 'Alice', + email: 'alice@example.com', + roles: (r) => r.connect({ id: ROLE_ADMIN }), + }), + ).rejects.toThrow(/required column.*`level`/); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'update(): disconnect on User.roles deletes the junction link; include("roles") readback is empty (explicit select)', + async () => { + await withCollectionRuntime(async (runtime) => { + const users = createReturningUsersCollection(runtime); + + await seedUsers(runtime, [{ id: 1, name: 'Alice', email: 'alice@example.com' }]); + await seedRoles(runtime, [ + { id: ROLE_ADMIN, name: 'Admin' }, + { id: ROLE_EDITOR, name: 'Editor' }, + ]); + await seedUserRoles(runtime, [ + { userId: 1, roleId: ROLE_ADMIN, level: 5 }, + { userId: 1, roleId: ROLE_EDITOR, level: 3 }, + ]); + + const updated = await users + .where({ id: 1 }) + .select('id', 'name') + .include('roles', (roles) => roles.select('id', 'name')) + .update({ + roles: (r) => r.disconnect([{ id: ROLE_ADMIN }]), + }); + + expect(updated).toEqual({ + id: 1, + name: 'Alice', + roles: [{ id: ROLE_EDITOR, name: 'Editor' }], + }); + + const junctionRows = await runtime.query<{ user_id: number; role_id: string }>( + 'select user_id, role_id from user_roles', + ); + expect(junctionRows).toEqual([{ user_id: 1, role_id: ROLE_EDITOR }]); + }); + }, + timeouts.spinUpPpgDev, + ); +}); diff --git a/test/integration/test/sql-orm-client/runtime-helpers.ts b/test/integration/test/sql-orm-client/runtime-helpers.ts index b6fe2210f0..4cc004bcf8 100644 --- a/test/integration/test/sql-orm-client/runtime-helpers.ts +++ b/test/integration/test/sql-orm-client/runtime-helpers.ts @@ -51,6 +51,17 @@ interface SeedUserTag { tagId: string; } +interface SeedRole { + id: string; + name: string; +} + +interface SeedUserRole { + userId: number; + roleId: string; + level: number; +} + export interface PgIntegrationRuntime extends RuntimeQueryable { readonly executions: readonly SqlExecutionPlan[]; query = Record>( @@ -178,6 +189,8 @@ export async function setupTestSchema(runtime: PgIntegrationRuntime): Promise { + for (const role of roles) { + await runtime.query('insert into roles (id, name) values ($1, $2)', [role.id, role.name]); + } +} + +export async function seedUserRoles( + runtime: PgIntegrationRuntime, + userRoles: readonly SeedUserRole[], +): Promise { + for (const ur of userRoles) { + await runtime.query('insert into user_roles (user_id, role_id, level) values ($1, $2, $3)', [ + ur.userId, + ur.roleId, + ur.level, + ]); + } +}