Skip to main content

pgevolve_core/parse/builder/
plpgsql.rs

1//! PL/pgSQL body parsing — dep extraction, COMMIT/ROLLBACK detection,
2//! `-- @pgevolve dep:` directive scanning.
3//!
4//! Entry point: [`parse_routine_body`].
5
6use serde_json::Value;
7
8use crate::identifier::{Identifier, QualifiedName};
9use crate::ir::function::FunctionLanguage;
10use crate::parse::error::{ParseError, SourceLocation};
11use crate::parse::normalize_body::NormalizedBody;
12use crate::plan::edges::{DepEdge, DepSource, NodeId};
13
14/// Parse a routine body and produce its [`NormalizedBody`], extracted
15/// [`DepEdge`]s, and the `commits_in_body` flag.
16///
17/// `commits_in_body` is only meaningful for procedures; it is always `false`
18/// for SQL-language bodies (SQL functions cannot issue COMMIT/ROLLBACK).
19pub fn parse_routine_body(
20    body_text: &str,
21    language: FunctionLanguage,
22    routine_qname: &QualifiedName,
23    location: &SourceLocation,
24) -> Result<(NormalizedBody, Vec<DepEdge>, bool), ParseError> {
25    match language {
26        FunctionLanguage::Sql => {
27            let (body, deps) = parse_sql_body(body_text, routine_qname, location)?;
28            Ok((body, deps, false))
29        }
30        FunctionLanguage::PlPgSql => parse_plpgsql_body(body_text, routine_qname, location),
31    }
32}
33
34// ---------------------------------------------------------------------------
35// PL/pgSQL path
36// ---------------------------------------------------------------------------
37
38fn parse_plpgsql_body(
39    body_text: &str,
40    routine_qname: &QualifiedName,
41    location: &SourceLocation,
42) -> Result<(NormalizedBody, Vec<DepEdge>, bool), ParseError> {
43    // Wrap the body in a synthetic CREATE FUNCTION so pg_query::parse_plpgsql
44    // can parse it. Use a dollar-quote tag unlikely to collide with body
45    // content.
46    let wrapper = format!(
47        "CREATE FUNCTION pgevolve_temp() RETURNS void LANGUAGE plpgsql \
48         AS $pgevolve_outer${body_text}$pgevolve_outer$;"
49    );
50    let json = pg_query::parse_plpgsql(&wrapper).map_err(|e| ParseError::Structural {
51        location: location.clone(),
52        message: format!("function {routine_qname}: PL/pgSQL parse error — {e}"),
53    })?;
54
55    let mut walker = PlpgsqlWalker {
56        routine_qname: routine_qname.clone(),
57        location: location.clone(),
58        dependencies: Vec::new(),
59        commits_in_body: false,
60    };
61    walker.walk_root(&json);
62
63    // Scan body text for `-- @pgevolve dep:` directives.
64    let directive_edges = scan_dep_directives(body_text, routine_qname, location)?;
65    walker.dependencies.extend(directive_edges);
66
67    // Stable dedup.
68    walker.dependencies.sort();
69    walker.dependencies.dedup();
70
71    let canonical_text = canonicalize_plpgsql_text(body_text);
72    let body = NormalizedBody::from_raw_canonical(canonical_text);
73    Ok((body, walker.dependencies, walker.commits_in_body))
74}
75
76// ---------------------------------------------------------------------------
77// SQL path
78// ---------------------------------------------------------------------------
79
80fn parse_sql_body(
81    body_text: &str,
82    routine_qname: &QualifiedName,
83    location: &SourceLocation,
84) -> Result<(NormalizedBody, Vec<DepEdge>), ParseError> {
85    let parsed = pg_query::parse(body_text).map_err(|e| ParseError::Structural {
86        location: location.clone(),
87        message: format!("function {routine_qname}: SQL body parse error — {e}"),
88    })?;
89
90    let mut deps: Vec<DepEdge> = Vec::new();
91    for stmt in &parsed.protobuf.stmts {
92        if let Some(node) = stmt.stmt.as_ref().and_then(|n| n.node.as_ref()) {
93            walk_sql_node_for_deps(node, routine_qname, &mut deps);
94        }
95    }
96
97    deps.sort();
98    deps.dedup();
99
100    // Use pg_query parse → deparse to get a byte-stable canonical form. NOTE:
101    // pg_query::normalize is the WRONG tool here — it replaces literal
102    // constants with positional placeholders (`$1`, `$2`, …) for query-log
103    // aggregation. When such a normalized body is later embedded inside a
104    // CREATE FUNCTION definition, PG interprets `$1` as the first function
105    // argument and the apply fails with `[42P02] there is no parameter $1`.
106    // parse.deparse() round-trips through the AST without parameter
107    // substitution, preserving the original literals.
108    let canonical_text = parsed
109        .deparse()
110        .unwrap_or_else(|_| collapse_whitespace(body_text));
111    let body = NormalizedBody::from_raw_canonical(canonical_text);
112    Ok((body, deps))
113}
114
115// ---------------------------------------------------------------------------
116// PL/pgSQL JSON walker
117// ---------------------------------------------------------------------------
118
119struct PlpgsqlWalker {
120    routine_qname: QualifiedName,
121    #[allow(dead_code)] // retained for future error-reporting (e.g., T11 lint sites)
122    location: SourceLocation,
123    dependencies: Vec<DepEdge>,
124    commits_in_body: bool,
125}
126
127impl PlpgsqlWalker {
128    fn walk_root(&mut self, json: &Value) {
129        // pg_query::parse_plpgsql returns a JSON array, one element per
130        // function/procedure body.
131        if let Some(arr) = json.as_array() {
132            for item in arr {
133                if let Some(action) = item.get("PLpgSQL_function").and_then(|f| f.get("action")) {
134                    self.walk(action);
135                }
136            }
137        }
138    }
139
140    fn walk(&mut self, node: &Value) {
141        match node {
142            Value::Object(map) => {
143                for (key, value) in map {
144                    match key.as_str() {
145                        // -------------------------------------------------- //
146                        // Transaction control — set the flag regardless of
147                        // nesting depth (inside IF, loops, etc.).
148                        // -------------------------------------------------- //
149                        "PLpgSQL_stmt_commit" | "PLpgSQL_stmt_rollback" => {
150                            self.commits_in_body = true;
151                        }
152
153                        // -------------------------------------------------- //
154                        // Static embedded SQL — re-parse and walk for deps.
155                        // -------------------------------------------------- //
156                        "PLpgSQL_stmt_execsql" => {
157                            // pg_query emits sqlstmt as:
158                            //   { "PLpgSQL_expr": { "query": "<sql text>" } }
159                            if let Some(query) = value
160                                .get("sqlstmt")
161                                .and_then(|s| s.get("PLpgSQL_expr"))
162                                .and_then(|e| e.get("query"))
163                                .and_then(|q| q.as_str())
164                            {
165                                self.extract_embedded_sql_deps(query);
166                            }
167                        }
168
169                        // Dynamic SQL (PLpgSQL_stmt_dynexecute, PLpgSQL_stmt_dynfors):
170                        // Opaque to static analysis. The pl-pgsql-dynamic-sql lint
171                        // (T11) checks body text for EXECUTE sites and requires at
172                        // least one @pgevolve dep: directive. Fall through to default.
173                        _ => {}
174                    }
175                    // Recurse into all values (handles IF, LOOP, CASE, etc.).
176                    self.walk(value);
177                }
178            }
179            Value::Array(arr) => {
180                for v in arr {
181                    self.walk(v);
182                }
183            }
184            _ => {}
185        }
186    }
187
188    fn extract_embedded_sql_deps(&mut self, sql: &str) {
189        let Ok(parsed) = pg_query::parse(sql) else {
190            return;
191        };
192        for stmt in &parsed.protobuf.stmts {
193            if let Some(node) = stmt.stmt.as_ref().and_then(|n| n.node.as_ref()) {
194                walk_sql_node_for_deps(node, &self.routine_qname, &mut self.dependencies);
195            }
196        }
197    }
198}
199
200// ---------------------------------------------------------------------------
201// SQL AST walker — relation-ref extraction
202// ---------------------------------------------------------------------------
203
204/// Walk a `pg_query::NodeEnum` tree for relation references (`RangeVar`) and
205/// emit `DepEdge` entries for each schema-qualified reference found.
206///
207/// Mirrors the `walk_node` logic in `parse/ast_canon.rs` that extracts
208/// `body_dependencies` for views, but without the `KnownObjects` catalog
209/// check: function bodies may reference relations that do not yet exist in the
210/// source catalog (e.g., catalog tables, external schemas). Validation is
211/// deferred to the T6 AST resolution pass.
212fn walk_sql_node_for_deps(
213    node: &pg_query::NodeEnum,
214    from_qname: &QualifiedName,
215    deps: &mut Vec<DepEdge>,
216) {
217    use pg_query::NodeEnum as N;
218
219    match node {
220        // SELECT: walk FROM, WHERE, UNION branches, CTEs.
221        N::SelectStmt(sel) => {
222            for from in &sel.from_clause {
223                if let Some(n) = from.node.as_ref() {
224                    walk_sql_node_for_deps(n, from_qname, deps);
225                }
226            }
227            if let Some(wc) = sel.where_clause.as_ref().and_then(|n| n.node.as_ref()) {
228                walk_sql_node_for_deps(wc, from_qname, deps);
229            }
230            if let Some(larg) = &sel.larg {
231                let node = pg_query::protobuf::Node {
232                    node: Some(N::SelectStmt(Box::new(*larg.clone()))),
233                };
234                if let Some(n) = node.node.as_ref() {
235                    walk_sql_node_for_deps(n, from_qname, deps);
236                }
237            }
238            if let Some(rarg) = &sel.rarg {
239                let node = pg_query::protobuf::Node {
240                    node: Some(N::SelectStmt(Box::new(*rarg.clone()))),
241                };
242                if let Some(n) = node.node.as_ref() {
243                    walk_sql_node_for_deps(n, from_qname, deps);
244                }
245            }
246            if let Some(with) = &sel.with_clause {
247                for cte in &with.ctes {
248                    if let Some(n) = cte.node.as_ref() {
249                        walk_sql_node_for_deps(n, from_qname, deps);
250                    }
251                }
252            }
253            // Target list (for expressions that contain subqueries etc.).
254            for t in &sel.target_list {
255                if let Some(n) = t.node.as_ref() {
256                    walk_sql_node_for_deps(n, from_qname, deps);
257                }
258            }
259        }
260
261        // INSERT: walk the target relation and SELECT source.
262        N::InsertStmt(ins) => {
263            if let Some(rel) = ins.relation.as_ref() {
264                emit_range_var_dep(rel, from_qname, deps);
265            }
266            if let Some(sel) = ins.select_stmt.as_ref().and_then(|n| n.node.as_ref()) {
267                walk_sql_node_for_deps(sel, from_qname, deps);
268            }
269        }
270
271        // UPDATE: walk target relation, FROM clause, WHERE.
272        N::UpdateStmt(upd) => {
273            if let Some(rel) = upd.relation.as_ref() {
274                emit_range_var_dep(rel, from_qname, deps);
275            }
276            for f in &upd.from_clause {
277                if let Some(n) = f.node.as_ref() {
278                    walk_sql_node_for_deps(n, from_qname, deps);
279                }
280            }
281            if let Some(wc) = upd.where_clause.as_ref().and_then(|n| n.node.as_ref()) {
282                walk_sql_node_for_deps(wc, from_qname, deps);
283            }
284        }
285
286        // DELETE: walk target relation and WHERE.
287        N::DeleteStmt(del) => {
288            if let Some(rel) = del.relation.as_ref() {
289                emit_range_var_dep(rel, from_qname, deps);
290            }
291            if let Some(wc) = del.where_clause.as_ref().and_then(|n| n.node.as_ref()) {
292                walk_sql_node_for_deps(wc, from_qname, deps);
293            }
294        }
295
296        // Relation reference in FROM clause.
297        N::RangeVar(rv) => {
298            emit_range_var_dep(rv, from_qname, deps);
299        }
300
301        // JOIN: walk both sides.
302        N::JoinExpr(j) => {
303            if let Some(l) = j.larg.as_ref().and_then(|n| n.node.as_ref()) {
304                walk_sql_node_for_deps(l, from_qname, deps);
305            }
306            if let Some(r) = j.rarg.as_ref().and_then(|n| n.node.as_ref()) {
307                walk_sql_node_for_deps(r, from_qname, deps);
308            }
309        }
310
311        // Subquery in FROM.
312        N::RangeSubselect(sub) => {
313            if let Some(q) = sub.subquery.as_ref().and_then(|n| n.node.as_ref()) {
314                walk_sql_node_for_deps(q, from_qname, deps);
315            }
316        }
317
318        // CTE.
319        N::CommonTableExpr(cte) => {
320            if let Some(q) = cte.ctequery.as_ref().and_then(|n| n.node.as_ref()) {
321                walk_sql_node_for_deps(q, from_qname, deps);
322            }
323        }
324
325        // ResTarget (SELECT target list element).
326        N::ResTarget(rt) => {
327            if let Some(val) = rt.val.as_ref().and_then(|n| n.node.as_ref()) {
328                walk_sql_node_for_deps(val, from_qname, deps);
329            }
330        }
331
332        // Other node kinds don't contain relation references at this level.
333        _ => {}
334    }
335}
336
337/// Emit a `DepEdge` for a schema-qualified `RangeVar`, if the name is
338/// schema-qualified. Unqualified names are skipped (search-path resolution
339/// is out of scope for static analysis).
340fn emit_range_var_dep(
341    rv: &pg_query::protobuf::RangeVar,
342    from_qname: &QualifiedName,
343    deps: &mut Vec<DepEdge>,
344) {
345    if rv.schemaname.is_empty() || rv.relname.is_empty() {
346        return;
347    }
348    let Ok(schema) = Identifier::from_unquoted(&rv.schemaname)
349        .or_else(|_| Identifier::from_quoted(&rv.schemaname))
350    else {
351        return;
352    };
353    let Ok(name) =
354        Identifier::from_unquoted(&rv.relname).or_else(|_| Identifier::from_quoted(&rv.relname))
355    else {
356        return;
357    };
358    let ref_qname = QualifiedName::new(schema, name);
359    deps.push(DepEdge {
360        from: NodeId::Table(from_qname.clone()),
361        to: NodeId::Table(ref_qname),
362        source: DepSource::AstExtracted,
363    });
364}
365
366// ---------------------------------------------------------------------------
367// Directive scanner
368// ---------------------------------------------------------------------------
369
370fn scan_dep_directives(
371    body_text: &str,
372    function_qname: &QualifiedName,
373    location: &SourceLocation,
374) -> Result<Vec<DepEdge>, ParseError> {
375    let mut out = Vec::new();
376    for line in body_text.lines() {
377        // Find `-- @pgevolve dep:` anywhere on the line, not just at the
378        // start. The canonicalizer may join non-comment prefix text onto the
379        // same line as the comment (e.g., `DECLARE -- @pgevolve dep: app.x`),
380        // so a line-start-only check would miss valid directives.
381        let Some(comment_pos) = line.find("-- @pgevolve dep:") else {
382            continue;
383        };
384        let rest = &line[comment_pos + "-- @pgevolve dep:".len()..];
385        let qname_text = rest.trim();
386        let Some((schema, name)) = qname_text.split_once('.') else {
387            return Err(ParseError::Structural {
388                location: location.clone(),
389                message: format!(
390                    "function {function_qname}: directive `-- @pgevolve dep:` must be \
391                     schema-qualified (got {qname_text:?})"
392                ),
393            });
394        };
395        let schema_id =
396            Identifier::from_unquoted(schema.trim()).map_err(|e| ParseError::Structural {
397                location: location.clone(),
398                message: format!("function {function_qname}: invalid schema in dep directive: {e}"),
399            })?;
400        let name_id =
401            Identifier::from_unquoted(name.trim()).map_err(|e| ParseError::Structural {
402                location: location.clone(),
403                message: format!("function {function_qname}: invalid name in dep directive: {e}"),
404            })?;
405        let target_qname = QualifiedName::new(schema_id, name_id);
406
407        // Directive target is ambiguous between table/view/MV/type/function/procedure.
408        // We record NodeId::Table as a placeholder; the T6 AST resolution pass
409        // probes all catalog collections for the qname and treats the directive
410        // as satisfied if any matches.
411        out.push(DepEdge {
412            from: NodeId::Table(function_qname.clone()),
413            to: NodeId::Table(target_qname),
414            source: DepSource::AstDeclared,
415        });
416    }
417    Ok(out)
418}
419
420// ---------------------------------------------------------------------------
421// Text canonicalization helpers
422// ---------------------------------------------------------------------------
423
424fn canonicalize_plpgsql_text(text: &str) -> String {
425    // PL/pgSQL is line-sensitive only around `--` line comments: those extend
426    // to end of line, so a line containing `--` MUST be terminated by a
427    // newline (otherwise the comment would swallow the next statement).
428    // All other whitespace (including newlines on non-comment lines) is
429    // semantically irrelevant and gets collapsed to a single space.
430    //
431    // This produces the same canonical text whether the input was multiline
432    // (source SQL file) or single-line (pg_get_functiondef output), at the
433    // cost of accepting that comment-bearing lines keep their newline.
434    let lines: Vec<String> = text
435        .lines()
436        .map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
437        .filter(|l| !l.is_empty())
438        .collect();
439
440    let mut out = String::with_capacity(text.len());
441    for (i, line) in lines.iter().enumerate() {
442        if i > 0 {
443            // Use newline as separator only when the PREVIOUS line ends in a
444            // way where `--` comment scope demands it. We detect this by
445            // checking if the previous line contains a `--` outside of a
446            // string literal (cheap heuristic: just look for `--`).
447            let prev_has_comment = contains_line_comment(&lines[i - 1]);
448            out.push(if prev_has_comment { '\n' } else { ' ' });
449        }
450        out.push_str(line);
451    }
452    out
453}
454
455/// True if `line` contains a `--` SQL line comment outside of a string literal.
456///
457/// Cheap two-state scanner: track whether we're inside a single-quoted string,
458/// flip on each `'` (PG-style escape `''` is naturally handled since the two
459/// flips cancel). If we hit `--` outside a string, return true.
460fn contains_line_comment(line: &str) -> bool {
461    let bytes = line.as_bytes();
462    let mut in_str = false;
463    let mut i = 0;
464    while i < bytes.len() {
465        match bytes[i] {
466            b'\'' => {
467                in_str = !in_str;
468                i += 1;
469            }
470            b'-' if !in_str && i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
471                return true;
472            }
473            _ => i += 1,
474        }
475    }
476    false
477}
478
479fn collapse_whitespace(s: &str) -> String {
480    s.split_whitespace().collect::<Vec<_>>().join(" ")
481}
482
483// ---------------------------------------------------------------------------
484// Tests
485// ---------------------------------------------------------------------------
486
487#[cfg(test)]
488mod tests {
489    use std::path::PathBuf;
490
491    use super::*;
492
493    fn loc() -> SourceLocation {
494        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
495    }
496
497    fn qn(schema: &str, name: &str) -> QualifiedName {
498        QualifiedName::new(
499            Identifier::from_unquoted(schema).unwrap(),
500            Identifier::from_unquoted(name).unwrap(),
501        )
502    }
503
504    #[test]
505    fn detects_commit_in_plpgsql_body() {
506        let body = "BEGIN INSERT INTO app.log VALUES (1); COMMIT; END";
507        let (_body, _deps, commits) =
508            parse_routine_body(body, FunctionLanguage::PlPgSql, &qn("app", "p"), &loc()).unwrap();
509        assert!(commits, "COMMIT must set commits_in_body");
510    }
511
512    #[test]
513    fn no_commit_in_plain_plpgsql_body() {
514        let body = "BEGIN INSERT INTO app.log VALUES (1); END";
515        let (_body, _deps, commits) =
516            parse_routine_body(body, FunctionLanguage::PlPgSql, &qn("app", "p"), &loc()).unwrap();
517        assert!(
518            !commits,
519            "no COMMIT/ROLLBACK → commits_in_body must be false"
520        );
521    }
522
523    #[test]
524    fn detects_rollback_in_plpgsql_body() {
525        let body = "BEGIN IF false THEN ROLLBACK; END IF; END";
526        let (_body, _deps, commits) =
527            parse_routine_body(body, FunctionLanguage::PlPgSql, &qn("app", "p"), &loc()).unwrap();
528        assert!(commits, "ROLLBACK must also set commits_in_body");
529    }
530
531    #[test]
532    fn sql_body_no_commit_flag() {
533        let body = "SELECT 1";
534        let (_body, _deps, commits) =
535            parse_routine_body(body, FunctionLanguage::Sql, &qn("app", "f"), &loc()).unwrap();
536        assert!(!commits, "SQL bodies cannot set commits_in_body");
537    }
538
539    #[test]
540    fn plpgsql_body_extracts_relation_dep() {
541        // The INSERT references app.log — should produce an AstExtracted edge.
542        let body = "BEGIN INSERT INTO app.log(msg) VALUES ('x'); END";
543        let (_body, deps, _commits) =
544            parse_routine_body(body, FunctionLanguage::PlPgSql, &qn("app", "p"), &loc()).unwrap();
545        let has_edge = deps.iter().any(|e| {
546            e.to == NodeId::Table(qn("app", "log")) && e.source == DepSource::AstExtracted
547        });
548        assert!(
549            has_edge,
550            "expected AstExtracted edge to app.log; got {deps:?}"
551        );
552    }
553
554    #[test]
555    fn sql_body_extracts_relation_dep() {
556        let body = "SELECT * FROM app.users WHERE id = $1";
557        let (_body, deps, _commits) =
558            parse_routine_body(body, FunctionLanguage::Sql, &qn("app", "f"), &loc()).unwrap();
559        let has_edge = deps.iter().any(|e| {
560            e.to == NodeId::Table(qn("app", "users")) && e.source == DepSource::AstExtracted
561        });
562        assert!(
563            has_edge,
564            "expected AstExtracted edge to app.users; got {deps:?}"
565        );
566    }
567
568    #[test]
569    fn directive_adds_declared_dep_edge() {
570        let body = "-- @pgevolve dep: app.summary\n\
571                    BEGIN EXECUTE 'REFRESH MATERIALIZED VIEW app.summary'; END";
572        let (_body, deps, _commits) =
573            parse_routine_body(body, FunctionLanguage::PlPgSql, &qn("app", "f"), &loc()).unwrap();
574        let has_declared = deps.iter().any(|e| {
575            e.to == NodeId::Table(qn("app", "summary")) && e.source == DepSource::AstDeclared
576        });
577        assert!(
578            has_declared,
579            "expected AstDeclared edge to app.summary; got {deps:?}"
580        );
581    }
582
583    #[test]
584    fn unqualified_directive_rejected() {
585        let body = "-- @pgevolve dep: nonsense\nBEGIN NULL; END";
586        let err = parse_routine_body(body, FunctionLanguage::PlPgSql, &qn("app", "f"), &loc())
587            .unwrap_err();
588        let msg = match &err {
589            ParseError::Structural { message, .. } => message.clone(),
590            other => panic!("expected Structural, got {other:?}"),
591        };
592        assert!(msg.contains("schema-qualified"), "{msg}");
593    }
594
595    #[test]
596    fn canonical_text_collapses_whitespace() {
597        let body = "BEGIN\n  NULL;\nEND";
598        let (body_val, _deps, _commits) =
599            parse_routine_body(body, FunctionLanguage::PlPgSql, &qn("app", "f"), &loc()).unwrap();
600        assert_eq!(body_val.canonical_text(), "BEGIN NULL; END");
601    }
602}