Skip to content

Commit 284b2d0

Browse files
committed
fix(planner): D3b schema-qualify enum projection + cross-namespace column binding
Contract-to-contract migration planning (migration plan) projects the prior contract to a schema IR via the family provider deriveAnnotations, which keyed enum storageTypes by bare nativeType. The compound-keyed readExistingEnumValues could not find them, so the planner took the introduce path instead of the rebuild recipe. deriveAnnotations now schema-qualifies namespace-scoped enum keys via an injected EnumStorageKeyResolver. The Postgres DDL-schema resolution and the compound-key format stay in target-postgres (control.ts wires the resolver from resolveDdlSchemaForNamespaceStorage + enumStorageCompoundKey); the family layer treats the returned string as opaque. Codec-typed storage types stay bare-keyed. lint:deps stays green — no family to target import. Also fix enumRebuildCallRecipe column-ref binding: a column carries a bare typeRef whose enum may live in a different namespace (the builder places contract-level types: enums in the default public namespace while a model table sits in the unbound namespace). Resolve the column to its own-namespace enum when present, else the ambient/public enum, replacing the over-narrow nsId === namespaceId filter that dropped the alterColumnType migration. Signed-off-by: Will Madden <madden@prisma.io>
1 parent 5dbd389 commit 284b2d0

5 files changed

Lines changed: 214 additions & 36 deletions

File tree

packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts

Lines changed: 81 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ColumnDefault, Contract } from '@prisma-next/contract/types';
22
import type { MigrationPlannerConflict } from '@prisma-next/framework-components/control';
3+
import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
34
import {
45
type ForeignKey,
56
type Index,
@@ -52,6 +53,26 @@ export type NativeTypeExpander = (input: {
5253
*/
5354
export type DefaultRenderer = (def: ColumnDefault, column: StorageColumn) => string;
5455

56+
/**
57+
* Target-supplied callback that computes the schema-qualified annotation-map
58+
* key for a namespace-scoped enum storage type.
59+
*
60+
* Enum lookups (`readExistingEnumValues`) are namespace/schema-qualified so two
61+
* namespaces holding an enum with the same TypeScript name (and even the same
62+
* native type) resolve to distinct live-database types. The *format* of that
63+
* key — and the namespace → DDL-schema resolution it depends on — is a
64+
* target-specific concern (Postgres schemas; SQLite/MySQL differ), so the
65+
* target injects it here as data rather than the family layer importing a
66+
* concrete `ddlSchemaName`/key implementation. This keeps the family layer
67+
* target-agnostic (no `@prisma-next/target-*` dependency) while the projection
68+
* still emits keys that match the target's read side exactly.
69+
*/
70+
export type EnumStorageKeyResolver = (
71+
storage: SqlStorage,
72+
namespaceId: string,
73+
nativeType: string,
74+
) => string;
75+
5576
function convertColumn(
5677
name: string,
5778
column: StorageColumn,
@@ -265,6 +286,14 @@ export interface ContractToSchemaIROptions {
265286
readonly annotationNamespace: string;
266287
readonly expandNativeType?: NativeTypeExpander;
267288
readonly renderDefault?: DefaultRenderer;
289+
/**
290+
* Target-supplied resolver for namespace/schema-qualified enum annotation
291+
* keys. When provided (Postgres), every namespace-scoped enum is keyed by the
292+
* resolver's output so the projected `storageTypes` map matches the target's
293+
* `readExistingEnumValues` lookup. Targets without namespace-qualified enum
294+
* storage (SQLite) omit it; enums are absent there.
295+
*/
296+
readonly resolveEnumStorageKey?: EnumStorageKeyResolver;
268297
}
269298

270299
/**
@@ -330,55 +359,73 @@ export function contractToSchemaIR(
330359
}
331360
}
332361

333-
const annotations = deriveAnnotations(storage, options.annotationNamespace);
362+
const annotations = deriveAnnotations(
363+
storage,
364+
options.annotationNamespace,
365+
options.resolveEnumStorageKey,
366+
);
334367

335368
return {
336369
tables,
337370
...ifDefined('annotations', annotations),
338371
};
339372
}
340373

374+
/**
375+
* Normalises a native enum storage entry to the codec-typed annotation shape
376+
* `{codecId, nativeType, typeParams}` the introspector writes and
377+
* `readExistingEnumValues` reads (`existing.codecId` + `existing.typeParams.values`).
378+
* Without this the projector would emit the raw `PostgresEnumStorageEntry`
379+
* shape (top-level `values`, no `typeParams`) and the enum would read as new.
380+
*/
381+
function normalizeEnumAnnotation(entry: PostgresEnumStorageEntry): StorageTypeInstance {
382+
return toStorageTypeInstance({
383+
codecId: entry.codecId,
384+
nativeType: entry.nativeType,
385+
typeParams: { values: entry.values },
386+
});
387+
}
388+
341389
function deriveAnnotations(
342390
storage: SqlStorage,
343391
annotationNamespace: string,
392+
resolveEnumStorageKey: EnumStorageKeyResolver | undefined,
344393
): SqlAnnotations | undefined {
345-
const allTypes: Record<string, StorageTypeInstance | PostgresEnumStorageEntry> = {
346-
...((storage.types ?? {}) as ResolvedStorageTypes),
347-
};
348-
for (const ns of Object.values(storage.namespaces)) {
349-
const nsEnums = (ns as { enum?: Record<string, PostgresEnumStorageEntry> }).enum;
350-
if (nsEnums) {
351-
for (const [k, v] of Object.entries(nsEnums)) {
352-
allTypes[k] = v;
353-
}
354-
}
355-
}
356-
const types = allTypes as ResolvedStorageTypes;
357-
if (Object.keys(types).length === 0) return undefined;
358-
// Re-key by nativeType, normalising every variant to the codec-typed
359-
// annotation shape `{codecId, nativeType, typeParams}` produced by the
360-
// adapter introspector (`introspectPostgresEnumTypes` writes that shape;
361-
// see also `enum-planning.ts § readExistingEnumValues`, which reads
362-
// `existing.codecId` + `existing.typeParams.values`). Without this
363-
// normalisation, the projector would emit the raw
364-
// `PostgresEnumStorageEntry` shape (top-level `values`, no `typeParams`)
365-
// and downstream Schema IR consumers that walk the codec-typed shape
366-
// would see enum entries as new (e.g. the planner emits a fresh
367-
// `CreateEnumTypeCall` instead of the rebuild recipe). Unknown future
368-
// kinds without `nativeType` are skipped rather than crashing.
369-
const byNativeType: Record<string, StorageTypeInstance> = {};
370-
for (const typeInstance of Object.values(types)) {
394+
const storageTypes: Record<string, StorageTypeInstance> = {};
395+
396+
// Top-level `storage.types`: codec-typed entries (vector, decimal, …) keyed
397+
// by bare `nativeType` (unchanged). Post-S1.B enums live in
398+
// `namespaces[*].enum`, not here; a defensive top-level enum is still
399+
// namespace/schema-qualified via the resolver under the unbound coordinate
400+
// so it never collides on a bare name.
401+
for (const typeInstance of Object.values((storage.types ?? {}) as ResolvedStorageTypes)) {
371402
if (isPostgresEnumStorageEntry(typeInstance)) {
372-
byNativeType[typeInstance.nativeType] = toStorageTypeInstance({
373-
codecId: typeInstance.codecId,
374-
nativeType: typeInstance.nativeType,
375-
typeParams: { values: typeInstance.values },
376-
});
403+
const key = resolveEnumStorageKey
404+
? resolveEnumStorageKey(storage, UNBOUND_NAMESPACE_ID, typeInstance.nativeType)
405+
: typeInstance.nativeType;
406+
storageTypes[key] = normalizeEnumAnnotation(typeInstance);
377407
continue;
378408
}
379409
if (isStorageTypeInstance(typeInstance)) {
380-
byNativeType[typeInstance.nativeType] = typeInstance;
410+
storageTypes[typeInstance.nativeType] = typeInstance;
411+
}
412+
}
413+
414+
// Namespace-scoped enums: schema-qualified compound key matching the target's
415+
// `readExistingEnumValues` read side, so two namespaces sharing an enum name
416+
// (or native type) resolve to distinct live-database types.
417+
for (const [namespaceId, ns] of Object.entries(storage.namespaces)) {
418+
const nsEnums = (ns as { enum?: Record<string, PostgresEnumStorageEntry> }).enum;
419+
if (!nsEnums) continue;
420+
for (const entry of Object.values(nsEnums)) {
421+
if (!isPostgresEnumStorageEntry(entry)) continue;
422+
const key = resolveEnumStorageKey
423+
? resolveEnumStorageKey(storage, namespaceId, entry.nativeType)
424+
: entry.nativeType;
425+
storageTypes[key] = normalizeEnumAnnotation(entry);
381426
}
382427
}
383-
return { [annotationNamespace]: { storageTypes: byNativeType } };
428+
429+
if (Object.keys(storageTypes).length === 0) return undefined;
430+
return { [annotationNamespace]: { storageTypes } };
384431
}

packages/2-sql/9-family/src/exports/control.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type { SqlControlFamilyInstance } from '../core/control-instance';
1717
export type {
1818
ContractToSchemaIROptions,
1919
DefaultRenderer,
20+
EnumStorageKeyResolver,
2021
NativeTypeExpander,
2122
} from '../core/migrations/contract-to-schema-ir';
2223
// Contract → SchemaIR conversion for offline migration planning

packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,43 @@ export function resolveDdlSchemaForNamespace(ctx: StrategyContext, namespaceId:
127127
return namespaceId;
128128
}
129129

130+
/** Default Postgres enum landing namespace — where contract-level (`types:`)
131+
* enums are placed by the authoring builder when no explicit namespace is
132+
* given. Mirrors `POSTGRES_ENUM_NAMESPACE_ID` in the contract-ts builder. */
133+
const DEFAULT_ENUM_NAMESPACE_ID = 'public';
134+
135+
function namespaceHasEnum(storage: SqlStorage, namespaceId: string, typeName: string): boolean {
136+
const ns = storage.namespaces[namespaceId];
137+
if (!ns || !('enum' in ns) || ns.enum == null) return false;
138+
return (ns.enum as Record<string, PostgresEnumStorageEntry>)[typeName] !== undefined;
139+
}
140+
141+
/**
142+
* Resolves which namespace's enum a column's bare `typeRef` binds to.
143+
*
144+
* Columns carry a bare (non-namespace-qualified) `typeRef`; the enum it names
145+
* may live in a different namespace than the column's own (the authoring
146+
* builder places contract-level `types:` enums in the default `public`
147+
* namespace while a model's table may sit in the unbound namespace). The
148+
* binding rule: an enum declared in the column's *own* namespace shadows
149+
* everything; otherwise the column references the ambient enum — the sole
150+
* namespace that defines `typeName`, preferring the default `public`
151+
* namespace when several do. Returns `undefined` when no namespace defines it.
152+
*/
153+
function resolveColumnEnumNamespace(
154+
storage: SqlStorage,
155+
columnNamespaceId: string,
156+
typeName: string,
157+
): string | undefined {
158+
if (namespaceHasEnum(storage, columnNamespaceId, typeName)) return columnNamespaceId;
159+
const owners = Object.keys(storage.namespaces).filter((nsId) =>
160+
namespaceHasEnum(storage, nsId, typeName),
161+
);
162+
if (owners.length === 1) return owners[0];
163+
if (owners.includes(DEFAULT_ENUM_NAMESPACE_ID)) return DEFAULT_ENUM_NAMESPACE_ID;
164+
return owners[0];
165+
}
166+
130167
/**
131168
* Finds a type entry by explicit namespace coordinate. Namespace types (e.g.
132169
* Postgres enums) live under `storage.namespaces[nsId].enum`. Returns the
@@ -392,12 +429,20 @@ function enumRebuildCallRecipe(
392429
ctx.schema,
393430
);
394431

432+
// Migrate every column whose `typeRef` binds to *this* enum. The column's
433+
// bare `typeRef` resolves to an enum namespace (own-namespace shadows;
434+
// otherwise the ambient/default `public` enum), so a column in the unbound
435+
// namespace correctly binds to a `public`-namespace enum, while two
436+
// same-named enums in distinct namespaces keep their columns disjoint.
395437
const columnRefs: { namespaceId: string; table: string; column: string }[] = [];
396438
for (const [nsId, ns] of Object.entries(ctx.toContract.storage.namespaces)) {
397439
for (const [tableName, tableNode] of Object.entries(ns.tables)) {
398440
const table = tableNode as StorageTable;
399441
for (const [columnName, column] of Object.entries(table.columns)) {
400-
if (column.typeRef === typeName && nsId === namespaceId) {
442+
if (
443+
column.typeRef === typeName &&
444+
resolveColumnEnumNamespace(ctx.toContract.storage, nsId, typeName) === namespaceId
445+
) {
401446
columnRefs.push({ namespaceId: nsId, table: tableName, column: columnName });
402447
}
403448
}

packages/3-targets/3-targets/postgres/src/exports/control.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import type {
1212
import type { SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types';
1313
import { ifDefined } from '@prisma-next/utils/defined';
1414
import { postgresTargetDescriptorMeta } from '../core/descriptor-meta';
15+
import {
16+
enumStorageCompoundKey,
17+
resolveDdlSchemaForNamespaceStorage,
18+
} from '../core/migrations/enum-planning';
1519
import { createPostgresMigrationPlanner } from '../core/migrations/planner';
1620
import { renderDefaultLiteral } from '../core/migrations/planner-ddl-builders';
1721
import type { PostgresPlanTargetDetails } from '../core/migrations/planner-target-details';
@@ -78,6 +82,18 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP
7882
annotationNamespace: 'pg',
7983
...ifDefined('expandNativeType', expander),
8084
renderDefault: postgresRenderDefault,
85+
// Schema-qualify enum annotation keys so the projected "from" IR's
86+
// `storageTypes` match `readExistingEnumValues` on the read side
87+
// (the contract-to-contract `migration plan` path). The DDL-schema
88+
// resolution + compound-key format stay here in the target layer;
89+
// the family projector treats the returned string as opaque.
90+
// `undefined` schema IR ⇒ the unbound coordinate resolves to the
91+
// default `public` landing schema, matching the read-side fallback.
92+
resolveEnumStorageKey: (storage, namespaceId, nativeType) =>
93+
enumStorageCompoundKey(
94+
resolveDdlSchemaForNamespaceStorage(storage, namespaceId, undefined),
95+
nativeType,
96+
),
8197
});
8298
},
8399
},

packages/3-targets/3-targets/postgres/test/migrations/enum-collision.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types';
22
import type { MigrationOperationPolicy } from '@prisma-next/family-sql/control';
33
import type { SchemaIssue } from '@prisma-next/framework-components/control';
4+
import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
45
import type { StorageTableInput } from '@prisma-next/sql-contract/types';
56
import { SqlStorage } from '@prisma-next/sql-contract/types';
67
import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
@@ -15,7 +16,7 @@ import {
1516
DropEnumTypeCall,
1617
} from '../../src/core/migrations/op-factory-call';
1718
import { nativeEnumPlanCallStrategy } from '../../src/core/migrations/planner-strategies';
18-
import { PostgresSchema } from '../../src/core/postgres-schema';
19+
import { PostgresSchema, PostgresUnboundSchema } from '../../src/core/postgres-schema';
1920
import { PostgresEnumType } from '../../src/exports/types';
2021

2122
const defaultCtx = {
@@ -268,4 +269,72 @@ describe('enum namespace collision planning', () => {
268269
expect(dropCalls[0]?.schemaName).toBe('audit');
269270
});
270271
});
272+
273+
describe('cross-namespace column binding: public enum, unbound-namespace table', () => {
274+
// The authoring builder places contract-level `types:` enums in the
275+
// default `public` namespace, while a model's table lands in the unbound
276+
// namespace. A column there carries a bare `typeRef` that must still bind
277+
// to the `public` enum so the rebuild migrates it (regression for the
278+
// D2 `nsId === namespaceId` over-narrowing).
279+
it('rebuilds the public enum and migrates the unbound-namespace column', () => {
280+
const userTable: StorageTableInput = {
281+
columns: {
282+
id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false },
283+
status: {
284+
nativeType: 'status',
285+
codecId: 'pg/enum@1',
286+
nullable: false,
287+
typeRef: 'status',
288+
},
289+
},
290+
primaryKey: { columns: ['id'] },
291+
uniques: [],
292+
indexes: [],
293+
foreignKeys: [],
294+
};
295+
const toContract: Contract<SqlStorage> = {
296+
target: 'postgres',
297+
targetFamily: 'sql',
298+
profileHash: profileHash('sha256:public-enum-unbound-col'),
299+
storage: new SqlStorage({
300+
storageHash: coreHash('sha256:public-enum-unbound-col'),
301+
namespaces: {
302+
public: new PostgresSchema({
303+
id: 'public',
304+
tables: {},
305+
enum: {
306+
status: new PostgresEnumType({ name: 'status', values: ['active', 'archived'] }),
307+
},
308+
}),
309+
[UNBOUND_NAMESPACE_ID]: new PostgresUnboundSchema({
310+
id: UNBOUND_NAMESPACE_ID,
311+
tables: { user: userTable },
312+
}),
313+
},
314+
}),
315+
roots: {},
316+
models: {},
317+
capabilities: {},
318+
extensionPacks: {},
319+
meta: {},
320+
};
321+
// Live enum has a value the contract drops → forces the rebuild recipe.
322+
const schema = makeLiveEnumSchema([
323+
{ schemaName: 'public', nativeType: 'status', values: ['active', 'pending', 'archived'] },
324+
]);
325+
const policy: MigrationOperationPolicy = {
326+
allowedOperationClasses: ['additive', 'destructive', 'widening', 'data'],
327+
};
328+
329+
const calls = planEnumCalls({ toContract, schema, policy });
330+
const alterCalls = calls.filter(
331+
(c): c is AlterColumnTypeCall => c instanceof AlterColumnTypeCall,
332+
);
333+
334+
expect(alterCalls).toHaveLength(1);
335+
expect(alterCalls[0]?.tableName).toBe('user');
336+
expect(calls.some((c) => c instanceof CreateEnumTypeCall)).toBe(true);
337+
expect(calls.some((c) => c instanceof DropEnumTypeCall)).toBe(true);
338+
});
339+
});
271340
});

0 commit comments

Comments
 (0)