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 {
226 let create = if routine.or_replace { "CREATE OR ALTER PROCEDURE" } else { "CREATE PROCEDURE" };
227 let mut lines = vec![format!("{create} {}", quoted_name(routine, '[', ']'))];
228 let last = routine.parameters.len().saturating_sub(1);
229 for (index, parameter) in routine.parameters.iter().enumerate() {
230 let default = parameter.default.as_ref().map_or_else(String::new, |d| format!(" = {d}"));
231 let output = match parameter.direction {
232 ParameterDirection::Out | ParameterDirection::InOut => " OUTPUT",
233 _ => "",
234 };
235 let comma = if index == last { "" } else { "," };
236 lines.push(format!(
237 " @{} {}{}{}{}",
238 parameter.name, parameter.data_type, default, output, comma
239 ));
240 }
241 if routine.security == RoutineSecurity::Owner {
242 lines.push("WITH EXECUTE AS OWNER".to_owned());
243 }
244 lines.push("AS".to_owned());
245 lines.push("BEGIN".to_owned());
246 lines.push(" SET NOCOUNT ON;".to_owned());
247 for declaration in declarations(routine) {
248 lines.push(match &declaration.default {
249 Some(default) => {
250 format!(
251 " DECLARE @{} {} = {};",
252 declaration.name, declaration.data_type, default
253 )
254 }
255 None => format!(" DECLARE @{} {};", declaration.name, declaration.data_type),
256 });
257 }
258 if !declarations(routine).is_empty() {
259 lines.push(String::new());
260 }
261 lines.extend(body_lines(routine, " "));
262 lines.push("END;".to_owned());
263 if let Some(comment) = &routine.comment {
264 let schema = routine.schema.as_deref().unwrap_or("dbo");
265 lines.push(String::new());
266 lines.push("GO".to_owned());
267 lines.push(format!(
268 "IF NOT EXISTS (SELECT 1 FROM sys.extended_properties p JOIN sys.objects o ON \
269 o.object_id = p.major_id JOIN sys.schemas s ON s.schema_id = o.schema_id WHERE \
270 p.name = N'MS_Description' AND p.minor_id = 0 AND s.name = N'{schema}' AND o.name = \
271 N'{}')",
272 routine.name
273 ));
274 lines.push(format!(
275 " EXEC sp_addextendedproperty @name = N'MS_Description', @value = N'{}', \
276 @level0type = N'SCHEMA', @level0name = N'{schema}', @level1type = N'PROCEDURE', \
277 @level1name = N'{}';",
278 quoted_text(comment),
279 routine.name
280 ));
281 }
282 lines.join("\n")
283}
284
285fn snowflake_scripting(routine: &RoutineSpec) -> String {
291 let mut lines = vec![
292 format!(
293 "CREATE {}PROCEDURE {}({})",
294 or_replace(routine),
295 plain_name(routine),
296 inline_parameters(routine)
297 ),
298 "RETURNS STRING".to_owned(),
299 "LANGUAGE SQL".to_owned(),
300 match routine.security {
301 RoutineSecurity::Caller => "EXECUTE AS CALLER",
302 RoutineSecurity::Owner => "EXECUTE AS OWNER",
303 }
304 .to_owned(),
305 "AS".to_owned(),
306 "$$".to_owned(),
307 ];
308 if !declarations(routine).is_empty() {
309 lines.push("DECLARE".to_owned());
310 for declaration in declarations(routine) {
311 lines.push(match &declaration.default {
312 Some(default) => {
313 format!(" {} {} := {};", declaration.name, declaration.data_type, default)
314 }
315 None => format!(" {} {};", declaration.name, declaration.data_type),
316 });
317 }
318 }
319 lines.push("BEGIN".to_owned());
320 lines.extend(body_lines(routine, " "));
321 lines.push(" RETURN 'SUCCESS';".to_owned());
322 lines.push("END;".to_owned());
323 lines.push("$$;".to_owned());
324 if let Some(comment) = &routine.comment {
325 lines.push(String::new());
326 lines.push(format!(
327 "COMMENT ON PROCEDURE {}({}) IS '{}';",
328 plain_name(routine),
329 parameter_types(routine),
330 quoted_text(comment)
331 ));
332 }
333 lines.join("\n")
334}
335
336fn google_sql(routine: &RoutineSpec) -> String {
341 let mut lines = vec![format!(
342 "CREATE {}PROCEDURE {}({})",
343 or_replace(routine),
344 quoted_name(routine, '`', '`'),
345 inline_parameters(routine)
346 )];
347 if let Some(comment) = &routine.comment {
348 lines.push(format!("OPTIONS (description = '{}')", comment.replace('\'', "\\'")));
349 }
350 lines.push("BEGIN".to_owned());
351 for declaration in declarations(routine) {
352 lines.push(match &declaration.default {
353 Some(default) => format!(
354 " DECLARE {} {} DEFAULT {};",
355 declaration.name, declaration.data_type, default
356 ),
357 None => format!(" DECLARE {} {};", declaration.name, declaration.data_type),
358 });
359 }
360 lines.extend(body_lines(routine, " "));
361 lines.push("END;".to_owned());
362 lines.join("\n")
363}
364
365fn agnostic(routine: &RoutineSpec) -> String {
371 let mut lines = vec![
372 "-- @razeri:type procedure".to_owned(),
373 format!(
374 "-- @razeri:security {}",
375 match routine.security {
376 RoutineSecurity::Caller => "invoker",
377 RoutineSecurity::Owner => "definer",
378 }
379 ),
380 ];
381 if let Some(comment) = &routine.comment {
382 lines.push(format!("-- @razeri:comment {comment}"));
383 }
384 lines.push(String::new());
385 lines.push(format!("CREATE {}PROCEDURE {}(", or_replace(routine), plain_name(routine)));
386 let last = routine.parameters.len().saturating_sub(1);
387 for (index, parameter) in routine.parameters.iter().enumerate() {
388 let comma = if index == last { "" } else { "," };
389 lines.push(format!(
390 " {} {} {}{}",
391 direction_keyword(parameter.direction),
392 parameter.name,
393 parameter.data_type,
394 comma
395 ));
396 }
397 lines.push(")".to_owned());
398 lines.push("AS $$".to_owned());
399 if !declarations(routine).is_empty() {
400 lines.push("DECLARE".to_owned());
401 for declaration in declarations(routine) {
402 lines.push(match &declaration.default {
403 Some(default) => {
404 format!(" {} {} := {};", declaration.name, declaration.data_type, default)
405 }
406 None => format!(" {} {};", declaration.name, declaration.data_type),
407 });
408 }
409 }
410 lines.push("BEGIN".to_owned());
411 lines.extend(body_lines(routine, " "));
412 lines.push("END;".to_owned());
413 lines.push("$$;".to_owned());
414 lines.join("\n")
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use crate::routine_spec::{RoutineKind, RoutineParameter};
421
422 fn parameter(name: &str, data_type: &str, direction: ParameterDirection) -> RoutineParameter {
424 RoutineParameter {
425 name: name.to_owned(),
426 data_type: data_type.to_owned(),
427 declared_type: None,
428 direction,
429 default: None,
430 }
431 }
432
433 fn a_procedure() -> RoutineSpec {
436 let mut spec = RoutineSpec::new(RoutineKind::Procedure, "load_customer");
437 spec.schema = Some("retail_core".to_owned());
438 spec.or_replace = true;
439 spec.comment = Some("Loads the day's customers.".to_owned());
440 spec.parameters = vec![
441 parameter("p_run_id", "BIGINT", ParameterDirection::In),
442 parameter("p_loaded", "BIGINT", ParameterDirection::Out),
443 ];
444 spec.declarations = vec![RoutineDeclaration {
445 name: "v_seen".to_owned(),
446 data_type: "DECIMAL(19, 4)".to_owned(),
447 declared_type: None,
448 default: Some("0".to_owned()),
449 }];
450 spec.body = RoutineBody::Statements(vec![
451 "DELETE FROM stage_customer".to_owned(),
452 "SELECT COUNT(*) FROM stage_customer INTO v_seen".to_owned(),
453 ]);
454 spec
455 }
456
457 #[test]
459 fn the_pl_pgsql_form_is_a_dollar_quoted_body_and_a_comment_statement() {
460 assert_eq!(
461 render_procedure_as(ProcedureForm::PlPgSql, &a_procedure(), true),
462 "CREATE OR REPLACE PROCEDURE retail_core.load_customer(\n IN p_run_id BIGINT,\n \
463 OUT p_loaded BIGINT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $$\nDECLARE\n \
464 v_seen DECIMAL(19, 4) := 0;\nBEGIN\n DELETE FROM stage_customer;\n SELECT \
465 COUNT(*) FROM stage_customer INTO v_seen;\nEND;\n$$;\n\nCOMMENT ON PROCEDURE \
466 retail_core.load_customer(BIGINT, BIGINT) IS 'Loads the day''s customers.';"
467 );
468 }
469
470 #[test]
473 fn the_mysql_form_wears_the_client_delimiter_and_drops_first() {
474 let rendered = render_procedure_as(ProcedureForm::MySql, &a_procedure(), true);
475 assert!(
476 rendered.starts_with(
477 "DROP PROCEDURE IF EXISTS retail_core.load_customer;\n\nDELIMITER $$\nCREATE \
478 PROCEDURE retail_core.load_customer("
479 ),
480 "{rendered}"
481 );
482 assert!(
483 rendered.contains("\n DECLARE v_seen DECIMAL(19, 4) DEFAULT 0;\n"),
484 "{rendered}"
485 );
486 assert!(rendered.ends_with("END$$\nDELIMITER ;"), "{rendered}");
487 assert!(!rendered.contains("$$;"), "dollar quoting is not MySQL's: {rendered}");
488 }
489
490 #[test]
493 fn the_t_sql_form_brackets_names_and_puts_the_description_in_its_own_batch() {
494 let rendered = render_procedure_as(ProcedureForm::TSql, &a_procedure(), true);
495 assert!(
496 rendered.starts_with(concat!(
497 "CREATE OR ALTER PROCEDURE [retail_core].[load_customer]\n",
498 " @p_run_id BIGINT,\n",
499 " @p_loaded BIGINT OUTPUT\n",
500 "AS\nBEGIN\n SET NOCOUNT ON;\n",
501 " DECLARE @v_seen DECIMAL(19, 4) = 0;\n"
502 )),
503 "{rendered}"
504 );
505 assert!(rendered.contains("\nEND;\n\nGO\nIF NOT EXISTS"), "{rendered}");
506 assert!(rendered.contains("sp_addextendedproperty"), "{rendered}");
507 }
508
509 #[test]
512 fn the_snowflake_form_declares_a_return_and_hands_one_back() {
513 let rendered = render_procedure_as(ProcedureForm::SnowflakeScripting, &a_procedure(), true);
514 assert!(rendered.contains("\nRETURNS STRING\nLANGUAGE SQL\nEXECUTE AS CALLER\nAS\n$$\n"));
515 assert!(rendered.contains("\n RETURN 'SUCCESS';\nEND;\n$$;"), "{rendered}");
516 }
517
518 #[test]
521 fn the_google_sql_form_leaves_the_body_unquoted() {
522 let rendered = render_procedure_as(ProcedureForm::GoogleSql, &a_procedure(), true);
523 assert!(rendered.starts_with(
524 "CREATE OR REPLACE PROCEDURE `retail_core`.`load_customer`(p_run_id BIGINT, p_loaded \
525 BIGINT)\nOPTIONS (description = 'Loads the day\\'s customers.')\nBEGIN\n"
526 ), "{rendered}");
527 assert!(!rendered.contains("$$"), "GoogleSQL has no dollar quoting: {rendered}");
528 }
529
530 #[test]
533 fn the_execution_context_is_spelled_by_each_grammar() {
534 let mut owner = a_procedure();
535 owner.security = RoutineSecurity::Owner;
536 assert!(
537 render_procedure_as(ProcedureForm::PlPgSql, &owner, true).contains("SECURITY DEFINER")
538 );
539 assert!(
540 render_procedure_as(ProcedureForm::TSql, &owner, true)
541 .contains("WITH EXECUTE AS OWNER")
542 );
543 assert!(
544 render_procedure_as(ProcedureForm::SnowflakeScripting, &owner, true)
545 .contains("EXECUTE AS OWNER")
546 );
547 }
548
549 #[test]
552 fn an_unsupported_form_answers_in_the_dialects_own_words_and_spells_no_statement() {
553 let rendered = render_procedure_as(
554 ProcedureForm::Unsupported("this engine keeps load logic in the application"),
555 &a_procedure(),
556 true,
557 );
558 assert!(rendered.contains("this engine keeps load logic in the application"));
559 assert!(rendered.lines().all(|line| line.starts_with("--")), "{rendered}");
560 assert!(!rendered.contains("CREATE"), "{rendered}");
561 }
562
563 #[test]
566 fn a_custom_form_answers_with_the_agnostic_form_and_names_the_grammar() {
567 let rendered = render_procedure_as(ProcedureForm::Custom("PL/SQL"), &a_procedure(), true);
568 assert!(rendered.starts_with("-- You asked for PL/SQL"), "{rendered}");
569 assert!(rendered.contains("@razeri:type procedure"), "{rendered}");
570 }
571
572 #[test]
576 fn the_two_forms_that_are_not_sql_say_so_rather_than_spelling_a_create() {
577 for (form, word) in [
578 (ProcedureForm::ClickHouse, "executable program"),
579 (ProcedureForm::Databricks, "Python function"),
580 ] {
581 let rendered = render_procedure_as(form, &a_procedure(), true);
582 assert!(rendered.contains(word), "{rendered}");
583 assert!(rendered.starts_with("--"), "{rendered}");
584 }
585 }
586
587 #[test]
590 fn a_bare_procedure_is_still_a_whole_statement() {
591 let mut spec = RoutineSpec::new(RoutineKind::Procedure, "tick");
592 spec.body = RoutineBody::Statements(vec!["UPDATE heartbeat SET seen = 1".to_owned()]);
593 assert_eq!(
594 render_procedure_as(ProcedureForm::PlPgSql, &spec, true),
595 "CREATE PROCEDURE tick(\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $$\nBEGIN\n \
596 UPDATE heartbeat SET seen = 1;\nEND;\n$$;"
597 );
598 }
599
600 #[test]
605 fn a_procedure_for_an_engine_without_descriptions_carries_none() {
606 let spec = a_procedure();
607 assert!(spec.comment.is_some(), "the fixture carries a description");
608 for form in [
609 ProcedureForm::PlPgSql,
610 ProcedureForm::SnowflakeScripting,
611 ProcedureForm::MySql,
612 ProcedureForm::TSql,
613 ProcedureForm::GoogleSql,
614 ] {
615 let declined = render_procedure_as(form, &spec, false);
616 assert!(!declined.contains("COMMENT ON PROCEDURE"), "{form:?}: {declined}");
617 assert!(
618 !declined.contains("Loads the day"),
619 "{form:?} kept the description: {declined}"
620 );
621 assert!(declined.contains("load_customer"), "{form:?} still writes the procedure");
622 let described = render_procedure_as(form, &spec, true);
623 assert!(
624 described.contains("Loads the day"),
625 "{form:?} lost the description: {described}"
626 );
627 }
628 }
629}