Skip to content

Commit c8e23a1

Browse files
committed
feat(plpgsql-deparser): add hydratePlpgsqlAst for parsing embedded SQL expressions
- Add hydrate-types.ts with discriminated union types for hydrated expressions: - HydratedExprRaw: fallback for unparseable expressions - HydratedExprSqlExpr: for SQL expressions parsed via SELECT wrapper - HydratedExprSqlStmt: for full SQL statements - HydratedExprAssign: for PL/pgSQL assignments with parsed target/value - Add hydrate.ts with hydratePlpgsqlAst() function that: - Walks the PL/pgSQL AST and parses each PLpgSQL_expr.query string - Handles parseMode 2 (expressions) by wrapping with SELECT - Handles parseMode 3 (assignments) by splitting on := using tokenizer - Returns enriched AST with parsed SQL nodes where possible - Tracks hydration stats and errors for debugging - Add utility functions: - isHydratedExpr(): type guard for hydrated expressions - getOriginalQuery(): extract original query string from hydrated or raw - Export new types and functions from package index - Add comprehensive tests for hydration functionality
1 parent 8e2786f commit c8e23a1

4 files changed

Lines changed: 615 additions & 0 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { loadModule, parsePlPgSQLSync } from '@libpg-query/parser';
2+
import { hydratePlpgsqlAst, isHydratedExpr, getOriginalQuery, PLpgSQLParseResult } from '../src';
3+
4+
describe('hydratePlpgsqlAst', () => {
5+
beforeAll(async () => {
6+
await loadModule();
7+
});
8+
9+
describe('basic hydration', () => {
10+
it('should hydrate a simple function with expressions', () => {
11+
const sql = `CREATE FUNCTION test_func(p_input integer) RETURNS integer
12+
LANGUAGE plpgsql
13+
AS $$
14+
BEGIN
15+
RETURN p_input * 2;
16+
END;
17+
$$`;
18+
19+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
20+
const result = hydratePlpgsqlAst(parsed);
21+
22+
expect(result.errors).toHaveLength(0);
23+
expect(result.stats.totalExpressions).toBeGreaterThan(0);
24+
expect(result.stats.parsedExpressions).toBeGreaterThan(0);
25+
});
26+
27+
it('should hydrate assignment expressions', () => {
28+
const sql = `CREATE FUNCTION test_func() RETURNS integer
29+
LANGUAGE plpgsql
30+
AS $$
31+
DECLARE
32+
v_result integer;
33+
BEGIN
34+
v_result := 10 + 20;
35+
RETURN v_result;
36+
END;
37+
$$`;
38+
39+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
40+
const result = hydratePlpgsqlAst(parsed);
41+
42+
expect(result.stats.assignmentExpressions).toBeGreaterThan(0);
43+
44+
const assignExpr = findExprByKind(result.ast, 'assign');
45+
expect(assignExpr).toBeDefined();
46+
if (assignExpr && assignExpr.kind === 'assign') {
47+
expect(assignExpr.target).toBe('v_result');
48+
expect(assignExpr.value).toBe('10 + 20');
49+
expect(assignExpr.valueExpr).toBeDefined();
50+
}
51+
});
52+
53+
it('should hydrate IF condition expressions', () => {
54+
const sql = `CREATE FUNCTION test_func(p_val integer) RETURNS text
55+
LANGUAGE plpgsql
56+
AS $$
57+
BEGIN
58+
IF p_val > 10 THEN
59+
RETURN 'large';
60+
END IF;
61+
RETURN 'small';
62+
END;
63+
$$`;
64+
65+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
66+
const result = hydratePlpgsqlAst(parsed);
67+
68+
expect(result.stats.sqlExpressions).toBeGreaterThan(0);
69+
70+
const sqlExpr = findExprByKind(result.ast, 'sql-expr');
71+
expect(sqlExpr).toBeDefined();
72+
if (sqlExpr && sqlExpr.kind === 'sql-expr') {
73+
expect(sqlExpr.original).toBe('p_val > 10');
74+
expect(sqlExpr.expr).toBeDefined();
75+
}
76+
});
77+
78+
it('should handle complex assignment targets', () => {
79+
const sql = `CREATE FUNCTION test_func() RETURNS void
80+
LANGUAGE plpgsql
81+
AS $$
82+
DECLARE
83+
r RECORD;
84+
arr integer[];
85+
BEGIN
86+
r.field := 10;
87+
arr[1] := 20;
88+
END;
89+
$$`;
90+
91+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
92+
const result = hydratePlpgsqlAst(parsed);
93+
94+
expect(result.stats.assignmentExpressions).toBeGreaterThanOrEqual(2);
95+
});
96+
});
97+
98+
describe('error handling', () => {
99+
it('should continue on parse errors with continueOnError option', () => {
100+
const sql = `CREATE FUNCTION test_func() RETURNS integer
101+
LANGUAGE plpgsql
102+
AS $$
103+
BEGIN
104+
RETURN 1;
105+
END;
106+
$$`;
107+
108+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
109+
const result = hydratePlpgsqlAst(parsed, { continueOnError: true });
110+
111+
expect(result.ast).toBeDefined();
112+
});
113+
});
114+
115+
describe('utility functions', () => {
116+
it('isHydratedExpr should identify hydrated expressions', () => {
117+
expect(isHydratedExpr({ kind: 'raw', original: 'test', parseMode: 2 })).toBe(true);
118+
expect(isHydratedExpr({ kind: 'sql-expr', original: 'test', parseMode: 2, expr: { ColumnRef: { fields: [] } } } as any)).toBe(true);
119+
expect(isHydratedExpr({ kind: 'assign', original: 'test', parseMode: 3, target: 'x', value: '1' })).toBe(true);
120+
expect(isHydratedExpr('string')).toBe(false);
121+
expect(isHydratedExpr(null)).toBe(false);
122+
});
123+
124+
it('getOriginalQuery should extract original query string', () => {
125+
expect(getOriginalQuery('test')).toBe('test');
126+
expect(getOriginalQuery({ kind: 'raw', original: 'original', parseMode: 2 })).toBe('original');
127+
expect(getOriginalQuery({ kind: 'sql-expr', original: 'expr', parseMode: 2, expr: { ColumnRef: { fields: [] } } } as any)).toBe('expr');
128+
});
129+
});
130+
131+
describe('hydration stats', () => {
132+
it('should track hydration statistics', () => {
133+
const sql = `CREATE FUNCTION test_func(p_val integer) RETURNS integer
134+
LANGUAGE plpgsql
135+
AS $$
136+
DECLARE
137+
v_result integer := 0;
138+
BEGIN
139+
v_result := p_val * 2;
140+
IF v_result > 100 THEN
141+
v_result := 100;
142+
END IF;
143+
RETURN v_result;
144+
END;
145+
$$`;
146+
147+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
148+
const result = hydratePlpgsqlAst(parsed);
149+
150+
expect(result.stats.totalExpressions).toBeGreaterThan(0);
151+
expect(result.stats.parsedExpressions + result.stats.failedExpressions + result.stats.rawExpressions)
152+
.toBe(result.stats.totalExpressions);
153+
});
154+
});
155+
});
156+
157+
function findExprByKind(obj: any, kind: string): any {
158+
if (obj === null || obj === undefined) return null;
159+
160+
if (typeof obj === 'object') {
161+
if ('PLpgSQL_expr' in obj) {
162+
const query = obj.PLpgSQL_expr.query;
163+
if (query && typeof query === 'object' && query.kind === kind) {
164+
return query;
165+
}
166+
}
167+
168+
for (const value of Object.values(obj)) {
169+
const found = findExprByKind(value, kind);
170+
if (found) return found;
171+
}
172+
}
173+
174+
if (Array.isArray(obj)) {
175+
for (const item of obj) {
176+
const found = findExprByKind(item, kind);
177+
if (found) return found;
178+
}
179+
}
180+
181+
return null;
182+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { ParseResult, Node } from '@pgsql/types';
2+
3+
export enum ParseMode {
4+
RAW_PARSE_DEFAULT = 0,
5+
RAW_PARSE_TYPE_NAME = 1,
6+
RAW_PARSE_PLPGSQL_EXPR = 2,
7+
RAW_PARSE_PLPGSQL_ASSIGN1 = 3,
8+
RAW_PARSE_PLPGSQL_ASSIGN2 = 4,
9+
RAW_PARSE_PLPGSQL_ASSIGN3 = 5,
10+
}
11+
12+
export interface HydratedExprRaw {
13+
kind: 'raw';
14+
original: string;
15+
parseMode: number;
16+
error?: string;
17+
}
18+
19+
export interface HydratedExprSqlStmt {
20+
kind: 'sql-stmt';
21+
original: string;
22+
parseMode: number;
23+
parseResult: ParseResult;
24+
}
25+
26+
export interface HydratedExprSqlExpr {
27+
kind: 'sql-expr';
28+
original: string;
29+
parseMode: number;
30+
expr: Node;
31+
}
32+
33+
export interface HydratedExprAssign {
34+
kind: 'assign';
35+
original: string;
36+
parseMode: number;
37+
target: string;
38+
targetExpr?: Node;
39+
value: string;
40+
valueExpr?: Node;
41+
error?: string;
42+
}
43+
44+
export type HydratedExprQuery =
45+
| HydratedExprRaw
46+
| HydratedExprSqlStmt
47+
| HydratedExprSqlExpr
48+
| HydratedExprAssign;
49+
50+
export interface HydratedPLpgSQL_expr {
51+
query: HydratedExprQuery;
52+
}
53+
54+
export interface HydrationOptions {
55+
parseExpressions?: boolean;
56+
parseAssignments?: boolean;
57+
continueOnError?: boolean;
58+
}
59+
60+
export interface HydrationResult<T> {
61+
ast: T;
62+
errors: HydrationError[];
63+
stats: HydrationStats;
64+
}
65+
66+
export interface HydrationError {
67+
path: string;
68+
original: string;
69+
parseMode: number;
70+
error: string;
71+
}
72+
73+
export interface HydrationStats {
74+
totalExpressions: number;
75+
parsedExpressions: number;
76+
failedExpressions: number;
77+
assignmentExpressions: number;
78+
sqlExpressions: number;
79+
rawExpressions: number;
80+
}

0 commit comments

Comments
 (0)