From 512ab3074f0a214ea2e61d450b01603a825b2ce3 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Thu, 25 Jun 2026 00:16:05 +0200 Subject: [PATCH 1/3] docs(psl-relation-syntax): slice 4 spec + plan (implicit M:N synthesis) Signed-off-by: Alexey Orlenko's AI Agent --- .../slices/04-implicit-mn-synthesis/plan.md | 36 +++++++++++++ .../slices/04-implicit-mn-synthesis/spec.md | 54 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/plan.md create mode 100644 projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/spec.md diff --git a/projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/plan.md b/projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/plan.md new file mode 100644 index 0000000000..89dc3f04f9 --- /dev/null +++ b/projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/plan.md @@ -0,0 +1,36 @@ +# Slice 4 — implicit M:N synthesis — Dispatch plan + +**Slice spec:** `projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/spec.md` +**Linear:** [TML-2943](https://linear.app/prisma-company/issue/TML-2943) + +Two dispatches. M1 front-loads the feasibility halt; M2 proves it downstream. + +## M1 — Detection + synthesis of the model-less junction (feasibility-gated) + +- **Outcome:** an implicit M:N (both ends bare, no junction model) emits a contract carrying a synthesised `_To` table (cols `A`/`B`, composite PK, FKs to the two model ids) + `N:M`/`through` on both ends, round-tripping `validateContract`; D5 precedence preserved. +- **Builds on:** slice 2's `through` lowering shape (the descriptor it emits) + slice 3 (no conflict). +- **Hands to:** the synthesised junction contract the migration + runtime consume. +- **Focus:** **first confirm the contract IR can hold a model-less storage table** (the § Feasibility halt) — read the storage-table IR + how `interpreter.ts` builds tables from models; if synthesis can't be injected cleanly within slice scope, HALT and surface. Then: detect the synthesise case in the bare-list path; inject the `_AToB` table (decision #7 naming) + emit the `through` on both ends; reuse the slice-2 `through`-descriptor machinery. Diagnostics for the edge cases (no `@id`; two implicit M:N between the same pair; name collision with a real table). +- **Completed when:** + - [ ] `pnpm --filter @prisma-next/sql-contract-psl test` green with: an implicit-M:N lowering test (synthesised table + `N:M`/`through`, `toEqual` on `Contract` + `validateSqlContractFully`); the D5-precedence control (both-bare-with-junction-model → recognised, **not** synthesised); the no-`@id` / ambiguous / collision diagnostics. + - [ ] `cd packages/2-sql/2-authoring/contract-psl && pnpm typecheck` + `lint` clean. +- **Halt conditions:** + - The contract IR / validator can't accept a model-less storage table within slice scope → **HALT + surface** (re-scope signal). + - Two implicit M:N between the same pair collide on the synthesised name → diagnostic (don't silently clobber). + +## M2 — Migration DDL + runtime integration + +- **Outcome:** the synthesised junction is created by `migrate` (postgres + sqlite), and `db.orm..include()` over an implicit M:N returns the related rows. +- **Builds on:** M1's synthesised contract. +- **Hands to:** implicit M:N parity with Prisma (the slice's downstream DoD). +- **Focus:** confirm the migration pipeline emits `CREATE TABLE _AToB` + composite PK + the two FKs for postgres **and** sqlite (it should, as a normal contract table — if it needs threading, that's the real work here); a PSL fixture authored as an implicit M:N (both bare, no junction), emitted + migrated; an `include` integration test (whole-row, ≥1 implicit; PGlite). `pnpm build` before integration/`fixtures:check`. +- **Completed when:** + - [ ] A migration/DDL test shows the synthesised table created on postgres + sqlite. + - [ ] The implicit-M:N `include` integration test passes (PGlite). + - [ ] `pnpm fixtures:check` clean (after `pnpm build`). +- **Halt conditions:** + - The migration pipeline rejects / can't create the model-less table → surface (may need its own dispatch). + +## Hand-off completeness + +M1 (synthesis into the contract) + M2 (migration creates it, runtime walks it) compose to the slice-DoD: implicit M:N authored as bare lists works end-to-end, with D5 precedence intact. The feasibility halt on M1 is the guard against the slice being bigger than scoped. diff --git a/projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/spec.md b/projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/spec.md new file mode 100644 index 0000000000..da9b331cb0 --- /dev/null +++ b/projects/psl-relation-syntax/slices/04-implicit-mn-synthesis/spec.md @@ -0,0 +1,54 @@ +# Slice 4: implicit M:N — synthesise a model-less junction + +_Parent project: `projects/psl-relation-syntax/`. Linear: [TML-2943](https://linear.app/prisma-company/issue/TML-2943). Builds on slice 2's `through:` lowering. Design: `design-notes.md` decision **D5** (case 3); operator decisions #5 (kept in-project), #7 (naming)._ + +## At a glance + +When both navigable list ends are **bare** (no `through:`) **and no junction model links them**, the framework **synthesises** a model-less junction table and lowers the relation to `cardinality:'N:M'` + a `through` descriptor over that synthesised table — Prisma's "implicit many-to-many." The table is created by the migration system (postgres + sqlite) and walked by the ORM, exactly like an authored junction. + +```prisma +model Post { id Uuid @id @default(uuid()); tags Tag[] } +model Tag { id Uuid @id @default(uuid()); posts Post[] } +// no junction model → framework synthesises `_PostToTag(A, B)` and an N:M through over it +``` + +Per D5, the precedence is preserved: `through:` on one end → use the named junction; both bare **and** a junction model exists → recognise it (slice-5 behaviour, slices 2's path); both bare **and no** junction → **synthesise** (this slice). + +## Chosen design + +- **Detection (resolver).** In the bare-list M:N path (`contract-psl` `findJunctionFkPairs` / the backrelation resolution), when both ends are bare and no junction model is found for the pair, branch to **synthesis** instead of the orphaned-backrelation diagnostic. The two terminal models must each have a single-column (or composite) `@id` to reference. +- **Synthesis (lowering).** Inject a storage table into the contract IR: name `_To` (terminal storage names, alphabetically ordered — decision #7), columns `A` (FK → first model's id) and `B` (FK → second model's id), composite identity `(A, B)`, the FK types matched to the referenced id columns. Emit the `N:M` + `through { table, parentColumns, childColumns, targetColumns, namespaceId }` descriptor over it on **both** navigable ends. The contract is **additive** (a table with no PSL model). +- **Migration/DDL.** The synthesised table is a normal contract storage table, so the migration system should create it (postgres + sqlite `CREATE TABLE` + the composite PK + the two FKs) **without special-casing** — confirm this; if the migration pipeline rejects a model-less table or needs threading, that is the slice's real work (and a feasibility signal). +- **Runtime.** The `through` descriptor is the same shape the sibling runtime already consumes — the ORM `include` walks the synthesised junction unchanged. + +## Feasibility halt (front-loaded) + +This slice injects a table the user never authored into the contract IR and the migration pipeline. **S4·M1 must first confirm the contract IR + migration can cleanly accept a synthesised (model-less) storage table.** If they cannot within slice scope (e.g. the IR assumes every storage table has an authoring model, or the migration keys off the model set), **HALT and surface** — the slice may need re-scoping or its own design pass, and that is a load-bearing finding, not something to force. + +## Scope + +**In:** detection of the synthesise case (both bare, no junction model); synthesis of the `_AToB` junction table + `N:M`/`through` into the contract; migration `CREATE TABLE` for it (postgres + sqlite); runtime `include` parity; round-trip `validateContract`; `fixtures:check`. + +**Out:** the explicit `through:` paths (slices 2–3); arrow-path (S5); a synthesised-junction naming **override** / `@@map` on the implicit junction (follow-up if wanted); implicit M:N **writes** beyond what the sibling runtime already supports; nullable/non-`@id` target keys (sibling slice 7 territory). + +## Pre-investigated edge cases + +| Edge case | Disposition | +|---|---| +| Both bare **and** a junction model exists | recognise it (D5 case 2 — slice 2's path), do **not** synthesise — preserve slice-5 behaviour | +| `through:` on one end | use the named junction (slice 2), not synthesis | +| terminal model lacks an `@id` | cannot synthesise an FK target → actionable diagnostic | +| two implicit M:N between the same pair of models | ambiguous for a synthesised name → diagnostic (explicit junction or `through:` required); do not silently collide table names | +| name collision: a real table already named `_AToB` | diagnostic, don't clobber | + +## Slice-specific done conditions + +- [ ] An implicit M:N (both ends bare, no junction model) emits a contract carrying the synthesised `_To` table + `N:M`/`through` on both ends, round-tripping `validateContract`. +- [ ] The synthesised table is created by `migrate` (postgres + sqlite) — DDL/migration test. +- [ ] `db.orm..include()` over an implicit M:N returns the related rows — integration (PGlite, project standard). +- [ ] D5 precedence intact: both-bare-with-junction-model still recognises (does not synthesise); `through:` still uses the named junction. + +## References + +- Project: `spec.md`, `design-notes.md` (D5 case 3); `wip/unattended-decisions.md` #5, #7. +- Surfaces: `contract-psl/src/psl-relation-resolution.ts` + `interpreter.ts` (detection + synthesis injection); the contract storage-table IR; the migration/DDL pipeline (postgres + sqlite); the sibling M:N runtime (unchanged). From 95db505020c6f5413c0c248bdf859f3b2d79f482 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Thu, 25 Jun 2026 00:40:47 +0200 Subject: [PATCH 2/3] feat(contract-psl): synthesise a model-less junction for implicit M:N MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both navigable list ends are bare (no `through:`) and no junction model links the pair, the PSL interpreter now synthesises a model-less junction table (Prisma's implicit many-to-many) instead of emitting the orphaned-backrelation diagnostic. Detection lives in `applyBackrelationCandidates`: a bare list with no FK-side match, no authored junction, and no junction near-miss is deferred to `resolveImplicitManyToMany`, which pairs it with its mirror end (or resolves a self-referential list on its own) and emits the `N:M`/`through` descriptor on both ends. D5 precedence is preserved — a both-bare pair with an authored junction model is still recognised, never synthesised. Synthesis injects a junction `ModelNode` named `_To` (terminal model names ordered alphabetically) with foreign-key columns `A` and `B` (A references the first model's id, B the second), a composite `(A, B)` identity, and the two foreign keys; the contract assembler turns it into a storage table and fills the through descriptors' `targetColumns` from the terminal ids. The junction is a physical table only — filtered out of `roots` like an STI variant. Diagnostics: a terminal without a single-column `@id` (`PSL_IMPLICIT_MN_TARGET_NO_ID`), more than one implicit many-to-many between the same pair (`PSL_IMPLICIT_MN_AMBIGUOUS`), and a real table already named like the synthesised junction (`PSL_IMPLICIT_MN_NAME_COLLISION`). Migration DDL and runtime `include` integration are S4·M2. Signed-off-by: Alexey Orlenko's AI Agent --- .../contract-psl/src/interpreter.ts | 130 +++++- .../src/psl-relation-resolution.ts | 273 ++++++++++- .../interpreter.relations.implicit-mn.test.ts | 422 ++++++++++++++++++ 3 files changed, 814 insertions(+), 11 deletions(-) create mode 100644 packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.implicit-mn.test.ts diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index ad9686adb3..9f65ec011a 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -60,7 +60,7 @@ import { type RelationNode, type UniqueConstraintNode, } from '@prisma-next/sql-contract-ts/contract-builder'; -import { invariant } from '@prisma-next/utils/assertions'; +import { assertDefined, invariant } from '@prisma-next/utils/assertions'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; @@ -102,6 +102,9 @@ import { type ParsedThrough, parseRelationAttribute, resolveTargetIdFieldNames, + SYNTHESIZED_JUNCTION_COLUMN_A, + SYNTHESIZED_JUNCTION_COLUMN_B, + type SynthesizedJunction, validateNavigationListFieldAttributes, } from './psl-relation-resolution'; @@ -1188,6 +1191,104 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult }; } +type JunctionTerminal = { + readonly tableName: string; + readonly idColumn: string; + readonly idDescriptor: FieldNode['descriptor']; + readonly namespaceId: string | undefined; +}; + +/** + * Resolves the table name, single `@id` column, and that column's type + * descriptor for one terminal of a synthesised junction, so the junction can + * copy the descriptor onto its referencing foreign-key column. The resolver + * guarantees a single-column `@id` before requesting synthesis, so the terminal + * model and its id column must both resolve. + */ +function junctionTerminal( + modelNodes: readonly ModelNode[], + modelName: string, + modelNamespaceIds: ReadonlyMap, +): JunctionTerminal { + const modelNode = modelNodes.find((node) => node.modelName === modelName); + assertDefined(modelNode, `synthesised junction terminal model "${modelName}"`); + const idColumns = modelNode.id?.columns; + invariant( + idColumns?.length === 1, + `synthesised junction terminal "${modelName}" must have a single-column @id`, + ); + const idColumn = idColumns[0]; + const idField = modelNode.fields.find( + (field): field is FieldNode => 'descriptor' in field && field.columnName === idColumn, + ); + assertDefined(idField, `synthesised junction terminal "${modelName}" @id column field`); + return { + tableName: modelNode.tableName, + idColumn: idField.columnName, + idDescriptor: idField.descriptor, + namespaceId: modelNamespaceIds.get(modelName), + }; +} + +/** + * Builds the model-less junction `ModelNode` for a synthesised implicit + * many-to-many: two foreign-key columns `A`/`B` whose types match the two + * terminal models' ids, a composite primary key over them, and the two foreign + * keys back to the terminals. The contract assembler turns this into a storage + * table and a (non-root) domain model; the through descriptors already emitted + * on the navigable ends reference it by name. + */ +function buildSynthesizedJunctionModelNode( + junction: SynthesizedJunction, + modelNodes: readonly ModelNode[], + modelNamespaceIds: ReadonlyMap, +): ModelNode { + const terminalA = junctionTerminal(modelNodes, junction.modelA, modelNamespaceIds); + const terminalB = junctionTerminal(modelNodes, junction.modelB, modelNamespaceIds); + const fields: FieldNode[] = [ + { + fieldName: SYNTHESIZED_JUNCTION_COLUMN_A, + columnName: SYNTHESIZED_JUNCTION_COLUMN_A, + descriptor: terminalA.idDescriptor, + nullable: false, + }, + { + fieldName: SYNTHESIZED_JUNCTION_COLUMN_B, + columnName: SYNTHESIZED_JUNCTION_COLUMN_B, + descriptor: terminalB.idDescriptor, + nullable: false, + }, + ]; + const foreignKeys: ForeignKeyNode[] = [ + { + columns: [SYNTHESIZED_JUNCTION_COLUMN_A], + references: { + model: junction.modelA, + table: terminalA.tableName, + columns: [terminalA.idColumn], + ...ifDefined('namespaceId', terminalA.namespaceId), + }, + }, + { + columns: [SYNTHESIZED_JUNCTION_COLUMN_B], + references: { + model: junction.modelB, + table: terminalB.tableName, + columns: [terminalB.idColumn], + ...ifDefined('namespaceId', terminalB.namespaceId), + }, + }, + ]; + return { + modelName: junction.junctionModelName, + tableName: junction.junctionModelName, + ...ifDefined('namespaceId', terminalA.namespaceId), + fields, + id: { columns: [SYNTHESIZED_JUNCTION_COLUMN_A, SYNTHESIZED_JUNCTION_COLUMN_B] }, + foreignKeys, + }; +} + interface BuildValueObjectsInput { readonly compositeTypes: readonly CompositeTypeSymbol[]; readonly enumTypeDescriptors: ReadonlyMap; @@ -1953,21 +2054,37 @@ export function interpretPslDocumentToSqlContract( fkRelationMetadata, }); const modelIdColumns = new Map(); + const modelTableNames = new Map(); + const declaredTableNames = new Set(); for (const modelNode of modelNodes) { if (modelNode.id) { modelIdColumns.set(modelNode.modelName, modelNode.id.columns); } + modelTableNames.set(modelNode.modelName, modelNode.tableName); + declaredTableNames.add(modelNode.modelName); + declaredTableNames.add(modelNode.tableName); } - applyBackrelationCandidates({ + const { synthesizedJunctions } = applyBackrelationCandidates({ backrelationCandidates, fkRelationsByPair, fkRelationsByDeclaringModel, modelIdColumns, + modelTableNames, + modelNamespaceIds, + declaredTableNames, modelRelations, diagnostics, sourceId, }); + // Inject a model-less junction table for each implicit many-to-many: a + // physical table the user never authored (Prisma's `_To` convention). + // The contract assembler turns it into a storage table and a domain model, + // and fills the through descriptors' `targetColumns` from the terminal ids. + for (const junction of synthesizedJunctions) { + modelNodes.push(buildSynthesizedJunctionModelNode(junction, modelNodes, modelNamespaceIds)); + } + // Merge cross-space relations into modelRelations after local back-relation matching. // Cross-space targets have no local back-relation candidates, so they bypass that step. for (const [modelName, relations] of crossSpaceRelationsByModel) { @@ -2137,10 +2254,15 @@ export function interpretPslDocumentToSqlContract( }); } - const variantModelNames = new Set(baseDeclarations.keys()); + // STI variants share the base table and synthesised junctions are physical + // tables only — neither is a queryable root. + const nonRootModelNames = new Set(baseDeclarations.keys()); + for (const junction of synthesizedJunctions) { + nonRootModelNames.add(junction.junctionModelName); + } const filteredRoots = Object.fromEntries( Object.entries(contract.roots).filter( - ([, crossReference]) => !variantModelNames.has(crossReference.model), + ([, crossReference]) => !nonRootModelNames.has(crossReference.model), ), ); diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index 2842b775e7..0a5afb6b55 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -165,6 +165,17 @@ export function fkRelationPairKey(declaringModelName: string, targetModelName: s return `${declaringModelName}::${targetModelName}`; } +/** Total order on model names for the alphabetical `_To` synthesis. */ +function compareModelNames(left: string, right: string): -1 | 0 | 1 { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + export function normalizeReferentialAction(input: { readonly modelName: string; readonly fieldName: string; @@ -695,15 +706,96 @@ function relationsForModel( return created; } +/** + * One side of a synthesised implicit-many-to-many junction: the navigable list + * field, the model that declares it, and the model it points at. Two of these + * (or one, for a self-referential list) compose into a `SynthesizedJunction`. + */ +type ImplicitManyToManyEnd = { + readonly candidate: ModelBackrelationCandidate; +}; + +/** + * An implicit many-to-many junction the interpreter must synthesise: a + * model-less junction table linking two models neither of which declares a + * foreign key, and no authored junction model links them. The names follow + * Prisma's `_To` convention — `modelA`/`modelB` are the two terminal model + * names ordered alphabetically, `A`/`B` are the junction's two foreign-key + * columns (A references `modelA`'s id, B references `modelB`'s id). `ends` + * carries the one or two navigable list fields that resolve to this junction; + * a self-referential list contributes a single end. + */ +export type SynthesizedJunction = { + readonly junctionModelName: string; + readonly modelA: string; + readonly modelB: string; + readonly ends: readonly ImplicitManyToManyEnd[]; +}; + +/** The junction column referencing each terminal model's id. */ +export const SYNTHESIZED_JUNCTION_COLUMN_A = 'A'; +export const SYNTHESIZED_JUNCTION_COLUMN_B = 'B'; + +/** Prisma's implicit-many-to-many junction name: `_To`. */ +export function synthesizedJunctionName(modelA: string, modelB: string): string { + return `_${modelA}To${modelB}`; +} + +/** + * Builds the N:M relation node for one navigable end of a synthesised junction. + * `selfColumn` is the junction foreign-key column referencing the declaring + * model (A or B); `otherColumn` references the target model. `targetColumns` is + * filled downstream by the contract assembler from the target model's id. + */ +function synthesizedManyToManyRelationNode(input: { + readonly candidate: ModelBackrelationCandidate; + readonly targetTableName: string; + readonly targetNamespaceId?: string; + readonly junctionTableName: string; + readonly junctionNamespaceId?: string; + readonly localColumns: readonly string[]; + readonly selfColumn: string; + readonly otherColumn: string; +}): ModelRelationMetadata { + return { + fieldName: input.candidate.field.name, + toModel: input.candidate.targetModelName, + toTable: input.targetTableName, + ...ifDefined('toNamespaceId', input.targetNamespaceId), + cardinality: 'N:M', + on: { + parentTable: input.candidate.tableName, + parentColumns: input.localColumns, + childTable: input.junctionTableName, + childColumns: [input.selfColumn], + }, + through: { + table: input.junctionTableName, + ...ifDefined('namespaceId', input.junctionNamespaceId), + parentColumns: [input.selfColumn], + childColumns: [input.otherColumn], + }, + }; +} + export function applyBackrelationCandidates(input: { readonly backrelationCandidates: readonly ModelBackrelationCandidate[]; readonly fkRelationsByPair: Map; readonly fkRelationsByDeclaringModel: ReadonlyMap; readonly modelIdColumns: ReadonlyMap; + readonly modelTableNames: ReadonlyMap; + readonly modelNamespaceIds: ReadonlyMap; + readonly declaredTableNames: ReadonlySet; readonly modelRelations: Map; readonly diagnostics: ContractSourceDiagnostic[]; readonly sourceId: string; -}): void { +}): { readonly synthesizedJunctions: readonly SynthesizedJunction[] } { + // Bare-list candidates that found no FK-side match, no authored junction, and + // no junction near-miss: implicit-many-to-many candidates resolved after the + // main loop, where a mirror end (or a self-referential list) turns the pair + // into a synthesised junction and a lone end stays orphaned. + const orphanedEnds: ModelBackrelationCandidate[] = []; + for (const candidate of input.backrelationCandidates) { const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName); const pairMatches = input.fkRelationsByPair.get(pairKey) ?? []; @@ -768,12 +860,11 @@ export function applyBackrelationCandidates(input: { input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId)); continue; } - input.diagnostics.push({ - code: 'PSL_ORPHANED_BACKRELATION_LIST', - message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(from: [...], to: [...]) on the FK-side relation or use an explicit join model for many-to-many.`, - sourceId: input.sourceId, - span: candidate.field.span, - }); + // No FK-side match, no authored junction, no near-miss: a bare navigable + // list with nothing to bind to. Deferred — a mirror bare list (or a + // self-referential list) makes this an implicit many-to-many to + // synthesise; a lone end stays orphaned. + orphanedEnds.push(candidate); continue; } if (matches.length > 1) { @@ -794,6 +885,174 @@ export function applyBackrelationCandidates(input: { oneToManyRelationNode(candidate, matched), ); } + + return resolveImplicitManyToMany({ + orphanedEnds, + modelIdColumns: input.modelIdColumns, + modelTableNames: input.modelTableNames, + modelNamespaceIds: input.modelNamespaceIds, + declaredTableNames: input.declaredTableNames, + modelRelations: input.modelRelations, + diagnostics: input.diagnostics, + sourceId: input.sourceId, + }); +} + +/** + * Pairs up the bare navigable list fields that found nothing to bind to and + * turns each pair (or self-referential list) into a synthesised implicit + * many-to-many junction, emitting the N:M relations on both ends. A lone end + * with no mirror stays orphaned; a pair whose terminal lacks an `@id`, a pair + * with more than one implicit many-to-many between the same models, and a + * synthesised name that collides with a real table are each diagnosed. + */ +function resolveImplicitManyToMany(input: { + readonly orphanedEnds: readonly ModelBackrelationCandidate[]; + readonly modelIdColumns: ReadonlyMap; + readonly modelTableNames: ReadonlyMap; + readonly modelNamespaceIds: ReadonlyMap; + readonly declaredTableNames: ReadonlySet; + readonly modelRelations: Map; + readonly diagnostics: ContractSourceDiagnostic[]; + readonly sourceId: string; +}): { readonly synthesizedJunctions: readonly SynthesizedJunction[] } { + // Group the ends by the unordered pair of models they link. A self-relation + // pairs a single end with itself; a two-sided relation pairs the two ends. + const endsByPair = new Map(); + for (const candidate of input.orphanedEnds) { + const [first, second] = [candidate.modelName, candidate.targetModelName].sort( + compareModelNames, + ); + const pairKey = `${first}::${second}`; + const ends = endsByPair.get(pairKey); + if (ends) { + ends.push(candidate); + } else { + endsByPair.set(pairKey, [candidate]); + } + } + + const synthesizedJunctions: SynthesizedJunction[] = []; + for (const ends of endsByPair.values()) { + const first = ends[0]; + if (!first) { + continue; + } + const isSelfRelation = first.modelName === first.targetModelName; + + // A lone end with no mirror is genuinely orphaned: there is no second + // navigable side to make a many-to-many. A self-referential list is its own + // mirror and synthesises from a single end. + if (!isSelfRelation && ends.length < 2) { + input.diagnostics.push(orphanedBackrelationDiagnostic(first, input.sourceId)); + continue; + } + // More than one implicit many-to-many between the same pair of models: the + // synthesised `_To` name would collide, and there is no junction model + // to disambiguate. A self-relation tolerates a single end (its own mirror); + // anything beyond the expected end count is ambiguous. + const expectedEndCount = isSelfRelation ? 1 : 2; + if (ends.length > expectedEndCount) { + for (const end of ends) { + input.diagnostics.push({ + code: 'PSL_IMPLICIT_MN_AMBIGUOUS', + message: `Backrelation list field "${end.modelName}.${end.field.name}" is one of multiple implicit many-to-many relations between "${end.modelName}" and "${end.targetModelName}". Name an explicit junction model (or use through:) so each relation has a distinct junction.`, + sourceId: input.sourceId, + span: end.field.span, + }); + } + continue; + } + + const [modelA, modelB] = [first.modelName, first.targetModelName].sort(compareModelNames); + if (modelA === undefined || modelB === undefined) { + continue; + } + const junctionModelName = synthesizedJunctionName(modelA, modelB); + + // A model named like the synthesised junction already declares a table: + // synthesising would clobber it. Report rather than overwrite. + if (input.declaredTableNames.has(junctionModelName)) { + input.diagnostics.push({ + code: 'PSL_IMPLICIT_MN_NAME_COLLISION', + message: `Implicit many-to-many between "${modelA}" and "${modelB}" would synthesise a junction table "${junctionModelName}", but a table with that name already exists. Rename the conflicting model or declare an explicit junction model.`, + sourceId: input.sourceId, + span: first.field.span, + }); + continue; + } + + // Both terminal models must expose a single-column `@id` to reference: each + // synthesised foreign key (A, B) is a single column pointing at it. A + // composite or absent id has no single column to reference here (sibling + // slice 7 territory), so it is diagnosed rather than synthesised. + const idA = input.modelIdColumns.get(modelA); + const idB = input.modelIdColumns.get(modelB); + const offendingIdModel = + idA === undefined || idA.length !== 1 + ? modelA + : idB === undefined || idB.length !== 1 + ? modelB + : undefined; + if (offendingIdModel !== undefined || idA === undefined || idB === undefined) { + const offending = offendingIdModel ?? modelA; + input.diagnostics.push({ + code: 'PSL_IMPLICIT_MN_TARGET_NO_ID', + message: `Implicit many-to-many between "${modelA}" and "${modelB}" cannot synthesise a junction: model "${offending}" must declare a single-column @id to reference. Add a single-column @id to "${offending}", or model the junction explicitly.`, + sourceId: input.sourceId, + span: first.field.span, + }); + continue; + } + + const synthesizedEnds: ImplicitManyToManyEnd[] = []; + for (const end of ends) { + const localColumns = input.modelIdColumns.get(end.modelName); + assertDefined(localColumns, 'implicit many-to-many end must reference an @id-bearing model'); + // The junction foreign-key column referencing the declaring model: A when + // the declaring model is the alphabetically-first, B otherwise. + const selfColumn = + end.modelName === modelA ? SYNTHESIZED_JUNCTION_COLUMN_A : SYNTHESIZED_JUNCTION_COLUMN_B; + const otherColumn = + selfColumn === SYNTHESIZED_JUNCTION_COLUMN_A + ? SYNTHESIZED_JUNCTION_COLUMN_B + : SYNTHESIZED_JUNCTION_COLUMN_A; + relationsForModel(input.modelRelations, end.modelName).push( + synthesizedManyToManyRelationNode({ + candidate: end, + targetTableName: input.modelTableNames.get(end.targetModelName) ?? end.targetModelName, + ...ifDefined('targetNamespaceId', input.modelNamespaceIds.get(end.targetModelName)), + junctionTableName: junctionModelName, + ...ifDefined('junctionNamespaceId', input.modelNamespaceIds.get(modelA)), + localColumns, + selfColumn, + otherColumn, + }), + ); + synthesizedEnds.push({ candidate: end }); + } + + synthesizedJunctions.push({ + junctionModelName, + modelA, + modelB, + ends: synthesizedEnds, + }); + } + + return { synthesizedJunctions }; +} + +function orphanedBackrelationDiagnostic( + candidate: ModelBackrelationCandidate, + sourceId: string, +): ContractSourceDiagnostic { + return { + code: 'PSL_ORPHANED_BACKRELATION_LIST', + message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(from: [...], to: [...]) on the FK-side relation or use an explicit join model for many-to-many.`, + sourceId, + span: candidate.field.span, + }; } export function validateNavigationListFieldAttributes(input: { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.implicit-mn.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.implicit-mn.test.ts new file mode 100644 index 0000000000..3bcbab0636 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.implicit-mn.test.ts @@ -0,0 +1,422 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { crossRef } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { validateSqlContractFully } from '@prisma-next/sql-contract/validators'; +import { describe, expect, it } from 'vitest'; +import { interpretPslDocumentToSqlContract } from '../src/interpreter'; +import { + createBuiltinLikeControlMutationDefaults, + createTestSqlNamespace, + modelsOf, + postgresScalarTypeDescriptors, + postgresTarget, + symbolTableInputFromParseArgs, +} from './fixtures'; +import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; + +const baseInput = { + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), + composedExtensionContracts: new Map(), + createNamespace: createTestSqlNamespace, +} as const; + +function interpret(schema: string) { + const document = symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }); + return interpretPslDocumentToSqlContract({ ...baseInput, ...document }); +} + +function relationsOf(contract: Contract) { + return modelsOf(contract) as Record }>; +} + +describe('interpretPslDocumentToSqlContract implicit many-to-many synthesis', () => { + it('synthesises a model-less junction table for two bare list ends with no junction model', () => { + const result = interpret(`model Post { + id Int @id + tags Tag[] +} + +model Tag { + id Int @id + posts Post[] +} +`); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const models = relationsOf(result.value); + expect(models['Post']?.relations).toEqual({ + tags: { + to: crossRef('Tag', 'public'), + cardinality: 'N:M', + on: { localFields: ['id'], targetFields: ['A'] }, + through: { + table: '_PostToTag', + namespaceId: 'public', + parentColumns: ['A'], + childColumns: ['B'], + targetColumns: ['id'], + }, + }, + }); + expect(models['Tag']?.relations).toEqual({ + posts: { + to: crossRef('Post', 'public'), + cardinality: 'N:M', + on: { localFields: ['id'], targetFields: ['B'] }, + through: { + table: '_PostToTag', + namespaceId: 'public', + parentColumns: ['B'], + childColumns: ['A'], + targetColumns: ['id'], + }, + }, + }); + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + const junctionTable = storage.namespaces['public']?.entries.table?.['_PostToTag']; + expect(junctionTable).toEqual({ + columns: { + A: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, + B: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, + }, + primaryKey: { columns: ['A', 'B'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + constraint: true, + index: true, + source: { tableName: '_PostToTag', namespaceId: 'public', columns: ['A'] }, + target: { tableName: 'post', namespaceId: 'public', columns: ['id'] }, + }, + { + constraint: true, + index: true, + source: { tableName: '_PostToTag', namespaceId: 'public', columns: ['B'] }, + target: { tableName: 'tag', namespaceId: 'public', columns: ['id'] }, + }, + ], + }); + + // The synthesised junction is a physical table only — not a queryable root. + expect(Object.keys(result.value.roots).sort()).toEqual(['post', 'tag']); + + const envelope = JSON.parse(JSON.stringify(result.value)) as unknown; + expect(() => validateSqlContractFully>(envelope)).not.toThrow(); + }); + + it('orders the synthesised name and columns alphabetically by terminal model name', () => { + const result = interpret(`model Tag { + id Int @id + posts Post[] +} + +model Post { + id Int @id + tags Tag[] +} +`); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + // Declaration order is Tag-before-Post, but the synthesised name and column + // assignment follow the alphabetical model order (Post < Tag): A → Post. + const junctionTable = storage.namespaces['public']?.entries.table?.['_PostToTag']; + expect(junctionTable?.foreignKeys).toEqual([ + { + constraint: true, + index: true, + source: { tableName: '_PostToTag', namespaceId: 'public', columns: ['A'] }, + target: { tableName: 'post', namespaceId: 'public', columns: ['id'] }, + }, + { + constraint: true, + index: true, + source: { tableName: '_PostToTag', namespaceId: 'public', columns: ['B'] }, + target: { tableName: 'tag', namespaceId: 'public', columns: ['id'] }, + }, + ]); + + const models = relationsOf(result.value); + expect(models['Post']?.relations).toEqual({ + tags: { + to: crossRef('Tag', 'public'), + cardinality: 'N:M', + on: { localFields: ['id'], targetFields: ['A'] }, + through: { + table: '_PostToTag', + namespaceId: 'public', + parentColumns: ['A'], + childColumns: ['B'], + targetColumns: ['id'], + }, + }, + }); + }); + + it('matches the synthesised FK column types to the referenced id columns', () => { + const result = interpret(`model Post { + id String @id + tags Tag[] +} + +model Tag { + id Int @id + posts Post[] +} +`); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + const junctionTable = storage.namespaces['public']?.entries.table?.['_PostToTag']; + expect(junctionTable?.columns).toEqual({ + A: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, + B: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, + }); + }); + + it('preserves D5 precedence: both-bare with an authored junction model is recognised, not synthesised', () => { + const result = interpret(`model Post { + id Int @id + tags Tag[] +} + +model Tag { + id Int @id + posts Post[] +} + +model PostTag { + postId Int + tagId Int + post Post @relation(from: [postId], to: [id]) + tag Tag @relation(from: [tagId], to: [id]) + + @@id([postId, tagId]) +} +`); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + // The authored junction is recognised; no `_PostToTag` table is synthesised. + expect(storage.namespaces['public']?.entries.table?.['_PostToTag']).toBeUndefined(); + expect(storage.namespaces['public']?.entries.table?.['postTag']).toBeDefined(); + + const models = relationsOf(result.value); + expect(models['Post']?.relations).toEqual({ + tags: { + to: crossRef('Tag', 'public'), + cardinality: 'N:M', + on: { localFields: ['id'], targetFields: ['postId'] }, + through: { + table: 'postTag', + namespaceId: 'public', + parentColumns: ['postId'], + childColumns: ['tagId'], + targetColumns: ['id'], + }, + }, + }); + }); + + it('diagnoses an implicit many-to-many whose terminal model has no @id', () => { + const result = interpret(`model Post { + id Int @id + tags Tag[] +} + +model Tag { + name String + posts Post[] +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_IMPLICIT_MN_TARGET_NO_ID', + message: expect.stringContaining('Tag'), + }), + ]), + ); + }); + + it('diagnoses an implicit many-to-many whose terminal has a composite @id', () => { + const result = interpret(`model Post { + id Int @id + tags Tag[] +} + +model Tag { + groupId Int + localId Int + posts Post[] + + @@id([groupId, localId]) +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_IMPLICIT_MN_TARGET_NO_ID', + message: expect.stringContaining('Tag'), + }), + ]), + ); + }); + + it('diagnoses two implicit many-to-many relations between the same pair of models', () => { + const result = interpret(`model Post { + id Int @id + tags Tag[] + pinnedTags Tag[] +} + +model Tag { + id Int @id + posts Post[] + pinnedPosts Post[] +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_IMPLICIT_MN_AMBIGUOUS', + }), + ]), + ); + }); + + it('diagnoses a name collision with a real table named like the synthesised junction', () => { + const result = interpret(`model Post { + id Int @id + tags Tag[] +} + +model Tag { + id Int @id + posts Post[] +} + +model _PostToTag { + id Int @id +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_IMPLICIT_MN_NAME_COLLISION', + message: expect.stringContaining('_PostToTag'), + }), + ]), + ); + }); + + it('keeps the orphaned diagnostic for a one-sided bare list with no inverse list and no junction', () => { + const result = interpret(`model Post { + id Int @id + tags Tag[] +} + +model Tag { + id Int @id +} +`); + + expect(result.ok).toBe(false); + if (result.ok) return; + + // No inverse `Post[]` list on Tag means there is no implicit M:N to + // synthesise: the bare list is genuinely orphaned. + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_ORPHANED_BACKRELATION_LIST', + message: expect.stringContaining('Post.tags'), + }), + ]), + ); + }); + + it('synthesises a self-referential implicit many-to-many over a single model', () => { + const result = interpret(`model User { + id Int @id + friends User[] +} +`); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + const junctionTable = storage.namespaces['public']?.entries.table?.['_UserToUser']; + expect(junctionTable).toEqual({ + columns: { + A: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, + B: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, + }, + primaryKey: { columns: ['A', 'B'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + constraint: true, + index: true, + source: { tableName: '_UserToUser', namespaceId: 'public', columns: ['A'] }, + target: { tableName: 'user', namespaceId: 'public', columns: ['id'] }, + }, + { + constraint: true, + index: true, + source: { tableName: '_UserToUser', namespaceId: 'public', columns: ['B'] }, + target: { tableName: 'user', namespaceId: 'public', columns: ['id'] }, + }, + ], + }); + + const models = relationsOf(result.value); + expect(models['User']?.relations).toEqual({ + friends: { + to: crossRef('User', 'public'), + cardinality: 'N:M', + on: { localFields: ['id'], targetFields: ['A'] }, + through: { + table: '_UserToUser', + namespaceId: 'public', + parentColumns: ['A'], + childColumns: ['B'], + targetColumns: ['id'], + }, + }, + }); + + const envelope = JSON.parse(JSON.stringify(result.value)) as unknown; + expect(() => validateSqlContractFully>(envelope)).not.toThrow(); + }); +}); From 8affc21787f019160ba3d7a649b5110b34374916 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko's AI Agent Date: Thu, 25 Jun 2026 01:33:33 +0200 Subject: [PATCH 3/3] test(sql-orm-client): migration DDL + ORM include for implicit M:N MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S4·M1 synthesises a model-less junction for an implicit many-to-many (both navigable list ends bare, no junction model). This proves that junction downstream: the migration system creates it, and the ORM `include` walks it. The `mn-psl-implicit` fixture authors `Post.tags Tag[]` / `Tag.posts Post[]` with no junction model, emitted via the real pipeline for both postgres and sqlite. The interpreter synthesises `_PostToTag` (composite PK `(A, B)`, FK `A` → `posts.id`, FK `B` → `tags.id`) and lowers both ends to `cardinality: 'N:M'` + `through` over it. Migration DDL: the synthesised junction is a normal contract storage table, so the migration planner creates it with no special-casing. A migration test drives the real planner over each emitted contract against an empty schema and asserts the `_PostToTag` `CREATE TABLE` (composite primary key + the two foreign keys) is planned for postgres and sqlite. Integration: an `include('tags')` test over the implicit M:N returns the related rows (whole-row assertions, explicit + implicit select, PGlite), walking the synthesised junction through a real emitted PSL contract with no authored junction model. Wires both emissions into the sql-orm-client emit script (so `fixtures:check` regenerates them) and adds `@prisma-next/sqlite` to the integration test package to emit the sqlite contract. Signed-off-by: Alexey Orlenko's AI Agent --- .../3-extensions/sql-orm-client/package.json | 2 +- pnpm-lock.yaml | 3 + test/integration/package.json | 1 + .../fixtures/mn-psl-implicit/contract.prisma | 35 ++ .../generated-sqlite/contract.d.ts | 337 +++++++++++++++++ .../generated-sqlite/contract.json | 313 ++++++++++++++++ .../mn-psl-implicit/generated/contract.d.ts | 351 ++++++++++++++++++ .../mn-psl-implicit/generated/contract.json | 322 ++++++++++++++++ .../prisma-next.config.sqlite.ts | 6 + .../mn-psl-implicit/prisma-next.config.ts | 6 + .../mn-psl-implicit-migration.test.ts | 123 ++++++ .../mn-psl-implicit-parity.test.ts | 205 ++++++++++ 12 files changed, 1703 insertions(+), 1 deletion(-) create mode 100644 test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/contract.prisma create mode 100644 test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated-sqlite/contract.d.ts create mode 100644 test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated-sqlite/contract.json create mode 100644 test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated/contract.d.ts create mode 100644 test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated/contract.json create mode 100644 test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.sqlite.ts create mode 100644 test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.ts create mode 100644 test/integration/test/sql-orm-client/mn-psl-implicit-migration.test.ts create mode 100644 test/integration/test/sql-orm-client/mn-psl-implicit-parity.test.ts diff --git a/packages/3-extensions/sql-orm-client/package.json b/packages/3-extensions/sql-orm-client/package.json index ed7a04552b..004ba41e44 100644 --- a/packages/3-extensions/sql-orm-client/package.json +++ b/packages/3-extensions/sql-orm-client/package.json @@ -7,7 +7,7 @@ "description": "ORM client for Prisma Next — fluent, type-safe model collections", "scripts": { "build": "tsdown", - "emit": "cd ../../../test/integration && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/polymorphism/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/junction-namespaces/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/execution-defaulted-tags/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl-through/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/disambiguated-1n-inverse/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/self-ref-mn-through/prisma-next.config.ts && cp test/sql-orm-client/fixtures/generated/contract.json test/sql-orm-client/fixtures/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/generated/ && cp test/sql-orm-client/fixtures/junction-namespaces/generated/contract.json test/sql-orm-client/fixtures/junction-namespaces/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/junction-namespaces/generated/ && cd ../../packages/3-extensions/sql-orm-client && node scripts/strip-pgvector-fixture.mjs test/fixtures/generated/contract.d.ts", + "emit": "cd ../../../test/integration && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/polymorphism/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/junction-namespaces/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/execution-defaulted-tags/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl-through/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/disambiguated-1n-inverse/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/self-ref-mn-through/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.ts && node ../../packages/1-framework/3-tooling/cli/dist/cli.js contract emit --config test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.sqlite.ts && cp test/sql-orm-client/fixtures/generated/contract.json test/sql-orm-client/fixtures/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/generated/ && cp test/sql-orm-client/fixtures/junction-namespaces/generated/contract.json test/sql-orm-client/fixtures/junction-namespaces/generated/contract.d.ts ../../packages/3-extensions/sql-orm-client/test/fixtures/junction-namespaces/generated/ && cd ../../packages/3-extensions/sql-orm-client && node scripts/strip-pgvector-fixture.mjs test/fixtures/generated/contract.d.ts", "emit:check": "pnpm emit && git diff --exit-code test/fixtures/generated/ test/fixtures/junction-namespaces/generated/", "test": "vitest run", "test:coverage": "vitest run --coverage", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a168ec154f..e3999c78bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4802,6 +4802,9 @@ importers: '@prisma-next/sql-schema-ir': specifier: workspace:0.14.0 version: link:../../packages/2-sql/1-core/schema-ir + '@prisma-next/sqlite': + specifier: workspace:0.14.0 + version: link:../../packages/3-extensions/sqlite '@prisma-next/target-mongo': specifier: workspace:0.14.0 version: link:../../packages/3-mongo-target/1-mongo-target diff --git a/test/integration/package.json b/test/integration/package.json index 6c19352224..0f350fea51 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -44,6 +44,7 @@ "@prisma-next/sql-relational-core": "workspace:0.14.0", "@prisma-next/sql-runtime": "workspace:0.14.0", "@prisma-next/sql-schema-ir": "workspace:0.14.0", + "@prisma-next/sqlite": "workspace:0.14.0", "@prisma-next/target-postgres": "workspace:0.14.0", "@prisma-next/adapter-mongo": "workspace:0.14.0", "@prisma-next/adapter-sqlite": "workspace:0.14.0", diff --git a/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/contract.prisma b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/contract.prisma new file mode 100644 index 0000000000..b5eb0fc27e --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/contract.prisma @@ -0,0 +1,35 @@ +// Real emitted fixture for the sql-orm-client implicit many-to-many parity +// test — the `Post ↔ Tag` relation authored as two bare navigable list ends +// with NO junction model. +// +// `Post.tags Tag[]` / `Tag.posts Post[]` are both bare (no `@relation(through:)`) +// and no model links the pair, so the interpreter synthesises a model-less +// junction table — Prisma's implicit many-to-many. The synthesised junction is +// named `_PostToTag` (the two terminal model names ordered alphabetically) with +// foreign-key columns `A` (→ `posts.id`) and `B` (→ `tags.id`) and a composite +// `(A, B)` identity. Both list ends lower to a navigable `cardinality: 'N:M'` +// relation with a populated `through` descriptor over `_PostToTag` — the same +// runtime-consumable shape an authored junction emits — so the M:N ORM +// `include` drives through a real emitted PSL contract with no authored +// junction model. +// +// The synthesised junction is a physical table only (filtered out of `roots`), +// so the migration system creates it like any contract storage table. + +model Post { + id Int @id + title String + + tags Tag[] + + @@map("posts") +} + +model Tag { + id String @id + label String @unique + + posts Post[] + + @@map("tags") +} diff --git a/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated-sqlite/contract.d.ts b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated-sqlite/contract.d.ts new file mode 100644 index 0000000000..1f98e37497 --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated-sqlite/contract.d.ts @@ -0,0 +1,337 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { CodecTypes as SqliteTypes } from '@prisma-next/adapter-sqlite/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:e8b070318ddd3ad2730e1724f4716fb06145c5288e6edaad0be3f551bde1ab09'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:3cc333ecad9f3f4c7229370a9d2c37e908cdce0f8d2e9fb132d50605b024eff2'>; + +export type CodecTypes = SqliteTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = Record; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly __unbound__: { + readonly _PostToTag: { + readonly A: CodecTypes['sqlite/integer@1']['output']; + readonly B: CodecTypes['sqlite/text@1']['output']; + }; + readonly Post: { + readonly id: CodecTypes['sqlite/integer@1']['output']; + readonly title: CodecTypes['sqlite/text@1']['output']; + }; + readonly Tag: { + readonly id: CodecTypes['sqlite/text@1']['output']; + readonly label: CodecTypes['sqlite/text@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly __unbound__: { + readonly _PostToTag: { + readonly A: CodecTypes['sqlite/integer@1']['input']; + readonly B: CodecTypes['sqlite/text@1']['input']; + }; + readonly Post: { + readonly id: CodecTypes['sqlite/integer@1']['input']; + readonly title: CodecTypes['sqlite/text@1']['input']; + }; + readonly Tag: { + readonly id: CodecTypes['sqlite/text@1']['input']; + readonly label: CodecTypes['sqlite/text@1']['input']; + }; + }; +}; +export type StorageColumnTypes = { + readonly __unbound__: { + readonly _PostToTag: { + readonly A: CodecTypes['sqlite/integer@1']['output']; + readonly B: CodecTypes['sqlite/text@1']['output']; + }; + readonly posts: { + readonly id: CodecTypes['sqlite/integer@1']['output']; + readonly title: CodecTypes['sqlite/text@1']['output']; + }; + readonly tags: { + readonly id: CodecTypes['sqlite/text@1']['output']; + readonly label: CodecTypes['sqlite/text@1']['output']; + }; + }; +}; +export type StorageColumnInputTypes = { + readonly __unbound__: { + readonly _PostToTag: { + readonly A: CodecTypes['sqlite/integer@1']['input']; + readonly B: CodecTypes['sqlite/text@1']['input']; + }; + readonly posts: { + readonly id: CodecTypes['sqlite/integer@1']['input']; + readonly title: CodecTypes['sqlite/text@1']['input']; + }; + readonly tags: { + readonly id: CodecTypes['sqlite/text@1']['input']; + readonly label: CodecTypes['sqlite/text@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes, + StorageColumnTypes, + StorageColumnInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly __unbound__: { + readonly id: '__unbound__'; + readonly kind: 'sqlite-namespace'; + readonly entries: { + readonly table: { + readonly _PostToTag: { + columns: { + readonly A: { + readonly nativeType: 'integer'; + readonly codecId: 'sqlite/integer@1'; + readonly nullable: false; + }; + readonly B: { + readonly nativeType: 'text'; + readonly codecId: 'sqlite/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['A', 'B'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly [ + { + readonly source: { + readonly namespaceId: '__unbound__' & NamespaceId; + readonly tableName: '_PostToTag'; + readonly columns: readonly ['A']; + }; + readonly target: { + readonly namespaceId: '__unbound__' & NamespaceId; + readonly tableName: 'posts'; + readonly columns: readonly ['id']; + }; + readonly constraint: true; + readonly index: true; + }, + { + readonly source: { + readonly namespaceId: '__unbound__' & NamespaceId; + readonly tableName: '_PostToTag'; + readonly columns: readonly ['B']; + }; + readonly target: { + readonly namespaceId: '__unbound__' & NamespaceId; + readonly tableName: 'tags'; + readonly columns: readonly ['id']; + }; + readonly constraint: true; + readonly index: true; + }, + ]; + }; + readonly posts: { + columns: { + readonly id: { + readonly nativeType: 'integer'; + readonly codecId: 'sqlite/integer@1'; + readonly nullable: false; + }; + readonly title: { + readonly nativeType: 'text'; + readonly codecId: 'sqlite/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly tags: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'sqlite/text@1'; + readonly nullable: false; + }; + readonly label: { + readonly nativeType: 'text'; + readonly codecId: 'sqlite/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly [{ readonly columns: readonly ['label'] }]; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'sqlite'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly posts: { readonly namespace: '__unbound__' & NamespaceId; readonly model: 'Post' }; + readonly tags: { readonly namespace: '__unbound__' & NamespaceId; readonly model: 'Tag' }; + }; + readonly domain: { + readonly namespaces: { + readonly __unbound__: { + readonly models: { + readonly _PostToTag: { + readonly fields: { + readonly A: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'sqlite/integer@1' }; + }; + readonly B: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'sqlite/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: '_PostToTag'; + readonly namespaceId: '__unbound__'; + readonly fields: { + readonly A: { readonly column: 'A' }; + readonly B: { readonly column: 'B' }; + }; + }; + }; + readonly Post: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'sqlite/integer@1' }; + }; + readonly title: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'sqlite/text@1' }; + }; + }; + readonly relations: { + readonly tags: { + readonly to: { + readonly namespace: '__unbound__' & NamespaceId; + readonly model: 'Tag'; + }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['A']; + }; + readonly through: { + readonly table: '_PostToTag'; + readonly namespaceId: '__unbound__'; + readonly parentColumns: readonly ['A']; + readonly childColumns: readonly ['B']; + readonly targetColumns: readonly ['id']; + }; + }; + }; + readonly storage: { + readonly table: 'posts'; + readonly namespaceId: '__unbound__'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly title: { readonly column: 'title' }; + }; + }; + }; + readonly Tag: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'sqlite/text@1' }; + }; + readonly label: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'sqlite/text@1' }; + }; + }; + readonly relations: { + readonly posts: { + readonly to: { + readonly namespace: '__unbound__' & NamespaceId; + readonly model: 'Post'; + }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['B']; + }; + readonly through: { + readonly table: '_PostToTag'; + readonly namespaceId: '__unbound__'; + readonly parentColumns: readonly ['B']; + readonly childColumns: readonly ['A']; + readonly targetColumns: readonly ['id']; + }; + }; + }; + readonly storage: { + readonly table: 'tags'; + readonly namespaceId: '__unbound__'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly label: { readonly column: 'label' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly sql: { + readonly enums: false; + readonly foreignKeys: true; + readonly jsonAgg: true; + readonly lateral: false; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated-sqlite/contract.json b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated-sqlite/contract.json new file mode 100644 index 0000000000..2e68e0626e --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated-sqlite/contract.json @@ -0,0 +1,313 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "sqlite", + "profileHash": "sha256:3cc333ecad9f3f4c7229370a9d2c37e908cdce0f8d2e9fb132d50605b024eff2", + "roots": { + "posts": { + "model": "Post", + "namespace": "__unbound__" + }, + "tags": { + "model": "Tag", + "namespace": "__unbound__" + } + }, + "domain": { + "namespaces": { + "__unbound__": { + "models": { + "Post": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "sqlite/integer@1", + "kind": "scalar" + } + }, + "title": { + "nullable": false, + "type": { + "codecId": "sqlite/text@1", + "kind": "scalar" + } + } + }, + "relations": { + "tags": { + "cardinality": "N:M", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "A" + ] + }, + "through": { + "childColumns": [ + "B" + ], + "namespaceId": "__unbound__", + "parentColumns": [ + "A" + ], + "table": "_PostToTag", + "targetColumns": [ + "id" + ] + }, + "to": { + "model": "Tag", + "namespace": "__unbound__" + } + } + }, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "title": { + "column": "title" + } + }, + "namespaceId": "__unbound__", + "table": "posts" + } + }, + "Tag": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "sqlite/text@1", + "kind": "scalar" + } + }, + "label": { + "nullable": false, + "type": { + "codecId": "sqlite/text@1", + "kind": "scalar" + } + } + }, + "relations": { + "posts": { + "cardinality": "N:M", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "B" + ] + }, + "through": { + "childColumns": [ + "A" + ], + "namespaceId": "__unbound__", + "parentColumns": [ + "B" + ], + "table": "_PostToTag", + "targetColumns": [ + "id" + ] + }, + "to": { + "model": "Post", + "namespace": "__unbound__" + } + } + }, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "label": { + "column": "label" + } + }, + "namespaceId": "__unbound__", + "table": "tags" + } + }, + "_PostToTag": { + "fields": { + "A": { + "nullable": false, + "type": { + "codecId": "sqlite/integer@1", + "kind": "scalar" + } + }, + "B": { + "nullable": false, + "type": { + "codecId": "sqlite/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "A": { + "column": "A" + }, + "B": { + "column": "B" + } + }, + "namespaceId": "__unbound__", + "table": "_PostToTag" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "__unbound__": { + "entries": { + "table": { + "_PostToTag": { + "columns": { + "A": { + "codecId": "sqlite/integer@1", + "nativeType": "integer", + "nullable": false + }, + "B": { + "codecId": "sqlite/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [ + { + "constraint": true, + "index": true, + "source": { + "columns": [ + "A" + ], + "namespaceId": "__unbound__", + "tableName": "_PostToTag" + }, + "target": { + "columns": [ + "id" + ], + "namespaceId": "__unbound__", + "tableName": "posts" + } + }, + { + "constraint": true, + "index": true, + "source": { + "columns": [ + "B" + ], + "namespaceId": "__unbound__", + "tableName": "_PostToTag" + }, + "target": { + "columns": [ + "id" + ], + "namespaceId": "__unbound__", + "tableName": "tags" + } + } + ], + "indexes": [], + "primaryKey": { + "columns": [ + "A", + "B" + ] + }, + "uniques": [] + }, + "posts": { + "columns": { + "id": { + "codecId": "sqlite/integer@1", + "nativeType": "integer", + "nullable": false + }, + "title": { + "codecId": "sqlite/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + }, + "tags": { + "columns": { + "id": { + "codecId": "sqlite/text@1", + "nativeType": "text", + "nullable": false + }, + "label": { + "codecId": "sqlite/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [ + { + "columns": [ + "label" + ] + } + ] + } + } + }, + "id": "__unbound__" + } + }, + "storageHash": "sha256:e8b070318ddd3ad2730e1724f4716fb06145c5288e6edaad0be3f551bde1ab09" + }, + "capabilities": { + "sql": { + "foreignKeys": true, + "jsonAgg": true, + "limit": true, + "orderBy": true, + "returning": true + } + }, + "extensionPacks": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} \ No newline at end of file diff --git a/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated/contract.d.ts b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated/contract.d.ts new file mode 100644 index 0000000000..4d63077194 --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated/contract.d.ts @@ -0,0 +1,351 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:e6a4590503820fd5dbc4f071f6faccbf5387c11d7194e10b78ca73a34deb327d'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly _PostToTag: { + readonly A: CodecTypes['pg/int4@1']['output']; + readonly B: CodecTypes['pg/text@1']['output']; + }; + readonly Post: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly title: CodecTypes['pg/text@1']['output']; + }; + readonly Tag: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly label: CodecTypes['pg/text@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly _PostToTag: { + readonly A: CodecTypes['pg/int4@1']['input']; + readonly B: CodecTypes['pg/text@1']['input']; + }; + readonly Post: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly title: CodecTypes['pg/text@1']['input']; + }; + readonly Tag: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly label: CodecTypes['pg/text@1']['input']; + }; + }; +}; +export type StorageColumnTypes = { + readonly public: { + readonly _PostToTag: { + readonly A: CodecTypes['pg/int4@1']['output']; + readonly B: CodecTypes['pg/text@1']['output']; + }; + readonly posts: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly title: CodecTypes['pg/text@1']['output']; + }; + readonly tags: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly label: CodecTypes['pg/text@1']['output']; + }; + }; +}; +export type StorageColumnInputTypes = { + readonly public: { + readonly _PostToTag: { + readonly A: CodecTypes['pg/int4@1']['input']; + readonly B: CodecTypes['pg/text@1']['input']; + }; + readonly posts: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly title: CodecTypes['pg/text@1']['input']; + }; + readonly tags: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly label: CodecTypes['pg/text@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes, + StorageColumnTypes, + StorageColumnInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly _PostToTag: { + columns: { + readonly A: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly B: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['A', 'B'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly [ + { + readonly source: { + readonly namespaceId: 'public' & NamespaceId; + readonly tableName: '_PostToTag'; + readonly columns: readonly ['A']; + }; + readonly target: { + readonly namespaceId: 'public' & NamespaceId; + readonly tableName: 'posts'; + readonly columns: readonly ['id']; + }; + readonly constraint: true; + readonly index: true; + }, + { + readonly source: { + readonly namespaceId: 'public' & NamespaceId; + readonly tableName: '_PostToTag'; + readonly columns: readonly ['B']; + }; + readonly target: { + readonly namespaceId: 'public' & NamespaceId; + readonly tableName: 'tags'; + readonly columns: readonly ['id']; + }; + readonly constraint: true; + readonly index: true; + }, + ]; + }; + readonly posts: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly title: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly tags: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly label: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly [{ readonly columns: readonly ['label'] }]; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly posts: { readonly namespace: 'public' & NamespaceId; readonly model: 'Post' }; + readonly tags: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly _PostToTag: { + readonly fields: { + readonly A: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly B: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: '_PostToTag'; + readonly namespaceId: 'public'; + readonly fields: { + readonly A: { readonly column: 'A' }; + readonly B: { readonly column: 'B' }; + }; + }; + }; + readonly Post: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly title: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: { + readonly tags: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Tag' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['A']; + }; + readonly through: { + readonly table: '_PostToTag'; + readonly namespaceId: 'public'; + readonly parentColumns: readonly ['A']; + readonly childColumns: readonly ['B']; + readonly targetColumns: readonly ['id']; + }; + }; + }; + readonly storage: { + readonly table: 'posts'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly title: { readonly column: 'title' }; + }; + }; + }; + readonly Tag: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly label: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: { + readonly posts: { + readonly to: { readonly namespace: 'public' & NamespaceId; readonly model: 'Post' }; + readonly cardinality: 'N:M'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['B']; + }; + readonly through: { + readonly table: '_PostToTag'; + readonly namespaceId: 'public'; + readonly parentColumns: readonly ['B']; + readonly childColumns: readonly ['A']; + readonly targetColumns: readonly ['id']; + }; + }; + }; + readonly storage: { + readonly table: 'tags'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly label: { readonly column: 'label' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + readonly scalarList: true; + }; + }; + readonly extensionPacks: {}; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated/contract.json b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated/contract.json new file mode 100644 index 0000000000..3a21e3d79d --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/generated/contract.json @@ -0,0 +1,322 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "posts": { + "model": "Post", + "namespace": "public" + }, + "tags": { + "model": "Tag", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Post": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "title": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": { + "tags": { + "cardinality": "N:M", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "A" + ] + }, + "through": { + "childColumns": [ + "B" + ], + "namespaceId": "public", + "parentColumns": [ + "A" + ], + "table": "_PostToTag", + "targetColumns": [ + "id" + ] + }, + "to": { + "model": "Tag", + "namespace": "public" + } + } + }, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "title": { + "column": "title" + } + }, + "namespaceId": "public", + "table": "posts" + } + }, + "Tag": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "label": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": { + "posts": { + "cardinality": "N:M", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "B" + ] + }, + "through": { + "childColumns": [ + "A" + ], + "namespaceId": "public", + "parentColumns": [ + "B" + ], + "table": "_PostToTag", + "targetColumns": [ + "id" + ] + }, + "to": { + "model": "Post", + "namespace": "public" + } + } + }, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "label": { + "column": "label" + } + }, + "namespaceId": "public", + "table": "tags" + } + }, + "_PostToTag": { + "fields": { + "A": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "B": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "A": { + "column": "A" + }, + "B": { + "column": "B" + } + }, + "namespaceId": "public", + "table": "_PostToTag" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "_PostToTag": { + "columns": { + "A": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "B": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [ + { + "constraint": true, + "index": true, + "source": { + "columns": [ + "A" + ], + "namespaceId": "public", + "tableName": "_PostToTag" + }, + "target": { + "columns": [ + "id" + ], + "namespaceId": "public", + "tableName": "posts" + } + }, + { + "constraint": true, + "index": true, + "source": { + "columns": [ + "B" + ], + "namespaceId": "public", + "tableName": "_PostToTag" + }, + "target": { + "columns": [ + "id" + ], + "namespaceId": "public", + "tableName": "tags" + } + } + ], + "indexes": [], + "primaryKey": { + "columns": [ + "A", + "B" + ] + }, + "uniques": [] + }, + "posts": { + "columns": { + "id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "title": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + }, + "tags": { + "columns": { + "id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "label": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [ + { + "columns": [ + "label" + ] + } + ] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:e6a4590503820fd5dbc4f071f6faccbf5387c11d7194e10b78ca73a34deb327d" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true, + "scalarList": true + } + }, + "extensionPacks": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} \ No newline at end of file diff --git a/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.sqlite.ts b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.sqlite.ts new file mode 100644 index 0000000000..fd50f6dbe3 --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.sqlite.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@prisma-next/sqlite/config'; + +export default defineConfig({ + contract: './contract.prisma', + outputPath: 'generated-sqlite', +}); diff --git a/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.ts b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.ts new file mode 100644 index 0000000000..00be097bb0 --- /dev/null +++ b/test/integration/test/sql-orm-client/fixtures/mn-psl-implicit/prisma-next.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@prisma-next/postgres/config'; + +export default defineConfig({ + contract: './contract.prisma', + outputPath: 'generated', +}); diff --git a/test/integration/test/sql-orm-client/mn-psl-implicit-migration.test.ts b/test/integration/test/sql-orm-client/mn-psl-implicit-migration.test.ts new file mode 100644 index 0000000000..4ae2180831 --- /dev/null +++ b/test/integration/test/sql-orm-client/mn-psl-implicit-migration.test.ts @@ -0,0 +1,123 @@ +// Migration / DDL coverage for the synthesised implicit-many-to-many junction. +// +// `mn-psl-implicit` authors `Post.tags Tag[]` / `Tag.posts Post[]` as two bare +// navigable list ends with NO junction model, so the interpreter synthesises a +// model-less junction `_PostToTag` (composite PK `(A, B)`, FK `A` → `posts.id`, +// FK `B` → `tags.id`). The junction is a normal contract storage table, so the +// migration system creates it like any other table — no special-casing. These +// tests drive the real migration planner over the real emitted contracts (one +// per target) against an empty schema and assert the synthesised junction's +// `CREATE TABLE` (with its composite primary key and the two foreign keys) is +// planned for both postgres and sqlite. +// +// Deserializing the emitted JSON runs the full sql contract validation pipeline, +// so a contract that failed to round-trip validation would throw at module load. + +import { + createPostgresBuiltinCodecLookup, + PostgresControlAdapter, +} from '@prisma-next/adapter-postgres/control'; +import { + createSqliteBuiltinCodecLookup, + SqliteControlAdapter, +} from '@prisma-next/adapter-sqlite/control'; +import { + INIT_ADDITIVE_POLICY, + type SqlMigrationPlanOperation, +} from '@prisma-next/family-sql/control'; +import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; +import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import { createPostgresMigrationPlanner } from '@prisma-next/target-postgres/planner'; +import { PostgresContractSerializer } from '@prisma-next/target-postgres/runtime'; +import { createSqliteMigrationPlanner } from '@prisma-next/target-sqlite/planner'; +import { SqliteContractSerializer } from '@prisma-next/target-sqlite/runtime'; +import { describe, expect, it } from 'vitest'; +import type { Contract as ImplicitPgContract } from './fixtures/mn-psl-implicit/generated/contract'; +import implicitPgContractJson from './fixtures/mn-psl-implicit/generated/contract.json' with { + type: 'json', +}; +import type { Contract as ImplicitSqliteContract } from './fixtures/mn-psl-implicit/generated-sqlite/contract'; +import implicitSqliteContractJson from './fixtures/mn-psl-implicit/generated-sqlite/contract.json' with { + type: 'json', +}; + +const implicitPgContract = new PostgresContractSerializer().deserializeContract( + implicitPgContractJson, +) as ImplicitPgContract; + +const implicitSqliteContract = new SqliteContractSerializer().deserializeContract( + implicitSqliteContractJson, +) as ImplicitSqliteContract; + +const emptySchema: SqlSchemaIR = { tables: {} }; + +describe('integration/mn-psl-implicit-migration', () => { + it('postgres migration plans CREATE TABLE for the synthesised _PostToTag junction with composite PK', async () => { + const planner = createPostgresMigrationPlanner( + new PostgresControlAdapter(createPostgresBuiltinCodecLookup()), + ); + + const result = planner.plan({ + contract: implicitPgContract, + schema: emptySchema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') throw new Error('expected planner success'); + const ops = (await Promise.all(result.plan.operations)) as SqlMigrationPlanOperation[]; + + const createJunction = ops.find((op) => op.id === 'table._PostToTag'); + expect(createJunction).toBeDefined(); + expect(createJunction!.execute[0]!.sql).toBe( + 'CREATE TABLE "public"."_PostToTag" (\n' + + ' "A" int4 NOT NULL,\n' + + ' "B" text NOT NULL,\n' + + ' PRIMARY KEY ("A", "B")\n' + + ')', + ); + + const fkA = ops.find((op) => op.id === 'foreignKey._PostToTag._PostToTag_A_fkey'); + const fkB = ops.find((op) => op.id === 'foreignKey._PostToTag._PostToTag_B_fkey'); + expect(fkA).toBeDefined(); + expect(fkB).toBeDefined(); + expect(fkA!.execute.map((step) => step.sql).join('\n')).toContain( + 'FOREIGN KEY ("A")\nREFERENCES "public"."posts" ("id")', + ); + expect(fkB!.execute.map((step) => step.sql).join('\n')).toContain( + 'FOREIGN KEY ("B")\nREFERENCES "public"."tags" ("id")', + ); + }); + + it('sqlite migration plans CREATE TABLE for the synthesised _PostToTag junction with composite PK and inline foreign keys', async () => { + const planner = createSqliteMigrationPlanner( + new SqliteControlAdapter(createSqliteBuiltinCodecLookup()), + ); + + const result = planner.plan({ + contract: implicitSqliteContract, + schema: emptySchema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') throw new Error('expected planner success'); + const ops = (await Promise.all(result.plan.operations)) as SqlMigrationPlanOperation[]; + + const createJunction = ops.find((op) => op.id === 'table._PostToTag'); + expect(createJunction).toBeDefined(); + expect(createJunction!.execute[0]!.sql).toBe( + 'CREATE TABLE "_PostToTag" (\n' + + ' "A" INTEGER NOT NULL,\n' + + ' "B" TEXT NOT NULL,\n' + + ' PRIMARY KEY ("A", "B"),\n' + + ' FOREIGN KEY ("A") REFERENCES "posts" ("id"),\n' + + ' FOREIGN KEY ("B") REFERENCES "tags" ("id")\n' + + ')', + ); + }); +}); diff --git a/test/integration/test/sql-orm-client/mn-psl-implicit-parity.test.ts b/test/integration/test/sql-orm-client/mn-psl-implicit-parity.test.ts new file mode 100644 index 0000000000..dca8a47c83 --- /dev/null +++ b/test/integration/test/sql-orm-client/mn-psl-implicit-parity.test.ts @@ -0,0 +1,205 @@ +// Runtime parity for an implicit many-to-many authored as two bare navigable +// list ends with NO junction model. +// +// `mn-psl-through-parity.test.ts` drives an M:N whose junction (`UserTag`) is an +// authored model. This file drives the `mn-psl-implicit` fixture, where +// `Post.tags Tag[]` / `Tag.posts Post[]` are both bare and no model links the +// pair, so the interpreter synthesises a model-less junction `_PostToTag` +// (composite PK `(A, B)`, FK `A` → `posts.id`, FK `B` → `tags.id`) and lowers +// both ends to `cardinality: 'N:M'` + `through` over it. The ORM `include` walks +// the synthesised junction exactly like an authored one — so an implicit M:N +// drives `include` end-to-end through a real emitted PSL contract with no +// authored junction model. +// +// The synthesised `_PostToTag` table is mixed-case, so the raw schema setup +// quotes it (an unquoted identifier folds to lowercase in postgres); the runtime +// references it quoted via the `through` descriptor. +// +// Deserializing the emitted JSON runs the full sql contract validation pipeline, +// so a contract that failed to round-trip validation would throw at module load. +// +// Standard: +// 1. Whole-row toEqual assertions on every test. +// 2. Explicit .select() in most tests; one implicit-selection readback. + +import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import pgvectorRuntime from '@prisma-next/extension-pgvector/runtime'; +import { Collection } from '@prisma-next/sql-orm-client'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime'; +import postgresTarget, { PostgresContractSerializer } from '@prisma-next/target-postgres/runtime'; +import { describe, expect, it } from 'vitest'; +import type { Contract as MnPslImplicitContract } from './fixtures/mn-psl-implicit/generated/contract'; +import mnPslImplicitContractJson from './fixtures/mn-psl-implicit/generated/contract.json' with { + type: 'json', +}; +import { timeouts, withCollectionRuntime } from './integration-helpers'; +import type { PgIntegrationRuntime } from './runtime-helpers'; + +const TAG_RUST = 'tag-rust'; +const TAG_TS = 'tag-typescript'; + +const mnPslImplicitContract = new PostgresContractSerializer().deserializeContract( + mnPslImplicitContractJson, +) as MnPslImplicitContract; + +const mnPslImplicitContext: ExecutionContext = createExecutionContext({ + contract: mnPslImplicitContract, + stack: createSqlExecutionStack({ + target: postgresTarget, + adapter: postgresAdapter, + extensionPacks: [pgvectorRuntime], + }), +}); + +function createPostsCollection(runtime: PgIntegrationRuntime) { + return new Collection({ runtime, context: mnPslImplicitContext }, 'Post', { + namespaceId: 'public', + }); +} + +// The implicit `posts` / `tags` / `_PostToTag` schema is not part of +// setupTestSchema, so build it directly. The synthesised junction is mixed-case +// and must be quoted to match the contract's table name. +async function createImplicitMnSchema(runtime: PgIntegrationRuntime): Promise { + await runtime.query('drop table if exists "_PostToTag"'); + await runtime.query('drop table if exists posts'); + await runtime.query('drop table if exists tags'); + await runtime.query(` + create table posts ( + id integer primary key, + title text not null + ) + `); + await runtime.query(` + create table tags ( + id text primary key, + label text not null unique + ) + `); + await runtime.query(` + create table "_PostToTag" ( + "A" integer not null references posts (id), + "B" text not null references tags (id), + primary key ("A", "B") + ) + `); +} + +async function seedPosts( + runtime: PgIntegrationRuntime, + posts: readonly { id: number; title: string }[], +): Promise { + for (const post of posts) { + await runtime.query('insert into posts (id, title) values ($1, $2)', [post.id, post.title]); + } +} + +async function seedTags( + runtime: PgIntegrationRuntime, + tags: readonly { id: string; label: string }[], +): Promise { + for (const tag of tags) { + await runtime.query('insert into tags (id, label) values ($1, $2)', [tag.id, tag.label]); + } +} + +async function seedPostTags( + runtime: PgIntegrationRuntime, + postTags: readonly { postId: number; tagId: string }[], +): Promise { + for (const pt of postTags) { + await runtime.query('insert into "_PostToTag" ("A", "B") values ($1, $2)', [ + pt.postId, + pt.tagId, + ]); + } +} + +describe('integration/mn-psl-implicit-parity', () => { + it( + 'include("tags") with explicit select returns selected fields on post and tags (whole-row toEqual)', + async () => { + await withCollectionRuntime(async (runtime) => { + await createImplicitMnSchema(runtime); + + const posts = createPostsCollection(runtime); + + await seedPosts(runtime, [ + { id: 1, title: 'Intro to Rust' }, + { id: 2, title: 'Intro to TypeScript' }, + ]); + await seedTags(runtime, [ + { id: TAG_RUST, label: 'Rust' }, + { id: TAG_TS, label: 'TypeScript' }, + ]); + await seedPostTags(runtime, [ + { postId: 1, tagId: TAG_RUST }, + { postId: 1, tagId: TAG_TS }, + { postId: 2, tagId: TAG_TS }, + ]); + + const rows = await posts + .select('id', 'title') + .orderBy((p) => p.id.asc()) + .include('tags', (tags) => tags.select('id', 'label').orderBy((t) => t.label.asc())) + .all(); + + expect(rows).toEqual([ + { + id: 1, + title: 'Intro to Rust', + tags: [ + { id: TAG_RUST, label: 'Rust' }, + { id: TAG_TS, label: 'TypeScript' }, + ], + }, + { + id: 2, + title: 'Intro to TypeScript', + tags: [{ id: TAG_TS, label: 'TypeScript' }], + }, + ]); + }, mnPslImplicitContext.contract); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'include("tags") with no .select returns the full default row shape (implicit selection)', + async () => { + await withCollectionRuntime(async (runtime) => { + await createImplicitMnSchema(runtime); + + const posts = createPostsCollection(runtime); + + await seedPosts(runtime, [{ id: 1, title: 'Intro to Rust' }]); + await seedTags(runtime, [ + { id: TAG_RUST, label: 'Rust' }, + { id: TAG_TS, label: 'TypeScript' }, + ]); + await seedPostTags(runtime, [ + { postId: 1, tagId: TAG_RUST }, + { postId: 1, tagId: TAG_TS }, + ]); + + const rows = await posts + .orderBy((p) => p.id.asc()) + .include('tags', (tags) => tags.orderBy((t) => t.label.asc())) + .all(); + + expect(rows).toEqual([ + { + id: 1, + title: 'Intro to Rust', + tags: [ + { id: TAG_RUST, label: 'Rust' }, + { id: TAG_TS, label: 'TypeScript' }, + ], + }, + ]); + }, mnPslImplicitContext.contract); + }, + timeouts.spinUpPpgDev, + ); +});