1use 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
14pub 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
34fn parse_plpgsql_body(
39 body_text: &str,
40 routine_qname: &QualifiedName,
41 location: &SourceLocation,
42) -> Result<(NormalizedBody, Vec<DepEdge>, bool), ParseError> {
43 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 let directive_edges = scan_dep_directives(body_text, routine_qname, location)?;
65 walker.dependencies.extend(directive_edges);
66
67 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
76fn 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 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
115struct PlpgsqlWalker {
120 routine_qname: QualifiedName,
121 #[allow(dead_code)] location: SourceLocation,
123 dependencies: Vec<DepEdge>,
124 commits_in_body: bool,
125}
126
127impl PlpgsqlWalker {
128 fn walk_root(&mut self, json: &Value) {
129 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 "PLpgSQL_stmt_commit" | "PLpgSQL_stmt_rollback" => {
150 self.commits_in_body = true;
151 }
152
153 "PLpgSQL_stmt_execsql" => {
157 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 _ => {}
174 }
175 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
200fn 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 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 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 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 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 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 N::RangeVar(rv) => {
298 emit_range_var_dep(rv, from_qname, deps);
299 }
300
301 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 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 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 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 _ => {}
334 }
335}
336
337fn 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
366fn 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 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 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
420fn canonicalize_plpgsql_text(text: &str) -> String {
425 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 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
455fn 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#[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 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}