|
7 | 7 | HydratedExprSqlExpr, |
8 | 8 | HydratedExprSqlStmt, |
9 | 9 | HydratedExprAssign, |
| 10 | + HydratedTypeName, |
10 | 11 | HydrationOptions, |
11 | 12 | HydrationResult, |
12 | 13 | HydrationError, |
@@ -54,6 +55,7 @@ export function hydratePlpgsqlAst( |
54 | 55 | assignmentExpressions: 0, |
55 | 56 | sqlExpressions: 0, |
56 | 57 | rawExpressions: 0, |
| 58 | + typeNameExpressions: 0, |
57 | 59 | }; |
58 | 60 |
|
59 | 61 | const hydratedAst = hydrateNode(ast, '', opts, errors, stats); |
@@ -107,13 +109,120 @@ function hydrateNode( |
107 | 109 | }; |
108 | 110 | } |
109 | 111 |
|
| 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 | + |
110 | 133 | const result: any = {}; |
111 | 134 | for (const [key, value] of Object.entries(node)) { |
112 | 135 | result[key] = hydrateNode(value, `${path}.${key}`, options, errors, stats); |
113 | 136 | } |
114 | 137 | return result; |
115 | 138 | } |
116 | 139 |
|
| 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 | + |
117 | 226 | function hydrateExpression( |
118 | 227 | query: string | HydratedExprQuery, |
119 | 228 | parseMode: number, |
@@ -404,6 +513,18 @@ export function isHydratedExpr(query: any): query is HydratedExprQuery { |
404 | 513 | ); |
405 | 514 | } |
406 | 515 |
|
| 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 | + |
407 | 528 | export function getOriginalQuery(query: string | HydratedExprQuery): string { |
408 | 529 | if (typeof query === 'string') { |
409 | 530 | return query; |
@@ -449,6 +570,28 @@ function dehydrateNode(node: any, options?: DehydrationOptions): any { |
449 | 570 | }; |
450 | 571 | } |
451 | 572 |
|
| 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 | + |
452 | 595 | const result: any = {}; |
453 | 596 | for (const [key, value] of Object.entries(node)) { |
454 | 597 | result[key] = dehydrateNode(value, options); |
@@ -483,6 +626,56 @@ function deparseExprNode(expr: Node, sqlDeparseOptions?: DeparserOptions): strin |
483 | 626 | } |
484 | 627 | } |
485 | 628 |
|
| 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 | + |
486 | 679 | /** |
487 | 680 | * Normalize whitespace for comparison purposes. |
488 | 681 | * This helps detect if a string field was modified vs just having different formatting. |
|
0 commit comments