use crate::{Error, RullstValue};
#[doc(hidden)]
pub fn replace_placeholders(sql: &str) -> String {
let scan = scan_sql(sql.as_bytes());
if scan.placeholders.is_empty() {
return sql.to_string();
}
let extra_capacity = scan.placeholders.len().saturating_mul(3);
let mut replaced = String::with_capacity(sql.len().saturating_add(extra_capacity));
let mut last_index = 0;
let mut parameter = scan.highest_parameter.saturating_add(1).max(1);
for index in scan.placeholders {
replaced.push_str(&sql[last_index..index]);
replaced.push('$');
replaced.push_str(¶meter.to_string());
parameter = parameter.saturating_add(1);
last_index = index + 1;
}
replaced.push_str(&sql[last_index..]);
replaced
}
#[doc(hidden)]
pub fn portable_subquery(
sql: &str,
bindings: Vec<RullstValue>,
) -> Result<(String, Vec<RullstValue>), Error> {
let scan = scan_sql(sql.as_bytes());
if scan.numbered.is_empty() {
return Ok((sql.to_string(), bindings));
}
if !scan.placeholders.is_empty() {
return Err(Error::Validation(
"subquery mixes portable `?` and numbered `$n` bind markers".to_string(),
));
}
let mut portable = String::with_capacity(sql.len());
let mut ordered = Vec::with_capacity(scan.numbered.len());
let mut referenced = vec![false; bindings.len()];
let mut last_index = 0;
for marker in &scan.numbered {
let binding = marker
.value
.checked_sub(1)
.and_then(|index| bindings.get(index).map(|value| (index, value)));
let Some((index, value)) = binding else {
return Err(Error::Validation(format!(
"subquery bind marker ${} has no matching binding ({} supplied)",
marker.value,
bindings.len()
)));
};
referenced[index] = true;
ordered.push(value.clone());
portable.push_str(&sql[last_index..marker.start]);
portable.push('?');
last_index = marker.start + marker.length;
}
if referenced.iter().any(|used| !used) {
return Err(Error::Validation(
"subquery supplies a binding that its SQL never references".to_string(),
));
}
portable.push_str(&sql[last_index..]);
Ok((portable, ordered))
}
#[doc(hidden)]
pub fn raw_fragment(
sql: &str,
bindings: Vec<RullstValue>,
) -> Result<(String, Vec<RullstValue>), Error> {
let scan = scan_sql(sql.as_bytes());
if !scan.numbered.is_empty() {
return portable_subquery(sql, bindings);
}
if scan.placeholders.len() != bindings.len() {
return Err(Error::Validation(format!(
"raw SQL fragment has {} bind marker(s) but {} binding(s)",
scan.placeholders.len(),
bindings.len()
)));
}
Ok((sql.to_string(), bindings))
}
struct NumberedParameter {
start: usize,
length: usize,
value: usize,
}
struct SqlScan {
placeholders: Vec<usize>,
numbered: Vec<NumberedParameter>,
highest_parameter: usize,
}
#[derive(Clone, Copy)]
enum LexState {
Code,
SingleQuoted {
backslash_escapes: bool,
},
DoubleQuoted,
BacktickQuoted,
LineComment,
BlockComment(usize),
DollarQuoted {
delimiter_start: usize,
delimiter_len: usize,
},
}
fn scan_sql(sql: &[u8]) -> SqlScan {
let mut placeholders = Vec::new();
let mut numbered = Vec::new();
let mut highest_parameter = 0;
let mut state = LexState::Code;
let mut index = 0;
while index < sql.len() {
match state {
LexState::Code => {
if starts_with(sql, index, b"--") {
state = LexState::LineComment;
index += 2;
} else if starts_with(sql, index, b"/*") {
state = LexState::BlockComment(1);
index += 2;
} else {
match sql[index] {
b'\'' => {
state = LexState::SingleQuoted {
backslash_escapes: is_escape_string_prefix(sql, index),
};
index += 1;
}
b'"' => {
state = LexState::DoubleQuoted;
index += 1;
}
b'`' => {
state = LexState::BacktickQuoted;
index += 1;
}
b'$' => {
if let Some((value, length)) = numbered_parameter(sql, index) {
highest_parameter = highest_parameter.max(value);
numbered.push(NumberedParameter {
start: index,
length,
value,
});
index += length;
} else if let Some(delimiter_len) = dollar_delimiter_len(sql, index) {
state = LexState::DollarQuoted {
delimiter_start: index,
delimiter_len,
};
index += delimiter_len;
} else {
index += 1;
}
}
b'?' => {
if !is_question_operator(sql, index) {
placeholders.push(index);
}
index += 1;
}
_ => index += 1,
}
}
}
LexState::SingleQuoted { backslash_escapes } => {
if backslash_escapes && sql[index] == b'\\' {
index = index.saturating_add(2).min(sql.len());
} else if sql[index] == b'\'' {
if sql.get(index + 1) == Some(&b'\'') {
index += 2;
} else {
state = LexState::Code;
index += 1;
}
} else {
index += 1;
}
}
LexState::DoubleQuoted => {
if sql[index] == b'"' {
if sql.get(index + 1) == Some(&b'"') {
index += 2;
} else {
state = LexState::Code;
index += 1;
}
} else {
index += 1;
}
}
LexState::BacktickQuoted => {
if sql[index] == b'`' {
if sql.get(index + 1) == Some(&b'`') {
index += 2;
} else {
state = LexState::Code;
index += 1;
}
} else {
index += 1;
}
}
LexState::LineComment => {
if sql[index] == b'\n' {
state = LexState::Code;
}
index += 1;
}
LexState::BlockComment(depth) => {
if starts_with(sql, index, b"/*") {
state = LexState::BlockComment(depth.saturating_add(1));
index += 2;
} else if starts_with(sql, index, b"*/") {
if depth == 1 {
state = LexState::Code;
} else {
state = LexState::BlockComment(depth - 1);
}
index += 2;
} else {
index += 1;
}
}
LexState::DollarQuoted {
delimiter_start,
delimiter_len,
} => {
let delimiter_end = delimiter_start.saturating_add(delimiter_len);
let delimiter = &sql[delimiter_start..delimiter_end];
if starts_with(sql, index, delimiter) {
state = LexState::Code;
index += delimiter_len;
} else {
index += 1;
}
}
}
}
SqlScan {
placeholders,
numbered,
highest_parameter,
}
}
fn is_escape_string_prefix(sql: &[u8], quote: usize) -> bool {
let Some(prefix) = quote.checked_sub(1) else {
return false;
};
if !matches!(sql[prefix], b'e' | b'E') {
return false;
}
prefix
.checked_sub(1)
.and_then(|before| sql.get(before))
.is_none_or(|byte| {
!(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') || *byte >= 0x80)
})
}
fn starts_with(sql: &[u8], index: usize, token: &[u8]) -> bool {
sql.get(index..)
.is_some_and(|remaining| remaining.starts_with(token))
}
fn numbered_parameter(sql: &[u8], index: usize) -> Option<(usize, usize)> {
let first_digit = *sql.get(index + 1)?;
if !first_digit.is_ascii_digit() {
return None;
}
let mut cursor = index + 1;
let mut value = 0_usize;
while let Some(byte) = sql.get(cursor)
&& byte.is_ascii_digit()
{
value = value
.saturating_mul(10)
.saturating_add(usize::from(*byte - b'0'));
cursor += 1;
}
Some((value, cursor - index))
}
fn dollar_delimiter_len(sql: &[u8], index: usize) -> Option<usize> {
let first = *sql.get(index + 1)?;
if first == b'$' {
return Some(2);
}
if !first.is_ascii_alphabetic() && first != b'_' {
return None;
}
let mut cursor = index + 2;
while let Some(byte) = sql.get(cursor) {
if *byte == b'$' {
return Some(cursor - index + 1);
}
if !byte.is_ascii_alphanumeric() && *byte != b'_' {
return None;
}
cursor += 1;
}
None
}
fn is_question_operator(sql: &[u8], index: usize) -> bool {
if matches!(sql.get(index + 1), Some(b'|' | b'&'))
|| sql.get(index.wrapping_sub(1)) == Some(&b'@')
{
return true;
}
let previous = previous_non_whitespace(sql, index);
let next = next_non_whitespace(sql, index + 1);
matches!(next, Some(b'\'' | b'"' | b'$'))
&& previous.is_some_and(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b')' | b']' | b'\'' | b'"')
})
}
fn previous_non_whitespace(sql: &[u8], before: usize) -> Option<u8> {
let mut cursor = before;
while cursor > 0 {
cursor -= 1;
if !sql[cursor].is_ascii_whitespace() {
return Some(sql[cursor]);
}
}
None
}
fn next_non_whitespace(sql: &[u8], after: usize) -> Option<u8> {
sql.get(after..)?
.iter()
.copied()
.find(|byte| !byte.is_ascii_whitespace())
}
#[cfg(test)]
#[path = "placeholders_tests.rs"]
mod tests;