use proc_macro2::{Ident, Spacing, TokenStream, TokenTree};
use quote::quote;
const SQL_SHAPES: &[(&str, &[&str], bool)] = &[
("SELECT", &["FROM"], false),
("INSERT", &["INTO"], true),
("UPDATE", &["SET"], true),
("DELETE", &["FROM"], true),
("MERGE", &["INTO", "USING", "ON", "WHEN"], true),
];
const SQL_MUTATION_VERBS: &[&str] = &["DELETE", "INSERT", "MERGE", "TRUNCATE", "UPDATE"];
const SQL_STATEMENT_VERBS: &[&str] = &["DELETE", "INSERT", "MERGE", "SELECT", "UPDATE", "VALUES"];
const TRUNCATE_CLAUSE_WORDS: &[&str] = &[
"CASCADE", "CONTINUE", "IDENTITY", "ONLY", "RESTART", "RESTRICT", "TABLE",
];
const MUTATION_IDENTS: &[&str] = &[
"create_many",
"delete_by_id",
"delete_from",
"insert_into",
"save",
"update_by_id",
"upsert",
];
const QUALIFIED_MUTATION_IDENTS: &[&str] = &["create", "delete", "insert", "update"];
fn flatten(stream: &TokenStream, out: &mut Vec<TokenTree>) {
for tree in stream.clone() {
match tree {
TokenTree::Group(ref group) => flatten(&group.stream(), out),
other => out.push(other),
}
}
}
fn is_path_sep(tokens: &[TokenTree], i: usize) -> bool {
matches!(tokens.get(i), Some(TokenTree::Punct(p))
if p.as_char() == ':' && p.spacing() == Spacing::Joint)
&& matches!(tokens.get(i + 1), Some(TokenTree::Punct(p)) if p.as_char() == ':')
}
fn is_type_shaped(ident: &Ident) -> bool {
ident
.to_string()
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase())
}
fn literal_text(literal: &str) -> String {
let trimmed = literal.trim_start_matches(['b', 'r', 'c']);
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
let closing = format!("\"{}", "#".repeat(hashes));
let inner = trimmed
.trim_start_matches('#')
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix(&closing))
.unwrap_or(trimmed);
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('n' | 'r') => out.push('\n'),
Some(_) | None => out.push(' '),
}
} else {
out.push(ch);
}
}
out
}
fn dollar_tag(chars: &[char], i: usize) -> Option<String> {
if chars.get(i) != Some(&'$') {
return None;
}
if i > 0 && (chars[i - 1].is_ascii_alphanumeric() || matches!(chars[i - 1], '_' | '$')) {
return None;
}
let mut end = i + 1;
if let Some(first) = chars.get(end)
&& (first.is_ascii_alphabetic() || *first == '_')
{
end += 1;
while chars
.get(end)
.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_')
{
end += 1;
}
}
(chars.get(end) == Some(&'$')).then(|| chars[i..=end].iter().collect())
}
fn strip_sql_comments(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
let mut block_depth = 0_u32;
while i < chars.len() {
let ch = chars[i];
let next = chars.get(i + 1).copied();
if block_depth > 0 {
match (ch, next) {
('/', Some('*')) => {
block_depth += 1;
i += 2;
}
('*', Some('/')) => {
block_depth -= 1;
i += 2;
}
_ => i += 1,
}
continue;
}
match (ch, next) {
('-', Some('-')) => {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
out.push('\n');
}
('/', Some('*')) => {
block_depth = 1;
i += 2;
out.push(' ');
}
('$', _) if dollar_tag(&chars, i).is_some() => {
let delimiter: String = dollar_tag(&chars, i).expect("guarded above");
out.push_str(&delimiter);
i += delimiter.chars().count();
while i < chars.len() {
if chars[i] == '$'
&& chars[i..]
.iter()
.take(delimiter.chars().count())
.copied()
.eq(delimiter.chars())
{
out.push_str(&delimiter);
i += delimiter.chars().count();
break;
}
out.push(chars[i]);
i += 1;
}
}
('\'' | '"', _) => {
let quote = ch;
out.push(quote);
i += 1;
while i < chars.len() {
out.push(chars[i]);
if chars[i] == quote {
if chars.get(i + 1) == Some("e) {
out.push(quote);
i += 2;
continue;
}
i += 1;
break;
}
i += 1;
}
}
_ => {
out.push(ch);
i += 1;
}
}
}
out
}
fn sql_words(literal: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
for ch in literal.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
current.push(ch);
} else if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
words.push(current);
}
words.retain(|word| !word.chars().all(|c| c.is_ascii_digit()));
words
}
fn is_cte_statement(rest: &[&str]) -> bool {
rest.contains(&"AS") && rest.iter().any(|w| SQL_STATEMENT_VERBS.contains(w))
}
fn is_truncate_operands(rest: &[&str]) -> bool {
let mut named_a_table = false;
for word in rest {
if TRUNCATE_CLAUSE_WORDS.contains(word) {
continue;
}
if !word.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
return false;
}
named_a_table = true;
}
named_a_table
}
fn verb_statements(text: &str) -> Vec<(String, String)> {
let chars: Vec<char> = text.chars().collect();
let mut statements = Vec::new();
let mut current = String::new();
let mut start = 0;
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if ch == ';' {
statements.push((
std::mem::take(&mut current),
chars[start..i].iter().collect(),
));
i += 1;
start = i;
} else if ch == '\'' {
current.push(' ');
i += 1;
while i < chars.len() {
let closes = chars[i] == '\'' && chars.get(i + 1) != Some(&'\'');
let escaped = chars[i] == '\'' && chars.get(i + 1) == Some(&'\'');
i += if escaped { 2 } else { 1 };
if closes {
break;
}
}
} else if ch == '"' {
current.push(ch);
i += 1;
while i < chars.len() {
current.push(chars[i]);
i += 1;
if chars[i - 1] == '"' {
if chars.get(i) == Some(&'"') {
current.push('"');
i += 1;
} else {
break;
}
}
}
} else if let Some(tag) = dollar_tag(&chars, i) {
let width = tag.chars().count();
current.push(' ');
i += width;
while i < chars.len() && !chars[i..].iter().take(width).copied().eq(tag.chars()) {
i += 1;
}
i = (i + width).min(chars.len());
} else {
current.push(ch);
i += 1;
}
}
statements.push((current, chars[start.min(chars.len())..].iter().collect()));
statements
}
const TABLE_TRAILING_CLAUSES: &[&str] = &[
"EXCEPT",
"FETCH",
"FOR",
"INTERSECT",
"LIMIT",
"OFFSET",
"ORDER",
"UNION",
];
fn is_table_operand(rest: &str) -> bool {
let rest = rest.trim();
let rest = match rest.strip_prefix("ONLY") {
Some(after) if after.starts_with(char::is_whitespace) => after.trim_start(),
_ => rest,
};
let (name, tail) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let tail_ok = tail
.split_whitespace()
.next()
.is_none_or(|word| TABLE_TRAILING_CLAUSES.contains(&word));
name != "ONLY"
&& tail_ok
&& name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_' || c == '"')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '"' | '*'))
}
fn copy_shape(rest_text: &str) -> Option<bool> {
let (writes, start, end) = copy_direction(rest_text)?;
let target = rest_text[..start].split_whitespace().next()?;
let source = rest_text[end..].split_whitespace().next();
let valid_source = source.is_none_or(|w| {
matches!(
w.trim_matches(|c: char| !c.is_ascii_alphanumeric()),
"STDIN"
| "STDOUT"
| "PROGRAM"
| ""
| "WITH"
| "WHERE"
| "CSV"
| "BINARY"
| "TEXT"
| "DELIMITER"
| "NULL"
| "HEADER"
| "QUOTE"
| "ESCAPE"
| "FORMAT"
| "FREEZE"
| "ENCODING"
)
});
(valid_source && target != "TO" && target != "FROM").then_some(writes)
}
fn copy_direction(rest_text: &str) -> Option<(bool, usize, usize)> {
let mut depth = 0_u32;
let mut in_quote = false;
let mut word_start = 0;
let mut in_word = false;
for (at, ch) in rest_text
.char_indices()
.chain(std::iter::once((rest_text.len(), ' ')))
{
if ch == '"' {
in_quote = !in_quote;
in_word = false;
} else if in_quote {
} else if ch.is_ascii_alphanumeric() || ch == '_' {
if depth == 0 && !in_word {
in_word = true;
word_start = at;
}
} else {
if in_word {
match &rest_text[word_start..at] {
"FROM" => return Some((true, word_start, at)),
"TO" => return Some((false, word_start, at)),
_ => {}
}
in_word = false;
}
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ => {}
}
}
}
None
}
fn statement_shape(statement: &str) -> Option<bool> {
let text = statement.to_ascii_uppercase();
let mut words = text
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.filter(|w| !w.is_empty());
let first = words.next()?;
let rest: Vec<&str> = words.collect();
let rest_text = text.trim_start().get(first.len()..).unwrap_or("");
if first == "TRUNCATE" {
return is_truncate_operands(&rest).then_some(true);
}
if first == "COPY" {
return copy_shape(rest_text);
}
if first == "TABLE" {
return is_table_operand(rest_text).then_some(false);
}
if first == "WITH" {
return is_cte_statement(&rest)
.then(|| rest.iter().any(|w| SQL_MUTATION_VERBS.contains(w)));
}
let (_, _, mutating) = SQL_SHAPES.iter().find(|(verb, companions, _)| {
first == *verb && companions.iter().all(|c| rest.contains(c))
})?;
Some(*mutating)
}
fn sql_shape(literal: &str) -> Option<bool> {
let text = strip_sql_comments(&literal_text(literal));
verb_statements(&text)
.iter()
.filter_map(|(blanked, _)| statement_shape(blanked))
.reduce(|a, b| a || b)
}
fn mutating_statement_words(literal: &str) -> Vec<String> {
let text = strip_sql_comments(&literal_text(literal));
verb_statements(&text)
.iter()
.filter(|(blanked, _)| statement_shape(blanked) == Some(true))
.flat_map(|(blanked, raw)| {
let targets = write_targets(blanked);
if targets.is_empty() {
sql_words(raw)
} else {
targets
}
})
.collect()
}
fn write_targets(statement: &str) -> Vec<String> {
let statement = statement
.split('.')
.map(str::trim)
.collect::<Vec<_>>()
.join(".");
let tokens: Vec<&str> = statement
.split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '"')))
.filter(|t| !t.is_empty())
.collect();
let upper = |i: usize| tokens.get(i).map(|t| t.to_ascii_uppercase());
let mut targets: Vec<String> = Vec::new();
let mut take = |i: usize| {
let mut i = i;
if upper(i).as_deref() == Some("ONLY") {
i += 1;
}
if let Some(name) = tokens.get(i) {
targets.extend(
name.split('.')
.map(|part| part.trim_matches('"').to_owned())
.filter(|part| !part.is_empty()),
);
}
};
for i in 0..tokens.len() {
let word = upper(i);
match word.as_deref() {
Some("INTO")
if matches!(
upper(i.wrapping_sub(1)).as_deref(),
Some("INSERT" | "MERGE")
) =>
{
take(i + 1);
}
Some("FROM") if upper(i.wrapping_sub(1)).as_deref() == Some("DELETE") => take(i + 1),
Some("UPDATE" | "COPY") => take(i + 1),
Some("TRUNCATE") => {
let mut j = i + 1;
while let Some(next) = upper(j) {
if TRUNCATE_CLAUSE_WORDS.contains(&next.as_str()) {
j += 1;
continue;
}
if next.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_' || c == '"') {
take(j);
j += 1;
} else {
break;
}
}
}
_ => {}
}
}
targets
}
fn looks_like_sql(literal: &str) -> bool {
sql_shape(literal).is_some()
}
#[must_use]
pub fn sql_candidate_symbols(stream: &TokenStream) -> Vec<String> {
let mut tokens = Vec::new();
flatten(stream, &mut tokens);
sql_symbols_of(&tokens)
}
fn sql_symbols_of(tokens: &[TokenTree]) -> Vec<String> {
let mut symbols: Vec<String> = Vec::new();
for tree in tokens {
if let TokenTree::Literal(literal) = tree {
let source = literal.to_string();
if looks_like_sql(&source) {
symbols.extend(sql_words(&strip_sql_comments(&literal_text(&source))));
}
}
}
symbols.sort();
symbols.dedup();
symbols
}
#[must_use]
pub fn candidate_symbols(stream: &TokenStream) -> Vec<String> {
let mut tokens = Vec::new();
flatten(stream, &mut tokens);
symbols_of(&tokens)
}
fn symbols_of(tokens: &[TokenTree]) -> Vec<String> {
let mut symbols: Vec<String> = Vec::new();
for (i, tree) in tokens.iter().enumerate() {
match tree {
TokenTree::Ident(ident) => {
let followed = is_path_sep(tokens, i + 1);
let preceded = i >= 2 && is_path_sep(tokens, i - 2);
if followed || preceded || is_type_shaped(ident) {
symbols.push(ident.to_string());
}
}
TokenTree::Literal(literal) => {
let source = literal.to_string();
if looks_like_sql(&source) {
symbols.extend(sql_words(&strip_sql_comments(&literal_text(&source))));
}
}
TokenTree::Punct(_) | TokenTree::Group(_) => {}
}
}
symbols.sort();
symbols.dedup();
symbols
}
#[must_use]
pub fn signature_symbols(sig: &syn::Signature) -> Vec<String> {
let mut stream = TokenStream::new();
for arg in &sig.inputs {
if let syn::FnArg::Typed(pat_type) = arg {
let ty = &pat_type.ty;
stream.extend(quote! { #ty });
}
}
candidate_symbols(&stream)
}
fn mutates(tokens: &[TokenTree]) -> bool {
tokens.iter().enumerate().any(|(i, tree)| match tree {
TokenTree::Ident(ident) => {
let name = ident.to_string();
MUTATION_IDENTS.contains(&name.as_str())
|| (QUALIFIED_MUTATION_IDENTS.contains(&name.as_str())
&& i >= 2
&& is_path_sep(tokens, i - 2))
}
TokenTree::Literal(literal) => has_placeholder_write(&literal.to_string()),
TokenTree::Punct(_) | TokenTree::Group(_) => false,
})
}
fn has_placeholder_write(literal: &str) -> bool {
let text = strip_sql_comments(&literal_text(literal));
verb_statements(&text)
.iter()
.any(|(blanked, _)| statement_shape(blanked) == Some(true) && blanked.contains('{'))
}
fn mutating_sql_symbols(tokens: &[TokenTree]) -> Vec<String> {
let mut symbols: Vec<String> = tokens
.iter()
.filter_map(|tree| match tree {
TokenTree::Literal(literal) => Some(mutating_statement_words(&literal.to_string())),
_ => None,
})
.flatten()
.collect();
symbols.sort();
symbols.dedup();
symbols
}
fn dot_mutation_receivers(tokens: &[TokenTree]) -> Vec<String> {
let is_dot =
|i: usize| matches!(tokens.get(i), Some(TokenTree::Punct(p)) if p.as_char() == '.');
let mut receivers: Vec<String> = Vec::new();
for (i, tree) in tokens.iter().enumerate() {
let TokenTree::Ident(verb) = tree else {
continue;
};
if i >= 2
&& is_dot(i - 1)
&& QUALIFIED_MUTATION_IDENTS.contains(&verb.to_string().as_str())
&& let Some(TokenTree::Ident(receiver)) = tokens.get(i - 2)
{
receivers.push(receiver.to_string());
}
}
receivers.sort();
receivers.dedup();
receivers
}
fn bound_name(pat: &syn::Pat) -> Option<String> {
match pat {
syn::Pat::Ident(ident) => Some(ident.ident.to_string()),
syn::Pat::TupleStruct(tuple) if tuple.elems.len() == 1 => bound_name(&tuple.elems[0]),
syn::Pat::Reference(reference) => bound_name(&reference.pat),
syn::Pat::Paren(paren) => bound_name(&paren.pat),
_ => None,
}
}
#[must_use]
pub fn signature_bindings(sig: &syn::Signature) -> Vec<(String, String)> {
let mut pairs = Vec::new();
for arg in &sig.inputs {
if let syn::FnArg::Typed(pat_type) = arg
&& let Some(name) = bound_name(&pat_type.pat)
{
let ty = &pat_type.ty;
for symbol in candidate_symbols("e! { #ty }) {
pairs.push((name.clone(), symbol));
}
}
}
pairs
}
#[must_use]
pub fn emit_symbol_slice(symbols: &[String]) -> TokenStream {
if symbols.is_empty() {
quote! { &[] }
} else {
let items = symbols.iter().map(String::as_str);
quote! { &[#(#items),*] }
}
}
fn emit_binding_slice(pairs: &[(String, String)]) -> TokenStream {
let items = pairs.iter().map(|(a, b)| quote! { (#a, #b) });
quote! { &[#(#items),*] }
}
#[must_use]
pub fn emit_route_descriptor(
input_fn: &syn::ItemFn,
method: &str,
path: &TokenStream,
static_route: bool,
) -> TokenStream {
let signature = emit_symbol_slice(&signature_symbols(&input_fn.sig));
let block = &input_fn.block;
let body_tokens = quote! { #block };
let body = emit_symbol_slice(&candidate_symbols(&body_tokens));
let sql = emit_symbol_slice(&sql_candidate_symbols(&body_tokens));
let handler = input_fn.sig.ident.to_string();
let crate_version = autumn_macros_support::graph::crate_version();
quote! {
::autumn_web::reexports::inventory::submit! {
::autumn_web::graph::RouteGraphDescriptor {
handler: #handler,
module_path: ::core::module_path!(),
method: #method,
path: #path,
static_route: #static_route,
crate_version: #crate_version,
file: ::core::file!(),
line: ::core::line!(),
signature_symbols: #signature,
body_symbols: #body,
sql_symbols: #sql,
}
}
}
}
#[must_use]
pub fn emit_job_descriptor(
input_fn: &syn::ItemFn,
name: &TokenStream,
kind: &str,
schedule: &TokenStream,
) -> TokenStream {
let signature = emit_symbol_slice(&signature_symbols(&input_fn.sig));
let block = &input_fn.block;
let body_tokens = quote! { #block };
let mut flat = Vec::new();
flatten(&body_tokens, &mut flat);
let body = emit_symbol_slice(&symbols_of(&flat));
let sql = emit_symbol_slice(&sql_symbols_of(&flat));
let mutating = mutates(&flat);
let mutating_symbols = emit_symbol_slice(&mutating_sql_symbols(&flat));
let receivers = emit_symbol_slice(&dot_mutation_receivers(&flat));
let bindings = emit_binding_slice(&signature_bindings(&input_fn.sig));
let handler = input_fn.sig.ident.to_string();
let kind_ident = syn::Ident::new(kind, proc_macro2::Span::call_site());
let crate_version = autumn_macros_support::graph::crate_version();
quote! {
::autumn_web::reexports::inventory::submit! {
::autumn_web::graph::JobGraphDescriptor {
name: #name,
kind: ::autumn_web::graph::JobKind::#kind_ident,
handler: #handler,
module_path: ::core::module_path!(),
schedule: #schedule,
mutating: #mutating,
mutating_symbols: #mutating_symbols,
crate_version: #crate_version,
file: ::core::file!(),
line: ::core::line!(),
signature_symbols: #signature,
signature_bindings: #bindings,
dot_mutation_receivers: #receivers,
body_symbols: #body,
sql_symbols: #sql,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
fn is_mutating(stream: &TokenStream) -> bool {
let mut tokens = Vec::new();
flatten(stream, &mut tokens);
mutates(&tokens) || !mutating_sql_symbols(&tokens).is_empty()
}
fn flat(stream: &TokenStream) -> Vec<TokenTree> {
let mut tokens = Vec::new();
flatten(stream, &mut tokens);
tokens
}
#[test]
fn mutating_sql_names_the_symbols_of_the_writing_statement_only() {
let tokens = flat("e! {
batch_execute("SELECT * FROM users; INSERT INTO audit_events (kind) VALUES ('x')")
});
let symbols = mutating_sql_symbols(&tokens);
assert!(symbols.contains(&"audit_events".to_owned()), "{symbols:?}");
assert!(!symbols.contains(&"users".to_owned()), "{symbols:?}");
assert!(!mutates(&tokens), "SQL evidence is attributed, not loose");
}
#[test]
fn mutating_sql_keeps_quoted_words_of_the_writing_statement() {
let tokens = flat("e! { sql_query("UPDATE \"Posts\" SET a = 'b; c' WHERE x = 1") });
let symbols = mutating_sql_symbols(&tokens);
assert!(symbols.contains(&"Posts".to_owned()), "{symbols:?}");
}
#[test]
fn a_read_only_statement_names_no_mutating_symbols() {
let tokens = flat("e! { sql_query("SELECT * FROM users") });
assert!(mutating_sql_symbols(&tokens).is_empty());
}
#[test]
fn evidence_that_names_no_symbol_stays_unattributed() {
assert!(mutates(&flat("e! { repo.save(&post).await })));
assert!(mutates(&flat("e! { diesel::update(posts::table) })));
}
#[test]
fn dot_receivers_are_the_names_before_a_mutation_verb() {
let tokens = flat("e! {
repo.update(id, &patch).await?;
vars.insert("a", 1);
other.create(&b);
q.delete(1);
let x = repo.find(1);
div hx-delete=(paths::delete_post(&slug)) {}
});
let mut receivers = dot_mutation_receivers(&tokens);
receivers.sort();
assert_eq!(receivers, ["other", "q", "repo", "vars"]);
}
#[test]
fn signature_bindings_pair_a_parameter_with_each_type_symbol() {
let sig: syn::Signature = syn::parse_quote! {
fn run(mut repo: PgPostRepository, State(users): State<PgUserRepository>, n: u32)
};
let mut pairs = signature_bindings(&sig);
pairs.sort();
let pairs: Vec<(&str, &str)> = pairs
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
assert_eq!(
pairs,
[
("repo", "PgPostRepository"),
("users", "PgUserRepository"),
("users", "State"),
]
);
}
#[test]
fn a_job_descriptor_carries_the_new_evidence() {
let item: syn::ItemFn = syn::parse_quote! {
async fn go(repo: PgPostRepository) {
repo.update(1, &p).await;
sql_query("INSERT INTO audit_events (a) VALUES (1)");
}
};
let generated =
emit_job_descriptor(&item, "e! { "go" }, "Job", "e! { "" }).to_string();
for needle in [
"mutating_symbols",
"audit_events",
"signature_bindings",
"dot_mutation_receivers",
"\"repo\"",
] {
assert!(generated.contains(needle), "{needle}: {generated}");
}
}
#[test]
fn ui_copy_that_reads_like_sql_is_not_scanned() {
for prose in [
" for auto-slug generation and logging on post create/update",
"Delete this post? This cannot be undone.",
"{n} new posts from your subreddits",
] {
let symbols = candidate_symbols("e! { let msg = #prose; });
assert!(
!symbols.iter().any(|s| s == "posts" || s == "post"),
"prose must not open the SQL scan ({prose:?}): {symbols:?}"
);
}
}
#[test]
fn ui_copy_that_reads_like_sql_is_not_mutation_evidence() {
assert!(
!is_mutating("e! { let label = "Delete this post? This cannot be undone."; }),
"a page that renders the word Delete is not a writer"
);
}
#[test]
fn an_ambiguous_mutation_name_counts_only_as_a_qualified_path() {
assert!(
!is_mutating("e! { vars.insert("name", user.name) }),
"`HashMap::insert` is not a database write"
);
assert!(
!is_mutating("e! { div hx-delete=(paths::delete_post(&slug)) }),
"maud lexes `hx-delete` into a bare `delete` ident"
);
assert!(
is_mutating("e! { diesel::update(posts::table).set(x.eq(1)) }),
"the query builder is called as a qualified path"
);
assert!(is_mutating("e! { diesel::delete(posts::table) }));
}
#[test]
fn a_raw_string_contributes_no_sigil_symbols() {
let symbols = candidate_symbols("e! { sql_query(r#"SELECT id FROM posts"#) });
assert!(symbols.contains(&"posts".to_owned()), "{symbols:?}");
assert!(!symbols.contains(&"r".to_owned()), "{symbols:?}");
}
#[test]
fn numbers_inside_a_statement_are_not_candidates() {
let symbols = candidate_symbols("e! {
sql_query("UPDATE posts SET hot_rank = 3600 / 1.5")
});
assert!(symbols.contains(&"posts".to_owned()), "{symbols:?}");
assert!(
!symbols.iter().any(|s| s == "3600" || s == "1"),
"{symbols:?}"
);
}
#[test]
fn an_identifier_followed_by_a_path_separator_is_a_candidate() {
let symbols = candidate_symbols("e! { posts::table.find(id) });
assert!(symbols.contains(&"posts".to_owned()), "{symbols:?}");
}
#[test]
fn an_identifier_preceded_by_a_path_separator_is_a_candidate() {
let symbols = candidate_symbols("e! { crate::schema::subreddits::table });
assert!(symbols.contains(&"subreddits".to_owned()), "{symbols:?}");
assert!(symbols.contains(&"schema".to_owned()), "{symbols:?}");
}
#[test]
fn a_type_shaped_identifier_is_a_candidate_even_standing_alone() {
let symbols = candidate_symbols("e! { let items: Vec<Post> = load(); });
assert!(symbols.contains(&"Post".to_owned()), "{symbols:?}");
}
#[test]
fn a_lowercase_identifier_with_no_path_separator_is_not_a_candidate() {
let symbols = candidate_symbols("e! { let posts = 1; });
assert!(!symbols.contains(&"posts".to_owned()), "{symbols:?}");
}
#[test]
fn candidates_are_found_inside_nested_groups() {
let symbols = candidate_symbols("e! {
if x { for y in z { let _ = posts::table; } }
});
assert!(symbols.contains(&"posts".to_owned()), "{symbols:?}");
}
#[test]
fn a_table_named_only_in_raw_sql_is_a_candidate() {
let symbols = candidate_symbols("e! {
diesel::sql_query("UPDATE posts SET hot_rank = 0")
});
assert!(
symbols.contains(&"posts".to_owned()),
"a table reachable only through raw SQL must still be found: {symbols:?}"
);
}
#[test]
fn a_word_that_merely_contains_a_sql_keyword_does_not_open_the_scan() {
let symbols = candidate_symbols("e! {
let msg = "your selection of posts is ready";
});
assert!(
!symbols.contains(&"posts".to_owned()),
"`selection` must not read as `SELECT`: {symbols:?}"
);
}
#[test]
fn prose_string_literals_are_not_scanned() {
let symbols = candidate_symbols("e! {
let msg = "no database pool available for posts right now";
});
assert!(
!symbols.contains(&"posts".to_owned()),
"a literal with no SQL keyword must not contribute names: {symbols:?}"
);
}
#[test]
fn candidates_are_sorted_and_deduplicated() {
let symbols = candidate_symbols("e! { Post::find(); Post::all(); Alpha::x(); });
assert_eq!(
symbols.iter().filter(|s| s.as_str() == "Post").count(),
1,
"{symbols:?}"
);
let mut sorted = symbols.clone();
sorted.sort();
assert_eq!(symbols, sorted);
}
#[test]
fn signature_symbols_name_the_declared_extractors() {
let input: syn::ItemFn = syn::parse_quote! {
async fn show(mut db: Db, repo: PgPostRepository) -> Markup { todo!() }
};
let symbols = signature_symbols(&input.sig);
assert!(
symbols.contains(&"PgPostRepository".to_owned()),
"{symbols:?}"
);
assert!(symbols.contains(&"Db".to_owned()), "{symbols:?}");
}
#[test]
fn signature_symbols_reach_inside_generic_arguments() {
let input: syn::ItemFn = syn::parse_quote! {
async fn create(form: ChangesetForm<NewPost>) -> Markup { todo!() }
};
let symbols = signature_symbols(&input.sig);
assert!(symbols.contains(&"NewPost".to_owned()), "{symbols:?}");
}
#[test]
fn a_binding_name_is_never_mistaken_for_an_extractor() {
let input: syn::ItemFn = syn::parse_quote! {
async fn show(posts: i64) -> Markup { todo!() }
};
assert!(
!signature_symbols(&input.sig).contains(&"posts".to_owned()),
"a parameter binding name must not be read as a type"
);
}
#[test]
fn mutation_is_detected_from_a_diesel_call() {
assert!(is_mutating("e! { diesel::insert_into(posts::table) }));
assert!(is_mutating("e! { repo.delete_by_id(id).await }));
}
#[test]
fn a_statement_verb_without_its_companion_is_not_sql() {
assert!(!looks_like_sql("\"Delete this post?\""));
assert!(looks_like_sql("\"DELETE FROM posts WHERE id = $1\""));
}
fn sql_symbols(source: &str) -> Vec<String> {
let stream: TokenStream = source.parse().expect("a parseable literal");
sql_candidate_symbols(&stream)
}
#[test]
fn a_leading_comment_does_not_hide_the_statement() {
let escaped = "\"-- prune rows nobody can reach\\nDELETE FROM posts WHERE id = $1\"";
assert_eq!(sql_shape(escaped), Some(true), "escaped newline");
assert!(
sql_symbols(escaped).contains(&"posts".to_owned()),
"the table behind a comment must still be a candidate"
);
let raw = "r#\"-- prune rows nobody can reach\nDELETE FROM posts WHERE id = $1\"#";
assert_eq!(sql_shape(raw), Some(true), "real newline");
assert!(sql_symbols(raw).contains(&"posts".to_owned()));
let block = "\"/* housekeeping */ SELECT id FROM posts\"";
assert_eq!(sql_shape(block), Some(false), "block comment");
assert!(sql_symbols(block).contains(&"posts".to_owned()));
}
#[test]
fn dollar_quoted_text_is_not_scanned_for_comments() {
let bare = "\"SELECT $$-- marker$$ FROM posts\"";
assert_eq!(sql_shape(bare), Some(false));
assert!(
sql_symbols(bare).contains(&"posts".to_owned()),
"a dollar-quoted -- must not eat the FROM clause"
);
let tagged = "\"SELECT $body$-- marker$body$ FROM posts\"";
assert_eq!(sql_shape(tagged), Some(false));
assert!(sql_symbols(tagged).contains(&"posts".to_owned()));
let placeholder = "\"DELETE FROM posts WHERE id = $1 AND slug = $2\"";
assert_eq!(sql_shape(placeholder), Some(true));
assert!(sql_symbols(placeholder).contains(&"posts".to_owned()));
}
#[test]
fn a_quoted_double_dash_is_not_a_comment() {
let literal = "\"SELECT id FROM posts WHERE slug = '--'\"";
assert_eq!(sql_shape(literal), Some(false));
assert!(
sql_symbols(literal).contains(&"posts".to_owned()),
"a quoted -- must not eat the FROM clause"
);
}
#[test]
fn a_mutation_verb_in_a_comment_is_not_mutation_evidence() {
let literal =
"\"WITH recent AS (SELECT id FROM posts) SELECT * FROM recent -- never DELETE\"";
assert_eq!(sql_shape(literal), Some(false));
}
#[test]
fn a_write_only_cte_statement_is_still_a_statement() {
let literal = "\"WITH removed AS (DELETE FROM posts RETURNING id) \
UPDATE counters SET value = 0\"";
assert_eq!(sql_shape(literal), Some(true), "both halves write");
let symbols = sql_symbols(literal);
assert!(symbols.contains(&"posts".to_owned()), "the CTE's table");
assert!(symbols.contains(&"counters".to_owned()), "the outer table");
}
#[test]
fn a_with_sentence_is_not_a_statement() {
assert_eq!(
sql_shape("\"With the draft saved as a copy\""),
None,
"`as` without a statement verb is prose"
);
assert_eq!(
sql_shape("\"With luck the update lands before the deadline\""),
None,
"a statement verb without `AS` is prose"
);
}
#[test]
fn merge_is_a_statement_and_a_mutation() {
let literal = "\"MERGE INTO posts USING staged ON posts.id = staged.id \
WHEN MATCHED THEN UPDATE SET title = staged.title \
WHEN NOT MATCHED THEN INSERT VALUES (staged.id, staged.title)\"";
assert_eq!(sql_shape(literal), Some(true), "MERGE always writes");
let symbols = sql_symbols(literal);
assert!(symbols.contains(&"posts".to_owned()), "the merge target");
assert!(symbols.contains(&"staged".to_owned()), "the merge source");
}
#[test]
fn a_merge_sentence_is_not_a_statement() {
assert_eq!(
sql_shape("\"Merge into main using the newest revision\""),
None,
"prose must not be scanned, and must not claim mutation"
);
assert_eq!(
sql_shape("\"Merge the draft into the published post\""),
None
);
}
#[test]
fn a_merge_behind_ctes_is_still_a_mutation() {
let literal = "\"WITH staged AS (SELECT id, title FROM imports) \
MERGE INTO posts USING staged ON posts.id = staged.id \
WHEN MATCHED THEN DO NOTHING\"";
assert_eq!(sql_shape(literal), Some(true));
}
#[test]
fn truncate_without_the_optional_table_keyword_is_still_a_statement() {
let bare = "\"TRUNCATE posts\"";
assert_eq!(sql_shape(bare), Some(true), "bare TRUNCATE");
assert!(
sql_symbols(bare).contains(&"posts".to_owned()),
"the truncated table must still be a candidate"
);
let spelled = "\"TRUNCATE TABLE posts\"";
assert_eq!(sql_shape(spelled), Some(true), "TRUNCATE TABLE");
assert!(sql_symbols(spelled).contains(&"posts".to_owned()));
let clauses = "\"TRUNCATE ONLY posts, comments RESTART IDENTITY CASCADE\"";
assert_eq!(sql_shape(clauses), Some(true), "every optional clause");
let symbols = sql_symbols(clauses);
assert!(symbols.contains(&"posts".to_owned()));
assert!(symbols.contains(&"comments".to_owned()));
}
#[test]
fn a_truncate_sentence_is_not_a_statement() {
assert_eq!(
sql_shape("\"Truncate the summary to 200 characters\""),
None,
"prose must not be scanned, and must not claim mutation"
);
assert_eq!(
sql_shape("\"Truncate a title that runs past 80 chars\""),
None
);
}
#[test]
fn a_cte_verb_inside_quoted_text_is_not_mutation_evidence() {
let single = "\"WITH a AS (SELECT 'DELETE' FROM posts) SELECT * FROM a\"";
assert_eq!(sql_shape(single), Some(false), "single quotes");
let dollar = "\"WITH a AS (SELECT $$UPDATE$$ FROM posts) SELECT * FROM a\"";
assert_eq!(sql_shape(dollar), Some(false), "dollar quotes");
let tagged = "\"WITH a AS (SELECT $t$ delete $t$ FROM posts) SELECT * FROM a\"";
assert_eq!(sql_shape(tagged), Some(false), "tagged dollar quotes");
let real = "\"WITH a AS (DELETE FROM posts RETURNING 'x') SELECT * FROM a\"";
assert_eq!(sql_shape(real), Some(true), "a real verb still counts");
}
#[test]
fn quoted_words_stay_table_symbols() {
let literal = "\"SELECT 'tags' FROM posts\"";
let symbols = sql_symbols(literal);
assert!(symbols.contains(&"tags".to_owned()));
assert!(symbols.contains(&"posts".to_owned()));
}
#[test]
fn an_unterminated_quote_is_treated_as_a_string() {
let literal = "\"WITH a AS (SELECT 'DELETE FROM posts) SELECT 1\"";
assert_eq!(sql_shape(literal), Some(false));
let dollar = "\"WITH a AS (SELECT $$ UPDATE x) SELECT 1\"";
assert_eq!(sql_shape(dollar), Some(false));
}
#[test]
fn every_statement_of_a_multi_statement_literal_is_classified() {
let read_first = "\"SELECT id FROM posts; DELETE FROM comments\"";
assert_eq!(sql_shape(read_first), Some(true));
let wrapped = "\"BEGIN; UPDATE posts SET a = 1; COMMIT\"";
assert_eq!(sql_shape(wrapped), Some(true));
assert!(looks_like_sql(wrapped));
let reads = "\"SELECT 1; SELECT id FROM posts\"";
assert_eq!(sql_shape(reads), Some(false));
let commented = "\"-- note; more\nSELECT 1; DELETE FROM t\"";
assert_eq!(sql_shape(commented), Some(true));
assert!(is_mutating(
"e! { sql_query("SELECT 1 FROM a; INSERT INTO b VALUES (1)") }
));
}
#[test]
fn a_semicolon_inside_a_quote_does_not_split() {
let single = "\"SELECT id FROM posts WHERE a = ';DELETE FROM x'\"";
assert_eq!(sql_shape(single), Some(false));
let dollar = "\"SELECT id FROM posts WHERE a = $$; DELETE FROM x$$\"";
assert_eq!(sql_shape(dollar), Some(false));
let ident = r##"r#"SELECT "a;b" FROM posts"#"##;
assert_eq!(sql_shape(ident), Some(false));
}
#[test]
fn prose_with_a_semicolon_is_not_sql() {
assert_eq!(
sql_shape("\"Delete this post; this cannot be undone\""),
None
);
assert!(!is_mutating(
"e! { "Delete this post; this cannot be undone" }
));
}
#[test]
fn copy_is_a_statement_and_mutates_only_from() {
let from = "\"COPY posts FROM STDIN\"";
assert_eq!(sql_shape(from), Some(true));
assert!(sql_symbols(from).contains(&"posts".to_owned()));
let cols = "\"COPY posts (id, title) FROM '/tmp/x.csv'\"";
assert_eq!(sql_shape(cols), Some(true));
let to = "\"COPY posts TO STDOUT\"";
assert_eq!(sql_shape(to), Some(false));
assert!(sql_symbols(to).contains(&"posts".to_owned()));
let query = "\"COPY (SELECT id FROM posts) TO STDOUT\"";
assert_eq!(sql_shape(query), Some(false));
assert!(sql_symbols(query).contains(&"posts".to_owned()));
assert_eq!(sql_shape("\"Copy the link\""), None);
}
#[test]
fn a_write_symbol_is_the_target_not_the_source() {
let words = |sql: &str| mutating_statement_words(&format!("{sql:?}"));
let insert = words("INSERT INTO audit_events SELECT id FROM users");
assert_eq!(insert, vec!["audit_events".to_owned()]);
let update = words("UPDATE posts SET n = 1 FROM users WHERE users.id = posts.uid");
assert_eq!(update, vec!["posts".to_owned()]);
assert_eq!(
words("DELETE FROM public.posts WHERE id = 1"),
vec!["public".to_owned(), "posts".to_owned()]
);
let cte = words("WITH r AS (DELETE FROM a RETURNING id) UPDATE b SET v = 0");
assert_eq!(cte, vec!["a".to_owned(), "b".to_owned()]);
assert_eq!(
words("TRUNCATE TABLE a, b CASCADE"),
vec!["a".to_owned(), "b".to_owned()]
);
assert!(words("SELECT id FROM users").is_empty());
assert_eq!(
words("UPDATE public . posts SET n = 1"),
vec!["public".to_owned(), "posts".to_owned()]
);
let quoted = words("UPDATE posts SET note = 'UPDATE users'");
assert_eq!(quoted, vec!["posts".to_owned()]);
}
#[test]
fn a_table_statement_may_carry_select_clauses() {
let sql = "\"TABLE posts ORDER BY id LIMIT 10\"";
assert_eq!(sql_shape(sql), Some(false));
assert!(sql_symbols(sql).contains(&"posts".to_owned()));
assert_eq!(sql_shape("\"Table of contents\""), None);
assert_eq!(sql_shape("\"Table settings are saved\""), None);
}
#[test]
fn copy_from_a_file_with_options_is_a_write() {
for sql in [
"COPY posts FROM '/tmp/posts.csv' WITH (FORMAT csv)",
"COPY posts FROM '/tmp/posts.csv' CSV HEADER",
"COPY posts (id) FROM '/tmp/p.csv' WHERE id > 1",
] {
assert_eq!(sql_shape(&format!("{sql:?}")), Some(true), "{sql}");
}
}
#[test]
fn copy_prose_is_not_a_statement() {
for prose in [
"Copy from template",
"Copy link from here",
"Copy the file from a to b",
"Copy to clipboard",
] {
assert_eq!(sql_shape(&format!("{prose:?}")), None, "{prose}");
}
}
#[test]
fn a_placeholder_write_is_unattributed_mutation() {
assert!(is_mutating(
"e! { sql_query(format!("UPDATE {} SET hits = hits + 1", Post::TABLE)) }
));
assert!(!is_mutating(
"e! { sql_query("SELECT id FROM posts WHERE id = {}") }
));
}
#[test]
fn a_dollar_inside_an_identifier_is_not_a_quote() {
assert_eq!(
sql_shape("\"SELECT a$b$ FROM posts; DELETE FROM q\""),
Some(true)
);
assert!(sql_symbols("\"SELECT a$b$ FROM posts\"").contains(&"posts".to_owned()));
}
#[test]
fn copy_direction_ignores_quoted_and_parenthesised_words() {
assert_eq!(sql_shape(r#""COPY "to" FROM STDIN""#), Some(true));
assert_eq!(sql_shape(r#""COPY posts ("from") TO STDOUT""#), Some(false));
assert_eq!(
sql_shape("\"COPY posts (\\\"to\\\") FROM STDIN\""),
Some(true)
);
}
#[test]
fn a_bare_table_statement_is_a_read() {
let plain = "\"TABLE posts\"";
assert_eq!(sql_shape(plain), Some(false));
assert!(sql_symbols(plain).contains(&"posts".to_owned()));
assert_eq!(sql_shape("\"TABLE ONLY posts\""), Some(false));
assert_eq!(sql_shape("\"TABLE public.posts\""), Some(false));
assert_eq!(sql_shape("\"Table of contents\""), None);
assert_eq!(sql_shape("\"Table\""), None);
assert_eq!(sql_shape("\"Table 1\""), None);
}
#[test]
fn ddl_is_not_a_statement() {
assert_eq!(sql_shape("\"CREATE TABLE posts (id int)\""), None);
assert_eq!(sql_shape("\"ALTER TABLE posts ADD COLUMN a int\""), None);
assert_eq!(sql_shape("\"DROP TABLE posts\""), None);
}
#[test]
fn mutation_is_detected_from_raw_sql() {
assert!(is_mutating("e! { sql_query("UPDATE posts SET x = 1") }));
assert!(!is_mutating("e! { sql_query("SELECT * FROM posts") }));
}
#[test]
fn a_pure_read_is_not_reported_as_a_mutation() {
assert!(!is_mutating("e! { posts::table.load(&mut db).await }));
}
#[test]
fn an_empty_symbol_list_emits_an_empty_slice() {
assert_eq!(
emit_symbol_slice(&[]).to_string(),
quote! { &[] }.to_string()
);
}
#[test]
fn the_route_descriptor_names_the_handler_and_its_symbols() {
let input: syn::ItemFn = syn::parse_quote! {
async fn show(repo: PgPostRepository) -> Markup {
let _ = posts::table;
todo!()
}
};
let generated =
emit_route_descriptor(&input, "GET", "e! { "/posts" }, false).to_string();
assert!(generated.contains("RouteGraphDescriptor"), "{generated}");
assert!(generated.contains(r#"handler : "show""#), "{generated}");
assert!(generated.contains(r#"method : "GET""#), "{generated}");
assert!(generated.contains("static_route : false"), "{generated}");
assert!(
!generated.contains("mutating"),
"a route's access comes from its declared HTTP method, so the descriptor \
carries no mutation flag: {generated}"
);
assert!(generated.contains(r#""PgPostRepository""#), "{generated}");
assert!(generated.contains(r#""posts""#), "{generated}");
}
#[test]
fn the_job_descriptor_carries_its_kind_and_schedule() {
let input: syn::ItemFn = syn::parse_quote! {
async fn recalculate(state: AppState) -> AutumnResult<()> {
diesel::sql_query("UPDATE posts SET hot_rank = 0");
Ok(())
}
};
let generated = emit_job_descriptor(
&input,
"e! { "hot-rank" },
"Scheduled",
"e! { "15m" },
)
.to_string();
assert!(generated.contains("JobGraphDescriptor"), "{generated}");
assert!(generated.contains("JobKind :: Scheduled"), "{generated}");
assert!(generated.contains(r#"schedule : "15m""#), "{generated}");
assert!(generated.contains("mutating : false"), "{generated}");
assert!(
generated.contains(r#"mutating_symbols : & ["posts"]"#),
"{generated}"
);
}
}