use super::{
indented, inline_parameters, note_above, or_replace, parameter_types, plain_name, quoted_name,
quoted_text, refusal_comment, terminated_statements,
};
use crate::routine_form::ProcedureForm;
use crate::routine_spec::{
ParameterDirection, RoutineBody, RoutineDeclaration, RoutineSecurity, RoutineSpec,
};
#[must_use]
pub fn render_procedure_as(
form: ProcedureForm,
routine: &RoutineSpec,
describes_procedures: bool,
) -> String {
let undescribed;
let routine = if describes_procedures || routine.comment.is_none() {
routine
} else {
undescribed = RoutineSpec { comment: None, ..routine.clone() };
&undescribed
};
match form {
ProcedureForm::PlPgSql => pl_pgsql(routine),
ProcedureForm::MySql => my_sql(routine),
ProcedureForm::TSql => t_sql(routine),
ProcedureForm::SnowflakeScripting => snowflake_scripting(routine),
ProcedureForm::GoogleSql => google_sql(routine),
ProcedureForm::Agnostic => agnostic(routine),
ProcedureForm::ClickHouse => note_above(NOT_A_SQL_GRAMMAR_CLICKHOUSE, &agnostic(routine)),
ProcedureForm::Databricks => note_above(NOT_A_SQL_GRAMMAR_DATABRICKS, &agnostic(routine)),
ProcedureForm::Custom(grammar) => {
note_above(&asked_for_its_own(grammar), &agnostic(routine))
}
ProcedureForm::Unsupported(orientation) => {
refusal_comment("procedure", &routine.name, orientation)
}
}
}
const NOT_A_SQL_GRAMMAR_CLICKHOUSE: &str = "ClickHouse deploys a procedure as an executable program with an XML declaration beside it, \
not as a statement. The agnostic form below says what the routine is; the deployment is not \
something a grammar spells.";
const NOT_A_SQL_GRAMMAR_DATABRICKS: &str = "Databricks deploys this procedure as a Python function rather than as a statement. The \
agnostic form below says what the routine is; the function is not something a grammar spells.";
fn asked_for_its_own(grammar: &str) -> String {
format!(
"You asked for {grammar}, which is your dialect's own grammar and not one spelled here. \
The agnostic form below is a neutral starting point; the named forms are reachable by \
naming one."
)
}
fn direction_keyword(direction: ParameterDirection) -> &'static str {
match direction {
ParameterDirection::In => "IN",
ParameterDirection::Out => "OUT",
ParameterDirection::InOut => "INOUT",
ParameterDirection::Variadic => "VARIADIC",
}
}
fn body_lines(routine: &RoutineSpec, pad: &str) -> Vec<String> {
match &routine.body {
RoutineBody::Statements(statements) => terminated_statements(statements, pad, ";"),
RoutineBody::Block(block) => vec![block.clone()],
RoutineBody::Query(query) => vec![format!("{};", indented(query, pad))],
RoutineBody::Expression(expression) => {
vec![format!("{};", indented(expression, pad))]
}
}
}
fn declarations(routine: &RoutineSpec) -> &[RoutineDeclaration] {
&routine.declarations
}
fn pl_pgsql(routine: &RoutineSpec) -> String {
let mut lines =
vec![format!("CREATE {}PROCEDURE {}(", or_replace(routine), plain_name(routine))];
let last = routine.parameters.len().saturating_sub(1);
for (index, parameter) in routine.parameters.iter().enumerate() {
let default =
parameter.default.as_ref().map_or_else(String::new, |d| format!(" DEFAULT {d}"));
let comma = if index == last { "" } else { "," };
lines.push(format!(
" {} {} {}{}{}",
direction_keyword(parameter.direction),
parameter.name,
parameter.data_type,
default,
comma
));
}
lines.push(")".to_owned());
lines.push("LANGUAGE plpgsql".to_owned());
lines.push(
match routine.security {
RoutineSecurity::Caller => "SECURITY INVOKER",
RoutineSecurity::Owner => "SECURITY DEFINER",
}
.to_owned(),
);
lines.push("AS $$".to_owned());
if !declarations(routine).is_empty() {
lines.push("DECLARE".to_owned());
for declaration in declarations(routine) {
lines.push(match &declaration.default {
Some(default) => {
format!(" {} {} := {};", declaration.name, declaration.data_type, default)
}
None => format!(" {} {};", declaration.name, declaration.data_type),
});
}
}
lines.push("BEGIN".to_owned());
lines.extend(body_lines(routine, " "));
lines.push("END;".to_owned());
lines.push("$$;".to_owned());
if let Some(comment) = &routine.comment {
lines.push(String::new());
lines.push(format!(
"COMMENT ON PROCEDURE {}({}) IS '{}';",
plain_name(routine),
parameter_types(routine),
quoted_text(comment)
));
}
lines.join("\n")
}
fn my_sql(routine: &RoutineSpec) -> String {
let mut lines = Vec::new();
if routine.or_replace {
lines.push(format!("DROP PROCEDURE IF EXISTS {};", plain_name(routine)));
lines.push(String::new());
}
lines.push("DELIMITER $$".to_owned());
lines.push(format!("CREATE PROCEDURE {}(", plain_name(routine)));
let last = routine.parameters.len().saturating_sub(1);
for (index, parameter) in routine.parameters.iter().enumerate() {
let comma = if index == last { "" } else { "," };
lines.push(format!(
" {} {} {}{}",
direction_keyword(parameter.direction),
parameter.name,
parameter.data_type,
comma
));
}
lines.push(")".to_owned());
if let Some(comment) = &routine.comment {
lines.push(format!("COMMENT '{}'", quoted_text(comment)));
}
lines.push("BEGIN".to_owned());
for declaration in declarations(routine) {
lines.push(match &declaration.default {
Some(default) => format!(
" DECLARE {} {} DEFAULT {};",
declaration.name, declaration.data_type, default
),
None => format!(" DECLARE {} {};", declaration.name, declaration.data_type),
});
}
lines.extend(body_lines(routine, " "));
lines.push("END$$".to_owned());
lines.push("DELIMITER ;".to_owned());
lines.join("\n")
}
fn t_sql(routine: &RoutineSpec) -> String {
let create = if routine.or_replace { "CREATE OR ALTER PROCEDURE" } else { "CREATE PROCEDURE" };
let mut lines = vec![format!("{create} {}", quoted_name(routine, '[', ']'))];
let last = routine.parameters.len().saturating_sub(1);
for (index, parameter) in routine.parameters.iter().enumerate() {
let default = parameter.default.as_ref().map_or_else(String::new, |d| format!(" = {d}"));
let output = match parameter.direction {
ParameterDirection::Out | ParameterDirection::InOut => " OUTPUT",
_ => "",
};
let comma = if index == last { "" } else { "," };
lines.push(format!(
" @{} {}{}{}{}",
parameter.name, parameter.data_type, default, output, comma
));
}
if routine.security == RoutineSecurity::Owner {
lines.push("WITH EXECUTE AS OWNER".to_owned());
}
lines.push("AS".to_owned());
lines.push("BEGIN".to_owned());
lines.push(" SET NOCOUNT ON;".to_owned());
lines.push(" SET XACT_ABORT ON;".to_owned());
for declaration in declarations(routine) {
lines.push(match &declaration.default {
Some(default) => {
format!(
" DECLARE @{} {} = {};",
declaration.name, declaration.data_type, default
)
}
None => format!(" DECLARE @{} {};", declaration.name, declaration.data_type),
});
}
if !declarations(routine).is_empty() {
lines.push(String::new());
}
lines.extend(body_lines(routine, " "));
lines.push("END;".to_owned());
if let Some(comment) = &routine.comment {
let schema = routine.schema.as_deref().unwrap_or("dbo");
lines.push(String::new());
lines.push("GO".to_owned());
lines.push(format!(
"IF NOT EXISTS (SELECT 1 FROM sys.extended_properties p JOIN sys.objects o ON \
o.object_id = p.major_id JOIN sys.schemas s ON s.schema_id = o.schema_id WHERE \
p.name = N'MS_Description' AND p.minor_id = 0 AND s.name = N'{schema}' AND o.name = \
N'{}')",
routine.name
));
lines.push(format!(
" EXEC sp_addextendedproperty @name = N'MS_Description', @value = N'{}', \
@level0type = N'SCHEMA', @level0name = N'{schema}', @level1type = N'PROCEDURE', \
@level1name = N'{}';",
quoted_text(comment),
routine.name
));
}
lines.join("\n")
}
fn snowflake_scripting(routine: &RoutineSpec) -> String {
let mut lines = vec![
format!(
"CREATE {}PROCEDURE {}({})",
or_replace(routine),
plain_name(routine),
inline_parameters(routine)
),
"RETURNS STRING".to_owned(),
"LANGUAGE SQL".to_owned(),
match routine.security {
RoutineSecurity::Caller => "EXECUTE AS CALLER",
RoutineSecurity::Owner => "EXECUTE AS OWNER",
}
.to_owned(),
"AS".to_owned(),
"$$".to_owned(),
];
if !declarations(routine).is_empty() {
lines.push("DECLARE".to_owned());
for declaration in declarations(routine) {
lines.push(match &declaration.default {
Some(default) => {
format!(" {} {} := {};", declaration.name, declaration.data_type, default)
}
None => format!(" {} {};", declaration.name, declaration.data_type),
});
}
}
lines.push("BEGIN".to_owned());
lines.extend(body_lines(routine, " "));
lines.push(" RETURN 'SUCCESS';".to_owned());
lines.push("END;".to_owned());
lines.push("$$;".to_owned());
if let Some(comment) = &routine.comment {
lines.push(String::new());
lines.push(format!(
"COMMENT ON PROCEDURE {}({}) IS '{}';",
plain_name(routine),
parameter_types(routine),
quoted_text(comment)
));
}
lines.join("\n")
}
fn google_sql(routine: &RoutineSpec) -> String {
let mut lines = vec![format!(
"CREATE {}PROCEDURE {}({})",
or_replace(routine),
quoted_name(routine, '`', '`'),
inline_parameters(routine)
)];
if let Some(comment) = &routine.comment {
lines.push(format!("OPTIONS (description = '{}')", comment.replace('\'', "\\'")));
}
lines.push("BEGIN".to_owned());
for declaration in declarations(routine) {
lines.push(match &declaration.default {
Some(default) => format!(
" DECLARE {} {} DEFAULT {};",
declaration.name, declaration.data_type, default
),
None => format!(" DECLARE {} {};", declaration.name, declaration.data_type),
});
}
lines.extend(body_lines(routine, " "));
lines.push("END;".to_owned());
lines.join("\n")
}
fn agnostic(routine: &RoutineSpec) -> String {
let mut lines = vec![
crate::comment::block_comment("@razeri:type procedure"),
crate::comment::block_comment(&format!(
"@razeri:security {}",
match routine.security {
RoutineSecurity::Caller => "invoker",
RoutineSecurity::Owner => "definer",
}
)),
];
if let Some(comment) = &routine.comment {
lines.push(crate::comment::block_comment(&format!("@razeri:comment {comment}")));
}
lines.push(String::new());
lines.push(format!("CREATE {}PROCEDURE {}(", or_replace(routine), plain_name(routine)));
let last = routine.parameters.len().saturating_sub(1);
for (index, parameter) in routine.parameters.iter().enumerate() {
let comma = if index == last { "" } else { "," };
lines.push(format!(
" {} {} {}{}",
direction_keyword(parameter.direction),
parameter.name,
parameter.data_type,
comma
));
}
lines.push(")".to_owned());
lines.push("AS $$".to_owned());
if !declarations(routine).is_empty() {
lines.push("DECLARE".to_owned());
for declaration in declarations(routine) {
lines.push(match &declaration.default {
Some(default) => {
format!(" {} {} := {};", declaration.name, declaration.data_type, default)
}
None => format!(" {} {};", declaration.name, declaration.data_type),
});
}
}
lines.push("BEGIN".to_owned());
lines.extend(body_lines(routine, " "));
lines.push("END;".to_owned());
lines.push("$$;".to_owned());
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::routine_spec::{RoutineKind, RoutineParameter};
fn parameter(name: &str, data_type: &str, direction: ParameterDirection) -> RoutineParameter {
RoutineParameter {
name: name.to_owned(),
data_type: data_type.to_owned(),
declared_type: None,
direction,
default: None,
}
}
fn a_procedure() -> RoutineSpec {
let mut spec = RoutineSpec::new(RoutineKind::Procedure, "load_customer");
spec.schema = Some("retail_core".to_owned());
spec.or_replace = true;
spec.comment = Some("Loads the day's customers.".to_owned());
spec.parameters = vec![
parameter("p_run_id", "BIGINT", ParameterDirection::In),
parameter("p_loaded", "BIGINT", ParameterDirection::Out),
];
spec.declarations = vec![RoutineDeclaration {
name: "v_seen".to_owned(),
data_type: "DECIMAL(19, 4)".to_owned(),
declared_type: None,
default: Some("0".to_owned()),
}];
spec.body = RoutineBody::Statements(vec![
"DELETE FROM stage_customer".to_owned(),
"SELECT COUNT(*) FROM stage_customer INTO v_seen".to_owned(),
]);
spec
}
#[test]
fn the_pl_pgsql_form_is_a_dollar_quoted_body_and_a_comment_statement() {
assert_eq!(
render_procedure_as(ProcedureForm::PlPgSql, &a_procedure(), true),
"CREATE OR REPLACE PROCEDURE retail_core.load_customer(\n IN p_run_id BIGINT,\n \
OUT p_loaded BIGINT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $$\nDECLARE\n \
v_seen DECIMAL(19, 4) := 0;\nBEGIN\n DELETE FROM stage_customer;\n SELECT \
COUNT(*) FROM stage_customer INTO v_seen;\nEND;\n$$;\n\nCOMMENT ON PROCEDURE \
retail_core.load_customer(BIGINT, BIGINT) IS 'Loads the day''s customers.';"
);
}
#[test]
fn the_mysql_form_wears_the_client_delimiter_and_drops_first() {
let rendered = render_procedure_as(ProcedureForm::MySql, &a_procedure(), true);
assert!(
rendered.starts_with(
"DROP PROCEDURE IF EXISTS retail_core.load_customer;\n\nDELIMITER $$\nCREATE \
PROCEDURE retail_core.load_customer("
),
"{rendered}"
);
assert!(
rendered.contains("\n DECLARE v_seen DECIMAL(19, 4) DEFAULT 0;\n"),
"{rendered}"
);
assert!(rendered.ends_with("END$$\nDELIMITER ;"), "{rendered}");
assert!(!rendered.contains("$$;"), "dollar quoting is not MySQL's: {rendered}");
}
#[test]
fn the_t_sql_form_brackets_names_and_puts_the_description_in_its_own_batch() {
let rendered = render_procedure_as(ProcedureForm::TSql, &a_procedure(), true);
assert!(
rendered.starts_with(concat!(
"CREATE OR ALTER PROCEDURE [retail_core].[load_customer]\n",
" @p_run_id BIGINT,\n",
" @p_loaded BIGINT OUTPUT\n",
"AS\nBEGIN\n SET NOCOUNT ON;\n SET XACT_ABORT ON;\n",
" DECLARE @v_seen DECIMAL(19, 4) = 0;\n"
)),
"{rendered}"
);
assert!(rendered.contains("\nEND;\n\nGO\nIF NOT EXISTS"), "{rendered}");
assert!(rendered.contains("sp_addextendedproperty"), "{rendered}");
}
#[test]
fn the_snowflake_form_declares_a_return_and_hands_one_back() {
let rendered = render_procedure_as(ProcedureForm::SnowflakeScripting, &a_procedure(), true);
assert!(rendered.contains("\nRETURNS STRING\nLANGUAGE SQL\nEXECUTE AS CALLER\nAS\n$$\n"));
assert!(rendered.contains("\n RETURN 'SUCCESS';\nEND;\n$$;"), "{rendered}");
}
#[test]
fn the_google_sql_form_leaves_the_body_unquoted() {
let rendered = render_procedure_as(ProcedureForm::GoogleSql, &a_procedure(), true);
assert!(rendered.starts_with(
"CREATE OR REPLACE PROCEDURE `retail_core`.`load_customer`(p_run_id BIGINT, p_loaded \
BIGINT)\nOPTIONS (description = 'Loads the day\\'s customers.')\nBEGIN\n"
), "{rendered}");
assert!(!rendered.contains("$$"), "GoogleSQL has no dollar quoting: {rendered}");
}
#[test]
fn the_execution_context_is_spelled_by_each_grammar() {
let mut owner = a_procedure();
owner.security = RoutineSecurity::Owner;
assert!(
render_procedure_as(ProcedureForm::PlPgSql, &owner, true).contains("SECURITY DEFINER")
);
assert!(
render_procedure_as(ProcedureForm::TSql, &owner, true)
.contains("WITH EXECUTE AS OWNER")
);
assert!(
render_procedure_as(ProcedureForm::SnowflakeScripting, &owner, true)
.contains("EXECUTE AS OWNER")
);
}
#[test]
fn an_unsupported_form_answers_in_the_dialects_own_words_and_spells_no_statement() {
let rendered = render_procedure_as(
ProcedureForm::Unsupported("this engine keeps load logic in the application"),
&a_procedure(),
true,
);
assert!(rendered.contains("this engine keeps load logic in the application"));
assert!(
crate::comment::comment_line_flags(&rendered).iter().all(|note| *note),
"{rendered}"
);
assert!(!rendered.contains("CREATE"), "{rendered}");
}
#[test]
fn a_custom_form_answers_with_the_agnostic_form_and_names_the_grammar() {
let rendered = render_procedure_as(ProcedureForm::Custom("PL/SQL"), &a_procedure(), true);
assert!(rendered.starts_with("/* You asked for PL/SQL"), "{rendered}");
assert!(rendered.contains("@razeri:type procedure"), "{rendered}");
}
#[test]
fn the_two_forms_that_are_not_sql_say_so_rather_than_spelling_a_create() {
for (form, word) in [
(ProcedureForm::ClickHouse, "executable program"),
(ProcedureForm::Databricks, "Python function"),
] {
let rendered = render_procedure_as(form, &a_procedure(), true);
assert!(rendered.contains(word), "{rendered}");
assert!(rendered.starts_with("/* "), "{rendered}");
}
}
#[test]
fn a_bare_procedure_is_still_a_whole_statement() {
let mut spec = RoutineSpec::new(RoutineKind::Procedure, "tick");
spec.body = RoutineBody::Statements(vec!["UPDATE heartbeat SET seen = 1".to_owned()]);
assert_eq!(
render_procedure_as(ProcedureForm::PlPgSql, &spec, true),
"CREATE PROCEDURE tick(\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $$\nBEGIN\n \
UPDATE heartbeat SET seen = 1;\nEND;\n$$;"
);
}
#[test]
fn a_procedure_for_an_engine_without_descriptions_carries_none() {
let spec = a_procedure();
assert!(spec.comment.is_some(), "the fixture carries a description");
for form in [
ProcedureForm::PlPgSql,
ProcedureForm::SnowflakeScripting,
ProcedureForm::MySql,
ProcedureForm::TSql,
ProcedureForm::GoogleSql,
] {
let declined = render_procedure_as(form, &spec, false);
assert!(!declined.contains("COMMENT ON PROCEDURE"), "{form:?}: {declined}");
assert!(
!declined.contains("Loads the day"),
"{form:?} kept the description: {declined}"
);
assert!(declined.contains("load_customer"), "{form:?} still writes the procedure");
let described = render_procedure_as(form, &spec, true);
assert!(
described.contains("Loads the day"),
"{form:?} lost the description: {described}"
);
}
}
}