1use 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#[must_use]
29pub fn render_procedure_as(form: ProcedureForm, routine: &RoutineSpec) -> String {
30 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
52const 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
57const 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
61fn 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
70fn 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
80fn 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
92fn declarations(routine: &RoutineSpec) -> &[RoutineDeclaration] {
94 &routine.declarations
95}
96
97fn 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
155fn 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
200fn 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
269fn 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
320fn 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
349fn 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}