Skip to content

Commit a5944ab

Browse files
authored
Merge pull request #271 from constructive-io/devin/1767751849-fix-nested-declare
fix(plpgsql-deparser): support nested DECLARE blocks inside FOR loops
2 parents 5d0c68e + 4029221 commit a5944ab

File tree

2 files changed

+257
-18
lines changed

2 files changed

+257
-18
lines changed

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ describe('PLpgSQLDeparser', () => {
3232
});
3333

3434
describe('round-trip tests using generated.json', () => {
35-
// Known failing fixtures due to pre-existing deparser issues:
36-
// TODO: Fix these underlying issues and remove from allowlist
37-
// Remaining known failing fixtures:
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)
41-
const KNOWN_FAILING_FIXTURES = new Set([
42-
'plpgsql_varprops-13.sql',
35+
// All 190 fixtures now pass round-trip testing!
36+
// Previously failing fixtures that have been fixed:
37+
// - Schema qualification (pg_catalog prefix preserved)
38+
// - Exception block handling
39+
// - Cursor FOR loops
40+
// - Labeled blocks with EXIT statements
41+
// - Nested DECLARE inside FOR loops (lineno-based scope tracking)
42+
const KNOWN_FAILING_FIXTURES = new Set<string>([
43+
// No known failures - all fixtures pass!
4344
]);
4445

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

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

Lines changed: 248 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export interface PLpgSQLDeparserContext {
8282
options: PLpgSQLDeparserOptions;
8383
datums?: PLpgSQLDatum[];
8484
returnInfo?: ReturnInfo;
85+
/** Set of linenos for loop-introduced variables (to exclude from DECLARE) */
86+
loopVarLinenos?: Set<number>;
87+
/** Map of block lineno to the set of datum indices that belong to that block */
88+
blockDatumMap?: Map<number, Set<number>>;
8589
}
8690

8791
/**
@@ -154,11 +158,30 @@ export class PLpgSQLDeparser {
154158
* @param returnInfo - Optional return type info for correct RETURN statement handling
155159
*/
156160
deparseFunction(func: PLpgSQL_function, returnInfo?: ReturnInfo): string {
161+
// Collect loop-introduced variables before generating DECLARE section
162+
const loopVarLinenos = new Set<number>();
163+
if (func.action) {
164+
this.collectLoopVariables(func.action, loopVarLinenos);
165+
}
166+
167+
// Build the block-to-datum mapping for nested DECLARE sections
168+
const blockDatumMap = this.buildBlockDatumMap(func.datums, func.action, loopVarLinenos);
169+
170+
// Collect all datum indices that belong to nested blocks (to exclude from top-level)
171+
const nestedDatumIndices = new Set<number>();
172+
for (const indices of blockDatumMap.values()) {
173+
for (const idx of indices) {
174+
nestedDatumIndices.add(idx);
175+
}
176+
}
177+
157178
const context: PLpgSQLDeparserContext = {
158179
indentLevel: 0,
159180
options: this.options,
160181
datums: func.datums,
161182
returnInfo,
183+
loopVarLinenos,
184+
blockDatumMap,
162185
};
163186

164187
const parts: string[] = [];
@@ -175,14 +198,14 @@ export class PLpgSQLDeparser {
175198
parts.push(`<<${blockLabel}>>`);
176199
}
177200

178-
// Collect loop-introduced variables before generating DECLARE section
179-
const loopVarLinenos = new Set<number>();
180-
if (func.action) {
181-
this.collectLoopVariables(func.action, loopVarLinenos);
182-
}
183-
184-
// Deparse DECLARE section (local variables, excluding loop variables)
185-
const declareSection = this.deparseDeclareSection(func.datums, context, loopVarLinenos);
201+
// Deparse DECLARE section (local variables, excluding loop variables and nested block variables)
202+
const declareSection = this.deparseDeclareSection(
203+
func.datums,
204+
context,
205+
loopVarLinenos,
206+
undefined, // includedIndices - not used for top-level
207+
nestedDatumIndices // excludedIndices - exclude datums that belong to nested blocks
208+
);
186209
if (declareSection) {
187210
parts.push(declareSection);
188211
}
@@ -339,20 +362,221 @@ export class PLpgSQLDeparser {
339362
}
340363
}
341364

365+
/**
366+
* Build a mapping of block linenos to the datum indices that belong to each block.
367+
* This is used to emit DECLARE sections at the correct nesting level.
368+
*
369+
* The algorithm:
370+
* 1. Get the top-level block's lineno (the BEGIN line of the function body)
371+
* 2. Collect all nested PLpgSQL_stmt_block linenos from the AST
372+
* 3. For each datum with a lineno GREATER than the top-level block's lineno:
373+
* - Assign it to the nested block whose lineno is the smallest value greater than the datum's lineno
374+
* 4. Datums with lineno <= top-level block lineno belong to the top-level DECLARE (not added to map)
375+
*/
376+
private buildBlockDatumMap(
377+
datums: PLpgSQLDatum[] | undefined,
378+
action: PLpgSQLStmtNode | undefined,
379+
loopVarLinenos: Set<number>
380+
): Map<number, Set<number>> {
381+
const blockDatumMap = new Map<number, Set<number>>();
382+
383+
if (!datums || !action) {
384+
return blockDatumMap;
385+
}
386+
387+
// Get the top-level block's lineno
388+
let topLevelBlockLineno: number | undefined;
389+
if ('PLpgSQL_stmt_block' in action) {
390+
topLevelBlockLineno = action.PLpgSQL_stmt_block.lineno;
391+
}
392+
393+
// Collect all nested block linenos (excluding the top-level block)
394+
const nestedBlockLinenos: number[] = [];
395+
this.collectNestedBlockLinenos(action, nestedBlockLinenos, true);
396+
nestedBlockLinenos.sort((a, b) => a - b);
397+
398+
// For each datum, find which block it belongs to
399+
datums.forEach((datum, index) => {
400+
let lineno: number | undefined;
401+
402+
if ('PLpgSQL_var' in datum) {
403+
lineno = datum.PLpgSQL_var.lineno;
404+
} else if ('PLpgSQL_rec' in datum) {
405+
lineno = datum.PLpgSQL_rec.lineno;
406+
} else if ('PLpgSQL_row' in datum) {
407+
lineno = datum.PLpgSQL_row.lineno;
408+
}
409+
410+
// Skip datums without lineno or loop variables
411+
if (lineno === undefined || loopVarLinenos.has(lineno)) {
412+
return;
413+
}
414+
415+
// Only consider datums declared AFTER the top-level BEGIN for nested blocks
416+
// Datums declared before the top-level BEGIN belong to the top-level DECLARE
417+
// If topLevelBlockLineno is undefined, we can't determine scope, so keep all at top-level
418+
if (topLevelBlockLineno === undefined || lineno <= topLevelBlockLineno) {
419+
return; // This datum belongs to top-level DECLARE
420+
}
421+
422+
// Find the block this datum belongs to (the next BEGIN after the datum's lineno)
423+
for (const blockLineno of nestedBlockLinenos) {
424+
if (blockLineno > lineno) {
425+
// This datum belongs to this block
426+
if (!blockDatumMap.has(blockLineno)) {
427+
blockDatumMap.set(blockLineno, new Set());
428+
}
429+
blockDatumMap.get(blockLineno)!.add(index);
430+
return;
431+
}
432+
}
433+
// If no nested block found, datum belongs to top-level (not added to map)
434+
});
435+
436+
return blockDatumMap;
437+
}
438+
439+
/**
440+
* Collect linenos of all nested PLpgSQL_stmt_block nodes in the AST.
441+
* @param isTopLevel - If true, skip the current block (it's the top-level block)
442+
*/
443+
private collectNestedBlockLinenos(
444+
stmt: PLpgSQLStmtNode,
445+
linenos: number[],
446+
isTopLevel: boolean = false
447+
): void {
448+
if ('PLpgSQL_stmt_block' in stmt) {
449+
const block = stmt.PLpgSQL_stmt_block;
450+
// Only add nested blocks, not the top-level block
451+
if (!isTopLevel && block.lineno !== undefined) {
452+
linenos.push(block.lineno);
453+
}
454+
if (block.body) {
455+
for (const s of block.body) {
456+
this.collectNestedBlockLinenos(s, linenos, false);
457+
}
458+
}
459+
} else if ('PLpgSQL_stmt_fori' in stmt) {
460+
const fori = stmt.PLpgSQL_stmt_fori;
461+
if (fori.body) {
462+
for (const s of fori.body) {
463+
this.collectNestedBlockLinenos(s, linenos, false);
464+
}
465+
}
466+
} else if ('PLpgSQL_stmt_fors' in stmt) {
467+
const fors = stmt.PLpgSQL_stmt_fors;
468+
if (fors.body) {
469+
for (const s of fors.body) {
470+
this.collectNestedBlockLinenos(s, linenos, false);
471+
}
472+
}
473+
} else if ('PLpgSQL_stmt_forc' in stmt) {
474+
const forc = stmt.PLpgSQL_stmt_forc;
475+
if (forc.body) {
476+
for (const s of forc.body) {
477+
this.collectNestedBlockLinenos(s, linenos, false);
478+
}
479+
}
480+
} else if ('PLpgSQL_stmt_foreach_a' in stmt) {
481+
const foreach = stmt.PLpgSQL_stmt_foreach_a;
482+
if (foreach.body) {
483+
for (const s of foreach.body) {
484+
this.collectNestedBlockLinenos(s, linenos, false);
485+
}
486+
}
487+
} else if ('PLpgSQL_stmt_loop' in stmt) {
488+
const loop = stmt.PLpgSQL_stmt_loop;
489+
if (loop.body) {
490+
for (const s of loop.body) {
491+
this.collectNestedBlockLinenos(s, linenos, false);
492+
}
493+
}
494+
} else if ('PLpgSQL_stmt_while' in stmt) {
495+
const whileStmt = stmt.PLpgSQL_stmt_while;
496+
if (whileStmt.body) {
497+
for (const s of whileStmt.body) {
498+
this.collectNestedBlockLinenos(s, linenos, false);
499+
}
500+
}
501+
} else if ('PLpgSQL_stmt_if' in stmt) {
502+
const ifStmt = stmt.PLpgSQL_stmt_if;
503+
if (ifStmt.then_body) {
504+
for (const s of ifStmt.then_body) {
505+
this.collectNestedBlockLinenos(s, linenos, false);
506+
}
507+
}
508+
if (ifStmt.elsif_list) {
509+
for (const elsif of ifStmt.elsif_list) {
510+
if ('PLpgSQL_if_elsif' in elsif && elsif.PLpgSQL_if_elsif.stmts) {
511+
for (const s of elsif.PLpgSQL_if_elsif.stmts) {
512+
this.collectNestedBlockLinenos(s, linenos, false);
513+
}
514+
}
515+
}
516+
}
517+
if (ifStmt.else_body) {
518+
for (const s of ifStmt.else_body) {
519+
this.collectNestedBlockLinenos(s, linenos, false);
520+
}
521+
}
522+
} else if ('PLpgSQL_stmt_case' in stmt) {
523+
const caseStmt = stmt.PLpgSQL_stmt_case;
524+
if (caseStmt.case_when_list) {
525+
for (const when of caseStmt.case_when_list) {
526+
if ('PLpgSQL_case_when' in when && when.PLpgSQL_case_when.stmts) {
527+
for (const s of when.PLpgSQL_case_when.stmts) {
528+
this.collectNestedBlockLinenos(s, linenos, false);
529+
}
530+
}
531+
}
532+
}
533+
if (caseStmt.have_else && caseStmt.else_stmts) {
534+
for (const s of caseStmt.else_stmts) {
535+
this.collectNestedBlockLinenos(s, linenos, false);
536+
}
537+
}
538+
} else if ('PLpgSQL_stmt_dynfors' in stmt) {
539+
const dynfors = stmt.PLpgSQL_stmt_dynfors;
540+
if (dynfors.body) {
541+
for (const s of dynfors.body) {
542+
this.collectNestedBlockLinenos(s, linenos, false);
543+
}
544+
}
545+
}
546+
}
547+
342548
/**
343549
* Deparse the DECLARE section
550+
* @param datums - All datums from the function
551+
* @param context - Deparser context
552+
* @param loopVarLinenos - Set of linenos for loop-introduced variables to exclude
553+
* @param includedIndices - Optional set of datum indices to include (for nested blocks).
554+
* If provided, only datums at these indices are included.
555+
* @param excludedIndices - Optional set of datum indices to exclude (for top-level).
556+
* If provided, datums at these indices are excluded.
344557
*/
345558
private deparseDeclareSection(
346559
datums: PLpgSQLDatum[] | undefined,
347560
context: PLpgSQLDeparserContext,
348-
loopVarLinenos: Set<number> = new Set()
561+
loopVarLinenos: Set<number> = new Set(),
562+
includedIndices?: Set<number>,
563+
excludedIndices?: Set<number>
349564
): string {
350565
if (!datums || datums.length === 0) {
351566
return '';
352567
}
353568

354569
// Filter out internal variables (like 'found', parameters, etc.) and loop variables
355-
const localVars = datums.filter(datum => {
570+
const localVars = datums.filter((datum, index) => {
571+
// If includedIndices is provided, only include datums at those indices
572+
if (includedIndices !== undefined && !includedIndices.has(index)) {
573+
return false;
574+
}
575+
// If excludedIndices is provided, exclude datums at those indices
576+
if (excludedIndices !== undefined && excludedIndices.has(index)) {
577+
return false;
578+
}
579+
356580
if ('PLpgSQL_var' in datum) {
357581
const v = datum.PLpgSQL_var;
358582
// Skip internal variables:
@@ -580,6 +804,20 @@ export class PLpgSQLDeparser {
580804
parts.push(`<<${block.label}>>`);
581805
}
582806

807+
// Check if this block has any datums assigned to it (nested DECLARE)
808+
if (block.lineno !== undefined && context.blockDatumMap?.has(block.lineno)) {
809+
const includedIndices = context.blockDatumMap.get(block.lineno)!;
810+
const declareSection = this.deparseDeclareSection(
811+
context.datums,
812+
context,
813+
context.loopVarLinenos || new Set(),
814+
includedIndices
815+
);
816+
if (declareSection) {
817+
parts.push(declareSection);
818+
}
819+
}
820+
583821
parts.push(kw('BEGIN'));
584822

585823
// Body statements

0 commit comments

Comments
 (0)