Skip to content

Commit 9164d84

Browse files
authored
Merge pull request #305 from constructive-io/fix/hydrate-typename-array-bounds
fix(plpgsql-deparser): dehydrateTypeName never used the AST — CAST deparse form broke extraction, losing array bounds on renamed types
2 parents 4213e2c + d858de3 commit 9164d84

5 files changed

Lines changed: 82 additions & 4 deletions

File tree

‎packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,3 +588,12 @@ BEGIN
588588
RETURN;
589589
END"
590590
`;
591+
592+
exports[`plpgsql-deparser bug fixes uppercase %ROWTYPE/%TYPE references should not quote uppercase %ROWTYPE and %TYPE references 1`] = `
593+
"DECLARE
594+
r myschema.users%ROWTYPE;
595+
n myschema.users.name%TYPE;
596+
BEGIN
597+
RETURN;
598+
END"
599+
`;

‎packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,4 +1214,28 @@ END$$`;
12141214
expect(deparsed).not.toMatch(/\(m\)\[/);
12151215
});
12161216
});
1217+
1218+
describe('uppercase %ROWTYPE/%TYPE references', () => {
1219+
it('should not quote uppercase %ROWTYPE and %TYPE references', async () => {
1220+
const sql = `CREATE FUNCTION test_rowtype_case() RETURNS void
1221+
LANGUAGE plpgsql
1222+
AS $$
1223+
DECLARE
1224+
r myschema.users%ROWTYPE;
1225+
n myschema.users.name%TYPE;
1226+
BEGIN
1227+
NULL;
1228+
END;
1229+
$$`;
1230+
1231+
await testUtils.expectAstMatch('uppercase rowtype/type refs', sql);
1232+
1233+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
1234+
const deparsed = deparseSync(parsed);
1235+
expect(deparsed).toMatchSnapshot();
1236+
expect(deparsed).toMatch(/myschema\.users%ROWTYPE/i);
1237+
expect(deparsed).not.toContain('"users%ROWTYPE"');
1238+
expect(deparsed).not.toContain('"name%TYPE"');
1239+
});
1240+
});
12171241
});

‎packages/plpgsql-deparser/__tests__/hydrate.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,47 @@ $$`;
274274
expect(deparsedBody).not.toContain('old-schema');
275275
});
276276

277+
it('should preserve array bounds when dehydrating modified type-name nodes', () => {
278+
const sql = `CREATE FUNCTION test_func() RETURNS void
279+
LANGUAGE plpgsql
280+
AS $$
281+
DECLARE
282+
v_items "old-schema".mytype[];
283+
v_item "old-schema".mytype;
284+
BEGIN
285+
NULL;
286+
END;
287+
$$`;
288+
289+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
290+
const { ast: hydratedAst } = hydratePlpgsqlAst(parsed);
291+
292+
// Rename schema inside the hydrated TypeName AST nodes
293+
const renameTypeNames = (obj: any): void => {
294+
if (obj === null || typeof obj !== 'object') return;
295+
if ('PLpgSQL_type' in obj) {
296+
const typname = obj.PLpgSQL_type.typname;
297+
if (typname && typeof typname === 'object' && typname.kind === 'type-name') {
298+
for (const name of typname.typeNameNode?.names ?? []) {
299+
if (name?.String?.sval === 'old-schema') {
300+
name.String.sval = 'new_schema';
301+
}
302+
}
303+
}
304+
}
305+
for (const value of Object.values(obj)) renameTypeNames(value);
306+
};
307+
renameTypeNames(hydratedAst);
308+
309+
const dehydratedAst = dehydratePlpgsqlAst(hydratedAst);
310+
const deparsedBody = deparseSync(dehydratedAst);
311+
312+
// The renamed array type must keep its [] bounds
313+
expect(deparsedBody).toContain('new_schema.mytype[]');
314+
expect(deparsedBody).toContain('v_item new_schema.mytype;');
315+
expect(deparsedBody).not.toContain('old-schema');
316+
});
317+
277318
it('should deparse modified assign AST nodes (schema renaming in assignments)', () => {
278319
const sql = `CREATE FUNCTION test_func() RETURNS void
279320
LANGUAGE plpgsql

‎packages/plpgsql-deparser/src/hydrate.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -652,8 +652,11 @@ function deparseTypeNameNode(typeNameNode: Node, sqlDeparseOptions?: DeparserOpt
652652
}
653653
} as any;
654654
const deparsed = Deparser.deparse(wrappedStmt, sqlDeparseOptions);
655-
// Extract the type name from "SELECT NULL::typename"
656-
const match = deparsed.match(/SELECT\s+NULL::(.+)/i);
655+
// Extract the type name from "SELECT NULL::typename" or
656+
// "SELECT CAST(NULL AS typename)" depending on the deparser's cast style
657+
const match =
658+
deparsed.match(/SELECT\s+NULL::(.+)/i) ||
659+
deparsed.match(/^SELECT\s+CAST\(NULL\s+AS\s+(.+)\)[\s;]*$/i);
657660
if (match) {
658661
return match[1].trim().replace(/;$/, '');
659662
}

‎packages/plpgsql-deparser/src/plpgsql-deparser.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -747,16 +747,17 @@ export class PLpgSQLDeparser {
747747
private deparseType(typeNode: PLpgSQLTypeNode): string {
748748
if ('PLpgSQL_type' in typeNode) {
749749
let typname = typeNode.PLpgSQL_type.typname;
750+
const isRowOrTypeRef = /%(rowtype|type)/i.test(typname);
750751

751752
// Strip pg_catalog. prefix for built-in types, but preserve schema qualification
752753
// for %rowtype and %type references where the schema is part of the table/variable reference
753-
if (!typname.includes('%rowtype') && !typname.includes('%type')) {
754+
if (!isRowOrTypeRef) {
754755
typname = typname.replace(/^"?pg_catalog"?\./, '');
755756
}
756757

757758
// For %rowtype and %type references, preserve as-is after stripping quotes
758759
// These are special PL/pgSQL type references that shouldn't be re-quoted
759-
if (typname.includes('%rowtype') || typname.includes('%type')) {
760+
if (isRowOrTypeRef) {
760761
// Strip quotes and return as-is
761762
return typname.replace(/"/g, '').trim();
762763
}

0 commit comments

Comments
 (0)