Skip to main content

razeri_plugin_contract/routine_render/
scalar.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! The scalar-function grammars.
6
7use super::{
8    inline_parameters, note_above, or_replace, plain_name, quoted_name, quoted_text,
9    refusal_comment,
10};
11use crate::routine_form::ScalarFunctionForm;
12use crate::routine_spec::{RoutineBody, RoutineReturns, RoutineSpec};
13
14/// **Spell this scalar function in a named grammar.**
15///
16/// Parameters:
17/// - `form`: the grammar to spell it in. `Custom` answers with the
18///   `PostgreSQL` form under a comment naming the grammar, so a dialect that is
19///   nearly one of these has something to start from;
20/// - `routine`: the function, in the shape a `Custom` dialect receives it.
21#[must_use]
22pub fn render_scalar_function_as(form: ScalarFunctionForm, routine: &RoutineSpec) -> String {
23    // No wildcard arm, deliberately: a form added to the enum has to be
24    // spelled here or the build stops.
25    match form {
26        ScalarFunctionForm::PostgreSql => postgres(routine),
27        ScalarFunctionForm::Redshift => redshift(routine),
28        ScalarFunctionForm::MySql => my_sql(routine),
29        ScalarFunctionForm::TSql => t_sql(routine),
30        ScalarFunctionForm::Snowflake => snowflake(routine),
31        ScalarFunctionForm::Databricks => databricks(routine),
32        ScalarFunctionForm::GoogleSql => google_sql(routine),
33        ScalarFunctionForm::DuckDb => duckdb(routine),
34        ScalarFunctionForm::ClickHouse => clickhouse(routine),
35        ScalarFunctionForm::Custom(grammar) => note_above(
36            &format!(
37                "You asked for {grammar}, which is your dialect's own grammar and not one spelled \
38                 here. The PostgreSQL form below is the nearest neutral starting point; the named \
39                 forms are reachable by naming one."
40            ),
41            &postgres(routine),
42        ),
43        ScalarFunctionForm::Unsupported(orientation) => {
44            refusal_comment("function", &routine.name, orientation)
45        }
46    }
47}
48
49/// The type the function returns, or an empty string where the spec carries a
50/// shape no scalar grammar can declare.
51fn returns(routine: &RoutineSpec) -> String {
52    match &routine.returns {
53        RoutineReturns::Value(data_type) => data_type.clone(),
54        _ => String::new(),
55    }
56}
57
58/// The one expression or query the function's value is.
59fn body(routine: &RoutineSpec) -> String {
60    match &routine.body {
61        RoutineBody::Expression(expression) => expression.clone(),
62        RoutineBody::Query(query) | RoutineBody::Block(query) => query.clone(),
63        RoutineBody::Statements(statements) => statements.join("; "),
64    }
65}
66
67/// `PostgreSQL` and Citus: a `RETURN` clause over a SQL body.
68fn postgres(routine: &RoutineSpec) -> String {
69    format!(
70        "CREATE {}FUNCTION {}({})\nRETURNS {}\nLANGUAGE SQL\nRETURN {};",
71        or_replace(routine),
72        plain_name(routine),
73        inline_parameters(routine),
74        returns(routine),
75        body(routine)
76    )
77}
78
79/// `Redshift`: the volatility is mandatory rather than optional, there is no
80/// `PARALLEL SAFE`, `LANGUAGE` follows the body rather than preceding it, and the
81/// body addresses its parameters as `$1` and never by name.
82fn redshift(routine: &RoutineSpec) -> String {
83    let mut expression = body(routine);
84    for (index, parameter) in routine.parameters.iter().enumerate() {
85        if !parameter.name.is_empty() {
86            expression = expression.replace(&parameter.name, &format!("${}", index + 1));
87        }
88    }
89    format!(
90        "CREATE {}FUNCTION {}({})\nRETURNS {}\nIMMUTABLE\nAS $$\n  SELECT {}\n$$ LANGUAGE sql;",
91        or_replace(routine),
92        plain_name(routine),
93        inline_parameters(routine),
94        returns(routine),
95        expression
96    )
97}
98
99/// `MySQL` and `MariaDB`: a replace is a `DROP` in front, and the description is
100/// a clause rather than a statement.
101fn my_sql(routine: &RoutineSpec) -> String {
102    let mut lines = Vec::new();
103    if routine.or_replace {
104        lines.push(format!("DROP FUNCTION IF EXISTS {};", plain_name(routine)));
105    }
106    lines.push(format!("CREATE FUNCTION {}({})", plain_name(routine), inline_parameters(routine)));
107    lines.push(format!("RETURNS {}", returns(routine)));
108    lines.push("READS SQL DATA".to_owned());
109    if let Some(comment) = &routine.comment {
110        lines.push(format!("COMMENT '{}'", quoted_text(comment)));
111    }
112    lines.push(format!("RETURN {}", body(routine)));
113    lines.join("\n")
114}
115
116/// T-SQL's scalar UDF: bracketed names, `@`-prefixed parameters, and a `BEGIN
117/// … END` block whose `RETURN` is the value.
118fn t_sql(routine: &RoutineSpec) -> String {
119    let create = if routine.or_replace { "CREATE OR ALTER FUNCTION" } else { "CREATE FUNCTION" };
120    let mut lines = vec![format!("{create} {}(", quoted_name(routine, '[', ']'))];
121    let last = routine.parameters.len().saturating_sub(1);
122    for (index, parameter) in routine.parameters.iter().enumerate() {
123        let comma = if index == last { "" } else { "," };
124        lines.push(format!("    @{} {}{}", parameter.name, parameter.data_type, comma));
125    }
126    lines.push(")".to_owned());
127    lines.push(format!("RETURNS {}", returns(routine)));
128    lines.push("AS".to_owned());
129    lines.push("BEGIN".to_owned());
130    lines.push(format!("    RETURN {}", body(routine)));
131    lines.push("END".to_owned());
132    lines.join("\n")
133}
134
135/// Snowflake's UDF, with the description as a clause.
136fn snowflake(routine: &RoutineSpec) -> String {
137    let mut lines = vec![
138        format!(
139            "CREATE {}FUNCTION {}({})",
140            or_replace(routine),
141            plain_name(routine),
142            inline_parameters(routine)
143        ),
144        format!("RETURNS {}", returns(routine)),
145        "LANGUAGE SQL".to_owned(),
146    ];
147    if let Some(comment) = &routine.comment {
148        lines.push(format!("COMMENT = '{}'", quoted_text(comment)));
149    }
150    lines.push("AS $$".to_owned());
151    lines.push(format!("RETURN {};", body(routine)));
152    lines.push("$$;".to_owned());
153    lines.join("\n")
154}
155
156/// Databricks SQL's UDF: a `RETURN` clause and no `LANGUAGE`.
157fn databricks(routine: &RoutineSpec) -> String {
158    let mut lines = vec![
159        format!(
160            "CREATE {}FUNCTION {}({})",
161            or_replace(routine),
162            plain_name(routine),
163            inline_parameters(routine)
164        ),
165        format!("RETURNS {}", returns(routine)),
166    ];
167    if let Some(comment) = &routine.comment {
168        lines.push(format!("COMMENT '{}'", quoted_text(comment)));
169    }
170    lines.push(format!("RETURN {}", body(routine)));
171    lines.join("\n")
172}
173
174/// `GoogleSQL`'s `CREATE FUNCTION`: the value is a parenthesised expression.
175fn google_sql(routine: &RoutineSpec) -> String {
176    format!(
177        "CREATE {}FUNCTION {}({})\nRETURNS {}\nAS (\n{}\n);",
178        or_replace(routine),
179        quoted_name(routine, '`', '`'),
180        inline_parameters(routine),
181        returns(routine),
182        body(routine)
183    )
184}
185
186/// `DuckDB`'s `CREATE MACRO`. A macro declares no types at all — not on its
187/// parameters and not on its result — so the types the spec carries have nowhere
188/// to go here.
189fn duckdb(routine: &RoutineSpec) -> String {
190    let parameters =
191        routine.parameters.iter().map(|p| p.name.clone()).collect::<Vec<_>>().join(", ");
192    format!(
193        "CREATE {}MACRO {}({}) AS (\n{}\n);",
194        or_replace(routine),
195        plain_name(routine),
196        parameters,
197        body(routine)
198    )
199}
200
201/// `ClickHouse`'s lambda. It has no schema for a function and no declared types,
202/// so the name is the bare one.
203fn clickhouse(routine: &RoutineSpec) -> String {
204    let parameters =
205        routine.parameters.iter().map(|p| p.name.clone()).collect::<Vec<_>>().join(", ");
206    format!(
207        "CREATE {}FUNCTION {} AS ({}) -> {}",
208        or_replace(routine),
209        routine.name,
210        parameters,
211        body(routine)
212    )
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::routine_spec::{RoutineKind, RoutineParameter};
219
220    fn a_function() -> RoutineSpec {
221        let mut spec = RoutineSpec::new(RoutineKind::ScalarFunction, "busy_day");
222        spec.schema = Some("retail_core".to_owned());
223        spec.or_replace = true;
224        spec.comment = Some("Whether the day was busy.".to_owned());
225        spec.parameters = vec![
226            RoutineParameter {
227                name: "p_rows".to_owned(),
228                data_type: "NUMERIC(19, 4)".to_owned(),
229                declared_type: None,
230                direction: crate::routine_spec::ParameterDirection::In,
231                default: None,
232            },
233            RoutineParameter {
234                name: "p_threshold".to_owned(),
235                data_type: "BIGINT".to_owned(),
236                declared_type: None,
237                direction: crate::routine_spec::ParameterDirection::In,
238                default: None,
239            },
240        ];
241        spec.returns = RoutineReturns::Value("BOOLEAN".to_owned());
242        spec.body = RoutineBody::Expression("p_rows > p_threshold".to_owned());
243        spec
244    }
245
246    /// `PostgreSQL`, whole.
247    #[test]
248    fn the_postgres_form_returns_the_expression() {
249        assert_eq!(
250            render_scalar_function_as(ScalarFunctionForm::PostgreSql, &a_function()),
251            "CREATE OR REPLACE FUNCTION retail_core.busy_day(p_rows NUMERIC(19, 4), p_threshold \
252             BIGINT)\nRETURNS BOOLEAN\nLANGUAGE SQL\nRETURN p_rows > p_threshold;"
253        );
254    }
255
256    /// **`Redshift` is not `PostgreSQL` here, and this is the difference that
257    /// costs a day.** The volatility is mandatory, `LANGUAGE` follows the body,
258    /// and the body addresses its parameters by position.
259    #[test]
260    fn the_redshift_form_is_positional_and_puts_language_last() {
261        assert_eq!(
262            render_scalar_function_as(ScalarFunctionForm::Redshift, &a_function()),
263            "CREATE OR REPLACE FUNCTION retail_core.busy_day(p_rows NUMERIC(19, 4), p_threshold \
264             BIGINT)\nRETURNS BOOLEAN\nIMMUTABLE\nAS $$\n  SELECT $1 > $2\n$$ LANGUAGE sql;"
265        );
266    }
267
268    /// T-SQL, whole.
269    #[test]
270    fn the_t_sql_form_brackets_the_name_and_returns_from_a_block() {
271        assert_eq!(
272            render_scalar_function_as(ScalarFunctionForm::TSql, &a_function()),
273            "CREATE OR ALTER FUNCTION [retail_core].[busy_day](\n    @p_rows NUMERIC(19, 4),\n    \
274             @p_threshold BIGINT\n)\nRETURNS BOOLEAN\nAS\nBEGIN\n    RETURN p_rows > \
275             p_threshold\nEND"
276        );
277    }
278
279    /// `MySQL` drops first and carries the description as a clause.
280    #[test]
281    fn the_mysql_form_drops_first() {
282        let rendered = render_scalar_function_as(ScalarFunctionForm::MySql, &a_function());
283        assert!(rendered.starts_with("DROP FUNCTION IF EXISTS retail_core.busy_day;\n"));
284        assert!(rendered.contains("\nREADS SQL DATA\nCOMMENT 'Whether the day was busy.'\n"));
285    }
286
287    /// **A macro declares no types.** `DuckDB` and `ClickHouse` both drop them,
288    /// and a renderer that emitted them writes a parse error.
289    #[test]
290    fn the_macro_forms_declare_no_types() {
291        let duck = render_scalar_function_as(ScalarFunctionForm::DuckDb, &a_function());
292        assert_eq!(
293            duck,
294            "CREATE OR REPLACE MACRO retail_core.busy_day(p_rows, p_threshold) AS (\np_rows > \
295             p_threshold\n);"
296        );
297        let click = render_scalar_function_as(ScalarFunctionForm::ClickHouse, &a_function());
298        assert_eq!(
299            click,
300            "CREATE OR REPLACE FUNCTION busy_day AS (p_rows, p_threshold) -> p_rows > p_threshold"
301        );
302        for rendered in [duck, click] {
303            assert!(!rendered.contains("NUMERIC"), "{rendered}");
304        }
305    }
306
307    /// Snowflake and `GoogleSQL`, whole.
308    #[test]
309    fn the_snowflake_and_google_forms_carry_their_own_clauses() {
310        assert_eq!(
311            render_scalar_function_as(ScalarFunctionForm::Snowflake, &a_function()),
312            "CREATE OR REPLACE FUNCTION retail_core.busy_day(p_rows NUMERIC(19, 4), p_threshold \
313             BIGINT)\nRETURNS BOOLEAN\nLANGUAGE SQL\nCOMMENT = 'Whether the day was \
314             busy.'\nAS $$\nRETURN p_rows > p_threshold;\n$$;"
315        );
316        assert_eq!(
317            render_scalar_function_as(ScalarFunctionForm::GoogleSql, &a_function()),
318            "CREATE OR REPLACE FUNCTION `retail_core`.`busy_day`(p_rows NUMERIC(19, 4), \
319             p_threshold BIGINT)\nRETURNS BOOLEAN\nAS (\np_rows > p_threshold\n);"
320        );
321    }
322
323    /// A refusal is the dialect's own sentence in a comment, and never a
324    /// statement.
325    #[test]
326    fn an_unsupported_form_spells_no_statement() {
327        let rendered = render_scalar_function_as(
328            ScalarFunctionForm::Unsupported("compute the value in the SELECT that needs it"),
329            &a_function(),
330        );
331        assert!(rendered.contains("compute the value in the SELECT that needs it"));
332        assert!(!rendered.contains("CREATE"), "{rendered}");
333    }
334}