Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2322884
docs(infer-roundtrip): shape the infer→emit round-trip fix
wmadden-electric Jul 16, 2026
06a0bf5
test(infer-roundtrip): reproduce every infer -> emit fidelity defect …
wmadden-electric Jul 16, 2026
178f669
docs(infer-roundtrip): correct defect 5 — Decimal is unusable on post…
wmadden-electric Jul 16, 2026
88c0974
fix(psl-infer): pluralize via the maintained `pluralize` library
wmadden-electric Jul 16, 2026
ddee746
fix(contract-psl): accept the 1:1 back-relation and list storage defa…
wmadden-electric Jul 16, 2026
c956825
fix(contract-psl): reject autoincrement() on a list field
wmadden-electric Jul 16, 2026
fb6b86f
fix(target-postgres): postgres codec set covers unbounded numeric and…
wmadden-electric Jul 16, 2026
38c4ab2
fix(target-postgres): register built-in index types; explain dropped …
wmadden-electric Jul 16, 2026
82fcdfa
fix(sql-schema-ir): identity columns round-trip as autoincrement() on…
wmadden-electric Jul 16, 2026
4ff2c86
docs(infer-roundtrip): defect 8 is a class, not a jsonb bug
wmadden-electric Jul 16, 2026
5f9b5d1
fix(target-postgres): authoring-side dbgenerated resolves literal def…
wmadden-electric Jul 16, 2026
ca3a0c8
fix(extension-supabase): delete the fixed-defect workarounds, regener…
wmadden-electric Jul 16, 2026
3bdc1e0
docs(upgrade): record TML-3037 breaking changes for 0.15-to-0.16
wmadden-electric Jul 16, 2026
4f849b6
fix(framework-components): keep DefaultFunctionLoweringContext family…
wmadden-electric Jul 16, 2026
23cb5d7
test(infer-roundtrip): drop dispatch/test-case IDs from test names an…
wmadden-electric Jul 16, 2026
9061bf1
fix(contract-psl): revert list-default relaxation, restore function-d…
wmadden-electric Jul 16, 2026
4a1952f
fix(contract-psl): recognize a named singular field as a back relatio…
wmadden-electric Jul 16, 2026
eb8acb4
fix(target-postgres): dbgenerated adopts literal resolutions only, no…
wmadden-electric Jul 16, 2026
1648ca2
test(infer-roundtrip): assert the exact decoded date through include(…
wmadden-electric Jul 16, 2026
f103431
docs(infer-roundtrip): defect 7 has no fix of its own — it is defect 8
wmadden-electric Jul 16, 2026
a2b9fb7
fix(sql-schema-ir): delete identity from SqlColumnIR, compensate once…
wmadden-electric Jul 17, 2026
2803794
fix(framework-components): delete fieldContext, normalize defaults on…
wmadden-electric Jul 17, 2026
82ffdd2
fix(contract-psl): source db.Date codec id from the target, not a 2-s…
wmadden-electric Jul 17, 2026
3ae4bb6
fix(target-postgres): infer prints literal list defaults from resolve…
wmadden-electric Jul 17, 2026
9608d00
Merge remote-tracking branch 'origin/main' into tml-3037-contract-inf…
wmadden-electric Jul 17, 2026
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
21 changes: 21 additions & 0 deletions packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,25 @@ describe('SqlColumnIR', () => {
expect(column.resolvedDefault).toEqual({ kind: 'literal', value: 'x' });
});
});

describe('identity columns (introspected, no raw default)', () => {
it('yields a default child node from resolvedDefault alone, with no raw default', () => {
// A `GENERATED ... AS IDENTITY` column has no `column_default` at
// all — the postgres control adapter sets `resolvedDefault` directly
// to `autoincrement()` without a raw expression to parse, so
// `children()` must still produce a default node without a `default`
// (raw) field.
const column = new SqlColumnIR({
name: 'id',
nativeType: 'int4',
nullable: false,
resolvedDefault: { kind: 'function', expression: 'autoincrement()' },
});
expect(column.children()).toEqual([
new SqlColumnDefaultIR({
resolved: { kind: 'function', expression: 'autoincrement()' },
}),
]);
});
});
});
42 changes: 37 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 @@ -79,7 +79,7 @@ import { blindCast } from '@prisma-next/utils/casts';
import { ifDefined } from '@prisma-next/utils/defined';
import { notOk, ok, type Result } from '@prisma-next/utils/result';

import { getAttribute, mapFieldNamesToColumns } from './psl-attribute-parsing';
import { getAttribute, getNamedArgument, mapFieldNamesToColumns } from './psl-attribute-parsing';
import type { ColumnDescriptor } from './psl-column-resolution';
import {
checkUncomposedNamespace,
Expand All @@ -103,7 +103,7 @@ import {
interpretRelationAttribute,
type ModelBackrelationCandidate,
normalizeReferentialAction,
validateNavigationListFieldAttributes,
validateBackrelationFieldAttributes,
} from './psl-relation-resolution';
import {
baseModelSpec,
Expand Down Expand Up @@ -668,6 +668,21 @@ interface BuildModelNodeResult {
readonly modelAttributeEntities: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
}

/**
* The owning side of a relation is the one that declares `fields`/`references` on its
* `@relation` attribute — those name the FK columns. A singular model-typed field whose
* `@relation` carries only a name (or nothing at all) is the back side: infer prints exactly
* that shape for a 1:1 back-relation whenever the FK needs disambiguating (two FKs between the
* same table pair, or a self-referencing unique FK). Checking for the attribute's mere presence
* would misclassify that back side as the owning side.
*/
function relationAttributeDeclaresOwningSide(relationAttribute: ResolvedAttribute): boolean {
return (
getNamedArgument(relationAttribute, 'fields') !== undefined ||
getNamedArgument(relationAttribute, 'references') !== undefined
);
}

function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult {
const { model, mapping, sourceId, diagnostics } = input;
const tableName = mapping.tableName;
Expand Down Expand Up @@ -726,10 +741,20 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult

const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
for (const field of Object.values(model.fields)) {
if (!field.list || !input.modelNames.has(field.typeName)) {
if (!input.modelNames.has(field.typeName)) {
continue;
}
const relationAttribute = getAttribute(field.attributes, 'relation');
if (
!field.list &&
relationAttribute &&
relationAttributeDeclaresOwningSide(relationAttribute)
) {
// The owning side of the relation: it declares fields/references and is
// lowered separately below, by the `relationAttributes` FK-building loop.
continue;
}
const attributesValid = validateNavigationListFieldAttributes({
const attributesValid = validateBackrelationFieldAttributes({
modelName: model.name,
field,
sourceId,
Expand All @@ -739,7 +764,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
familyId: input.familyId,
targetId: input.targetId,
});
const relationAttribute = getAttribute(field.attributes, 'relation');
let relationName: string | undefined;
if (relationAttribute) {
const parsedRelation = interpretRelationAttribute({
Expand Down Expand Up @@ -786,6 +810,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
tableName,
field,
targetModelName: field.typeName,
isList: field.list,
...ifDefined('relationName', relationName),
});
}
Expand Down Expand Up @@ -1095,6 +1120,13 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
continue;
}

if (!relationAttributeDeclaresOwningSide(relationAttribute.relation)) {
// A singular model-typed field whose `@relation` carries only a name (or nothing) is the
// back side of a 1:1 relation, already lowered above via backrelationCandidates. It is
// not the owning side, so it has no fields/references to validate here.
continue;
}

// Cross-contract-space relation: the target model lives in a different contract space
// identified by `typeContractSpaceId` (e.g. `supabase:auth.User`).
if (fieldTypeContractSpaceId !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,11 @@ export function resolveFieldTypeDescriptor(input: {
* Declarative specification for @db.* native type attributes.
*
* Argument kinds:
* - `noArgs`: No arguments accepted; `codecId: null` means inherit from baseDescriptor.
* - `noArgs`: No arguments accepted; `codecId: null` means resolve at runtime
* from the target-contributed `scalarTypeDescriptors` map keyed by the
* attribute's own name (e.g. `"db.Date"`), falling back to `baseDescriptor`
* (the base PSL type's own codec) when the target contributes nothing
* under that key — see `resolveDbNativeTypeAttribute`.
* - `optionalLength`: Zero or one positional integer (minimum 1), stored as `{ length }`.
* - `optionalPrecision`: Zero or one positional integer (minimum 0), stored as `{ precision }`.
* - `optionalNumeric`: Zero, one, or two positional integers (precision + scale).
Expand Down Expand Up @@ -783,6 +787,18 @@ export function resolveDbNativeTypeAttribute(input: {
readonly attribute: ResolvedAttribute;
readonly baseType: string;
readonly baseDescriptor: ColumnDescriptor;
/**
* Target-contributed descriptor lookup, keyed by PSL scalar type name
* (e.g. `"DateTime"`) everywhere else it's used — but also consulted here
* by the full `@db.*` attribute name (e.g. `"db.Date"`) as a `noArgs`
* spec's codec-id source. A `noArgs` spec with `codecId: null` can't
* simply inherit the base type's own descriptor (that's `baseDescriptor`,
* already the wrong codec for e.g. `@db.Date` vs its `DateTime` base); the
* target contributes the attribute-specific codec id under its own key in
* the same map instead, keeping this family module free of any target's
* concrete codec id.
*/
readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
readonly diagnostics: ContractSourceDiagnostic[];
readonly sourceId: string;
readonly entityLabel: string;
Expand Down Expand Up @@ -818,7 +834,10 @@ export function resolveDbNativeTypeAttribute(input: {
});
}
return {
codecId: spec.codecId ?? input.baseDescriptor.codecId,
codecId:
spec.codecId ??
input.scalarTypeDescriptors.get(input.attribute.name)?.codecId ??
input.baseDescriptor.codecId,
nativeType: spec.nativeType,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,13 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
if (field.typeContractSpaceId !== undefined && relationAttribute) {
continue;
}
// A model-typed, non-list field with no `@relation` is the back side of a
// 1:1 relation — the owning side always carries `@relation(fields: [...],
// references: [...])`. It is lowered separately, via the interpreter's
// backrelation-candidate matching, not as a scalar column here.
if (isModelField) {
continue;
}

const isValueObjectField = compositeTypeNames.has(field.typeName);
const isListField = field.list;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export function resolveNamedTypeDeclarations(input: ResolveNamedTypeDeclarations
attribute: dbNativeTypeAttribute,
baseType,
baseDescriptor,
scalarTypeDescriptors: input.scalarTypeDescriptors,
diagnostics: input.diagnostics,
sourceId: input.sourceId,
entityLabel: `Named type "${declaration.name}"`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export type ModelBackrelationCandidate = {
readonly tableName: string;
readonly field: FieldSymbol;
readonly targetModelName: string;
/** Whether the PSL field itself is list-typed (`Target[]`) rather than singular (`Target?`). A singular candidate is the back side of a 1:1 relation and can never be many-to-many. */
readonly isList: boolean;
readonly relationName?: string;
};

Expand Down Expand Up @@ -453,35 +455,39 @@ export function applyBackrelationCandidates(input: {
: [...pairMatches];

if (matches.length === 0) {
const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({
candidate,
fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel,
modelIdColumns: input.modelIdColumns,
});
const junctionPair = junctionPairs[0];
if (junctionPairs.length === 1 && junctionPair) {
relationsForModel(input.modelRelations, candidate.modelName).push(
manyToManyRelationNode(candidate, junctionPair),
);
continue;
}
if (junctionPairs.length > 1) {
input.diagnostics.push({
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add @relation(name: "...") (or @relation("...")) to the list field and the junction FK-side relation pointing back at "${candidate.modelName}" to disambiguate.`,
sourceId: input.sourceId,
span: candidate.field.span,
// A singular candidate is the back side of a 1:1 — many-to-many junction
// matching only makes sense for a list-typed backrelation.
if (candidate.isList) {
const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({
candidate,
fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel,
modelIdColumns: input.modelIdColumns,
});
continue;
}
const nearMiss = nearMisses[0];
if (nearMiss) {
input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId));
continue;
const junctionPair = junctionPairs[0];
if (junctionPairs.length === 1 && junctionPair) {
relationsForModel(input.modelRelations, candidate.modelName).push(
manyToManyRelationNode(candidate, junctionPair),
);
continue;
}
if (junctionPairs.length > 1) {
input.diagnostics.push({
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add @relation(name: "...") (or @relation("...")) to the list field and the junction FK-side relation pointing back at "${candidate.modelName}" to disambiguate.`,
sourceId: input.sourceId,
span: candidate.field.span,
});
continue;
}
const nearMiss = nearMisses[0];
if (nearMiss) {
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(fields: [...], references: [...]) on the FK-side relation or use an explicit join model for many-to-many.`,
message: `Backrelation field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(fields: [...], references: [...]) on the FK-side relation${candidate.isList ? ' or use an explicit join model for many-to-many' : ''}.`,
sourceId: input.sourceId,
span: candidate.field.span,
});
Expand All @@ -490,7 +496,7 @@ export function applyBackrelationCandidates(input: {
if (matches.length > 1) {
input.diagnostics.push({
code: 'PSL_AMBIGUOUS_BACKRELATION_LIST',
message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation(name: "...") (or @relation("...")) to both sides to disambiguate.`,
message: `Backrelation field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation(name: "...") (or @relation("...")) to both sides to disambiguate.`,
sourceId: input.sourceId,
span: candidate.field.span,
});
Expand All @@ -506,7 +512,7 @@ export function applyBackrelationCandidates(input: {
toModel: matched.declaringModelName,
toTable: matched.declaringTableName,
...ifDefined('toNamespaceId', matched.declaringNamespaceId),
cardinality: '1:N',
cardinality: candidate.isList ? '1:N' : '1:1',
on: {
parentTable: candidate.tableName,
parentColumns: matched.referencedColumns,
Expand All @@ -517,7 +523,7 @@ export function applyBackrelationCandidates(input: {
}
}

export function validateNavigationListFieldAttributes(input: {
export function validateBackrelationFieldAttributes(input: {
readonly modelName: string;
readonly field: FieldSymbol;
readonly sourceId: string;
Expand Down
4 changes: 4 additions & 0 deletions packages/2-sql/2-authoring/contract-psl/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ export const postgresScalarTypeDescriptors = new Map([
['DateTime', { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz' }],
['Json', { codecId: 'pg/jsonb@1', nativeType: 'jsonb' }],
['Bytes', { codecId: 'pg/bytea@1', nativeType: 'bytea' }],
// Keyed by the full `@db.*` attribute name, not a PSL base type — see the
// matching entry (and its comment) in the real target's
// `postgresScalarTypeDescriptors`, `control-mutation-defaults.ts`.
['db.Date', { codecId: 'pg/date@1', nativeType: 'date' }],
] as const);

export function buildSymbolTableInput(
Expand Down
Loading
Loading