Skip to content

Commit 5d0c68e

Browse files
authored
Merge pull request #270 from constructive-io/devin/1767750707-fix-remaining-fixtures
fix(plpgsql-deparser): fix 2 more failing fixtures (189/190 now pass)
2 parents 141de8d + 707e36e commit 5d0c68e

File tree

3 files changed

+68
-33
lines changed

3 files changed

+68
-33
lines changed

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

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,13 @@ describe('PLpgSQLDeparser', () => {
3333

3434
describe('round-trip tests using generated.json', () => {
3535
// Known failing fixtures due to pre-existing deparser issues:
36-
// - Schema qualification loss (pg_catalog.pg_class%rowtype[] -> pg_class%rowtype[])
37-
// - Tagged dollar quote reconstruction ($tag$...$tag$ not supported)
38-
// - Exception block handling issues
3936
// TODO: Fix these underlying issues and remove from allowlist
4037
// Remaining known failing fixtures:
41-
// - plpgsql_varprops-13.sql: nested DECLARE inside FOR loop (loop variable scope issue)
42-
// - plpgsql_transaction-17.sql: CURSOR FOR loop with EXCEPTION block
43-
// - plpgsql_control-15.sql: labeled block with EXIT statement
38+
// - plpgsql_varprops-13.sql: nested DECLARE inside FOR loop - variables declared inside
39+
// the loop body are hoisted to the top-level DECLARE section, changing semantics
40+
// (variables should be reinitialized on each loop iteration)
4441
const KNOWN_FAILING_FIXTURES = new Set([
4542
'plpgsql_varprops-13.sql',
46-
'plpgsql_transaction-17.sql',
47-
'plpgsql_control-15.sql',
4843
]);
4944

5045
it('should round-trip ALL generated fixtures (excluding known failures)', async () => {

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

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,18 @@ export class PLpgSQLDeparser {
163163

164164
const parts: string[] = [];
165165

166+
// Extract label from action block - it should come before DECLARE
167+
// In PL/pgSQL, the syntax is: <<label>> DECLARE ... BEGIN ... END label
168+
let blockLabel: string | undefined;
169+
if (func.action && 'PLpgSQL_stmt_block' in func.action) {
170+
blockLabel = func.action.PLpgSQL_stmt_block.label;
171+
}
172+
173+
// Output label before DECLARE if present
174+
if (blockLabel) {
175+
parts.push(`<<${blockLabel}>>`);
176+
}
177+
166178
// Collect loop-introduced variables before generating DECLARE section
167179
const loopVarLinenos = new Set<number>();
168180
if (func.action) {
@@ -176,8 +188,9 @@ export class PLpgSQLDeparser {
176188
}
177189

178190
// Deparse the action block (BEGIN...END)
191+
// Pass skipLabel=true since we already output the label
179192
if (func.action) {
180-
parts.push(this.deparseStmt(func.action, context));
193+
parts.push(this.deparseStmt(func.action, context, blockLabel ? true : false));
181194
}
182195

183196
return parts.join(this.options.newline);
@@ -417,6 +430,14 @@ export class PLpgSQLDeparser {
417430
parts.push(kw('CONSTANT'));
418431
}
419432

433+
// Handle cursor declarations - don't output the type for cursors
434+
// The syntax is: cursor_name CURSOR FOR query
435+
if (v.cursor_explicit_expr) {
436+
parts.push(kw('CURSOR FOR'));
437+
parts.push(this.deparseExpr(v.cursor_explicit_expr));
438+
return parts.join(' ');
439+
}
440+
420441
if (v.datatype) {
421442
parts.push(this.deparseType(v.datatype));
422443
}
@@ -430,12 +451,6 @@ export class PLpgSQLDeparser {
430451
parts.push(this.deparseExpr(v.default_val));
431452
}
432453

433-
// Handle cursor declarations
434-
if (v.cursor_explicit_expr) {
435-
parts.push(kw('CURSOR FOR'));
436-
parts.push(this.deparseExpr(v.cursor_explicit_expr));
437-
}
438-
439454
return parts.join(' ');
440455
}
441456

@@ -484,14 +499,15 @@ export class PLpgSQLDeparser {
484499

485500
/**
486501
* Deparse a statement node
502+
* @param skipLabel - If true, skip outputting the label (used when label is output before DECLARE)
487503
*/
488-
private deparseStmt(stmt: PLpgSQLStmtNode, context: PLpgSQLDeparserContext): string {
504+
private deparseStmt(stmt: PLpgSQLStmtNode, context: PLpgSQLDeparserContext, skipLabel?: boolean): string {
489505
const nodeType = Object.keys(stmt)[0];
490506
const nodeData = (stmt as any)[nodeType];
491507

492508
switch (nodeType) {
493509
case 'PLpgSQL_stmt_block':
494-
return this.deparseBlock(nodeData, context);
510+
return this.deparseBlock(nodeData, context, skipLabel);
495511
case 'PLpgSQL_stmt_assign':
496512
return this.deparseAssign(nodeData, context);
497513
case 'PLpgSQL_stmt_if':
@@ -553,13 +569,14 @@ export class PLpgSQLDeparser {
553569

554570
/**
555571
* Deparse a block statement (BEGIN...END)
572+
* @param skipLabel - If true, skip outputting the label (used when label is output before DECLARE)
556573
*/
557-
private deparseBlock(block: PLpgSQL_stmt_block, context: PLpgSQLDeparserContext): string {
574+
private deparseBlock(block: PLpgSQL_stmt_block, context: PLpgSQLDeparserContext, skipLabel?: boolean): string {
558575
const kw = this.keyword;
559576
const parts: string[] = [];
560577

561-
// Label
562-
if (block.label) {
578+
// Label - skip if already output before DECLARE
579+
if (block.label && !skipLabel) {
563580
parts.push(`<<${block.label}>>`);
564581
}
565582

@@ -1558,13 +1575,20 @@ export class PLpgSQLDeparser {
15581575
if ('PLpgSQL_row' in datum) {
15591576
const row = datum.PLpgSQL_row;
15601577
// If this is an "(unnamed row)" with fields, expand the fields to get actual variable names
1561-
if (row.refname === '(unnamed row)' && row.fields && row.fields.length > 0 && context?.datums) {
1578+
if (row.refname === '(unnamed row)' && row.fields && row.fields.length > 0) {
15621579
const fieldNames = row.fields.map(field => {
1580+
// If the field name contains a dot (qualified reference like lbl.a), use it directly
1581+
// This preserves block-qualified variable references
1582+
if (field.name && field.name.includes('.')) {
1583+
return field.name;
1584+
}
15631585
// Try to resolve the varno to get the actual variable name
1564-
const fieldDatum = context.datums[field.varno];
1565-
if (fieldDatum) {
1566-
// Recursively get the name, passing context to resolve parent records
1567-
return this.deparseDatumName(fieldDatum, context);
1586+
if (context?.datums) {
1587+
const fieldDatum = context.datums[field.varno];
1588+
if (fieldDatum) {
1589+
// Recursively get the name, passing context to resolve parent records
1590+
return this.deparseDatumName(fieldDatum, context);
1591+
}
15681592
}
15691593
// Fall back to the field name if we can't resolve the varno
15701594
return field.name;

packages/plpgsql-deparser/test-utils/index.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,15 +279,31 @@ function getErrorMessage(type: ParseErrorType): string {
279279
}
280280

281281
function extractBodyFromSql(sql: string): { body: string; prefix: string; suffix: string } | null {
282-
const dollarQuoteMatch = sql.match(/^([\s\S]*?\$\$)([\s\S]*?)(\$\$[\s\S]*)$/);
283-
if (dollarQuoteMatch) {
284-
return {
285-
prefix: dollarQuoteMatch[1],
286-
body: dollarQuoteMatch[2],
287-
suffix: dollarQuoteMatch[3],
288-
};
282+
// Match tagged dollar quotes like $proc$, $body$, $func$, etc. or plain $$
283+
// The tag is optional: $tag$ or $$
284+
// We need to find the FIRST dollar quote and match it with the LAST occurrence of the same tag
285+
const dollarQuoteStartMatch = sql.match(/(\$[\w]*\$)/);
286+
if (!dollarQuoteStartMatch) {
287+
return null;
289288
}
290-
return null;
289+
290+
const tag = dollarQuoteStartMatch[1];
291+
const escapedTag = tag.replace(/\$/g, '\\$');
292+
293+
// Find the first occurrence of the tag and the last occurrence
294+
const firstIndex = sql.indexOf(tag);
295+
const lastIndex = sql.lastIndexOf(tag);
296+
297+
if (firstIndex === lastIndex) {
298+
// Only one occurrence - can't extract body
299+
return null;
300+
}
301+
302+
return {
303+
prefix: sql.substring(0, firstIndex + tag.length),
304+
body: sql.substring(firstIndex + tag.length, lastIndex),
305+
suffix: sql.substring(lastIndex),
306+
};
291307
}
292308

293309
function reconstructSql(originalSql: string, newBody: string): string {

0 commit comments

Comments
 (0)