|
| 1 | +/** |
| 2 | + * Managed native-enum add-value migration against a REAL PostgreSQL server |
| 3 | + * (not PGlite): plans and applies `ALTER TYPE … ADD VALUE`, then proves the |
| 4 | + * newly-added value is usable for CRUD in separate, subsequent statements — |
| 5 | + * the whole point of committing the ALTER on a real server (PGlite tests |
| 6 | + * cover planning/apply-ordering but can't prove this cross-transaction |
| 7 | + * usability). |
| 8 | + * |
| 9 | + * Isolated in a throwaway database (`prisma_next_native_enum_add_value_realdb`) |
| 10 | + * dropped and recreated on a maintenance connection; skips (does not fail) |
| 11 | + * when no real Postgres is reachable. |
| 12 | + */ |
| 13 | +import type { Contract, ControlPolicy } from '@prisma-next/contract/types'; |
| 14 | +import { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; |
| 15 | +import { |
| 16 | + APP_SPACE_ID, |
| 17 | + assembleAuthoringContributions, |
| 18 | + type MigrationOperationPolicy, |
| 19 | +} from '@prisma-next/framework-components/control'; |
| 20 | +import { buildSymbolTable } from '@prisma-next/psl-parser'; |
| 21 | +import { parse } from '@prisma-next/psl-parser/syntax'; |
| 22 | +import type { SqlStorage } from '@prisma-next/sql-contract/types'; |
| 23 | +import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl'; |
| 24 | +import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; |
| 25 | +import { |
| 26 | + PostgresDatabaseSchemaNode, |
| 27 | + postgresCreateNamespace, |
| 28 | +} from '@prisma-next/target-postgres/types'; |
| 29 | +import { ifDefined } from '@prisma-next/utils/defined'; |
| 30 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 31 | +import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup'; |
| 32 | +import { createPostgresScalarTypeDescriptors } from '../../src/core/control-mutation-defaults'; |
| 33 | +import { |
| 34 | + controlAdapter, |
| 35 | + createDriver, |
| 36 | + emptySchema, |
| 37 | + familyInstance, |
| 38 | + formatRunnerFailure, |
| 39 | + frameworkComponents, |
| 40 | + type PostgresControlDriver, |
| 41 | + postgresTargetDescriptor, |
| 42 | + synthEdges, |
| 43 | + testTimeout, |
| 44 | +} from './fixtures/runner-fixtures'; |
| 45 | + |
| 46 | +// ============================================================================ |
| 47 | +// PSL sources |
| 48 | +// ============================================================================ |
| 49 | + |
| 50 | +const PSL_ENUM_TWO_MEMBERS = ` |
| 51 | +namespace public { |
| 52 | + native_enum OrderStatus { |
| 53 | + draft = "draft" |
| 54 | + review = "review" |
| 55 | + @@map("order_status") |
| 56 | + } |
| 57 | +
|
| 58 | + model orders { |
| 59 | + id Int @id |
| 60 | + status pg.enum(OrderStatus) |
| 61 | + } |
| 62 | +} |
| 63 | +`; |
| 64 | + |
| 65 | +const PSL_WITH_ENUM = ` |
| 66 | +namespace public { |
| 67 | + native_enum OrderStatus { |
| 68 | + draft = "draft" |
| 69 | + review = "review" |
| 70 | + done = "done" |
| 71 | + @@map("order_status") |
| 72 | + } |
| 73 | +
|
| 74 | + model orders { |
| 75 | + id Int @id |
| 76 | + status pg.enum(OrderStatus) |
| 77 | + } |
| 78 | +} |
| 79 | +`; |
| 80 | + |
| 81 | +// ============================================================================ |
| 82 | +// PSL → contract helpers (mirrors native-enum-lifecycle-e2e.integration.test.ts) |
| 83 | +// ============================================================================ |
| 84 | + |
| 85 | +function buildScalarTypeDescriptors(): ReadonlyMap< |
| 86 | + string, |
| 87 | + { codecId: string; nativeType: string } |
| 88 | +> { |
| 89 | + const codecIdMap = createPostgresScalarTypeDescriptors(); |
| 90 | + const codecLookup = createPostgresBuiltinCodecLookup(); |
| 91 | + const result = new Map<string, { codecId: string; nativeType: string }>(); |
| 92 | + for (const [typeName, codecId] of codecIdMap) { |
| 93 | + const nativeType = codecLookup.targetTypesFor(codecId)?.[0]; |
| 94 | + if (nativeType !== undefined) { |
| 95 | + result.set(typeName, { codecId, nativeType }); |
| 96 | + } |
| 97 | + } |
| 98 | + return result; |
| 99 | +} |
| 100 | + |
| 101 | +function buildContractFromPsl(psl: string, control: ControlPolicy): Contract<SqlStorage> { |
| 102 | + const assembled = assembleAuthoringContributions([postgresTargetDescriptor]); |
| 103 | + const scalarTypeDescriptors = buildScalarTypeDescriptors(); |
| 104 | + |
| 105 | + const { document, sourceFile } = parse(psl); |
| 106 | + const { table: symbolTable } = buildSymbolTable({ |
| 107 | + document, |
| 108 | + sourceFile, |
| 109 | + scalarTypes: [...scalarTypeDescriptors.keys()], |
| 110 | + pslBlockDescriptors: assembled.pslBlockDescriptors, |
| 111 | + }); |
| 112 | + |
| 113 | + const result = interpretPslDocumentToSqlContract({ |
| 114 | + symbolTable, |
| 115 | + sourceFile, |
| 116 | + sourceId: 'schema.prisma', |
| 117 | + target: { |
| 118 | + kind: 'target' as const, |
| 119 | + familyId: 'sql' as const, |
| 120 | + targetId: 'postgres' as const, |
| 121 | + id: 'postgres', |
| 122 | + version: postgresTargetDescriptor.version, |
| 123 | + capabilities: {}, |
| 124 | + defaultNamespaceId: 'public', |
| 125 | + ...ifDefined('authoring', postgresTargetDescriptor.authoring), |
| 126 | + }, |
| 127 | + scalarTypeDescriptors, |
| 128 | + authoringContributions: assembled, |
| 129 | + composedExtensionContracts: new Map(), |
| 130 | + createNamespace: postgresCreateNamespace, |
| 131 | + codecLookup: createPostgresBuiltinCodecLookup(), |
| 132 | + capabilities: { sql: { scalarList: true } }, |
| 133 | + }); |
| 134 | + |
| 135 | + if (!result.ok) throw new Error(`PSL interpretation failed: ${JSON.stringify(result)}`); |
| 136 | + return { ...(result.value as Contract<SqlStorage>), defaultControlPolicy: control }; |
| 137 | +} |
| 138 | + |
| 139 | +async function planContract( |
| 140 | + contract: Contract<SqlStorage>, |
| 141 | + schema: SqlSchemaIRNode, |
| 142 | + policy: MigrationOperationPolicy, |
| 143 | +) { |
| 144 | + const planner = postgresTargetDescriptor.createPlanner(controlAdapter); |
| 145 | + const planResult = planner.plan({ |
| 146 | + contract, |
| 147 | + schema, |
| 148 | + policy, |
| 149 | + fromContract: null, |
| 150 | + frameworkComponents, |
| 151 | + spaceId: APP_SPACE_ID, |
| 152 | + }); |
| 153 | + if (planResult.kind !== 'success') |
| 154 | + throw new Error(`Planner failed: ${JSON.stringify(planResult)}`); |
| 155 | + return planResult.plan; |
| 156 | +} |
| 157 | + |
| 158 | +async function applyPlan( |
| 159 | + driver: PostgresControlDriver, |
| 160 | + plan: Awaited<ReturnType<typeof planContract>>, |
| 161 | + contract: Contract<SqlStorage>, |
| 162 | + policy: MigrationOperationPolicy, |
| 163 | +): Promise<void> { |
| 164 | + const runner = postgresTargetDescriptor.createRunner(familyInstance); |
| 165 | + const executeResult = await runner.execute({ |
| 166 | + driver, |
| 167 | + perSpaceOptions: [ |
| 168 | + { |
| 169 | + space: plan.spaceId ?? APP_SPACE_ID, |
| 170 | + plan, |
| 171 | + migrationEdges: synthEdges(plan), |
| 172 | + driver, |
| 173 | + destinationContract: contract, |
| 174 | + policy, |
| 175 | + frameworkComponents, |
| 176 | + }, |
| 177 | + ], |
| 178 | + }); |
| 179 | + if (!executeResult.ok) |
| 180 | + throw new Error(`Runner failed:\n${formatRunnerFailure(executeResult.failure)}`); |
| 181 | +} |
| 182 | + |
| 183 | +async function opIds(plan: Awaited<ReturnType<typeof planContract>>): Promise<readonly string[]> { |
| 184 | + const ops = await Promise.all(plan.operations); |
| 185 | + return ops.map((op) => op.id); |
| 186 | +} |
| 187 | + |
| 188 | +/** The ordered member list of a native enum type in a namespace of an introspected/live schema tree, or `undefined` when the namespace or type is absent. */ |
| 189 | +function nativeEnumMembers( |
| 190 | + schema: SqlSchemaIRNode, |
| 191 | + namespaceId: string, |
| 192 | + typeName: string, |
| 193 | +): readonly string[] | undefined { |
| 194 | + PostgresDatabaseSchemaNode.assert(schema); |
| 195 | + return schema.namespaces[namespaceId]?.nativeEnums.find((e) => e.typeName === typeName)?.members; |
| 196 | +} |
| 197 | + |
| 198 | +// ============================================================================ |
| 199 | +// Real-DB connection + isolation |
| 200 | +// ============================================================================ |
| 201 | + |
| 202 | +const MAINTENANCE_URL = |
| 203 | + process.env['DATABASE_URL'] ?? 'postgres://postgres:postgres@localhost:5432/postgres'; |
| 204 | +const TEST_DB = 'prisma_next_native_enum_add_value_realdb'; |
| 205 | + |
| 206 | +function testDatabaseUrl(): string { |
| 207 | + const u = new URL(MAINTENANCE_URL); |
| 208 | + u.pathname = '/' + TEST_DB; |
| 209 | + return u.toString(); |
| 210 | +} |
| 211 | + |
| 212 | +async function isRealPostgresAvailable(): Promise<boolean> { |
| 213 | + try { |
| 214 | + const d = await createDriver(MAINTENANCE_URL); |
| 215 | + await d.query('select 1'); |
| 216 | + await d.close(); |
| 217 | + return true; |
| 218 | + } catch { |
| 219 | + return false; |
| 220 | + } |
| 221 | +} |
| 222 | + |
| 223 | +async function dropTestDatabaseViaMaintenance(): Promise<void> { |
| 224 | + const maintenance = await createDriver(MAINTENANCE_URL); |
| 225 | + await maintenance.query(`DROP DATABASE IF EXISTS ${TEST_DB} WITH (FORCE)`); |
| 226 | + await maintenance.close(); |
| 227 | +} |
| 228 | + |
| 229 | +// ============================================================================ |
| 230 | +// Tests |
| 231 | +// ============================================================================ |
| 232 | + |
| 233 | +describe.runIf(await isRealPostgresAvailable())( |
| 234 | + 'managed native-enum add-value — real Postgres CRUD (R8)', |
| 235 | + () => { |
| 236 | + let driver: PostgresControlDriver | undefined; |
| 237 | + |
| 238 | + beforeAll(async () => { |
| 239 | + const maintenance = await createDriver(MAINTENANCE_URL); |
| 240 | + await maintenance.query(`DROP DATABASE IF EXISTS ${TEST_DB} WITH (FORCE)`); |
| 241 | + await maintenance.query(`CREATE DATABASE ${TEST_DB}`); |
| 242 | + await maintenance.close(); |
| 243 | + |
| 244 | + driver = await createDriver(testDatabaseUrl()); |
| 245 | + }, testTimeout); |
| 246 | + |
| 247 | + afterAll(async () => { |
| 248 | + await driver?.close(); |
| 249 | + await dropTestDatabaseViaMaintenance(); |
| 250 | + }, testTimeout); |
| 251 | + |
| 252 | + it( |
| 253 | + 'appends a member via ALTER TYPE ADD VALUE and the new value is usable for CRUD on a real server', |
| 254 | + async () => { |
| 255 | + // 1. Plan + apply the 2-member baseline from empty. |
| 256 | + const baseContract = buildContractFromPsl(PSL_ENUM_TWO_MEMBERS, 'managed'); |
| 257 | + const basePlan = await planContract(baseContract, emptySchema, INIT_ADDITIVE_POLICY); |
| 258 | + await applyPlan(driver!, basePlan, baseContract, INIT_ADDITIVE_POLICY); |
| 259 | + |
| 260 | + // 2. Introspect, plan the append to 3 members, apply it. |
| 261 | + const introspectedBase = await familyInstance.introspect({ |
| 262 | + driver: driver!, |
| 263 | + contract: baseContract, |
| 264 | + }); |
| 265 | + const appendedContract = buildContractFromPsl(PSL_WITH_ENUM, 'managed'); |
| 266 | + const appendPlan = await planContract( |
| 267 | + appendedContract, |
| 268 | + introspectedBase, |
| 269 | + INIT_ADDITIVE_POLICY, |
| 270 | + ); |
| 271 | + const ids = await opIds(appendPlan); |
| 272 | + expect(ids).toEqual(['addNativeEnumValue.order_status.done']); |
| 273 | + await applyPlan(driver!, appendPlan, appendedContract, INIT_ADDITIVE_POLICY); |
| 274 | + |
| 275 | + // 3. The new member is live. |
| 276 | + const introspectedAfter = await familyInstance.introspect({ |
| 277 | + driver: driver!, |
| 278 | + contract: appendedContract, |
| 279 | + }); |
| 280 | + expect(nativeEnumMembers(introspectedAfter, 'public', 'order_status')).toEqual([ |
| 281 | + 'draft', |
| 282 | + 'review', |
| 283 | + 'done', |
| 284 | + ]); |
| 285 | + |
| 286 | + // 4. CRUD using the newly-added 'done' value, in separate statements |
| 287 | + // (the whole point of a real, committed ALTER TYPE ADD VALUE). |
| 288 | + await driver!.query('INSERT INTO "public"."orders" (id, status) VALUES ($1, $2)', [ |
| 289 | + 1, |
| 290 | + 'done', |
| 291 | + ]); |
| 292 | + const createdRow = await driver!.query<{ status: string }>( |
| 293 | + 'SELECT status FROM "public"."orders" WHERE id = $1', |
| 294 | + [1], |
| 295 | + ); |
| 296 | + expect(createdRow.rows[0]?.status).toBe('done'); |
| 297 | + |
| 298 | + await driver!.query('INSERT INTO "public"."orders" (id, status) VALUES ($1, $2)', [ |
| 299 | + 2, |
| 300 | + 'draft', |
| 301 | + ]); |
| 302 | + await driver!.query('UPDATE "public"."orders" SET status = $1 WHERE id = $2', ['done', 2]); |
| 303 | + const updatedRow = await driver!.query<{ status: string }>( |
| 304 | + 'SELECT status FROM "public"."orders" WHERE id = $1', |
| 305 | + [2], |
| 306 | + ); |
| 307 | + expect(updatedRow.rows[0]?.status).toBe('done'); |
| 308 | + |
| 309 | + await driver!.query('DELETE FROM "public"."orders" WHERE id = $1', [2]); |
| 310 | + const remaining = await driver!.query<{ count: string }>( |
| 311 | + 'SELECT count(*)::text AS count FROM "public"."orders" WHERE id = $1', |
| 312 | + [2], |
| 313 | + ); |
| 314 | + expect(remaining.rows[0]?.count).toBe('0'); |
| 315 | + |
| 316 | + // 5. Verify is clean against the appended contract. |
| 317 | + const verify = familyInstance.verifySchema({ |
| 318 | + contract: appendedContract, |
| 319 | + schema: introspectedAfter, |
| 320 | + strict: true, |
| 321 | + frameworkComponents, |
| 322 | + }); |
| 323 | + expect(verify.ok).toBe(true); |
| 324 | + }, |
| 325 | + testTimeout, |
| 326 | + ); |
| 327 | + }, |
| 328 | +); |
0 commit comments