Skip to content

Commit db65dd3

Browse files
authored
Merge pull request #275 from constructive-io/devin/1767855697-plpgsql-type-hydration
feat(plpgsql-deparser): add PLpgSQL_type hydration support
2 parents 65aeb17 + 42ac2e2 commit db65dd3

File tree

5 files changed

+224
-3
lines changed

5 files changed

+224
-3
lines changed

packages/plpgsql-deparser/__tests__/schema-rename-mapped.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,15 @@ describe('schema rename mapped', () => {
213213
}
214214

215215
// Handle PLpgSQL_type nodes (variable type declarations)
216+
// With hydration, the typname is now a HydratedTypeName object with a typeNameNode
217+
// that can be transformed using the SQL AST visitor
216218
if ('PLpgSQL_type' in node) {
217219
const plType = node.PLpgSQL_type;
218-
if (plType.typname) {
220+
if (plType.typname && typeof plType.typname === 'object' && plType.typname.kind === 'type-name') {
221+
// Transform the TypeName AST node using the SQL visitor
222+
collectAndTransformSqlAst(plType.typname.typeNameNode, schemaRenameMap, `${location}.PLpgSQL_type.typname`);
223+
} else if (plType.typname && typeof plType.typname === 'string') {
224+
// Fallback for non-hydrated typnames (simple types without schema qualification)
219225
for (const oldSchema of Object.keys(schemaRenameMap)) {
220226
if (plType.typname.startsWith(oldSchema + '.')) {
221227
const typeName = plType.typname.substring(oldSchema.length + 1);

packages/plpgsql-deparser/__tests__/schema-transform.demo.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,15 @@ describe('schema transform demo', () => {
158158
}
159159

160160
// Handle PLpgSQL_type nodes (variable type declarations)
161+
// With hydration, the typname is now a HydratedTypeName object with a typeNameNode
162+
// that can be transformed using the SQL AST visitor
161163
if ('PLpgSQL_type' in node) {
162164
const plType = node.PLpgSQL_type;
163-
if (plType.typname && plType.typname.startsWith(oldSchema + '.')) {
165+
if (plType.typname && typeof plType.typname === 'object' && plType.typname.kind === 'type-name') {
166+
// Transform the TypeName AST node using the SQL visitor
167+
transformSchemaInSqlAst(plType.typname.typeNameNode, oldSchema, newSchema);
168+
} else if (plType.typname && typeof plType.typname === 'string' && plType.typname.startsWith(oldSchema + '.')) {
169+
// Fallback for non-hydrated typnames (simple types without schema qualification)
164170
plType.typname = plType.typname.replace(oldSchema + '.', newSchema + '.');
165171
}
166172
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ export type HydratedExprQuery =
4747
| HydratedExprSqlExpr
4848
| HydratedExprAssign;
4949

50+
/**
51+
* Hydrated PLpgSQL_type typname field.
52+
* The typname string (e.g., "schema.typename") is parsed into a TypeName AST node.
53+
*/
54+
export interface HydratedTypeName {
55+
kind: 'type-name';
56+
/** The original typname string */
57+
original: string;
58+
/** The parsed TypeName AST node (from parsing SELECT NULL::typename) */
59+
typeNameNode: Node;
60+
/** Optional suffix like %rowtype or %type that was stripped before parsing */
61+
suffix?: string;
62+
}
63+
5064
export interface HydratedPLpgSQL_expr {
5165
query: HydratedExprQuery;
5266
}
@@ -77,4 +91,6 @@ export interface HydrationStats {
7791
assignmentExpressions: number;
7892
sqlExpressions: number;
7993
rawExpressions: number;
94+
/** Number of PLpgSQL_type nodes with hydrated typname */
95+
typeNameExpressions: number;
8096
}

packages/plpgsql-deparser/src/hydrate.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
HydratedExprSqlExpr,
88
HydratedExprSqlStmt,
99
HydratedExprAssign,
10+
HydratedTypeName,
1011
HydrationOptions,
1112
HydrationResult,
1213
HydrationError,
@@ -54,6 +55,7 @@ export function hydratePlpgsqlAst(
5455
assignmentExpressions: 0,
5556
sqlExpressions: 0,
5657
rawExpressions: 0,
58+
typeNameExpressions: 0,
5759
};
5860

5961
const hydratedAst = hydrateNode(ast, '', opts, errors, stats);
@@ -107,13 +109,120 @@ function hydrateNode(
107109
};
108110
}
109111

112+
// Handle PLpgSQL_type nodes (variable type declarations)
113+
// Parse the typname string into a TypeName AST node
114+
if ('PLpgSQL_type' in node) {
115+
const plType = node.PLpgSQL_type;
116+
if (plType.typname && typeof plType.typname === 'string') {
117+
const hydratedTypename = hydrateTypeName(
118+
plType.typname,
119+
`${path}.PLpgSQL_type.typname`,
120+
errors,
121+
stats
122+
);
123+
124+
return {
125+
PLpgSQL_type: {
126+
...plType,
127+
typname: hydratedTypename,
128+
},
129+
};
130+
}
131+
}
132+
110133
const result: any = {};
111134
for (const [key, value] of Object.entries(node)) {
112135
result[key] = hydrateNode(value, `${path}.${key}`, options, errors, stats);
113136
}
114137
return result;
115138
}
116139

140+
/**
141+
* Extract the TypeName node from a parsed cast expression.
142+
* Given a parse result from "SELECT NULL::typename", extracts the TypeName node.
143+
*/
144+
function extractTypeNameFromCast(result: ParseResult): Node | undefined {
145+
const stmt = result.stmts?.[0]?.stmt as any;
146+
if (stmt?.SelectStmt?.targetList?.[0]?.ResTarget?.val?.TypeCast?.typeName) {
147+
return stmt.SelectStmt.targetList[0].ResTarget.val.TypeCast.typeName;
148+
}
149+
return undefined;
150+
}
151+
152+
/**
153+
* Hydrate a PLpgSQL_type typname string into a HydratedTypeName.
154+
*
155+
* Parses the typname string (e.g., "schema.typename") into a TypeName AST node
156+
* by wrapping it in a cast expression: SELECT NULL::typename
157+
*
158+
* Handles special suffixes like %rowtype and %type by stripping them before
159+
* parsing and preserving them in the result.
160+
*/
161+
function hydrateTypeName(
162+
typname: string,
163+
path: string,
164+
errors: HydrationError[],
165+
stats: HydrationStats
166+
): HydratedTypeName | string {
167+
// Handle %rowtype and %type suffixes - these can't be parsed as SQL types
168+
let suffix: string | undefined;
169+
let baseTypname = typname;
170+
171+
const suffixMatch = typname.match(/(%rowtype|%type)$/i);
172+
if (suffixMatch) {
173+
suffix = suffixMatch[1];
174+
baseTypname = typname.substring(0, typname.length - suffix.length);
175+
}
176+
177+
// Check if this is a schema-qualified type (contains a dot)
178+
// We need to be careful with quoted identifiers - "schema".type or schema."type"
179+
// A simple heuristic: if there's a dot not inside quotes, it's schema-qualified
180+
const hasSchemaQualification = /^[^"]*\.|"[^"]*"\./i.test(baseTypname);
181+
182+
// Skip hydration for simple built-in types without schema qualification
183+
// These don't benefit from AST transformation
184+
if (!hasSchemaQualification) {
185+
return typname;
186+
}
187+
188+
// Remove pg_catalog prefix for built-in types (but only if no suffix)
189+
let parseTypname = baseTypname;
190+
if (!suffix) {
191+
parseTypname = parseTypname.replace(/^pg_catalog\./, '');
192+
}
193+
194+
try {
195+
// Parse the type name by wrapping it in a cast expression
196+
// Keep quotes intact for proper parsing of special identifiers
197+
const sql = `SELECT NULL::${parseTypname}`;
198+
const parseResult = parseSync(sql);
199+
const typeNameNode = extractTypeNameFromCast(parseResult);
200+
201+
if (typeNameNode) {
202+
stats.typeNameExpressions++;
203+
return {
204+
kind: 'type-name',
205+
original: typname,
206+
typeNameNode,
207+
suffix,
208+
};
209+
}
210+
211+
// If we couldn't extract the TypeName, throw to trigger error handling
212+
throw new Error('Could not extract TypeName from cast expression');
213+
} catch (err) {
214+
// If parsing fails, record the error and throw
215+
const error: HydrationError = {
216+
path,
217+
original: typname,
218+
parseMode: ParseMode.RAW_PARSE_TYPE_NAME,
219+
error: err instanceof Error ? err.message : String(err),
220+
};
221+
errors.push(error);
222+
throw new Error(`Failed to hydrate PLpgSQL_type typname "${typname}": ${error.error}`);
223+
}
224+
}
225+
117226
function hydrateExpression(
118227
query: string | HydratedExprQuery,
119228
parseMode: number,
@@ -404,6 +513,18 @@ export function isHydratedExpr(query: any): query is HydratedExprQuery {
404513
);
405514
}
406515

516+
/**
517+
* Check if a typname value is a hydrated type name object.
518+
*/
519+
export function isHydratedTypeName(typname: any): typname is HydratedTypeName {
520+
return Boolean(
521+
typname &&
522+
typeof typname === 'object' &&
523+
'kind' in typname &&
524+
typname.kind === 'type-name'
525+
);
526+
}
527+
407528
export function getOriginalQuery(query: string | HydratedExprQuery): string {
408529
if (typeof query === 'string') {
409530
return query;
@@ -449,6 +570,28 @@ function dehydrateNode(node: any, options?: DehydrationOptions): any {
449570
};
450571
}
451572

573+
// Handle PLpgSQL_type nodes with hydrated typname
574+
if ('PLpgSQL_type' in node) {
575+
const plType = node.PLpgSQL_type;
576+
const typname = plType.typname;
577+
578+
let dehydratedTypname: string;
579+
if (typeof typname === 'string') {
580+
dehydratedTypname = typname;
581+
} else if (isHydratedTypeName(typname)) {
582+
dehydratedTypname = dehydrateTypeName(typname, options?.sqlDeparseOptions);
583+
} else {
584+
dehydratedTypname = String(typname);
585+
}
586+
587+
return {
588+
PLpgSQL_type: {
589+
...plType,
590+
typname: dehydratedTypname,
591+
},
592+
};
593+
}
594+
452595
const result: any = {};
453596
for (const [key, value] of Object.entries(node)) {
454597
result[key] = dehydrateNode(value, options);
@@ -483,6 +626,56 @@ function deparseExprNode(expr: Node, sqlDeparseOptions?: DeparserOptions): strin
483626
}
484627
}
485628

629+
/**
630+
* Deparse a TypeName AST node back to a string.
631+
* Wraps the TypeName in a cast expression, deparses, and extracts the type name.
632+
*/
633+
function deparseTypeNameNode(typeNameNode: Node, sqlDeparseOptions?: DeparserOptions): string | null {
634+
try {
635+
// Wrap the TypeName in a cast expression: SELECT NULL::typename
636+
// We use 'as any' because the Node type is a union type and we know
637+
// this is specifically a TypeName node from extractTypeNameFromCast
638+
const wrappedStmt = {
639+
SelectStmt: {
640+
targetList: [
641+
{
642+
ResTarget: {
643+
val: {
644+
TypeCast: {
645+
arg: { A_Const: { isnull: true } },
646+
typeName: typeNameNode as any
647+
}
648+
}
649+
}
650+
}
651+
]
652+
}
653+
} as any;
654+
const deparsed = Deparser.deparse(wrappedStmt, sqlDeparseOptions);
655+
// Extract the type name from "SELECT NULL::typename"
656+
const match = deparsed.match(/SELECT\s+NULL::(.+)/i);
657+
if (match) {
658+
return match[1].trim().replace(/;$/, '');
659+
}
660+
return null;
661+
} catch {
662+
return null;
663+
}
664+
}
665+
666+
/**
667+
* Dehydrate a HydratedTypeName back to a string.
668+
* Deparses the TypeName AST node and appends any suffix (%rowtype, %type).
669+
*/
670+
function dehydrateTypeName(typname: HydratedTypeName, sqlDeparseOptions?: DeparserOptions): string {
671+
const deparsed = deparseTypeNameNode(typname.typeNameNode, sqlDeparseOptions);
672+
if (deparsed !== null) {
673+
return deparsed + (typname.suffix || '');
674+
}
675+
// Fall back to original if deparse fails
676+
return typname.original;
677+
}
678+
486679
/**
487680
* Normalize whitespace for comparison purposes.
488681
* This helps detect if a string field was modified vs just having different formatting.

packages/plpgsql-deparser/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ export const deparseFunction = async (
2121
export { PLpgSQLDeparser, PLpgSQLDeparserOptions, ReturnInfo, ReturnInfoKind };
2222
export * from './types';
2323
export * from './hydrate-types';
24-
export { hydratePlpgsqlAst, dehydratePlpgsqlAst, isHydratedExpr, getOriginalQuery, DehydrationOptions } from './hydrate';
24+
export { hydratePlpgsqlAst, dehydratePlpgsqlAst, isHydratedExpr, isHydratedTypeName, getOriginalQuery, DehydrationOptions } from './hydrate';

0 commit comments

Comments
 (0)