diff --git a/CHANGELOG.md b/CHANGELOG.md index 0457a29..8d5536b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- [#3](https://github.com/sunsided/query-string-builder/pull/3): + The functions now change inputs that implement `ToString` rather than requiring `Into`. + This allows for any `Display` types to be used directly. + ## [0.4.0] - 2023-07-08 ### Added diff --git a/Cargo.toml b/Cargo.toml index e9856f6..6ac6ccf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,10 @@ license = "EUPL-1.2" [dependencies] percent-encoding = { version = "2.3.0", default-features = false, features = ["std"] } + +[dev-dependencies] +criterion = "0.5.1" + +[[bench]] +name = "bench" +harness = false diff --git a/README.md b/README.md index ea89eae..513a69f 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ use query_string_builder::QueryString; fn main() { let qs = QueryString::new() .with_value("q", "apple") + .with_value("tasty", true) .with_opt_value("color", None::) .with_opt_value("category", Some("fruits and vegetables?")); assert_eq!( format!("https://example.com/{qs}"), - "https://example.com/?q=apple&category=fruits%20and%20vegetables?" + "https://example.com/?q=apple&tasty=true&category=fruits%20and%20vegetables?&tasty=true" ); } ``` diff --git a/benches/bench.rs b/benches/bench.rs new file mode 100644 index 0000000..62504f8 --- /dev/null +++ b/benches/bench.rs @@ -0,0 +1,57 @@ +use criterion::{Criterion, criterion_group, criterion_main}; + +use query_string_builder::QueryString; + +pub fn criterion_benchmark(c: &mut Criterion) { + // `with_value` method benchmark + c.bench_function("with_value", |b| { + b.iter(|| { + let qs = QueryString::new() + .with_value("q", "apple???") + .with_value("category", "fruits and vegetables"); + format!("{qs}") + }) + }); + + c.bench_function("with_value_more", |b| { + b.iter(|| { + let qs = QueryString::new() + .with_value("q", "apple???") + .with_value("category", "fruits and vegetables") + .with_value("tasty", true) + .with_value("weight", 99.9); + format!("{qs}") + }) + }); + + // `with_opt_value` method benchmark + c.bench_function("with_opt_value", |b| { + b.iter(|| { + let qs = QueryString::new() + .with_value("q", "celery") + .with_opt_value("taste", None::) + .with_opt_value("category", Some("fruits and vegetables")) + .with_opt_value("tasty", Some(true)) + .with_opt_value("weight", Some(99.9)); + format!("{qs}") + }) + }); + + // Full test including creating, pushing and appending + c.bench_function("push_opt_and_append", |b| { + b.iter(|| { + let mut qs = QueryString::new(); + qs.push("a", "apple"); + qs.push_opt("b", None::); + qs.push_opt("c", Some("🍎 apple")); + + let more = QueryString::new().with_value("q", "pear"); + let qs = qs.append_into(more); + + format!("{qs}") + }) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/src/lib.rs b/src/lib.rs index c3cc6af..1d26282 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,23 +10,24 @@ //! //! let qs = QueryString::new() //! .with_value("q", "🍎 apple") +//! .with_value("tasty", true) //! .with_opt_value("color", None::) //! .with_opt_value("category", Some("fruits and vegetables?")); //! //! assert_eq!( //! format!("example.com/{qs}"), -//! "example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables?" +//! "example.com/?q=%F0%9F%8D%8E%20apple&tasty=true&category=fruits%20and%20vegetables?" //! ); //! ``` #![deny(unsafe_code)] -use std::fmt::{Debug, Display, Formatter}; +use std::fmt::{Display, Formatter, Write}; -use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; +use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; /// https://url.spec.whatwg.org/#fragment-percent-encode-set -const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`'); +const QUERY: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`'); /// A query string builder for percent encoding key-value pairs. /// @@ -44,12 +45,12 @@ const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').ad /// "https://example.com/?q=apple&category=fruits%20and%20vegetables" /// ); /// ``` -#[derive(Debug, Default, Clone)] -pub struct QueryString { - pairs: Vec, +#[derive(Default)] +pub struct QueryString<'a> { + pairs: Vec>, } -impl QueryString { +impl<'a> QueryString<'a> { /// Creates a new, empty query string builder. pub fn new() -> Self { Self { @@ -66,18 +67,27 @@ impl QueryString { /// /// let qs = QueryString::new() /// .with_value("q", "🍎 apple") - /// .with_value("category", "fruits and vegetables"); + /// .with_value("category", "fruits and vegetables") + /// .with_value("answer", 42); /// /// assert_eq!( /// format!("https://example.com/{qs}"), - /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables" + /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&answer=42" /// ); /// ``` - pub fn with_value, V: Into>(mut self, key: K, value: V) -> Self { - self.pairs.push(Kvp { - key: key.into(), - value: value.into(), - }); + pub fn with_value( + mut self, + key: K, + value: V, + ) -> Self { + self.pairs + .push(Kvp::new(Key::from(key), Value::from(value))); + self + } + + /// TODO: Provide documentation + pub fn with>, V: Into>>(mut self, key: K, value: V) -> Self { + self.pairs.push(Kvp::new(key.into(), value.into())); self } @@ -91,14 +101,15 @@ impl QueryString { /// let qs = QueryString::new() /// .with_opt_value("q", Some("🍎 apple")) /// .with_opt_value("f", None::) - /// .with_opt_value("category", Some("fruits and vegetables")); + /// .with_opt_value("category", Some("fruits and vegetables")) + /// .with_opt_value("works", Some(true)); /// /// assert_eq!( /// format!("https://example.com/{qs}"), - /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables" + /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&works=true" /// ); /// ``` - pub fn with_opt_value, V: Into>( + pub fn with_opt_value( self, key: K, value: Option, @@ -126,11 +137,13 @@ impl QueryString { /// "https://example.com/?q=apple&category=fruits%20and%20vegetables" /// ); /// ``` - pub fn push, V: Into>(&mut self, key: K, value: V) -> &Self { - self.pairs.push(Kvp { - key: key.into(), - value: value.into(), - }); + pub fn push( + &mut self, + key: K, + value: V, + ) -> &Self { + self.pairs + .push(Kvp::new(Key::from(key), Value::from(value))); self } @@ -150,7 +163,7 @@ impl QueryString { /// "https://example.com/?q=%F0%9F%8D%8E%20apple" /// ); /// ``` - pub fn push_opt, V: Into>( + pub fn push_opt( &mut self, key: K, value: Option, @@ -189,7 +202,7 @@ impl QueryString { /// "https://example.com/?q=apple&q=pear" /// ); /// ``` - pub fn append(&mut self, mut other: QueryString) { + pub fn append(&mut self, mut other: QueryString<'a>) { self.pairs.append(&mut other.pairs) } @@ -210,38 +223,133 @@ impl QueryString { /// "https://example.com/?q=apple&q=pear" /// ); /// ``` - pub fn append_into(mut self, mut other: QueryString) -> Self { + pub fn append_into(mut self, mut other: QueryString<'a>) -> Self { self.pairs.append(&mut other.pairs); self } } -impl Display for QueryString { +impl<'a> Display for QueryString<'a> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if self.pairs.is_empty() { Ok(()) } else { - write!(f, "?")?; + f.write_char('?')?; for (i, pair) in self.pairs.iter().enumerate() { if i > 0 { - write!(f, "&")?; + f.write_char('&')?; } - write!( - f, - "{key}={value}", - key = utf8_percent_encode(&pair.key, FRAGMENT), - value = utf8_percent_encode(&pair.value, FRAGMENT) - )?; + + Display::fmt(&pair.key, f)?; + f.write_char('=')?; + Display::fmt(&pair.value, f)?; } Ok(()) } } } -#[derive(Debug, Clone)] -struct Kvp { - key: String, - value: String, +pub struct Key<'a>(QueryPart<'a>); + +pub struct Value<'a>(QueryPart<'a>); + +impl<'a> Key<'a> { + #[inline(always)] + pub fn from(value: T) -> Self { + Self(QueryPart::Owned(Box::new(value))) + } + + #[inline(always)] + pub fn from_ref(value: &'a T) -> Self { + Self(QueryPart::Reference(value)) + } + + #[inline(always)] + pub fn from_str(key: &'a str) -> Key<'a> { + Self(QueryPart::RefStr(key)) + } +} + +impl<'a> Value<'a> { + #[inline(always)] + pub fn from(value: T) -> Self { + Self(QueryPart::Owned(Box::new(value))) + } + + #[inline(always)] + pub fn from_ref(value: &'a T) -> Self { + Self(QueryPart::Reference(value)) + } + + #[inline(always)] + pub fn from_str(value: &'a str) -> Self { + Self(QueryPart::RefStr(&value)) + } +} + +impl<'a> From<&'a str> for Key<'a> { + #[inline(always)] + fn from(value: &'a str) -> Self { + Self::from_str(value) + } +} + +impl<'a> From<&'a str> for Value<'a> { + #[inline(always)] + fn from(value: &'a str) -> Self { + Self::from_str(value) + } +} + +struct Kvp<'a> { + key: QueryPart<'a>, + value: QueryPart<'a>, +} + +impl<'a> Kvp<'a> { + #[inline(always)] + pub fn new>, V: Into>>(key: K, value: V) -> Self { + Self { + key: key.into(), + value: value.into(), + } + } +} + +enum QueryPart<'a> { + /// Captures a string reference. + RefStr(&'a str), + Owned(Box), + Reference(&'a dyn ToString), +} + +impl<'a> From> for QueryPart<'a> { + #[inline(always)] + fn from(value: Key<'a>) -> Self { + value.0 + } +} + +impl<'a> From> for QueryPart<'a> { + #[inline(always)] + fn from(value: Value<'a>) -> Self { + value.0 + } +} + +impl<'a> Display for QueryPart<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + QueryPart::Owned(b) => write(&b.to_string(), f), + QueryPart::Reference(b) => write(&b.to_string(), f), + QueryPart::RefStr(s) => write(s, f), + } + } +} + +#[inline(always)] +fn write(x: &str, f: &mut Formatter<'_>) -> std::fmt::Result { + Display::fmt(&utf8_percent_encode(x, QUERY), f) } #[cfg(test)] @@ -260,15 +368,38 @@ mod tests { fn test_simple() { let qs = QueryString::new() .with_value("q", "apple???") - .with_value("category", "fruits and vegetables"); + .with_value("category", "fruits and vegetables") + .with_value("tasty", true) + .with_value("weight", 99.9); assert_eq!( qs.to_string(), - "?q=apple???&category=fruits%20and%20vegetables" + "?q=apple???&category=fruits%20and%20vegetables&tasty=true&weight=99.9" ); - assert_eq!(qs.len(), 2); + assert_eq!(qs.len(), 4); assert!(!qs.is_empty()); } + #[test] + fn test_deferred() { + let query = String::from("apple???"); + + struct Complex; + impl Display for Complex { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "complex") + } + } + + let complex = Complex; + let complex_ref = Complex; + + let qs = QueryString::new() + .with("q", Value::from_str(&query)) + .with("owned", Value::from(complex)) + .with("borrowed", Value::from_ref(&complex_ref)); + assert_eq!(qs.to_string(), "?q=apple???&owned=complex&borrowed=complex"); + } + #[test] fn test_encoding() { let qs = QueryString::new() @@ -293,12 +424,14 @@ mod tests { let qs = QueryString::new() .with_value("q", "celery") .with_opt_value("taste", None::) - .with_opt_value("category", Some("fruits and vegetables")); + .with_opt_value("category", Some("fruits and vegetables")) + .with_opt_value("tasty", Some(true)) + .with_opt_value("weight", Some(99.9)); assert_eq!( qs.to_string(), - "?q=celery&category=fruits%20and%20vegetables" + "?q=celery&category=fruits%20and%20vegetables&tasty=true&weight=99.9" ); - assert_eq!(qs.len(), 2); // not three! + assert_eq!(qs.len(), 4); // not five! } #[test]