From 1ec05029eee4a2f30bccbec59eddb187319598c1 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:15:35 -0700 Subject: [PATCH] Order RLS policy drops before column drops in migration plans When one migration drops both a column and an RLS policy whose USING expression references that column, the planner emitted dropColumn before dropPolicy. Postgres rejects that ordering: DROP COLUMN fails with 2BP01 (dependent objects still exist) when a policy depends on the column. After assembling the operation calls, hoist each policy drop ahead of the first column drop on the same table when it currently sits behind it. The policy drop only needs the table to exist, so the reorder is always safe, and calls that are already correctly ordered keep their exact positions (in particular, deliberate policy create/drop sequences are untouched). Fixes prisma/orm#30226. --- .../postgres/src/core/migrations/planner.ts | 55 +++++- .../migrations/rls-policy-drop-order.test.ts | 169 ++++++++++++++++++ 2 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rls-policy-drop-order.test.ts 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 50b35a20883c..5d9ecfdf3699 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 @@ -55,6 +55,7 @@ import { import type { PostgresOpFactoryCall } from './op-factory-call'; import { CreatePostgresRlsPolicyCall, + DropColumnCall, DropPostgresRlsPolicyCall, RenameCheckConstraintCall, RenameIndexCall, @@ -350,12 +351,14 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr resolvePostgresCallControlPolicySubject(call, options.contract), resolveFactoryName: (call) => call.factoryName, }); - const calls = [ + // RLS policy drops must precede column drops on the same table: Postgres + // rejects DROP COLUMN while a policy depends on the column (2BP01). + const calls = orderPolicyDropsBeforeColumnDrops([ ...result.value.calls, ...indexRenamePartition.kept, ...schemaDiffPartition.kept, ...fieldEventPartition.kept, - ]; + ]); // Byte-identical suppression warnings (the same subject suppressed by // more than one partition) collapse to one; distinct subjects — e.g. a // table-level suppression beside a policy-level one naming its @@ -860,6 +863,54 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr * combined tree diff, routed to `planPostgresSchemaDiff` instead of * `planIssues`. */ +/** + * Hoists an RLS policy drop ahead of the first column drop on the same + * table when it currently sits behind it. Postgres refuses `DROP COLUMN` + * while a policy depends on the column (2BP01); a policy drop only needs + * its table to exist, so the hoist is always safe. Calls that are already + * correctly ordered keep their exact positions. + */ +function orderPolicyDropsBeforeColumnDrops( + calls: readonly PostgresOpFactoryCall[], +): PostgresOpFactoryCall[] { + const tableKey = (schemaName: string, tableName: string) => `${schemaName}.${tableName}`; + const firstColumnDrop = new Map(); + calls.forEach((call, index) => { + if (call instanceof DropColumnCall) { + const key = tableKey(call.schemaName, call.tableName); + if (!firstColumnDrop.has(key)) { + firstColumnDrop.set(key, index); + } + } + }); + const insertBefore = new Map(); + const skipped = new Set(); + calls.forEach((call, index) => { + if (!(call instanceof DropPostgresRlsPolicyCall)) { + return; + } + const target = firstColumnDrop.get(tableKey(call.schemaName, call.tableName)); + if (target !== undefined && index > target) { + skipped.add(index); + insertBefore.set(target, [...(insertBefore.get(target) ?? []), call]); + } + }); + if (skipped.size === 0) { + return [...calls]; + } + const output: PostgresOpFactoryCall[] = []; + calls.forEach((call, index) => { + const pending = insertBefore.get(index); + if (pending !== undefined) { + output.push(...pending); + } + if (!skipped.has(index)) { + output.push(call); + } + }); + return output; +} + function isPolicyDiffIssue(issue: SchemaDiffIssue): boolean { const node = issue.expected ?? issue.actual; return node !== undefined && PostgresPolicySchemaNode.is(node); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-policy-drop-order.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-policy-drop-order.test.ts new file mode 100644 index 000000000000..db7afd99623a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-policy-drop-order.test.ts @@ -0,0 +1,169 @@ +/** + * Reproduction for https://github.com/prisma/orm/issues/30226: when one + * contract change drops a column AND an RLS policy whose `using` expression + * references that column, the planner must emit the policy drop before the + * column drop — Postgres refuses `DROP COLUMN` while a policy depends on + * it (2BP01). + */ + +import type { Contract } from '@internal/contract/types'; +import { 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 { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { namingOfLiveName, parseNaming } from '@internal/sql-schema-ir/naming'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { PostgresRlsEnablement } from '../../src/core/postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; +import { PostgresSchema } 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 { PostgresPolicySchemaNode } from '../../src/core/schema-ir/postgres-policy-schema-node'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; + +const stubLowerer: ExecuteRequestLowerer = { + lower(_ast, _ctx) { + return { sql: 'DROP POLICY stub', params: [] }; + }, + async lowerToExecuteRequest(_ast, _ctx) { + return { sql: 'DROP POLICY stub', params: [] }; + }, +}; + +const TABLE = 'note'; +const POLICY_NAME = 'note_public_read_443ba5fa'; +const USING = 'published = true'; + +function buildContract( + columns: Record, + withPolicy: boolean, +): Contract { + const policy = new PostgresRlsPolicy({ + naming: namingOfLiveName(POLICY_NAME), + tableName: TABLE, + namespaceId: 'public', + operation: 'select', + roles: ['anon', 'authenticated'], + using: USING, + permissive: true, + withCheck: undefined, + }); + const schema = new PostgresSchema({ + id: 'public', + entries: { + table: { + [TABLE]: new StorageTable({ + columns, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }), + }, + ...(withPolicy ? { policy: { [POLICY_NAME]: policy } } : {}), + rls: { + [TABLE]: new PostgresRlsEnablement({ tableName: TABLE, namespaceId: 'public' }), + }, + }, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('rls-policy-drop-order'), + storage: new SqlStorage({ + storageHash: coreHash('rls-policy-drop-order'), + namespaces: { public: schema }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +/** Live schema: the old world, with the column and the policy still present. */ +function liveSchema(): PostgresDatabaseSchemaNode { + return new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + [TABLE]: new PostgresTableSchemaNode({ + name: TABLE, + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false }, + title: { name: 'title', nativeType: 'text', nullable: false }, + published: { name: 'published', nativeType: 'bool', nullable: false }, + }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [ + new PostgresPolicySchemaNode({ + naming: parseNaming(POLICY_NAME, undefined), + tableName: TABLE, + namespaceId: 'public', + operation: 'select', + roles: ['anon', 'authenticated'], + using: USING, + withCheck: undefined, + permissive: true, + dependsOn: undefined, + }), + ], + rlsEnabled: true, + }), + }, + }), + }, + roles: [], + existingSchemas: ['public'], + pgVersion: 'unknown', + }); +} + +const FULL_COLUMNS = { + id: { nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false }, + title: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + published: { nativeType: 'bool', codecId: 'pg/bool@1', nullable: false }, +}; +const TRIMMED_COLUMNS = { + id: { nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false }, + title: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, +}; + +const DB_UPDATE_POLICY = { + allowedOperationClasses: ['additive', 'widening', 'destructive'] as const, +}; + +describe('RLS policy drop vs column drop ordering (prisma/orm#30226)', () => { + it('drops the policy before the column it references', async () => { + const planner = createPostgresMigrationPlanner(stubLowerer); + const result = planner.plan({ + contract: buildContract(TRIMMED_COLUMNS, false), + fromContract: buildContract(FULL_COLUMNS, true), + schema: liveSchema(), + policy: DB_UPDATE_POLICY, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + + const ops = await Promise.all(result.plan.operations); + const opIds = ops.map((op) => op.id); + const dropPolicyIdx = opIds.findIndex( + (id) => id.startsWith('rlsPolicy.') && id.endsWith('.drop'), + ); + const dropColumnIdx = opIds.findIndex((id) => id.startsWith('dropColumn.')); + + expect(dropPolicyIdx).toBeGreaterThanOrEqual(0); + expect(dropColumnIdx).toBeGreaterThanOrEqual(0); + expect(dropPolicyIdx).toBeLessThan(dropColumnIdx); + }); +});