tdsql 0.8.0

Ergonomic async Microsoft SQL Server client for Rust, with typed rows and multi-result-set support
Documentation
//! Named-parameter commands: text batches and stored procedures.

use crate::value::DataValue;

/// Whether a [`Command`] is a SQL batch or a stored procedure call.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandType {
    /// A literal SQL batch.
    Text,
    /// A stored procedure, invoked by name over RPC.
    StoredProcedure,
}

/// A named parameter and its value.
#[derive(Debug, Clone, PartialEq)]
pub struct Parameter {
    /// The parameter name, with or without a leading `@`.
    pub name: String,
    /// The bound value.
    pub value: DataValue,
}

impl Parameter {
    /// Bind `value` to `name`.
    ///
    /// ```
    /// use tdsql::Parameter;
    ///
    /// let p = Parameter::new("id", 7);
    /// let missing = Parameter::new("note", None::<String>); // binds NULL
    /// ```
    pub fn new<T: Into<DataValue>>(name: impl Into<String>, value: T) -> Self {
        Self {
            name: name.into(),
            value: value.into(),
        }
    }

    /// The name without any leading `@`.
    pub fn bare_name(&self) -> &str {
        self.name.strip_prefix('@').unwrap_or(&self.name)
    }

    /// The name with a leading `@`, which is the form TDS RPC expects.
    pub fn at_name(&self) -> String {
        format!("@{}", self.bare_name())
    }
}

/// A statement plus its named parameters.
///
/// Use this for stored procedures, for named parameters in a text batch, and
/// whenever a command returns more than one result set. For a plain
/// parameterised query, [`Client::query`](crate::Client::query) with positional
/// parameters is shorter.
///
/// ```
/// use tdsql::{Command, Parameter};
///
/// let cmd = Command::stored_procedure("sp_upsert_order")
///     .param("id", 1001)
///     .param("status", "PAID");
///
/// let batch = Command::query("SELECT @id AS id").param("id", 7);
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct Command {
    text: String,
    command_type: CommandType,
    parameters: Vec<Parameter>,
}

/// What a [`Command`] turns into on the wire.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Prepared {
    /// A SQL batch with positional `@P1..@Pn` parameters.
    Text { sql: String, params: Vec<DataValue> },
    /// A stored procedure invoked over RPC with genuinely named parameters.
    Proc {
        name: String,
        params: Vec<(String, DataValue)>,
    },
}

impl Command {
    /// A SQL text batch.
    pub fn query(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            command_type: CommandType::Text,
            parameters: Vec::new(),
        }
    }

    /// A stored procedure, called by name.
    pub fn stored_procedure(name: impl Into<String>) -> Self {
        Self {
            text: name.into(),
            command_type: CommandType::StoredProcedure,
            parameters: Vec::new(),
        }
    }

    /// Bind one named parameter.
    pub fn param<T: Into<DataValue>>(mut self, name: impl Into<String>, value: T) -> Self {
        self.parameters.push(Parameter::new(name, value));
        self
    }

    /// Bind several parameters at once.
    pub fn params<I, N, T>(mut self, params: I) -> Self
    where
        I: IntoIterator<Item = (N, T)>,
        N: Into<String>,
        T: Into<DataValue>,
    {
        self.parameters
            .extend(params.into_iter().map(|(n, v)| Parameter::new(n, v)));
        self
    }

    /// Append an already-built [`Parameter`].
    pub fn with_parameter(mut self, param: Parameter) -> Self {
        self.parameters.push(param);
        self
    }

    /// The SQL text, or the procedure name.
    pub fn text(&self) -> &str {
        &self.text
    }

    /// Whether this is a text batch or a stored procedure.
    pub fn command_type(&self) -> CommandType {
        self.command_type
    }

    /// The bound parameters, in binding order.
    pub fn parameters(&self) -> &[Parameter] {
        &self.parameters
    }

    /// Lower the command to its wire form.
    ///
    /// A stored procedure keeps its named parameters and is sent as an RPC. A
    /// text batch has its `@name` placeholders rewritten to the positional
    /// `@P1..@Pn` the protocol expects.
    pub(crate) fn prepare(&self) -> Prepared {
        match self.command_type {
            CommandType::StoredProcedure => Prepared::Proc {
                name: self.text.clone(),
                // RPC parameter names travel with their `@`; the server
                // matches on the prefixed form.
                params: self
                    .parameters
                    .iter()
                    .map(|p| (p.at_name(), p.value.clone()))
                    .collect(),
            },
            CommandType::Text => {
                let names: Vec<&str> = self.parameters.iter().map(|p| p.bare_name()).collect();
                Prepared::Text {
                    sql: rewrite_placeholders(&self.text, &names),
                    params: self.parameters.iter().map(|p| p.value.clone()).collect(),
                }
            }
        }
    }
}

/// The parameter in `names` that the placeholder `@ident` refers to.
///
/// Case-insensitive, as T-SQL identifiers are under the usual collations, so
/// `@Id` in the SQL finds a parameter bound as `id`.
fn placeholder_index(ident: &str, names: &[&str]) -> Option<usize> {
    let ident = ident.to_lowercase();
    names.iter().position(|n| n.to_lowercase() == ident)
}

/// The lexical regions of a T-SQL batch. A `@name` token is only a placeholder
/// in `Sql`; anywhere else it is somebody's data or prose and must survive
/// untouched.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Region {
    Sql,
    /// `'...'`. A doubled `''` is an escaped quote and stays inside.
    String,
    /// `"..."`, a quoted identifier. A doubled `""` stays inside.
    QuotedIdent,
    /// `[...]`, a bracketed identifier. A doubled `]]` stays inside.
    BracketIdent,
    /// `-- ...`, to the end of the line.
    LineComment,
    /// `/* ... */`, which nests in T-SQL, so the depth is carried.
    BlockComment(u32),
}

/// Characters that continue a T-SQL identifier. `@`, `$` and `#` are all legal
/// inside one, so `@id` must not match inside `@@id`, `@id2` or `@id$x`.
fn is_ident_continue(c: char) -> bool {
    c.is_alphanumeric() || c == '_' || c == '@' || c == '$' || c == '#'
}

/// Rewrite every `@name` placeholder in executable SQL to the position of
/// `name` in `names`, as `@P1..@Pn`.
///
/// One pass, with every placeholder looked up rather than each name replaced
/// in turn, so a parameter named `P2` bound first becomes `@P1`, and a
/// replacement can never be mistaken for a placeholder still to come. A
/// `@name` that is bound to nothing is left as it is.
///
/// Two things are skipped. Identifier boundaries on *both* sides, so `@id`
/// matches neither `@id2` nor `@@id`. And whole non-SQL regions — string
/// literals, quoted and bracketed identifiers, and comments — so binding `id`
/// does not rewrite `'@id'` or `'email@id.com'` into the placeholder.
///
/// Copies whole UTF-8 characters, so non-ASCII SQL (`N'café'`) survives intact.
fn rewrite_placeholders(haystack: &str, names: &[&str]) -> String {
    let bytes = haystack.as_bytes();
    let mut out = String::with_capacity(haystack.len());
    let mut region = Region::Sql;
    // The last character written, for the left-hand identifier boundary.
    let mut prev: Option<char> = None;
    let mut i = 0;

    // Copy a fixed ASCII delimiter and step past it.
    macro_rules! emit {
        ($text:expr) => {{
            out.push_str($text);
            prev = $text.chars().last();
            i += $text.len();
            continue;
        }};
    }

    while i < bytes.len() {
        match region {
            Region::Sql => {
                match bytes[i] {
                    b'\'' => {
                        region = Region::String;
                        emit!("'")
                    }
                    b'"' => {
                        region = Region::QuotedIdent;
                        emit!("\"")
                    }
                    b'[' => {
                        region = Region::BracketIdent;
                        emit!("[")
                    }
                    b'-' if bytes.get(i + 1) == Some(&b'-') => {
                        region = Region::LineComment;
                        emit!("--")
                    }
                    b'/' if bytes.get(i + 1) == Some(&b'*') => {
                        region = Region::BlockComment(1);
                        emit!("/*")
                    }
                    _ => {}
                }

                // A `@` at an identifier boundary starts a placeholder; the
                // identifier runs to the next character that cannot continue
                // one, which keeps `@id` apart from `@id2`.
                if bytes[i] == b'@' && prev.is_none_or(|c| !is_ident_continue(c)) {
                    let ident: &str = {
                        let rest = &haystack[i + 1..];
                        let end = rest
                            .find(|c: char| !is_ident_continue(c))
                            .unwrap_or(rest.len());
                        &rest[..end]
                    };
                    if let Some(index) = placeholder_index(ident, names) {
                        let replacement = format!("@P{}", index + 1);
                        out.push_str(&replacement);
                        prev = replacement.chars().last();
                        i += 1 + ident.len();
                        continue;
                    }
                }
            }
            Region::String => {
                if bytes[i] == b'\'' {
                    if bytes.get(i + 1) == Some(&b'\'') {
                        emit!("''")
                    }
                    region = Region::Sql;
                }
            }
            Region::QuotedIdent => {
                if bytes[i] == b'"' {
                    if bytes.get(i + 1) == Some(&b'"') {
                        emit!("\"\"")
                    }
                    region = Region::Sql;
                }
            }
            Region::BracketIdent => {
                if bytes[i] == b']' {
                    if bytes.get(i + 1) == Some(&b']') {
                        emit!("]]")
                    }
                    region = Region::Sql;
                }
            }
            Region::LineComment => {
                if bytes[i] == b'\n' {
                    region = Region::Sql;
                }
            }
            Region::BlockComment(depth) => {
                if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
                    region = Region::BlockComment(depth + 1);
                    emit!("/*")
                }
                if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
                    region = if depth == 1 {
                        Region::Sql
                    } else {
                        Region::BlockComment(depth - 1)
                    };
                    emit!("*/")
                }
            }
        }

        // Advance by a whole character, never a lone byte.
        let ch = haystack[i..]
            .chars()
            .next()
            .expect("index is on a char boundary");
        out.push(ch);
        prev = Some(ch);
        i += ch.len_utf8();
    }

    out
}

#[cfg(test)]
mod tests;