From 194e0fdaeedb8b784cb25b2b83711a99f51d32ce Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 5 Nov 2026 21:23:32 +0000 Subject: [PATCH 1/4] test(deparser): schema-qualified object references in GRANT ON TYPE/DOMAIN, COMMENT ON TRIGGER/RULE/OPERATOR/CAST and CREATE OPERATOR commutator --- __fixtures__/generated/generated.json | 10 ++++++++++ .../misc/qualified-object-refs.sql | 12 ++++++++++++ .../misc-qualified-object-refs.test.ts | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 __fixtures__/kitchen-sink/misc/qualified-object-refs.sql create mode 100644 packages/deparser/__tests__/kitchen-sink/misc-qualified-object-refs.test.ts diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json index 557e5fcb..d355dbb3 100644 --- a/__fixtures__/generated/generated.json +++ b/__fixtures__/generated/generated.json @@ -21256,6 +21256,16 @@ "misc/quotes_etc-28.sql": "DO $$\nBEGIN\n RAISE NOTICE 'Line one\\nLine two';\nEND;\n$$ LANGUAGE plpgsql", "misc/quotes_etc-29.sql": "CREATE USER MAPPING FOR local_user SERVER \"foreign_server\" OPTIONS (user 'remote_user', password 'secret123')", "misc/quotes_etc-30.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (user 'remote_user', password 'secret123')", + "misc/qualified-object-refs-1.sql": "GRANT USAGE ON TYPE fx.e TO r1", + "misc/qualified-object-refs-2.sql": "GRANT USAGE ON DOMAIN fx.d TO r1", + "misc/qualified-object-refs-3.sql": "REVOKE USAGE ON TYPE fx.e FROM r1", + "misc/qualified-object-refs-4.sql": "GRANT USAGE ON TYPE \"Fx\".\"Mixed Case\" TO r1", + "misc/qualified-object-refs-5.sql": "COMMENT ON TRIGGER t_tg ON fx.t IS 'trigger comment'", + "misc/qualified-object-refs-6.sql": "COMMENT ON RULE r ON fx.t IS 'rule comment'", + "misc/qualified-object-refs-7.sql": "COMMENT ON OPERATOR fx.### (int, int) IS 'operator comment'", + "misc/qualified-object-refs-8.sql": "COMMENT ON OPERATOR fx.### (NONE, int) IS 'prefix operator comment'", + "misc/qualified-object-refs-9.sql": "COMMENT ON CAST (int AS text) IS 'cast comment'", + "misc/qualified-object-refs-10.sql": "CREATE OPERATOR fx.### (LEFTARG = int, RIGHTARG = int, FUNCTION = fx.add3, COMMUTATOR = OPERATOR(fx.###), NEGATOR = OPERATOR(fx.!!!))", "misc/policy-quoted-names-1.sql": "CREATE POLICY \"Users can view own data\" ON public.todos FOR SELECT TO authenticated USING (auth.uid() = user_id)", "misc/policy-quoted-names-2.sql": "DROP POLICY \"Users can view own data\" ON public.todos", "misc/policy-quoted-names-3.sql": "DROP POLICY IF EXISTS \"Users can view own data\" ON todos", diff --git a/__fixtures__/kitchen-sink/misc/qualified-object-refs.sql b/__fixtures__/kitchen-sink/misc/qualified-object-refs.sql new file mode 100644 index 00000000..7b95f86d --- /dev/null +++ b/__fixtures__/kitchen-sink/misc/qualified-object-refs.sql @@ -0,0 +1,12 @@ +-- Schema-qualified object references in GRANT/REVOKE, COMMENT and CREATE OPERATOR +-- (found while round-tripping pgpm diff migrations through the deparser) +GRANT USAGE ON TYPE fx.e TO r1; +GRANT USAGE ON DOMAIN fx.d TO r1; +REVOKE USAGE ON TYPE fx.e FROM r1; +GRANT USAGE ON TYPE "Fx"."Mixed Case" TO r1; +COMMENT ON TRIGGER t_tg ON fx.t IS 'trigger comment'; +COMMENT ON RULE r ON fx.t IS 'rule comment'; +COMMENT ON OPERATOR fx.### (int, int) IS 'operator comment'; +COMMENT ON OPERATOR fx.### (NONE, int) IS 'prefix operator comment'; +COMMENT ON CAST (int AS text) IS 'cast comment'; +CREATE OPERATOR fx.### (LEFTARG = int, RIGHTARG = int, FUNCTION = fx.add3, COMMUTATOR = OPERATOR(fx.###), NEGATOR = OPERATOR(fx.!!!)); diff --git a/packages/deparser/__tests__/kitchen-sink/misc-qualified-object-refs.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-qualified-object-refs.test.ts new file mode 100644 index 00000000..7c3864eb --- /dev/null +++ b/packages/deparser/__tests__/kitchen-sink/misc-qualified-object-refs.test.ts @@ -0,0 +1,18 @@ + +import { FixtureTestUtils } from '../../test-utils'; +const fixtures = new FixtureTestUtils(); + +it('misc-qualified-object-refs', async () => { + await fixtures.runFixtureTests([ + "misc/qualified-object-refs-1.sql", + "misc/qualified-object-refs-2.sql", + "misc/qualified-object-refs-3.sql", + "misc/qualified-object-refs-4.sql", + "misc/qualified-object-refs-5.sql", + "misc/qualified-object-refs-6.sql", + "misc/qualified-object-refs-7.sql", + "misc/qualified-object-refs-8.sql", + "misc/qualified-object-refs-9.sql", + "misc/qualified-object-refs-10.sql" +]); +}); From b030b3a81e4f184e3212799d75975411a829e67e Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 5 Nov 2026 21:24:47 +0000 Subject: [PATCH 2/4] fix(deparser): keep schema qualification on GRANT ON TYPE/DOMAIN, COMMENT ON TRIGGER/RULE/OPERATOR/CAST and CREATE OPERATOR commutator/negator --- packages/deparser/src/deparser.ts | 42 ++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 70b14361..02632285 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -6980,6 +6980,13 @@ export class Deparser implements DeparserVisitor { } } + /** `schema.op` for an operator name list: schema parts quoted, the operator symbol as is. */ + private qualifiedOperatorName(objname: any): string { + const names = ListUtils.unwrapList(objname ?? []).map((n: any) => (n.String ? n.String.sval : 'unknown')); + if (names.length === 0) return 'unknown'; + return [...names.slice(0, -1).map((n: string) => QuoteUtils.quoteIdentifier(n)), names[names.length - 1]].join('.'); + } + CommentStmt(node: t.CommentStmt, context: DeparserContext): string { const output: string[] = ['COMMENT ON']; @@ -7098,20 +7105,18 @@ export class Deparser implements DeparserVisitor { output.push(objectParts.join('.')); } } else if (node.objtype === 'OBJECT_TRIGGER') { - if (objectParts.length === 2) { - const [table, trigger] = objectParts; - output.push(trigger); + if (objectParts.length >= 2) { + output.push(objectParts[objectParts.length - 1]); output.push('ON'); - output.push(table); + output.push(objectParts.slice(0, -1).join('.')); } else { output.push(objectParts.join('.')); } } else if (node.objtype === 'OBJECT_RULE') { - if (objectParts.length === 2) { - const [table, rule] = objectParts; - output.push(rule); + if (objectParts.length >= 2) { + output.push(objectParts[objectParts.length - 1]); output.push('ON'); - output.push(table); + output.push(objectParts.slice(0, -1).join('.')); } else { output.push(objectParts.join('.')); } @@ -7120,8 +7125,7 @@ export class Deparser implements DeparserVisitor { // For operators, we need to handle ObjectWithArgs structure if (node.object && (node.object as any).ObjectWithArgs) { const objWithArgs = (node.object as any).ObjectWithArgs; - let operatorName = objWithArgs.objname && objWithArgs.objname[0] && objWithArgs.objname[0].String - ? objWithArgs.objname[0].String.sval : 'unknown'; + let operatorName = this.qualifiedOperatorName(objWithArgs.objname); if (operatorName.startsWith('"') && operatorName.endsWith('"')) { operatorName = operatorName.slice(1, -1); @@ -7180,6 +7184,8 @@ export class Deparser implements DeparserVisitor { } else { output.push(objectParts.join('.')); } + } else if (node.objtype === 'OBJECT_CAST' && objectParts.length === 2) { + output.push(`(${objectParts[0]} AS ${objectParts[1]})`); } else { output.push(objectParts.join('.')); } @@ -7187,8 +7193,7 @@ export class Deparser implements DeparserVisitor { } else if (node.objtype === 'OBJECT_OPERATOR' && node.object && (node.object as any).ObjectWithArgs) { // Handle direct ObjectWithArgs for OPERATOR syntax: COMMENT ON OPERATOR -(NONE, integer) IS 'comment' const objWithArgs = (node.object as any).ObjectWithArgs; - let operatorName = objWithArgs.objname && objWithArgs.objname[0] && objWithArgs.objname[0].String - ? objWithArgs.objname[0].String.sval : 'unknown'; + let operatorName = this.qualifiedOperatorName(objWithArgs.objname); // Remove quotes from operator name if present if (operatorName.startsWith('"') && operatorName.endsWith('"')) { @@ -8722,8 +8727,11 @@ export class Deparser implements DeparserVisitor { output.push('DATABASE'); } if (node.objects && node.objects.length > 0) { + const qualifiedKinds = ['OBJECT_TYPE', 'OBJECT_DOMAIN']; const objects = ListUtils.unwrapList(node.objects) - .map(obj => this.visit(obj, context)) + .map(obj => qualifiedKinds.includes(node.objtype) && (obj as any).List + ? ListUtils.unwrapList((obj as any).List.items).map((item: any) => this.visit(item, context)).join('.') + : this.visit(obj, context)) .join(', '); output.push(objects); } @@ -8762,8 +8770,11 @@ export class Deparser implements DeparserVisitor { break; default: if (node.objects && node.objects.length > 0) { + const qualifiedKinds = ['OBJECT_TYPE', 'OBJECT_DOMAIN']; const objects = ListUtils.unwrapList(node.objects) - .map(obj => this.visit(obj, context)) + .map(obj => qualifiedKinds.includes(node.objtype) && (obj as any).List + ? ListUtils.unwrapList((obj as any).List.items).map((item: any) => this.visit(item, context)).join('.') + : this.visit(obj, context)) .join(', '); output.push(objects); } @@ -10128,6 +10139,9 @@ export class Deparser implements DeparserVisitor { if (listItems.length === 1 && listItems[0].String) { return `${preservedDefName} = ${listItems[0].String.sval}`; } + if (listItems.length > 1 && listItems.every(item => item.String)) { + return `${preservedDefName} = OPERATOR(${this.qualifiedOperatorName(listItems)})`; + } } // For commutator/negator, we already handled them above if ((defName.toLowerCase() === 'commutator' || defName.toLowerCase() === 'negator')) { From 8951738e9b1e432ffdf802d8d0fbf3b4154d4f12 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 5 Nov 2026 22:00:14 +0000 Subject: [PATCH 3/4] test(deparser): ALTER OPERATOR ... SET (COMMUTATOR = OPERATOR(schema.op)) keeps the qualified operator --- __fixtures__/generated/generated.json | 1 + __fixtures__/kitchen-sink/misc/qualified-object-refs.sql | 1 + .../__tests__/kitchen-sink/misc-qualified-object-refs.test.ts | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json index d355dbb3..4fc2f187 100644 --- a/__fixtures__/generated/generated.json +++ b/__fixtures__/generated/generated.json @@ -21266,6 +21266,7 @@ "misc/qualified-object-refs-8.sql": "COMMENT ON OPERATOR fx.### (NONE, int) IS 'prefix operator comment'", "misc/qualified-object-refs-9.sql": "COMMENT ON CAST (int AS text) IS 'cast comment'", "misc/qualified-object-refs-10.sql": "CREATE OPERATOR fx.### (LEFTARG = int, RIGHTARG = int, FUNCTION = fx.add3, COMMUTATOR = OPERATOR(fx.###), NEGATOR = OPERATOR(fx.!!!))", + "misc/qualified-object-refs-11.sql": "ALTER OPERATOR fx.### (int, int) SET (COMMUTATOR = OPERATOR(fx.!!!), NEGATOR = OPERATOR(fx.!!!))", "misc/policy-quoted-names-1.sql": "CREATE POLICY \"Users can view own data\" ON public.todos FOR SELECT TO authenticated USING (auth.uid() = user_id)", "misc/policy-quoted-names-2.sql": "DROP POLICY \"Users can view own data\" ON public.todos", "misc/policy-quoted-names-3.sql": "DROP POLICY IF EXISTS \"Users can view own data\" ON todos", diff --git a/__fixtures__/kitchen-sink/misc/qualified-object-refs.sql b/__fixtures__/kitchen-sink/misc/qualified-object-refs.sql index 7b95f86d..210c2ebc 100644 --- a/__fixtures__/kitchen-sink/misc/qualified-object-refs.sql +++ b/__fixtures__/kitchen-sink/misc/qualified-object-refs.sql @@ -10,3 +10,4 @@ COMMENT ON OPERATOR fx.### (int, int) IS 'operator comment'; COMMENT ON OPERATOR fx.### (NONE, int) IS 'prefix operator comment'; COMMENT ON CAST (int AS text) IS 'cast comment'; CREATE OPERATOR fx.### (LEFTARG = int, RIGHTARG = int, FUNCTION = fx.add3, COMMUTATOR = OPERATOR(fx.###), NEGATOR = OPERATOR(fx.!!!)); +ALTER OPERATOR fx.### (int, int) SET (COMMUTATOR = OPERATOR(fx.!!!), NEGATOR = OPERATOR(fx.!!!)); diff --git a/packages/deparser/__tests__/kitchen-sink/misc-qualified-object-refs.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-qualified-object-refs.test.ts index 7c3864eb..f43b3bdf 100644 --- a/packages/deparser/__tests__/kitchen-sink/misc-qualified-object-refs.test.ts +++ b/packages/deparser/__tests__/kitchen-sink/misc-qualified-object-refs.test.ts @@ -13,6 +13,7 @@ it('misc-qualified-object-refs', async () => { "misc/qualified-object-refs-7.sql", "misc/qualified-object-refs-8.sql", "misc/qualified-object-refs-9.sql", - "misc/qualified-object-refs-10.sql" + "misc/qualified-object-refs-10.sql", + "misc/qualified-object-refs-11.sql" ]); }); From 5aef5ad39f4becd24d30810a97877fe326a06b0c Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 5 Nov 2026 22:00:14 +0000 Subject: [PATCH 4/4] fix(deparser): render multi-part COMMUTATOR/NEGATOR lists as OPERATOR(schema.op) in ALTER OPERATOR and DefElem paths too --- packages/deparser/src/deparser.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 02632285..26356342 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -5944,10 +5944,13 @@ export class Deparser implements DeparserVisitor { if (node.arg && this.getNodeType(node.arg) === 'List') { const listData = this.getNodeData(node.arg); const listItems = ListUtils.unwrapList(listData.items); + const preservedName = this.preserveOperatorDefElemCase(node.defname); if (listItems.length === 1 && listItems[0].String) { - const preservedName = this.preserveOperatorDefElemCase(node.defname); return `${preservedName} = ${listItems[0].String.sval}`; } + if (listItems.length > 1 && listItems.every((item: any) => item.String)) { + return `${preservedName} = OPERATOR(${this.qualifiedOperatorName(listItems)})`; + } } } @@ -6059,6 +6062,9 @@ export class Deparser implements DeparserVisitor { if (listItems.length === 1 && listItems[0].String) { return `${node.defname} = ${listItems[0].String.sval}`; } + if (listItems.length > 1 && listItems.every((item: any) => item.String)) { + return `${node.defname} = OPERATOR(${this.qualifiedOperatorName(listItems)})`; + } } }