Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions LINEAR.md
Comment thread
jkomyno marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# WS5: PSL composite primary keys

## Summary

Support SQL PSL model-level composite primary keys via `@@id([fieldA, fieldB])` in the contract interpreter, closing the current printer/interpreter asymmetry for greenfield junction tables.

## Problem

`contract infer` can already print composite SQL primary keys as `@@id([...])`, but the PSL interpreter rejects `@@id` as an unsupported model attribute. This blocks the WS5 M2 SaaS skeleton, specifically membership/junction models that use composite primary keys instead of surrogate IDs.

## Scope

- Accept `@@id([fieldA, fieldB])` on SQL PSL models.
- Preserve declared field order in the emitted primary key.
- Resolve fields through existing `@map`/`@@map` mappings so storage primary keys use column names.
- Support the `map: "constraint_name"` argument consistently with `@@unique` and `@@index`.
- Return diagnostics for malformed field lists, unknown fields, nullable fields, and duplicate field-level/model-level primary key declarations.
- Add a regression proving PSL emitted by `contract infer` for a composite primary key can be interpreted back into an equivalent contract shape.

## Out of Scope

- Native scalar arrays.
- `@updatedAt`.
- Inline `@db.*` field attributes.
- P7 upgrade compatibility syntax.
- New relation inference behavior beyond what composite primary keys require.

## Acceptance Criteria

- A model with `@@id([orgId, userId])` emits `storage.tables.<table>.primaryKey.columns` with the mapped column names in the same order.
- A model with `@@id([orgId, userId], map: "membership_pkey")` emits the primary key name.
- A model with `@@id` and no field-level `@id` no longer triggers `PSL_MISSING_PRIMARY_KEY`.
- A model that combines field-level `@id` with model-level `@@id` fails with a clear diagnostic.
- A model that references an unknown or nullable field in `@@id` fails with a clear diagnostic.
- Focused package tests pass for `@prisma-next/sql-contract-psl`.
117 changes: 112 additions & 5 deletions packages/2-sql/2-authoring/contract-psl/src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type ForeignKeyNode,
type IndexNode,
type ModelNode,
type PrimaryKeyNode,
type UniqueConstraintNode,
} from '@prisma-next/sql-contract-ts/contract-builder';
import { ifDefined } from '@prisma-next/utils/defined';
Expand Down Expand Up @@ -431,6 +432,97 @@ interface BuildModelNodeInput {
readonly diagnostics: ContractSourceDiagnostic[];
}

function resolveModelLevelPrimaryKey(input: {
readonly model: PslModel;
readonly attributes: readonly PslAttribute[];
readonly resolvedFields: readonly ResolvedField[];
readonly fieldPrimaryKeyFields: readonly ResolvedField[];
readonly sourceId: string;
readonly diagnostics: ContractSourceDiagnostic[];
}): PrimaryKeyNode | undefined {
const [attribute, duplicateAttribute] = input.attributes;
if (!attribute) {
return undefined;
}

if (duplicateAttribute) {
input.diagnostics.push({
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
message: `Model "${input.model.name}" declares multiple @@id attributes`,
sourceId: input.sourceId,
span: duplicateAttribute.span,
});
return undefined;
}

if (input.fieldPrimaryKeyFields.length > 0) {
input.diagnostics.push({
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
message: `Model "${input.model.name}" cannot combine field-level @id with model-level @@id`,
sourceId: input.sourceId,
span: attribute.span,
});
return undefined;
}

const fieldNames = parseAttributeFieldList({
attribute,
sourceId: input.sourceId,
diagnostics: input.diagnostics,
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
messagePrefix: `Model "${input.model.name}" @@id`,
});
if (!fieldNames) {
return undefined;
}

const resolvedFieldsByName = new Map(
input.resolvedFields.map((field) => [field.field.name, field]),
);
const fieldColumns = new Map(
input.resolvedFields.map((field) => [field.field.name, field.columnName]),
);
const columns = mapFieldNamesToColumns({
modelName: input.model.name,
fieldNames,
mapping: { fieldColumns },
sourceId: input.sourceId,
diagnostics: input.diagnostics,
span: attribute.span,
contextLabel: `Model "${input.model.name}" @@id`,
});
if (!columns) {
return undefined;
}

const nullableFieldName = fieldNames.find(
(fieldName) => resolvedFieldsByName.get(fieldName)?.field.optional,
);
if (nullableFieldName) {
input.diagnostics.push({
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
message: `Model "${input.model.name}" @@id cannot include nullable field "${input.model.name}.${nullableFieldName}"`,
sourceId: input.sourceId,
span: attribute.span,
});
return undefined;
}

const constraintName = parseConstraintMapArgument({
attribute,
sourceId: input.sourceId,
diagnostics: input.diagnostics,
entityLabel: `Model "${input.model.name}" @@id`,
span: attribute.span,
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
});

return {
columns,
...ifDefined('name', constraintName),
};
}

interface BuildModelNodeResult {
readonly modelNode: ModelNode;
readonly fkRelationMetadata: FkRelationMetadata[];
Expand Down Expand Up @@ -460,14 +552,26 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
scalarTypeDescriptors: input.scalarTypeDescriptors,
});

const primaryKeyFields = resolvedFields.filter((field) => field.isId);
const primaryKeyColumns = primaryKeyFields.map((field) => field.columnName);
const primaryKeyName = primaryKeyFields.length === 1 ? primaryKeyFields[0]?.idName : undefined;
const fieldPrimaryKeyFields = resolvedFields.filter((field) => field.isId);
const fieldPrimaryKeyColumns = fieldPrimaryKeyFields.map((field) => field.columnName);
const fieldPrimaryKeyName =
fieldPrimaryKeyFields.length === 1 ? fieldPrimaryKeyFields[0]?.idName : undefined;
const modelPrimaryKeyAttributes = model.attributes.filter((attribute) => attribute.name === 'id');
const modelPrimaryKey = resolveModelLevelPrimaryKey({
model,
attributes: modelPrimaryKeyAttributes,
resolvedFields,
fieldPrimaryKeyFields,
sourceId,
diagnostics,
});
const primaryKeyColumns = modelPrimaryKey?.columns ?? fieldPrimaryKeyColumns;
const primaryKeyName = modelPrimaryKey?.name ?? fieldPrimaryKeyName;
const isVariantModel = model.attributes.some((attr) => attr.name === 'base');
if (primaryKeyColumns.length === 0 && !isVariantModel) {
if (primaryKeyColumns.length === 0 && !isVariantModel && modelPrimaryKeyAttributes.length === 0) {
Comment thread
jkomyno marked this conversation as resolved.
Outdated
diagnostics.push({
code: 'PSL_MISSING_PRIMARY_KEY',
message: `Model "${model.name}" must declare at least one @id field for SQL provider`,
message: `Model "${model.name}" must declare at least one @id field or @@id attribute for SQL provider`,
sourceId,
span: model.span,
});
Expand Down Expand Up @@ -554,6 +658,9 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
if (modelAttribute.name === 'map') {
continue;
}
if (modelAttribute.name === 'id') {
continue;
}
if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ const testSpan: PslSpan = {
end: { line: 1, column: 7, offset: 6 },
};

function expectDiagnosticForSchema(
schema: string,
diagnostic: { readonly code: string; readonly message: string },
): void {
const document = parsePslDocument({ schema, sourceId: 'schema.prisma' });
const result = interpretPslDocumentToSqlContract({
...baseInput,
document,
controlMutationDefaults: builtinControlMutationDefaults,
});

expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.failure.diagnostics).toEqual(
expect.arrayContaining([expect.objectContaining(diagnostic)]),
);
}

describe('interpretPslDocumentToSqlContract diagnostics', () => {
it('returns diagnostics when target context is missing', () => {
const document = parsePslDocument({
Expand Down Expand Up @@ -194,6 +212,55 @@ model User {
);
});

it('returns diagnostics for duplicate field and model primary keys', () => {
expectDiagnosticForSchema(
`model Membership {
id Int @id
orgId String
userId String

@@id([orgId, userId])
}
`,
{
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
message: 'Model "Membership" cannot combine field-level @id with model-level @@id',
},
);
});

it('returns diagnostics for nullable composite primary key fields', () => {
expectDiagnosticForSchema(
`model Membership {
orgId String
userId String?

@@id([orgId, userId])
}
`,
{
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
message: 'Model "Membership" @@id cannot include nullable field "Membership.userId"',
},
);
});

it('returns diagnostics for unknown composite primary key fields', () => {
expectDiagnosticForSchema(
`model Membership {
orgId String
userId String

@@id([orgId, missingId])
}
`,
{
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
message: 'Model "Membership" @@id references unknown field "Membership.missingId"',
},
);
});

it('returns diagnostics for model attributes with unrecognized extension namespace', () => {
const document = parsePslDocument({
schema: `model Team {
Expand Down
30 changes: 30 additions & 0 deletions packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,4 +354,34 @@ model Member {
},
});
});

it('maps model-level composite primary keys to storage columns', () => {
const document = parsePslDocument({
schema: `model Membership {
orgId String @map("org_id")
userId String @map("user_id")

@@id([orgId, userId], map: "membership_pkey")
@@map("membership")
}
`,
sourceId: 'schema.prisma',
});

const result = interpretPslDocumentToSqlContract({
document,
controlMutationDefaults: builtinControlMutationDefaults,
});

expect(result.ok).toBe(true);
if (!result.ok) return;

expect(result.value.storage).toMatchObject({
tables: {
membership: {
primaryKey: { columns: ['org_id', 'user_id'], name: 'membership_pkey' },
},
},
});
});
});
Loading