diff --git a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap index 41965ab43..0b11abafc 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap @@ -588,3 +588,12 @@ BEGIN RETURN; END" `; + +exports[`plpgsql-deparser bug fixes uppercase %ROWTYPE/%TYPE references should not quote uppercase %ROWTYPE and %TYPE references 1`] = ` +"DECLARE + r myschema.users%ROWTYPE; + n myschema.users.name%TYPE; +BEGIN + RETURN; +END" +`; diff --git a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts index 753db18a2..4b7a9a6bb 100644 --- a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts +++ b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts @@ -1214,4 +1214,28 @@ END$$`; expect(deparsed).not.toMatch(/\(m\)\[/); }); }); + + describe('uppercase %ROWTYPE/%TYPE references', () => { + it('should not quote uppercase %ROWTYPE and %TYPE references', async () => { + const sql = `CREATE FUNCTION test_rowtype_case() RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + r myschema.users%ROWTYPE; + n myschema.users.name%TYPE; +BEGIN + NULL; +END; +$$`; + + await testUtils.expectAstMatch('uppercase rowtype/type refs', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toMatch(/myschema\.users%ROWTYPE/i); + expect(deparsed).not.toContain('"users%ROWTYPE"'); + expect(deparsed).not.toContain('"name%TYPE"'); + }); + }); }); diff --git a/packages/plpgsql-deparser/__tests__/hydrate.test.ts b/packages/plpgsql-deparser/__tests__/hydrate.test.ts index 5696b2cfc..0fb327cb3 100644 --- a/packages/plpgsql-deparser/__tests__/hydrate.test.ts +++ b/packages/plpgsql-deparser/__tests__/hydrate.test.ts @@ -274,6 +274,47 @@ $$`; expect(deparsedBody).not.toContain('old-schema'); }); + it('should preserve array bounds when dehydrating modified type-name nodes', () => { + const sql = `CREATE FUNCTION test_func() RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + v_items "old-schema".mytype[]; + v_item "old-schema".mytype; +BEGIN + NULL; +END; +$$`; + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const { ast: hydratedAst } = hydratePlpgsqlAst(parsed); + + // Rename schema inside the hydrated TypeName AST nodes + const renameTypeNames = (obj: any): void => { + if (obj === null || typeof obj !== 'object') return; + if ('PLpgSQL_type' in obj) { + const typname = obj.PLpgSQL_type.typname; + if (typname && typeof typname === 'object' && typname.kind === 'type-name') { + for (const name of typname.typeNameNode?.names ?? []) { + if (name?.String?.sval === 'old-schema') { + name.String.sval = 'new_schema'; + } + } + } + } + for (const value of Object.values(obj)) renameTypeNames(value); + }; + renameTypeNames(hydratedAst); + + const dehydratedAst = dehydratePlpgsqlAst(hydratedAst); + const deparsedBody = deparseSync(dehydratedAst); + + // The renamed array type must keep its [] bounds + expect(deparsedBody).toContain('new_schema.mytype[]'); + expect(deparsedBody).toContain('v_item new_schema.mytype;'); + expect(deparsedBody).not.toContain('old-schema'); + }); + it('should deparse modified assign AST nodes (schema renaming in assignments)', () => { const sql = `CREATE FUNCTION test_func() RETURNS void LANGUAGE plpgsql diff --git a/packages/plpgsql-deparser/src/hydrate.ts b/packages/plpgsql-deparser/src/hydrate.ts index b99c823b7..6ac5b2a8b 100644 --- a/packages/plpgsql-deparser/src/hydrate.ts +++ b/packages/plpgsql-deparser/src/hydrate.ts @@ -652,8 +652,11 @@ function deparseTypeNameNode(typeNameNode: Node, sqlDeparseOptions?: DeparserOpt } } as any; const deparsed = Deparser.deparse(wrappedStmt, sqlDeparseOptions); - // Extract the type name from "SELECT NULL::typename" - const match = deparsed.match(/SELECT\s+NULL::(.+)/i); + // Extract the type name from "SELECT NULL::typename" or + // "SELECT CAST(NULL AS typename)" depending on the deparser's cast style + const match = + deparsed.match(/SELECT\s+NULL::(.+)/i) || + deparsed.match(/^SELECT\s+CAST\(NULL\s+AS\s+(.+)\)[\s;]*$/i); if (match) { return match[1].trim().replace(/;$/, ''); } diff --git a/packages/plpgsql-deparser/src/plpgsql-deparser.ts b/packages/plpgsql-deparser/src/plpgsql-deparser.ts index c23b355ff..b561cfc10 100644 --- a/packages/plpgsql-deparser/src/plpgsql-deparser.ts +++ b/packages/plpgsql-deparser/src/plpgsql-deparser.ts @@ -747,16 +747,17 @@ export class PLpgSQLDeparser { private deparseType(typeNode: PLpgSQLTypeNode): string { if ('PLpgSQL_type' in typeNode) { let typname = typeNode.PLpgSQL_type.typname; + const isRowOrTypeRef = /%(rowtype|type)/i.test(typname); // Strip pg_catalog. prefix for built-in types, but preserve schema qualification // for %rowtype and %type references where the schema is part of the table/variable reference - if (!typname.includes('%rowtype') && !typname.includes('%type')) { + if (!isRowOrTypeRef) { typname = typname.replace(/^"?pg_catalog"?\./, ''); } // For %rowtype and %type references, preserve as-is after stripping quotes // These are special PL/pgSQL type references that shouldn't be re-quoted - if (typname.includes('%rowtype') || typname.includes('%type')) { + if (isRowOrTypeRef) { // Strip quotes and return as-is return typname.replace(/"/g, '').trim(); }