Skip to main content

pgevolve_core/parse/builder/
create_function_stmt.rs

1//! Source-side parser for `CREATE FUNCTION` and `CREATE PROCEDURE`.
2//!
3//! Both statements are represented in `pg_query` by a single
4//! `CreateFunctionStmt` with an `is_procedure: bool` discriminant.
5//! This builder dispatches on that flag and returns either a
6//! [`Routine::Function`] or a [`Routine::Procedure`].
7//!
8//! **Body parsing (T2 stub)**: SQL-language bodies are canonicalized via
9//! [`NormalizedBody::from_sql`]; PL/pgSQL bodies fall back to
10//! [`NormalizedBody::empty`] because `pg_query` cannot parse PL/pgSQL.
11//! T4 replaces the fallback with real PL/pgSQL AST parsing.
12
13use pg_query::NodeEnum;
14use pg_query::protobuf::{CreateFunctionStmt, FunctionParameterMode};
15
16use crate::identifier::Identifier;
17use crate::ir::function::{
18    ArgMode, Function, FunctionArg, FunctionLanguage, NormalizedArgTypes, ParallelSafety,
19    ReturnType, SecurityMode, TableColumn, Volatility,
20};
21use crate::ir::procedure::Procedure;
22use crate::parse::builder::plpgsql;
23use crate::parse::builder::shared;
24use crate::parse::error::{ParseError, SourceLocation};
25use crate::parse::normalize_body::NormalizedBody;
26use crate::parse::normalize_expr;
27
28/// Dispatch output for this builder.
29#[derive(Debug)]
30pub enum Routine {
31    /// A `CREATE FUNCTION` statement.
32    Function(Function),
33    /// A `CREATE PROCEDURE` statement.
34    Procedure(Procedure),
35}
36
37/// Build a [`Routine`] from a `CreateFunctionStmt` AST node.
38///
39/// * `default_schema` — used when the statement has no schema prefix and a
40///   `-- @pgevolve schema=` directive is in effect.
41/// * Unsupported languages (anything other than `sql` / `plpgsql`) are
42///   rejected with a [`ParseError::Structural`] naming the bad language.
43/// * Procedure-specific constraints: `VOLATILE/STABLE/IMMUTABLE`, `STRICT`,
44///   `PARALLEL`, `LEAKPROOF`, `COST`, `ROWS`, and a return-type clause are
45///   all rejected on procedures.
46#[allow(clippy::too_many_lines)] // exhaustive walk of `CREATE FUNCTION`/`CREATE PROCEDURE` options; one arm per option kind.
47pub(crate) fn build_function_or_procedure(
48    stmt: &CreateFunctionStmt,
49    default_schema: Option<&Identifier>,
50    location: &SourceLocation,
51) -> Result<Routine, ParseError> {
52    let qname = shared::qname_from_string_list(&stmt.funcname, default_schema, location)?;
53    let is_procedure = stmt.is_procedure;
54
55    // ── Parameters ──────────────────────────────────────────────────────────
56    let mut args: Vec<FunctionArg> = Vec::new();
57    let mut table_columns: Vec<TableColumn> = Vec::new();
58
59    for param_node in &stmt.parameters {
60        let Some(NodeEnum::FunctionParameter(p)) = param_node.node.as_ref() else {
61            return Err(ParseError::Structural {
62                location: location.clone(),
63                message: format!("CREATE FUNCTION/PROCEDURE {qname}: unexpected parameter node"),
64            });
65        };
66
67        let raw_mode =
68            FunctionParameterMode::try_from(p.mode).unwrap_or(FunctionParameterMode::Undefined);
69
70        // TABLE mode parameters are collected separately for RETURNS TABLE.
71        if raw_mode == FunctionParameterMode::FuncParamTable {
72            let tn = p.arg_type.as_ref().ok_or_else(|| ParseError::Structural {
73                location: location.clone(),
74                message: format!("{qname}: TABLE column missing type"),
75            })?;
76            let ty = shared::type_name_to_column_type(tn, location)?;
77            let name = if p.name.is_empty() {
78                return Err(ParseError::Structural {
79                    location: location.clone(),
80                    message: format!("{qname}: TABLE column missing name"),
81                });
82            } else {
83                shared::ident(&p.name, location)?
84            };
85            table_columns.push(TableColumn { name, ty });
86            continue;
87        }
88
89        let mode = match raw_mode {
90            FunctionParameterMode::FuncParamIn
91            | FunctionParameterMode::FuncParamDefault
92            | FunctionParameterMode::Undefined => ArgMode::In,
93            FunctionParameterMode::FuncParamOut => ArgMode::Out,
94            FunctionParameterMode::FuncParamInout => ArgMode::InOut,
95            FunctionParameterMode::FuncParamVariadic => ArgMode::Variadic,
96            FunctionParameterMode::FuncParamTable => unreachable!("handled above"),
97        };
98
99        let tn = p.arg_type.as_ref().ok_or_else(|| ParseError::Structural {
100            location: location.clone(),
101            message: format!("{qname}: parameter missing type"),
102        })?;
103        let ty = shared::type_name_to_column_type(tn, location)?;
104
105        let name = if p.name.is_empty() {
106            None
107        } else {
108            Some(shared::ident(&p.name, location)?)
109        };
110
111        // Default expression (stubbed normalize via from_pg_node)
112        let default = if let Some(defexpr_boxed) = p.defexpr.as_ref() {
113            let node_enum = defexpr_boxed
114                .node
115                .as_ref()
116                .ok_or_else(|| ParseError::Structural {
117                    location: location.clone(),
118                    message: format!("{qname}: argument default expression is empty"),
119                })?;
120            Some(normalize_expr::from_pg_node(
121                node_enum,
122                Some(&ty),
123                location,
124            )?)
125        } else {
126            None
127        };
128
129        args.push(FunctionArg {
130            name,
131            mode,
132            ty,
133            default,
134        });
135    }
136
137    // ── Options ──────────────────────────────────────────────────────────────
138    let mut language: Option<FunctionLanguage> = None;
139    let mut volatility: Option<Volatility> = None;
140    let mut strict = false;
141    let mut security: Option<SecurityMode> = None;
142    let mut parallel: Option<ParallelSafety> = None;
143    let mut leakproof = false;
144    let mut cost: Option<f32> = None;
145    let mut rows: Option<f32> = None;
146    let mut body_text: Option<String> = None;
147
148    for opt_node in &stmt.options {
149        let Some(NodeEnum::DefElem(de)) = opt_node.node.as_ref() else {
150            return Err(ParseError::Structural {
151                location: location.clone(),
152                message: format!("{qname}: unexpected option node"),
153            });
154        };
155
156        match de.defname.as_str() {
157            "language" => {
158                let lang_str = string_from_def_elem(de).ok_or_else(|| ParseError::Structural {
159                    location: location.clone(),
160                    message: format!("{qname}: LANGUAGE option missing value"),
161                })?;
162                language = Some(match lang_str.to_lowercase().as_str() {
163                    "sql" => FunctionLanguage::Sql,
164                    "plpgsql" => FunctionLanguage::PlPgSql,
165                    other => {
166                        return Err(ParseError::Structural {
167                            location: location.clone(),
168                            message: format!(
169                                "{qname}: unsupported language {other:?} \
170                                 (pgevolve v0.2 supports sql and plpgsql)"
171                            ),
172                        });
173                    }
174                });
175            }
176            "volatility" => {
177                if is_procedure {
178                    return Err(ParseError::Structural {
179                        location: location.clone(),
180                        message: format!(
181                            "{qname}: VOLATILE/STABLE/IMMUTABLE is not valid on procedures"
182                        ),
183                    });
184                }
185                let v_str = string_from_def_elem(de).ok_or_else(|| ParseError::Structural {
186                    location: location.clone(),
187                    message: format!("{qname}: volatility option missing value"),
188                })?;
189                volatility = Some(match v_str.to_lowercase().as_str() {
190                    "immutable" => Volatility::Immutable,
191                    "stable" => Volatility::Stable,
192                    "volatile" => Volatility::Volatile,
193                    other => {
194                        return Err(ParseError::Structural {
195                            location: location.clone(),
196                            message: format!("{qname}: unknown volatility {other:?}"),
197                        });
198                    }
199                });
200            }
201            "strict" => {
202                if is_procedure {
203                    return Err(ParseError::Structural {
204                        location: location.clone(),
205                        message: format!("{qname}: STRICT is not valid on procedures"),
206                    });
207                }
208                strict = bool_from_def_elem(de);
209            }
210            "security" => {
211                // PG encodes SECURITY DEFINER as defname="security" with
212                // arg = Boolean(true); SECURITY INVOKER as Boolean(false).
213                // Also accept Integer/String values for robustness.
214                security = Some(match de.arg.as_ref().and_then(|a| a.node.as_ref()) {
215                    Some(NodeEnum::Boolean(b)) => {
216                        if b.boolval {
217                            SecurityMode::Definer
218                        } else {
219                            SecurityMode::Invoker
220                        }
221                    }
222                    Some(NodeEnum::Integer(i)) => {
223                        if i.ival != 0 {
224                            SecurityMode::Definer
225                        } else {
226                            SecurityMode::Invoker
227                        }
228                    }
229                    Some(NodeEnum::String(s)) => match s.sval.to_lowercase().as_str() {
230                        "invoker" => SecurityMode::Invoker,
231                        "definer" => SecurityMode::Definer,
232                        other => {
233                            return Err(ParseError::Structural {
234                                location: location.clone(),
235                                message: format!("{qname}: unknown security mode {other:?}"),
236                            });
237                        }
238                    },
239                    None => SecurityMode::Invoker, // bare "security" without qualifier
240                    _ => {
241                        return Err(ParseError::Structural {
242                            location: location.clone(),
243                            message: format!("{qname}: SECURITY option has unexpected value"),
244                        });
245                    }
246                });
247            }
248            "parallel" => {
249                if is_procedure {
250                    return Err(ParseError::Structural {
251                        location: location.clone(),
252                        message: format!("{qname}: PARALLEL is not valid on procedures"),
253                    });
254                }
255                let p_str = string_from_def_elem(de).ok_or_else(|| ParseError::Structural {
256                    location: location.clone(),
257                    message: format!("{qname}: PARALLEL option missing value"),
258                })?;
259                parallel = Some(match p_str.to_lowercase().as_str() {
260                    "safe" => ParallelSafety::Safe,
261                    "restricted" => ParallelSafety::Restricted,
262                    "unsafe" => ParallelSafety::Unsafe,
263                    other => {
264                        return Err(ParseError::Structural {
265                            location: location.clone(),
266                            message: format!("{qname}: unknown parallel safety {other:?}"),
267                        });
268                    }
269                });
270            }
271            "leakproof" => {
272                if is_procedure {
273                    return Err(ParseError::Structural {
274                        location: location.clone(),
275                        message: format!("{qname}: LEAKPROOF is not valid on procedures"),
276                    });
277                }
278                leakproof = bool_from_def_elem(de);
279            }
280            "cost" => {
281                if is_procedure {
282                    return Err(ParseError::Structural {
283                        location: location.clone(),
284                        message: format!("{qname}: COST is not valid on procedures"),
285                    });
286                }
287                // Raw parse: `ir::canon::filter_pg_defaults` strips the
288                // PG-default value of 100 to None on both sides.
289                cost = float_from_def_elem(de);
290            }
291            "rows" => {
292                if is_procedure {
293                    return Err(ParseError::Structural {
294                        location: location.clone(),
295                        message: format!("{qname}: ROWS is not valid on procedures"),
296                    });
297                }
298                // Raw parse: `ir::canon::filter_pg_defaults` strips the
299                // PG-default (1000 for SETOF, 0 otherwise) to None.
300                rows = float_from_def_elem(de);
301            }
302            "as" => {
303                // The body is the first element (the $$ string $$).
304                // For sql_body-style functions, body comes via stmt.sql_body instead.
305                body_text = Some(string_from_def_elem(de).unwrap_or_default());
306            }
307            other => {
308                return Err(ParseError::Structural {
309                    location: location.clone(),
310                    message: format!("{qname}: unsupported function option {other:?}"),
311                });
312            }
313        }
314    }
315
316    let lang = language.unwrap_or(FunctionLanguage::Sql);
317
318    // ── Body parsing ─────────────────────────────────────────────────────────
319    // Delegate to the T4 PL/pgSQL body parser which handles both SQL and
320    // PL/pgSQL languages, extracts dep edges, and detects COMMIT/ROLLBACK.
321    let raw_body = body_text.as_deref().unwrap_or("").trim().to_string();
322    let (body, body_deps, commits_in_body) = if raw_body.is_empty() {
323        (NormalizedBody::empty(), vec![], false)
324    } else {
325        plpgsql::parse_routine_body(&raw_body, lang, &qname, location)?
326    };
327
328    if is_procedure {
329        // Procedure: no return type clause allowed.
330        if stmt.return_type.is_some() {
331            return Err(ParseError::Structural {
332                location: location.clone(),
333                message: format!("{qname}: procedures cannot have a RETURNS clause"),
334            });
335        }
336
337        let security = security.unwrap_or(SecurityMode::Invoker);
338
339        return Ok(Routine::Procedure(Procedure {
340            qname,
341            args,
342            language: lang,
343            body,
344            body_dependencies: body_deps,
345            security,
346            commits_in_body,
347            comment: None,
348            owner: None,
349            grants: vec![],
350        }));
351    }
352
353    // ── Return type ──────────────────────────────────────────────────────────
354    let return_type = if !table_columns.is_empty() {
355        // RETURNS TABLE(...) — collected from FuncParamTable parameters.
356        ReturnType::Table {
357            columns: table_columns,
358        }
359    } else if let Some(tn) = stmt.return_type.as_ref() {
360        let type_str =
361            shared::render_type_name_to_string(tn).ok_or_else(|| ParseError::Structural {
362                location: location.clone(),
363                message: format!("{qname}: could not stringify return type"),
364            })?;
365        match type_str.to_lowercase().as_str() {
366            "trigger" => ReturnType::Trigger,
367            "event_trigger" => ReturnType::EventTrigger,
368            "void" => ReturnType::Void,
369            _ => {
370                let ty = shared::type_name_to_column_type(tn, location)?;
371                if tn.setof {
372                    ReturnType::SetOf { ty }
373                } else {
374                    ReturnType::Scalar { ty }
375                }
376            }
377        }
378    } else {
379        return Err(ParseError::Structural {
380            location: location.clone(),
381            message: format!("{qname}: function is missing a RETURNS clause"),
382        });
383    };
384
385    let arg_types_normalized = NormalizedArgTypes::from_args(&args);
386
387    Ok(Routine::Function(Function {
388        qname,
389        args,
390        arg_types_normalized,
391        return_type,
392        language: lang,
393        body,
394        body_dependencies: body_deps,
395        volatility: volatility.unwrap_or(Volatility::Volatile),
396        strict,
397        security: security.unwrap_or(SecurityMode::Invoker),
398        parallel: parallel.unwrap_or(ParallelSafety::Unsafe),
399        leakproof,
400        cost,
401        rows,
402        comment: None,
403        owner: None,
404        grants: vec![],
405    }))
406}
407
408/// Extract a string value from a `DefElem.arg` (String node).
409///
410/// Dollar-quoted function bodies are wrapped in a `List` by `pg_query`;
411/// this function unwraps the outer `List` and returns the first `String`
412/// element's value (which is the body text without the dollar-quote
413/// delimiters).
414fn string_from_def_elem(de: &pg_query::protobuf::DefElem) -> Option<String> {
415    let arg = de.arg.as_ref()?;
416    match arg.node.as_ref()? {
417        NodeEnum::String(s) => Some(s.sval.clone()),
418        NodeEnum::Integer(i) => Some(i.ival.to_string()),
419        NodeEnum::Float(f) => Some(f.fval.clone()),
420        // Dollar-quoted bodies: the arg is a List whose first item is the
421        // body String (the second item, when present, is the dollar-quote tag).
422        NodeEnum::List(list) => list
423            .items
424            .first()
425            .and_then(|n| n.node.as_ref())
426            .and_then(|n| {
427                if let NodeEnum::String(s) = n {
428                    Some(s.sval.clone())
429                } else {
430                    None
431                }
432            }),
433        _ => None,
434    }
435}
436
437/// Extract a boolean value from a `DefElem.arg`, defaulting to `true` if
438/// the arg is absent (bare keyword like `STRICT` or `LEAKPROOF`).
439fn bool_from_def_elem(de: &pg_query::protobuf::DefElem) -> bool {
440    let Some(arg) = de.arg.as_ref() else {
441        return true; // bare keyword = true
442    };
443    match arg.node.as_ref() {
444        Some(NodeEnum::Integer(i)) => i.ival != 0,
445        _ => true,
446    }
447}
448
449/// Extract a float cost/rows value from a `DefElem.arg`.
450fn float_from_def_elem(de: &pg_query::protobuf::DefElem) -> Option<f32> {
451    let arg = de.arg.as_ref()?;
452    match arg.node.as_ref()? {
453        NodeEnum::Float(f) => f.fval.parse::<f32>().ok(),
454        #[allow(clippy::cast_precision_loss)]
455        NodeEnum::Integer(i) => Some(i.ival as f32),
456        _ => None,
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use std::path::PathBuf;
464
465    use pg_query::NodeEnum as PgNodeEnum;
466
467    fn loc() -> SourceLocation {
468        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
469    }
470
471    fn parse_function(sql: &str) -> CreateFunctionStmt {
472        let parsed = pg_query::parse(sql).expect("parses");
473        let node = parsed
474            .protobuf
475            .stmts
476            .into_iter()
477            .next()
478            .and_then(|r| r.stmt)
479            .and_then(|n| n.node)
480            .expect("stmt");
481        let PgNodeEnum::CreateFunctionStmt(boxed) = node else {
482            panic!("expected CreateFunctionStmt, got: {node:?}");
483        };
484        *boxed
485    }
486
487    fn build_fn(sql: &str) -> Function {
488        let stmt = parse_function(sql);
489        match build_function_or_procedure(&stmt, None, &loc()).expect("build") {
490            Routine::Function(f) => f,
491            Routine::Procedure(_) => panic!("expected Function"),
492        }
493    }
494
495    fn build_proc(sql: &str) -> Procedure {
496        let stmt = parse_function(sql);
497        match build_function_or_procedure(&stmt, None, &loc()).expect("build") {
498            Routine::Procedure(p) => p,
499            Routine::Function(_) => panic!("expected Procedure"),
500        }
501    }
502
503    #[test]
504    fn simple_sql_function() {
505        let f = build_fn(
506            "CREATE FUNCTION app.double(x integer) RETURNS integer \
507             LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT x * 2 $$;",
508        );
509        assert_eq!(f.qname.to_string(), "app.double");
510        assert_eq!(f.args.len(), 1);
511        assert_eq!(
512            f.args[0]
513                .name
514                .as_ref()
515                .map(crate::identifier::Identifier::as_str),
516            Some("x")
517        );
518        assert_eq!(f.args[0].mode, ArgMode::In);
519        assert!(matches!(
520            f.return_type,
521            ReturnType::Scalar {
522                ty: crate::ir::column_type::ColumnType::Integer
523            }
524        ));
525        assert_eq!(f.language, FunctionLanguage::Sql);
526        assert!(matches!(f.volatility, Volatility::Immutable));
527        assert!(f.strict);
528    }
529
530    #[test]
531    fn plpgsql_function_body_parsed() {
532        let f = build_fn(
533            "CREATE FUNCTION app.greet(name text) RETURNS text \
534             LANGUAGE plpgsql AS $$ BEGIN RETURN 'hello'; END $$;",
535        );
536        assert_eq!(f.qname.to_string(), "app.greet");
537        assert_eq!(f.language, FunctionLanguage::PlPgSql);
538        // T4: body is now parsed; canonical text should be non-empty.
539        assert!(
540            !f.body.canonical_text().is_empty(),
541            "PL/pgSQL body should be canonicalized, got empty string"
542        );
543    }
544
545    #[test]
546    fn unqualified_uses_default_schema() {
547        let stmt = parse_function(
548            "CREATE FUNCTION double(x integer) RETURNS integer \
549             LANGUAGE sql AS $$ SELECT x $$;",
550        );
551        let app = Identifier::from_unquoted("app").unwrap();
552        let Routine::Function(f) = build_function_or_procedure(&stmt, Some(&app), &loc()).unwrap()
553        else {
554            panic!()
555        };
556        assert_eq!(f.qname.to_string(), "app.double");
557    }
558
559    #[test]
560    fn unsupported_language_rejected() {
561        let stmt = parse_function("CREATE FUNCTION app.f() RETURNS void LANGUAGE plperl AS $$ $$;");
562        let err = build_function_or_procedure(&stmt, None, &loc()).unwrap_err();
563        let msg = match &err {
564            ParseError::Structural { message, .. } => message.clone(),
565            other => panic!("expected Structural, got {other:?}"),
566        };
567        assert!(msg.contains("plperl"), "should name bad language: {msg}");
568    }
569
570    #[test]
571    fn procedure_builds() {
572        let p = build_proc(
573            "CREATE PROCEDURE app.do_work() \
574             LANGUAGE plpgsql AS $$ BEGIN NULL; END $$;",
575        );
576        assert_eq!(p.qname.to_string(), "app.do_work");
577        assert!(p.args.is_empty());
578        assert_eq!(p.language, FunctionLanguage::PlPgSql);
579    }
580
581    #[test]
582    fn procedure_rejects_volatility() {
583        let stmt = parse_function(
584            "CREATE PROCEDURE app.p() LANGUAGE plpgsql VOLATILE AS $$ BEGIN NULL; END $$;",
585        );
586        let err = build_function_or_procedure(&stmt, None, &loc()).unwrap_err();
587        let msg = match &err {
588            ParseError::Structural { message, .. } => message.clone(),
589            other => panic!("expected Structural, got {other:?}"),
590        };
591        assert!(
592            msg.contains("VOLATILE") || msg.contains("STABLE") || msg.contains("IMMUTABLE"),
593            "msg: {msg}"
594        );
595    }
596
597    #[test]
598    fn procedure_rejects_return_type() {
599        // pg_query rejects CREATE PROCEDURE ... RETURNS at the grammar level,
600        // so the builder's defensive check is unreachable via parsed SQL. We
601        // construct a synthetic CreateFunctionStmt with is_procedure=true and
602        // return_type=Some(...) to exercise the rejection path directly.
603        use pg_query::NodeEnum;
604        use pg_query::protobuf::{CreateFunctionStmt, Node, TypeName};
605
606        // Build the type-name list for "app.proc".
607        let funcname = vec![
608            Node {
609                node: Some(NodeEnum::String(pg_query::protobuf::String {
610                    sval: "app".into(),
611                })),
612            },
613            Node {
614                node: Some(NodeEnum::String(pg_query::protobuf::String {
615                    sval: "proc".into(),
616                })),
617            },
618        ];
619        // Build a return-type TypeName for "integer".
620        let return_type = TypeName {
621            names: vec![Node {
622                node: Some(NodeEnum::String(pg_query::protobuf::String {
623                    sval: "integer".into(),
624                })),
625            }],
626            type_oid: 0,
627            setof: false,
628            pct_type: false,
629            typmods: vec![],
630            typemod: -1,
631            array_bounds: vec![],
632            location: 0,
633        };
634
635        let stmt = CreateFunctionStmt {
636            is_procedure: true,
637            replace: false,
638            funcname,
639            parameters: vec![],
640            return_type: Some(return_type),
641            options: vec![],
642            sql_body: None,
643        };
644
645        let err = build_function_or_procedure(&stmt, None, &loc()).unwrap_err();
646        let msg = match &err {
647            ParseError::Structural { message, .. } => message.clone(),
648            other => panic!("expected Structural, got {other:?}"),
649        };
650        let lower = msg.to_lowercase();
651        assert!(
652            lower.contains("return") || lower.contains("returns"),
653            "expected a 'returns/return-type' rejection message; got: {msg}",
654        );
655    }
656
657    #[test]
658    fn function_with_default_arg() {
659        let f = build_fn(
660            "CREATE FUNCTION app.greet(name text DEFAULT 'world') RETURNS text \
661             LANGUAGE sql AS $$ SELECT 'hello ' || name $$;",
662        );
663        assert_eq!(f.args.len(), 1);
664        assert!(f.args[0].default.is_some(), "should have a default");
665    }
666
667    #[test]
668    fn security_definer() {
669        let f = build_fn(
670            "CREATE FUNCTION app.f() RETURNS void LANGUAGE sql SECURITY DEFINER \
671             AS $$ SELECT NULL $$;",
672        );
673        assert!(matches!(f.security, SecurityMode::Definer));
674    }
675}