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 =
159                    shared::def_elem_string(de).ok_or_else(|| ParseError::Structural {
160                        location: location.clone(),
161                        message: format!("{qname}: LANGUAGE option missing value"),
162                    })?;
163                language = Some(match lang_str.to_lowercase().as_str() {
164                    "sql" => FunctionLanguage::Sql,
165                    "plpgsql" => FunctionLanguage::PlPgSql,
166                    other => {
167                        return Err(ParseError::Structural {
168                            location: location.clone(),
169                            message: format!(
170                                "{qname}: unsupported language {other:?} \
171                                 (pgevolve v0.2 supports sql and plpgsql)"
172                            ),
173                        });
174                    }
175                });
176            }
177            "volatility" => {
178                if is_procedure {
179                    return Err(ParseError::Structural {
180                        location: location.clone(),
181                        message: format!(
182                            "{qname}: VOLATILE/STABLE/IMMUTABLE is not valid on procedures"
183                        ),
184                    });
185                }
186                let v_str = shared::def_elem_string(de).ok_or_else(|| ParseError::Structural {
187                    location: location.clone(),
188                    message: format!("{qname}: volatility option missing value"),
189                })?;
190                volatility = Some(match v_str.to_lowercase().as_str() {
191                    "immutable" => Volatility::Immutable,
192                    "stable" => Volatility::Stable,
193                    "volatile" => Volatility::Volatile,
194                    other => {
195                        return Err(ParseError::Structural {
196                            location: location.clone(),
197                            message: format!("{qname}: unknown volatility {other:?}"),
198                        });
199                    }
200                });
201            }
202            "strict" => {
203                if is_procedure {
204                    return Err(ParseError::Structural {
205                        location: location.clone(),
206                        message: format!("{qname}: STRICT is not valid on procedures"),
207                    });
208                }
209                strict = bool_from_def_elem(de);
210            }
211            "security" => {
212                // PG encodes SECURITY DEFINER as defname="security" with
213                // arg = Boolean(true); SECURITY INVOKER as Boolean(false).
214                // Also accept Integer/String values for robustness.
215                security = Some(match de.arg.as_ref().and_then(|a| a.node.as_ref()) {
216                    Some(NodeEnum::Boolean(b)) => {
217                        if b.boolval {
218                            SecurityMode::Definer
219                        } else {
220                            SecurityMode::Invoker
221                        }
222                    }
223                    Some(NodeEnum::Integer(i)) => {
224                        if i.ival != 0 {
225                            SecurityMode::Definer
226                        } else {
227                            SecurityMode::Invoker
228                        }
229                    }
230                    Some(NodeEnum::String(s)) => match s.sval.to_lowercase().as_str() {
231                        "invoker" => SecurityMode::Invoker,
232                        "definer" => SecurityMode::Definer,
233                        other => {
234                            return Err(ParseError::Structural {
235                                location: location.clone(),
236                                message: format!("{qname}: unknown security mode {other:?}"),
237                            });
238                        }
239                    },
240                    None => SecurityMode::Invoker, // bare "security" without qualifier
241                    _ => {
242                        return Err(ParseError::Structural {
243                            location: location.clone(),
244                            message: format!("{qname}: SECURITY option has unexpected value"),
245                        });
246                    }
247                });
248            }
249            "parallel" => {
250                if is_procedure {
251                    return Err(ParseError::Structural {
252                        location: location.clone(),
253                        message: format!("{qname}: PARALLEL is not valid on procedures"),
254                    });
255                }
256                let p_str = shared::def_elem_string(de).ok_or_else(|| ParseError::Structural {
257                    location: location.clone(),
258                    message: format!("{qname}: PARALLEL option missing value"),
259                })?;
260                parallel = Some(match p_str.to_lowercase().as_str() {
261                    "safe" => ParallelSafety::Safe,
262                    "restricted" => ParallelSafety::Restricted,
263                    "unsafe" => ParallelSafety::Unsafe,
264                    other => {
265                        return Err(ParseError::Structural {
266                            location: location.clone(),
267                            message: format!("{qname}: unknown parallel safety {other:?}"),
268                        });
269                    }
270                });
271            }
272            "leakproof" => {
273                if is_procedure {
274                    return Err(ParseError::Structural {
275                        location: location.clone(),
276                        message: format!("{qname}: LEAKPROOF is not valid on procedures"),
277                    });
278                }
279                leakproof = bool_from_def_elem(de);
280            }
281            "cost" => {
282                if is_procedure {
283                    return Err(ParseError::Structural {
284                        location: location.clone(),
285                        message: format!("{qname}: COST is not valid on procedures"),
286                    });
287                }
288                // Raw parse: `ir::canon::filter_pg_defaults` strips the
289                // PG-default value of 100 to None on both sides.
290                cost = float_from_def_elem(de);
291            }
292            "rows" => {
293                if is_procedure {
294                    return Err(ParseError::Structural {
295                        location: location.clone(),
296                        message: format!("{qname}: ROWS is not valid on procedures"),
297                    });
298                }
299                // Raw parse: `ir::canon::filter_pg_defaults` strips the
300                // PG-default (1000 for SETOF, 0 otherwise) to None.
301                rows = float_from_def_elem(de);
302            }
303            "as" => {
304                // The body is the first element (the $$ string $$).
305                // For sql_body-style functions, body comes via stmt.sql_body instead.
306                body_text = Some(shared::def_elem_string(de).unwrap_or_default());
307            }
308            other => {
309                return Err(ParseError::Structural {
310                    location: location.clone(),
311                    message: format!("{qname}: unsupported function option {other:?}"),
312                });
313            }
314        }
315    }
316
317    let lang = language.unwrap_or(FunctionLanguage::Sql);
318
319    // ── Body parsing ─────────────────────────────────────────────────────────
320    // Delegate to the T4 PL/pgSQL body parser which handles both SQL and
321    // PL/pgSQL languages, extracts dep edges, and detects COMMIT/ROLLBACK.
322    let raw_body = body_text.as_deref().unwrap_or("").trim().to_string();
323    let (body, body_deps, commits_in_body) = if raw_body.is_empty() {
324        (NormalizedBody::empty(), vec![], false)
325    } else {
326        plpgsql::parse_routine_body(&raw_body, lang, &qname, location)?
327    };
328
329    if is_procedure {
330        // Procedure: no return type clause allowed.
331        if stmt.return_type.is_some() {
332            return Err(ParseError::Structural {
333                location: location.clone(),
334                message: format!("{qname}: procedures cannot have a RETURNS clause"),
335            });
336        }
337
338        let security = security.unwrap_or(SecurityMode::Invoker);
339
340        return Ok(Routine::Procedure(Procedure {
341            qname,
342            args,
343            language: lang,
344            body,
345            body_dependencies: body_deps,
346            security,
347            commits_in_body,
348            comment: None,
349            owner: None,
350            grants: vec![],
351        }));
352    }
353
354    // ── Return type ──────────────────────────────────────────────────────────
355    let return_type = if !table_columns.is_empty() {
356        // RETURNS TABLE(...) — collected from FuncParamTable parameters.
357        ReturnType::Table {
358            columns: table_columns,
359        }
360    } else if let Some(tn) = stmt.return_type.as_ref() {
361        let type_str =
362            shared::render_type_name_to_string(tn).ok_or_else(|| ParseError::Structural {
363                location: location.clone(),
364                message: format!("{qname}: could not stringify return type"),
365            })?;
366        match type_str.to_lowercase().as_str() {
367            "trigger" => ReturnType::Trigger,
368            "event_trigger" => ReturnType::EventTrigger,
369            "void" => ReturnType::Void,
370            _ => {
371                let ty = shared::type_name_to_column_type(tn, location)?;
372                if tn.setof {
373                    ReturnType::SetOf { ty }
374                } else {
375                    ReturnType::Scalar { ty }
376                }
377            }
378        }
379    } else {
380        return Err(ParseError::Structural {
381            location: location.clone(),
382            message: format!("{qname}: function is missing a RETURNS clause"),
383        });
384    };
385
386    let arg_types_normalized = NormalizedArgTypes::from_args(&args);
387
388    Ok(Routine::Function(Function {
389        qname,
390        args,
391        arg_types_normalized,
392        return_type,
393        language: lang,
394        body,
395        body_dependencies: body_deps,
396        volatility: volatility.unwrap_or(Volatility::Volatile),
397        strict,
398        security: security.unwrap_or(SecurityMode::Invoker),
399        parallel: parallel.unwrap_or(ParallelSafety::Unsafe),
400        leakproof,
401        cost,
402        rows,
403        comment: None,
404        owner: None,
405        grants: vec![],
406    }))
407}
408
409/// Extract a boolean value from a `DefElem.arg`, defaulting to `true` if
410/// the arg is absent (bare keyword like `STRICT` or `LEAKPROOF`).
411fn bool_from_def_elem(de: &pg_query::protobuf::DefElem) -> bool {
412    let Some(arg) = de.arg.as_ref() else {
413        return true; // bare keyword = true
414    };
415    match arg.node.as_ref() {
416        Some(NodeEnum::Integer(i)) => i.ival != 0,
417        _ => true,
418    }
419}
420
421/// Extract a float cost/rows value from a `DefElem.arg`.
422fn float_from_def_elem(de: &pg_query::protobuf::DefElem) -> Option<f32> {
423    let arg = de.arg.as_ref()?;
424    match arg.node.as_ref()? {
425        NodeEnum::Float(f) => f.fval.parse::<f32>().ok(),
426        #[allow(clippy::cast_precision_loss)]
427        NodeEnum::Integer(i) => Some(i.ival as f32),
428        _ => None,
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use std::path::PathBuf;
436
437    use pg_query::NodeEnum as PgNodeEnum;
438
439    fn loc() -> SourceLocation {
440        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
441    }
442
443    fn parse_function(sql: &str) -> CreateFunctionStmt {
444        let parsed = pg_query::parse(sql).expect("parses");
445        let node = parsed
446            .protobuf
447            .stmts
448            .into_iter()
449            .next()
450            .and_then(|r| r.stmt)
451            .and_then(|n| n.node)
452            .expect("stmt");
453        let PgNodeEnum::CreateFunctionStmt(boxed) = node else {
454            panic!("expected CreateFunctionStmt, got: {node:?}");
455        };
456        *boxed
457    }
458
459    fn build_fn(sql: &str) -> Function {
460        let stmt = parse_function(sql);
461        match build_function_or_procedure(&stmt, None, &loc()).expect("build") {
462            Routine::Function(f) => f,
463            Routine::Procedure(_) => panic!("expected Function"),
464        }
465    }
466
467    fn build_proc(sql: &str) -> Procedure {
468        let stmt = parse_function(sql);
469        match build_function_or_procedure(&stmt, None, &loc()).expect("build") {
470            Routine::Procedure(p) => p,
471            Routine::Function(_) => panic!("expected Procedure"),
472        }
473    }
474
475    #[test]
476    fn simple_sql_function() {
477        let f = build_fn(
478            "CREATE FUNCTION app.double(x integer) RETURNS integer \
479             LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT x * 2 $$;",
480        );
481        assert_eq!(f.qname.to_string(), "app.double");
482        assert_eq!(f.args.len(), 1);
483        assert_eq!(
484            f.args[0]
485                .name
486                .as_ref()
487                .map(crate::identifier::Identifier::as_str),
488            Some("x")
489        );
490        assert_eq!(f.args[0].mode, ArgMode::In);
491        assert!(matches!(
492            f.return_type,
493            ReturnType::Scalar {
494                ty: crate::ir::column_type::ColumnType::Integer
495            }
496        ));
497        assert_eq!(f.language, FunctionLanguage::Sql);
498        assert!(matches!(f.volatility, Volatility::Immutable));
499        assert!(f.strict);
500    }
501
502    #[test]
503    fn plpgsql_function_body_parsed() {
504        let f = build_fn(
505            "CREATE FUNCTION app.greet(name text) RETURNS text \
506             LANGUAGE plpgsql AS $$ BEGIN RETURN 'hello'; END $$;",
507        );
508        assert_eq!(f.qname.to_string(), "app.greet");
509        assert_eq!(f.language, FunctionLanguage::PlPgSql);
510        // T4: body is now parsed; canonical text should be non-empty.
511        assert!(
512            !f.body.canonical_text().is_empty(),
513            "PL/pgSQL body should be canonicalized, got empty string"
514        );
515    }
516
517    #[test]
518    fn unqualified_uses_default_schema() {
519        let stmt = parse_function(
520            "CREATE FUNCTION double(x integer) RETURNS integer \
521             LANGUAGE sql AS $$ SELECT x $$;",
522        );
523        let app = Identifier::from_unquoted("app").unwrap();
524        let Routine::Function(f) = build_function_or_procedure(&stmt, Some(&app), &loc()).unwrap()
525        else {
526            panic!()
527        };
528        assert_eq!(f.qname.to_string(), "app.double");
529    }
530
531    #[test]
532    fn unsupported_language_rejected() {
533        let stmt = parse_function("CREATE FUNCTION app.f() RETURNS void LANGUAGE plperl AS $$ $$;");
534        let err = build_function_or_procedure(&stmt, None, &loc()).unwrap_err();
535        let msg = match &err {
536            ParseError::Structural { message, .. } => message.clone(),
537            other => panic!("expected Structural, got {other:?}"),
538        };
539        assert!(msg.contains("plperl"), "should name bad language: {msg}");
540    }
541
542    #[test]
543    fn procedure_builds() {
544        let p = build_proc(
545            "CREATE PROCEDURE app.do_work() \
546             LANGUAGE plpgsql AS $$ BEGIN NULL; END $$;",
547        );
548        assert_eq!(p.qname.to_string(), "app.do_work");
549        assert!(p.args.is_empty());
550        assert_eq!(p.language, FunctionLanguage::PlPgSql);
551    }
552
553    #[test]
554    fn procedure_rejects_volatility() {
555        let stmt = parse_function(
556            "CREATE PROCEDURE app.p() LANGUAGE plpgsql VOLATILE AS $$ BEGIN NULL; END $$;",
557        );
558        let err = build_function_or_procedure(&stmt, None, &loc()).unwrap_err();
559        let msg = match &err {
560            ParseError::Structural { message, .. } => message.clone(),
561            other => panic!("expected Structural, got {other:?}"),
562        };
563        assert!(
564            msg.contains("VOLATILE") || msg.contains("STABLE") || msg.contains("IMMUTABLE"),
565            "msg: {msg}"
566        );
567    }
568
569    #[test]
570    fn procedure_rejects_return_type() {
571        // pg_query rejects CREATE PROCEDURE ... RETURNS at the grammar level,
572        // so the builder's defensive check is unreachable via parsed SQL. We
573        // construct a synthetic CreateFunctionStmt with is_procedure=true and
574        // return_type=Some(...) to exercise the rejection path directly.
575        use pg_query::NodeEnum;
576        use pg_query::protobuf::{CreateFunctionStmt, Node, TypeName};
577
578        // Build the type-name list for "app.proc".
579        let funcname = vec![
580            Node {
581                node: Some(NodeEnum::String(pg_query::protobuf::String {
582                    sval: "app".into(),
583                })),
584            },
585            Node {
586                node: Some(NodeEnum::String(pg_query::protobuf::String {
587                    sval: "proc".into(),
588                })),
589            },
590        ];
591        // Build a return-type TypeName for "integer".
592        let return_type = TypeName {
593            names: vec![Node {
594                node: Some(NodeEnum::String(pg_query::protobuf::String {
595                    sval: "integer".into(),
596                })),
597            }],
598            type_oid: 0,
599            setof: false,
600            pct_type: false,
601            typmods: vec![],
602            typemod: -1,
603            array_bounds: vec![],
604            location: 0,
605        };
606
607        let stmt = CreateFunctionStmt {
608            is_procedure: true,
609            replace: false,
610            funcname,
611            parameters: vec![],
612            return_type: Some(return_type),
613            options: vec![],
614            sql_body: None,
615        };
616
617        let err = build_function_or_procedure(&stmt, None, &loc()).unwrap_err();
618        let msg = match &err {
619            ParseError::Structural { message, .. } => message.clone(),
620            other => panic!("expected Structural, got {other:?}"),
621        };
622        let lower = msg.to_lowercase();
623        assert!(
624            lower.contains("return") || lower.contains("returns"),
625            "expected a 'returns/return-type' rejection message; got: {msg}",
626        );
627    }
628
629    #[test]
630    fn function_with_default_arg() {
631        let f = build_fn(
632            "CREATE FUNCTION app.greet(name text DEFAULT 'world') RETURNS text \
633             LANGUAGE sql AS $$ SELECT 'hello ' || name $$;",
634        );
635        assert_eq!(f.args.len(), 1);
636        assert!(f.args[0].default.is_some(), "should have a default");
637    }
638
639    #[test]
640    fn security_definer() {
641        let f = build_fn(
642            "CREATE FUNCTION app.f() RETURNS void LANGUAGE sql SECURITY DEFINER \
643             AS $$ SELECT NULL $$;",
644        );
645        assert!(matches!(f.security, SecurityMode::Definer));
646    }
647}