Skip to main content

razeri_plugin_contract/routine_render/
procedure.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 stored-procedure grammars.
6
7use super::{
8    indented, inline_parameters, note_above, or_replace, parameter_types, plain_name, quoted_name,
9    quoted_text, refusal_comment, terminated_statements,
10};
11use crate::routine_form::ProcedureForm;
12use crate::routine_spec::{
13    ParameterDirection, RoutineBody, RoutineDeclaration, RoutineSecurity, RoutineSpec,
14};
15
16/// **Spell this procedure in a named grammar.**
17///
18/// The grammar a dialect is nearly the same as, written out, so a dialect that
19/// answers [`ProcedureForm::Custom`] can start from it and say what differs
20/// instead of writing a `CREATE` from nothing.
21///
22/// Parameters:
23/// - `form`: the grammar to spell it in. `Custom` is not an error to pass — it
24///   answers with the agnostic form under a comment naming the grammar, so a
25///   fallback branch that reaches here gets something to read. `ClickHouse` and
26///   `Databricks` do the same, because neither deploys a procedure as SQL;
27/// - `routine`: the procedure, in the shape a `Custom` dialect receives it.
28#[must_use]
29pub fn render_procedure_as(form: ProcedureForm, routine: &RoutineSpec) -> String {
30    // No wildcard arm, deliberately. The enum is `#[non_exhaustive]` for
31    // dialects outside this crate, and inside it a new form has to be
32    // spelled here or the build stops — which is the point: a form nobody
33    // wrote a grammar for would otherwise render as something it is not.
34    match form {
35        ProcedureForm::PlPgSql => pl_pgsql(routine),
36        ProcedureForm::MySql => my_sql(routine),
37        ProcedureForm::TSql => t_sql(routine),
38        ProcedureForm::SnowflakeScripting => snowflake_scripting(routine),
39        ProcedureForm::GoogleSql => google_sql(routine),
40        ProcedureForm::Agnostic => agnostic(routine),
41        ProcedureForm::ClickHouse => note_above(NOT_A_SQL_GRAMMAR_CLICKHOUSE, &agnostic(routine)),
42        ProcedureForm::Databricks => note_above(NOT_A_SQL_GRAMMAR_DATABRICKS, &agnostic(routine)),
43        ProcedureForm::Custom(grammar) => {
44            note_above(&asked_for_its_own(grammar), &agnostic(routine))
45        }
46        ProcedureForm::Unsupported(orientation) => {
47            refusal_comment("procedure", &routine.name, orientation)
48        }
49    }
50}
51
52/// Why `ClickHouse` has no `CREATE` here.
53const NOT_A_SQL_GRAMMAR_CLICKHOUSE: &str = "ClickHouse deploys a procedure as an executable program with an XML declaration beside it, \
54     not as a statement. The agnostic form below says what the routine is; the deployment is not \
55     something a grammar spells.";
56
57/// Why Databricks has no `CREATE` here.
58const NOT_A_SQL_GRAMMAR_DATABRICKS: &str = "Databricks deploys this procedure as a Python function rather than as a statement. The \
59     agnostic form below says what the routine is; the function is not something a grammar spells.";
60
61/// What `Custom` gets, and why it is not an error to ask.
62fn asked_for_its_own(grammar: &str) -> String {
63    format!(
64        "You asked for {grammar}, which is your dialect's own grammar and not one spelled here. \
65         The agnostic form below is a neutral starting point; the named forms are reachable by \
66         naming one."
67    )
68}
69
70/// `IN` / `OUT` / `INOUT` / `VARIADIC`, the spelling most engines share.
71fn direction_keyword(direction: ParameterDirection) -> &'static str {
72    match direction {
73        ParameterDirection::In => "IN",
74        ParameterDirection::Out => "OUT",
75        ParameterDirection::InOut => "INOUT",
76        ParameterDirection::Variadic => "VARIADIC",
77    }
78}
79
80/// The body's statements, or the block the design framed itself.
81fn body_lines(routine: &RoutineSpec, pad: &str) -> Vec<String> {
82    match &routine.body {
83        RoutineBody::Statements(statements) => terminated_statements(statements, pad, ";"),
84        RoutineBody::Block(block) => vec![block.clone()],
85        RoutineBody::Query(query) => vec![format!("{};", indented(query, pad))],
86        RoutineBody::Expression(expression) => {
87            vec![format!("{};", indented(expression, pad))]
88        }
89    }
90}
91
92/// Every declaration the procedure opens with: the spec's own, in order.
93fn declarations(routine: &RoutineSpec) -> &[RoutineDeclaration] {
94    &routine.declarations
95}
96
97// ───────────────────────────── PL/pgSQL ─────────────────────────────
98
99/// PL/pgSQL: a dollar-quoted body, `DECLARE` above `BEGIN`, and the description
100/// as its own `COMMENT ON` statement.
101fn pl_pgsql(routine: &RoutineSpec) -> String {
102    let mut lines =
103        vec![format!("CREATE {}PROCEDURE {}(", or_replace(routine), plain_name(routine))];
104    let last = routine.parameters.len().saturating_sub(1);
105    for (index, parameter) in routine.parameters.iter().enumerate() {
106        let default =
107            parameter.default.as_ref().map_or_else(String::new, |d| format!(" DEFAULT {d}"));
108        let comma = if index == last { "" } else { "," };
109        lines.push(format!(
110            "    {} {} {}{}{}",
111            direction_keyword(parameter.direction),
112            parameter.name,
113            parameter.data_type,
114            default,
115            comma
116        ));
117    }
118    lines.push(")".to_owned());
119    lines.push("LANGUAGE plpgsql".to_owned());
120    lines.push(
121        match routine.security {
122            RoutineSecurity::Caller => "SECURITY INVOKER",
123            RoutineSecurity::Owner => "SECURITY DEFINER",
124        }
125        .to_owned(),
126    );
127    lines.push("AS $$".to_owned());
128    if !declarations(routine).is_empty() {
129        lines.push("DECLARE".to_owned());
130        for declaration in declarations(routine) {
131            lines.push(match &declaration.default {
132                Some(default) => {
133                    format!("    {} {} := {};", declaration.name, declaration.data_type, default)
134                }
135                None => format!("    {} {};", declaration.name, declaration.data_type),
136            });
137        }
138    }
139    lines.push("BEGIN".to_owned());
140    lines.extend(body_lines(routine, "    "));
141    lines.push("END;".to_owned());
142    lines.push("$$;".to_owned());
143    if let Some(comment) = &routine.comment {
144        lines.push(String::new());
145        lines.push(format!(
146            "COMMENT ON PROCEDURE {}({}) IS '{}';",
147            plain_name(routine),
148            parameter_types(routine),
149            quoted_text(comment)
150        ));
151    }
152    lines.join("\n")
153}
154
155// ────────────────────────── MySQL / MariaDB ──────────────────────────
156
157/// `MySQL`'s compound statement. There is no dollar quoting here — the body is
158/// protected by the client's own `DELIMITER` directive, which is what a rendered
159/// `.sql` file applied through a client needs, and a replace is a `DROP` in
160/// front because the engine has no `OR REPLACE` for a procedure.
161fn my_sql(routine: &RoutineSpec) -> String {
162    let mut lines = Vec::new();
163    if routine.or_replace {
164        lines.push(format!("DROP PROCEDURE IF EXISTS {};", plain_name(routine)));
165        lines.push(String::new());
166    }
167    lines.push("DELIMITER $$".to_owned());
168    lines.push(format!("CREATE PROCEDURE {}(", plain_name(routine)));
169    let last = routine.parameters.len().saturating_sub(1);
170    for (index, parameter) in routine.parameters.iter().enumerate() {
171        let comma = if index == last { "" } else { "," };
172        lines.push(format!(
173            "    {} {} {}{}",
174            direction_keyword(parameter.direction),
175            parameter.name,
176            parameter.data_type,
177            comma
178        ));
179    }
180    lines.push(")".to_owned());
181    if let Some(comment) = &routine.comment {
182        lines.push(format!("COMMENT '{}'", quoted_text(comment)));
183    }
184    lines.push("BEGIN".to_owned());
185    for declaration in declarations(routine) {
186        lines.push(match &declaration.default {
187            Some(default) => format!(
188                "    DECLARE {} {} DEFAULT {};",
189                declaration.name, declaration.data_type, default
190            ),
191            None => format!("    DECLARE {} {};", declaration.name, declaration.data_type),
192        });
193    }
194    lines.extend(body_lines(routine, "    "));
195    lines.push("END$$".to_owned());
196    lines.push("DELIMITER ;".to_owned());
197    lines.join("\n")
198}
199
200// ──────────────────────────────── T-SQL ────────────────────────────────
201
202/// T-SQL: bracketed names, `@`-prefixed parameters, `SET NOCOUNT ON` at the top
203/// of the body, and the description in its own batch behind an existence check.
204///
205/// The batch separator is what makes the description a statement *beside* the
206/// procedure rather than its last line: a procedure body here runs to the end of
207/// the batch, and `sp_addextendedproperty` is not idempotent, so a redeploy
208/// without the check fails on the description.
209fn t_sql(routine: &RoutineSpec) -> String {
210    let create = if routine.or_replace { "CREATE OR ALTER PROCEDURE" } else { "CREATE PROCEDURE" };
211    let mut lines = vec![format!("{create} {}", quoted_name(routine, '[', ']'))];
212    let last = routine.parameters.len().saturating_sub(1);
213    for (index, parameter) in routine.parameters.iter().enumerate() {
214        let default = parameter.default.as_ref().map_or_else(String::new, |d| format!(" = {d}"));
215        let output = match parameter.direction {
216            ParameterDirection::Out | ParameterDirection::InOut => " OUTPUT",
217            _ => "",
218        };
219        let comma = if index == last { "" } else { "," };
220        lines.push(format!(
221            "    @{} {}{}{}{}",
222            parameter.name, parameter.data_type, default, output, comma
223        ));
224    }
225    if routine.security == RoutineSecurity::Owner {
226        lines.push("WITH EXECUTE AS OWNER".to_owned());
227    }
228    lines.push("AS".to_owned());
229    lines.push("BEGIN".to_owned());
230    lines.push("    SET NOCOUNT ON;".to_owned());
231    for declaration in declarations(routine) {
232        lines.push(match &declaration.default {
233            Some(default) => {
234                format!(
235                    "    DECLARE @{} {} = {};",
236                    declaration.name, declaration.data_type, default
237                )
238            }
239            None => format!("    DECLARE @{} {};", declaration.name, declaration.data_type),
240        });
241    }
242    if !declarations(routine).is_empty() {
243        lines.push(String::new());
244    }
245    lines.extend(body_lines(routine, "    "));
246    lines.push("END;".to_owned());
247    if let Some(comment) = &routine.comment {
248        let schema = routine.schema.as_deref().unwrap_or("dbo");
249        lines.push(String::new());
250        lines.push("GO".to_owned());
251        lines.push(format!(
252            "IF NOT EXISTS (SELECT 1 FROM sys.extended_properties p JOIN sys.objects o ON \
253             o.object_id = p.major_id JOIN sys.schemas s ON s.schema_id = o.schema_id WHERE \
254             p.name = N'MS_Description' AND p.minor_id = 0 AND s.name = N'{schema}' AND o.name = \
255             N'{}')",
256            routine.name
257        ));
258        lines.push(format!(
259            "    EXEC sp_addextendedproperty @name = N'MS_Description', @value = N'{}', \
260             @level0type = N'SCHEMA', @level0name = N'{schema}', @level1type = N'PROCEDURE', \
261             @level1name = N'{}';",
262            quoted_text(comment),
263            routine.name
264        ));
265    }
266    lines.join("\n")
267}
268
269// ───────────────────────── Snowflake Scripting ─────────────────────────
270
271/// Snowflake Scripting. A procedure here declares a return type even when it
272/// returns nothing a caller reads, so the form returns a status string and the
273/// body ends by handing one back.
274fn snowflake_scripting(routine: &RoutineSpec) -> String {
275    let mut lines = vec![
276        format!(
277            "CREATE {}PROCEDURE {}({})",
278            or_replace(routine),
279            plain_name(routine),
280            inline_parameters(routine)
281        ),
282        "RETURNS STRING".to_owned(),
283        "LANGUAGE SQL".to_owned(),
284        match routine.security {
285            RoutineSecurity::Caller => "EXECUTE AS CALLER",
286            RoutineSecurity::Owner => "EXECUTE AS OWNER",
287        }
288        .to_owned(),
289        "AS".to_owned(),
290        "$$".to_owned(),
291    ];
292    if !declarations(routine).is_empty() {
293        lines.push("DECLARE".to_owned());
294        for declaration in declarations(routine) {
295            lines.push(match &declaration.default {
296                Some(default) => {
297                    format!("    {} {} := {};", declaration.name, declaration.data_type, default)
298                }
299                None => format!("    {} {};", declaration.name, declaration.data_type),
300            });
301        }
302    }
303    lines.push("BEGIN".to_owned());
304    lines.extend(body_lines(routine, "    "));
305    lines.push("    RETURN 'SUCCESS';".to_owned());
306    lines.push("END;".to_owned());
307    lines.push("$$;".to_owned());
308    if let Some(comment) = &routine.comment {
309        lines.push(String::new());
310        lines.push(format!(
311            "COMMENT ON PROCEDURE {}({}) IS '{}';",
312            plain_name(routine),
313            parameter_types(routine),
314            quoted_text(comment)
315        ));
316    }
317    lines.join("\n")
318}
319
320// ────────────────────────────── GoogleSQL ──────────────────────────────
321
322/// `GoogleSQL` scripting. The body is not quoted at all, and the description is
323/// an `OPTIONS` clause rather than a statement of its own.
324fn google_sql(routine: &RoutineSpec) -> String {
325    let mut lines = vec![format!(
326        "CREATE {}PROCEDURE {}({})",
327        or_replace(routine),
328        quoted_name(routine, '`', '`'),
329        inline_parameters(routine)
330    )];
331    if let Some(comment) = &routine.comment {
332        lines.push(format!("OPTIONS (description = '{}')", comment.replace('\'', "\\'")));
333    }
334    lines.push("BEGIN".to_owned());
335    for declaration in declarations(routine) {
336        lines.push(match &declaration.default {
337            Some(default) => format!(
338                "    DECLARE {} {} DEFAULT {};",
339                declaration.name, declaration.data_type, default
340            ),
341            None => format!("    DECLARE {} {};", declaration.name, declaration.data_type),
342        });
343    }
344    lines.extend(body_lines(routine, "    "));
345    lines.push("END;".to_owned());
346    lines.join("\n")
347}
348
349// ─────────────────────────────── Agnostic ───────────────────────────────
350
351/// Razeri's own annotated form: ANSI SQL carrying `@razeri:` headers. It is a
352/// design artifact — no engine parses it — and it is the honest answer for a
353/// grammar that has no spelling here.
354fn agnostic(routine: &RoutineSpec) -> String {
355    let mut lines = vec![
356        "-- @razeri:type procedure".to_owned(),
357        format!(
358            "-- @razeri:security {}",
359            match routine.security {
360                RoutineSecurity::Caller => "invoker",
361                RoutineSecurity::Owner => "definer",
362            }
363        ),
364    ];
365    if let Some(comment) = &routine.comment {
366        lines.push(format!("-- @razeri:comment {comment}"));
367    }
368    lines.push(String::new());
369    lines.push(format!("CREATE {}PROCEDURE {}(", or_replace(routine), plain_name(routine)));
370    let last = routine.parameters.len().saturating_sub(1);
371    for (index, parameter) in routine.parameters.iter().enumerate() {
372        let comma = if index == last { "" } else { "," };
373        lines.push(format!(
374            "    {} {} {}{}",
375            direction_keyword(parameter.direction),
376            parameter.name,
377            parameter.data_type,
378            comma
379        ));
380    }
381    lines.push(")".to_owned());
382    lines.push("AS $$".to_owned());
383    if !declarations(routine).is_empty() {
384        lines.push("DECLARE".to_owned());
385        for declaration in declarations(routine) {
386            lines.push(match &declaration.default {
387                Some(default) => {
388                    format!("    {} {} := {};", declaration.name, declaration.data_type, default)
389                }
390                None => format!("    {} {};", declaration.name, declaration.data_type),
391            });
392        }
393    }
394    lines.push("BEGIN".to_owned());
395    lines.extend(body_lines(routine, "    "));
396    lines.push("END;".to_owned());
397    lines.push("$$;".to_owned());
398    lines.join("\n")
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::routine_spec::{RoutineKind, RoutineParameter};
405
406    /// A parameter, built the way a renderer reads one.
407    fn parameter(name: &str, data_type: &str, direction: ParameterDirection) -> RoutineParameter {
408        RoutineParameter {
409            name: name.to_owned(),
410            data_type: data_type.to_owned(),
411            declared_type: None,
412            direction,
413            default: None,
414        }
415    }
416
417    /// The procedure every test below spells: a schema, a description, an input
418    /// and an output, one declaration with a value, and two statements.
419    fn a_procedure() -> RoutineSpec {
420        let mut spec = RoutineSpec::new(RoutineKind::Procedure, "load_customer");
421        spec.schema = Some("retail_core".to_owned());
422        spec.or_replace = true;
423        spec.comment = Some("Loads the day's customers.".to_owned());
424        spec.parameters = vec![
425            parameter("p_run_id", "BIGINT", ParameterDirection::In),
426            parameter("p_loaded", "BIGINT", ParameterDirection::Out),
427        ];
428        spec.declarations = vec![RoutineDeclaration {
429            name: "v_seen".to_owned(),
430            data_type: "DECIMAL(19, 4)".to_owned(),
431            declared_type: None,
432            default: Some("0".to_owned()),
433        }];
434        spec.body = RoutineBody::Statements(vec![
435            "DELETE FROM stage_customer".to_owned(),
436            "SELECT COUNT(*) FROM stage_customer INTO v_seen".to_owned(),
437        ]);
438        spec
439    }
440
441    /// PL/pgSQL, whole.
442    #[test]
443    fn the_pl_pgsql_form_is_a_dollar_quoted_body_and_a_comment_statement() {
444        assert_eq!(
445            render_procedure_as(ProcedureForm::PlPgSql, &a_procedure()),
446            "CREATE OR REPLACE PROCEDURE retail_core.load_customer(\n    IN p_run_id BIGINT,\n    \
447             OUT p_loaded BIGINT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $$\nDECLARE\n    \
448             v_seen DECIMAL(19, 4) := 0;\nBEGIN\n    DELETE FROM stage_customer;\n    SELECT \
449             COUNT(*) FROM stage_customer INTO v_seen;\nEND;\n$$;\n\nCOMMENT ON PROCEDURE \
450             retail_core.load_customer(BIGINT, BIGINT) IS 'Loads the day''s customers.';"
451        );
452    }
453
454    /// `MySQL`, whole. A replace is a `DROP` in front, and the body is wrapped
455    /// in the client's `DELIMITER` directive rather than in dollar quotes.
456    #[test]
457    fn the_mysql_form_wears_the_client_delimiter_and_drops_first() {
458        let rendered = render_procedure_as(ProcedureForm::MySql, &a_procedure());
459        assert!(
460            rendered.starts_with(
461                "DROP PROCEDURE IF EXISTS retail_core.load_customer;\n\nDELIMITER $$\nCREATE \
462                 PROCEDURE retail_core.load_customer("
463            ),
464            "{rendered}"
465        );
466        assert!(
467            rendered.contains("\n    DECLARE v_seen DECIMAL(19, 4) DEFAULT 0;\n"),
468            "{rendered}"
469        );
470        assert!(rendered.ends_with("END$$\nDELIMITER ;"), "{rendered}");
471        assert!(!rendered.contains("$$;"), "dollar quoting is not MySQL's: {rendered}");
472    }
473
474    /// T-SQL, whole. The description is its own batch behind an existence check,
475    /// because a procedure body here runs to the end of the batch.
476    #[test]
477    fn the_t_sql_form_brackets_names_and_puts_the_description_in_its_own_batch() {
478        let rendered = render_procedure_as(ProcedureForm::TSql, &a_procedure());
479        assert!(
480            rendered.starts_with(concat!(
481                "CREATE OR ALTER PROCEDURE [retail_core].[load_customer]\n",
482                "    @p_run_id BIGINT,\n",
483                "    @p_loaded BIGINT OUTPUT\n",
484                "AS\nBEGIN\n    SET NOCOUNT ON;\n",
485                "    DECLARE @v_seen DECIMAL(19, 4) = 0;\n"
486            )),
487            "{rendered}"
488        );
489        assert!(rendered.contains("\nEND;\n\nGO\nIF NOT EXISTS"), "{rendered}");
490        assert!(rendered.contains("sp_addextendedproperty"), "{rendered}");
491    }
492
493    /// Snowflake Scripting returns a status string, because the form declares a
494    /// return type whether or not a caller reads one.
495    #[test]
496    fn the_snowflake_form_declares_a_return_and_hands_one_back() {
497        let rendered = render_procedure_as(ProcedureForm::SnowflakeScripting, &a_procedure());
498        assert!(rendered.contains("\nRETURNS STRING\nLANGUAGE SQL\nEXECUTE AS CALLER\nAS\n$$\n"));
499        assert!(rendered.contains("\n    RETURN 'SUCCESS';\nEND;\n$$;"), "{rendered}");
500    }
501
502    /// `GoogleSQL` does not quote the body and carries the description in
503    /// `OPTIONS`.
504    #[test]
505    fn the_google_sql_form_leaves_the_body_unquoted() {
506        let rendered = render_procedure_as(ProcedureForm::GoogleSql, &a_procedure());
507        assert!(rendered.starts_with(
508            "CREATE OR REPLACE PROCEDURE `retail_core`.`load_customer`(p_run_id BIGINT, p_loaded \
509             BIGINT)\nOPTIONS (description = 'Loads the day\\'s customers.')\nBEGIN\n"
510        ), "{rendered}");
511        assert!(!rendered.contains("$$"), "GoogleSQL has no dollar quoting: {rendered}");
512    }
513
514    /// **The execution context is the spec's, not a default the renderer keeps
515    /// to itself.** Three grammars, three spellings, one fact.
516    #[test]
517    fn the_execution_context_is_spelled_by_each_grammar() {
518        let mut owner = a_procedure();
519        owner.security = RoutineSecurity::Owner;
520        assert!(render_procedure_as(ProcedureForm::PlPgSql, &owner).contains("SECURITY DEFINER"));
521        assert!(render_procedure_as(ProcedureForm::TSql, &owner).contains("WITH EXECUTE AS OWNER"));
522        assert!(
523            render_procedure_as(ProcedureForm::SnowflakeScripting, &owner)
524                .contains("EXECUTE AS OWNER")
525        );
526    }
527
528    /// **A refusal is a comment and never a statement.** It lands where DDL was
529    /// expected, and text that parsed would define something over the real name.
530    #[test]
531    fn an_unsupported_form_answers_in_the_dialects_own_words_and_spells_no_statement() {
532        let rendered = render_procedure_as(
533            ProcedureForm::Unsupported("this engine keeps load logic in the application"),
534            &a_procedure(),
535        );
536        assert!(rendered.contains("this engine keeps load logic in the application"));
537        assert!(rendered.lines().all(|line| line.starts_with("--")), "{rendered}");
538        assert!(!rendered.contains("CREATE"), "{rendered}");
539    }
540
541    /// **Asking for your own grammar is not an error.** A dialect that reaches
542    /// here with `Custom` gets the agnostic form and a sentence, not a panic.
543    #[test]
544    fn a_custom_form_answers_with_the_agnostic_form_and_names_the_grammar() {
545        let rendered = render_procedure_as(ProcedureForm::Custom("PL/SQL"), &a_procedure());
546        assert!(rendered.starts_with("-- You asked for PL/SQL"), "{rendered}");
547        assert!(rendered.contains("@razeri:type procedure"), "{rendered}");
548    }
549
550    /// **The two forms that are not SQL say so.** Neither engine deploys a
551    /// procedure as a statement, and a `CREATE` that pretended otherwise would
552    /// fail on the engine rather than in review.
553    #[test]
554    fn the_two_forms_that_are_not_sql_say_so_rather_than_spelling_a_create() {
555        for (form, word) in [
556            (ProcedureForm::ClickHouse, "executable program"),
557            (ProcedureForm::Databricks, "Python function"),
558        ] {
559            let rendered = render_procedure_as(form, &a_procedure());
560            assert!(rendered.contains(word), "{rendered}");
561            assert!(rendered.starts_with("--"), "{rendered}");
562        }
563    }
564
565    /// A procedure with no parameters and nothing to declare is still a whole
566    /// statement, which is the shape most easily got wrong.
567    #[test]
568    fn a_bare_procedure_is_still_a_whole_statement() {
569        let mut spec = RoutineSpec::new(RoutineKind::Procedure, "tick");
570        spec.body = RoutineBody::Statements(vec!["UPDATE heartbeat SET seen = 1".to_owned()]);
571        assert_eq!(
572            render_procedure_as(ProcedureForm::PlPgSql, &spec),
573            "CREATE PROCEDURE tick(\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $$\nBEGIN\n    \
574             UPDATE heartbeat SET seen = 1;\nEND;\n$$;"
575        );
576    }
577}