Skip to main content

razeri_plugin_contract/routine_render/
table.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 table-valued-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::TableFunctionForm;
12use crate::routine_spec::{ParameterDirection, RoutineBody, RoutineReturns, RoutineSpec};
13
14/// **Spell this table-valued 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;
19/// - `routine`: the function, in the shape a `Custom` dialect receives it.
20#[must_use]
21pub fn render_table_function_as(form: TableFunctionForm, routine: &RoutineSpec) -> String {
22    // No wildcard arm, deliberately: a form added to the enum has to be
23    // spelled here or the build stops.
24    match form {
25        TableFunctionForm::PostgreSql => replacing_postgres(routine),
26        TableFunctionForm::TSql => t_sql(routine),
27        TableFunctionForm::Snowflake => snowflake(routine),
28        TableFunctionForm::Databricks => databricks(routine),
29        TableFunctionForm::GoogleSql => google_sql(routine),
30        TableFunctionForm::DuckDb => duckdb(routine),
31        TableFunctionForm::ClickHouse => clickhouse(routine),
32        TableFunctionForm::Custom(grammar) => note_above(
33            &format!(
34                "You asked for {grammar}, which is your dialect's own grammar and not one spelled \
35                 here. The PostgreSQL form below is the nearest neutral starting point; the named \
36                 forms are reachable by naming one."
37            ),
38            &postgres(routine),
39        ),
40        TableFunctionForm::Unsupported(orientation) => {
41            refusal_comment("table function", &routine.name, orientation)
42        }
43    }
44}
45
46/// The row shape, spelled the way most grammars spell it.
47fn row_shape(routine: &RoutineSpec) -> String {
48    match &routine.returns {
49        RoutineReturns::Columns(columns) => format!(
50            "TABLE({})",
51            columns
52                .iter()
53                .map(|c| format!("{} {}", c.name, c.data_type))
54                .collect::<Vec<_>>()
55                .join(", ")
56        ),
57        RoutineReturns::RowsOf(relation) => format!("SETOF {relation}"),
58        RoutineReturns::Value(data_type) => format!("SETOF {data_type}"),
59        RoutineReturns::Inferred | RoutineReturns::Nothing => "TABLE".to_owned(),
60    }
61}
62
63/// The query whose rows the function returns.
64fn body(routine: &RoutineSpec) -> String {
65    match &routine.body {
66        RoutineBody::Query(query) | RoutineBody::Block(query) => query.clone(),
67        RoutineBody::Expression(expression) => expression.clone(),
68        RoutineBody::Statements(statements) => statements.join(";\n"),
69    }
70}
71
72/// **`PostgreSQL` and Citus, with the drop a replace needs.**
73///
74/// The engine refuses to replace a function whose returned columns changed:
75/// *cannot change return type of existing function*. A table function holds
76/// no rows, so one that is replaced is dropped first and created again. The
77/// drop names it by its input types, the way the engine names a function, and
78/// never cascades: a view of the operator's own that reads it stops the deploy
79/// here, and the engine names the view. Razeri's own renderer writes the same
80/// two statements.
81fn replacing_postgres(routine: &RoutineSpec) -> String {
82    if !routine.or_replace {
83        return postgres(routine);
84    }
85    let inputs = routine
86        .parameters
87        .iter()
88        .filter(|parameter| parameter.direction != ParameterDirection::Out)
89        .map(|parameter| parameter.data_type.as_str())
90        .collect::<Vec<_>>()
91        .join(", ");
92    format!("DROP FUNCTION IF EXISTS {}({inputs});\n{}", plain_name(routine), postgres(routine))
93}
94
95/// `PostgreSQL` and Citus: `RETURNS TABLE(…)` or `RETURNS SETOF …` over a
96/// dollar-quoted body.
97fn postgres(routine: &RoutineSpec) -> String {
98    format!(
99        "CREATE {}FUNCTION {}({})\nRETURNS {}\nLANGUAGE SQL\nAS $$\n{}\n$$;",
100        or_replace(routine),
101        plain_name(routine),
102        inline_parameters(routine),
103        row_shape(routine),
104        body(routine)
105    )
106}
107
108/// T-SQL's inline table-valued function. The row shape is not declared: the
109/// engine reads it off the query, and `RETURNS TABLE` is the whole clause.
110fn t_sql(routine: &RoutineSpec) -> String {
111    let create = if routine.or_replace { "CREATE OR ALTER FUNCTION" } else { "CREATE FUNCTION" };
112    let mut lines = vec![format!("{create} {}(", quoted_name(routine, '[', ']'))];
113    let last = routine.parameters.len().saturating_sub(1);
114    for (index, parameter) in routine.parameters.iter().enumerate() {
115        let comma = if index == last { "" } else { "," };
116        lines.push(format!("    @{} {}{}", parameter.name, parameter.data_type, comma));
117    }
118    lines.push(")".to_owned());
119    lines.push("RETURNS TABLE".to_owned());
120    lines.push("AS".to_owned());
121    lines.push("RETURN (".to_owned());
122    lines.push(format!("    {}", body(routine)));
123    lines.push(")".to_owned());
124    lines.join("\n")
125}
126
127/// Snowflake's UDTF, with the description as a clause.
128fn snowflake(routine: &RoutineSpec) -> String {
129    let mut lines = vec![
130        format!(
131            "CREATE {}FUNCTION {}({})",
132            or_replace(routine),
133            plain_name(routine),
134            inline_parameters(routine)
135        ),
136        format!("RETURNS {}", row_shape(routine)),
137        "LANGUAGE SQL".to_owned(),
138    ];
139    if let Some(comment) = &routine.comment {
140        lines.push(format!("COMMENT = '{}'", quoted_text(comment)));
141    }
142    lines.push("AS $$".to_owned());
143    lines.push(body(routine));
144    lines.push("$$;".to_owned());
145    lines.join("\n")
146}
147
148/// Databricks SQL: `RETURNS TABLE … RETURN <query>`, and no `LANGUAGE`.
149fn databricks(routine: &RoutineSpec) -> String {
150    let mut lines = vec![
151        format!(
152            "CREATE {}FUNCTION {}({})",
153            or_replace(routine),
154            plain_name(routine),
155            inline_parameters(routine)
156        ),
157        format!("RETURNS {}", row_shape(routine)),
158    ];
159    if let Some(comment) = &routine.comment {
160        lines.push(format!("COMMENT '{}'", quoted_text(comment)));
161    }
162    lines.push(format!("RETURN {}", body(routine)));
163    lines.join("\n")
164}
165
166/// `GoogleSQL`'s `CREATE TABLE FUNCTION`. The row shape is read off the query.
167fn google_sql(routine: &RoutineSpec) -> String {
168    format!(
169        "CREATE {}TABLE FUNCTION {}({}) AS (\n{}\n);",
170        or_replace(routine),
171        quoted_name(routine, '`', '`'),
172        inline_parameters(routine),
173        body(routine)
174    )
175}
176
177/// `DuckDB`'s table `MACRO`. There is no `RETURNS` clause at all, which is why
178/// riding the `PostgreSQL` form here is a parse error at `RETURNS`.
179fn duckdb(routine: &RoutineSpec) -> String {
180    let parameters =
181        routine.parameters.iter().map(|p| p.name.clone()).collect::<Vec<_>>().join(", ");
182    format!(
183        "CREATE {}MACRO {}({}) AS TABLE\n{}",
184        or_replace(routine),
185        plain_name(routine),
186        parameters,
187        body(routine)
188    )
189}
190
191/// `ClickHouse`'s parameterized view. It is a view and not a function, which is
192/// why the parameters are not in the statement at all: the query addresses them
193/// as `{name:Type}` and the caller supplies them at the call site.
194fn clickhouse(routine: &RoutineSpec) -> String {
195    format!("CREATE {}VIEW {} AS\n{}", or_replace(routine), plain_name(routine), body(routine))
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::routine_spec::{RoutineColumn, RoutineKind, RoutineParameter};
202
203    fn a_function() -> RoutineSpec {
204        let mut spec = RoutineSpec::new(RoutineKind::TableFunction, "customer_at");
205        spec.schema = Some("retail_core".to_owned());
206        spec.or_replace = true;
207        spec.comment = Some("The customers as they stood.".to_owned());
208        spec.parameters = vec![RoutineParameter {
209            name: "p_as_of".to_owned(),
210            data_type: "DATE".to_owned(),
211            declared_type: None,
212            direction: ParameterDirection::In,
213            default: None,
214        }];
215        spec.returns = RoutineReturns::Columns(vec![
216            RoutineColumn {
217                name: "customer_sid".to_owned(),
218                data_type: "BIGINT".to_owned(),
219                declared_type: None,
220            },
221            RoutineColumn {
222                name: "customer_name".to_owned(),
223                data_type: "VARCHAR(200)".to_owned(),
224                declared_type: None,
225            },
226        ]);
227        spec.body =
228            RoutineBody::Query("SELECT customer_sid, customer_name FROM customer".to_owned());
229        spec
230    }
231
232    /// `PostgreSQL`, whole: the drop, then the function.
233    #[test]
234    fn the_postgres_form_declares_the_row_shape() {
235        assert_eq!(
236            render_table_function_as(TableFunctionForm::PostgreSql, &a_function()),
237            "DROP FUNCTION IF EXISTS retail_core.customer_at(DATE);\nCREATE OR REPLACE FUNCTION retail_core.customer_at(p_as_of DATE)\nRETURNS \
238             TABLE(customer_sid BIGINT, customer_name VARCHAR(200))\nLANGUAGE SQL\nAS \
239             $$\nSELECT customer_sid, customer_name FROM customer\n$$;"
240        );
241    }
242
243    /// **A replaced function is dropped first on `PostgreSQL`**, because the
244    /// engine will not replace one whose returned columns changed: *cannot
245    /// change return type of existing function*. The drop names the function by
246    /// its input types, as the engine does, and leaves an `OUT` parameter out.
247    /// A function that is created, not replaced, has nothing to drop, and the
248    /// `PostgreSQL` form a `Custom` grammar starts from is left as it was.
249    #[test]
250    fn a_replaced_postgres_function_is_dropped_first_by_its_input_types() {
251        let mut spec = a_function();
252        spec.parameters.push(RoutineParameter {
253            name: "p_seen".to_owned(),
254            data_type: "BIGINT".to_owned(),
255            declared_type: None,
256            direction: ParameterDirection::Out,
257            default: None,
258        });
259        let rendered = render_table_function_as(TableFunctionForm::PostgreSql, &spec);
260        assert!(
261            rendered.starts_with(
262                "DROP FUNCTION IF EXISTS retail_core.customer_at(DATE);\nCREATE OR REPLACE \
263                 FUNCTION retail_core.customer_at("
264            ),
265            "{rendered}"
266        );
267        spec.or_replace = false;
268        let created = render_table_function_as(TableFunctionForm::PostgreSql, &spec);
269        assert!(created.starts_with("CREATE FUNCTION"), "{created}");
270        let custom = render_table_function_as(TableFunctionForm::Custom("PL/SQL"), &a_function());
271        assert!(!custom.contains("DROP FUNCTION"), "{custom}");
272    }
273
274    /// **`RETURNS SETOF` is one column, and a routine declaring several has
275    /// nowhere to put them.** A spec asking for rows of an existing relation gets
276    /// the `SETOF` spelling and nothing else.
277    #[test]
278    fn rows_of_a_relation_are_setof_and_not_a_column_list() {
279        let mut spec = a_function();
280        spec.returns = RoutineReturns::RowsOf("customer".to_owned());
281        assert!(
282            render_table_function_as(TableFunctionForm::PostgreSql, &spec)
283                .contains("RETURNS SETOF customer\n")
284        );
285    }
286
287    /// **T-SQL reads the row shape off the query.** `RETURNS TABLE` is the whole
288    /// clause for an inline TVF, and a column list there is a syntax error.
289    #[test]
290    fn the_t_sql_form_declares_no_columns() {
291        let rendered = render_table_function_as(TableFunctionForm::TSql, &a_function());
292        assert_eq!(
293            rendered,
294            "CREATE OR ALTER FUNCTION [retail_core].[customer_at](\n    @p_as_of \
295             DATE\n)\nRETURNS TABLE\nAS\nRETURN (\n    SELECT customer_sid, customer_name FROM \
296             customer\n)"
297        );
298        assert!(!rendered.contains("customer_sid BIGINT"), "{rendered}");
299    }
300
301    /// **`DuckDB` has no `RETURNS` clause at all**, which is why riding the
302    /// `PostgreSQL` form here is a parse error at `RETURNS`.
303    #[test]
304    fn the_duckdb_macro_has_no_returns_clause() {
305        let rendered = render_table_function_as(TableFunctionForm::DuckDb, &a_function());
306        assert_eq!(
307            rendered,
308            "CREATE OR REPLACE MACRO retail_core.customer_at(p_as_of) AS TABLE\nSELECT \
309             customer_sid, customer_name FROM customer"
310        );
311        assert!(!rendered.contains("RETURNS"), "{rendered}");
312    }
313
314    /// Snowflake, Databricks and `GoogleSQL`, whole.
315    #[test]
316    fn the_remaining_forms_carry_their_own_clauses() {
317        assert!(
318            render_table_function_as(TableFunctionForm::Snowflake, &a_function())
319                .contains("\nLANGUAGE SQL\nCOMMENT = 'The customers as they stood.'\nAS $$\n")
320        );
321        assert!(
322            render_table_function_as(TableFunctionForm::Databricks, &a_function())
323                .ends_with("RETURN SELECT customer_sid, customer_name FROM customer")
324        );
325        assert!(
326            render_table_function_as(TableFunctionForm::GoogleSql, &a_function())
327                .starts_with("CREATE OR REPLACE TABLE FUNCTION `retail_core`.`customer_at`(")
328        );
329    }
330
331    /// **`ClickHouse` has a parameterized view and not a function**, so the
332    /// parameters are not in the statement: the query addresses them and the
333    /// caller supplies them at the call site.
334    #[test]
335    fn the_clickhouse_form_is_a_view_and_carries_no_parameter_list() {
336        let rendered = render_table_function_as(TableFunctionForm::ClickHouse, &a_function());
337        assert_eq!(
338            rendered,
339            "CREATE OR REPLACE VIEW retail_core.customer_at AS\nSELECT customer_sid, \
340             customer_name FROM customer"
341        );
342        assert!(!rendered.contains("p_as_of"), "{rendered}");
343    }
344
345    /// A refusal is the dialect's own sentence in a comment, and never a
346    /// statement.
347    #[test]
348    fn an_unsupported_form_spells_no_statement() {
349        let rendered = render_table_function_as(
350            TableFunctionForm::Unsupported(
351                "a VIEW returns rows here, with the parameter in the \
352                                           caller's own WHERE clause",
353            ),
354            &a_function(),
355        );
356        assert!(rendered.contains("a VIEW returns rows here"));
357        assert!(!rendered.contains("CREATE"), "{rendered}");
358    }
359}