From 76f51015d985ac2c2c35b2db1494d0ffcc003b5c Mon Sep 17 00:00:00 2001 From: Richard Newman Date: Tue, 21 Feb 2017 18:47:06 -0800 Subject: [PATCH] Support accumulating TypedValue instances into a SQL query. (#339) r=nalexander These expand into a collection of named variables that should be passed via bind parameters when the query is executed. Bind parameters are now only named. --- sql/Cargo.toml | 6 +++ sql/src/lib.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/sql/Cargo.toml b/sql/Cargo.toml index 419ccf8e..0a15dde6 100644 --- a/sql/Cargo.toml +++ b/sql/Cargo.toml @@ -2,3 +2,9 @@ name = "mentat_sql" version = "0.0.1" workspace = ".." + +[dependencies] +ordered-float = "0.4.0" + +[dependencies.mentat_core] +path = "../core" diff --git a/sql/src/lib.rs b/sql/src/lib.rs index 670de167..62fb8f11 100644 --- a/sql/src/lib.rs +++ b/sql/src/lib.rs @@ -8,18 +8,42 @@ // CONDITIONS OF ANY KIND, either express or implied. See the License for the // specific language governing permissions and limitations under the License. +extern crate ordered_float; +extern crate mentat_core; + use std::error::Error; +use ordered_float::OrderedFloat; + +use mentat_core::TypedValue; + pub type BuildQueryError = Box; pub type BuildQueryResult = Result<(), BuildQueryError>; +pub enum BindParamError { + InvalidParameterName(String), + BindParamCouldBeGenerated(String), +} + +/// We want to accumulate values that will later be substituted into a SQL statement execution. +/// This struct encapsulates the generated string and the _initial_ argument list. +/// Additional user-supplied argument bindings, with their placeholders accumulated via +/// `push_bind_param`, will be appended to this argument list. +pub struct SQLQuery { + pub sql: String, + + /// These will eventually perhaps be rusqlite `ToSql` instances. + pub args: Vec<(String, String)>, +} + /// Gratefully based on Diesel's QueryBuilder trait: /// https://github.com/diesel-rs/diesel/blob/4885f61b8205f7f3c2cfa03837ed6714831abe6b/diesel/src/query_builder/mod.rs#L56 pub trait QueryBuilder { fn push_sql(&mut self, sql: &str); fn push_identifier(&mut self, identifier: &str) -> BuildQueryResult; - fn push_bind_param(&mut self); - fn finish(self) -> String; + fn push_typed_value(&mut self, value: &TypedValue) -> BuildQueryResult; + fn push_bind_param(&mut self, name: &str) -> Result<(), BindParamError>; + fn finish(self) -> SQLQuery; } pub trait QueryFragment { @@ -47,14 +71,36 @@ impl QueryFragment for () { /// A QueryBuilder that implements SQLite's specific escaping rules. pub struct SQLiteQueryBuilder { pub sql: String, + + arg_prefix: String, + arg_counter: i64, + args: Vec<(String, String)>, } impl SQLiteQueryBuilder { pub fn new() -> Self { + SQLiteQueryBuilder::with_prefix("$v".to_string()) + } + + pub fn with_prefix(prefix: String) -> Self { SQLiteQueryBuilder { sql: String::new(), + arg_prefix: prefix, + arg_counter: 0, + args: vec![], } } + + fn push_static_arg(&mut self, val: String) { + let arg = format!("{}{}", self.arg_prefix, self.arg_counter); + self.arg_counter = self.arg_counter + 1; + self.push_named_arg(arg.as_str()); + self.args.push((arg, val)); + } + + fn push_named_arg(&mut self, arg: &str) { + self.push_sql(arg); + } } impl QueryBuilder for SQLiteQueryBuilder { @@ -69,11 +115,70 @@ impl QueryBuilder for SQLiteQueryBuilder { Ok(()) } - fn push_bind_param(&mut self) { - self.push_sql("?"); + fn push_typed_value(&mut self, value: &TypedValue) -> BuildQueryResult { + use TypedValue::*; + match value { + &Ref(entid) => self.push_sql(entid.to_string().as_str()), + &Boolean(v) => self.push_sql(if v { "1" } else { "0" }), + &Long(v) => self.push_sql(v.to_string().as_str()), + &Double(OrderedFloat(v)) => self.push_sql(v.to_string().as_str()), + &String(ref s) => self.push_static_arg(s.clone()), + &Keyword(ref s) => self.push_static_arg(s.to_string()), + } + Ok(()) } - fn finish(self) -> String { - self.sql + /// Our bind parameters will be interleaved with pushed `TypedValue` instances. That means we + /// need to use named parameters, not positional parameters. + /// The `name` argument to this method is expected to be alphanumeric. If not, this method + /// returns an `InvalidParameterName` error result. + /// Callers should make sure that the name doesn't overlap with generated parameter names. If + /// it does, `BindParamCouldBeGenerated` is the error. + fn push_bind_param(&mut self, name: &str) -> Result<(), BindParamError> { + // Do some validation first. + // This is not free, but it's probably worth it for now. + if !name.chars().all(char::is_alphanumeric) { + return Err(BindParamError::InvalidParameterName(name.to_string())); + } + + if name.starts_with(self.arg_prefix.as_str()) && + name.chars().skip(self.arg_prefix.len()).all(char::is_numeric) { + return Err(BindParamError::BindParamCouldBeGenerated(name.to_string())); + } + + self.push_sql("$"); + self.push_sql(name); + Ok(()) + } + + fn finish(self) -> SQLQuery { + SQLQuery { + sql: self.sql, + args: self.args, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sql() { + let mut s = SQLiteQueryBuilder::new(); + s.push_sql("SELECT "); + s.push_identifier("foo").unwrap(); + s.push_sql(" WHERE "); + s.push_identifier("bar").unwrap(); + s.push_sql(" = "); + s.push_static_arg("frobnicate".to_string()); + s.push_sql(" OR "); + s.push_static_arg("swoogle".to_string()); + let q = s.finish(); + + assert_eq!(q.sql.as_str(), "SELECT `foo` WHERE `bar` = $v0 OR $v1"); + assert_eq!(q.args, + vec![("$v0".to_string(), "frobnicate".to_string()), + ("$v1".to_string(), "swoogle".to_string())]); } }