use proc_macro2::{Span, TokenStream};
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::{Error, Expr, LitStr, Path, Result, Token};
pub(crate) struct Input {
constructor: Path,
sql: LitStr,
}
impl Parse for Input {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let constructor = input.parse()?;
input.parse::<Token![,]>()?;
let sql = input.parse()?;
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
if !input.is_empty() {
return Err(input.error(
"`sql!` takes one string literal. Values go in `{…}` holes inside it, not as \
further arguments",
));
}
Ok(Input { constructor, sql })
}
}
enum Hole {
Value(Expr),
Sql(Expr),
}
pub(crate) fn expand(input: Input) -> Result<TokenStream> {
let Input { constructor, sql } = input;
let (text, holes) = scan(&sql.value(), sql.span())?;
let binds = holes.into_iter().map(|hole| match hole {
Hole::Value(e) => quote!(.bind(#e)),
Hole::Sql(e) => quote!(.bind_expr(#e)),
});
Ok(quote! {
#constructor(#text) #(#binds)*
})
}
fn scan(sql: &str, span: Span) -> Result<(String, Vec<Hole>)> {
let mut text = String::with_capacity(sql.len());
let mut holes = Vec::new();
let mut rest = sql;
while let Some(i) = rest.find(['{', '}', '?']) {
text.push_str(&rest[..i]);
let (matched, tail) = rest.split_at(i);
let _ = matched;
rest = tail;
match rest.as_bytes()[0] {
b'?' => {
text.push_str("\\?");
rest = &rest[1..];
}
b'}' => {
if let Some(tail) = rest.strip_prefix("}}") {
text.push('}');
rest = tail;
} else {
return Err(Error::new(
span,
"unmatched `}` in the SQL. Write `}}` for a literal closing brace",
));
}
}
_ => {
if let Some(tail) = rest.strip_prefix("{{") {
text.push('{');
rest = tail;
continue;
}
let end = rest.find('}').ok_or_else(|| {
Error::new(
span,
"unclosed `{` in the SQL. Write `{{` for a literal opening brace",
)
})?;
holes.push(parse_hole(&rest[1..end], span)?);
text.push('?');
rest = &rest[end + 1..];
}
}
}
text.push_str(rest);
Ok((text, holes))
}
fn parse_hole(body: &str, span: Span) -> Result<Hole> {
let trimmed = body.trim();
if trimmed.is_empty() {
return Err(Error::new(
span,
"an empty `{}` has nothing to bind. Name the value: `{user_id}`",
));
}
let (source, hole): (&str, fn(Expr) -> Hole) = match trimmed.strip_suffix(":sql") {
Some(head) => (head, Hole::Sql),
None => (trimmed, Hole::Value),
};
let expr: Expr = LitStr::new(source.trim(), span).parse().map_err(|e| {
Error::new(
span,
format!("`{{{trimmed}}}` is not a Rust expression: {e}"),
)
})?;
Ok(hole(expr))
}
#[cfg(test)]
mod tests {
use super::*;
fn text_of(sql: &str) -> (String, usize) {
let (text, holes) = scan(sql, Span::call_site()).expect("scan");
(text, holes.len())
}
#[test]
fn a_hole_becomes_a_placeholder() {
assert_eq!(
text_of("SELECT * FROM t WHERE a = {x} AND b > {y}"),
("SELECT * FROM t WHERE a = ? AND b > ?".to_owned(), 2)
);
}
#[test]
fn an_authors_question_mark_is_escaped_rather_than_captured() {
assert_eq!(
text_of("SELECT * FROM t WHERE note = 'what?' AND a = {x}"),
(
r"SELECT * FROM t WHERE note = 'what\?' AND a = ?".to_owned(),
1
)
);
}
#[test]
fn doubled_braces_are_literal() {
assert_eq!(
text_of(r#"SELECT '{{"a": 1}}'::jsonb"#),
(r#"SELECT '{"a": 1}'::jsonb"#.to_owned(), 0)
);
}
#[test]
fn a_spliced_hole_is_still_one_placeholder() {
let (text, holes) =
scan("SELECT * FROM t WHERE id IN ({ids:sql})", Span::call_site()).expect("scan");
assert_eq!(text, "SELECT * FROM t WHERE id IN (?)");
assert!(matches!(holes[0], Hole::Sql(_)));
}
#[test]
fn a_path_in_a_hole_is_not_mistaken_for_a_spec() {
let (_, holes) = scan("SELECT {Config::LIMIT}", Span::call_site()).expect("scan");
assert!(matches!(holes[0], Hole::Value(_)));
}
#[test]
fn unbalanced_braces_are_refused_by_name() {
for (sql, wanted) in [
("SELECT {x", "unclosed"),
("SELECT x}", "unmatched"),
("SELECT {}", "nothing to bind"),
] {
match scan(sql, Span::call_site()) {
Ok(_) => panic!("{sql} should not scan"),
Err(e) => assert!(e.to_string().contains(wanted), "{sql}: {e}"),
}
}
}
}