diff --git a/Cargo.lock b/Cargo.lock index 64634c69da..c18ea49f27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom", + "getrandom 0.2.15", "once_cell", "version_check", ] @@ -429,7 +429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" dependencies = [ "futures-core", - "getrandom", + "getrandom 0.2.15", "instant", "pin-project-lite", "rand", @@ -603,6 +603,15 @@ dependencies = [ "serde", ] +[[package]] +name = "btoi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976" +dependencies = [ + "num-traits", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -941,6 +950,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -1303,6 +1321,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.9.0" @@ -1526,6 +1554,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "gimli" version = "0.31.1" @@ -2200,11 +2240,12 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.2" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2257,6 +2298,32 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "mysql_common" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bffc2127d4035fa5a614935c663a15a4468e64e798473e0cc21c8df40a607588" +dependencies = [ + "base64 0.22.1", + "bitflags 2.7.0", + "btoi", + "byteorder", + "bytes", + "crc32fast", + "flate2", + "getrandom 0.3.4", + "num-bigint", + "num-traits", + "regex", + "saturating", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 2.0.11", + "uuid", +] + [[package]] name = "native-tls" version = "0.2.12" @@ -2768,6 +2835,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radium" version = "0.7.0" @@ -2801,7 +2874,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] @@ -2909,7 +2982,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.15", "libc", "spin", "untrusted", @@ -3030,7 +3103,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3099,6 +3172,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + [[package]] name = "schannel" version = "0.1.27" @@ -3329,6 +3408,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simdutf8" version = "0.1.5" @@ -3672,6 +3757,7 @@ dependencies = [ "digest", "dotenvy", "either", + "flate2", "futures-channel", "futures-core", "futures-io", @@ -3684,6 +3770,7 @@ dependencies = [ "log", "md-5", "memchr", + "mysql_common", "once_cell", "percent-encoding", "rand", @@ -3990,7 +4077,7 @@ checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" dependencies = [ "cfg-if", "fastrand 2.3.0", - "getrandom", + "getrandom 0.2.15", "once_cell", "rustix 0.38.43", "windows-sys 0.59.0", @@ -4543,6 +4630,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -4854,6 +4950,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "write16" version = "1.0.0" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 29f0b09695..8d177ac0ba 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ # Note: should NOT increase during a minor/patch release cycle [toolchain] -channel = "1.78" +channel = "1.88" profile = "minimal" diff --git a/sqlx-mysql/Cargo.toml b/sqlx-mysql/Cargo.toml index 3971c2ff87..68217455d9 100644 --- a/sqlx-mysql/Cargo.toml +++ b/sqlx-mysql/Cargo.toml @@ -49,6 +49,14 @@ rust_decimal = { workspace = true, optional = true } time = { workspace = true, optional = true } uuid = { workspace = true, optional = true } +# Vendored patch: client-side parameter interpolation when +# `statement_cache_capacity == 0`. We use only `mysql_common::Value::as_sql` +# for canonical MySQL-client quoting/escaping. mysql_common's default +# features select a flate2 backend (required for it to compile); we unify +# flate2 to its pure-Rust `rust_backend` here so we don't pull in libz-sys. +mysql_common = { version = "0.37", default-features = false } +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } + # Misc atoi = "2.0" base64 = { version = "0.22.0", default-features = false, features = ["std"] } diff --git a/sqlx-mysql/src/connection/executor.rs b/sqlx-mysql/src/connection/executor.rs index 4f5af4bf6d..c35cb38fce 100644 --- a/sqlx-mysql/src/connection/executor.rs +++ b/sqlx-mysql/src/connection/executor.rs @@ -118,7 +118,21 @@ impl MySqlConnection { let mut columns = Arc::new(Vec::new()); let (mut column_names, format, mut needs_metadata) = if let Some(arguments) = arguments { - if persistent && self.inner.cache_statement.is_enabled() { + if !self.inner.cache_statement.is_enabled() { + // Vendored patch: when the prepared-statement cache is + // disabled, skip COM_STMT_PREPARE entirely. Interpolate + // bind values into the SQL and send a plain COM_QUERY. + let no_backslash_escape = self.inner.status_flags.contains( + Status::SERVER_STATUS_NO_BACKSLASH_ESCAPES, + ); + let interpolated = super::text_query::interpolate( + sql, + &arguments, + no_backslash_escape, + )?; + self.inner.stream.send_packet(Query(&interpolated)).await?; + (Arc::default(), MySqlValueFormat::Text, true) + } else if persistent && self.inner.cache_statement.is_enabled() { let (id, metadata) = self .get_or_prepare_statement(sql) .await?; diff --git a/sqlx-mysql/src/connection/mod.rs b/sqlx-mysql/src/connection/mod.rs index 0a2f5fb839..c98f2d5fac 100644 --- a/sqlx-mysql/src/connection/mod.rs +++ b/sqlx-mysql/src/connection/mod.rs @@ -19,6 +19,7 @@ mod auth; mod establish; mod executor; mod stream; +mod text_query; mod tls; const MAX_PACKET_SIZE: u32 = 1024; diff --git a/sqlx-mysql/src/connection/text_query.rs b/sqlx-mysql/src/connection/text_query.rs new file mode 100644 index 0000000000..68efb0b51b --- /dev/null +++ b/sqlx-mysql/src/connection/text_query.rs @@ -0,0 +1,460 @@ +//! Client-side parameter interpolation for the COM_QUERY (text) protocol. +//! +//! When `statement_cache_capacity == 0`, the executor opts out of prepared +//! statements entirely. For queries with bind arguments we splice the values +//! directly into the SQL using `mysql_common::Value::as_sql`, which applies +//! the same quoting/escaping rules used by the canonical MySQL client. +//! +//! This module exists as a vendored patch on top of upstream sqlx; it is +//! self-contained so future upstream merges only touch tiny call-site hooks +//! in `executor.rs` and `mod.rs`. +use mysql_common::value::Value; + +use crate::error::Error; +use crate::protocol::text::{ColumnFlags, ColumnType}; +use crate::{MySqlArguments, MySqlTypeInfo}; + +/// Interpolate `arguments` into `sql` and return the resulting SQL string. +/// +/// `no_backslash_escape` should reflect the connection's current +/// `SERVER_STATUS_NO_BACKSLASH_ESCAPES` status flag. +pub(crate) fn interpolate( + sql: &str, + arguments: &MySqlArguments, + no_backslash_escape: bool, +) -> Result { + let values = decode_arguments(arguments)?; + splice(sql, &values, no_backslash_escape) +} + +#[derive(Copy, Clone)] +enum State { + Normal, + /// Inside a `'…'`, `"…"`, or `` `…` `` literal; field is the opening byte. + Quoted(u8), + LineComment, + BlockComment, +} + +fn splice(sql: &str, values: &[Value], no_backslash_escape: bool) -> Result { + let mut out = String::with_capacity(sql.len() + values.len() * 8); + let bytes = sql.as_bytes(); + let mut next_param = 0usize; + let mut state = State::Normal; + let mut seg_start = 0usize; + let mut i = 0usize; + + while i < bytes.len() { + let b = bytes[i]; + match state { + State::Normal => match b { + b'\'' | b'"' | b'`' => { + state = State::Quoted(b); + i += 1; + } + b'-' if bytes.get(i + 1) == Some(&b'-') => { + state = State::LineComment; + i += 2; + } + b'#' => { + state = State::LineComment; + i += 1; + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + state = State::BlockComment; + i += 2; + } + b'?' => { + out.push_str(&sql[seg_start..i]); + let value = values.get(next_param).ok_or_else(|| { + err_protocol!( + "interpolation: SQL has more `?` placeholders than bound arguments ({})", + values.len() + ) + })?; + out.push_str(&value.as_sql(no_backslash_escape)); + next_param += 1; + i += 1; + seg_start = i; + } + _ => i += 1, + }, + State::Quoted(q) => { + // Backslash escapes apply only in '…' and "…" string literals, + // and only when sql_mode does not include NO_BACKSLASH_ESCAPES. + if b == b'\\' && !no_backslash_escape && q != b'`' && i + 1 < bytes.len() { + i += 2; + } else if b == q { + // Doubled-quote escape: `''`, `""`, or `` `` ``. + if bytes.get(i + 1) == Some(&q) { + i += 2; + } else { + state = State::Normal; + i += 1; + } + } else { + i += 1; + } + } + State::LineComment => { + if b == b'\n' { + state = State::Normal; + } + i += 1; + } + State::BlockComment => { + if b == b'*' && bytes.get(i + 1) == Some(&b'/') { + state = State::Normal; + i += 2; + } else { + i += 1; + } + } + } + } + out.push_str(&sql[seg_start..]); + + if next_param != values.len() { + return Err(err_protocol!( + "interpolation: bound {} arguments but SQL contains {} `?` placeholders", + values.len(), + next_param + )); + } + + Ok(out) +} + +fn decode_arguments(arguments: &MySqlArguments) -> Result, Error> { + let mut out = Vec::with_capacity(arguments.types.len()); + let mut buf: &[u8] = &arguments.values; + + for (i, ty) in arguments.types.iter().enumerate() { + if is_null(arguments, i) { + out.push(Value::NULL); + continue; + } + + out.push(decode_one(ty, &mut buf)?); + } + + if !buf.is_empty() { + return Err(err_protocol!( + "interpolation: {} trailing bytes after decoding {} parameters", + buf.len(), + arguments.types.len() + )); + } + + Ok(out) +} + +fn is_null(arguments: &MySqlArguments, i: usize) -> bool { + let bitmap: &[u8] = &arguments.null_bitmap; + let byte = i / 8; + let bit = i % 8; + bitmap.get(byte).is_some_and(|b| (b >> bit) & 1 == 1) +} + +fn decode_one(ty: &MySqlTypeInfo, buf: &mut &[u8]) -> Result { + let unsigned = ty.flags.contains(ColumnFlags::UNSIGNED); + + match ty.r#type { + ColumnType::Tiny => { + let bytes = take_fixed::<1>(buf)?; + Ok(if unsigned { + Value::UInt(u64::from(u8::from_le_bytes(bytes))) + } else { + Value::Int(i64::from(i8::from_le_bytes(bytes))) + }) + } + ColumnType::Short | ColumnType::Year => { + let bytes = take_fixed::<2>(buf)?; + Ok(if unsigned { + Value::UInt(u64::from(u16::from_le_bytes(bytes))) + } else { + Value::Int(i64::from(i16::from_le_bytes(bytes))) + }) + } + ColumnType::Long | ColumnType::Int24 => { + let bytes = take_fixed::<4>(buf)?; + Ok(if unsigned { + Value::UInt(u64::from(u32::from_le_bytes(bytes))) + } else { + Value::Int(i64::from(i32::from_le_bytes(bytes))) + }) + } + ColumnType::LongLong => { + let bytes = take_fixed::<8>(buf)?; + Ok(if unsigned { + Value::UInt(u64::from_le_bytes(bytes)) + } else { + Value::Int(i64::from_le_bytes(bytes)) + }) + } + ColumnType::Float => { + let bytes = take_fixed::<4>(buf)?; + Ok(Value::Float(f32::from_le_bytes(bytes))) + } + ColumnType::Double => { + let bytes = take_fixed::<8>(buf)?; + Ok(Value::Double(f64::from_le_bytes(bytes))) + } + ColumnType::Date | ColumnType::Datetime | ColumnType::Timestamp => decode_date(buf), + ColumnType::Time => decode_time(buf), + + // Lenenc-bytes types: strings, blobs, decimals, json, bit, geometry, + // enum, set. `Value::Bytes` round-trips through `as_sql` as a quoted + // string for textual payloads and as `x'..'` hex for non-UTF8 binary; + // both are valid in COM_QUERY. + ColumnType::Decimal + | ColumnType::NewDecimal + | ColumnType::VarChar + | ColumnType::VarString + | ColumnType::String + | ColumnType::Blob + | ColumnType::TinyBlob + | ColumnType::MediumBlob + | ColumnType::LongBlob + | ColumnType::Json + | ColumnType::Bit + | ColumnType::Geometry + | ColumnType::Enum + | ColumnType::Set => { + let bytes = take_lenenc_bytes(buf)?; + Ok(Value::Bytes(bytes.to_vec())) + } + + ColumnType::Null => Ok(Value::NULL), + } +} + +fn decode_date(buf: &mut &[u8]) -> Result { + let len = take_u8(buf)?; + match len { + 0 => Ok(Value::Date(0, 0, 0, 0, 0, 0, 0)), + 4 | 7 | 11 => { + let payload = take_n(buf, usize::from(len))?; + let year = u16::from_le_bytes([payload[0], payload[1]]); + let month = payload[2]; + let day = payload[3]; + let (hour, minute, second) = if len >= 7 { + (payload[4], payload[5], payload[6]) + } else { + (0, 0, 0) + }; + let micros = if len == 11 { + u32::from_le_bytes([payload[7], payload[8], payload[9], payload[10]]) + } else { + 0 + }; + Ok(Value::Date(year, month, day, hour, minute, second, micros)) + } + other => Err(err_protocol!( + "interpolation: unexpected DATE/DATETIME length {other}" + )), + } +} + +fn decode_time(buf: &mut &[u8]) -> Result { + let len = take_u8(buf)?; + match len { + 0 => Ok(Value::Time(false, 0, 0, 0, 0, 0)), + 8 | 12 => { + let payload = take_n(buf, usize::from(len))?; + let is_neg = payload[0] != 0; + let days = u32::from_le_bytes([payload[1], payload[2], payload[3], payload[4]]); + let hours = payload[5]; + let minutes = payload[6]; + let seconds = payload[7]; + let micros = if len == 12 { + u32::from_le_bytes([payload[8], payload[9], payload[10], payload[11]]) + } else { + 0 + }; + Ok(Value::Time(is_neg, days, hours, minutes, seconds, micros)) + } + other => Err(err_protocol!( + "interpolation: unexpected TIME length {other}" + )), + } +} + +fn take_u8(buf: &mut &[u8]) -> Result { + let (head, tail) = buf + .split_first() + .ok_or_else(|| err_protocol!("interpolation: unexpected end of argument buffer"))?; + *buf = tail; + Ok(*head) +} + +fn take_fixed(buf: &mut &[u8]) -> Result<[u8; N], Error> { + if buf.len() < N { + return Err(err_protocol!( + "interpolation: need {N} bytes, have {}", + buf.len() + )); + } + let (head, tail) = buf.split_at(N); + let mut out = [0u8; N]; + out.copy_from_slice(head); + *buf = tail; + Ok(out) +} + +fn take_n<'a>(buf: &mut &'a [u8], n: usize) -> Result<&'a [u8], Error> { + if buf.len() < n { + return Err(err_protocol!( + "interpolation: need {n} bytes, have {}", + buf.len() + )); + } + let (head, tail) = buf.split_at(n); + *buf = tail; + Ok(head) +} + +fn take_lenenc_bytes<'a>(buf: &mut &'a [u8]) -> Result<&'a [u8], Error> { + let len = take_lenenc_int(buf)?; + let len = usize::try_from(len) + .map_err(|_| err_protocol!("interpolation: lenenc length overflows usize: {len}"))?; + take_n(buf, len) +} + +fn take_lenenc_int(buf: &mut &[u8]) -> Result { + let first = take_u8(buf)?; + match first { + 0xfc => Ok(u64::from(u16::from_le_bytes(take_fixed::<2>(buf)?))), + 0xfd => { + let b = take_fixed::<3>(buf)?; + Ok(u64::from(u32::from_le_bytes([b[0], b[1], b[2], 0]))) + } + 0xfe => Ok(u64::from_le_bytes(take_fixed::<8>(buf)?)), + // 0xfb / 0xff aren't valid as encoded parameter lengths. Bind values + // shouldn't produce them; treat as a single-byte length. + v => Ok(u64::from(v)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::arguments::MySqlArguments; + + fn args_with(f: F) -> MySqlArguments { + let mut a = MySqlArguments::default(); + f(&mut a); + a + } + + #[test] + fn splice_basic_int_string() { + let args = args_with(|a| { + a.add(42i32).unwrap(); + a.add("o'reilly").unwrap(); + }); + let out = interpolate("SELECT ?, ?", &args, false).unwrap(); + assert_eq!(out, "SELECT 42, 'o\\'reilly'"); + } + + #[test] + fn splice_no_backslash_escape() { + let args = args_with(|a| { + a.add("o'reilly").unwrap(); + }); + let out = interpolate("SELECT ?", &args, true).unwrap(); + assert_eq!(out, "SELECT 'o''reilly'"); + } + + #[test] + fn splice_skips_question_mark_in_string_literal() { + let args = args_with(|a| { + a.add(1i32).unwrap(); + }); + let out = interpolate("SELECT '?', ?", &args, false).unwrap(); + assert_eq!(out, "SELECT '?', 1"); + } + + #[test] + fn splice_skips_question_mark_in_line_comment() { + let args = args_with(|a| { + a.add(1i32).unwrap(); + }); + let out = interpolate("SELECT 1 -- ?\n, ?", &args, false).unwrap(); + assert_eq!(out, "SELECT 1 -- ?\n, 1"); + } + + #[test] + fn splice_skips_question_mark_in_block_comment() { + let args = args_with(|a| { + a.add(1i32).unwrap(); + }); + let out = interpolate("SELECT /* ? */ ?", &args, false).unwrap(); + assert_eq!(out, "SELECT /* ? */ 1"); + } + + #[test] + fn splice_preserves_multibyte_utf8() { + let args = args_with(|a| { + a.add(7i32).unwrap(); + }); + // The crab emoji is 4 bytes in UTF-8; ensure it round-trips. + let out = interpolate("SELECT '🦀', ?", &args, false).unwrap(); + assert_eq!(out, "SELECT '🦀', 7"); + } + + #[test] + fn splice_arity_mismatch_too_few_placeholders() { + let args = args_with(|a| { + a.add(1i32).unwrap(); + a.add(2i32).unwrap(); + }); + assert!(interpolate("SELECT ?", &args, false).is_err()); + } + + #[test] + fn splice_arity_mismatch_too_many_placeholders() { + let args = args_with(|a| { + a.add(1i32).unwrap(); + }); + assert!(interpolate("SELECT ?, ?", &args, false).is_err()); + } + + #[test] + fn splice_null_argument() { + let args = args_with(|a| { + a.add(Option::::None).unwrap(); + }); + let out = interpolate("SELECT ?", &args, false).unwrap(); + assert_eq!(out, "SELECT NULL"); + } + + #[test] + fn splice_doubled_quote_inside_string() { + let args = args_with(|a| { + a.add(1i32).unwrap(); + }); + // The inner `''` is an escaped single quote; the `?` between them is + // still inside the string literal. + let out = interpolate("SELECT 'a''?b', ?", &args, false).unwrap(); + assert_eq!(out, "SELECT 'a''?b', 1"); + } + + #[test] + fn splice_unsigned_int() { + let args = args_with(|a| { + a.add(u32::MAX).unwrap(); + }); + let out = interpolate("SELECT ?", &args, false).unwrap(); + assert_eq!(out, "SELECT 4294967295"); + } + + #[test] + fn splice_negative_int() { + let args = args_with(|a| { + a.add(-7i64).unwrap(); + }); + let out = interpolate("SELECT ?", &args, false).unwrap(); + assert_eq!(out, "SELECT -7"); + } +}