Add a first (yet unused) version of a "querystring" filter parser.

This commit is contained in:
Sebastian Jeltsch
2025-05-16 10:18:12 +02:00
parent 46e4d4c13e
commit f0f4ae5e12
8 changed files with 716 additions and 1 deletions

41
Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -6,7 +6,7 @@ use rusqlite::Error;
use rusqlite::functions::Context;
use std::sync::LazyLock;
static ARGON2: LazyLock<Argon2<'static>> = LazyLock::new(|| Argon2::default());
static ARGON2: LazyLock<Argon2<'static>> = LazyLock::new(Argon2::default);
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);

16
trailbase-qs/Cargo.toml Normal file
View File

@@ -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"

View File

@@ -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<Self> {
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<D>(deserializer: D) -> Result<Self, D::Error>
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<ColumnOpValue, D::Error>
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::<D>(value)?,
}),
Value::Map(mut m) if m.len() == 1 => {
let (k, v) = m.pop_first().expect("len() == 1");
let op = k.deserialize_into::<CompareOp>().map_err(Error::custom)?;
Ok(ColumnOpValue {
column: key,
op,
value: crate::value::serde_value_to_value::<D>(v)?,
})
}
v => Err(Error::invalid_type(
unexpected(&v),
&"Map<String, String | Map<Rel, String>>",
)),
};
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ColumnOpValueMap(pub(crate) Vec<ColumnOpValue>);
impl<'de> serde::de::Deserialize<'de> for ColumnOpValueMap {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<String, String | Map<Rel, String>>",
));
};
let vec = m
.into_iter()
.map(|(k, v)| {
return match (k, v) {
(Value::String(key), v) => serde_value_to_single_column_rel_value::<D>(key, v),
(k, _) => Err(Error::invalid_type(unexpected(&k), &"String")),
};
})
.collect::<Result<Vec<_>, _>>()?;
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<ColumnOpValueMap>,
}
#[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),
},
]
);
}
}

259
trailbase-qs/src/filter.rs Normal file
View File

@@ -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<ValueOrComposite>),
}
impl ValueOrComposite {
#[allow(unused)]
pub fn to_sql(&self) -> String {
let mut fragments: Vec<String> = 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<ValueOrComposite, D::Error>
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::<D>(
Value::Map(BTreeMap::from([(Value::String(key), v)])),
depth + 1,
),
_ => Ok(ValueOrComposite::Value(
serde_value_to_single_column_rel_value::<D>(key, v)?,
)),
},
_ => Err(Error::invalid_type(unexpected(&k), &"Map0")),
};
})
.collect::<Result<Vec<_>, _>>()?;
return Ok(ValueOrComposite::Composite(Combiner::And, vec));
}
let combine = |combiner: Combiner, values: Value| -> Result<ValueOrComposite, D::Error> {
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::<D>(v, depth + 1))
.collect::<Result<Vec<_>, _>>()?,
));
}
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::<D>(str, v)?,
)),
},
(k, _) => Err(Error::invalid_type(unexpected(&k), &"String")),
}
}
impl<'de> serde::de::Deserialize<'de> for ValueOrComposite {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
use serde_value::Value;
let value = Value::deserialize(deserializer)?;
return serde_value_to_value_or_composite::<D>(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<ValueOrComposite>,
}
#[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<Query, _> = 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::<Query>(
"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<Query, _> = qs.deserialize_str("composite_filter[$and][0][col0]=val0");
assert!(m4.is_err(), "{m4:?}");
// Too few elements
let m3: Result<Query, _> =
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)"
);
}
}

7
trailbase-qs/src/lib.rs Normal file
View File

@@ -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;

171
trailbase-qs/src/value.rs Normal file
View File

@@ -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<Value, D::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<ValueOrComposite>,
}
#[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),
})
);
}
}