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]
33pub fn render_procedure_as(
34 form: ProcedureForm,
35 routine: &RoutineSpec,
36 describes_procedures: bool,
37) -> String {
38 let undescribed;
40 let routine = if describes_procedures || routine.comment.is_none() {
41 routine
42 } else {
43 undescribed = RoutineSpec { comment: None, ..routine.clone() };
44 &undescribed
45 };
46 match form {
51 ProcedureForm::PlPgSql => pl_pgsql(routine),
52 ProcedureForm::MySql => my_sql(routine),
53 ProcedureForm::TSql => t_sql(routine),
54 ProcedureForm::SnowflakeScripting => snowflake_scripting(routine),
55 ProcedureForm::GoogleSql => google_sql(routine),
56 ProcedureForm::Agnostic => agnostic(routine),
57 ProcedureForm::ClickHouse => note_above(NOT_A_SQL_GRAMMAR_CLICKHOUSE, &agnostic(routine)),
58 ProcedureForm::Databricks => note_above(NOT_A_SQL_GRAMMAR_DATABRICKS, &agnostic(routine)),
59 ProcedureForm::Custom(grammar) => {
60 note_above(&asked_for_its_own(grammar), &agnostic(routine))
61 }
62 ProcedureForm::Unsupported(orientation) => {
63 refusal_comment("procedure", &routine.name, orientation)
64 }
65 }
66}
67
68const NOT_A_SQL_GRAMMAR_CLICKHOUSE: &str = "ClickHouse deploys a procedure as an executable program with an XML declaration beside it, \
70 not as a statement. The agnostic form below says what the routine is; the deployment is not \
71 something a grammar spells.";
72
73const NOT_A_SQL_GRAMMAR_DATABRICKS: &str = "Databricks deploys this procedure as a Python function rather than as a statement. The \
75 agnostic form below says what the routine is; the function is not something a grammar spells.";
76
77fn asked_for_its_own(grammar: &str) -> String {
79 format!(
80 "You asked for {grammar}, which is your dialect's own grammar and not one spelled here. \
81 The agnostic form below is a neutral starting point; the named forms are reachable by \
82 naming one."
83 )
84}
85
86fn direction_keyword(direction: ParameterDirection) -> &'static str {
88 match direction {
89 ParameterDirection::In => "IN",
90 ParameterDirection::Out => "OUT",
91 ParameterDirection::InOut => "INOUT",
92 ParameterDirection::Variadic => "VARIADIC",
93 }
94}
95
96fn body_lines(routine: &RoutineSpec, pad: &str) -> Vec<String> {
98 match &routine.body {
99 RoutineBody::Statements(statements) => terminated_statements(statements, pad, ";"),
100 RoutineBody::Block(block) => vec![block.clone()],
101 RoutineBody::Query(query) => vec![format!("{};", indented(query, pad))],
102 RoutineBody::Expression(expression) => {
103 vec![format!("{};", indented(expression, pad))]
104 }
105 }
106}
107
108fn declarations(routine: &RoutineSpec) -> &[RoutineDeclaration] {
110 &routine.declarations
111}
112
113fn pl_pgsql(routine: &RoutineSpec) -> String {
118 let mut lines =
119 vec![format!("CREATE {}PROCEDURE {}(", or_replace(routine), plain_name(routine))];
120 let last = routine.parameters.len().saturating_sub(1);
121 for (index, parameter) in routine.parameters.iter().enumerate() {
122 let default =
123 parameter.default.as_ref().map_or_else(String::new, |d| format!(" DEFAULT {d}"));
124 let comma = if index == last { "" } else { "," };
125 lines.push(format!(
126 " {} {} {}{}{}",
127 direction_keyword(parameter.direction),
128 parameter.name,
129 parameter.data_type,
130 default,
131 comma
132 ));
133 }
134 lines.push(")".to_owned());
135 lines.push("LANGUAGE plpgsql".to_owned());
136 lines.push(
137 match routine.security {
138 RoutineSecurity::Caller => "SECURITY INVOKER",
139 RoutineSecurity::Owner => "SECURITY DEFINER",
140 }
141 .to_owned(),
142 );
143 lines.push("AS $$".to_owned());
144 if !declarations(routine).is_empty() {
145 lines.push("DECLARE".to_owned());
146 for declaration in declarations(routine) {
147 lines.push(match &declaration.default {
148 Some(default) => {
149 format!(" {} {} := {};", declaration.name, declaration.data_type, default)
150 }
151 None => format!(" {} {};", declaration.name, declaration.data_type),
152 });
153 }
154 }
155 lines.push("BEGIN".to_owned());
156 lines.extend(body_lines(routine, " "));
157 lines.push("END;".to_owned());
158 lines.push("$$;".to_owned());
159 if let Some(comment) = &routine.comment {
160 lines.push(String::new());
161 lines.push(format!(
162 "COMMENT ON PROCEDURE {}({}) IS '{}';",
163 plain_name(routine),
164 parameter_types(routine),
165 quoted_text(comment)
166 ));
167 }
168 lines.join("\n")
169}
170
171fn my_sql(routine: &RoutineSpec) -> String {
178 let mut lines = Vec::new();
179 if routine.or_replace {
180 lines.push(format!("DROP PROCEDURE IF EXISTS {};", plain_name(routine)));
181 lines.push(String::new());
182 }
183 lines.push("DELIMITER $$".to_owned());
184 lines.push(format!("CREATE PROCEDURE {}(", plain_name(routine)));
185 let last = routine.parameters.len().saturating_sub(1);
186 for (index, parameter) in routine.parameters.iter().enumerate() {
187 let comma = if index == last { "" } else { "," };
188 lines.push(format!(
189 " {} {} {}{}",
190 direction_keyword(parameter.direction),
191 parameter.name,
192 parameter.data_type,
193 comma
194 ));
195 }
196 lines.push(")".to_owned());
197 if let Some(comment) = &routine.comment {
198 lines.push(format!("COMMENT '{}'", quoted_text(comment)));
199 }
200 lines.push("BEGIN".to_owned());
201 for declaration in declarations(routine) {
202 lines.push(match &declaration.default {
203 Some(default) => format!(
204 " DECLARE {} {} DEFAULT {};",
205 declaration.name, declaration.data_type, default
206 ),
207 None => format!(" DECLARE {} {};", declaration.name, declaration.data_type),
208 });
209 }
210 lines.extend(body_lines(routine, " "));
211 lines.push("END$$".to_owned());
212 lines.push("DELIMITER ;".to_owned());
213 lines.join("\n")
214}
215
216fn t_sql(routine: &RoutineSpec) -> String {
227 let create = if routine.or_replace { "CREATE OR ALTER PROCEDURE" } else { "CREATE PROCEDURE" };
228 let mut lines = vec![format!("{create} {}", quoted_name(routine, '[', ']'))];
229 let last = routine.parameters.len().saturating_sub(1);
230 for (index, parameter) in routine.parameters.iter().enumerate() {
231 let default = parameter.default.as_ref().map_or_else(String::new, |d| format!(" = {d}"));
232 let output = match parameter.direction {
233 ParameterDirection::Out | ParameterDirection::InOut => " OUTPUT",
234 _ => "",
235 };
236 let comma = if index == last { "" } else { "," };
237 lines.push(format!(
238 " @{} {}{}{}{}",
239 parameter.name, parameter.data_type, default, output, comma
240 ));
241 }
242 if routine.security == RoutineSecurity::Owner {
243 lines.push("WITH EXECUTE AS OWNER".to_owned());
244 }
245 lines.push("AS".to_owned());
246 lines.push("BEGIN".to_owned());
247 lines.push(" SET NOCOUNT ON;".to_owned());
248 lines.push(" SET XACT_ABORT ON;".to_owned());
252 for declaration in declarations(routine) {
253 lines.push(match &declaration.default {
254 Some(default) => {
255 format!(
256 " DECLARE @{} {} = {};",
257 declaration.name, declaration.data_type, default
258 )
259 }
260 None => format!(" DECLARE @{} {};", declaration.name, declaration.data_type),
261 });
262 }
263 if !declarations(routine).is_empty() {
264 lines.push(String::new());
265 }
266 lines.extend(body_lines(routine, " "));
267 lines.push("END;".to_owned());
268 if let Some(comment) = &routine.comment {
269 let schema = routine.schema.as_deref().unwrap_or("dbo");
270 lines.push(String::new());
271 lines.push("GO".to_owned());
272 lines.push(format!(
273 "IF NOT EXISTS (SELECT 1 FROM sys.extended_properties p JOIN sys.objects o ON \
274 o.object_id = p.major_id JOIN sys.schemas s ON s.schema_id = o.schema_id WHERE \
275 p.name = N'MS_Description' AND p.minor_id = 0 AND s.name = N'{schema}' AND o.name = \
276 N'{}')",
277 routine.name
278 ));
279 lines.push(format!(
280 " EXEC sp_addextendedproperty @name = N'MS_Description', @value = N'{}', \
281 @level0type = N'SCHEMA', @level0name = N'{schema}', @level1type = N'PROCEDURE', \
282 @level1name = N'{}';",
283 quoted_text(comment),
284 routine.name
285 ));
286 }
287 lines.join("\n")
288}
289
290fn snowflake_scripting(routine: &RoutineSpec) -> String {
296 let mut lines = vec![
297 format!(
298 "CREATE {}PROCEDURE {}({})",
299 or_replace(routine),
300 plain_name(routine),
301 inline_parameters(routine)
302 ),
303 "RETURNS STRING".to_owned(),
304 "LANGUAGE SQL".to_owned(),
305 match routine.security {
306 RoutineSecurity::Caller => "EXECUTE AS CALLER",
307 RoutineSecurity::Owner => "EXECUTE AS OWNER",
308 }
309 .to_owned(),
310 "AS".to_owned(),
311 "$$".to_owned(),
312 ];
313 if !declarations(routine).is_empty() {
314 lines.push("DECLARE".to_owned());
315 for declaration in declarations(routine) {
316 lines.push(match &declaration.default {
317 Some(default) => {
318 format!(" {} {} := {};", declaration.name, declaration.data_type, default)
319 }
320 None => format!(" {} {};", declaration.name, declaration.data_type),
321 });
322 }
323 }
324 lines.push("BEGIN".to_owned());
325 lines.extend(body_lines(routine, " "));
326 lines.push(" RETURN 'SUCCESS';".to_owned());
327 lines.push("END;".to_owned());
328 lines.push("$$;".to_owned());
329 if let Some(comment) = &routine.comment {
330 lines.push(String::new());
331 lines.push(format!(
332 "COMMENT ON PROCEDURE {}({}) IS '{}';",
333 plain_name(routine),
334 parameter_types(routine),
335 quoted_text(comment)
336 ));
337 }
338 lines.join("\n")
339}
340
341fn google_sql(routine: &RoutineSpec) -> String {
346 let mut lines = vec![format!(
347 "CREATE {}PROCEDURE {}({})",
348 or_replace(routine),
349 quoted_name(routine, '`', '`'),
350 inline_parameters(routine)
351 )];
352 if let Some(comment) = &routine.comment {
353 lines.push(format!("OPTIONS (description = '{}')", comment.replace('\'', "\\'")));
354 }
355 lines.push("BEGIN".to_owned());
356 for declaration in declarations(routine) {
357 lines.push(match &declaration.default {
358 Some(default) => format!(
359 " DECLARE {} {} DEFAULT {};",
360 declaration.name, declaration.data_type, default
361 ),
362 None => format!(" DECLARE {} {};", declaration.name, declaration.data_type),
363 });
364 }
365 lines.extend(body_lines(routine, " "));
366 lines.push("END;".to_owned());
367 lines.join("\n")
368}
369
370fn agnostic(routine: &RoutineSpec) -> String {
376 let mut lines = vec![
377 crate::comment::block_comment("@razeri:type procedure"),
378 crate::comment::block_comment(&format!(
379 "@razeri:security {}",
380 match routine.security {
381 RoutineSecurity::Caller => "invoker",
382 RoutineSecurity::Owner => "definer",
383 }
384 )),
385 ];
386 if let Some(comment) = &routine.comment {
387 lines.push(crate::comment::block_comment(&format!("@razeri:comment {comment}")));
388 }
389 lines.push(String::new());
390 lines.push(format!("CREATE {}PROCEDURE {}(", or_replace(routine), plain_name(routine)));
391 let last = routine.parameters.len().saturating_sub(1);
392 for (index, parameter) in routine.parameters.iter().enumerate() {
393 let comma = if index == last { "" } else { "," };
394 lines.push(format!(
395 " {} {} {}{}",
396 direction_keyword(parameter.direction),
397 parameter.name,
398 parameter.data_type,
399 comma
400 ));
401 }
402 lines.push(")".to_owned());
403 lines.push("AS $$".to_owned());
404 if !declarations(routine).is_empty() {
405 lines.push("DECLARE".to_owned());
406 for declaration in declarations(routine) {
407 lines.push(match &declaration.default {
408 Some(default) => {
409 format!(" {} {} := {};", declaration.name, declaration.data_type, default)
410 }
411 None => format!(" {} {};", declaration.name, declaration.data_type),
412 });
413 }
414 }
415 lines.push("BEGIN".to_owned());
416 lines.extend(body_lines(routine, " "));
417 lines.push("END;".to_owned());
418 lines.push("$$;".to_owned());
419 lines.join("\n")
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crate::routine_spec::{RoutineKind, RoutineParameter};
426
427 fn parameter(name: &str, data_type: &str, direction: ParameterDirection) -> RoutineParameter {
429 RoutineParameter {
430 name: name.to_owned(),
431 data_type: data_type.to_owned(),
432 declared_type: None,
433 direction,
434 default: None,
435 }
436 }
437
438 fn a_procedure() -> RoutineSpec {
441 let mut spec = RoutineSpec::new(RoutineKind::Procedure, "load_customer");
442 spec.schema = Some("retail_core".to_owned());
443 spec.or_replace = true;
444 spec.comment = Some("Loads the day's customers.".to_owned());
445 spec.parameters = vec![
446 parameter("p_run_id", "BIGINT", ParameterDirection::In),
447 parameter("p_loaded", "BIGINT", ParameterDirection::Out),
448 ];
449 spec.declarations = vec![RoutineDeclaration {
450 name: "v_seen".to_owned(),
451 data_type: "DECIMAL(19, 4)".to_owned(),
452 declared_type: None,
453 default: Some("0".to_owned()),
454 }];
455 spec.body = RoutineBody::Statements(vec![
456 "DELETE FROM stage_customer".to_owned(),
457 "SELECT COUNT(*) FROM stage_customer INTO v_seen".to_owned(),
458 ]);
459 spec
460 }
461
462 #[test]
464 fn the_pl_pgsql_form_is_a_dollar_quoted_body_and_a_comment_statement() {
465 assert_eq!(
466 render_procedure_as(ProcedureForm::PlPgSql, &a_procedure(), true),
467 "CREATE OR REPLACE PROCEDURE retail_core.load_customer(\n IN p_run_id BIGINT,\n \
468 OUT p_loaded BIGINT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $$\nDECLARE\n \
469 v_seen DECIMAL(19, 4) := 0;\nBEGIN\n DELETE FROM stage_customer;\n SELECT \
470 COUNT(*) FROM stage_customer INTO v_seen;\nEND;\n$$;\n\nCOMMENT ON PROCEDURE \
471 retail_core.load_customer(BIGINT, BIGINT) IS 'Loads the day''s customers.';"
472 );
473 }
474
475 #[test]
478 fn the_mysql_form_wears_the_client_delimiter_and_drops_first() {
479 let rendered = render_procedure_as(ProcedureForm::MySql, &a_procedure(), true);
480 assert!(
481 rendered.starts_with(
482 "DROP PROCEDURE IF EXISTS retail_core.load_customer;\n\nDELIMITER $$\nCREATE \
483 PROCEDURE retail_core.load_customer("
484 ),
485 "{rendered}"
486 );
487 assert!(
488 rendered.contains("\n DECLARE v_seen DECIMAL(19, 4) DEFAULT 0;\n"),
489 "{rendered}"
490 );
491 assert!(rendered.ends_with("END$$\nDELIMITER ;"), "{rendered}");
492 assert!(!rendered.contains("$$;"), "dollar quoting is not MySQL's: {rendered}");
493 }
494
495 #[test]
498 fn the_t_sql_form_brackets_names_and_puts_the_description_in_its_own_batch() {
499 let rendered = render_procedure_as(ProcedureForm::TSql, &a_procedure(), true);
500 assert!(
501 rendered.starts_with(concat!(
502 "CREATE OR ALTER PROCEDURE [retail_core].[load_customer]\n",
503 " @p_run_id BIGINT,\n",
504 " @p_loaded BIGINT OUTPUT\n",
505 "AS\nBEGIN\n SET NOCOUNT ON;\n SET XACT_ABORT ON;\n",
506 " DECLARE @v_seen DECIMAL(19, 4) = 0;\n"
507 )),
508 "{rendered}"
509 );
510 assert!(rendered.contains("\nEND;\n\nGO\nIF NOT EXISTS"), "{rendered}");
511 assert!(rendered.contains("sp_addextendedproperty"), "{rendered}");
512 }
513
514 #[test]
517 fn the_snowflake_form_declares_a_return_and_hands_one_back() {
518 let rendered = render_procedure_as(ProcedureForm::SnowflakeScripting, &a_procedure(), true);
519 assert!(rendered.contains("\nRETURNS STRING\nLANGUAGE SQL\nEXECUTE AS CALLER\nAS\n$$\n"));
520 assert!(rendered.contains("\n RETURN 'SUCCESS';\nEND;\n$$;"), "{rendered}");
521 }
522
523 #[test]
526 fn the_google_sql_form_leaves_the_body_unquoted() {
527 let rendered = render_procedure_as(ProcedureForm::GoogleSql, &a_procedure(), true);
528 assert!(rendered.starts_with(
529 "CREATE OR REPLACE PROCEDURE `retail_core`.`load_customer`(p_run_id BIGINT, p_loaded \
530 BIGINT)\nOPTIONS (description = 'Loads the day\\'s customers.')\nBEGIN\n"
531 ), "{rendered}");
532 assert!(!rendered.contains("$$"), "GoogleSQL has no dollar quoting: {rendered}");
533 }
534
535 #[test]
538 fn the_execution_context_is_spelled_by_each_grammar() {
539 let mut owner = a_procedure();
540 owner.security = RoutineSecurity::Owner;
541 assert!(
542 render_procedure_as(ProcedureForm::PlPgSql, &owner, true).contains("SECURITY DEFINER")
543 );
544 assert!(
545 render_procedure_as(ProcedureForm::TSql, &owner, true)
546 .contains("WITH EXECUTE AS OWNER")
547 );
548 assert!(
549 render_procedure_as(ProcedureForm::SnowflakeScripting, &owner, true)
550 .contains("EXECUTE AS OWNER")
551 );
552 }
553
554 #[test]
557 fn an_unsupported_form_answers_in_the_dialects_own_words_and_spells_no_statement() {
558 let rendered = render_procedure_as(
559 ProcedureForm::Unsupported("this engine keeps load logic in the application"),
560 &a_procedure(),
561 true,
562 );
563 assert!(rendered.contains("this engine keeps load logic in the application"));
564 assert!(
565 crate::comment::comment_line_flags(&rendered).iter().all(|note| *note),
566 "{rendered}"
567 );
568 assert!(!rendered.contains("CREATE"), "{rendered}");
569 }
570
571 #[test]
574 fn a_custom_form_answers_with_the_agnostic_form_and_names_the_grammar() {
575 let rendered = render_procedure_as(ProcedureForm::Custom("PL/SQL"), &a_procedure(), true);
576 assert!(rendered.starts_with("/* You asked for PL/SQL"), "{rendered}");
577 assert!(rendered.contains("@razeri:type procedure"), "{rendered}");
578 }
579
580 #[test]
584 fn the_two_forms_that_are_not_sql_say_so_rather_than_spelling_a_create() {
585 for (form, word) in [
586 (ProcedureForm::ClickHouse, "executable program"),
587 (ProcedureForm::Databricks, "Python function"),
588 ] {
589 let rendered = render_procedure_as(form, &a_procedure(), true);
590 assert!(rendered.contains(word), "{rendered}");
591 assert!(rendered.starts_with("/* "), "{rendered}");
592 }
593 }
594
595 #[test]
598 fn a_bare_procedure_is_still_a_whole_statement() {
599 let mut spec = RoutineSpec::new(RoutineKind::Procedure, "tick");
600 spec.body = RoutineBody::Statements(vec!["UPDATE heartbeat SET seen = 1".to_owned()]);
601 assert_eq!(
602 render_procedure_as(ProcedureForm::PlPgSql, &spec, true),
603 "CREATE PROCEDURE tick(\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $$\nBEGIN\n \
604 UPDATE heartbeat SET seen = 1;\nEND;\n$$;"
605 );
606 }
607
608 #[test]
613 fn a_procedure_for_an_engine_without_descriptions_carries_none() {
614 let spec = a_procedure();
615 assert!(spec.comment.is_some(), "the fixture carries a description");
616 for form in [
617 ProcedureForm::PlPgSql,
618 ProcedureForm::SnowflakeScripting,
619 ProcedureForm::MySql,
620 ProcedureForm::TSql,
621 ProcedureForm::GoogleSql,
622 ] {
623 let declined = render_procedure_as(form, &spec, false);
624 assert!(!declined.contains("COMMENT ON PROCEDURE"), "{form:?}: {declined}");
625 assert!(
626 !declined.contains("Loads the day"),
627 "{form:?} kept the description: {declined}"
628 );
629 assert!(declined.contains("load_customer"), "{form:?} still writes the procedure");
630 let described = render_procedure_as(form, &spec, true);
631 assert!(
632 described.contains("Loads the day"),
633 "{form:?} lost the description: {described}"
634 );
635 }
636 }
637}