diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 20cf3a0510eb..4d65f10cae85 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1300,7 +1300,11 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.TABLE_NAME_CASE_CHANGED -The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table, or rename it by hand with `ALTER TABLE "X" RENAME TO "Y"`, after which the plan is empty. Payload: `droppedTable`, `createdTable`. +The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. The message gives three ways out. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table. In a project with migration history, make the rename its own schema change, create its migration with `prisma migration new`, and add `...this.renameTable({ table: "X", to: "Y" })` to its operations; the call renames the table and the constraints and indexes named after it, and on Postgres the message adds `schema: ""` when the table is outside the default schema or another schema declares `X`. In a project that uses `db update`, rename the table by hand with the statements the message gives, then run `db update` again: on Postgres `ALTER TABLE ""."X" RENAME TO "Y"`, and on SQLite, which refuses in one statement a rename that only changes the case of ASCII letters, `ALTER TABLE "X" RENAME TO "_prisma_rename_Y"; ALTER TABLE "_prisma_rename_Y" RENAME TO "Y"`. Payload: `droppedTable`, `createdTable`. + +### MIGRATION.TABLE_RENAME_UNMATCHED + +`this.renameTable({ table, to })` in a hand-written migration does not match the migration's contracts: the migration has no start contract, the start contract has no table `table` (or, with no `schema`, declares it in more than one namespace), the start contract already has a table `to`, or the end contract has no table `to`. Raised when the migration's operations are built, so `migration.ts` writes no `ops.json`. Make the rename its own schema change, so the migration's start contract is the schema before the rename and its end contract the schema after it, and check the table names and the `schema`. Payload: `from`, `to`. ### MIGRATION.TARGET_MISMATCH diff --git a/packages/2-sql/9-family/src/core/errors.ts b/packages/2-sql/9-family/src/core/errors.ts index 5e8cdd081772..ba3a3e1a3460 100644 --- a/packages/2-sql/9-family/src/core/errors.ts +++ b/packages/2-sql/9-family/src/core/errors.ts @@ -8,7 +8,8 @@ type SqlFamilyErrorCode = | 'CONTRACT.PACK_CONTRIBUTION_INVALID' | 'CONTRACT.TABLE_AMBIGUOUS' | 'CONTRACT.TYPE_UNKNOWN' - | 'MIGRATION.MARKER_CAS_FAILURE'; + | 'MIGRATION.MARKER_CAS_FAILURE' + | 'MIGRATION.TABLE_RENAME_UNMATCHED'; export function sqlFamilyError( code: SqlFamilyErrorCode, diff --git a/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts b/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts new file mode 100644 index 000000000000..74ad2397aee8 --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts @@ -0,0 +1,270 @@ +import type { Contract } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + ForeignKey, + type ForeignKeyReference, + type ForeignKeyReferenceInput, + isMaterializedSqlNamespace, + type SqlNamespace, + type SqlNamespaceBase, + type SqlNamespaceEntries, + SqlStorage, + StorageTable, +} from '@internal/sql-contract/types'; +import { InternalError } from '@internal/utils/internal-error'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import type { StructuredError } from '@internal/utils/structured-error'; +import { sqlFamilyError } from '../errors'; + +export const TABLE_RENAME_UNMATCHED_CODE = 'MIGRATION.TABLE_RENAME_UNMATCHED'; + +/** A table a migration renames: `namespaceId` is `undefined` when the migration leaves the namespace to the contracts. */ +export interface TableRename { + readonly namespaceId: string | undefined; + readonly from: string; + readonly to: string; +} + +/** A rename after its table was found in the start contract. */ +export interface ResolvedTableRename { + readonly namespaceId: string; + readonly from: string; + readonly to: string; +} + +/** + * Renames a target's own references to a renamed table inside one namespace's entries: entity kinds the family does not know, such as a target's row-level-security markers, that name a table. + */ +export type RenameTableReferences = ( + entries: SqlNamespaceEntries, + rename: ResolvedTableRename, +) => SqlNamespaceEntries; + +export interface ApplyTableRenameInput { + readonly startContract: Contract | null; + readonly endContract: Contract; + readonly rename: TableRename; + readonly renameTableReferences: RenameTableReferences | undefined; +} + +export interface AppliedTableRename { + /** The start contract with the table under its new name. */ + readonly contract: Contract; + readonly rename: ResolvedTableRename; +} + +function tableLabel(namespaceId: string | undefined, tableName: string): string { + return namespaceId === undefined || namespaceId === UNBOUND_NAMESPACE_ID + ? tableName + : `${namespaceId}.${tableName}`; +} + +function namespacesDeclaring(contract: Contract, tableName: string): string[] { + return Object.values(contract.storage.namespaces) + .filter((namespace) => Object.hasOwn(namespace.entries.table ?? {}, tableName)) + .map((namespace) => namespace.id); +} + +function declares(contract: Contract, namespaceId: string, tableName: string): boolean { + return Object.hasOwn(contract.storage.namespaces[namespaceId]?.entries.table ?? {}, tableName); +} + +function unmatched(rename: TableRename, reason: string): StructuredError { + return sqlFamilyError( + TABLE_RENAME_UNMATCHED_CODE, + `renameTable "${tableLabel(rename.namespaceId, rename.from)}" to "${rename.to}" does not match the migration's contracts: ${reason}.`, + { + why: "renameTable must name a table of the migration's start contract and a new name that the end contract has and the start contract does not. Make the rename its own schema change, so the migration's start contract is the schema before the rename and its end contract the schema after it, and check the spelling and the namespace.", + meta: { from: rename.from, to: rename.to }, + }, + ); +} + +function resolveTableRename( + rename: TableRename, + startContract: Contract, + endContract: Contract, +): Result { + const namespaceIds = + rename.namespaceId === undefined + ? namespacesDeclaring(startContract, rename.from) + : declares(startContract, rename.namespaceId, rename.from) + ? [rename.namespaceId] + : []; + const [namespaceId, ...others] = namespaceIds; + if (namespaceId === undefined) { + return notOk( + unmatched( + rename, + `table "${tableLabel(rename.namespaceId, rename.from)}" does not exist in the start contract`, + ), + ); + } + if (others.length > 0) { + return notOk( + unmatched( + rename, + `table "${rename.from}" is declared in more than one namespace (${namespaceIds.join(', ')}); name its namespace`, + ), + ); + } + if (declares(startContract, namespaceId, rename.to)) { + return notOk( + unmatched( + rename, + `table "${tableLabel(namespaceId, rename.to)}" already exists in the start contract`, + ), + ); + } + if (!declares(endContract, namespaceId, rename.to)) { + return notOk( + unmatched( + rename, + `table "${tableLabel(namespaceId, rename.to)}" does not exist in the end contract`, + ), + ); + } + return ok({ namespaceId, from: rename.from, to: rename.to }); +} + +function renamedReference( + reference: ForeignKeyReference, + rename: ResolvedTableRename, +): ForeignKeyReference | ForeignKeyReferenceInput { + const local = reference.spaceId === undefined; + if ( + !local || + reference.namespaceId !== rename.namespaceId || + reference.tableName !== rename.from + ) { + return reference; + } + return { ...reference, tableName: rename.to }; +} + +function renameForeignKeys(table: StorageTable, rename: ResolvedTableRename): StorageTable { + const touched = table.foreignKeys.some( + (fk) => + renamedReference(fk.source, rename) !== fk.source || + renamedReference(fk.target, rename) !== fk.target, + ); + if (!touched) return table; + return new StorageTable({ + ...table, + foreignKeys: table.foreignKeys.map( + (fk) => + new ForeignKey({ + ...fk, + source: renamedReference(fk.source, rename), + target: renamedReference(fk.target, rename), + }), + ), + }); +} + +/** + * The same namespace instance with only `entries` replaced: every own + * property, including the non-enumerable `kind`, is carried over onto the + * same prototype, so the target's namespace class (its `qualifyTable`, + * `ddlSchemaName`, entity getters) keeps working on the copy. This is the + * one place a frozen contract node is rebuilt without its constructor: the + * constructors re-hydrate `entries` from raw input, which the already + * hydrated entities here do not need. + */ +function withEntries(namespace: SqlNamespaceBase, entries: SqlNamespaceEntries): SqlNamespaceBase { + const copy: SqlNamespaceBase = Object.create(Object.getPrototypeOf(namespace), { + ...Object.getOwnPropertyDescriptors(namespace), + entries: { + value: Object.freeze(entries), + enumerable: true, + writable: false, + configurable: false, + }, + }); + return Object.freeze(copy); +} + +function renameTableInNamespace( + namespace: SqlNamespace, + rename: ResolvedTableRename, + renameTableReferences: RenameTableReferences | undefined, +): SqlNamespace { + const ownsTable = rename.namespaceId === namespace.id; + const tables = Object.entries(namespace.entries.table ?? {}).map( + ([name, table]) => + [ + ownsTable && name === rename.from ? rename.to : name, + renameForeignKeys(table, rename), + ] as const, + ); + const untouched = tables.every(([name, table]) => namespace.entries.table?.[name] === table); + if (untouched) return namespace; + if (!isMaterializedSqlNamespace(namespace)) { + throw new InternalError( + `applyTableRename: namespace "${namespace.id}" is not a materialized SQL namespace`, + ); + } + const renamedTables: SqlNamespaceEntries = { + ...namespace.entries, + table: Object.fromEntries(tables), + }; + return withEntries( + namespace, + renameTableReferences === undefined || !ownsTable + ? renamedTables + : renameTableReferences(renamedTables, rename), + ); +} + +function renameTableInContract( + contract: Contract, + rename: ResolvedTableRename, + renameTableReferences: RenameTableReferences | undefined, +): Contract { + const namespaces = Object.fromEntries( + Object.entries(contract.storage.namespaces).map(([id, namespace]) => [ + id, + renameTableInNamespace(namespace, rename, renameTableReferences), + ]), + ); + const materialized: Record = {}; + for (const [id, namespace] of Object.entries(namespaces)) { + if (!isMaterializedSqlNamespace(namespace)) { + throw new InternalError( + `applyTableRename: namespace "${id}" is not a materialized SQL namespace`, + ); + } + materialized[id] = namespace; + } + return { + ...contract, + storage: new SqlStorage({ + storageHash: contract.storage.storageHash, + ...(contract.storage.types === undefined ? {} : { types: contract.storage.types }), + namespaces: materialized, + }), + }; +} + +/** + * Resolves the table a migration renames against its start and end contracts and returns the start contract with that table under its new name, so a diff against the end contract sees the table under one name. The table must exist in the start contract (in exactly one namespace when the namespace is not given), and the new name must exist in the end contract and not in the start contract; otherwise the rename is refused with `MIGRATION.TABLE_RENAME_UNMATCHED`. + * + * Foreign keys that name the renamed table on either side are retargeted. Index, unique, check and primary-key names are carried unchanged. Target entity kinds that name the table are renamed by the target's `renameTableReferences`. + */ +export function applyTableRename( + input: ApplyTableRenameInput, +): Result { + if (input.startContract === null) { + return notOk(unmatched(input.rename, 'the migration has no start contract')); + } + const resolved = resolveTableRename(input.rename, input.startContract, input.endContract); + if (!resolved.ok) return resolved; + return ok({ + contract: renameTableInContract( + input.startContract, + resolved.value, + input.renameTableReferences, + ), + rename: resolved.value, + }); +} diff --git a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts index 4f09506266c4..a8823f862ef4 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts @@ -1,5 +1,8 @@ +import type { Contract } from '@internal/contract/types'; import type { SchemaDiffIssue } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; import type { SqlPlannerConflict } from './types'; export const TABLE_NAME_CASE_CHANGED_CODE = 'MIGRATION.TABLE_NAME_CASE_CHANGED'; @@ -9,6 +12,20 @@ export interface TableNameCaseGuardTable { readonly name: string; } +/** A table rename the operator performs by hand, for the target to write the statements of. */ +export interface TableRenameByHand { + readonly namespaceId: string; + readonly from: string; + readonly to: string; +} + +/** A table rename to write as a migration call; `namespaceId` is `undefined` when the call does not need to name the namespace. */ +export interface TableRenameInMigration { + readonly namespaceId: string | undefined; + readonly from: string; + readonly to: string; +} + interface PlannedTable { readonly namespaceId: string; readonly tableName: string; @@ -22,6 +39,27 @@ function plannedTable(node: TableNameCaseGuardTable, namespaceId: string): Plann return { namespaceId, tableName: node.name }; } +function renameNeedsNamespace( + drop: PlannedTable, + dropped: readonly PlannedTable[], + contract: Contract, + defaultNamespaceId: string, +): boolean { + if (drop.namespaceId !== UNBOUND_NAMESPACE_ID && drop.namespaceId !== defaultNamespaceId) { + return true; + } + const declaredElsewhere = (namespaceId: string, tableName: string) => + namespaceId !== drop.namespaceId && tableName === drop.tableName; + return ( + dropped.some((other) => declaredElsewhere(other.namespaceId, other.tableName)) || + Object.values(contract.storage.namespaces).some((namespace) => + Object.keys(namespace.entries.table ?? {}).some((tableName) => + declaredElsewhere(namespace.id, tableName), + ), + ) + ); +} + /** * Finds every (drop `X`, create `Y`) pair in the same namespace where `X` is * `Y` with its first letter lowered. That shape is the signature of a schema @@ -37,6 +75,10 @@ export function detectTableNameCaseChanges(input: { readonly issues: readonly SchemaDiffIssue[]; readonly tableOf: (issue: SchemaDiffIssue) => TableNameCaseGuardTable | undefined; readonly namespaceIdOf: (issue: SchemaDiffIssue) => string; + readonly renameByHandStatements: (rename: TableRenameByHand) => readonly string[]; + readonly renameTableCall: (rename: TableRenameInMigration) => string; + readonly contract: Contract; + readonly defaultNamespaceId: string; }): SqlPlannerConflict[] { const dropped: PlannedTable[] = []; const created: PlannedTable[] = []; @@ -58,10 +100,17 @@ export function detectTableNameCaseChanges(input: { candidate.tableName === lowerFirst(create.tableName), ); if (drop === undefined) continue; + const renameCall = input.renameTableCall({ + namespaceId: renameNeedsNamespace(drop, dropped, input.contract, input.defaultNamespaceId) + ? drop.namespaceId + : undefined, + from: drop.tableName, + to: create.tableName, + }); conflicts.push({ kind: 'tableNameCaseChanged', summary: `${TABLE_NAME_CASE_CHANGED_CODE}: table "${create.tableName}" would be created and table "${drop.tableName}" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model ${create.tableName} points at "${create.tableName}" instead of "${drop.tableName}".`, - why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. Prisma 8 has no rename-table operation, so a deliberate rename is done by hand: run ALTER TABLE "${drop.tableName}" RENAME TO "${create.tableName}" (schema-qualified where applicable), after which the plan is empty.`, + why: `To keep table "${drop.tableName}" and its rows, add @@map("${drop.tableName}") to model ${create.tableName} (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ${renameCall} to the migration's operations, which renames the table and the objects named after it; in a project that uses db update, rename it by hand with ${input.renameByHandStatements({ namespaceId: create.namespaceId, from: drop.tableName, to: create.tableName }).join('; ')}, then run db update again.`, location: { namespaceId: create.namespaceId, entityKind: 'table', diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index ee459ad97af8..e150e5470173 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -19,6 +19,14 @@ export type { SqlControlTargetDescriptor, SqlDescribedContractSpace, } from '../core/control-target-descriptor'; +export type { + AppliedTableRename, + ApplyTableRenameInput, + RenameTableReferences, + ResolvedTableRename, + TableRename, +} from '../core/migrations/apply-table-rename'; +export { applyTableRename } from '../core/migrations/apply-table-rename'; export type { ContractToSchemaIROptions, DefaultRenderer, @@ -54,7 +62,11 @@ export type { SqlSchemaDiffInput, SqlSchemaDiffResult, } from '../core/migrations/schema-differ'; -export type { TableNameCaseGuardTable } from '../core/migrations/table-name-case-guard'; +export type { + TableNameCaseGuardTable, + TableRenameByHand, + TableRenameInMigration, +} from '../core/migrations/table-name-case-guard'; export { detectTableNameCaseChanges, TABLE_NAME_CASE_CHANGED_CODE, diff --git a/packages/2-sql/9-family/test/apply-table-rename.test.ts b/packages/2-sql/9-family/test/apply-table-rename.test.ts new file mode 100644 index 000000000000..fb1d94642c35 --- /dev/null +++ b/packages/2-sql/9-family/test/apply-table-rename.test.ts @@ -0,0 +1,327 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + isMaterializedSqlNamespace, + SqlStorage, + StorageTable, + type StorageTableInput, + toStorageTypeInstance, +} from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; +import { + applyTableRename, + TABLE_RENAME_UNMATCHED_CODE, + type TableRename, +} from '../src/core/migrations/apply-table-rename'; + +const idColumn = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; + +function table(extra: Partial = {}): StorageTable { + return new StorageTable({ + columns: { id: idColumn }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + ...extra, + }); +} + +function contractOf( + namespaces: Readonly>>>, + hashSeed = 'seed', +): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + types: { Money: toStorageTypeInstance({ codecId: 'pg/numeric@1', nativeType: 'numeric' }) }, + namespaces: Object.fromEntries( + Object.entries(namespaces).map(([id, tables]) => [ + id, + createTestSqlNamespace({ id, entries: { table: tables } }), + ]), + ), + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +const rename = (from: string, to: string, namespaceId?: string): TableRename => ({ + namespaceId, + from, + to, +}); + +function tablesOf(contract: Contract, namespaceId: string): readonly string[] { + return Object.keys(contract.storage.namespaces[namespaceId]?.entries.table ?? {}); +} + +describe('applyTableRename', () => { + it('re-keys the table under its new name and retargets every foreign key that named it', () => { + const startContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { + userProfile: table({ + columns: { id: idColumn, parentId: { ...idColumn, nullable: true } }, + foreignKeys: [ + { + source: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'userProfile', + columns: ['parentId'], + }, + target: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'userProfile', + columns: ['id'], + }, + name: 'userProfile_parent_fkey', + }, + ], + }), + post: table({ + columns: { id: idColumn, authorId: idColumn }, + foreignKeys: [ + { + source: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'post', + columns: ['authorId'], + }, + target: { + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'userProfile', + columns: ['id'], + }, + }, + ], + }), + }, + }); + const endContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), post: table() }, + }); + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rename).toEqual({ + namespaceId: UNBOUND_NAMESPACE_ID, + from: 'userProfile', + to: 'UserProfile', + }); + const renamed = result.value.contract; + expect(tablesOf(renamed, UNBOUND_NAMESPACE_ID)).toEqual(['UserProfile', 'post']); + const renamedTable = + renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['UserProfile']; + expect(renamedTable).toBeInstanceOf(StorageTable); + expect(Object.keys(renamedTable?.columns ?? {})).toEqual(['id', 'parentId']); + expect( + renamedTable?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName, fk.name]), + ).toEqual([['UserProfile', 'UserProfile', 'userProfile_parent_fkey']]); + const post = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['post']; + expect(post?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName])).toEqual([ + ['post', 'UserProfile'], + ]); + }); + + it('keeps the namespace class, the storage hash and types, and leaves the input untouched', () => { + const startContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { userProfile: table() }, + audit: { log: table() }, + }); + const endContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table() }, + audit: { log: table() }, + }); + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const renamed = result.value.contract; + const namespace = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]; + expect(isMaterializedSqlNamespace(namespace)).toBe(true); + expect(Object.getPrototypeOf(namespace)).toBe( + Object.getPrototypeOf(startContract.storage.namespaces[UNBOUND_NAMESPACE_ID]), + ); + expect(namespace?.id).toBe(UNBOUND_NAMESPACE_ID); + expect(renamed.storage.namespaces['audit']).toBe(startContract.storage.namespaces['audit']); + expect(renamed.storage.storageHash).toBe(startContract.storage.storageHash); + expect(renamed.storage.types).toEqual(startContract.storage.types); + expect(renamed.profileHash).toBe(startContract.profileHash); + expect(tablesOf(startContract, UNBOUND_NAMESPACE_ID)).toEqual(['userProfile']); + }); + + it('finds the table in whichever namespace declares it when no namespace is given', () => { + const startContract = contractOf({ auth: { userProfile: table() }, app: { post: table() } }); + const endContract = contractOf({ auth: { UserProfile: table() }, app: { post: table() } }); + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rename).toEqual({ + namespaceId: 'auth', + from: 'userProfile', + to: 'UserProfile', + }); + expect(tablesOf(result.value.contract, 'auth')).toEqual(['UserProfile']); + }); + + it('renames only in the namespace given', () => { + const startContract = contractOf({ + auth: { userProfile: table() }, + app: { userProfile: table() }, + }); + const endContract = contractOf({ + auth: { UserProfile: table() }, + app: { userProfile: table() }, + }); + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile', 'auth'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.rename).toEqual({ + namespaceId: 'auth', + from: 'userProfile', + to: 'UserProfile', + }); + expect(tablesOf(result.value.contract, 'app')).toEqual(['userProfile']); + }); + + it("lets the target rename its own references to the table in the table's namespace", () => { + const startContract = contractOf({ + auth: { userProfile: table(), account: table() }, + app: { userProfile: table() }, + }); + const endContract = contractOf({ + auth: { UserProfile: table(), account: table() }, + app: { userProfile: table() }, + }); + const calls: string[] = []; + + const result = applyTableRename({ + startContract, + endContract, + rename: rename('userProfile', 'UserProfile', 'auth'), + renameTableReferences: (entries, applied) => { + calls.push(`${applied.namespaceId}:${applied.from}>${applied.to}`); + return { ...entries, marker: { [applied.to]: { tableName: applied.to } } }; + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(calls).toEqual(['auth:userProfile>UserProfile']); + expect(result.value.contract.storage.namespaces['auth']?.entries['marker']).toEqual({ + UserProfile: { tableName: 'UserProfile' }, + }); + expect(result.value.contract.storage.namespaces['app']?.entries['marker']).toBeUndefined(); + }); + + describe('refusals', () => { + const startContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { userProfile: table(), Account: table() }, + }); + const endContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), Account: table() }, + }); + + function refusalFor( + tableRename: TableRename, + start: Contract | null = startContract, + ) { + const result = applyTableRename({ + startContract: start, + endContract, + rename: tableRename, + renameTableReferences: undefined, + }); + expect(result.ok).toBe(false); + return result.ok ? undefined : result.failure; + } + + it('refuses a table the start contract does not have', () => { + expect(refusalFor(rename('ghost', 'UserProfile'))).toMatchObject({ + code: TABLE_RENAME_UNMATCHED_CODE, + message: + 'renameTable "ghost" to "UserProfile" does not match the migration\'s contracts: table "ghost" does not exist in the start contract.', + meta: { from: 'ghost', to: 'UserProfile' }, + }); + }); + + it('refuses a new name the start contract already has', () => { + expect(refusalFor(rename('userProfile', 'Account'))?.message).toContain( + 'table "Account" already exists in the start contract', + ); + }); + + it('refuses a new name the end contract does not have', () => { + expect(refusalFor(rename('userProfile', 'Profile'))?.message).toContain( + 'table "Profile" does not exist in the end contract', + ); + }); + + it('refuses a namespace the start contract does not have', () => { + expect(refusalFor(rename('userProfile', 'UserProfile', 'auth'))?.message).toContain( + 'table "auth.userProfile" does not exist in the start contract', + ); + }); + + it('refuses a migration without a start contract', () => { + expect(refusalFor(rename('userProfile', 'UserProfile'), null)).toMatchObject({ + code: TABLE_RENAME_UNMATCHED_CODE, + message: expect.stringContaining('the migration has no start contract'), + }); + }); + + it('refuses a table name declared in more than one namespace when no namespace is given', () => { + const result = applyTableRename({ + startContract: contractOf({ + auth: { userProfile: table() }, + app: { userProfile: table() }, + }), + endContract: contractOf({ auth: { UserProfile: table() }, app: { userProfile: table() } }), + rename: rename('userProfile', 'UserProfile'), + renameTableReferences: undefined, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.message).toContain( + 'table "userProfile" is declared in more than one namespace (auth, app); name its namespace', + ); + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/default-constraint-names.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/default-constraint-names.ts new file mode 100644 index 000000000000..994bfa262c91 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/default-constraint-names.ts @@ -0,0 +1,15 @@ +/** + * The names the planner gives a primary key, unique constraint or foreign key the contract leaves unnamed. They derive from the table name, so renaming the table leaves them stale until the planner renames them too. + */ + +export function defaultPrimaryKeyName(tableName: string): string { + return `${tableName}_pkey`; +} + +export function defaultUniqueName(tableName: string, columns: readonly string[]): string { + return `${tableName}_${columns.join('_')}_key`; +} + +export function defaultForeignKeyName(tableName: string, columns: readonly string[]): string { + return `${tableName}_${columns.join('_')}_fkey`; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts new file mode 100644 index 000000000000..e1c0d6722e67 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts @@ -0,0 +1,233 @@ +import type { SqlMigrationPlannerPlanOptions } from '@internal/family-sql/control'; +import type { SchemaDiffIssue } from '@internal/framework-components/control'; +import { issueOutcome } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { parseWireName } from '@internal/sql-schema-ir/naming'; +import { SqlCheckConstraintIR, SqlIndexIR } from '@internal/sql-schema-ir/types'; +import type { SqlSchemaDiffNode } from '../schema-ir/schema-node-kinds'; +import { resolveNamespaceIdForDdlSchema } from './control-policy'; +import { issueNode } from './issue-planner'; +import { RenameConstraintCall, RenameIndexCall } from './op-factory-call'; + +/** + * Rename post-pass for indexes, per `(schema, table)`, widening-only, + * deterministic by sorted names — the same structure as the policy pass + * below (which stays untouched: policies pair by hash only). + * + * Hash pairing (prefix-only renames): extras whose live names parse as + * wire names, grouped by `(schema, table, hash)`; missing nodes iterated + * in sorted-name order consume the sorted-name-first candidate. + * + * Content pairing (exact→wire convergence), after hash pairing has + * consumed its matches: the remaining wire-named-missing nodes + * (`prefix` defined) against the remaining extras of any name shape, + * paired iff content-equal (columns ordered-strict both-defined-or- + * both-undefined, `unique`/`type` strict, `options` loose, bodies + * byte-equal). + * + * Leftovers proceed as create/drop exactly as before; without the + * widening allowance the pass is skipped and pairing degrades to the + * additive half, like the policy pass. + */ +export function pairIndexRenames( + options: Pick, + issues: readonly SchemaDiffIssue[], +): { + readonly calls: readonly RenameIndexCall[]; + readonly consumed: ReadonlySet>; +} { + const consumed = new Set>(); + const calls: RenameIndexCall[] = []; + if (!options.policy.allowedOperationClasses.includes('widening')) { + return { calls, consumed }; + } + + interface IndexFinding { + readonly issue: SchemaDiffIssue; + readonly node: SqlIndexIR; + readonly ddlSchema: string; + readonly tableName: string; + } + const missing: IndexFinding[] = []; + const extra: IndexFinding[] = []; + for (const issue of issues) { + const node = issueNode(issue); + if (node === undefined || !SqlIndexIR.is(node)) continue; + const ddlSchema = issue.path[1]; + const tableName = issue.path[2]; + if (ddlSchema === undefined || tableName === undefined) continue; + if (issueOutcome(issue) === 'not-found') { + missing.push({ issue, node, ddlSchema, tableName }); + } else if (issueOutcome(issue) === 'not-expected') { + extra.push({ issue, node, ddlSchema, tableName }); + } + } + if (missing.length === 0 || extra.length === 0) { + return { calls, consumed }; + } + + const byName = (a: IndexFinding, b: IndexFinding): number => + a.node.name < b.node.name ? -1 : a.node.name > b.node.name ? 1 : 0; + // DDL emission must stay unqualified for the unbound namespace, exactly + // like the per-issue mapping's `emissionSchemaName`. + const emissionSchema = (ddlSchema: string): string => + resolveNamespaceIdForDdlSchema(options.contract, ddlSchema) === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : ddlSchema; + const pairingKey = (finding: IndexFinding, hash: string): string => + JSON.stringify([finding.ddlSchema, finding.tableName, hash]); + const rename = (missingFinding: IndexFinding, candidate: IndexFinding): void => { + consumed.add(missingFinding.issue); + consumed.add(candidate.issue); + calls.push( + new RenameIndexCall( + emissionSchema(missingFinding.ddlSchema), + missingFinding.tableName, + candidate.node.name, + missingFinding.node.name, + ), + ); + }; + + const sortedMissing = [...missing].sort(byName); + + const extrasByHash = new Map(); + for (const finding of extra) { + const parsed = parseWireName(finding.node.name); + if (parsed === undefined) continue; + const key = pairingKey(finding, parsed.hash); + const group = extrasByHash.get(key) ?? []; + group.push(finding); + extrasByHash.set(key, group); + } + for (const group of extrasByHash.values()) { + group.sort(byName); + } + for (const missingFinding of sortedMissing) { + const parsed = parseWireName(missingFinding.node.name); + if (parsed === undefined) continue; + const candidate = extrasByHash.get(pairingKey(missingFinding, parsed.hash))?.shift(); + if (candidate === undefined) continue; + rename(missingFinding, candidate); + } + + const sortedExtras = [...extra].sort(byName); + for (const missingFinding of sortedMissing) { + if (consumed.has(missingFinding.issue)) continue; + if (missingFinding.node.prefix === undefined) continue; + const candidate = sortedExtras.find( + (extraFinding) => + !consumed.has(extraFinding.issue) && + extraFinding.ddlSchema === missingFinding.ddlSchema && + extraFinding.tableName === missingFinding.tableName && + missingFinding.node.contentEquals(extraFinding.node, { + columnPresence: 'matching', + bodies: 'verbatim', + }), + ); + if (candidate === undefined) continue; + rename(missingFinding, candidate); + } + + return { calls, consumed }; +} + +/** + * Check-constraint rename post-pass: a `not-found` and a `not-expected` + * check on the same table whose wire-name content hashes match but whose + * prefixes differ is a prefix-only rename, and collapses into one + * `ALTER TABLE … RENAME CONSTRAINT`. + * + * This is the index pass's hash-pairing phase and nothing else. There is + * deliberately no content-pairing phase: a live check body is whatever + * Postgres reprinted, so it never byte-matches the authored text, and + * pairing an exact-named live check by content would bless whatever + * predicate is actually live. Adoption of an old exact-named check stays + * drop + add. + * + * Runs only when the policy allows `widening` (rename's class). Without it + * the pass no-ops and the pair degrades to the slice-1 behavior: an add, + * plus a drop when `destructive` is allowed too. + */ +export function pairCheckRenames( + options: Pick, + issues: readonly SchemaDiffIssue[], +): { + readonly calls: readonly RenameConstraintCall[]; + readonly consumed: ReadonlySet>; +} { + const consumed = new Set>(); + const calls: RenameConstraintCall[] = []; + if (!options.policy.allowedOperationClasses.includes('widening')) { + return { calls, consumed }; + } + + interface CheckFinding { + readonly issue: SchemaDiffIssue; + readonly node: SqlCheckConstraintIR; + readonly ddlSchema: string; + readonly tableName: string; + } + const missing: CheckFinding[] = []; + const extra: CheckFinding[] = []; + for (const issue of issues) { + const node = issueNode(issue); + if (node === undefined || !SqlCheckConstraintIR.is(node)) continue; + const ddlSchema = issue.path[1]; + const tableName = issue.path[2]; + if (ddlSchema === undefined || tableName === undefined) continue; + if (issueOutcome(issue) === 'not-found') { + missing.push({ issue, node, ddlSchema, tableName }); + } else if (issueOutcome(issue) === 'not-expected') { + extra.push({ issue, node, ddlSchema, tableName }); + } + } + if (missing.length === 0 || extra.length === 0) { + return { calls, consumed }; + } + + const byName = (a: CheckFinding, b: CheckFinding): number => + a.node.name < b.node.name ? -1 : a.node.name > b.node.name ? 1 : 0; + // DDL emission must stay unqualified for the unbound namespace, exactly + // like the per-issue mapping's `emissionSchemaName`. + const emissionSchema = (ddlSchema: string): string => + resolveNamespaceIdForDdlSchema(options.contract, ddlSchema) === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : ddlSchema; + const pairingKey = (finding: CheckFinding, hash: string): string => + JSON.stringify([finding.ddlSchema, finding.tableName, hash]); + + const sortedMissing = [...missing].sort(byName); + + const extrasByHash = new Map(); + for (const finding of extra) { + const parsed = parseWireName(finding.node.name); + if (parsed === undefined) continue; + const key = pairingKey(finding, parsed.hash); + const group = extrasByHash.get(key) ?? []; + group.push(finding); + extrasByHash.set(key, group); + } + for (const group of extrasByHash.values()) { + group.sort(byName); + } + for (const missingFinding of sortedMissing) { + const parsed = parseWireName(missingFinding.node.name); + if (parsed === undefined) continue; + const candidate = extrasByHash.get(pairingKey(missingFinding, parsed.hash))?.shift(); + if (candidate === undefined) continue; + consumed.add(missingFinding.issue); + consumed.add(candidate.issue); + calls.push( + new RenameConstraintCall( + emissionSchema(missingFinding.ddlSchema), + missingFinding.tableName, + 'checkConstraint', + candidate.node.name, + missingFinding.node.name, + ), + ); + } + + return { calls, consumed }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 79ea69cd6505..04f55f168fd9 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -55,6 +55,11 @@ import { renderColumnDefaultSql, } from './column-ddl-rendering'; import { resolveNamespaceIdForDdlSchema } from './control-policy'; +import { + defaultForeignKeyName, + defaultPrimaryKeyName, + defaultUniqueName, +} from './default-constraint-names'; import { AddCheckConstraintCall, AddColumnCall, @@ -158,9 +163,10 @@ function classifyCall(call: PostgresOpFactoryCall): CallCategory { case 'dropDefault': return 'drop'; case 'addCheckConstraint': - case 'renameCheckConstraint': + case 'renameConstraint': return 'unique'; // after uniques, before indexes case 'createTable': + case 'renameTable': return 'table'; case 'enableRowLevelSecurity': case 'disableRowLevelSecurity': @@ -409,7 +415,7 @@ function isStrictDescendantPath(path: readonly string[], ancestor: readonly stri // ---------------------------------------------------------------------------- function fkSpecFromNode(fk: SqlForeignKeyIR, tableName: string): ForeignKeySpec { - const name = fk.name ?? `${tableName}_${fk.columns.join('_')}_fkey`; + const name = fk.name ?? defaultForeignKeyName(tableName, fk.columns); return { name, columns: [...fk.columns], @@ -469,7 +475,7 @@ function buildCreateTableCallsFromNode( calls.push(new AddForeignKeyCall(schemaName, table.name, fkSpecFromNode(fk, table.name))); } for (const unique of table.uniques) { - const constraintName = unique.name ?? `${table.name}_${unique.columns.join('_')}_key`; + const constraintName = unique.name ?? defaultUniqueName(table.name, unique.columns); calls.push(new AddUniqueCall(schemaName, table.name, constraintName, [...unique.columns])); } // Marker-driven: a newly-created table that is RLS-controlled enables RLS @@ -730,7 +736,7 @@ function mapPrimaryKeyNodeIssue( { readonly columns: readonly string[]; readonly name?: string }, 'a not-found primary-key issue always carries the expected PrimaryKey node' >(issue.expected); - const constraintName = pk.name ?? `${tableName}_pkey`; + const constraintName = pk.name ?? defaultPrimaryKeyName(tableName); return ok([new AddPrimaryKeyCall(schemaName, tableName, constraintName, [...pk.columns])]); } if (issueOutcome(issue) === 'not-expected') { @@ -739,7 +745,12 @@ function mapPrimaryKeyNodeIssue( 'a not-expected primary-key issue always carries the actual PrimaryKey node' >(issue.actual); return ok([ - new DropConstraintCall(schemaName, tableName, pk.name ?? `${tableName}_pkey`, 'primaryKey'), + new DropConstraintCall( + schemaName, + tableName, + pk.name ?? defaultPrimaryKeyName(tableName), + 'primaryKey', + ), ]); } return notOk(nodeConflict('indexIncompatible', issue.path.join('/'))); @@ -762,7 +773,7 @@ function mapForeignKeyNodeIssue( SqlForeignKeyIR, 'a not-expected foreign-key issue always carries the actual foreign-key node' >(issue.actual); - const name = fk.name ?? `${tableName}_${fk.columns.join('_')}_fkey`; + const name = fk.name ?? defaultForeignKeyName(tableName, fk.columns); return ok([new DropConstraintCall(schemaName, tableName, name, 'foreignKey')]); } return notOk(nodeConflict('foreignKeyConflict', issue.path.join('/'))); @@ -778,7 +789,7 @@ function mapUniqueNodeIssue( SqlUniqueIR, 'a not-found unique issue always carries the expected unique node' >(issue.expected); - const name = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`; + const name = unique.name ?? defaultUniqueName(tableName, unique.columns); return ok([new AddUniqueCall(schemaName, tableName, name, [...unique.columns])]); } if (issueOutcome(issue) === 'not-expected') { @@ -786,7 +797,7 @@ function mapUniqueNodeIssue( SqlUniqueIR, 'a not-expected unique issue always carries the actual unique node' >(issue.actual); - const name = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`; + const name = unique.name ?? defaultUniqueName(tableName, unique.columns); return ok([new DropConstraintCall(schemaName, tableName, name, 'unique')]); } return notOk(nodeConflict('indexIncompatible', issue.path.join('/'))); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index 23999c305276..4032e082a993 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -73,7 +73,9 @@ import { addUnique, dropCheckConstraint, dropConstraint, - renameCheckConstraint, + type RenamableConstraintKind, + renameConstraint, + renameConstraintLabel, } from './operations/constraints'; import { createExtension } from './operations/dependencies'; import { @@ -93,7 +95,7 @@ import { } from './operations/rls'; import type { ForeignKeySpec } from './operations/shared'; import { step, targetDetails } from './operations/shared'; -import { dropTable } from './operations/tables'; +import { dropTable, renameTable } from './operations/tables'; import { buildAddNotNullColumnWithTemporaryDefaultOperation } from './planner-recipes'; import type { PostgresPlanTargetDetails } from './planner-target-details'; @@ -363,6 +365,54 @@ export class DropTableCall extends PostgresOpFactoryCallNode { } } +export class RenameTableCall extends PostgresOpFactoryCallNode { + readonly factoryName = 'renameTable' as const; + // `widening` for the same reason as `RenameConstraintCall`: a rename is + // neither additive creation nor destructive, and the class vocabulary has no + // neutral middle class, so this is the class that plans under every + // allowance set except additive-only init. + readonly operationClass = 'widening' as const; + readonly schemaName: string; + readonly oldTableName: string; + /** The new name: the table's contract-side identity after the rename. */ + readonly tableName: string; + readonly label: string; + + constructor(schemaName: string, oldTableName: string, tableName: string) { + super(); + this.schemaName = schemaName; + this.oldTableName = oldTableName; + this.tableName = tableName; + this.label = `Rename table "${oldTableName}" to "${tableName}"`; + this.freeze(); + } + + async toOp(lowerer?: ExecuteRequestLowerer): Promise { + if (lowerer === undefined) { + throw postgresError( + 'MIGRATION.POSTGRES_CONTROL_STACK_MISSING', + `RenameTableCall.toOp: a lowerer is required on the Postgres planner path (table "${this.oldTableName}"). Pass the control adapter to createPostgresMigrationPlanner.`, + { meta: { factory: 'RenameTableCall' } }, + ); + } + return renameTable(this.schemaName, this.oldTableName, this.tableName, lowerer); + } + + renderTypeScript(): string { + const opts: string[] = []; + if (this.schemaName !== UNBOUND_NAMESPACE_ID) { + opts.push(`schema: ${jsonToTsSource(this.schemaName)}`); + } + opts.push(`table: ${jsonToTsSource(this.oldTableName)}`); + opts.push(`to: ${jsonToTsSource(this.tableName)}`); + return `...this.renameTable({ ${opts.join(', ')} })`; + } + + override importRequirements(): readonly ImportRequirement[] { + return []; + } +} + // ============================================================================ // Column // ============================================================================ @@ -1047,8 +1097,8 @@ export class DropConstraintCall extends PostgresOpFactoryCallNode { } } -export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { - readonly factoryName = 'renameCheckConstraint' as const; +export class RenameConstraintCall extends PostgresOpFactoryCallNode { + readonly factoryName = 'renameConstraint' as const; // `widening` is chosen so the rename plans under every allowance set except // additive-only init — a rename is neither additive-creation nor // destructive, and the class vocabulary has no neutral middle class. It is @@ -1056,6 +1106,7 @@ export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; + readonly kind: RenamableConstraintKind; readonly oldConstraintName: string; readonly newConstraintName: string; readonly label: string; @@ -1063,15 +1114,17 @@ export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { constructor( schemaName: string, tableName: string, + kind: RenamableConstraintKind, oldConstraintName: string, newConstraintName: string, ) { super(); this.schemaName = schemaName; this.tableName = tableName; + this.kind = kind; this.oldConstraintName = oldConstraintName; this.newConstraintName = newConstraintName; - this.label = `Rename check constraint "${oldConstraintName}" to "${newConstraintName}" on "${tableName}"`; + this.label = renameConstraintLabel(kind, oldConstraintName, newConstraintName, tableName); this.freeze(); } @@ -1079,13 +1132,14 @@ export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { if (lowerer === undefined) { throw postgresError( 'MIGRATION.POSTGRES_CONTROL_STACK_MISSING', - `RenameCheckConstraintCall.toOp: a lowerer is required on the Postgres planner path (constraint "${this.oldConstraintName}" on table "${this.tableName}"). Pass the control adapter to createPostgresMigrationPlanner.`, - { meta: { factory: 'RenameCheckConstraintCall' } }, + `RenameConstraintCall.toOp: a lowerer is required on the Postgres planner path (constraint "${this.oldConstraintName}" on table "${this.tableName}"). Pass the control adapter to createPostgresMigrationPlanner.`, + { meta: { factory: 'RenameConstraintCall' } }, ); } - return renameCheckConstraint( + return renameConstraint( this.schemaName, this.tableName, + this.kind, this.oldConstraintName, this.newConstraintName, lowerer, @@ -1098,9 +1152,10 @@ export class RenameCheckConstraintCall extends PostgresOpFactoryCallNode { opts.push(`schema: ${jsonToTsSource(this.schemaName)}`); } opts.push(`table: ${jsonToTsSource(this.tableName)}`); + opts.push(`kind: ${jsonToTsSource(this.kind)}`); opts.push(`from: ${jsonToTsSource(this.oldConstraintName)}`); opts.push(`to: ${jsonToTsSource(this.newConstraintName)}`); - return `this.renameCheckConstraint({ ${opts.join(', ')} })`; + return `this.renameConstraint({ ${opts.join(', ')} })`; } override importRequirements(): readonly ImportRequirement[] { @@ -1959,6 +2014,7 @@ export class RenamePostgresRlsPolicyCall extends PostgresOpFactoryCallNode { export type PostgresOpFactoryCall = | CreateTableCall | DropTableCall + | RenameTableCall | AddColumnCall | DropColumnCall | AlterColumnTypeCall @@ -1972,7 +2028,7 @@ export type PostgresOpFactoryCall = | AddForeignKeyCall | AddUniqueCall | AddCheckConstraintCall - | RenameCheckConstraintCall + | RenameConstraintCall | DropCheckConstraintCall | CreateIndexCall | RenameIndexCall diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts index 4c2fd2704bd4..1c93e9c776e0 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts @@ -161,9 +161,28 @@ export async function addCheckConstraint( }; } -export async function renameCheckConstraint( +export type RenamableConstraintKind = 'primaryKey' | 'unique' | 'foreignKey' | 'checkConstraint'; + +const CONSTRAINT_KIND_LABEL: Readonly> = { + primaryKey: 'primary key', + unique: 'unique constraint', + foreignKey: 'foreign key', + checkConstraint: 'check constraint', +}; + +export function renameConstraintLabel( + kind: RenamableConstraintKind, + fromName: string, + toName: string, + tableName: string, +): string { + return `Rename ${CONSTRAINT_KIND_LABEL[kind]} "${fromName}" to "${toName}" on "${tableName}"`; +} + +export async function renameConstraint( schemaName: string, tableName: string, + kind: RenamableConstraintKind, fromName: string, toName: string, lowerer: ExecuteRequestLowerer, @@ -182,19 +201,19 @@ export async function renameCheckConstraint( table: tableName, }); return { - id: `checkConstraint.${schemaName}.${tableName}.${fromName}.rename`, - label: `Rename check constraint "${fromName}" to "${toName}" on "${tableName}"`, + id: `${kind}.${schemaName}.${tableName}.${fromName}.rename`, + label: renameConstraintLabel(kind, fromName, toName, tableName), operationClass: 'widening', // The NEW name is the constraint's contract-side identity — the rename // convention indexes and policies already follow. - target: targetDetails('checkConstraint', toName, schemaName, tableName), + target: targetDetails(kind, toName, schemaName, tableName), precheck: [ step(`ensure constraint "${fromName}" exists`, from.present.sql, from.present.params), step(`ensure constraint "${toName}" does not exist`, to.absent.sql, to.absent.params), ], execute: [ step( - `rename check constraint "${fromName}" to "${toName}"`, + `rename ${CONSTRAINT_KIND_LABEL[kind]} "${fromName}" to "${toName}"`, `ALTER TABLE ${qualified} RENAME CONSTRAINT ${quoteIdentifier(fromName)} TO ${quoteIdentifier(toName)}`, ), ], diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts index a9e2fe9d36a0..1bd746a44b7b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts @@ -1,5 +1,6 @@ import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import { tableExistsAst } from '../../../contract-free/checks'; +import { quoteIdentifier } from '../../sql-utils'; import { qualifyTableName } from '../planner-sql-checks'; import { type Op, step, targetDetails } from './shared'; @@ -22,3 +23,44 @@ export async function dropTable( postcheck: [step(`verify table "${tableName}" does not exist`, absent.sql, absent.params)], }; } + +export function renameTableStatement(schemaName: string, fromName: string, toName: string): string { + return `ALTER TABLE ${qualifyTableName(schemaName, fromName)} RENAME TO ${quoteIdentifier(toName)}`; +} + +export async function renameTable( + schemaName: string, + fromName: string, + toName: string, + lowerer: ExecuteRequestLowerer, +): Promise { + const fromChecks = tableExistsAst(schemaName, fromName); + const toChecks = tableExistsAst(schemaName, toName); + const fromPresent = await lowerer.lowerToExecuteRequest(fromChecks.tablePresent()); + const toAbsent = await lowerer.lowerToExecuteRequest(toChecks.tableAbsent()); + const toPresent = await lowerer.lowerToExecuteRequest(toChecks.tablePresent()); + const fromAbsent = await lowerer.lowerToExecuteRequest(fromChecks.tableAbsent()); + return { + id: `renameTable.${fromName}`, + label: `Rename table "${fromName}" to "${toName}"`, + operationClass: 'widening', + target: targetDetails('table', toName, schemaName), + precheck: [ + step(`ensure table "${fromName}" exists`, fromPresent.sql, fromPresent.params), + step(`ensure table "${toName}" does not exist`, toAbsent.sql, toAbsent.params), + ], + execute: [ + step( + `rename table "${fromName}" to "${toName}"`, + renameTableStatement(schemaName, fromName, toName), + ), + ], + // Both postchecks: the runner skips an operation whose postcheck already + // holds, and "the new table exists" alone would skip a rename that never + // happened when both tables exist. + postcheck: [ + step(`verify table "${toName}" exists`, toPresent.sql, toPresent.params), + step(`verify table "${fromName}" no longer exists`, fromAbsent.sql, fromAbsent.params), + ], + }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 2ea5148e12f2..07bfbd79cfdf 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -30,9 +30,9 @@ import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import type { SqlStorage } from '@internal/sql-contract/types'; import { namingOf, parseWireName } from '@internal/sql-schema-ir/naming'; import type { SqlSchemaIR } from '@internal/sql-schema-ir/types'; -import { SqlCheckConstraintIR, SqlIndexIR } from '@internal/sql-schema-ir/types'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; +import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; import { postgresNodeStorageCoordinate } from '../schema-ir/node-storage-coordinate'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; @@ -47,6 +47,7 @@ import { resolvePostgresNodeIssueCreationFactoryName, } from './control-policy'; import { buildPostgresPlanDiff } from './diff-database-schema'; +import { pairCheckRenames, pairIndexRenames } from './index-and-check-renames'; import { coalesceSubtreeIssues, conflictForDisallowedCall, @@ -58,13 +59,14 @@ import type { PostgresOpFactoryCall } from './op-factory-call'; import { CreatePostgresRlsPolicyCall, DropPostgresRlsPolicyCall, - RenameCheckConstraintCall, - RenameIndexCall, RenamePostgresRlsPolicyCall, + RenameTableCall, } from './op-factory-call'; +import { renameTableStatement } from './operations/tables'; import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration'; import { postgresPlannerStrategies } from './planner-strategies'; import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; +import { emissionSchemaForNamespace } from './table-rename-calls'; import { verifyPostgresNamespacePresence } from './verify-postgres-namespaces'; type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends { @@ -77,6 +79,18 @@ type PlannerOptionsWithComponents = SqlMigrationPlannerPlanOptions & { readonly frameworkComponents: PlannerFrameworkComponents; }; +function partitionPostgresCallsByControlPolicy( + calls: readonly TCall[], + contract: Contract, +) { + return partitionCallsByControlPolicy({ + calls, + contract, + resolveControlPolicySubject: (call) => resolvePostgresCallControlPolicySubject(call, contract), + resolveFactoryName: (call) => call.factoryName, + }); +} + export function createPostgresMigrationPlanner( lowerer: ExecuteRequestLowerer, ): PostgresMigrationPlanner { @@ -256,8 +270,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // mapping turns them into create + drop. Consumed issues never reach // `planIssues`; the rename calls go through the same call-side // control-policy partition the policy ops use. - const indexRenames = this.pairIndexRenames(options, schemaIssues); - const checkRenames = this.pairCheckRenames(options, schemaIssues); + const indexRenames = pairIndexRenames(options, schemaIssues); + const checkRenames = pairCheckRenames(options, schemaIssues); const renameConsumed = new Set([...indexRenames.consumed, ...checkRenames.consumed]); const plannableIssues = renameConsumed.size === 0 @@ -299,6 +313,21 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr }, namespaceIdOf: (issue) => resolveNamespaceIdForDdlSchema(options.contract, issueSchemaName(issue) ?? schemaName), + renameByHandStatements: (rename) => [ + renameTableStatement( + emissionSchemaForNamespace(options.contract, rename.namespaceId), + rename.from, + rename.to, + ), + ], + renameTableCall: (rename) => + new RenameTableCall( + rename.namespaceId ?? UNBOUND_NAMESPACE_ID, + rename.from, + rename.to, + ).renderTypeScript(), + contract: options.contract, + defaultNamespaceId: DEFAULT_NAMESPACE_ID, }); if (caseChangeConflicts.length > 0) { return plannerFailure(caseChangeConflicts); @@ -329,21 +358,15 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return plannerFailure([...(result.ok ? [] : result.failure), ...schemaDiff.conflicts]); } - const indexRenamePartition = partitionCallsByControlPolicy({ - calls: [...indexRenames.calls, ...checkRenames.calls], - contract: options.contract, - resolveControlPolicySubject: (call) => - resolvePostgresCallControlPolicySubject(call, options.contract), - resolveFactoryName: (call) => call.factoryName, - }); + const indexRenamePartition = partitionPostgresCallsByControlPolicy( + [...indexRenames.calls, ...checkRenames.calls], + options.contract, + ); - const schemaDiffPartition = partitionCallsByControlPolicy({ - calls: schemaDiff.calls, - contract: options.contract, - resolveControlPolicySubject: (call) => - resolvePostgresCallControlPolicySubject(call, options.contract), - resolveFactoryName: (call) => call.factoryName, - }); + const schemaDiffPartition = partitionPostgresCallsByControlPolicy( + schemaDiff.calls, + options.contract, + ); // Inline `onFieldEvent`-emitted ops after structural DDL. The fixed // ordering is `structural → added → dropped → altered`, with @@ -361,13 +384,10 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr readonly PostgresOpFactoryCall[], 'Codec hook ops conform to PostgresOpFactoryCall at the app emitter boundary' >(fieldEventOps); - const fieldEventPartition = partitionCallsByControlPolicy({ - calls: fieldEventPostgresCalls, - contract: options.contract, - resolveControlPolicySubject: (call) => - resolvePostgresCallControlPolicySubject(call, options.contract), - resolveFactoryName: (call) => call.factoryName, - }); + const fieldEventPartition = partitionPostgresCallsByControlPolicy( + fieldEventPostgresCalls, + options.contract, + ); const calls = [ ...result.value.calls, ...indexRenamePartition.kept, @@ -410,228 +430,6 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr }); } - /** - * Rename post-pass for indexes, per `(schema, table)`, widening-only, - * deterministic by sorted names — the same structure as the policy pass - * below (which stays untouched: policies pair by hash only). - * - * Hash pairing (prefix-only renames): extras whose live names parse as - * wire names, grouped by `(schema, table, hash)`; missing nodes iterated - * in sorted-name order consume the sorted-name-first candidate. - * - * Content pairing (exact→wire convergence), after hash pairing has - * consumed its matches: the remaining wire-named-missing nodes - * (`prefix` defined) against the remaining extras of any name shape, - * paired iff content-equal (columns ordered-strict both-defined-or- - * both-undefined, `unique`/`type` strict, `options` loose, bodies - * byte-equal). - * - * Leftovers proceed as create/drop exactly as before; without the - * widening allowance the pass is skipped and pairing degrades to the - * additive half, like the policy pass. - */ - private pairIndexRenames( - options: PlannerOptionsWithComponents, - issues: readonly SchemaDiffIssue[], - ): { - readonly calls: readonly RenameIndexCall[]; - readonly consumed: ReadonlySet>; - } { - const consumed = new Set>(); - const calls: RenameIndexCall[] = []; - if (!options.policy.allowedOperationClasses.includes('widening')) { - return { calls, consumed }; - } - - interface IndexFinding { - readonly issue: SchemaDiffIssue; - readonly node: SqlIndexIR; - readonly ddlSchema: string; - readonly tableName: string; - } - const missing: IndexFinding[] = []; - const extra: IndexFinding[] = []; - for (const issue of issues) { - const node = issueNode(issue); - if (node === undefined || !SqlIndexIR.is(node)) continue; - const ddlSchema = issue.path[1]; - const tableName = issue.path[2]; - if (ddlSchema === undefined || tableName === undefined) continue; - if (issueOutcome(issue) === 'not-found') { - missing.push({ issue, node, ddlSchema, tableName }); - } else if (issueOutcome(issue) === 'not-expected') { - extra.push({ issue, node, ddlSchema, tableName }); - } - } - if (missing.length === 0 || extra.length === 0) { - return { calls, consumed }; - } - - const byName = (a: IndexFinding, b: IndexFinding): number => - a.node.name < b.node.name ? -1 : a.node.name > b.node.name ? 1 : 0; - // DDL emission must stay unqualified for the unbound namespace, exactly - // like the per-issue mapping's `emissionSchemaName`. - const emissionSchema = (ddlSchema: string): string => - resolveNamespaceIdForDdlSchema(options.contract, ddlSchema) === UNBOUND_NAMESPACE_ID - ? UNBOUND_NAMESPACE_ID - : ddlSchema; - const pairingKey = (finding: IndexFinding, hash: string): string => - JSON.stringify([finding.ddlSchema, finding.tableName, hash]); - const rename = (missingFinding: IndexFinding, candidate: IndexFinding): void => { - consumed.add(missingFinding.issue); - consumed.add(candidate.issue); - calls.push( - new RenameIndexCall( - emissionSchema(missingFinding.ddlSchema), - missingFinding.tableName, - candidate.node.name, - missingFinding.node.name, - ), - ); - }; - - const sortedMissing = [...missing].sort(byName); - - const extrasByHash = new Map(); - for (const finding of extra) { - const parsed = parseWireName(finding.node.name); - if (parsed === undefined) continue; - const key = pairingKey(finding, parsed.hash); - const group = extrasByHash.get(key) ?? []; - group.push(finding); - extrasByHash.set(key, group); - } - for (const group of extrasByHash.values()) { - group.sort(byName); - } - for (const missingFinding of sortedMissing) { - const parsed = parseWireName(missingFinding.node.name); - if (parsed === undefined) continue; - const candidate = extrasByHash.get(pairingKey(missingFinding, parsed.hash))?.shift(); - if (candidate === undefined) continue; - rename(missingFinding, candidate); - } - - const sortedExtras = [...extra].sort(byName); - for (const missingFinding of sortedMissing) { - if (consumed.has(missingFinding.issue)) continue; - if (missingFinding.node.prefix === undefined) continue; - const candidate = sortedExtras.find( - (extraFinding) => - !consumed.has(extraFinding.issue) && - extraFinding.ddlSchema === missingFinding.ddlSchema && - extraFinding.tableName === missingFinding.tableName && - missingFinding.node.contentEquals(extraFinding.node, { - columnPresence: 'matching', - bodies: 'verbatim', - }), - ); - if (candidate === undefined) continue; - rename(missingFinding, candidate); - } - - return { calls, consumed }; - } - - /** - * Check-constraint rename post-pass: a `not-found` and a `not-expected` - * check on the same table whose wire-name content hashes match but whose - * prefixes differ is a prefix-only rename, and collapses into one - * `ALTER TABLE … RENAME CONSTRAINT`. - * - * This is the index pass's hash-pairing phase and nothing else. There is - * deliberately no content-pairing phase: a live check body is whatever - * Postgres reprinted, so it never byte-matches the authored text, and - * pairing an exact-named live check by content would bless whatever - * predicate is actually live. Adoption of an old exact-named check stays - * drop + add. - * - * Runs only when the policy allows `widening` (rename's class). Without it - * the pass no-ops and the pair degrades to the slice-1 behavior: an add, - * plus a drop when `destructive` is allowed too. - */ - private pairCheckRenames( - options: PlannerOptionsWithComponents, - issues: readonly SchemaDiffIssue[], - ): { - readonly calls: readonly RenameCheckConstraintCall[]; - readonly consumed: ReadonlySet>; - } { - const consumed = new Set>(); - const calls: RenameCheckConstraintCall[] = []; - if (!options.policy.allowedOperationClasses.includes('widening')) { - return { calls, consumed }; - } - - interface CheckFinding { - readonly issue: SchemaDiffIssue; - readonly node: SqlCheckConstraintIR; - readonly ddlSchema: string; - readonly tableName: string; - } - const missing: CheckFinding[] = []; - const extra: CheckFinding[] = []; - for (const issue of issues) { - const node = issueNode(issue); - if (node === undefined || !SqlCheckConstraintIR.is(node)) continue; - const ddlSchema = issue.path[1]; - const tableName = issue.path[2]; - if (ddlSchema === undefined || tableName === undefined) continue; - if (issueOutcome(issue) === 'not-found') { - missing.push({ issue, node, ddlSchema, tableName }); - } else if (issueOutcome(issue) === 'not-expected') { - extra.push({ issue, node, ddlSchema, tableName }); - } - } - if (missing.length === 0 || extra.length === 0) { - return { calls, consumed }; - } - - const byName = (a: CheckFinding, b: CheckFinding): number => - a.node.name < b.node.name ? -1 : a.node.name > b.node.name ? 1 : 0; - // DDL emission must stay unqualified for the unbound namespace, exactly - // like the per-issue mapping's `emissionSchemaName`. - const emissionSchema = (ddlSchema: string): string => - resolveNamespaceIdForDdlSchema(options.contract, ddlSchema) === UNBOUND_NAMESPACE_ID - ? UNBOUND_NAMESPACE_ID - : ddlSchema; - const pairingKey = (finding: CheckFinding, hash: string): string => - JSON.stringify([finding.ddlSchema, finding.tableName, hash]); - - const sortedMissing = [...missing].sort(byName); - - const extrasByHash = new Map(); - for (const finding of extra) { - const parsed = parseWireName(finding.node.name); - if (parsed === undefined) continue; - const key = pairingKey(finding, parsed.hash); - const group = extrasByHash.get(key) ?? []; - group.push(finding); - extrasByHash.set(key, group); - } - for (const group of extrasByHash.values()) { - group.sort(byName); - } - for (const missingFinding of sortedMissing) { - const parsed = parseWireName(missingFinding.node.name); - if (parsed === undefined) continue; - const candidate = extrasByHash.get(pairingKey(missingFinding, parsed.hash))?.shift(); - if (candidate === undefined) continue; - consumed.add(missingFinding.issue); - consumed.add(candidate.issue); - calls.push( - new RenameCheckConstraintCall( - emissionSchema(missingFinding.ddlSchema), - missingFinding.tableName, - candidate.node.name, - missingFinding.node.name, - ), - ); - } - - return { calls, consumed }; - } - /** * Maps the RLS policy findings of the one combined tree diff * (`buildPostgresPlanDiff`, already ownership-filtered) into diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts new file mode 100644 index 000000000000..63f74433e355 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-contract-to-schema.ts @@ -0,0 +1,41 @@ +import type { ColumnDefault, Contract } from '@internal/contract/types'; +import { buildNativeTypeExpander } from '@internal/family-sql/control'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; +import type { StorageColumn } from '@internal/sql-contract/types'; +import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; +import { postgresResolveDefault } from '../default-normalizer'; +import type { PostgresContract } from '../postgres-schema'; +import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { contractToPostgresDatabaseSchemaNode } from './contract-to-postgres-database-schema-node'; +import { renderDefaultLiteral } from './planner-ddl-builders'; + +export function postgresRenderDefault(def: ColumnDefault, column: StorageColumn): string { + if (def.kind === 'function') { + return def.expression; + } + return renderDefaultLiteral(def.value, column); +} + +/** + * The Postgres schema tree a contract describes, as the planner's "from" side: + * the target descriptor's `migrations.contractToSchema` hook and the trees + * `renameTable` compares to find its companion renames both go through here, + * so they are built with the same expander, default renderer and resolver. + */ +export function postgresContractToSchema( + contract: Contract | null, + frameworkComponents: ReadonlyArray> | undefined, +): PostgresDatabaseSchemaNode { + const expander = buildNativeTypeExpander(frameworkComponents); + const postgresContract = blindCast< + PostgresContract | null, + 'the family resolver only binds this hook for a Postgres-target contract' + >(contract); + return contractToPostgresDatabaseSchemaNode(postgresContract, { + annotationNamespace: 'pg', + ...ifDefined('expandNativeType', expander), + renderDefault: postgresRenderDefault, + resolveDefault: postgresResolveDefault, + }); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts index 5a8a0d51193e..78e7d7140542 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts @@ -2,6 +2,7 @@ import type { Contract } from '@internal/contract/types'; import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import { Migration as SqlMigration } from '@internal/family-sql/migration'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { ControlStack } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { MigrationContractViews } from '@internal/migration-tools/migration'; @@ -35,17 +36,19 @@ import { DropPostgresRlsPolicyCall, DropTableCall, EnableRowLevelSecurityCall, - RenameCheckConstraintCall, + RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, SetDefaultCall, SetNotNullCall, } from './op-factory-call'; +import type { RenamableConstraintKind } from './operations/constraints'; import { type DataTransformOptions, dataTransform } from './operations/data-transform'; import { installExtension } from './operations/dependencies'; import type { CreateIndexExtras } from './operations/indexes'; import type { ForeignKeySpec } from './operations/shared'; import type { PostgresPlanTargetDetails } from './planner-target-details'; +import { postgresTableRenameCalls } from './table-rename-calls'; /** * Target-owned base class for Postgres migrations. @@ -109,6 +112,16 @@ export abstract class PostgresMigration< : undefined; } + private frameworkComponents(): ReadonlyArray> { + const stack = this.stack; + if (stack === undefined) return []; + return [ + stack.target, + ...(stack.adapter === undefined ? [] : [stack.adapter]), + ...stack.extensions, + ]; + } + /** * Returns the materialized control adapter, or throws a MIGRATION.POSTGRES_CONTROL_STACK_MISSING naming * `operation` when the migration was constructed without a `ControlStack`. @@ -296,12 +309,23 @@ export abstract class PostgresMigration< readonly from: string; readonly to: string; }): Promise> { - return new RenameCheckConstraintCall( + return this.renameConstraint({ ...options, kind: 'checkConstraint' }); + } + + protected renameConstraint(options: { + readonly schema?: string; + readonly table: string; + readonly kind: RenamableConstraintKind; + readonly from: string; + readonly to: string; + }): Promise> { + return new RenameConstraintCall( options.schema ?? UNBOUND_NAMESPACE_ID, options.table, + options.kind, options.from, options.to, - ).toOp(this.controlAdapterFor('renameCheckConstraint')); + ).toOp(this.controlAdapterFor('renameConstraint')); } protected dropCheckConstraint(options: { @@ -328,6 +352,23 @@ export abstract class PostgresMigration< ).toOp(this.controlAdapterFor('dropConstraint')); } + /** + * Emit the operations that rename a table: the table rename, then a rename of each primary key, unique constraint, foreign key, index and check whose name was derived from the old table name, read from this migration's start and end contracts. Spread the result into `operations`: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. `schema` names the table's namespace when more than one declares the table. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + */ + protected renameTable(options: { + readonly schema?: string; + readonly table: string; + readonly to: string; + }): readonly Promise>[] { + const adapter = this.controlAdapterFor('renameTable'); + return postgresTableRenameCalls({ + startContract: this.startContract, + endContract: this.endContract, + rename: { namespaceId: options.schema, from: options.table, to: options.to }, + frameworkComponents: this.frameworkComponents(), + }).map(async (call) => call.toOp(adapter)); + } + protected dropTable(options: { readonly schema: string; readonly table: string; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts new file mode 100644 index 000000000000..0934ab53f2f6 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts @@ -0,0 +1,55 @@ +import type { ResolvedTableRename } from '@internal/family-sql/control'; +import type { SqlNamespaceEntries } from '@internal/sql-contract/types'; +import { namingOf } from '@internal/sql-schema-ir/naming'; +import { PostgresRlsEnablement } from '../postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../postgres-rls-policy'; + +function renamedMarker(marker: unknown, rename: ResolvedTableRename): unknown { + if (!(marker instanceof PostgresRlsEnablement) || marker.tableName !== rename.from) return marker; + return new PostgresRlsEnablement({ tableName: rename.to, namespaceId: marker.namespaceId }); +} + +function renamedPolicy(policy: unknown, rename: ResolvedTableRename): unknown { + if (!(policy instanceof PostgresRlsPolicy) || policy.tableName !== rename.from) return policy; + return new PostgresRlsPolicy({ + naming: namingOf(policy.name, policy.prefix), + tableName: rename.to, + namespaceId: policy.namespaceId, + operation: policy.operation, + roles: policy.roles, + using: policy.using, + withCheck: policy.withCheck, + permissive: policy.permissive, + }); +} + +/** + * Moves the RLS marker and the policies of a renamed table onto its new name. The marker is keyed by table name; policies are keyed by their own name and only their `tableName` changes. Policy names do not derive from the table name, so none is renamed. + */ +export function renameRlsReferences( + entries: SqlNamespaceEntries, + rename: ResolvedTableRename, +): SqlNamespaceEntries { + const markers = entries['rls']; + const policies = entries['policy']; + return { + ...entries, + ...(markers === undefined + ? {} + : { + rls: Object.fromEntries( + Object.entries(markers).map(([tableName, marker]) => [ + tableName === rename.from ? rename.to : tableName, + renamedMarker(marker, rename), + ]), + ), + }), + ...(policies === undefined + ? {} + : { + policy: Object.fromEntries( + Object.entries(policies).map(([name, policy]) => [name, renamedPolicy(policy, rename)]), + ), + }), + }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts new file mode 100644 index 000000000000..68ee25ad4d79 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts @@ -0,0 +1,92 @@ +import type { Contract } from '@internal/contract/types'; +import { + applyTableRename, + type MigrationOperationPolicy, + type ResolvedTableRename, + type TableRename, +} from '@internal/family-sql/control'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { assertDefined } from '@internal/utils/assertions'; +import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import type { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; +import { buildPostgresPlanDiff } from './diff-database-schema'; +import { pairCheckRenames, pairIndexRenames } from './index-and-check-renames'; +import { type PostgresOpFactoryCall, RenameTableCall } from './op-factory-call'; +import { postgresContractToSchema } from './postgres-contract-to-schema'; +import { renameRlsReferences } from './rename-rls-references'; +import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; +import { constraintRenamesForTableRename } from './table-rename-constraint-renames'; + +const RENAME_POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['widening'] }; + +export function emissionSchemaForNamespace( + contract: Contract, + namespaceId: string, +): string { + return namespaceId === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : resolveDdlSchemaForNamespaceStorage(contract.storage, namespaceId); +} + +function renamedTableNode( + schema: PostgresDatabaseSchemaNode, + contract: Contract, + rename: ResolvedTableRename, +): PostgresTableSchemaNode { + const ddlSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, rename.namespaceId); + const table = Object.values(schema.namespaces).find( + (namespace) => namespace.schemaName === ddlSchema, + )?.tables[rename.to]; + assertDefined(table, `a resolved rename names table "${rename.to}" in schema "${ddlSchema}"`); + return table; +} + +/** + * The calls a migration's `renameTable` emits: the table rename, then a rename of each primary key, unique constraint and foreign key the start contract left unnamed, and of each wire-named index and check whose prefix derives from the table name. Only objects the end contract leaves otherwise unchanged are renamed; an unchanged constraint takes the end contract's explicit name if it has one. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + */ +export function postgresTableRenameCalls(input: { + readonly startContract: Contract | null; + readonly endContract: Contract; + readonly rename: TableRename; + readonly frameworkComponents: ReadonlyArray>; +}): readonly PostgresOpFactoryCall[] { + const applied = applyTableRename({ + startContract: input.startContract, + endContract: input.endContract, + rename: input.rename, + renameTableReferences: renameRlsReferences, + }); + if (!applied.ok) { + throw applied.failure; + } + const { rename } = applied.value; + const contract = input.endContract; + const schemaName = emissionSchemaForNamespace(contract, rename.namespaceId); + const previousSchema = postgresContractToSchema( + applied.value.contract, + input.frameworkComponents, + ); + const nextSchema = postgresContractToSchema(contract, input.frameworkComponents); + const { issues } = buildPostgresPlanDiff({ + contract, + actualSchema: previousSchema, + frameworkComponents: input.frameworkComponents, + }); + const onRenamedTable = (call: { readonly schemaName: string; readonly tableName: string }) => + call.schemaName === schemaName && call.tableName === rename.to; + const pairing = { contract, policy: RENAME_POLICY }; + return [ + new RenameTableCall(schemaName, rename.from, rename.to), + ...constraintRenamesForTableRename({ + schemaName, + from: rename.from, + to: rename.to, + previous: renamedTableNode(previousSchema, contract, rename), + next: renamedTableNode(nextSchema, contract, rename), + }), + ...pairIndexRenames(pairing, issues).calls.filter(onRenamedTable), + ...pairCheckRenames(pairing, issues).calls.filter(onRenamedTable), + ]; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts new file mode 100644 index 000000000000..995aabd78966 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts @@ -0,0 +1,80 @@ +import type { DiffableNode } from '@internal/framework-components/control'; +import type { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; +import { + defaultForeignKeyName, + defaultPrimaryKeyName, + defaultUniqueName, +} from './default-constraint-names'; +import { RenameConstraintCall } from './op-factory-call'; + +export interface TableRenameConstraintInput { + readonly schemaName: string; + readonly from: string; + readonly to: string; + /** The renamed table as the start contract describes it, under its new name. */ + readonly previous: PostgresTableSchemaNode; + readonly next: PostgresTableSchemaNode; +} + +/** The constraint of the next table the diff pairs with `node` and finds unchanged, as the diff compares them. */ +function unchangedIn( + node: TNode, + nextNodes: readonly TNode[], +): TNode | undefined { + return nextNodes.find((next) => next.id === node.id && next.isEqualTo(node)); +} + +/** + * The constraint renames that follow a table rename. A primary key, unique constraint or foreign key the start contract left unnamed carries a name derived from the old table name. When the end contract keeps the same constraint unchanged, it is renamed to the name the end contract gives it explicitly, or otherwise to the name derived from the new table name. A constraint the end contract changes is not renamed, so it keeps its name in the database. A constraint the start contract named keeps its name. Indexes and checks are not handled here: their wire names pair by content hash in the index and check rename passes. + */ +export function constraintRenamesForTableRename( + input: TableRenameConstraintInput, +): readonly RenameConstraintCall[] { + const { schemaName, from, to, previous, next } = input; + const rename = ( + kind: 'primaryKey' | 'unique' | 'foreignKey', + oldName: string, + unchanged: { readonly name?: string } | undefined, + derivedName: string, + ): readonly RenameConstraintCall[] => { + if (unchanged === undefined) return []; + const newName = unchanged.name ?? derivedName; + return oldName === newName + ? [] + : [new RenameConstraintCall(schemaName, to, kind, oldName, newName)]; + }; + + const primaryKey = + previous.primaryKey !== undefined && previous.primaryKey.name === undefined + ? rename( + 'primaryKey', + defaultPrimaryKeyName(from), + unchangedIn(previous.primaryKey, next.primaryKey === undefined ? [] : [next.primaryKey]), + defaultPrimaryKeyName(to), + ) + : []; + + const uniques = previous.uniques + .filter((unique) => unique.name === undefined) + .flatMap((unique) => + rename( + 'unique', + defaultUniqueName(from, unique.columns), + unchangedIn(unique, next.uniques), + defaultUniqueName(to, unique.columns), + ), + ); + + const foreignKeys = previous.foreignKeys + .filter((fk) => fk.name === undefined) + .flatMap((fk) => + rename( + 'foreignKey', + defaultForeignKeyName(from, fk.columns), + unchangedIn(fk, next.foreignKeys), + defaultForeignKeyName(to, fk.columns), + ), + ); + + return [...primaryKey, ...uniques, ...foreignKeys]; +} diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 65fd945e7d1f..66ec9362da13 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -1,24 +1,16 @@ -import type { ColumnDefault } from '@internal/contract/types'; import type { SqlControlTargetDescriptor } from '@internal/family-sql/control'; -import { buildNativeTypeExpander } from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import type { ControlTargetInstance, MigrationRunner, } from '@internal/framework-components/control'; -import type { StorageColumn } from '@internal/sql-contract/types'; -import { blindCast } from '@internal/utils/casts'; -import { ifDefined } from '@internal/utils/defined'; -import { postgresResolveDefault } from '../core/default-normalizer'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; -import { contractToPostgresDatabaseSchemaNode } from '../core/migrations/contract-to-postgres-database-schema-node'; import { diffPostgresSchema } from '../core/migrations/diff-database-schema'; import { createPostgresMigrationPlanner } from '../core/migrations/planner'; -import { renderDefaultLiteral } from '../core/migrations/planner-ddl-builders'; import type { PostgresPlanTargetDetails } from '../core/migrations/planner-target-details'; +import { postgresContractToSchema } from '../core/migrations/postgres-contract-to-schema'; import { createPostgresMigrationRunner } from '../core/migrations/runner'; import { PostgresContractSerializer } from '../core/postgres-contract-serializer'; -import type { PostgresContract } from '../core/postgres-schema'; import { PostgresSchemaVerifier } from '../core/postgres-schema-verifier'; import { inferPostgresPslContract } from '../core/psl-infer/infer-psl-contract'; import { PostgresDatabaseSchemaNode } from '../core/schema-ir/postgres-database-schema-node'; @@ -27,12 +19,7 @@ import { postgresDiffSubjectGranularity, } from '../core/schema-ir/schema-node-kinds'; -export function postgresRenderDefault(def: ColumnDefault, column: StorageColumn): string { - if (def.kind === 'function') { - return def.expression; - } - return renderDefaultLiteral(def.value, column); -} +export { postgresRenderDefault } from '../core/migrations/postgres-contract-to-schema'; const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresPlanTargetDetails> = { @@ -56,17 +43,7 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP return createPostgresMigrationRunner(family) as MigrationRunner<'sql', 'postgres'>; }, contractToSchema(contract, frameworkComponents) { - const expander = buildNativeTypeExpander(frameworkComponents); - const postgresContract = blindCast< - PostgresContract | null, - 'the family resolver only binds this hook for a Postgres-target contract' - >(contract); - return contractToPostgresDatabaseSchemaNode(postgresContract, { - annotationNamespace: 'pg', - ...ifDefined('expandNativeType', expander), - renderDefault: postgresRenderDefault, - resolveDefault: postgresResolveDefault, - }); + return postgresContractToSchema(contract, frameworkComponents); }, }, create(): ControlTargetInstance<'sql', 'postgres'> { diff --git a/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts index 2b23535b3990..d48e49622b37 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts @@ -26,9 +26,10 @@ export { DropTableCall, EnableRowLevelSecurityCall, RawSqlCall, - RenameCheckConstraintCall, + RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, + RenameTableCall, SetDefaultCall, SetNotNullCall, } from '../core/migrations/op-factory-call'; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts index 99908e5ac51a..6b60d7f1692e 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts @@ -11,17 +11,25 @@ */ import { type Contract, type ControlPolicy, coreHash, profileHash } from '@internal/contract/types'; -import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; +import type { + ExecuteRequestLowerer, + SqlControlAdapter, +} from '@internal/family-sql/control-adapter'; +import { APP_SPACE_ID, type ControlStack } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; import { describe, expect, it } from 'vitest'; +import { postgresResolveDefault } from '../../src/core/default-normalizer'; +import { contractToPostgresDatabaseSchemaNode } from '../../src/core/migrations/contract-to-postgres-database-schema-node'; import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; -import { postgresCreateNamespace } from '../../src/core/postgres-schema'; +import { PostgresMigration } from '../../src/core/migrations/postgres-migration'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import { type PostgresContract, postgresCreateNamespace } from '../../src/core/postgres-schema'; import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; +import { postgresRenderDefault } from '../../src/exports/control'; const stubLowerer: ExecuteRequestLowerer = { lower(_ast, _ctx) { @@ -167,7 +175,13 @@ describe('Postgres planner table-name case guard', () => { }), ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); - expect(result.conflicts[0]?.why).toContain('ALTER TABLE "userProfile" RENAME TO "UserProfile"'); + expect(result.conflicts[0]?.why).toContain( + 'in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" }) to the migration\'s operations, which renames the table and the objects named after it;', + ); + expect(result.conflicts[0]?.why).not.toContain('--rename'); + expect(result.conflicts[0]?.why).toContain( + 'in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "UserProfile", then run db update again.', + ); }); it('still refuses when UserProfile also gained a column', () => { @@ -191,6 +205,9 @@ describe('Postgres planner table-name case guard', () => { entityKind: 'table', entityName: 'UserProfile', }); + expect(result.conflicts[0]?.why).toContain( + 'rename it by hand with ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile", then run db update again.', + ); }); it('plans nothing once the model maps back to userProfile', async () => { @@ -235,3 +252,141 @@ describe('Postgres planner table-name case guard', () => { expect(ids).toContain('table.Accounts'); }); }); + +function namespacedContract( + tablesByNamespace: Readonly>, + hashSeed: string, +): PostgresContract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: Object.fromEntries( + Object.entries(tablesByNamespace).map(([namespaceId, tableName]) => [ + namespaceId, + postgresCreateNamespace({ + id: namespaceId, + entries: { + table: { [tableName]: storageTable(undefined, undefined, tableName) }, + policy: {}, + }, + }), + ]), + ), + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +function planMigration(from: PostgresContract, to: PostgresContract) { + return createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: contractToPostgresDatabaseSchemaNode(from, { + annotationNamespace: 'pg', + renderDefault: postgresRenderDefault, + resolveDefault: postgresResolveDefault, + }), + policy: DESTRUCTIVE_POLICY, + fromContract: from, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); +} + +type ContractJson = { readonly storage: { readonly storageHash: string } }; +type RenameTableOptions = { readonly schema?: string; readonly table: string; readonly to: string }; + +const stack = { + adapter: { create: () => stubLowerer as unknown as SqlControlAdapter<'postgres'> }, + target: { kind: 'target', familyId: 'sql', targetId: 'postgres' }, + extensions: [], +} as unknown as ControlStack<'sql', 'postgres'>; + +function jsonOf(contract: PostgresContract): ContractJson { + return new PostgresContractSerializer().serializeContract(contract) as unknown as ContractJson; +} + +function suggestedRenameTable(why: string | undefined): { + readonly call: string; + readonly options: RenameTableOptions; +} { + const call = /\.\.\.this\.renameTable\(\{[^}]*\}\)/.exec(why ?? '')?.[0]; + if (call === undefined) throw new Error('the case guard suggested no renameTable call'); + const fields = Object.fromEntries( + [...call.matchAll(/(\w+): "([^"]*)"/g)].map(([, key, value]) => [key, value]), + ); + return { call, options: fields as unknown as RenameTableOptions }; +} + +async function renameStatements( + from: PostgresContract, + to: PostgresContract, + options: RenameTableOptions, +): Promise { + const startJson = jsonOf(from); + const endJson = jsonOf(to); + class RenameMigration extends PostgresMigration { + override readonly startContractJson = startJson; + override readonly endContractJson = endJson; + override get operations() { + return [...this.renameTable(options)]; + } + } + const ops = await Promise.all(new RenameMigration(stack).operations); + return ops.map((op) => op.execute.map((step) => step.sql)); +} + +describe('the renameTable call the Postgres case guard suggests', () => { + async function renameWithSuggestion(from: PostgresContract, to: PostgresContract) { + const refused = planMigration(from, to); + expect(refused.kind).toBe('failure'); + if (refused.kind !== 'failure') throw new Error('the case guard did not refuse'); + const { call, options } = suggestedRenameTable(refused.conflicts[0]?.why); + return { call, statements: await renameStatements(from, to, options) }; + } + + it('names the schema when another schema declares the old table name, and the call renames the table', async () => { + const result = await renameWithSuggestion( + namespacedContract({ public: 'userProfile', auth: 'userProfile' }, 'two-namespaces-from'), + namespacedContract({ public: 'UserProfile', auth: 'userProfile' }, 'two-namespaces-to'), + ); + + expect(result).toEqual({ + call: '...this.renameTable({ schema: "public", table: "userProfile", to: "UserProfile" })', + statements: [['ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"']], + }); + }); + + it('names the schema when the table is not in the default schema', async () => { + const result = await renameWithSuggestion( + namespacedContract({ auth: 'userProfile' }, 'auth-from'), + namespacedContract({ auth: 'UserProfile' }, 'auth-to'), + ); + + expect(result.call).toBe( + '...this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })', + ); + expect(result.statements[0]).toEqual([ + 'ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"', + ]); + }); + + it('leaves the schema out for a table of the default schema whose name no other schema declares', async () => { + const result = await renameWithSuggestion( + namespacedContract({ public: 'userProfile', auth: 'account' }, 'public-from'), + namespacedContract({ public: 'UserProfile', auth: 'account' }, 'public-to'), + ); + + expect(result.call).toBe('...this.renameTable({ table: "userProfile", to: "UserProfile" })'); + expect(result.statements[0]).toEqual([ + 'ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"', + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts new file mode 100644 index 000000000000..06e64d6b4b0b --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts @@ -0,0 +1,345 @@ +/** + * `this.renameTable` in a hand-written Postgres migration. It reads the migration's start and end contracts and emits the table rename, then a rename of each object on the table whose name the planner derived from the old table name: unnamed primary keys, unique constraints and foreign keys, and wire-named indexes and checks. Only objects the end contract leaves otherwise unchanged are renamed; a constraint the end contract also changes keeps its name. Explicitly named objects keep their names. A table missing from either contract is refused. + */ + +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; +import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; +import type { ControlStack } from '@internal/framework-components/control'; +import { + type CheckConstraintInput, + type ForeignKeyInput, + type IndexInput, + SqlStorage, + StorageTable, +} from '@internal/sql-contract/types'; +import { computeCheckContentHash, computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import type { PostgresPlanTargetDetails } from '../../src/core/migrations/planner-target-details'; +import { PostgresMigration } from '../../src/core/migrations/postgres-migration'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import { PostgresRlsEnablement } from '../../src/core/postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; +import { PostgresSchema } from '../../src/core/postgres-schema'; +import { + contractOf, + NICKNAME_CHECK, + type ProfileSpec, + postTable, + reference, + stubLowerer, +} from './rename-table-fixtures'; + +type Op = SqlMigrationPlanOperation; +type ContractJson = { readonly storage: { readonly storageHash: string } }; + +const stack = { + adapter: { create: () => stubLowerer as unknown as SqlControlAdapter<'postgres'> }, + target: { kind: 'target', familyId: 'sql', targetId: 'postgres' }, + extensions: [], +} as unknown as ControlStack<'sql', 'postgres'>; + +function jsonOf(contract: Contract): ContractJson { + return new PostgresContractSerializer().serializeContract(contract) as unknown as ContractJson; +} + +function renameMigration( + start: Contract | null, + end: Contract, + rename: { readonly schema?: string; readonly table: string; readonly to: string }, +): { readonly operations: readonly Promise[] } { + const endJson = jsonOf(end); + class WithoutStart extends PostgresMigration { + override readonly endContractJson = endJson; + override get operations(): readonly Promise[] { + return [...this.renameTable(rename)]; + } + } + if (start === null) return new WithoutStart(stack); + const startJson = jsonOf(start); + class WithStart extends WithoutStart { + override readonly startContractJson = startJson; + } + return new WithStart(stack); +} + +const RENAME = { table: 'userProfile', to: 'UserProfile' } as const; + +async function renameOps( + start: Contract | null, + end: Contract, + rename: { readonly schema?: string; readonly table: string; readonly to: string } = RENAME, +): Promise { + return Promise.all(renameMigration(start, end, rename).operations); +} + +async function renameLabels(spec: ProfileSpec, nextSpec: ProfileSpec = spec) { + const ops = await renameOps( + contractOf('userProfile', spec, 'from'), + contractOf('UserProfile', nextSpec, 'to'), + ); + return ops.map((op) => op.label); +} + +const HANDLE_HASH = computeIndexContentHash({ columns: ['handle'], unique: false }); +const NICKNAME_HASH = computeCheckContentHash(NICKNAME_CHECK); + +const handleIndex = (tableName: string): IndexInput => ({ + columns: ['handle'], + naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash: HANDLE_HASH }, + where: undefined, + unique: false, + type: undefined, + options: undefined, +}); + +const nicknameCheck = (tableName: string): CheckConstraintInput => ({ + naming: { kind: 'wire', prefix: `${tableName}_nickname_check`, hash: NICKNAME_HASH }, + expression: NICKNAME_CHECK, +}); + +const accountForeignKey = (tableName: string): ForeignKeyInput => ({ + source: reference(tableName, ['accountId']), + target: reference('account', ['id']), +}); + +const withObjects: ProfileSpec = { + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [accountForeignKey(tableName)], + indexes: (tableName) => [handleIndex(tableName)], + checks: (tableName) => [nicknameCheck(tableName)], +}; + +function rlsContract(tableName: string, hashSeed: string): Contract { + const policy = new PostgresRlsPolicy({ + naming: { kind: 'wire', prefix: 'tenant_read', hash: 'f8d5e783' }, + tableName, + namespaceId: 'public', + operation: 'select', + roles: ['app_user'], + using: '(tenant_id = 1)', + withCheck: undefined, + permissive: true, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + public: new PostgresSchema({ + id: 'public', + entries: { + table: { + [tableName]: new StorageTable({ + columns: { + id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + tenant_id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, + }, + primaryKey: { columns: ['id'], name: 'profile_pk' }, + foreignKeys: [], + uniques: [], + indexes: [], + }), + }, + policy: { [policy.name]: policy }, + rls: { [tableName]: new PostgresRlsEnablement({ tableName, namespaceId: 'public' }) }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +describe('PostgresMigration.renameTable', () => { + it('emits the table rename, then a rename of each object named after the old table', async () => { + expect(await renameLabels(withObjects)).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + `Rename index "userProfile_handle_idx_${HANDLE_HASH}" to "UserProfile_handle_idx_${HANDLE_HASH}" on "UserProfile"`, + `Rename check constraint "userProfile_nickname_check_${NICKNAME_HASH}" to "UserProfile_nickname_check_${NICKNAME_HASH}" on "UserProfile"`, + ]); + }); + + it('renders the rename statements', async () => { + const ops = await renameOps( + contractOf('userProfile', { primaryKey: { columns: ['id'] } }, 'from'), + contractOf('UserProfile', { primaryKey: { columns: ['id'] } }, 'to'), + ); + + expect(ops.map((op) => op.execute.map((step) => step.sql))).toEqual([ + ['ALTER TABLE "userProfile" RENAME TO "UserProfile"'], + ['ALTER TABLE "UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"'], + ]); + }); + + it('renames an unnamed constraint to the explicit name the end contract gives it', async () => { + expect( + await renameLabels( + { uniques: [{ columns: ['email'] }] }, + { uniques: [{ columns: ['email'], name: 'profile_email_unique' }] }, + ), + ).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "profile_email_unique" on "UserProfile"', + ]); + }); + + it('leaves a foreign key the end contract points at another table under its current name', async () => { + const memberTable = () => ({ + member: new StorageTable({ + columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } }, + primaryKey: { columns: ['id'], name: 'member_pk' }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + }); + const ops = await renameOps( + contractOf( + 'userProfile', + { foreignKeys: (tableName) => [accountForeignKey(tableName)] }, + 'from', + memberTable, + ), + contractOf( + 'UserProfile', + { + foreignKeys: (tableName) => [ + { + source: reference(tableName, ['accountId']), + target: reference('member', ['id']), + name: 'profile_member_fk', + }, + ], + }, + 'to', + memberTable, + ), + ); + + expect(ops.map((op) => op.label)).toEqual(['Rename table "userProfile" to "UserProfile"']); + }); + + it('leaves a primary key whose columns the end contract changes under its current name', async () => { + expect( + await renameLabels( + { primaryKey: { columns: ['id'] } }, + { primaryKey: { columns: ['id', 'email'], name: 'profile_pk' } }, + ), + ).toEqual(['Rename table "userProfile" to "UserProfile"']); + }); + + it('leaves explicitly named objects alone', async () => { + const spec: ProfileSpec = { + primaryKey: { columns: ['id'], name: 'profile_pk' }, + uniques: [{ columns: ['email'], name: 'profile_email_unique' }], + foreignKeys: (tableName) => [{ ...accountForeignKey(tableName), name: 'profile_account_fk' }], + indexes: (tableName) => [ + { ...handleIndex(tableName), naming: { kind: 'exact', name: 'profile_handle' } }, + ], + }; + + expect(await renameLabels(spec)).toEqual(['Rename table "userProfile" to "UserProfile"']); + }); + + it('leaves a foreign key on another table that references the renamed table alone', async () => { + const ops = await renameOps( + contractOf('userProfile', {}, 'from', (tableName) => ({ post: postTable(tableName) })), + contractOf('UserProfile', {}, 'to', (tableName) => ({ post: postTable(tableName) })), + ); + + expect(ops.map((op) => op.label)).toEqual(['Rename table "userProfile" to "UserProfile"']); + }); + + it('emits the rename alone for a table with row-level security and a policy, which the rename carries', async () => { + const ops = await renameOps( + rlsContract('userProfile', 'from'), + rlsContract('UserProfile', 'to'), + ); + + expect(ops.map((op) => op.id)).toEqual(['renameTable.userProfile']); + }); + + it('renames in the schema that declares the table', async () => { + const ops = await renameOps( + contractOf('userProfile', {}, 'from', () => ({}), 'auth'), + contractOf('UserProfile', {}, 'to', () => ({}), 'auth'), + { schema: 'auth', ...RENAME }, + ); + + expect(ops.map((op) => op.execute.map((step) => step.sql))).toEqual([ + ['ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"'], + ]); + }); + + it('refuses a table the start contract does not have', async () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { table: 'ghost', to: 'UserProfile' }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining('table "ghost" does not exist in the start contract'), + }), + ); + }); + + it('refuses a new name the end contract does not have', async () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { table: 'userProfile', to: 'Profile' }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining('table "Profile" does not exist in the end contract'), + }), + ); + }); + + it('refuses a new name the start contract already has', async () => { + const withBoth = contractOf('userProfile', {}, 'from', () => ({ + UserProfile: new StorageTable({ + columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } }, + primaryKey: { columns: ['id'], name: 'other_pk' }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + })); + expect( + () => renameMigration(withBoth, contractOf('UserProfile', {}, 'to'), RENAME).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining( + 'table "UserProfile" already exists in the start contract', + ), + }), + ); + }); + + it('refuses in a migration without a start contract', async () => { + expect( + () => renameMigration(null, contractOf('UserProfile', {}, 'to'), RENAME).operations, + ).toThrow(expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' })); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts new file mode 100644 index 000000000000..6cfc5222ed2b --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts @@ -0,0 +1,104 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + type CheckConstraintInput, + type ForeignKeyInput, + type IndexInput, + type PrimaryKeyInput, + SqlStorage, + StorageTable, + type UniqueConstraintInput, +} from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { postgresCreateNamespace } from '../../src/core/postgres-schema'; + +export const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +const text = { nativeType: 'text', codecId: 'pg/text@1', nullable: false }; +const int4 = { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }; +export const NICKNAME_CHECK = 'length(nickname) > 0'; + +export interface ProfileSpec { + readonly primaryKey?: PrimaryKeyInput; + readonly uniques?: readonly UniqueConstraintInput[]; + readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; + readonly indexes?: (tableName: string) => readonly IndexInput[]; + readonly checks?: (tableName: string) => readonly CheckConstraintInput[]; +} + +function profileTable(tableName: string, spec: ProfileSpec): StorageTable { + return new StorageTable({ + columns: { id: int4, email: text, handle: text, nickname: text, accountId: int4 }, + primaryKey: spec.primaryKey ?? { columns: ['id'], name: 'profile_pk' }, + uniques: spec.uniques ?? [], + indexes: spec.indexes?.(tableName) ?? [], + foreignKeys: spec.foreignKeys?.(tableName) ?? [], + checks: spec.checks?.(tableName) ?? [], + }); +} + +export function reference(tableName: string, columns: readonly string[]) { + return { namespaceId: UNBOUND_NAMESPACE_ID, tableName, columns }; +} + +const accountTable = new StorageTable({ + columns: { id: int4 }, + primaryKey: { columns: ['id'], name: 'account_pk' }, + uniques: [], + indexes: [], + foreignKeys: [], +}); + +export function postTable(profileTableName: string): StorageTable { + return new StorageTable({ + columns: { id: int4, profileId: int4 }, + primaryKey: { columns: ['id'], name: 'post_pk' }, + uniques: [], + indexes: [], + foreignKeys: [ + { + source: reference('post', ['profileId']), + target: reference(profileTableName, ['id']), + }, + ], + }); +} + +export function contractOf( + profileTableName: string, + spec: ProfileSpec, + hashSeed: string, + extraTables: (profileTableName: string) => Record = () => ({}), + namespaceId: string = UNBOUND_NAMESPACE_ID, +): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [namespaceId]: postgresCreateNamespace({ + id: namespaceId, + entries: { + table: { + [profileTableName]: profileTable(profileTableName, spec), + account: accountTable, + ...extraTables(profileTableName), + }, + policy: {}, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts new file mode 100644 index 000000000000..3755072900b6 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts @@ -0,0 +1,126 @@ +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { tableExistsAst } from '../../src/contract-free/checks'; +import { RenameTableCall } from '../../src/core/migrations/op-factory-call'; +import { renameTable } from '../../src/core/migrations/operations/tables'; + +function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: unknown[] } { + const received: unknown[] = []; + const lowerer: ExecuteRequestLowerer = { + lower: () => Object.freeze({ sql: 'UNUSED', params: Object.freeze([]) }), + lowerToExecuteRequest: async (ast) => { + received.push(ast); + return Object.freeze({ + sql: `LOWERED ${received.length}`, + params: Object.freeze([`p${received.length}`]), + }); + }, + }; + return { lowerer, received }; +} + +describe('renameTable (postgres)', () => { + it('renders a schema-qualified ALTER TABLE ... RENAME TO', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await renameTable('auth', 'userProfile', 'UserProfile', lowerer); + + expect(op).toMatchObject({ + id: 'renameTable.userProfile', + label: 'Rename table "userProfile" to "UserProfile"', + operationClass: 'widening', + target: { + id: 'postgres', + details: { schema: 'auth', objectType: 'table', name: 'UserProfile' }, + }, + execute: [ + { + description: 'rename table "userProfile" to "UserProfile"', + sql: 'ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"', + }, + ], + }); + }); + + it('renders an unqualified statement for the unbound namespace', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await renameTable(UNBOUND_NAMESPACE_ID, 'userProfile', 'UserProfile', lowerer); + + expect(op.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "userProfile" RENAME TO "UserProfile"', + ]); + }); + + it('prechecks that the old table exists and the new one does not, then postchecks both', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await renameTable('public', 'userProfile', 'UserProfile', lowerer); + + expect(received).toEqual([ + tableExistsAst('public', 'userProfile').tablePresent(), + tableExistsAst('public', 'UserProfile').tableAbsent(), + tableExistsAst('public', 'UserProfile').tablePresent(), + tableExistsAst('public', 'userProfile').tableAbsent(), + ]); + expect(op.precheck).toEqual([ + { description: 'ensure table "userProfile" exists', sql: 'LOWERED 1', params: ['p1'] }, + { + description: 'ensure table "UserProfile" does not exist', + sql: 'LOWERED 2', + params: ['p2'], + }, + ]); + expect(op.postcheck).toEqual([ + { description: 'verify table "UserProfile" exists', sql: 'LOWERED 3', params: ['p3'] }, + { + description: 'verify table "userProfile" no longer exists', + sql: 'LOWERED 4', + params: ['p4'], + }, + ]); + }); +}); + +describe('RenameTableCall (postgres)', () => { + it('is a widening renameTable call whose contract-side identity is the new name', () => { + const call = new RenameTableCall('public', 'userProfile', 'UserProfile'); + + expect(call).toMatchObject({ + factoryName: 'renameTable', + operationClass: 'widening', + schemaName: 'public', + oldTableName: 'userProfile', + tableName: 'UserProfile', + label: 'Rename table "userProfile" to "UserProfile"', + }); + }); + + it('toOp() delegates to renameTable', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await new RenameTableCall('auth', 'userProfile', 'UserProfile').toOp(lowerer); + + expect(op.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"', + ]); + }); + + it('toOp() without a lowerer reports MIGRATION.POSTGRES_CONTROL_STACK_MISSING', async () => { + const call = new RenameTableCall('public', 'userProfile', 'UserProfile'); + await expect(call.toOp()).rejects.toMatchObject({ + code: 'MIGRATION.POSTGRES_CONTROL_STACK_MISSING', + meta: { factory: 'RenameTableCall' }, + }); + }); + + it('renderTypeScript() spreads the facade call, which returns every rename, schema-qualified only when bound', () => { + expect(new RenameTableCall('auth', 'userProfile', 'UserProfile').renderTypeScript()).toBe( + '...this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })', + ); + expect( + new RenameTableCall(UNBOUND_NAMESPACE_ID, 'userProfile', 'UserProfile').renderTypeScript(), + ).toBe('...this.renameTable({ table: "userProfile", to: "UserProfile" })'); + }); + + it('needs no facade import because the call is a method on the migration', () => { + expect(new RenameTableCall('public', 'a', 'b').importRequirements()).toEqual([]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts index 05f896f58b0d..35ceca2b0c9d 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts @@ -41,9 +41,10 @@ import { DropTableCall, EnableRowLevelSecurityCall, RawSqlCall, - RenameCheckConstraintCall, + RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, + RenameTableCall, SetDefaultCall, SetNotNullCall, } from '../../src/core/migrations/op-factory-call'; @@ -200,6 +201,7 @@ describe('renderCallsToTypeScript (postgres) — facade import surface', () => { ], ), new DropTableCall('public', 'stale'), + new RenameTableCall('public', 'stale', 'archived'), new AddColumnCall('public', 'note', col('nickname', 'text')), new DropColumnCall('public', 'note', 'nickname'), new AlterColumnTypeCall('public', 'note', 'kind', { @@ -230,12 +232,14 @@ describe('renderCallsToTypeScript (postgres) — facade import surface', () => { new AddUniqueCall('public', 'note', 'note_kind_key', ['kind']), new AddCheckConstraintCall('public', 'note', 'note_kind_check', `"kind" IN ('draft')`), new DropCheckConstraintCall('public', 'note', 'note_kind_check'), - new RenameCheckConstraintCall( + new RenameConstraintCall( 'public', 'note', + 'checkConstraint', 'note_kind_check_0a1b2c3d', 'note_kind_check_1b2c3d4e', ), + new RenameConstraintCall('public', 'note', 'unique', 'note_kind_key', 'Note_kind_key'), new CreateIndexCall('public', 'note', 'note_kind_idx', { columns: ['kind'] }), new RenameIndexCall('public', 'note', 'note_kind_old_idx', 'note_kind_idx'), new DropIndexCall('public', 'note', 'note_kind_idx'), diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/identifier-case.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/identifier-case.ts new file mode 100644 index 000000000000..bbeaab03c38d --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/identifier-case.ts @@ -0,0 +1,10 @@ +function foldAsciiLetters(identifier: string): string { + return identifier.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} + +/** + * Whether SQLite takes two identifiers for the same name. It compares table and index names without regard to the case of ASCII letters, and compares every other character exactly. + */ +export function sqliteIdentifiersCollide(left: string, right: string): boolean { + return foldAsciiLetters(left) === foldAsciiLetters(right); +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts new file mode 100644 index 000000000000..7dc91f66e553 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts @@ -0,0 +1,86 @@ +import type { SchemaDiffIssue } from '@internal/framework-components/control'; +import { issueOutcome } from '@internal/framework-components/control'; +import { parseWireName } from '@internal/sql-schema-ir/naming'; +import { SqlIndexIR } from '@internal/sql-schema-ir/types'; +import { sqliteIdentifiersCollide } from './identifier-case'; +import { issueNode } from './issue-planner'; +import { CreateIndexCall, DropIndexCall, type SqliteOpFactoryCall } from './op-factory-call'; + +export interface IndexFinding { + readonly issue: SchemaDiffIssue; + readonly index: SqlIndexIR; + readonly tableName: string; +} + +/** Whether an index the plan drops is replaced by one the plan creates. */ +export type IndexReplacementMatch = (old: IndexFinding, replacement: IndexFinding) => boolean; + +function indexFindings( + issues: readonly SchemaDiffIssue[], + outcome: 'not-found' | 'not-expected', +): IndexFinding[] { + return issues.flatMap((issue) => { + const node = issueNode(issue); + const tableName = issue.path[1]; + if (node === undefined || !SqlIndexIR.is(node) || tableName === undefined) return []; + return issueOutcome(issue) === outcome ? [{ issue, index: node, tableName }] : []; + }); +} + +/** + * An index on a renamed table whose wire name derives from the table name: the old and the new index share their content hash and differ in name. + */ +export function renamedTableIndex(renamedTables: ReadonlySet): IndexReplacementMatch { + return (old, replacement) => { + const oldWire = parseWireName(old.index.name); + const replacementWire = parseWireName(replacement.index.name); + return ( + renamedTables.has(old.tableName) && + old.tableName === replacement.tableName && + oldWire !== undefined && + oldWire.hash === replacementWire?.hash && + old.index.name !== replacement.index.name + ); + }; +} + +/** + * An index on the same table whose new name SQLite takes for the old one, as after a table renamed by hand. Its content is not compared: the old index must be dropped before the new one can be created whatever either defines, and the new one is created from its own definition. + */ +export const indexNameCaseChange: IndexReplacementMatch = (old, replacement) => + old.tableName === replacement.tableName && + old.index.name !== replacement.index.name && + sqliteIdentifiersCollide(old.index.name, replacement.index.name); + +/** + * Pairs each index the plan drops with the index that replaces it, and plans every paired drop before every paired create. SQLite cannot rename an index, and it compares index names without regard to the case of ASCII letters, so a replacement whose name differs from the old one only in that case would otherwise collide with it. The paired issues are returned as consumed so the ordinary diff does not plan them again. + */ +export function pairIndexReplacements( + issues: readonly SchemaDiffIssue[], + matches: IndexReplacementMatch, +): { + readonly calls: readonly SqliteOpFactoryCall[]; + readonly consumed: ReadonlySet; +} { + const missing = indexFindings(issues, 'not-found'); + const pairs = indexFindings(issues, 'not-expected').flatMap((old) => { + const replacement = missing.find((candidate) => matches(old, candidate)); + if (replacement === undefined) return []; + missing.splice(missing.indexOf(replacement), 1); + return [{ old, replacement }]; + }); + return { + calls: [ + ...pairs.map(({ old }) => new DropIndexCall(old.tableName, old.index.name)), + ...pairs.map( + ({ replacement }) => + new CreateIndexCall( + replacement.tableName, + replacement.index.name, + replacement.index.columns ?? [], + ), + ), + ], + consumed: new Set(pairs.flatMap(({ old, replacement }) => [old.issue, replacement.issue])), + }; +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts index b1ffe35e9286..f8e249e64784 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts @@ -531,7 +531,7 @@ function emptySchemaIR(): SqlSchemaIR { return new SqlSchemaIR({ tables: {} }); } -function conflictForDisallowedCall( +export function conflictForDisallowedCall( call: SqliteOpFactoryCall, allowed: readonly string[], ): SqlPlannerConflict { diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index 1ce21951915c..569e43fe547e 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -30,7 +30,12 @@ import { quoteIdentifier } from '../sql-utils'; import { addColumnExecuteSql, dropColumnExecuteSql } from './operations/columns'; import type { SqliteColumnSpec, SqliteIndexSpec, SqliteTableSpec } from './operations/shared'; import { step } from './operations/shared'; -import { recreateTable } from './operations/tables'; +import { + recreateTable, + renameChangesOnlyCase, + renameTableSteps, + renameTableViaName, +} from './operations/tables'; import { buildCreateIndexSql, buildDropIndexSql } from './planner-ddl-builders'; import type { SqlitePlanTargetDetails } from './planner-target-details'; import { buildTargetDetails } from './planner-target-details'; @@ -262,6 +267,86 @@ export class DropTableCall extends SqliteOpFactoryCallNode { } } +export class RenameTableCall extends SqliteOpFactoryCallNode { + readonly factoryName = 'renameTable' as const; + // `widening`: a rename is neither additive creation nor destructive, and the + // class vocabulary has no neutral middle class, so this is the class that + // plans under every allowance set except additive-only init. + readonly operationClass = 'widening' as const; + readonly oldTableName: string; + /** The new name: the table's contract-side identity after the rename. */ + readonly tableName: string; + readonly label: string; + + constructor(oldTableName: string, tableName: string) { + super(); + this.oldTableName = oldTableName; + this.tableName = tableName; + this.label = `Rename table ${oldTableName} to ${tableName}`; + this.freeze(); + } + + async toOp(lowerer?: ExecuteRequestLowerer): Promise { + if (lowerer === undefined) { + throw sqliteError( + 'MIGRATION.SQLITE_CONTROL_STACK_MISSING', + `RenameTableCall.toOp: a lowerer is required on the SQLite planner path (table "${this.oldTableName}"). Pass the control adapter to createSqliteMigrationPlanner.`, + { meta: { factory: this.factoryName, tableName: this.oldTableName } }, + ); + } + const fromChecks = tableExistsAst(this.oldTableName); + const toChecks = tableExistsAst(this.tableName); + const fromPresent = await lowerer.lowerToExecuteRequest(fromChecks.tablePresent()); + const toAbsent = await lowerer.lowerToExecuteRequest(toChecks.tableAbsent()); + const viaName = renameTableViaName(this.tableName); + const viaAbsent = renameChangesOnlyCase(this.oldTableName, this.tableName) + ? await lowerer.lowerToExecuteRequest(tableExistsAst(viaName).tableAbsent()) + : undefined; + const toPresent = await lowerer.lowerToExecuteRequest(toChecks.tablePresent()); + const fromAbsent = await lowerer.lowerToExecuteRequest(fromChecks.tableAbsent()); + return { + id: `renameTable.${this.oldTableName}`, + label: this.label, + summary: `Renames table ${this.oldTableName} to ${this.tableName}, keeping its rows`, + operationClass: 'widening', + target: { id: 'sqlite', details: buildTargetDetails('table', this.tableName) }, + precheck: [ + step(`ensure table "${this.oldTableName}" exists`, fromPresent.sql, fromPresent.params), + step(`ensure table "${this.tableName}" does not exist`, toAbsent.sql, toAbsent.params), + ...(viaAbsent === undefined + ? [] + : [ + step( + `ensure table "${viaName}" does not exist (a rename that only changes case passes through this temporary name, because SQLite compares table names without case)`, + viaAbsent.sql, + viaAbsent.params, + ), + ]), + ], + execute: renameTableSteps(this.oldTableName, this.tableName), + // Both postchecks: the runner skips an operation whose postcheck + // already holds, and "the new table exists" alone would skip a rename + // that never happened when both tables exist. + postcheck: [ + step(`verify table "${this.tableName}" exists`, toPresent.sql, toPresent.params), + step( + `verify table "${this.oldTableName}" no longer exists`, + fromAbsent.sql, + fromAbsent.params, + ), + ], + }; + } + + renderTypeScript(): string { + return `...this.renameTable({ table: ${jsonToTsSource(this.oldTableName)}, to: ${jsonToTsSource(this.tableName)} })`; + } + + override importRequirements(): readonly ImportRequirement[] { + return []; + } +} + export class RecreateTableCall extends SqliteOpFactoryCallNode { readonly factoryName = 'recreateTable' as const; readonly operationClass: MigrationOperationClass; @@ -688,6 +773,7 @@ export type SqliteOpFactoryCall = | CreateTableCall | DropTableCall | RecreateTableCall + | RenameTableCall | AddColumnCall | DropColumnCall | CreateIndexCall diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts index d631ad7b4236..f82e7453d7b0 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/tables.ts @@ -7,6 +7,7 @@ import { blindCast } from '@internal/utils/casts'; import { tableExistsAst } from '../../../contract-free/checks'; import { stripOuterParens } from '../../default-normalizer'; import { escapeLiteral, quoteIdentifier } from '../../sql-utils'; +import { sqliteIdentifiersCollide } from '../identifier-case'; import { buildCreateIndexSql } from '../planner-ddl-builders'; import { buildTargetDetails } from '../planner-target-details'; import { @@ -77,6 +78,38 @@ export async function createTable( }; } +export function renameTableViaName(toName: string): string { + return `_prisma_rename_${toName}`; +} + +export function renameChangesOnlyCase(fromName: string, toName: string): boolean { + return sqliteIdentifiersCollide(fromName, toName); +} + +/** + * SQLite takes names that differ only in the case of ASCII letters for the + * same name, so such a rename (`userProfile` to `UserProfile`) is refused as + * "already exists" when done in one statement. It goes through a temporary name. + */ +export function renameTableSteps(fromName: string, toName: string): Op['execute'] { + const from = quoteIdentifier(fromName); + const to = quoteIdentifier(toName); + if (!renameChangesOnlyCase(fromName, toName)) { + return [ + step(`rename table "${fromName}" to "${toName}"`, `ALTER TABLE ${from} RENAME TO ${to}`), + ]; + } + const viaName = renameTableViaName(toName); + const via = quoteIdentifier(viaName); + return [ + step( + `rename table "${fromName}" to "${viaName}" (SQLite table names are case-insensitive)`, + `ALTER TABLE ${from} RENAME TO ${via}`, + ), + step(`rename table "${viaName}" to "${toName}"`, `ALTER TABLE ${via} RENAME TO ${to}`), + ]; +} + export async function dropTable(tableName: string, lowerer: ExecuteRequestLowerer): Promise { const { present, absent } = await tableExistsSteps(lowerer, tableName); return { @@ -245,6 +278,16 @@ function quoteSqlList(values: readonly string[]): string { return values.map((v) => `'${escapeLiteral(v)}'`).join(', '); } +/** + * A condition on the index aliased `l`: it covers exactly these columns. Order is not checked, because SQLite identifies a unique index by its column set. + */ +function indexCoversExactly(columns: readonly string[]): string { + return ( + `(SELECT COUNT(*) FROM pragma_index_info(l.name)) = ${columns.length}` + + ` AND (SELECT COUNT(*) FROM pragma_index_info(l.name) WHERE name IN (${quoteSqlList(columns)})) = ${columns.length}` + ); +} + function columnNameFromNode(issue: SchemaDiffIssue): string | undefined { const node = issue.expected ?? issue.actual; if (node === undefined) return undefined; @@ -382,24 +425,33 @@ export function buildRecreatePostchecks( if (hasUniqueIssue) { for (const u of spec.uniques ?? []) { - const colCount = u.columns.length; const description = u.name ? `verify unique constraint "${u.name}" on "${tableName}"` : `verify unique constraint (${u.columns.join(', ')}) on "${tableName}"`; - // Match any unique index whose covered columns are exactly the expected - // set. Order is intentionally not checked — SQLite's unique-index - // identity is column-set, not column-sequence. checks.push({ description, sql: `SELECT EXISTS (SELECT 1 FROM pragma_index_list('${t}') l` + - ` WHERE l."unique" = 1` + - ` AND (SELECT COUNT(*) FROM pragma_index_info(l.name)) = ${colCount}` + - ` AND (SELECT COUNT(*) FROM pragma_index_info(l.name) WHERE name IN (${quoteSqlList(u.columns)})) = ${colCount})`, + ` WHERE l."unique" = 1 AND ${indexCoversExactly(u.columns)})`, }); } } + // The checks above only prove expected uniques exist, so removing one would + // leave the postcheck already true and the runner would skip the recreate. + // This check fails while a UNIQUE constraint index (`origin = 'u'`) matches + // no expected unique. It does not count indexes: SQLite folds a unique that + // repeats a non-integer primary key into the primary key's index. + if (hasUniqueIssue) { + const expected = (spec.uniques ?? []).map((u) => `(${indexCoversExactly(u.columns)})`); + checks.push({ + description: `verify "${tableName}" has no unique constraint besides the expected ones`, + sql: + `SELECT NOT EXISTS (SELECT 1 FROM pragma_index_list('${t}') l` + + ` WHERE l.origin = 'u' AND NOT (${expected.length === 0 ? '0' : expected.join(' OR ')}))`, + }); + } + if (hasFkIssue) { for (const fk of spec.foreignKeys ?? []) { const refTable = escapeLiteral(fk.references.table); @@ -424,6 +476,11 @@ export function buildRecreatePostchecks( ` AND SUM(CASE WHEN (f."from", f."to") IN (${tuples}) THEN 1 ELSE 0 END) = ${colCount})`, }); } + const expected = spec.foreignKeys?.length ?? 0; + checks.push({ + description: `verify "${tableName}" has exactly ${expected} foreign keys`, + sql: `SELECT (SELECT COUNT(DISTINCT id) FROM pragma_foreign_key_list('${t}')) = ${expected}`, + }); } return checks; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 3aeadd0ad65a..f7a22319cbe1 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -27,7 +27,15 @@ import { SqlTableIR, } from '@internal/sql-schema-ir/types'; import { buildSqlitePlanDiff } from './diff-database-schema'; -import { coalesceSubtreeIssues, issueNode, planIssues } from './issue-planner'; +import { indexNameCaseChange, pairIndexReplacements } from './index-replacements'; +import { + coalesceSubtreeIssues, + conflictForDisallowedCall, + issueNode, + planIssues, +} from './issue-planner'; +import { RenameTableCall } from './op-factory-call'; +import { renameTableSteps } from './operations/tables'; import { type SqliteMigrationDestinationInfo, TypeScriptRenderableSqliteMigration, @@ -128,7 +136,19 @@ export class SqliteMigrationPlanner const policyResult = this.ensureAdditivePolicy(options.policy); if (policyResult) return policyResult; - const { expected, actual, issues } = this.collectSchemaIssues(options); + const { expected, actual, issues: diffIssues } = this.collectSchemaIssues(options); + const replacedIndexes = pairIndexReplacements(diffIssues, indexNameCaseChange); + const disallowedIndexCalls = replacedIndexes.calls.filter( + (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), + ); + if (disallowedIndexCalls.length > 0) { + return plannerFailure( + disallowedIndexCalls.map((call) => + conflictForDisallowedCall(call, options.policy.allowedOperationClasses), + ), + ); + } + const issues = diffIssues.filter((issue) => !replacedIndexes.consumed.has(issue)); const caseChangeConflicts = detectTableNameCaseChanges({ issues, tableOf: (issue) => { @@ -136,6 +156,11 @@ export class SqliteMigrationPlanner return node instanceof SqlTableIR ? node : undefined; }, namespaceIdOf: () => UNBOUND_NAMESPACE_ID, + renameByHandStatements: (rename) => + renameTableSteps(rename.from, rename.to).map((renameStep) => renameStep.sql), + renameTableCall: (rename) => new RenameTableCall(rename.from, rename.to).renderTypeScript(), + contract: options.contract, + defaultNamespaceId: UNBOUND_NAMESPACE_ID, }); if (caseChangeConflicts.length > 0) { return plannerFailure(caseChangeConflicts); @@ -169,7 +194,7 @@ export class SqliteMigrationPlanner // Codec-emitted calls already conform to `OpFactoryCall` — render + // toOp + importRequirements ride directly through the same emit path // as structural ops, no `RawSqlCall` wrap. - const calls = [...result.value.calls, ...fieldEventOps]; + const calls = [...replacedIndexes.calls, ...result.value.calls, ...fieldEventOps]; const destination: SqliteMigrationDestinationInfo = { storageHash: options.contract.storage.storageHash, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts index 804de4f5d970..c3a21ab73ffe 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts @@ -5,6 +5,7 @@ import type { } from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import { Migration as SqlMigration } from '@internal/family-sql/migration'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { ControlStack } from '@internal/framework-components/control'; import { MigrationContractViews } from '@internal/migration-tools/migration'; import type { SqlStorage } from '@internal/sql-contract/types'; @@ -23,6 +24,7 @@ import { } from './op-factory-call'; import type { SqliteColumnSpec, SqliteIndexSpec, SqliteTableSpec } from './operations/shared'; import type { SqlitePlanTargetDetails } from './planner-target-details'; +import { sqliteTableRenameCalls } from './table-rename-calls'; type Op = SqlMigrationPlanOperation; @@ -81,6 +83,16 @@ export abstract class SqliteMigration< : undefined; } + private frameworkComponents(): ReadonlyArray> { + const stack = this.stack; + if (stack === undefined) return []; + return [ + stack.target, + ...(stack.adapter === undefined ? [] : [stack.adapter]), + ...stack.extensions, + ]; + } + /** * Returns the materialized control adapter, or throws a MIGRATION.SQLITE_CONTROL_STACK_MISSING naming * `operation` when the migration was constructed without a `ControlStack`. @@ -125,6 +137,22 @@ export abstract class SqliteMigration< return new DropTableCall(options.table).toOp(this.controlAdapterFor('dropTable')); } + /** + * Emit the operations that rename a table: the table rename, then a drop and a create under the new name of each index whose name was derived from the old table name, read from this migration's start and end contracts. Spread the result into `operations`: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + */ + protected renameTable(options: { + readonly table: string; + readonly to: string; + }): readonly Promise[] { + const adapter = this.controlAdapterFor('renameTable'); + return sqliteTableRenameCalls({ + startContract: this.startContract, + endContract: this.endContract, + rename: { namespaceId: undefined, from: options.table, to: options.to }, + frameworkComponents: this.frameworkComponents(), + }).map(async (call) => call.toOp(adapter)); + } + protected addColumn(options: { readonly table: string; readonly column: SqliteColumnSpec; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts new file mode 100644 index 000000000000..a420a123a5cf --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts @@ -0,0 +1,39 @@ +import type { Contract } from '@internal/contract/types'; +import { applyTableRename, type TableRename } from '@internal/family-sql/control'; +import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { buildSqlitePlanDiff, sqliteContractToSchema } from './diff-database-schema'; +import { pairIndexReplacements, renamedTableIndex } from './index-replacements'; +import { coalesceSubtreeIssues } from './issue-planner'; +import { RenameTableCall, type SqliteOpFactoryCall } from './op-factory-call'; + +/** + * The calls a migration's `renameTable` emits: the table rename, then a drop of each wire-named index whose prefix derives from the old table name and a create of it under the new name, since SQLite cannot rename an index. SQLite names no primary key, unique constraint or foreign key the contract leaves unnamed, so those need nothing. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + */ +export function sqliteTableRenameCalls(input: { + readonly startContract: Contract | null; + readonly endContract: Contract; + readonly rename: TableRename; + readonly frameworkComponents: ReadonlyArray>; +}): readonly SqliteOpFactoryCall[] { + const applied = applyTableRename({ + startContract: input.startContract, + endContract: input.endContract, + rename: input.rename, + renameTableReferences: undefined, + }); + if (!applied.ok) { + throw applied.failure; + } + const { rename } = applied.value; + const { issues } = buildSqlitePlanDiff({ + contract: input.endContract, + actualSchema: sqliteContractToSchema(applied.value.contract), + frameworkComponents: input.frameworkComponents, + }); + return [ + new RenameTableCall(rename.from, rename.to), + ...pairIndexReplacements(coalesceSubtreeIssues(issues), renamedTableIndex(new Set([rename.to]))) + .calls, + ]; +} diff --git a/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts index df8c3afe917b..6c9d44e812e6 100644 --- a/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts @@ -8,5 +8,6 @@ export { DropTableCall, RawSqlCall, RecreateTableCall, + RenameTableCall, type SqliteOpFactoryCall, } from '../core/migrations/op-factory-call'; diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts new file mode 100644 index 000000000000..df11d3e90cf2 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts @@ -0,0 +1,106 @@ +/** + * After a table is renamed by hand, `db update` finds each index named after the old table under a name that differs only in case from the one the contract wants. SQLite compares index names without case, so the old index must be dropped before the new one is created. + */ + +import { DatabaseSync } from 'node:sqlite'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import type { IndexInput } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; +import { contractOf, HANDLE_INDEX_HASH, handleIndex, stubLowerer } from './rename-table-fixtures'; + +async function plannedLabels(liveIndexTable: string, contractIndexTable: string) { + const live = contractOf('UserProfile', { indexes: () => [handleIndex(liveIndexTable)] }, 'live'); + const result = createSqliteMigrationPlanner(stubLowerer).plan({ + contract: contractOf('UserProfile', { indexes: () => [handleIndex(contractIndexTable)] }, 'to'), + schema: sqliteContractToSchema(live), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: null, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return []; + return (await Promise.all(result.plan.operations)).map((op) => op.label); +} + +function exactIndex(name: string, columns: readonly string[]): IndexInput { + return { + columns, + naming: { kind: 'exact', name }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }; +} + +describe('SQLite planner index names that differ only in case', () => { + it('drops the old index before creating the one whose name differs only in case', async () => { + expect(await plannedLabels('userProfile', 'UserProfile')).toEqual([ + `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + ]); + }); + + it('keeps the usual order for an index whose name changes by more than case', async () => { + expect(await plannedLabels('user_profile', 'UserProfile')).toEqual([ + `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Drop index user_profile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + ]); + }); + + it('drops the old index first when the name changes only in case and the columns change too, and the plan runs on SQLite', async () => { + const live = contractOf( + 'UserProfile', + { indexes: () => [exactIndex('idx_handle', ['handle'])] }, + 'live', + ); + const result = createSqliteMigrationPlanner(stubLowerer).plan({ + contract: contractOf( + 'UserProfile', + { indexes: () => [exactIndex('Idx_Handle', ['handle', 'email'])] }, + 'to', + ), + schema: sqliteContractToSchema(live), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: null, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const statements = (await Promise.all(result.plan.operations)).flatMap((op) => + op.execute.map((step) => step.sql), + ); + + const db = new DatabaseSync(':memory:'); + try { + db.exec('CREATE TABLE "UserProfile" ("id" INTEGER PRIMARY KEY, "email" TEXT, "handle" TEXT)'); + db.exec('CREATE INDEX "idx_handle" ON "UserProfile" ("handle")'); + expect(() => { + for (const statement of statements) db.exec(statement); + }).not.toThrow(); + expect( + db.prepare(`SELECT name, sql FROM sqlite_master WHERE type = 'index' ORDER BY name`).all(), + ).toEqual([ + { + name: 'Idx_Handle', + sql: 'CREATE INDEX "Idx_Handle" ON "UserProfile" ("handle", "email")', + }, + ]); + } finally { + db.close(); + } + }); + + it('keeps the usual order for an index whose name changes only in the case of a non-ASCII letter, which SQLite does not fold', async () => { + expect(await plannedLabels('Äpfel', 'äpfel')).toEqual([ + `Create index äpfel_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Drop index Äpfel_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + ]); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts index 47b8190364c0..fdcbf82ef23c 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts @@ -137,7 +137,20 @@ describe('SQLite planner table-name case guard', () => { }), ]); expect(result.conflicts[0]?.summary).toContain('MIGRATION.TABLE_NAME_CASE_CHANGED'); - expect(result.conflicts[0]?.why).toContain('ALTER TABLE "userProfile" RENAME TO "UserProfile"'); + expect(result.conflicts[0]?.why).toContain( + 'in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" }) to the migration\'s operations, which renames the table and the objects named after it;', + ); + expect(result.conflicts[0]?.why).not.toContain('--rename'); + }); + + it('gives a by-hand rename through a temporary name, because SQLite refuses a case-only rename in one statement', () => { + const result = planFromLive(['userProfile'], 'UserProfile')(); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts[0]?.why).toContain( + 'in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.', + ); }); it('still refuses when UserProfile also gained a column', () => { diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.execution.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.execution.test.ts new file mode 100644 index 000000000000..a1471a3260a8 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.execution.test.ts @@ -0,0 +1,57 @@ +/** + * The recreate postchecks, run against a real SQLite database in the shape a rebuild leaves behind. They must hold for a correct rebuild and fail while a removed constraint remains. + */ + +import { DatabaseSync } from 'node:sqlite'; +import { describe, expect, it } from 'vitest'; +import type { SqliteTableSpec } from '../../src/core/migrations/operations/shared'; +import { buildRecreatePostchecks } from '../../src/core/migrations/operations/tables'; +import { issue, unique } from './node-issue-helpers'; + +const codesSpec: SqliteTableSpec = { + columns: [ + { name: 'code', typeSql: 'TEXT', defaultSql: '', nullable: false }, + { name: 'label', typeSql: 'TEXT', defaultSql: '', nullable: false }, + ], + primaryKey: { columns: ['code'] }, + uniques: [{ columns: ['code'] }], + foreignKeys: [], +}; + +const uniqueIssues = [ + issue({ path: ['database', 'codes', 'unique:code'], expected: unique(['code']) }), +]; + +function postcheckResults(createTableSql: string): Record { + const db = new DatabaseSync(':memory:'); + try { + db.exec(createTableSql); + return Object.fromEntries( + buildRecreatePostchecks('codes', uniqueIssues, codesSpec).map((check) => [ + check.description, + Object.values(db.prepare(check.sql).get() ?? {})[0] === 1, + ]), + ); + } finally { + db.close(); + } +} + +describe('buildRecreatePostchecks on SQLite', () => { + it('holds after a rebuild whose unique constraint repeats a text primary key', () => { + const results = postcheckResults( + 'CREATE TABLE "codes" ("code" TEXT NOT NULL, "label" TEXT NOT NULL, PRIMARY KEY ("code"), UNIQUE ("code"))', + ); + + expect(Object.values(results)).toEqual(Object.values(results).map(() => true)); + expect(Object.keys(results).length).toBeGreaterThan(1); + }); + + it('fails while a unique constraint the rebuild removes is still there', () => { + const results = postcheckResults( + 'CREATE TABLE "codes" ("code" TEXT NOT NULL, "label" TEXT NOT NULL, PRIMARY KEY ("code"), UNIQUE ("code"), UNIQUE ("label"))', + ); + + expect(Object.values(results)).toContain(false); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts index 08b767c32b20..2416e1273ecf 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/recreate-postchecks.test.ts @@ -205,7 +205,7 @@ describe('buildRecreatePostchecks — constraints', () => { }), ]; const checks = buildRecreatePostchecks('users', issues, spec); - const uniqueChecks = checks.filter((c) => c.description.includes('unique constraint')); + const uniqueChecks = checks.filter((c) => c.description.startsWith('verify unique constraint')); expect(uniqueChecks).toHaveLength(2); expect(uniqueChecks[0]!.sql).toContain("pragma_index_list('users')"); expect(uniqueChecks[0]!.sql).toContain('l."unique" = 1'); @@ -236,7 +236,7 @@ describe('buildRecreatePostchecks — constraints', () => { }), ]; const checks = buildRecreatePostchecks('posts', issues, spec); - const fkChecks = checks.filter((c) => c.description.includes('foreign key')); + const fkChecks = checks.filter((c) => c.description.startsWith('verify foreign key')); expect(fkChecks).toHaveLength(2); expect(fkChecks[0]!.sql).toContain("pragma_foreign_key_list('posts')"); @@ -248,6 +248,41 @@ describe('buildRecreatePostchecks — constraints', () => { expect(fkChecks[1]!.sql).toContain('HAVING COUNT(*) = 2'); }); + it('checks that no unexpected unique remains, so removing the last unique is not already satisfied', () => { + const spec = tableSpec({ columns: [colSpec({ name: 'email' })], uniques: [] }); + const issues = [ + issue({ path: ['database', 'users', 'unique:email'], actual: unique(['email']) }), + ]; + + expect(buildRecreatePostchecks('users', issues, spec)).toEqual([ + { + description: 'verify "users" has no unique constraint besides the expected ones', + sql: `SELECT NOT EXISTS (SELECT 1 FROM pragma_index_list('users') l WHERE l.origin = 'u' AND NOT (0))`, + }, + ]); + }); + + it('checks the foreign key count, so removing the last foreign key is not already satisfied', () => { + const spec = tableSpec({ columns: [colSpec({ name: 'user_id' })], foreignKeys: [] }); + const issues = [ + issue({ + path: ['database', 'posts', 'foreign-key:user_id->.users(id)'], + actual: foreignKey({ + columns: ['user_id'], + referencedTable: 'users', + referencedColumns: ['id'], + }), + }), + ]; + + expect(buildRecreatePostchecks('posts', issues, spec)).toEqual([ + { + description: 'verify "posts" has exactly 0 foreign keys', + sql: `SELECT (SELECT COUNT(DISTINCT id) FROM pragma_foreign_key_list('posts')) = 0`, + }, + ]); + }); + it('does not emit constraint postchecks when only column-level issues are present', () => { const spec = tableSpec({ columns: [colSpec({ name: 'a' })], diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts new file mode 100644 index 000000000000..23bb64a2756c --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts @@ -0,0 +1,157 @@ +/** + * `this.renameTable` in a hand-written SQLite migration. It reads the migration's start and end contracts and emits the table rename, then drops each index whose wire name derives from the old table name and creates it under the new name, because SQLite cannot rename an index. SQLite names no primary key, unique constraint or foreign key the contract leaves unnamed, so those need nothing. A table missing from either contract is refused. + */ + +import type { Contract } from '@internal/contract/types'; +import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; +import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; +import type { ControlStack } from '@internal/framework-components/control'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import type { SqlitePlanTargetDetails } from '../../src/core/migrations/planner-target-details'; +import { SqliteMigration } from '../../src/core/migrations/sqlite-migration'; +import { SqliteContractSerializer } from '../../src/core/sqlite-contract-serializer'; +import { + contractOf, + HANDLE_INDEX_HASH, + handleIndex, + type ProfileSpec, + reference, + stubLowerer, +} from './rename-table-fixtures'; + +type Op = SqlMigrationPlanOperation; +type ContractJson = { readonly storage: { readonly storageHash: string } }; + +const stack = { + adapter: { create: () => stubLowerer as unknown as SqlControlAdapter<'sqlite'> }, + target: { kind: 'target', familyId: 'sql', targetId: 'sqlite' }, + extensions: [], +} as unknown as ControlStack<'sql', 'sqlite'>; + +function jsonOf(contract: Contract): ContractJson { + return new SqliteContractSerializer().serializeContract(contract) as unknown as ContractJson; +} + +function renameMigration( + start: Contract | null, + end: Contract, + rename: { readonly table: string; readonly to: string }, +): { readonly operations: readonly Promise[] } { + const endJson = jsonOf(end); + class WithoutStart extends SqliteMigration { + override readonly endContractJson = endJson; + override get operations(): readonly Promise[] { + return [...this.renameTable(rename)]; + } + } + if (start === null) return new WithoutStart(stack); + const startJson = jsonOf(start); + class WithStart extends WithoutStart { + override readonly startContractJson = startJson; + } + return new WithStart(stack); +} + +const RENAME = { table: 'userProfile', to: 'UserProfile' } as const; + +async function renameLabels(spec: ProfileSpec): Promise { + const ops = await Promise.all( + renameMigration( + contractOf('userProfile', spec, 'from'), + contractOf('UserProfile', spec, 'to'), + RENAME, + ).operations, + ); + return ops.map((op) => op.label); +} + +describe('SqliteMigration.renameTable', () => { + it('drops an index whose prefix derives from the table name, then creates it under the new name', async () => { + expect(await renameLabels({ indexes: (tableName) => [handleIndex(tableName)] })).toEqual([ + 'Rename table userProfile to UserProfile', + `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + ]); + }); + + it('emits the rename alone for an unnamed unique, an owned foreign key and a referencing foreign key', async () => { + expect( + await renameLabels({ + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [ + { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, + ], + }), + ).toEqual(['Rename table userProfile to UserProfile']); + }); + + it('leaves an explicitly named index alone', async () => { + expect( + await renameLabels({ + indexes: (tableName) => [ + { ...handleIndex(tableName), naming: { kind: 'exact', name: 'profile_handle' } }, + ], + }), + ).toEqual(['Rename table userProfile to UserProfile']); + }); + + it('renames through a temporary name when only the case changes', async () => { + const [rename] = await Promise.all( + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + RENAME, + ).operations, + ); + + expect(rename?.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"', + 'ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"', + ]); + }); + + it('refuses a table the start contract does not have', () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { + table: 'ghost', + to: 'UserProfile', + }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining('table "ghost" does not exist in the start contract'), + }), + ); + }); + + it('refuses a new name the end contract does not have', () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { + table: 'userProfile', + to: 'Profile', + }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining('table "Profile" does not exist in the end contract'), + }), + ); + }); + + it('refuses in a migration without a start contract', () => { + expect( + () => renameMigration(null, contractOf('UserProfile', {}, 'to'), RENAME).operations, + ).toThrow(expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' })); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts new file mode 100644 index 000000000000..25f3a13151c1 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts @@ -0,0 +1,99 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + type ForeignKeyInput, + type IndexInput, + SqlStorage, + StorageTable, + type UniqueConstraintInput, +} from '@internal/sql-contract/types'; +import { computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { applicationDomainOf } from '@repo/test-utils'; +import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; + +export const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +const integer = { nativeType: 'integer', codecId: 'sqlite/integer@1', nullable: false }; +const text = { nativeType: 'text', codecId: 'sqlite/text@1', nullable: false }; + +export interface ProfileSpec { + readonly uniques?: readonly UniqueConstraintInput[]; + readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; + readonly indexes?: (tableName: string) => readonly IndexInput[]; +} + +export function reference(tableName: string, columns: readonly string[]) { + return { namespaceId: UNBOUND_NAMESPACE_ID, tableName, columns }; +} + +export function contractOf( + profileTableName: string, + spec: ProfileSpec, + hashSeed: string, +): Contract { + return { + target: 'sqlite', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [UNBOUND_NAMESPACE_ID]: sqliteCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + [profileTableName]: new StorageTable({ + columns: { id: integer, email: text, handle: text, accountId: integer }, + primaryKey: { columns: ['id'] }, + uniques: spec.uniques ?? [], + indexes: spec.indexes?.(profileTableName) ?? [], + foreignKeys: spec.foreignKeys?.(profileTableName) ?? [], + }), + account: new StorageTable({ + columns: { id: integer }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + post: new StorageTable({ + columns: { id: integer, profileId: integer }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + source: reference('post', ['profileId']), + target: reference(profileTableName, ['id']), + }, + ], + }), + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +export const HANDLE_INDEX_HASH = computeIndexContentHash({ columns: ['handle'], unique: false }); + +export function handleIndex(tableName: string): IndexInput { + return { + columns: ['handle'], + naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash: HANDLE_INDEX_HASH }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }; +} diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts new file mode 100644 index 000000000000..42eddfe8ddd8 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts @@ -0,0 +1,133 @@ +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { describe, expect, it } from 'vitest'; +import { tableExistsAst } from '../../src/contract-free/checks'; +import { RenameTableCall } from '../../src/core/migrations/op-factory-call'; + +function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: unknown[] } { + const received: unknown[] = []; + const lowerer: ExecuteRequestLowerer = { + lower: () => Object.freeze({ sql: 'UNUSED', params: Object.freeze([]) }), + lowerToExecuteRequest: async (ast) => { + received.push(ast); + return Object.freeze({ + sql: `LOWERED ${received.length}`, + params: Object.freeze([`p${received.length}`]), + }); + }, + }; + return { lowerer, received }; +} + +describe('RenameTableCall (sqlite)', () => { + it('is a widening renameTable call whose contract-side identity is the new name', () => { + const call = new RenameTableCall('userProfile', 'UserProfile'); + + expect(call).toMatchObject({ + factoryName: 'renameTable', + operationClass: 'widening', + oldTableName: 'userProfile', + tableName: 'UserProfile', + label: 'Rename table userProfile to UserProfile', + }); + }); + + it('renders ALTER TABLE ... RENAME TO with existence prechecks and a postcheck', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await new RenameTableCall('profile', 'account').toOp(lowerer); + + expect(received).toEqual([ + tableExistsAst('profile').tablePresent(), + tableExistsAst('account').tableAbsent(), + tableExistsAst('account').tablePresent(), + tableExistsAst('profile').tableAbsent(), + ]); + expect(op).toEqual({ + id: 'renameTable.profile', + label: 'Rename table profile to account', + summary: 'Renames table profile to account, keeping its rows', + operationClass: 'widening', + target: { id: 'sqlite', details: { schema: 'main', objectType: 'table', name: 'account' } }, + precheck: [ + { description: 'ensure table "profile" exists', sql: 'LOWERED 1', params: ['p1'] }, + { description: 'ensure table "account" does not exist', sql: 'LOWERED 2', params: ['p2'] }, + ], + execute: [ + { + description: 'rename table "profile" to "account"', + sql: 'ALTER TABLE "profile" RENAME TO "account"', + }, + ], + postcheck: [ + { description: 'verify table "account" exists', sql: 'LOWERED 3', params: ['p3'] }, + { + description: 'verify table "profile" no longer exists', + sql: 'LOWERED 4', + params: ['p4'], + }, + ], + }); + }); + + it('renames through a temporary name when only the case changes, which SQLite would otherwise refuse', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await new RenameTableCall('userProfile', 'UserProfile').toOp(lowerer); + + expect(op.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"', + 'ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"', + ]); + }); + + it('renames in one statement when only a non-ASCII letter changes case, since SQLite folds only ASCII letters', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await new RenameTableCall('Äpfel', 'äpfel').toOp(lowerer); + + expect(op.execute.map((step) => step.sql)).toEqual(['ALTER TABLE "Äpfel" RENAME TO "äpfel"']); + expect(received).not.toContainEqual(tableExistsAst('_prisma_rename_äpfel').tableAbsent()); + }); + + it('prechecks that the temporary name is free on a case-only rename, saying why it is needed', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await new RenameTableCall('userProfile', 'UserProfile').toOp(lowerer); + + expect(received).toEqual([ + tableExistsAst('userProfile').tablePresent(), + tableExistsAst('UserProfile').tableAbsent(), + tableExistsAst('_prisma_rename_UserProfile').tableAbsent(), + tableExistsAst('UserProfile').tablePresent(), + tableExistsAst('userProfile').tableAbsent(), + ]); + expect(op.precheck).toEqual([ + { description: 'ensure table "userProfile" exists', sql: 'LOWERED 1', params: ['p1'] }, + { + description: 'ensure table "UserProfile" does not exist', + sql: 'LOWERED 2', + params: ['p2'], + }, + { + description: + 'ensure table "_prisma_rename_UserProfile" does not exist (a rename that only changes case passes through this temporary name, because SQLite compares table names without case)', + sql: 'LOWERED 3', + params: ['p3'], + }, + ]); + }); + + it('toOp() without a lowerer reports MIGRATION.SQLITE_CONTROL_STACK_MISSING', async () => { + const call = new RenameTableCall('userProfile', 'UserProfile'); + await expect(call.toOp()).rejects.toMatchObject({ + code: 'MIGRATION.SQLITE_CONTROL_STACK_MISSING', + meta: { factory: 'renameTable' }, + }); + }); + + it('renderTypeScript() spreads the facade call, which returns every rename', () => { + expect(new RenameTableCall('userProfile', 'UserProfile').renderTypeScript()).toBe( + '...this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); + }); + + it('needs no facade import because the call is a method on the migration', () => { + expect(new RenameTableCall('a', 'b').importRequirements()).toEqual([]); + }); +}); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts new file mode 100644 index 000000000000..6711b919b1f8 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts @@ -0,0 +1,85 @@ +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { RenameTableCall } from '@internal/target-postgres/op-factory-call'; +import type { PostgresPlanTargetDetails } from '@internal/target-postgres/planner-target-details'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + contract, + controlAdapter, + createDriver, + createMigrationPlan, + createTestDatabase, + familyInstance, + frameworkComponents, + type PostgresControlDriver, + postgresTargetDescriptor, + resetDatabase, + synthEdges, + testTimeout, + toPlanContractInfo, +} from './fixtures/runner-fixtures'; + +describe('PostgresMigrationRunner - renameTable', { concurrent: false }, () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, testTimeout); + + afterAll(async () => { + await database?.close(); + }, testTimeout); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + }, testTimeout); + + afterEach(async () => { + await driver?.close(); + driver = undefined; + }, testTimeout); + + it('fails the precheck instead of skipping the rename when the old and the new table both exist', { + timeout: testTimeout, + }, async () => { + await driver!.query('create table "public"."userProfile" (id int primary key)'); + await driver!.query('insert into "public"."userProfile" (id) values (1)'); + await driver!.query('create table "public"."UserProfile" (id int primary key)'); + const plan = createMigrationPlan({ + targetId: 'postgres', + spaceId: APP_SPACE_ID, + origin: null, + destination: toPlanContractInfo(contract), + operations: [ + await new RenameTableCall('public', 'userProfile', 'UserProfile').toOp(controlAdapter), + ], + providedInvariants: [], + }); + + const result = await postgresTargetDescriptor.createRunner(familyInstance).execute({ + driver: driver!, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan, + migrationEdges: synthEdges(plan), + driver: driver!, + destinationContract: contract, + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + frameworkComponents, + }, + ], + }); + + expect(result.ok).toBe(false); + expect(result.assertNotOk()).toMatchObject({ + code: 'MIGRATION.PRECHECK_FAILED', + summary: + 'Operation renameTable.userProfile failed during precheck: ensure table "UserProfile" does not exist', + meta: { operationId: 'renameTable.userProfile' }, + }); + const rows = await driver!.query('select id from "public"."userProfile"'); + expect(rows.rows).toEqual([{ id: 1 }]); + }); +}); diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts new file mode 100644 index 000000000000..5525aa8379c9 --- /dev/null +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts @@ -0,0 +1,66 @@ +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { RenameTableCall } from '@internal/target-sqlite/op-factory-call'; +import type { SqlitePlanTargetDetails } from '@internal/target-sqlite/planner-target-details'; +import { timeouts } from '@repo/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + contract, + controlAdapter, + createMigrationPlan, + createTestDatabase, + familyInstance, + frameworkComponents, + sqliteTargetDescriptor, + synthEdges, + type TestDatabase, + toPlanContractInfo, +} from './fixtures/runner-fixtures'; + +describe('SqliteMigrationRunner - renameTable', { timeout: timeouts.databaseOperation }, () => { + let testDb: TestDatabase; + + afterEach(() => { + testDb?.cleanup(); + }); + + it('fails the precheck instead of skipping the rename when the old and the new table both exist', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + await driver.query('CREATE TABLE "profile" (id INTEGER PRIMARY KEY)'); + await driver.query('INSERT INTO "profile" (id) VALUES (1)'); + await driver.query('CREATE TABLE "account" (id INTEGER PRIMARY KEY)'); + const plan = createMigrationPlan({ + targetId: 'sqlite', + spaceId: APP_SPACE_ID, + origin: null, + destination: toPlanContractInfo(contract), + operations: [await new RenameTableCall('profile', 'account').toOp(controlAdapter)], + providedInvariants: [], + }); + + const result = await sqliteTargetDescriptor.createRunner(familyInstance).execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan, + migrationEdges: synthEdges(plan), + driver, + destinationContract: contract, + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + frameworkComponents, + }, + ], + }); + + expect(result.ok).toBe(false); + expect(result.assertNotOk()).toMatchObject({ + code: 'MIGRATION.PRECHECK_FAILED', + summary: + 'Operation renameTable.profile failed during precheck: ensure table "account" does not exist', + meta: { operationId: 'renameTable.profile' }, + }); + const rows = await driver.query('SELECT id FROM "profile"'); + expect(rows.rows).toEqual([{ id: 1 }]); + }); +}); diff --git a/projects/psl-verbatim-table-names/design-notes.md b/projects/psl-verbatim-table-names/design-notes.md index 2d00adf4d02b..8b08396ecc5e 100644 --- a/projects/psl-verbatim-table-names/design-notes.md +++ b/projects/psl-verbatim-table-names/design-notes.md @@ -13,7 +13,7 @@ The storage name of a model with no `@@map` is the model name. This was already - **Keep the lowered-first-letter default and fix only `contract infer`** (the original TML-3248 fix). Rejected: it leaves a default nobody knew about, that matches no convention, and that contradicts every other authoring surface. - **Have infer always write `@@map`.** Rejected for the same reason; it papers over the default instead of removing it. -- **Detect the rename in the planner and emit a table rename.** Rejected: there is no rename-table operation, and a structural-match heuristic would also fire on genuine renames later. The planner detects and refuses; the codemod is the fix. +- **Detect the rename in the planner and emit a table rename.** Rejected: a structural-match heuristic would fire on genuine drops and creates, and carry rows into the wrong table. The planner detects and refuses; the codemod keeps existing tables, and slice 2 adds a rename operation the operator requests explicitly with `--rename`. - **Regenerate all repo fixtures with the new names.** Rejected: adding `@@map` to existing repo schemas keeps every emitted artifact byte-identical and exercises the user codemod on real schemas. ## Decisions taken in discussion (2026-09-16) @@ -22,3 +22,8 @@ The storage name of a model with no `@@map` is the model name. This was already - Operator: the planner intervenes by throwing with mitigation instructions, never by renaming. - Operator: repo fixtures get `@@map` added rather than being regenerated. - Orchestrator, unchallenged: infer keeps re-casing snake_case tables to PascalCase model names with an explicit `@@map`; the guard lives in the planner, not in `db verify`, because verify already prints both names side by side. + +## Decision taken 2026-09-17: renames are stated in hand-written migrations + +Slice 2 first added a `--rename =` flag to `migration plan` and `migration new`. The documented design for stating renames is a planner hint in the contract source (`@hint(was: ...)`, Data Contract and Migration System subsystem docs, ADR 001), so the flag was a second, undocumented mechanism. The operator removed it. Until hints exist, a rename is stated with `this.renameTable(...)` in a migration created by `migration new`, which also computes the companion constraint and index renames from the migration's contracts. + diff --git a/projects/psl-verbatim-table-names/plan.md b/projects/psl-verbatim-table-names/plan.md index 1db34cd492da..5f966bc38d4d 100644 --- a/projects/psl-verbatim-table-names/plan.md +++ b/projects/psl-verbatim-table-names/plan.md @@ -14,10 +14,10 @@ ### Slice 2 — Rename-table migration operation -- **Outcome:** a PSL model whose table name changes plans as one rename-table operation on Postgres and SQLite instead of a drop and a create, and the guard's second remedy points at that path. How the planner learns a rename is intended is settled in this slice's spec before implementation. +- **Outcome:** a user renames a table without losing its rows by writing `this.renameTable(...)` in a migration created with `prisma migration new`; the method also renames the constraints and indexes named after the table. The guard points at this path. Spec: `slices/rename-table-operation/spec.md`. - **Builds on:** slice 1 (guard and error text). -- **Hands to:** the rename op and the intent mechanism. -- **Branch:** to be cut from `main` after slice 1 merges. +- **Hands to:** the rename operation and the companion-rename computation that a future planner-hint feature can reuse. +- **PR:** https://github.com/prisma/orm/pull/30331 ### Slice 3 — TS DSL cross-space relation table fallback @@ -29,6 +29,10 @@ Slice 2 and slice 3 are independent of each other and run in parallel after slic ## Follow-ups filed outside this project +- Planner hints in the contract source, `@hint(was: ...)`, as documented in the Data Contract and Migration System subsystem docs and ADR 001. Not implemented. With hints, `migration plan` and `db update` could plan a rename without a hand-written migration, reusing slice 2's companion-rename code. Open questions: PSL syntax and the TypeScript equivalent; whether `was` names the old model or the old table; how hints reach the offline planner without entering `contract.json`; whether a leftover hint that matches nothing is ignored or an error. + +- Extension packages keep migrations directly under `migrations/` (per `.agents/rules/contract-space-package-layout.mdc` and every shipped extension), but `migration plan` reads and writes history under `migrations/app/`. Inside such a package `migration plan` cannot see the history, so `--from` fails and a stated rename cannot be planned. Found while writing the slice 2 extension upgrade note; it predates this project and affects every `migration plan` run in an extension package. + - Whether `contract infer` should keep names verbatim like Prisma 7 instead of re-casing to PascalCase. - Mongo has no planner guard; the operator chose not to address it in this project. diff --git a/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md new file mode 100644 index 000000000000..5d047c9248aa --- /dev/null +++ b/projects/psl-verbatim-table-names/slices/rename-table-operation/spec.md @@ -0,0 +1,46 @@ +# Slice spec — Rename-table migration operation + +**Project:** `projects/psl-verbatim-table-names/` · **Slice 2** · **Branch:** `psl-verbatim-rename-table` · **PR:** https://github.com/prisma/orm/pull/30331 + +## At a glance + +Today a model whose table name changes plans as `DropTable` plus `CreateTable`, and the rows are gone. After this slice a user makes the rename its own schema change, runs `prisma migration new`, and writes one line in the generated migration: + +```ts +this.renameTable({ table: 'userProfile', to: 'UserProfile' }) +``` + +`migrate` then renames the table and every constraint and index named after it, and the rows survive. + +## Chosen design + +**A rename is stated in a hand-written migration, never inferred and never stated on the command line.** Tables have no content identity the way indexes and checks do (ADR 243), so the planner cannot tell a rename from a drop and a create. The documented long-term design is a planner hint in the contract source, `@hint(was: ...)` (Data Contract and Migration System subsystem docs, ADR 001); it is not implemented and is a follow-up outside this project. Until it exists, the way to state a rename is the one the migration system already offers for anything the planner cannot infer: a hand-written migration. + +**`this.renameTable` emits every rename the table needs.** The migration facade method reads the migration's start and end contracts and emits the table rename followed by a rename for each object on that table whose name is derived from the table name: unnamed primary keys, unique constraints and foreign keys on Postgres; indexes and check constraints whose derived prefix comes from the table name; default-named indexes on SQLite, dropped and recreated because SQLite cannot rename an index. Only objects the end contract leaves otherwise unchanged are renamed; such an object takes the end contract's explicit name if it has one, otherwise the name derived from the new table name. An object the end contract also changes keeps the name the database has, so an author who writes that change by hand refers to it by that name. Amended after review: an earlier rule renamed changed constraints to the derived name, which only made sense while a planned drop followed. Explicitly named objects and foreign keys on other tables keep their names. On Postgres the method also carries the table's row-level security settings and policies to the new name where the contract refers to them by table name. If the start contract has no such table, or the end contract has no table under the new name, the method refuses with `MIGRATION.TABLE_RENAME_UNMATCHED`. + +**The rename is its own schema change.** `migrate` verifies the database against the migration's end contract, so a hand-written migration that renames a table but omits other edits made in the same change fails loudly at `migrate`. The guide tells users to rename first, then make other edits and plan them. + +**The operation.** `renameTable` on Postgres and SQLite: prechecks that the old table exists and the new one does not; postchecks that the new table exists and the old one is gone. Postgres qualifies by schema. SQLite folds only ASCII letters when comparing identifiers, so a rename that only changes ASCII case goes through a temporary name. Operation class `widening`. `RenameCheckConstraintCall` is generalised into `RenameConstraintCall` with a kind. + +**The guard's remedies.** `MIGRATION.TABLE_NAME_CASE_CHANGED` offers three ways out: add `@@map` to keep the old table; in a project with migration history, create a migration with `prisma migration new` and call `this.renameTable(...)` in it; in a project managed with `db update`, run the statements the target supplies by hand, then `db update` again. On SQLite `db update` drops an index before creating one whose name collides only in ASCII case. + +**No CLI flag.** An earlier version of this slice added `--rename =` to `migration plan` and `migration new`. It was removed because it is a second, undocumented way to state a rename that the documented hint design will replace. Amended 2026-09-17 on the operator's decision. + +## Scope + +**In:** the operation and its call for Postgres and SQLite; the facade method computing companion renames from the migration's contracts; the SQLite identifier-collision helper, index drop-before-create ordering, and rebuild postchecks; the guard's three remedies with target-supplied by-hand statements; the error reference; the upgrade fragments under `upgrade-instructions/pending/psl-verbatim-table-names/` and `upgrade-instructions/pending/rename-constraint-call/`; removal of the flag and everything reachable only through it. + +**Out:** planner hints; rename inference; column renames; MongoDB collection renames (users rename by hand). + +## Tests, all red before their change + +- Operation, both targets: rendered SQL, prechecks, postchecks, TypeScript rendering round-trip. +- Facade, both targets: `this.renameTable` emits the table rename plus the companion renames for a table with an unnamed primary key, a unique constraint, a foreign key, an index and a check; a constraint the end contract also changes keeps its current name; an explicitly named object is left alone; an unknown table refuses. +- Guard, both targets: the three remedies and the target's by-hand statements. +- Journeys under `test/integration/test/cli-journeys/`: Postgres and SQLite, `migration new` plus a hand-written `this.renameTable` on a table with rows, a unique constraint, a foreign key and an index (and row-level security and a policy on Postgres); after `migrate` the rows and objects are present, a plan with no schema change is empty, `db verify --schema-only` is clean, and a follow-up migration removing those objects applies. The SQLite `db update` by-hand path journey stays. + +## Done conditions + +- Every test above is green; all repository checks pass one at a time, including `lint:framework-vocabulary` at or below main's count and the checks only CI runs. +- No `--rename` flag, `CLI.INVALID_RENAME_FLAG`, or rename-intent code reachable only through the flag remains. +- The project DoD line "a model rename in PSL keeps its rows on Postgres and SQLite" is met by the journeys. diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index 6426954ef854..9c31a99c63fc 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -358,9 +358,9 @@ On Postgres the operations are **methods on the `Migration` base class**, each t **Postgres** operations (representative set, all `this.({...})`): -- Tables: `createTable`, `dropTable`. +- Tables: `createTable`, `renameTable`, `dropTable`. `renameTable` reads the migration's start and end contracts and returns the table rename plus a rename of every constraint and index named after the old table, so spread it: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. - Columns: `addColumn` (`column: col(name, nativeType, { codecRef })`), `dropColumn`, `alterColumnType`, `setNotNull`, `dropNotNull`, `setDefault`, `dropDefault`. -- Constraints: `addPrimaryKey`, `addForeignKey`, `addUnique`, `addCheckConstraint`, `renameCheckConstraint`, `dropCheckConstraint`, `dropConstraint`. +- Constraints: `addPrimaryKey`, `addForeignKey`, `addUnique`, `addCheckConstraint`, `renameConstraint` (`kind`: `primaryKey`, `unique`, `foreignKey` or `checkConstraint`), `renameCheckConstraint`, `dropCheckConstraint`, `dropConstraint`. - Indexes: `createIndex`, `renameIndex`, `dropIndex`. - Enums: `createNativeEnumType`, `addNativeEnumValue`, `dropNativeEnumType`. - Row-level security: `enableRowLevelSecurity`, `disableRowLevelSecurity`, `createRlsPolicy`, `renameRlsPolicy`, `dropRlsPolicy`. diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index 814ccaa777ca..a70d9b4493ea 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -30,6 +30,8 @@ pnpm test:journeys | `db-update-workflows.e2e.test.ts` | **Direct update**: `db update` without migrations (additive-only, dry-run, noop). **Destructive update**: drops a column, tests `--no-interactive` rejection, `--json` error envelope, and `--json -y` auto-accept. **Re-init conflict**: `db init` on an already-initialized DB with a different contract fails; recovery via `db update` | | `contract-infer-workflow.e2e.test.ts` | **PSL inference workflow**: refresh `contract.prisma` from the live database with `contract infer`, emit from the inferred PSL, verify the schema, and confirm a second infer is stable | | `brownfield-adoption.e2e.test.ts` | **Adopt Prisma on existing DB**: infer a PSL contract from the live DB, emit matching artifacts, `db verify --schema-only`, sign, verify, and then evolve via `db update`. **Schema mismatch**: emit a contract that doesn't match the DB, observe sign / schema-only verify failures, fix contract, retry | +| `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard, which points at `this.renameTable`, and that a `renameTable` call naming a table the end contract lacks fails when `migration.ts` builds its operations. A migration created with `migration new` and `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })` renames the table and each constraint and index named after it; `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. Journey R5 changes the table name and its foreign key target in one schema change: a rename-only migration for it fails at `migrate`, which verifies against the end contract; made as a rename-only change and then a planned foreign key change, it keeps the rows and verifies clean | +| `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the first rename journey, driven through a file database and the SQLite facade config: the rename plus a drop and a create of each index named after the old table, then the same empty plan and removal migration. A second journey follows the by-hand path of a `db update` project: the case guard gives the two-statement rename through `_prisma_rename_UserProfile`, and after running it `db update` drops and recreates the indexes named after the old table, keeps the rows and verifies clean | ### Graph features and refs diff --git a/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts new file mode 100644 index 000000000000..3871ee8bfff4 --- /dev/null +++ b/test/integration/test/cli-journeys/rename-table-migration.e2e.test.ts @@ -0,0 +1,383 @@ +/** + * Renaming a table keeps its rows (Postgres). + * + * A model whose table name changes plans as `dropTable` plus `createTable`. A user who wants the rows makes the rename its own schema change, creates its migration with `migration new`, and writes `...this.renameTable({ table, to })`, which renames the table and each constraint and index named after the old table. + * + * Journey R1 creates `userProfile` with rows, a unique constraint, a foreign key, an index, row-level security and a policy, then drops the model's `@@map` so it names `UserProfile`. Planning the change is refused by the case-change guard, which points at the `renameTable` call; a call naming a table the end contract lacks fails when `migration.ts` builds its operations. After `migrate` the rows, the policy and RLS are kept, every constraint and index carries the new table name, `db verify --schema-only` is clean, a plan with no schema change is empty, and a later migration that removes the unique constraint, the foreign key and the index applies. + * + * Journey R5 changes a table's name and its foreign key's target in one schema change. A hand-written migration that only renames fails at `migrate`, which verifies the database against the migration's end contract. Made as two changes, a rename-only migration and then a planned foreign key change, it keeps the rows and verifies clean. + */ + +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { withTempDir } from '../utils/cli-test-helpers'; +import { + authorMigration, + engineError, + getMigrationDirs, + type JourneyContext, + latestMigrationDirName, + parseJsonOutput, + planMigrationAndSelfEmit, + runContractEmit, + runDbVerify, + runMigrate, + runMigrationPlan, + setupJourney, + sql, + swapPslContract, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; + +const FOREIGN_KEY_FROM_PSL = `// use prisma-8 + +model Account { + id Int @id + profiles UserProfile[] +} + +model Member { + id Int @id +} + +model UserProfile { + id Int @id + accountId Int + account Account @relation(fields: [accountId], references: [id]) + + @@map("userProfile") +} +`; + +const FOREIGN_KEY_RENAMED_PSL = `// use prisma-8 + +model Account { + id Int @id + profiles UserProfile[] +} + +model Member { + id Int @id +} + +model UserProfile { + id Int @id + accountId Int + account Account @relation(fields: [accountId], references: [id]) +} +`; + +const FOREIGN_KEY_TO_PSL = `// use prisma-8 + +model Account { + id Int @id +} + +model Member { + id Int @id + profiles UserProfile[] +} + +model UserProfile { + id Int @id + accountId Int + member Member @relation(fields: [accountId], references: [id], map: "profile_member_fk") +} +`; + +const RENAME_CALL = "...this.renameTable({ table: 'userProfile', to: 'UserProfile' })"; + +const RENAME_LABELS = [ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + 'Rename index "userProfile_accountId_idx_cbfb3085" to "UserProfile_accountId_idx_cbfb3085" on "UserProfile"', + 'Rename index "userProfile_handle_idx_b5b249e4" to "UserProfile_handle_idx_b5b249e4" on "UserProfile"', +]; + +async function seedTableWithObjects( + ctx: JourneyContext, + connectionString: string, + label: string, +): Promise { + await sql(connectionString, 'CREATE ROLE app_user'); + swapPslContract(ctx, 'contract-rename-table-objects-from'); + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `${label}.01: emit userProfile: ${emit.stderr}`).toBe(0); + const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(initial.exitCode, `${label}.02: plan initial: ${initial.stderr}`).toBe(0); + const applyInitial = await runMigrate(ctx); + expect(applyInitial.exitCode, `${label}.03: migrate initial: ${applyInitial.stderr}`).toBe(0); + await sql( + connectionString, + `INSERT INTO "public"."Account" (id) VALUES (1); + INSERT INTO "public"."userProfile" (id, email, handle, tenant_id, "accountId") + VALUES (1, 'alice@example.com', 'alice', 1, 1), (2, 'bob@example.com', 'bob', 1, 1)`, + ); + swapPslContract(ctx, 'contract-rename-table-objects-to'); + const emitRenamed = await runContractEmit(ctx); + expect(emitRenamed.exitCode, `${label}.04: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); + return latestMigrationDirName(ctx); +} + +async function expectRenameApplied( + ctx: JourneyContext, + connectionString: string, + label: string, +): Promise { + const rows = await sql( + connectionString, + `SELECT id, email FROM "public"."UserProfile" ORDER BY id`, + ); + expect(rows.rows, `${label}: rows present under the new name`).toEqual([ + { id: 1, email: 'alice@example.com' }, + { id: 2, email: 'bob@example.com' }, + ]); + const live = await sql( + connectionString, + `SELECT + to_regclass('"public"."userProfile"') AS old, + (SELECT array_agg(conname::text ORDER BY conname) FROM pg_constraint + WHERE conrelid = '"public"."UserProfile"'::regclass) AS constraints, + (SELECT array_agg(indexname::text ORDER BY indexname) FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'UserProfile') AS indexes, + (SELECT array_agg(policyname::text) FROM pg_policies + WHERE schemaname = 'public' AND tablename = 'UserProfile') AS policies, + (SELECT relrowsecurity FROM pg_class + WHERE oid = '"public"."UserProfile"'::regclass) AS rls`, + ); + expect(live.rows[0], `${label}: old name gone, objects renamed, policy and RLS kept`).toEqual({ + old: null, + constraints: ['UserProfile_accountId_fkey', 'UserProfile_email_key', 'UserProfile_pkey'], + indexes: [ + 'UserProfile_accountId_idx_cbfb3085', + 'UserProfile_email_key', + 'UserProfile_handle_idx_b5b249e4', + 'UserProfile_pkey', + ], + policies: ['tenant_read_f8d5e783'], + rls: true, + }); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `${label}: db verify --schema-only: ${verify.stderr}`).toBe(0); +} + +async function expectLaterChangesApply( + ctx: JourneyContext, + connectionString: string, + label: string, +): Promise { + const migrationCount = getMigrationDirs(ctx).length; + const fresh = await runMigrationPlan(ctx, ['--from', latestMigrationDirName(ctx), '--json']); + expect(fresh.exitCode, `${label}.01: plan with no schema change: ${fresh.stderr}`).toBe(0); + expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, `${label}.01: plan is empty`).toBe(true); + expect(getMigrationDirs(ctx), `${label}.01: nothing written`).toHaveLength(migrationCount); + + swapPslContract(ctx, 'contract-rename-table-objects-dropped'); + const emitDropped = await runContractEmit(ctx); + expect(emitDropped.exitCode, `${label}.02: emit without objects: ${emitDropped.stderr}`).toBe(0); + const dropPlan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'drop-objects', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(dropPlan.exitCode, `${label}.02: plan the removal: ${dropPlan.stderr}`).toBe(0); + const applyDrop = await runMigrate(ctx); + expect(applyDrop.exitCode, `${label}.03: migrate the removal: ${applyDrop.stderr}`).toBe(0); + const remaining = await sql( + connectionString, + `SELECT + (SELECT array_agg(conname::text ORDER BY conname) FROM pg_constraint + WHERE conrelid = '"public"."UserProfile"'::regclass) AS constraints, + (SELECT array_agg(indexname::text) FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'UserProfile') AS indexes`, + ); + expect(remaining.rows[0], `${label}.03: unique, foreign key and indexes removed`).toEqual({ + constraints: ['UserProfile_pkey'], + indexes: ['UserProfile_pkey'], + }); + const verifyDropped = await runDbVerify(ctx, ['--schema-only']); + expect( + verifyDropped.exitCode, + `${label}.04: db verify --schema-only after the removal: ${verifyDropped.stderr}`, + ).toBe(0); +} + +function operationsOf(ctx: JourneyContext, dirName: string) { + return JSON.parse( + readFileSync(join(ctx.testDir, 'migrations', 'app', dirName, 'ops.json'), 'utf-8'), + ) as readonly { readonly label: string; readonly operationClass: string }[]; +} + +withTempDir(({ createTempDir }) => { + describe('Journey R1: rename a table with migration new and this.renameTable', () => { + const db = useDevDatabase(); + + it( + 'guard points at renameTable; a call naming a missing table fails; the call renames the table and its named objects, keeps rows and policies, and later changes apply', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + const origin = await seedTableWithObjects(ctx, db.connectionString, 'R1'); + + const bare = await runMigrationPlan(ctx, ['--name', 'bare', '--from', origin, '--json']); + expect(bare.exitCode, 'R1.05: bare plan is refused').not.toBe(0); + const bareError = engineError(bare); + expect(bareError?.code, 'R1.05: guard code').toBe('MIGRATION.PLANNING_FAILED'); + expect(bareError?.why, 'R1.05: guard names the case change').toContain( + 'MIGRATION.TABLE_NAME_CASE_CHANGED', + ); + expect( + bareError?.nextActions?.map((action) => action.label).join('\n'), + 'R1.05: guard points at migration new and renameTable', + ).toContain( + 'create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); + expect(getMigrationDirs(ctx), 'R1.05: nothing written').toHaveLength(1); + + const stale = await authorMigration( + ctx, + 'stale', + "...this.renameTable({ table: 'userProfile', to: 'Nope' })", + ); + expect(stale.emit.exitCode, 'R1.06: a call naming a missing table fails').not.toBe(0); + expect(stale.emit.stderr, 'R1.06: names the unmatched rename').toContain( + 'renameTable "userProfile" to "Nope" does not match the migration\'s contracts: table "public.Nope" does not exist in the end contract.', + ); + rmSync(join(ctx.testDir, 'migrations', 'app', stale.dirName), { recursive: true }); + + const rename = await authorMigration(ctx, 'rename-user-profile', RENAME_CALL); + expect(rename.emit.exitCode, `R1.07: self-emit: ${rename.emit.stderr}`).toBe(0); + const ops = operationsOf(ctx, rename.dirName); + expect( + ops.map((op) => op.label), + 'R1.07: the rename, then a rename of each object named after the old table', + ).toEqual(RENAME_LABELS); + expect( + ops.every((op) => op.operationClass === 'widening'), + 'R1.07: every operation is a widening rename', + ).toBe(true); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `R1.08: migrate: ${apply.stderr}`).toBe(0); + await expectRenameApplied(ctx, db.connectionString, 'R1.09'); + await expectLaterChangesApply(ctx, db.connectionString, 'R1.10'); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey R5: rename a table, then point its foreign key at another table', () => { + const db = useDevDatabase(); + + it( + 'a rename migration that omits the foreign key change fails at migrate; a rename-only change, then a planned foreign key change, keeps the rows and verifies clean', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + writeFileSync(join(ctx.testDir, 'contract.prisma'), FOREIGN_KEY_FROM_PSL); + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `R5.01: emit userProfile: ${emit.stderr}`).toBe(0); + const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(initial.exitCode, `R5.02: plan initial: ${initial.stderr}`).toBe(0); + const applyInitial = await runMigrate(ctx); + expect(applyInitial.exitCode, `R5.03: migrate initial: ${applyInitial.stderr}`).toBe(0); + await sql( + db.connectionString, + `INSERT INTO "public"."Account" (id) VALUES (1); + INSERT INTO "public"."Member" (id) VALUES (1); + INSERT INTO "public"."userProfile" (id, "accountId") VALUES (1, 1)`, + ); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), FOREIGN_KEY_TO_PSL); + const emitBoth = await runContractEmit(ctx); + expect(emitBoth.exitCode, `R5.04: emit both changes: ${emitBoth.stderr}`).toBe(0); + const incomplete = await authorMigration(ctx, 'rename-and-retarget', RENAME_CALL); + expect(incomplete.emit.exitCode, `R5.05: self-emit: ${incomplete.emit.stderr}`).toBe(0); + const refused = await runMigrate(ctx, ['--json']); + expect(refused.exitCode, 'R5.06: migrate refuses the incomplete migration').not.toBe(0); + expect( + engineError(refused), + 'R5.06: the database does not match the end contract', + ).toMatchObject({ + code: 'MIGRATION.RUNNER_FAILED', + summary: expect.stringContaining('Database schema does not satisfy contract'), + }); + const untouched = await sql( + db.connectionString, + `SELECT to_regclass('"public"."userProfile"')::text AS old, to_regclass('"public"."UserProfile"')::text AS new`, + ); + expect(untouched.rows[0], 'R5.06: the failed migration left the table as it was').toEqual({ + old: '"userProfile"', + new: null, + }); + rmSync(join(ctx.testDir, 'migrations', 'app', incomplete.dirName), { recursive: true }); + expect( + existsSync(join(ctx.testDir, 'migrations', 'app', incomplete.dirName)), + 'R5.06: the incomplete migration is removed', + ).toBe(false); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), FOREIGN_KEY_RENAMED_PSL); + const emitRenamed = await runContractEmit(ctx); + expect(emitRenamed.exitCode, `R5.07: emit the rename alone: ${emitRenamed.stderr}`).toBe(0); + const rename = await authorMigration(ctx, 'rename-user-profile', RENAME_CALL); + expect(rename.emit.exitCode, `R5.07: self-emit: ${rename.emit.stderr}`).toBe(0); + expect( + operationsOf(ctx, rename.dirName).map((op) => op.label), + 'R5.07: the rename', + ).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + 'Rename index "userProfile_accountId_idx_cbfb3085" to "UserProfile_accountId_idx_cbfb3085" on "UserProfile"', + ]); + const applyRename = await runMigrate(ctx); + expect(applyRename.exitCode, `R5.08: migrate the rename: ${applyRename.stderr}`).toBe(0); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), FOREIGN_KEY_TO_PSL); + const emitRetarget = await runContractEmit(ctx); + expect( + emitRetarget.exitCode, + `R5.09: emit the foreign key change: ${emitRetarget.stderr}`, + ).toBe(0); + const retarget = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'retarget-foreign-key', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(retarget.exitCode, `R5.09: plan the foreign key change: ${retarget.stderr}`).toBe(0); + const applyRetarget = await runMigrate(ctx); + expect(applyRetarget.exitCode, `R5.10: migrate: ${applyRetarget.stderr}`).toBe(0); + const live = await sql( + db.connectionString, + `SELECT + (SELECT array_agg(id) FROM "public"."UserProfile") AS ids, + (SELECT array_agg(conname::text || '->' || confrelid::regclass::text ORDER BY conname) + FROM pg_constraint + WHERE conrelid = '"public"."UserProfile"'::regclass AND contype = 'f') AS foreign_keys`, + ); + expect(live.rows[0], 'R5.11: rows kept, one foreign key to Member').toEqual({ + ids: [1], + foreign_keys: ['profile_member_fk->"Member"'], + }); + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `R5.12: db verify --schema-only: ${verify.stderr}`).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts new file mode 100644 index 000000000000..ffac237dc85a --- /dev/null +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -0,0 +1,324 @@ +/** + * Renaming a table keeps its rows (SQLite). + * + * The SQLite twin of `rename-table-migration.e2e.test.ts`, driven through a file database and the SQLite facade config. Journey R3: create `userProfile` with rows, a unique constraint, a foreign key and an index, then drop the `@@map` so the model names `UserProfile`. Planning the change is refused by the case guard, which points at the `renameTable` call; a call naming a table the end contract lacks fails when `migration.ts` builds its operations. A migration created with `migration new` and `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })` renames the table and drops and recreates each index named after the old table; `migrate` keeps the rows; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. + * + * Journey R4 follows the by-hand path of a project managed with `db update`: the case guard refuses and gives the two-statement rename, the statements are run by hand, and `db update` then drops each index named after the old table before creating it under the new name, keeping the rows. + */ + +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import { withTempDir, writeProjectManifest } from '../utils/cli-test-helpers'; +import { + authorMigration, + engineError, + getMigrationDirs, + type JourneyContext, + latestMigrationDirName, + parseJsonOutput, + planMigrationAndSelfEmit, + runContractEmit, + runDbUpdate, + runDbVerify, + runMigrate, + runMigrationPlan, + sqlitePslConfigFixture, + timeouts, +} from '../utils/journey-test-helpers'; + +const FROM_PSL = `// use prisma-8 + +model Account { + id Int @id + profiles UserProfile[] +} + +model UserProfile { + id Int @id + email String @unique + handle String + accountId Int + account Account @relation(fields: [accountId], references: [id]) + + @@index([handle]) + @@map("userProfile") +} +`; + +const TO_PSL = `// use prisma-8 + +model Account { + id Int @id + profiles UserProfile[] +} + +model UserProfile { + id Int @id + email String @unique + handle String + accountId Int + account Account @relation(fields: [accountId], references: [id]) + + @@index([handle]) +} +`; + +const DROPPED_PSL = `// use prisma-8 + +model Account { + id Int @id +} + +model UserProfile { + id Int @id + email String + handle String + accountId Int +} +`; + +function setupSqliteJourney(createTempDir: () => string): JourneyContext & { dbPath: string } { + const testDir = createTempDir(); + const dbPath = join(testDir, 'journey.db'); + const configPath = join(testDir, 'prisma.config.ts'); + const config = readFileSync(sqlitePslConfigFixture, 'utf-8').replace('{{DB_PATH}}', () => dbPath); + writeFileSync(configPath, config, 'utf-8'); + writeFileSync(join(testDir, 'contract.prisma'), FROM_PSL, 'utf-8'); + writeProjectManifest(testDir); + return { testDir, configPath, outputDir: testDir, dbPath }; +} + +function withDatabase(dbPath: string, run: (db: DatabaseSync) => T): T { + const db = new DatabaseSync(dbPath); + try { + return run(db); + } finally { + db.close(); + } +} + +withTempDir(({ createTempDir }) => { + describe('Journey R3 (SQLite): rename a table with migration new and this.renameTable', () => { + it( + 'guard points at renameTable; a call naming a missing table fails; the call renames the table and recreates its named indexes, keeps the rows, and later changes apply', + async () => { + const ctx = setupSqliteJourney(createTempDir); + + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `R3.01: emit userProfile: ${emit.stderr}`).toBe(0); + const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(initial.exitCode, `R3.02: plan initial: ${initial.stderr}`).toBe(0); + const applyInitial = await runMigrate(ctx); + expect(applyInitial.exitCode, `R3.03: migrate initial: ${applyInitial.stderr}`).toBe(0); + const origin = latestMigrationDirName(ctx); + withDatabase(ctx.dbPath, (db) => { + db.exec( + `INSERT INTO "Account" (id) VALUES (1); + INSERT INTO "userProfile" (id, email, handle, "accountId") + VALUES (1, 'alice@example.com', 'alice', 1), (2, 'bob@example.com', 'bob', 1)`, + ); + }); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), TO_PSL, 'utf-8'); + const emitRenamed = await runContractEmit(ctx); + expect(emitRenamed.exitCode, `R3.04: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); + + const bare = await runMigrationPlan(ctx, ['--name', 'bare', '--from', origin, '--json']); + expect(bare.exitCode, 'R3.05: bare plan is refused by the guard').not.toBe(0); + expect(engineError(bare)?.why, 'R3.05: guard names the case change').toContain( + 'MIGRATION.TABLE_NAME_CASE_CHANGED', + ); + expect( + engineError(bare) + ?.nextActions?.map((action) => action.label) + .join('\n'), + 'R3.05: guard points at migration new and renameTable', + ).toContain( + 'create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); + + const stale = await authorMigration( + ctx, + 'stale', + "...this.renameTable({ table: 'userProfile', to: 'Nope' })", + ); + expect(stale.emit.exitCode, 'R3.06: a call naming a missing table fails').not.toBe(0); + expect(stale.emit.stderr, 'R3.06: names the unmatched rename').toContain( + 'renameTable "userProfile" to "Nope" does not match the migration\'s contracts: table "Nope" does not exist in the end contract.', + ); + rmSync(join(ctx.testDir, 'migrations', 'app', stale.dirName), { recursive: true }); + expect(getMigrationDirs(ctx), 'R3.06: only the initial migration remains').toHaveLength(1); + + const rename = await authorMigration( + ctx, + 'rename-user-profile', + "...this.renameTable({ table: 'userProfile', to: 'UserProfile' })", + ); + expect(rename.emit.exitCode, `R3.07: self-emit: ${rename.emit.stderr}`).toBe(0); + const ops = JSON.parse( + readFileSync(join(ctx.testDir, 'migrations', 'app', rename.dirName, 'ops.json'), 'utf-8'), + ) as readonly { readonly label: string }[]; + expect( + ops.map((op) => op.label), + 'R3.07: the rename, then each index named after the old table dropped and recreated', + ).toEqual([ + 'Rename table userProfile to UserProfile', + 'Drop index userProfile_accountId_idx_cbfb3085 on UserProfile', + 'Drop index userProfile_handle_idx_b5b249e4 on UserProfile', + 'Create index UserProfile_accountId_idx_cbfb3085 on UserProfile', + 'Create index UserProfile_handle_idx_b5b249e4 on UserProfile', + ]); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `R3.08: migrate: ${apply.stderr}`).toBe(0); + const state = withDatabase(ctx.dbPath, (db) => ({ + rows: db.prepare(`SELECT id, email FROM "UserProfile" ORDER BY id`).all(), + objects: db + .prepare( + `SELECT type, name FROM sqlite_master WHERE tbl_name = 'UserProfile' ORDER BY type, name`, + ) + .all() + .map((row) => `${row['type']} ${row['name']}`), + tables: db + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%serProfile'`, + ) + .all() + .map((row) => row['name']), + })); + expect(state.rows, 'R3.09: rows present under the new name').toEqual([ + { id: 1, email: 'alice@example.com' }, + { id: 2, email: 'bob@example.com' }, + ]); + expect(state.tables, 'R3.09: old name is gone').toEqual(['UserProfile']); + expect(state.objects, 'R3.09: every index follows the new name').toEqual([ + 'index UserProfile_accountId_idx_cbfb3085', + 'index UserProfile_handle_idx_b5b249e4', + 'index sqlite_autoindex_UserProfile_1', + 'table UserProfile', + ]); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `R3.10: db verify --schema-only: ${verify.stderr}`).toBe(0); + + const fresh = await runMigrationPlan(ctx, [ + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(fresh.exitCode, `R3.11: plan with no schema change: ${fresh.stderr}`).toBe(0); + expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, 'R3.11: plan is empty').toBe(true); + expect(getMigrationDirs(ctx), 'R3.11: nothing written').toHaveLength(2); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), DROPPED_PSL, 'utf-8'); + const emitDropped = await runContractEmit(ctx); + expect(emitDropped.exitCode, `R3.12: emit without objects: ${emitDropped.stderr}`).toBe(0); + const dropPlan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'drop-objects', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(dropPlan.exitCode, `R3.12: plan the removal: ${dropPlan.stderr}`).toBe(0); + const applyDrop = await runMigrate(ctx); + expect(applyDrop.exitCode, `R3.13: migrate the removal: ${applyDrop.stderr}`).toBe(0); + const remaining = withDatabase(ctx.dbPath, (db) => ({ + rows: db.prepare(`SELECT id, email FROM "UserProfile" ORDER BY id`).all(), + objects: db + .prepare( + `SELECT type, name FROM sqlite_master WHERE tbl_name = 'UserProfile' ORDER BY type, name`, + ) + .all() + .map((row) => `${row['type']} ${row['name']}`), + })); + expect(remaining, 'R3.13: unique, foreign key and indexes removed, rows kept').toEqual({ + rows: [ + { id: 1, email: 'alice@example.com' }, + { id: 2, email: 'bob@example.com' }, + ], + objects: ['table UserProfile'], + }); + const verifyDropped = await runDbVerify(ctx, ['--schema-only']); + expect( + verifyDropped.exitCode, + `R3.14: db verify --schema-only after the removal: ${verifyDropped.stderr}`, + ).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey R4 (SQLite): rename a table by hand in a project managed with db update', () => { + it( + 'the guard gives the two-statement rename; after running it, db update recreates the indexes and verify is clean', + async () => { + const ctx = setupSqliteJourney(createTempDir); + + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `R4.01: emit userProfile: ${emit.stderr}`).toBe(0); + const create = await runDbUpdate(ctx, ['--json']); + expect(create.exitCode, `R4.02: db update creates userProfile: ${create.stderr}`).toBe(0); + withDatabase(ctx.dbPath, (db) => { + db.exec( + `INSERT INTO "Account" (id) VALUES (1); + INSERT INTO "userProfile" (id, email, handle, "accountId") + VALUES (1, 'alice@example.com', 'alice', 1), (2, 'bob@example.com', 'bob', 1)`, + ); + }); + + writeFileSync(join(ctx.testDir, 'contract.prisma'), TO_PSL, 'utf-8'); + const emitRenamed = await runContractEmit(ctx); + expect(emitRenamed.exitCode, `R4.03: emit UserProfile: ${emitRenamed.stderr}`).toBe(0); + + const refused = await runDbUpdate(ctx, ['--json']); + expect(refused.exitCode, 'R4.04: db update is refused by the guard').not.toBe(0); + const refusal = engineError(refused); + expect(refusal?.why, 'R4.04: guard names the case change').toContain( + 'MIGRATION.TABLE_NAME_CASE_CHANGED', + ); + expect( + refusal?.nextActions?.map((action) => action.label).join('\n'), + 'R4.04: guard gives the SQLite by-hand statements', + ).toContain( + 'rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.', + ); + + withDatabase(ctx.dbPath, (db) => { + db.exec('ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"'); + db.exec('ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"'); + }); + + const update = await runDbUpdate(ctx, ['--json', '--confirm', 'journey.db']); + expect(update.exitCode, `R4.05: db update after the rename: ${update.stdout}`).toBe(0); + const state = withDatabase(ctx.dbPath, (db) => ({ + rows: db.prepare(`SELECT id, email FROM "UserProfile" ORDER BY id`).all(), + objects: db + .prepare( + `SELECT type, name FROM sqlite_master WHERE tbl_name = 'UserProfile' ORDER BY type, name`, + ) + .all() + .map((row) => `${row['type']} ${row['name']}`), + })); + expect(state, 'R4.06: rows kept, indexes under the new name').toEqual({ + rows: [ + { id: 1, email: 'alice@example.com' }, + { id: 2, email: 'bob@example.com' }, + ], + objects: [ + 'index UserProfile_accountId_idx_cbfb3085', + 'index UserProfile_handle_idx_b5b249e4', + 'index sqlite_autoindex_UserProfile_1', + 'table UserProfile', + ], + }); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `R4.07: db verify --schema-only: ${verify.stderr}`).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-dropped.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-dropped.prisma new file mode 100644 index 000000000000..77744be0c8d4 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-dropped.prisma @@ -0,0 +1,23 @@ +// use prisma-8 + +namespace public { + model Account { + id Int @id + } + + model UserProfile { + id Int @id + email String + handle String + tenantId Int @map("tenant_id") + accountId Int + + @@rls + } + + policy_select tenant_read { + target = UserProfile + roles = [app_user] + using = "(tenant_id = 1)" + } +} diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-from.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-from.prisma new file mode 100644 index 000000000000..e856d3dcf937 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-from.prisma @@ -0,0 +1,27 @@ +// use prisma-8 + +namespace public { + model Account { + id Int @id + profiles UserProfile[] + } + + model UserProfile { + id Int @id + email String @unique + handle String + tenantId Int @map("tenant_id") + accountId Int + account Account @relation(fields: [accountId], references: [id]) + + @@index([handle]) + @@map("userProfile") + @@rls + } + + policy_select tenant_read { + target = UserProfile + roles = [app_user] + using = "(tenant_id = 1)" + } +} diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-to.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-to.prisma new file mode 100644 index 000000000000..3cca24a432e6 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-table-objects-to.prisma @@ -0,0 +1,26 @@ +// use prisma-8 + +namespace public { + model Account { + id Int @id + profiles UserProfile[] + } + + model UserProfile { + id Int @id + email String @unique + handle String + tenantId Int @map("tenant_id") + accountId Int + account Account @relation(fields: [accountId], references: [id]) + + @@index([handle]) + @@rls + } + + policy_select tenant_read { + target = UserProfile + roles = [app_user] + using = "(tenant_id = 1)" + } +} diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.sqlite.psl.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.sqlite.psl.ts new file mode 100644 index 000000000000..6d1b40a292b3 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/prisma.config.sqlite.psl.ts @@ -0,0 +1,14 @@ +import { defineConfig as ormConfig } from '@internal/sqlite/config'; +import { defineConfig } from '@prisma/cli-engine'; + +export default defineConfig({ + orm: ormConfig({ + contract: './contract.prisma', + db: { + connection: '{{DB_PATH}}', + }, + migrations: { + dir: 'migrations', + }, + }), +}); diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index d7128dbcf0e5..6d07a0626bc3 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -218,10 +218,24 @@ export const pslContractFixtures = { ), 'contract-rls-adopted': join(JOURNEY_FIXTURES_DIR, 'contract-rls-adopted.prisma'), 'contract-rls-wire': join(JOURNEY_FIXTURES_DIR, 'contract-rls-wire.prisma'), + 'contract-rename-table-objects-from': join( + JOURNEY_FIXTURES_DIR, + 'contract-rename-table-objects-from.prisma', + ), + 'contract-rename-table-objects-to': join( + JOURNEY_FIXTURES_DIR, + 'contract-rename-table-objects-to.prisma', + ), + 'contract-rename-table-objects-dropped': join( + JOURNEY_FIXTURES_DIR, + 'contract-rename-table-objects-dropped.prisma', + ), } as const; export type PslContractVariant = keyof typeof pslContractFixtures; +export const sqlitePslConfigFixture = join(JOURNEY_FIXTURES_DIR, 'prisma.config.sqlite.psl.ts'); + /** * Swaps the active contract in the test directory to a different variant. * Copies the variant file over `contract.ts` so the config picks it up on next emit. @@ -363,6 +377,33 @@ export async function runMigrationNew( return runOnEngine(ctx, ['migration', 'new', ...extraArgs], options); } +/** + * Authors a migration by hand: `migration new` scaffolds `migration.ts`, the given source replaces the body of its `operations` array, and `migration.ts` is run to write `ops.json` and `migration.json`. Returns the directory name and the self-emit result, which carries the error when building the operations fails. + */ +export async function authorMigration( + ctx: JourneyContext, + name: string, + operationsSource: string, +): Promise<{ readonly dirName: string; readonly emit: CommandResult }> { + const scaffold = await runMigrationNew(ctx, ['--name', name]); + if (scaffold.exitCode !== 0) { + throw new Error(`authorMigration: migration new failed: ${scaffold.stderr}`); + } + const dirName = latestMigrationDirName(ctx); + const migrationTsPath = join(appMigrationsDir(ctx), dirName, 'migration.ts'); + const scaffolded = readFileSync(migrationTsPath, 'utf-8'); + const authored = scaffolded.replace( + /return \[[\s\S]*?\];/, + () => `return [\n ${operationsSource},\n ];`, + ); + if (authored === scaffolded) { + throw new Error('authorMigration: the scaffold has no operations array to fill'); + } + writeFileSync(migrationTsPath, authored, 'utf-8'); + const emit = await selfEmitMigration(ctx, ['--dir', `migrations/app/${dirName}`]); + return { dirName, emit }; +} + export async function runMigrate( ctx: JourneyContext, extraArgs: readonly string[] = [], diff --git a/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md b/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md index d954de26816a..8d995369d7b0 100644 --- a/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md +++ b/upgrade-instructions/pending/psl-verbatim-table-names/app/instructions.md @@ -37,11 +37,62 @@ If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma mi ```text ✘ [MIGRATION.PLANNING_FAILED] Migration planning failed why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile". -→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. Prisma 8 has no rename-table operation, so a deliberate rename is done by hand: run ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty. +→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" }) to the migration's operations, which renames the table and the objects named after it; in a project that uses db update, rename it by hand with ALTER TABLE "public"."userProfile" RENAME TO "UserProfile", then run db update again. ``` -The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no planner and gives no error: an unmapped model silently reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before deploying. +That is the Postgres output for a model in the default `public` schema. On SQLite the last clause gives the two statements SQLite needs for a rename that only changes case: `in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.` -To adopt the verbatim names on purpose instead of mapping, rename the storage by hand and skip the codemod for those models. Postgres and SQLite: `ALTER TABLE "userProfile" RENAME TO "UserProfile"` (`ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"` inside a schema). Mongo: `db.userProfile.renameCollection("UserProfile")`. After the rename the migration plan is empty and `db verify` is clean. +The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no such check. A migration planned without the codemod drops the `userProfile` collection, and an application running the unmapped model reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before planning or deploying. + +To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and rename the storage. + +**Postgres or SQLite, with migration history.** Make the rename its own schema change: remove the `@@map` from the models you rename, change nothing else, and emit the contract. Create a migration for the change: + +```bash +prisma migration new --name rename-user-profile +``` + +In the new `migration.ts`, spread one `renameTable` call per renamed table into the operations: + +```ts +override get operations() { + return [...this.renameTable({ table: 'userProfile', to: 'UserProfile' })]; +} +``` + +On Postgres, add `schema: 'auth'` when the table is not in the default schema or another schema has a table with the same name. The call reads the migration's start and end contracts. It renames the table, then each primary key, unique constraint, foreign key, index and check constraint named after the old table; on SQLite it drops each such index and creates it under the new name. Run `node migrations/app//migration.ts` to write the operations, then `prisma db migrate`. The rows stay. + +Make your other schema edits afterwards and plan them as usual. `db migrate` checks the database against the migration's end contract, so a rename migration that leaves out other edits from the same schema change fails there. + +**Postgres, managed with `db update`.** Rename the table by hand, then run `prisma db update`: + +```sql +ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"; +``` + +For a table in another schema, write that schema: `ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"`. The rows stay. Until `db update` runs, `db verify --schema-only` reports the indexes named after the old table as missing; `db update` renames them, and after that `db verify --schema-only` is clean. Primary keys, unique constraints and foreign keys that you did not name with `map:` keep the names derived from the old table, and neither `db verify` nor `db update` reports it. Rename them as well, because a migration you plan later derives these names from the new table name: + +```sql +ALTER TABLE "public"."UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"; +ALTER TABLE "public"."UserProfile" RENAME CONSTRAINT "userProfile__key" TO "UserProfile__key"; +ALTER TABLE "public"."UserProfile" RENAME CONSTRAINT "userProfile__fkey" TO "UserProfile__fkey"; +``` + +`` is the constraint's column names joined with `_`, such as `email` or `tenantId_email`. Add one statement per unique constraint and per foreign key. + +**SQLite, managed with `db update`.** SQLite refuses in one statement a rename that only changes case, so rename the table through a temporary name, then run `prisma db update`: + +```sql +ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; +ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"; +``` + +The rows stay, and SQLite renames its automatic indexes itself. `db update` drops each index named after the old table and creates it under the new name. Dropping an index needs your consent: `db update` asks for it, or, without a terminal, takes the database name it prints with `--confirm`. After that `db verify --schema-only` is clean. + +**MongoDB.** Rename the collection by hand before you plan: + +```js +db.userProfile.renameCollection("UserProfile") +``` `contract infer` follows the same rule: a table whose name already equals the model name (`"UserProfile"`, `"User"`) infers to a model with no `@@map` and verifies clean, where the previous output pointed the model at a lowercase table that did not exist. A snake_case table still infers with `@@map("user_profile")`. There is nothing to detect for this: the inferred text for such a table is the same as before, it is now correct. diff --git a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md index e7410d41fe18..c8a93c4a2542 100644 --- a/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md +++ b/upgrade-instructions/pending/psl-verbatim-table-names/extension/instructions.md @@ -37,11 +37,33 @@ If you plan a migration (`prisma migration plan`, `prisma db update`, `prisma mi ```text ✘ [MIGRATION.PLANNING_FAILED] Migration planning failed why: MIGRATION.TABLE_NAME_CASE_CHANGED: table "UserProfile" would be created and table "userProfile" dropped. Prisma 8 changed the default table name: a model with no @@map now names its table verbatim, so model UserProfile points at "UserProfile" instead of "userProfile". -→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. Prisma 8 has no rename-table operation, so a deliberate rename is done by hand: run ALTER TABLE "userProfile" RENAME TO "UserProfile" (schema-qualified where applicable), after which the plan is empty. +→ To keep table "userProfile" and its rows, add @@map("userProfile") to model UserProfile (or run the add-model-map codemod over the schema) and plan again. To rename the table and keep its rows instead: in a project with migration history, make the rename its own schema change, create its migration with prisma migration new, and add ...this.renameTable({ table: "userProfile", to: "UserProfile" }) to the migration's operations, which renames the table and the objects named after it; in a project that uses db update, rename it by hand with ALTER TABLE "public"."userProfile" RENAME TO "UserProfile", then run db update again. ``` -The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no planner and gives no error: an unmapped model silently reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before deploying. +That is the Postgres output for a model in the default `public` schema. On SQLite the last clause gives the two statements SQLite needs for a rename that only changes case: `in a project that uses db update, rename it by hand with ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"; ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile", then run db update again.` -To adopt the verbatim names on purpose instead of mapping, rename the storage by hand and skip the codemod for those models. Postgres and SQLite: `ALTER TABLE "userProfile" RENAME TO "UserProfile"` (`ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"` inside a schema). Mongo: `db.userProfile.renameCollection("UserProfile")`. After the rename the migration plan is empty and `db verify` is clean. +The conflict fires for each pair where the table to drop equals the table to create with its first letter lowered, in the same namespace, whatever the columns. It does not fire on an empty database or on tables the contract's control policy marks `external` or `observed`. Mongo has no such check. A migration planned without the codemod drops the `userProfile` collection, and an application running the unmapped model reads and writes an empty `UserProfile` collection while the documents stay in `userProfile`, so run the codemod before planning or deploying. + +To adopt the verbatim names on purpose instead of mapping, skip the codemod for those models and ship the rename as a migration of your contract space. Applications change your tables only by running the migrations your descriptor lists in `contractSpace.migrations`: their `prisma migration plan` copies each one into `migrations//`, and `prisma db migrate` applies it. If you rename the table by hand in your own database and run `db update`, only that database changes. Every application keeps the old table. + +**Postgres or SQLite.** Make the rename its own schema change: remove the `@@map` from the models you rename, change nothing else, and run the package's contract-space build. From the extension package root, create a migration for the change: + +```bash +prisma migration new --name rename-user-profile +``` + +In the new `migration.ts`, spread one `renameTable` call per renamed table into the operations: + +```ts +override get operations() { + return [...this.renameTable({ table: 'userProfile', to: 'UserProfile' })]; +} +``` + +On Postgres, add `schema: 'auth'` when the table is not in the default schema or another schema has a table with the same name. The call renames the table and the constraints and indexes named after it, and the rows stay. Run `node migrations/app//migration.ts` to write the operations. Add the new migration's `migration.json` and `ops.json` to `contractSpace.migrations` in the descriptor. In the head ref the descriptor passes as `headRef`, set `hash` to the new migration's `to` hash and keep `invariants` as they are. Applications get the rename when they upgrade the extension and run `prisma migration plan` and `prisma db migrate`, and their rows stay. + +This works only if the package's migration history is in `migrations/app/`, where `migration new` reads and writes it. If the package keeps its migration directories directly under `migrations/`, as Prisma's own extension packages do, `migration new` does not see that history, the new migration has no start contract, and `renameTable` refuses with `MIGRATION.TABLE_RENAME_UNMATCHED`. A deliberate rename is not supported for that layout yet, so keep the `@@map`. + +**MongoDB.** A Mongo contract space has no rename operation, so a deliberate rename is not supported there yet. Keep the `@@map`. `contract infer` follows the same rule: a table whose name already equals the model name (`"UserProfile"`, `"User"`) infers to a model with no `@@map` and verifies clean, where the previous output pointed the model at a lowercase table that did not exist. A snake_case table still infers with `@@map("user_profile")`. There is nothing to detect for this: the inferred text for such a table is the same as before, it is now correct. diff --git a/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md b/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md new file mode 100644 index 000000000000..3a60c47f4dd8 --- /dev/null +++ b/upgrade-instructions/pending/rename-constraint-call/extension/instructions.md @@ -0,0 +1,22 @@ +--- +changes: + - id: rename-check-constraint-call-is-rename-constraint-call + summary: Replace `RenameCheckConstraintCall` with `RenameConstraintCall`, which takes the constraint kind as a new third constructor argument; pass `'checkConstraint'` for a check constraint. + detection: + glob: "**/*.{ts,mts,cts,js,mjs,cjs}" + matches: + - '(?