From f0f4ae5e12cb06b850b1eacf86bda3fc8b0d4495 Mon Sep 17 00:00:00 2001 From: Sebastian Jeltsch Date: Fri, 16 May 2025 10:18:12 +0200 Subject: [PATCH] Add a first (yet unused) version of a "querystring" filter parser. --- Cargo.lock | 41 +++++ Cargo.toml | 3 + trailbase-extension/src/password.rs | 2 +- trailbase-qs/Cargo.toml | 16 ++ trailbase-qs/src/column_rel_value.rs | 218 ++++++++++++++++++++++ trailbase-qs/src/filter.rs | 259 +++++++++++++++++++++++++++ trailbase-qs/src/lib.rs | 7 + trailbase-qs/src/value.rs | 171 ++++++++++++++++++ 8 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 trailbase-qs/Cargo.toml create mode 100644 trailbase-qs/src/column_rel_value.rs create mode 100644 trailbase-qs/src/filter.rs create mode 100644 trailbase-qs/src/lib.rs create mode 100644 trailbase-qs/src/value.rs diff --git a/Cargo.lock b/Cargo.lock index 11ccaa21..d2a5853f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4097,6 +4097,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "os_pipe" version = "1.1.5" @@ -5482,6 +5491,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.17" @@ -5536,6 +5555,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "serde_rusqlite" version = "0.38.0" @@ -6964,6 +6994,17 @@ dependencies = [ "trailbase-sqlite", ] +[[package]] +name = "trailbase-qs" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "itertools 0.14.0", + "serde", + "serde-value", + "serde_qs", +] + [[package]] name = "trailbase-refinery-core" version = "0.8.16" diff --git a/Cargo.toml b/Cargo.toml index 0896127d..9d45d8d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "trailbase-core", "trailbase-extension", "trailbase-js", + "trailbase-qs", "trailbase-schema", "trailbase-sqlite", "vendor/serde_rusqlite", @@ -24,6 +25,7 @@ default-members = [ "trailbase-core", "trailbase-extension", "trailbase-js", + "trailbase-qs", "trailbase-schema", "trailbase-sqlite", ] @@ -75,6 +77,7 @@ trailbase-assets = { path = "trailbase-assets", version = "0.1.0" } trailbase-sqlean = { path = "vendor/sqlean", version = "0.0.2" } trailbase-extension = { path = "trailbase-extension", version = "0.2.0" } trailbase-js = { path = "trailbase-js", version = "0.1.0" } +trailbase-qs = { path = "trailbase-qs", version = "0.1.0" } trailbase-refinery-core = { path = "vendor/refinery/refinery_core", version = "0.8.16", default-features = false, features = ["rusqlite-bundled"] } trailbase-refinery-macros = { path = "vendor/refinery/refinery_macros", version = "0.8.15" } trailbase-schema = { path = "trailbase-schema", version = "0.1.0" } diff --git a/trailbase-extension/src/password.rs b/trailbase-extension/src/password.rs index 5a2a6d42..1c130cd8 100644 --- a/trailbase-extension/src/password.rs +++ b/trailbase-extension/src/password.rs @@ -6,7 +6,7 @@ use rusqlite::Error; use rusqlite::functions::Context; use std::sync::LazyLock; -static ARGON2: LazyLock> = LazyLock::new(|| Argon2::default()); +static ARGON2: LazyLock> = LazyLock::new(Argon2::default); pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); diff --git a/trailbase-qs/Cargo.toml b/trailbase-qs/Cargo.toml new file mode 100644 index 00000000..04ad7159 --- /dev/null +++ b/trailbase-qs/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "trailbase-qs" +version = "0.1.0" +edition = "2024" +license = "OSL-3.0" +description = "Query string parser for TrailBase" +homepage = "https://trailbase.io" +repository = "https://github.com/trailbaseio/trailbase" +readme = "../README.md" + +[dependencies] +base64 = "0.22.1" +itertools = "0.14.0" +serde = "1.0.219" +serde-value = "0.7.0" +serde_qs = "0.15.0" diff --git a/trailbase-qs/src/column_rel_value.rs b/trailbase-qs/src/column_rel_value.rs new file mode 100644 index 00000000..938030d5 --- /dev/null +++ b/trailbase-qs/src/column_rel_value.rs @@ -0,0 +1,218 @@ +use serde::de::{Deserializer, Error}; + +use crate::value::{Value, unexpected}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum CompareOp { + Not, + Equal, + NotEqual, + GreaterThanEqual, + GreaterThan, + LessThanEqual, + LessThan, + Like, + Regexp, +} + +impl CompareOp { + pub fn from(qualifier: &str) -> Option { + return match qualifier { + "gte" => Some(Self::GreaterThanEqual), + "gt" => Some(Self::GreaterThan), + "lte" => Some(Self::LessThanEqual), + "lt" => Some(Self::LessThan), + "eq" => Some(Self::Equal), + "not" => Some(Self::Not), + "ne" => Some(Self::NotEqual), + "like" => Some(Self::Like), + "re" => Some(Self::Regexp), + _ => None, + }; + } + + pub fn to_sql(self) -> &'static str { + return match self { + Self::GreaterThanEqual => ">=", + Self::GreaterThan => ">", + Self::LessThanEqual => "<=", + Self::LessThan => "<", + Self::Not => "<>", + Self::NotEqual => "<>", + Self::Like => "LIKE", + Self::Regexp => "REGEXP", + Self::Equal => "=", + }; + } +} + +impl<'de> serde::de::Deserialize<'de> for CompareOp { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde_value::Value; + let value = Value::deserialize(deserializer)?; + + let Value::String(ref string) = value else { + return Err(Error::invalid_type(unexpected(&value), &"String")); + }; + + return CompareOp::from(string) + .ok_or_else(|| Error::invalid_type(unexpected(&value), &"(eq|ne)")); + } +} + +/// Type to support query of shape: `[column][op]=value`. +#[derive(Clone, Debug, PartialEq)] +pub struct ColumnOpValue { + pub column: String, + pub op: CompareOp, + pub value: Value, +} + +impl ColumnOpValue { + pub fn to_sql(&self) -> String { + return format!( + "{c} {o} {v}", + c = self.column, + o = self.op.to_sql(), + v = self.value.to_sql() + ); + } +} + +pub fn serde_value_to_single_column_rel_value<'de, D>( + key: String, + value: serde_value::Value, +) -> Result +where + D: Deserializer<'de>, +{ + use serde_value::Value; + + return match value { + Value::String(_) => Ok(ColumnOpValue { + column: key, + op: CompareOp::Equal, + value: crate::value::serde_value_to_value::(value)?, + }), + Value::Map(mut m) if m.len() == 1 => { + let (k, v) = m.pop_first().expect("len() == 1"); + + let op = k.deserialize_into::().map_err(Error::custom)?; + + Ok(ColumnOpValue { + column: key, + op, + value: crate::value::serde_value_to_value::(v)?, + }) + } + v => Err(Error::invalid_type( + unexpected(&v), + &"Map>", + )), + }; +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ColumnOpValueMap(pub(crate) Vec); + +impl<'de> serde::de::Deserialize<'de> for ColumnOpValueMap { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde_value::Value; + let value = Value::deserialize(deserializer)?; + + let Value::Map(m) = value else { + return Err(Error::invalid_type( + unexpected(&value), + &"Map>", + )); + }; + + let vec = m + .into_iter() + .map(|(k, v)| { + return match (k, v) { + (Value::String(key), v) => serde_value_to_single_column_rel_value::(key, v), + (k, _) => Err(Error::invalid_type(unexpected(&k), &"String")), + }; + }) + .collect::, _>>()?; + + return Ok(ColumnOpValueMap(vec)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde::Deserialize; + use serde_qs::Config; + + #[derive(Clone, Debug, Deserialize)] + struct Query { + filter: Option, + } + + #[test] + fn test_column_rel_value() { + let qs = Config::new(5, true); + + let m_empty: Query = qs.deserialize_str("").unwrap(); + assert_eq!(m_empty.filter, None); + + let m0: Query = qs.deserialize_str("filter[column]=1").unwrap(); + assert_eq!( + m0.filter.unwrap().0, + vec![ColumnOpValue { + column: "column".to_string(), + op: CompareOp::Equal, + value: Value::Integer(1), + }] + ); + + let m0: Query = qs.deserialize_str("filter[column]=true").unwrap(); + assert_eq!( + m0.filter.unwrap().0, + vec![ColumnOpValue { + column: "column".to_string(), + op: CompareOp::Equal, + value: Value::Bool(true), + }] + ); + + let m1: Query = qs.deserialize_str("filter[column][eq]=1").unwrap(); + assert_eq!( + m1.filter.unwrap().0, + vec![ColumnOpValue { + column: "column".to_string(), + op: CompareOp::Equal, + value: Value::Integer(1), + }] + ); + + let m2: Query = qs + .deserialize_str("filter[col1][ne]=1&filter[col2]=2") + .unwrap(); + assert_eq!( + m2.filter.unwrap().0, + vec![ + ColumnOpValue { + column: "col1".to_string(), + op: CompareOp::NotEqual, + value: Value::Integer(1), + }, + ColumnOpValue { + column: "col2".to_string(), + op: CompareOp::Equal, + value: Value::Integer(2), + }, + ] + ); + } +} diff --git a/trailbase-qs/src/filter.rs b/trailbase-qs/src/filter.rs new file mode 100644 index 00000000..320b8440 --- /dev/null +++ b/trailbase-qs/src/filter.rs @@ -0,0 +1,259 @@ +/// Custom deserializers for urlencoded filters. +/// +/// Supported examples: +/// +/// filters[column]=value <-- might be nice otherwise below. +/// filter[column]=value +/// filters[column][eq]=value +/// filters[and][0][column0][eq]=value0&filters[and][1][column1][eq]=value1 +/// filters[and][0][or][0][column0]=value0&[and][0][or][1][column1]=value1 +use itertools::Itertools; +use std::collections::BTreeMap; + +use crate::column_rel_value::{ColumnOpValue, serde_value_to_single_column_rel_value}; +use crate::value::unexpected; + +#[derive(Clone, Debug, PartialEq)] +pub enum Combiner { + And, + Or, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ValueOrComposite { + Value(ColumnOpValue), + Composite(Combiner, Vec), +} + +impl ValueOrComposite { + #[allow(unused)] + pub fn to_sql(&self) -> String { + let mut fragments: Vec = vec![]; + match self { + Self::Value(v) => fragments.push(v.to_sql()), + Self::Composite(combiner, vec) => { + let f = vec.iter().map(|v| v.to_sql()).join(match combiner { + Combiner::And => " AND ", + Combiner::Or => " OR ", + }); + fragments.push(format!("({f})")); + } + }; + return fragments.join(" "); + } +} + +fn serde_value_to_value_or_composite<'de, D>( + value: serde_value::Value, + depth: usize, +) -> Result +where + D: serde::de::Deserializer<'de>, +{ + use serde::de::Error; + use serde_value::Value; + + // Limit recursion depth + if depth >= 5 { + return Err(Error::custom("Recursion limit exceeded")); + } + + // We always expect [key] = value, i.e. a Map[key] = value. + let Value::Map(mut m) = value else { + return Err(Error::invalid_type(unexpected(&value), &"Map1")); + }; + + if m.is_empty() { + // We could also error here, but this allows empty query-string. + return Ok(ValueOrComposite::Composite(Combiner::And, vec![])); + } else if m.len() > 1 { + // Multiple entries on the same same level => Implicit AND composite + let vec = m + .into_iter() + .map(|(k, v)| { + return match k { + Value::String(key) => match key.as_str() { + "$and" | "$or" => serde_value_to_value_or_composite::( + Value::Map(BTreeMap::from([(Value::String(key), v)])), + depth + 1, + ), + _ => Ok(ValueOrComposite::Value( + serde_value_to_single_column_rel_value::(key, v)?, + )), + }, + _ => Err(Error::invalid_type(unexpected(&k), &"Map0")), + }; + }) + .collect::, _>>()?; + + return Ok(ValueOrComposite::Composite(Combiner::And, vec)); + } + + let combine = |combiner: Combiner, values: Value| -> Result { + match values { + Value::Seq(vec) => { + if vec.len() < 2 { + return Err(serde::de::Error::invalid_length( + vec.len(), + &"Sequence with 2 or more elements", + )); + } + + return Ok(ValueOrComposite::Composite( + combiner, + vec + .into_iter() + .map(|v| serde_value_to_value_or_composite::(v, depth + 1)) + .collect::, _>>()?, + )); + } + v => Err(Error::invalid_type(unexpected(&v), &"Sequence")), + } + }; + + // We iterate only to take ownership. + match m.pop_first().expect("len == 1") { + (Value::String(str), v) => match str.as_str() { + "$and" => combine(Combiner::And, v), + "$or" => combine(Combiner::Or, v), + _ => Ok(ValueOrComposite::Value( + serde_value_to_single_column_rel_value::(str, v)?, + )), + }, + (k, _) => Err(Error::invalid_type(unexpected(&k), &"String")), + } +} + +impl<'de> serde::de::Deserialize<'de> for ValueOrComposite { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + use serde_value::Value; + let value = Value::deserialize(deserializer)?; + + return serde_value_to_value_or_composite::(value, 0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde::Deserialize; + use serde_qs::Config; + + use crate::column_rel_value::{ColumnOpValue, CompareOp}; + use crate::value::Value; + + #[derive(Clone, Debug, Default, Deserialize)] + struct Query { + composite_filter: Option, + } + + #[test] + fn test_filter_parsing() { + let qs = Config::new(5, true); + + let m_empty: Query = qs.deserialize_str("").unwrap(); + assert_eq!(m_empty.composite_filter, None); + + let m0: Result = qs.deserialize_str("composite_filter[$and][0][col0]=val0"); + assert!(m0.is_err(), "{m0:?}"); + + let m1: Query = qs + .deserialize_str("composite_filter[$and][0][col0]=val0&composite_filter[$and][1][col1]=val1") + .unwrap(); + assert_eq!( + m1.composite_filter.unwrap(), + ValueOrComposite::Composite( + Combiner::And, + vec![ + ValueOrComposite::Value(ColumnOpValue { + column: "col0".to_string(), + op: CompareOp::Equal, + value: Value::String("val0".to_string()), + }), + ValueOrComposite::Value(ColumnOpValue { + column: "col1".to_string(), + op: CompareOp::Equal, + value: Value::String("val1".to_string()), + }), + ] + ) + ); + + assert!( + qs.deserialize_str::( + "composite_filter[$and][0][col0]=val0&composite_filter[$or][1][col1]=val1", + ) + .is_err() + ); + + let m2: Query = qs + .deserialize_str("composite_filter[col0]=val0&composite_filter[col1]=val1") + .unwrap(); + assert_eq!( + m2.composite_filter.unwrap(), + ValueOrComposite::Composite( + Combiner::And, + vec![ + ValueOrComposite::Value(ColumnOpValue { + column: "col0".to_string(), + op: CompareOp::Equal, + value: Value::String("val0".to_string()), + }), + ValueOrComposite::Value(ColumnOpValue { + column: "col1".to_string(), + op: CompareOp::Equal, + value: Value::String("val1".to_string()), + }), + ] + ) + ); + + // Too few elements + let m4: Result = qs.deserialize_str("composite_filter[$and][0][col0]=val0"); + assert!(m4.is_err(), "{m4:?}"); + + // Too few elements + let m3: Result = + qs.deserialize_str("composite_filter[col0]=val0&composite_filter[$and][0][col0]=val0&composite_filter[col1]=val1"); + assert!(m3.is_err(), "{m3:?}"); + + // Implicit and with nested or and out of order. + let m5: Query = qs.deserialize_str("composite_filter[$or][1][col0][ne]=val0&composite_filter[col1]=1&composite_filter[$or][0][col2]=val2").unwrap(); + assert_eq!( + m5.composite_filter.as_ref().unwrap(), + &ValueOrComposite::Composite( + Combiner::And, + vec![ + ValueOrComposite::Composite( + Combiner::Or, + vec![ + ValueOrComposite::Value(ColumnOpValue { + column: "col2".to_string(), + op: CompareOp::Equal, + value: Value::String("val2".to_string()), + }), + ValueOrComposite::Value(ColumnOpValue { + column: "col0".to_string(), + op: CompareOp::NotEqual, + value: Value::String("val0".to_string()), + }), + ] + ), + ValueOrComposite::Value(ColumnOpValue { + column: "col1".to_string(), + op: CompareOp::Equal, + value: Value::Integer(1), + }), + ] + ) + ); + assert_eq!( + m5.composite_filter.unwrap().to_sql(), + "((col2 = 'val2' OR col0 <> 'val0') AND col1 = 1)" + ); + } +} diff --git a/trailbase-qs/src/lib.rs b/trailbase-qs/src/lib.rs new file mode 100644 index 00000000..e3ce225d --- /dev/null +++ b/trailbase-qs/src/lib.rs @@ -0,0 +1,7 @@ +#![forbid(unsafe_code, clippy::unwrap_used)] +#![allow(clippy::needless_return)] +#![warn(clippy::await_holding_lock, clippy::inefficient_to_string)] + +mod column_rel_value; +mod filter; +mod value; diff --git a/trailbase-qs/src/value.rs b/trailbase-qs/src/value.rs new file mode 100644 index 00000000..6527bf25 --- /dev/null +++ b/trailbase-qs/src/value.rs @@ -0,0 +1,171 @@ +use base64::prelude::*; +use serde::de::{Deserialize, Deserializer, Error, Unexpected}; +use std::str::FromStr; + +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + // Note that bytes are also strings, either UUID or url-safe-b64 encoded. Need to be decoded + // downstream based on content. + String(String), + Integer(i64), + Double(f64), + Bool(bool), +} + +impl Value { + fn unparse(value: String) -> Self { + return match value.as_str() { + "true" | "TRUE" => Value::Bool(true), + "false" | "FALSE" => Value::Bool(false), + _ => { + if let Ok(i) = i64::from_str(&value) { + Value::Integer(i) + } else if let Ok(d) = f64::from_str(&value) { + Value::Double(d) + } else { + Value::String(value) + } + } + }; + } + + pub fn to_sql(&self) -> String { + return match self { + Self::String(s) => format!("'{s}'"), + Self::Integer(i) => i.to_string(), + Self::Double(d) => d.to_string(), + Self::Bool(b) => match b { + true => "TRUE".to_string(), + false => "false".to_string(), + }, + }; + } +} + +impl std::fmt::Display for Value { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + return match self { + Self::String(s) => s.fmt(f), + Self::Integer(i) => i.fmt(f), + Self::Double(d) => d.fmt(f), + Self::Bool(b) => b.fmt(f), + }; + } +} + +pub fn serde_value_to_value<'de, D>(value: serde_value::Value) -> Result +where + D: Deserializer<'de>, +{ + return match value { + serde_value::Value::String(value) => Ok(Value::unparse(value)), + serde_value::Value::Bytes(bytes) => Ok(Value::String(BASE64_URL_SAFE.encode(bytes))), + serde_value::Value::I64(i) => Ok(Value::Integer(i)), + serde_value::Value::I32(i) => Ok(Value::Integer(i as i64)), + serde_value::Value::I16(i) => Ok(Value::Integer(i as i64)), + serde_value::Value::I8(i) => Ok(Value::Integer(i as i64)), + serde_value::Value::U64(i) => Ok(Value::Integer(i as i64)), + serde_value::Value::U32(i) => Ok(Value::Integer(i as i64)), + serde_value::Value::U16(i) => Ok(Value::Integer(i as i64)), + serde_value::Value::U8(i) => Ok(Value::Integer(i as i64)), + serde_value::Value::Bool(b) => Ok(Value::Bool(b)), + _ => Err(Error::invalid_type(unexpected(&value), &"Value")), + }; +} + +impl<'de> Deserialize<'de> for Value { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde_value::Value; + let value = Value::deserialize(deserializer)?; + + return serde_value_to_value::<'de, D>(value); + } +} + +pub fn unexpected(value: &serde_value::Value) -> Unexpected { + use serde_value::Value; + match *value { + Value::Bool(b) => serde::de::Unexpected::Bool(b), + Value::U8(n) => serde::de::Unexpected::Unsigned(n as u64), + Value::U16(n) => serde::de::Unexpected::Unsigned(n as u64), + Value::U32(n) => serde::de::Unexpected::Unsigned(n as u64), + Value::U64(n) => serde::de::Unexpected::Unsigned(n), + Value::I8(n) => serde::de::Unexpected::Signed(n as i64), + Value::I16(n) => serde::de::Unexpected::Signed(n as i64), + Value::I32(n) => serde::de::Unexpected::Signed(n as i64), + Value::I64(n) => serde::de::Unexpected::Signed(n), + Value::F32(n) => serde::de::Unexpected::Float(n as f64), + Value::F64(n) => serde::de::Unexpected::Float(n), + Value::Char(c) => serde::de::Unexpected::Char(c), + Value::String(ref s) => serde::de::Unexpected::Str(s), + Value::Unit => serde::de::Unexpected::Unit, + Value::Option(_) => serde::de::Unexpected::Option, + Value::Newtype(_) => serde::de::Unexpected::NewtypeStruct, + Value::Seq(_) => serde::de::Unexpected::Seq, + Value::Map(_) => serde::de::Unexpected::Map, + Value::Bytes(ref b) => serde::de::Unexpected::Bytes(b), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde::Deserialize; + use serde_qs::Config; + + use crate::column_rel_value::{ColumnOpValue, CompareOp}; + use crate::filter::ValueOrComposite; + + #[derive(Clone, Debug, Default, Deserialize)] + struct Query { + filter: Option, + } + + #[test] + fn test_value() { + let qs = Config::new(5, true); + + let v0: Query = qs.deserialize_str("filter[col0][eq]=val0").unwrap(); + assert_eq!( + v0.filter.unwrap(), + ValueOrComposite::Value(ColumnOpValue { + column: "col0".to_string(), + op: CompareOp::Equal, + value: Value::String("val0".to_string()), + }) + ); + let v1: Query = qs.deserialize_str("filter[col0][ne]=TRUE").unwrap(); + assert_eq!( + v1.filter.unwrap(), + ValueOrComposite::Value(ColumnOpValue { + column: "col0".to_string(), + op: CompareOp::NotEqual, + value: Value::Bool(true), + }) + ); + + let v2: Query = qs.deserialize_str("filter[col0][ne]=0").unwrap(); + assert_eq!( + v2.filter.unwrap(), + ValueOrComposite::Value(ColumnOpValue { + column: "col0".to_string(), + op: CompareOp::NotEqual, + value: Value::Integer(0), + }) + ); + + let v3: Query = qs.deserialize_str("filter[col0][ne]=0.0").unwrap(); + assert_eq!( + v3.filter.unwrap(), + ValueOrComposite::Value(ColumnOpValue { + column: "col0".to_string(), + op: CompareOp::NotEqual, + value: Value::Double(0.0), + }) + ); + } +}