plpgsql-parse 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/body-scanner.d.ts +41 -0
- package/body-scanner.js +80 -0
- package/deparse.d.ts +22 -0
- package/deparse.js +577 -0
- package/esm/body-scanner.js +76 -0
- package/esm/deparse.js +573 -0
- package/esm/index.js +18 -0
- package/esm/parse.js +119 -0
- package/esm/return-info.js +62 -0
- package/esm/types.js +7 -0
- package/index.d.ts +17 -0
- package/index.js +31 -0
- package/package.json +54 -0
- package/parse.d.ts +25 -0
- package/parse.js +124 -0
- package/return-info.d.ts +17 -0
- package/return-info.js +66 -0
- package/types.d.ts +42 -0
- package/types.js +8 -0
package/deparse.js
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Enhanced deparse that re-injects body comments into PL/pgSQL function
|
|
4
|
+
* bodies after the standard plpgsql-deparser has produced its output.
|
|
5
|
+
*
|
|
6
|
+
* Also handles outer SQL comments/whitespace via pgsql-parse's deparser.
|
|
7
|
+
*
|
|
8
|
+
* This module does NOT modify plpgsql-deparser or pgsql-deparser.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.deparseSync = deparseSync;
|
|
12
|
+
exports.deparse = deparse;
|
|
13
|
+
const parser_1 = require("@libpg-query/parser");
|
|
14
|
+
const pgsql_parse_1 = require("pgsql-parse");
|
|
15
|
+
const pgsql_deparser_1 = require("pgsql-deparser");
|
|
16
|
+
const plpgsql_deparser_1 = require("plpgsql-deparser");
|
|
17
|
+
const body_scanner_1 = require("./body-scanner");
|
|
18
|
+
/**
|
|
19
|
+
* Deparse a PlpgsqlParseResult back to SQL, preserving both outer
|
|
20
|
+
* comments/whitespace AND comments inside PL/pgSQL function bodies.
|
|
21
|
+
*/
|
|
22
|
+
function deparseSync(result, options = {}) {
|
|
23
|
+
const { pretty = true, newline = '\n' } = options;
|
|
24
|
+
const enhanced = result.enhanced;
|
|
25
|
+
const functionMap = new Map();
|
|
26
|
+
for (const fn of result.functions) {
|
|
27
|
+
functionMap.set(fn.stmtIndex, fn);
|
|
28
|
+
}
|
|
29
|
+
const lines = [];
|
|
30
|
+
for (let i = 0; i < enhanced.stmts.length; i++) {
|
|
31
|
+
const entry = enhanced.stmts[i];
|
|
32
|
+
if ((0, pgsql_parse_1.isRawComment)(entry)) {
|
|
33
|
+
const commentText = `--${entry.RawComment.text}`;
|
|
34
|
+
if (entry.RawComment.trailing && lines.length > 0) {
|
|
35
|
+
lines[lines.length - 1] += ' ' + commentText;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
lines.push(commentText);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else if ((0, pgsql_parse_1.isRawWhitespace)(entry)) {
|
|
42
|
+
for (let b = 0; b < entry.RawWhitespace.lines; b++) {
|
|
43
|
+
lines.push('');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if ((0, pgsql_parse_1.isRawStmt)(entry)) {
|
|
47
|
+
const fnComments = functionMap.get(i);
|
|
48
|
+
if (fnComments) {
|
|
49
|
+
// This is a PL/pgSQL function — deparse with body comments
|
|
50
|
+
const sql = deparseWithBodyComments(entry, fnComments, pretty);
|
|
51
|
+
if (sql)
|
|
52
|
+
lines.push(sql);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// Standard SQL statement
|
|
56
|
+
const sql = pgsql_deparser_1.Deparser.deparse({ version: 0, stmts: [entry] }, { pretty });
|
|
57
|
+
if (sql)
|
|
58
|
+
lines.push(sql);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return lines.join(newline);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Async version of deparseSync.
|
|
66
|
+
*/
|
|
67
|
+
async function deparse(result, options = {}) {
|
|
68
|
+
return deparseSync(result, options);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Deparse a single PL/pgSQL CREATE FUNCTION statement, re-injecting
|
|
72
|
+
* body comments into the deparsed function body.
|
|
73
|
+
*/
|
|
74
|
+
function deparseWithBodyComments(rawStmt, fnComments, pretty) {
|
|
75
|
+
const stmt = rawStmt?.stmt;
|
|
76
|
+
if (!stmt)
|
|
77
|
+
return null;
|
|
78
|
+
const createFunctionStmt = stmt.CreateFunctionStmt;
|
|
79
|
+
if (!createFunctionStmt)
|
|
80
|
+
return null;
|
|
81
|
+
// Parse the PL/pgSQL body to get the AST
|
|
82
|
+
// We need the full CREATE FUNCTION SQL for parsePlPgSQL, but we
|
|
83
|
+
// only have the AST node. So we first deparse without comments
|
|
84
|
+
// to get valid SQL, then parse the PL/pgSQL body.
|
|
85
|
+
try {
|
|
86
|
+
// First, get a clean deparse of the statement to extract the body
|
|
87
|
+
const cleanSql = pgsql_deparser_1.Deparser.deparse({ version: 0, stmts: [rawStmt] }, { pretty });
|
|
88
|
+
if (!cleanSql)
|
|
89
|
+
return null;
|
|
90
|
+
// Parse PL/pgSQL from the clean SQL
|
|
91
|
+
const plpgsqlRaw = (0, parser_1.parsePlPgSQLSync)(cleanSql);
|
|
92
|
+
const { ast: hydrated } = (0, plpgsql_deparser_1.hydratePlpgsqlAst)(plpgsqlRaw);
|
|
93
|
+
const dehydrated = (0, plpgsql_deparser_1.dehydratePlpgsqlAst)(hydrated);
|
|
94
|
+
const plpgsqlFunc = dehydrated.plpgsql_funcs?.[0]?.PLpgSQL_function;
|
|
95
|
+
if (!plpgsqlFunc)
|
|
96
|
+
return cleanSql;
|
|
97
|
+
// Get return info for correct RETURN statement handling
|
|
98
|
+
const returnInfo = getReturnInfoForStmt(createFunctionStmt);
|
|
99
|
+
// Deparse the PL/pgSQL body
|
|
100
|
+
const deparsedBody = (0, plpgsql_deparser_1.deparseFunctionSync)(plpgsqlFunc, undefined, returnInfo);
|
|
101
|
+
// Re-inject comments into the deparsed body
|
|
102
|
+
const enhancedBody = reinjectBodyComments(fnComments.originalBody, deparsedBody, fnComments.comments, plpgsqlRaw);
|
|
103
|
+
// Stitch the enhanced body back into the AST
|
|
104
|
+
stitchBodyIntoAst(createFunctionStmt, enhancedBody);
|
|
105
|
+
// Deparse the full statement with the enhanced body
|
|
106
|
+
const result = pgsql_deparser_1.Deparser.deparse({ version: 0, stmts: [rawStmt] }, { pretty });
|
|
107
|
+
return result || null;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// If anything fails, fall back to standard deparse
|
|
111
|
+
return pgsql_deparser_1.Deparser.deparse({ version: 0, stmts: [rawStmt] }, { pretty }) || null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Re-inject comments into a deparsed PL/pgSQL function body.
|
|
116
|
+
*
|
|
117
|
+
* Strategy:
|
|
118
|
+
* 1. Collect statement linenos from the PL/pgSQL AST (DFS order)
|
|
119
|
+
* 2. Associate each comment with the next statement by lineno
|
|
120
|
+
* 3. Walk the deparsed output line-by-line, tracking statement indices
|
|
121
|
+
* 4. Insert comments before their associated statement's output
|
|
122
|
+
*
|
|
123
|
+
* Uses sequential keyword matching: the deparser emits statements in
|
|
124
|
+
* the same order as the AST's body arrays, so we match them in order.
|
|
125
|
+
*/
|
|
126
|
+
function reinjectBodyComments(originalBody, deparsedBody, comments, plpgsqlAst) {
|
|
127
|
+
if (comments.length === 0)
|
|
128
|
+
return deparsedBody;
|
|
129
|
+
// 1. Collect statement linenos from the AST
|
|
130
|
+
const stmtLinenos = collectStmtLinenos(plpgsqlAst);
|
|
131
|
+
// 2. Group comments by anchor statement
|
|
132
|
+
const groups = (0, body_scanner_1.groupCommentsByAnchor)(comments, stmtLinenos);
|
|
133
|
+
if (groups.length === 0)
|
|
134
|
+
return deparsedBody;
|
|
135
|
+
// 3. Build a map of anchorLineno → comment texts
|
|
136
|
+
const commentsByAnchor = new Map();
|
|
137
|
+
const trailingComments = [];
|
|
138
|
+
for (const group of groups) {
|
|
139
|
+
if (group.anchorLineno !== null) {
|
|
140
|
+
const existing = commentsByAnchor.get(group.anchorLineno) ?? [];
|
|
141
|
+
existing.push(...group.comments);
|
|
142
|
+
commentsByAnchor.set(group.anchorLineno, existing);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
trailingComments.push(...group.comments);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// 4. Walk the deparsed body and insert comments at statement boundaries
|
|
149
|
+
const depLines = deparsedBody.split('\n');
|
|
150
|
+
const result = [];
|
|
151
|
+
// Build an ordered list of (lineno, keyword) for matching
|
|
152
|
+
const stmtKeywords = buildStmtKeywords(plpgsqlAst);
|
|
153
|
+
let stmtKeyIdx = 0;
|
|
154
|
+
const usedAnchors = new Set();
|
|
155
|
+
for (const line of depLines) {
|
|
156
|
+
const trimmed = line.trim().toUpperCase();
|
|
157
|
+
// Try to match this line to the next expected statement
|
|
158
|
+
if (stmtKeyIdx < stmtKeywords.length) {
|
|
159
|
+
const { lineno, keywords } = stmtKeywords[stmtKeyIdx];
|
|
160
|
+
if (lineMatchesKeywords(trimmed, keywords)) {
|
|
161
|
+
// Insert comments anchored to this statement
|
|
162
|
+
if (commentsByAnchor.has(lineno) && !usedAnchors.has(lineno)) {
|
|
163
|
+
const indent = line.match(/^\s*/)?.[0] ?? '';
|
|
164
|
+
for (const commentText of commentsByAnchor.get(lineno)) {
|
|
165
|
+
result.push(indent + commentText);
|
|
166
|
+
}
|
|
167
|
+
usedAnchors.add(lineno);
|
|
168
|
+
}
|
|
169
|
+
stmtKeyIdx++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
result.push(line);
|
|
173
|
+
}
|
|
174
|
+
// Append any trailing comments before the final END
|
|
175
|
+
if (trailingComments.length > 0) {
|
|
176
|
+
// Find the last END line and insert before it
|
|
177
|
+
const lastEndIdx = findLastEndLine(result);
|
|
178
|
+
if (lastEndIdx >= 0) {
|
|
179
|
+
const indent = result[lastEndIdx].match(/^\s*/)?.[0] ?? ' ';
|
|
180
|
+
const commentLines = trailingComments.map(c => indent + c);
|
|
181
|
+
result.splice(lastEndIdx, 0, ...commentLines);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
for (const c of trailingComments) {
|
|
185
|
+
result.push(' ' + c);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result.join('\n');
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Collect all statement linenos from a PL/pgSQL parse result in DFS body order.
|
|
193
|
+
*/
|
|
194
|
+
function collectStmtLinenos(ast) {
|
|
195
|
+
const linenos = [];
|
|
196
|
+
if (!ast.plpgsql_funcs)
|
|
197
|
+
return linenos;
|
|
198
|
+
for (const funcNode of ast.plpgsql_funcs) {
|
|
199
|
+
if ('PLpgSQL_function' in funcNode) {
|
|
200
|
+
const func = funcNode.PLpgSQL_function;
|
|
201
|
+
if (func.action) {
|
|
202
|
+
collectStmtLinenosFromNode(func.action, linenos);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return linenos.sort((a, b) => a - b);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Recursively collect linenos from PL/pgSQL statement nodes.
|
|
210
|
+
*/
|
|
211
|
+
function collectStmtLinenosFromNode(stmtNode, linenos) {
|
|
212
|
+
const [kind, data] = Object.entries(stmtNode)[0];
|
|
213
|
+
if (data?.lineno !== undefined) {
|
|
214
|
+
linenos.push(data.lineno);
|
|
215
|
+
}
|
|
216
|
+
// Recurse into body arrays
|
|
217
|
+
const bodyKeys = ['body', 'then_body', 'else_body', 'else_stmts', 'action'];
|
|
218
|
+
for (const key of bodyKeys) {
|
|
219
|
+
const arr = data?.[key];
|
|
220
|
+
if (Array.isArray(arr)) {
|
|
221
|
+
for (const child of arr) {
|
|
222
|
+
collectStmtLinenosFromNode(child, linenos);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Recurse into elsif_list
|
|
227
|
+
if (data?.elsif_list) {
|
|
228
|
+
for (const elsif of data.elsif_list) {
|
|
229
|
+
const elsifData = elsif?.PLpgSQL_if_elsif;
|
|
230
|
+
if (elsifData?.stmts) {
|
|
231
|
+
for (const child of elsifData.stmts) {
|
|
232
|
+
collectStmtLinenosFromNode(child, linenos);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Recurse into case_when_list
|
|
238
|
+
if (data?.case_when_list) {
|
|
239
|
+
for (const when of data.case_when_list) {
|
|
240
|
+
const whenData = when?.PLpgSQL_case_when;
|
|
241
|
+
if (whenData?.stmts) {
|
|
242
|
+
for (const child of whenData.stmts) {
|
|
243
|
+
collectStmtLinenosFromNode(child, linenos);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Recurse into exception handlers
|
|
249
|
+
const excList = data?.exceptions?.exc_list ??
|
|
250
|
+
data?.exceptions?.PLpgSQL_exception_block?.exc_list;
|
|
251
|
+
if (excList) {
|
|
252
|
+
for (const exc of excList) {
|
|
253
|
+
const excData = exc?.PLpgSQL_exception;
|
|
254
|
+
if (excData?.action) {
|
|
255
|
+
for (const child of excData.action) {
|
|
256
|
+
collectStmtLinenosFromNode(child, linenos);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Build an ordered list of (lineno, keywords) for matching deparsed output.
|
|
264
|
+
* Keywords are the first tokens expected for each statement type.
|
|
265
|
+
*/
|
|
266
|
+
function buildStmtKeywords(ast) {
|
|
267
|
+
const result = [];
|
|
268
|
+
if (!ast.plpgsql_funcs)
|
|
269
|
+
return result;
|
|
270
|
+
for (const funcNode of ast.plpgsql_funcs) {
|
|
271
|
+
if ('PLpgSQL_function' in funcNode) {
|
|
272
|
+
const func = funcNode.PLpgSQL_function;
|
|
273
|
+
if (func.action) {
|
|
274
|
+
collectStmtKeywordsFromBlock(func.action, result);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Sort by lineno to match comment association order
|
|
279
|
+
result.sort((a, b) => a.lineno - b.lineno);
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Recursively collect statement keywords from a block's body.
|
|
284
|
+
* Only collects from direct body statements, not nested blocks' bodies
|
|
285
|
+
* (those are handled when their enclosing block keyword is matched).
|
|
286
|
+
*/
|
|
287
|
+
function collectStmtKeywordsFromBlock(blockNode, result) {
|
|
288
|
+
const block = blockNode?.PLpgSQL_stmt_block;
|
|
289
|
+
if (!block?.body)
|
|
290
|
+
return;
|
|
291
|
+
for (const stmt of block.body) {
|
|
292
|
+
const [kind, data] = Object.entries(stmt)[0];
|
|
293
|
+
if (data?.lineno === undefined)
|
|
294
|
+
continue;
|
|
295
|
+
const keywords = getKeywordsForStmtKind(kind, data);
|
|
296
|
+
result.push({ lineno: data.lineno, keywords });
|
|
297
|
+
// Recurse into nested structures to collect their direct children
|
|
298
|
+
if (kind === 'PLpgSQL_stmt_block') {
|
|
299
|
+
collectStmtKeywordsFromBlock(stmt, result);
|
|
300
|
+
}
|
|
301
|
+
else if (kind === 'PLpgSQL_stmt_if') {
|
|
302
|
+
collectStmtKeywordsFromIf(data, result);
|
|
303
|
+
}
|
|
304
|
+
else if (kind === 'PLpgSQL_stmt_case') {
|
|
305
|
+
collectStmtKeywordsFromCase(data, result);
|
|
306
|
+
}
|
|
307
|
+
else if (kind === 'PLpgSQL_stmt_loop' || kind === 'PLpgSQL_stmt_while' ||
|
|
308
|
+
kind === 'PLpgSQL_stmt_fori' || kind === 'PLpgSQL_stmt_fors' ||
|
|
309
|
+
kind === 'PLpgSQL_stmt_forc' || kind === 'PLpgSQL_stmt_foreach_a' ||
|
|
310
|
+
kind === 'PLpgSQL_stmt_dynfors') {
|
|
311
|
+
if (data.body) {
|
|
312
|
+
for (const child of data.body) {
|
|
313
|
+
const [childKind, childData] = Object.entries(child)[0];
|
|
314
|
+
if (childData?.lineno !== undefined) {
|
|
315
|
+
result.push({ lineno: childData.lineno, keywords: getKeywordsForStmtKind(childKind, childData) });
|
|
316
|
+
}
|
|
317
|
+
if (childKind === 'PLpgSQL_stmt_block') {
|
|
318
|
+
collectStmtKeywordsFromBlock(child, result);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Recurse into exception handlers
|
|
324
|
+
if (kind === 'PLpgSQL_stmt_block') {
|
|
325
|
+
const excList = data?.exceptions?.exc_list ??
|
|
326
|
+
data?.exceptions?.PLpgSQL_exception_block?.exc_list;
|
|
327
|
+
if (excList) {
|
|
328
|
+
for (const exc of excList) {
|
|
329
|
+
const excData = exc?.PLpgSQL_exception;
|
|
330
|
+
if (excData?.action) {
|
|
331
|
+
for (const child of excData.action) {
|
|
332
|
+
const [childKind, childData] = Object.entries(child)[0];
|
|
333
|
+
if (childData?.lineno !== undefined) {
|
|
334
|
+
result.push({ lineno: childData.lineno, keywords: getKeywordsForStmtKind(childKind, childData) });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function collectStmtKeywordsFromIf(ifData, result) {
|
|
344
|
+
// then_body
|
|
345
|
+
if (ifData.then_body) {
|
|
346
|
+
for (const child of ifData.then_body) {
|
|
347
|
+
const [kind, data] = Object.entries(child)[0];
|
|
348
|
+
if (data?.lineno !== undefined) {
|
|
349
|
+
result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
|
|
350
|
+
}
|
|
351
|
+
if (kind === 'PLpgSQL_stmt_block') {
|
|
352
|
+
collectStmtKeywordsFromBlock(child, result);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// elsif_list
|
|
357
|
+
if (ifData.elsif_list) {
|
|
358
|
+
for (const elsif of ifData.elsif_list) {
|
|
359
|
+
const elsifData = elsif?.PLpgSQL_if_elsif;
|
|
360
|
+
if (elsifData?.stmts) {
|
|
361
|
+
for (const child of elsifData.stmts) {
|
|
362
|
+
const [kind, data] = Object.entries(child)[0];
|
|
363
|
+
if (data?.lineno !== undefined) {
|
|
364
|
+
result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
|
|
365
|
+
}
|
|
366
|
+
if (kind === 'PLpgSQL_stmt_block') {
|
|
367
|
+
collectStmtKeywordsFromBlock(child, result);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// else_body
|
|
374
|
+
if (ifData.else_body) {
|
|
375
|
+
for (const child of ifData.else_body) {
|
|
376
|
+
const [kind, data] = Object.entries(child)[0];
|
|
377
|
+
if (data?.lineno !== undefined) {
|
|
378
|
+
result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
|
|
379
|
+
}
|
|
380
|
+
if (kind === 'PLpgSQL_stmt_block') {
|
|
381
|
+
collectStmtKeywordsFromBlock(child, result);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function collectStmtKeywordsFromCase(caseData, result) {
|
|
387
|
+
if (caseData.case_when_list) {
|
|
388
|
+
for (const when of caseData.case_when_list) {
|
|
389
|
+
const whenData = when?.PLpgSQL_case_when;
|
|
390
|
+
if (whenData?.stmts) {
|
|
391
|
+
for (const child of whenData.stmts) {
|
|
392
|
+
const [kind, data] = Object.entries(child)[0];
|
|
393
|
+
if (data?.lineno !== undefined) {
|
|
394
|
+
result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
|
|
395
|
+
}
|
|
396
|
+
if (kind === 'PLpgSQL_stmt_block') {
|
|
397
|
+
collectStmtKeywordsFromBlock(child, result);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (caseData.have_else && caseData.else_stmts) {
|
|
404
|
+
for (const child of caseData.else_stmts) {
|
|
405
|
+
const [kind, data] = Object.entries(child)[0];
|
|
406
|
+
if (data?.lineno !== undefined) {
|
|
407
|
+
result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
|
|
408
|
+
}
|
|
409
|
+
if (kind === 'PLpgSQL_stmt_block') {
|
|
410
|
+
collectStmtKeywordsFromBlock(child, result);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Map PL/pgSQL statement kind to the keywords that would start
|
|
417
|
+
* its deparsed output line (uppercase).
|
|
418
|
+
*/
|
|
419
|
+
function getKeywordsForStmtKind(kind, data) {
|
|
420
|
+
const map = {
|
|
421
|
+
PLpgSQL_stmt_block: ['BEGIN', '<<'],
|
|
422
|
+
PLpgSQL_stmt_assign: [], // starts with variable name — handled separately
|
|
423
|
+
PLpgSQL_stmt_if: ['IF'],
|
|
424
|
+
PLpgSQL_stmt_case: ['CASE'],
|
|
425
|
+
PLpgSQL_stmt_loop: ['LOOP'],
|
|
426
|
+
PLpgSQL_stmt_while: ['WHILE'],
|
|
427
|
+
PLpgSQL_stmt_fori: ['FOR'],
|
|
428
|
+
PLpgSQL_stmt_fors: ['FOR'],
|
|
429
|
+
PLpgSQL_stmt_forc: ['FOR'],
|
|
430
|
+
PLpgSQL_stmt_foreach_a: ['FOREACH'],
|
|
431
|
+
PLpgSQL_stmt_exit: ['EXIT', 'CONTINUE'],
|
|
432
|
+
PLpgSQL_stmt_return: ['RETURN'],
|
|
433
|
+
PLpgSQL_stmt_return_next: ['RETURN'],
|
|
434
|
+
PLpgSQL_stmt_return_query: ['RETURN'],
|
|
435
|
+
PLpgSQL_stmt_raise: ['RAISE'],
|
|
436
|
+
PLpgSQL_stmt_assert: ['ASSERT'],
|
|
437
|
+
PLpgSQL_stmt_execsql: [], // SQL statement — handled separately
|
|
438
|
+
PLpgSQL_stmt_dynexecute: ['EXECUTE'],
|
|
439
|
+
PLpgSQL_stmt_dynfors: ['FOR'],
|
|
440
|
+
PLpgSQL_stmt_getdiag: ['GET'],
|
|
441
|
+
PLpgSQL_stmt_open: ['OPEN'],
|
|
442
|
+
PLpgSQL_stmt_fetch: ['FETCH'],
|
|
443
|
+
PLpgSQL_stmt_close: ['CLOSE'],
|
|
444
|
+
PLpgSQL_stmt_perform: ['PERFORM'],
|
|
445
|
+
PLpgSQL_stmt_call: ['CALL'],
|
|
446
|
+
PLpgSQL_stmt_commit: ['COMMIT'],
|
|
447
|
+
PLpgSQL_stmt_rollback: ['ROLLBACK'],
|
|
448
|
+
PLpgSQL_stmt_set: ['SET'],
|
|
449
|
+
};
|
|
450
|
+
const keywords = map[kind];
|
|
451
|
+
if (keywords && keywords.length > 0)
|
|
452
|
+
return keywords;
|
|
453
|
+
// For assignment and execsql, try to extract the first word from the expr
|
|
454
|
+
if (kind === 'PLpgSQL_stmt_assign') {
|
|
455
|
+
// Assignment: extract from the expression query string
|
|
456
|
+
const query = data?.expr?.PLpgSQL_expr?.query;
|
|
457
|
+
if (query) {
|
|
458
|
+
const firstWord = query.trim().split(/[\s:=]+/)[0].toUpperCase();
|
|
459
|
+
if (firstWord)
|
|
460
|
+
return [firstWord];
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (kind === 'PLpgSQL_stmt_execsql') {
|
|
464
|
+
// execsql: the first word of the SQL query
|
|
465
|
+
const query = data?.sqlstmt?.PLpgSQL_expr?.query;
|
|
466
|
+
if (query) {
|
|
467
|
+
const firstWord = query.trim().split(/\s+/)[0].toUpperCase();
|
|
468
|
+
if (firstWord)
|
|
469
|
+
return [firstWord];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Check if a deparsed line starts with any of the given keywords.
|
|
476
|
+
*/
|
|
477
|
+
function lineMatchesKeywords(trimmedUpperLine, keywords) {
|
|
478
|
+
if (keywords.length === 0) {
|
|
479
|
+
// No keywords to match — match any non-structural line
|
|
480
|
+
// (not BEGIN, END, DECLARE, EXCEPTION, WHEN, ELSE, ELSIF)
|
|
481
|
+
const structural = ['BEGIN', 'END', 'DECLARE', 'EXCEPTION', 'WHEN', 'ELSE', 'ELSIF', 'END IF', 'END LOOP', 'END CASE'];
|
|
482
|
+
return !structural.some(s => trimmedUpperLine.startsWith(s)) && trimmedUpperLine.length > 0;
|
|
483
|
+
}
|
|
484
|
+
for (const kw of keywords) {
|
|
485
|
+
if (trimmedUpperLine.startsWith(kw)) {
|
|
486
|
+
// Make sure it's a word boundary (followed by space, semicolon, or end)
|
|
487
|
+
const nextChar = trimmedUpperLine[kw.length];
|
|
488
|
+
if (nextChar === undefined || nextChar === ' ' || nextChar === ';' || nextChar === '\t' || nextChar === '>' || nextChar === '\n') {
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Find the index of the last END line in the result array.
|
|
497
|
+
*/
|
|
498
|
+
function findLastEndLine(lines) {
|
|
499
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
500
|
+
if (lines[i].trim().toUpperCase().startsWith('END')) {
|
|
501
|
+
return i;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return -1;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Replace the function body in a CREATE FUNCTION AST node.
|
|
508
|
+
*/
|
|
509
|
+
function stitchBodyIntoAst(createFunctionStmt, newBody) {
|
|
510
|
+
if (!createFunctionStmt?.options)
|
|
511
|
+
return;
|
|
512
|
+
for (const opt of createFunctionStmt.options) {
|
|
513
|
+
if (opt?.DefElem?.defname === 'as') {
|
|
514
|
+
const arg = opt.DefElem.arg;
|
|
515
|
+
if (arg?.List?.items?.[0]?.String) {
|
|
516
|
+
arg.List.items[0].String.sval = newBody;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (arg?.String) {
|
|
520
|
+
arg.String.sval = newBody;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Extract return info from a CREATE FUNCTION statement for correct
|
|
528
|
+
* RETURN statement handling in the PL/pgSQL deparser.
|
|
529
|
+
*/
|
|
530
|
+
function getReturnInfoForStmt(createFunctionStmt) {
|
|
531
|
+
// Check for RETURNS TABLE / SETOF
|
|
532
|
+
const returnType = createFunctionStmt?.returnType;
|
|
533
|
+
if (returnType) {
|
|
534
|
+
// SETOF
|
|
535
|
+
if (returnType.TypeName?.setof) {
|
|
536
|
+
return { kind: 'setof' };
|
|
537
|
+
}
|
|
538
|
+
// RETURNS TABLE
|
|
539
|
+
if (returnType.TypeName?.names) {
|
|
540
|
+
const names = returnType.TypeName.names;
|
|
541
|
+
for (const n of names) {
|
|
542
|
+
if (n?.String?.sval === 'record') {
|
|
543
|
+
return { kind: 'setof' };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Check for OUT parameters
|
|
549
|
+
const params = createFunctionStmt?.parameters;
|
|
550
|
+
if (params) {
|
|
551
|
+
for (const p of params) {
|
|
552
|
+
const fp = p?.FunctionParameter;
|
|
553
|
+
if (fp?.mode === 'FUNC_PARAM_OUT' || fp?.mode === 'FUNC_PARAM_INOUT' || fp?.mode === 'FUNC_PARAM_TABLE') {
|
|
554
|
+
return { kind: 'out_params' };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// Check for RETURNS void
|
|
559
|
+
if (returnType?.TypeName?.names) {
|
|
560
|
+
const names = returnType.TypeName.names;
|
|
561
|
+
for (const n of names) {
|
|
562
|
+
if (n?.String?.sval === 'void') {
|
|
563
|
+
return { kind: 'void' };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Check for RETURNS trigger
|
|
568
|
+
if (returnType?.TypeName?.names) {
|
|
569
|
+
const names = returnType.TypeName.names;
|
|
570
|
+
for (const n of names) {
|
|
571
|
+
if (n?.String?.sval === 'trigger') {
|
|
572
|
+
return { kind: 'trigger' };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return { kind: 'scalar' };
|
|
577
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scanner for extracting comments from PL/pgSQL function bodies.
|
|
3
|
+
*
|
|
4
|
+
* Uses simple line-based scanning since the body text has already been
|
|
5
|
+
* extracted from the dollar-quoted string — no need to worry about
|
|
6
|
+
* string literal boundaries at this level.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Scan a PL/pgSQL function body for -- line comments.
|
|
10
|
+
*
|
|
11
|
+
* Returns an array of BodyComment objects ordered by line number.
|
|
12
|
+
* Only detects standalone comment lines (lines where the first
|
|
13
|
+
* non-whitespace content is --). Inline trailing comments on code
|
|
14
|
+
* lines are not extracted since the PL/pgSQL deparser cannot
|
|
15
|
+
* reconstruct them at the right position.
|
|
16
|
+
*/
|
|
17
|
+
export function scanBodyComments(body) {
|
|
18
|
+
const lines = body.split('\n');
|
|
19
|
+
const comments = [];
|
|
20
|
+
for (let i = 0; i < lines.length; i++) {
|
|
21
|
+
const trimmed = lines[i].trim();
|
|
22
|
+
if (trimmed.startsWith('--')) {
|
|
23
|
+
comments.push({
|
|
24
|
+
text: trimmed,
|
|
25
|
+
lineNo: i + 1, // 1-based
|
|
26
|
+
standalone: true,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return comments;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Associate body comments with PL/pgSQL statement line numbers.
|
|
34
|
+
*
|
|
35
|
+
* For each comment, finds the first statement whose lineno is greater
|
|
36
|
+
* than the comment's line number. Groups consecutive comments together.
|
|
37
|
+
*
|
|
38
|
+
* @param comments - Comments extracted by scanBodyComments
|
|
39
|
+
* @param stmtLinenos - Sorted array of statement lineno values from the PL/pgSQL AST
|
|
40
|
+
*/
|
|
41
|
+
export function groupCommentsByAnchor(comments, stmtLinenos) {
|
|
42
|
+
if (comments.length === 0)
|
|
43
|
+
return [];
|
|
44
|
+
const groups = [];
|
|
45
|
+
let currentGroup = [];
|
|
46
|
+
let lastLineNo = -1;
|
|
47
|
+
for (const comment of comments) {
|
|
48
|
+
// Start a new group if there's a gap (non-consecutive lines)
|
|
49
|
+
if (lastLineNo >= 0 && comment.lineNo > lastLineNo + 1) {
|
|
50
|
+
if (currentGroup.length > 0) {
|
|
51
|
+
const anchor = findAnchorLineno(lastLineNo, stmtLinenos);
|
|
52
|
+
groups.push({ comments: [...currentGroup], anchorLineno: anchor });
|
|
53
|
+
currentGroup = [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
currentGroup.push(comment.text);
|
|
57
|
+
lastLineNo = comment.lineNo;
|
|
58
|
+
}
|
|
59
|
+
// Flush remaining group
|
|
60
|
+
if (currentGroup.length > 0) {
|
|
61
|
+
const anchor = findAnchorLineno(lastLineNo, stmtLinenos);
|
|
62
|
+
groups.push({ comments: [...currentGroup], anchorLineno: anchor });
|
|
63
|
+
}
|
|
64
|
+
return groups;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Find the first statement lineno that comes after the given line number.
|
|
68
|
+
*/
|
|
69
|
+
function findAnchorLineno(afterLine, stmtLinenos) {
|
|
70
|
+
for (const lineno of stmtLinenos) {
|
|
71
|
+
if (lineno > afterLine) {
|
|
72
|
+
return lineno;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|