Add support for parsing all the search query parameters for listing.

This commit is contained in:
Sebastian Jeltsch
2025-05-16 11:37:30 +02:00
parent f0f4ae5e12
commit 6b128e87d4
7 changed files with 341 additions and 74 deletions

View File

@@ -26,6 +26,6 @@ docker:
docker buildx build --platform linux/arm64,linux/amd64 --output=type=registry -t trailbase/trailbase:latest .
cloc:
cloc --not-match-d=".*(/target|/dist|/node_modules|/vendor|.astro|.venv|/traildepot|/flutter|/assets|lock|_benchmark|/bin|/obj).*" .
cloc --not-match-d=".*(/target|/dist|/node_modules|/vendor|.astro|.build|.venv|/traildepot|/flutter|/assets|lock|_benchmark|/bin|/obj).*" .
.PHONY: default format check static docker cloc

View File

@@ -1,6 +1,6 @@
use serde::de::{Deserializer, Error};
use crate::value::{Value, unexpected};
use crate::value::Value;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum CompareOp {
@@ -55,11 +55,14 @@ impl<'de> serde::de::Deserialize<'de> for CompareOp {
let value = Value::deserialize(deserializer)?;
let Value::String(ref string) = value else {
return Err(Error::invalid_type(unexpected(&value), &"String"));
return Err(Error::invalid_type(
crate::util::unexpected(&value),
&"String",
));
};
return CompareOp::from(string)
.ok_or_else(|| Error::invalid_type(unexpected(&value), &"(eq|ne)"));
.ok_or_else(|| Error::invalid_type(crate::util::unexpected(&value), &"(eq|ne)"));
}
}
@@ -90,6 +93,9 @@ where
D: Deserializer<'de>,
{
use serde_value::Value;
if !crate::util::sanitize_column_name(&key) {
return Err(Error::custom(format!("invalid column name: {key}")));
}
return match value {
Value::String(_) => Ok(ColumnOpValue {
@@ -109,7 +115,7 @@ where
})
}
v => Err(Error::invalid_type(
unexpected(&v),
crate::util::unexpected(&v),
&"Map<String, String | Map<Rel, String>>",
)),
};
@@ -128,7 +134,7 @@ impl<'de> serde::de::Deserialize<'de> for ColumnOpValueMap {
let Value::Map(m) = value else {
return Err(Error::invalid_type(
unexpected(&value),
crate::util::unexpected(&value),
&"Map<String, String | Map<Rel, String>>",
));
};
@@ -138,7 +144,7 @@ impl<'de> serde::de::Deserialize<'de> for ColumnOpValueMap {
.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")),
(k, _) => Err(Error::invalid_type(crate::util::unexpected(&k), &"String")),
};
})
.collect::<Result<Vec<_>, _>>()?;

View File

@@ -11,7 +11,6 @@ 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 {
@@ -60,7 +59,10 @@ where
// 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"));
return Err(Error::invalid_type(
crate::util::unexpected(&value),
&"map[col]=val or map[col][op]=val",
));
};
if m.is_empty() {
@@ -81,7 +83,10 @@ where
serde_value_to_single_column_rel_value::<D>(key, v)?,
)),
},
_ => Err(Error::invalid_type(unexpected(&k), &"Map0")),
_ => Err(Error::invalid_type(
crate::util::unexpected(&k),
&"string key",
)),
};
})
.collect::<Result<Vec<_>, _>>()?;
@@ -107,7 +112,10 @@ where
.collect::<Result<Vec<_>, _>>()?,
));
}
v => Err(Error::invalid_type(unexpected(&v), &"Sequence")),
v => Err(Error::invalid_type(
crate::util::unexpected(&v),
&"Sequence",
)),
}
};
@@ -120,7 +128,7 @@ where
serde_value_to_single_column_rel_value::<D>(str, v)?,
)),
},
(k, _) => Err(Error::invalid_type(unexpected(&k), &"String")),
(k, _) => Err(Error::invalid_type(crate::util::unexpected(&k), &"String")),
}
}
@@ -220,40 +228,5 @@ mod tests {
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)"
);
}
}

View File

@@ -4,4 +4,6 @@
mod column_rel_value;
mod filter;
mod query;
mod util;
mod value;

272
trailbase-qs/src/query.rs Normal file
View File

@@ -0,0 +1,272 @@
use serde::Deserialize;
use crate::filter::ValueOrComposite;
#[derive(Clone, Debug, PartialEq, Deserialize)]
pub enum Cursor {
Blob(Vec<u8>),
Integer(i64),
}
#[derive(Clone, Debug, PartialEq)]
pub enum OrderPrecedent {
Ascending,
Descending,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Order {
columns: Vec<(String, OrderPrecedent)>,
}
impl<'de> serde::de::Deserialize<'de> for Order {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
use serde::de::Error;
use serde_value::Value;
let value = Value::deserialize(deserializer)?;
let Value::String(str) = value else {
return Err(Error::invalid_type(
crate::util::unexpected(&value),
&"comma separated column names",
));
};
let columns = str
.split(",")
.map(|v| {
let col_order = match v.trim() {
x if x.starts_with("-") => (v[1..].to_string(), OrderPrecedent::Descending),
x if x.starts_with("+") => (v[1..].to_string(), OrderPrecedent::Ascending),
x => (x.to_string(), OrderPrecedent::Ascending),
};
if !crate::util::sanitize_column_name(&col_order.0) {
return Err(Error::custom(format!(
"invalid column name: {}",
col_order.0
)));
}
return Ok(col_order);
})
.collect::<Result<Vec<_>, _>>()?;
if columns.len() > 5 {
return Err(Error::invalid_length(
5,
&"more more than 5 order dimension",
));
}
return Ok(Order { columns });
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Expand {
columns: Vec<String>,
}
impl<'de> serde::de::Deserialize<'de> for Expand {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
use serde::de::Error;
use serde_value::Value;
let value = Value::deserialize(deserializer)?;
let Value::String(str) = value else {
return Err(Error::invalid_type(
crate::util::unexpected(&value),
&"comma separated column names",
));
};
let columns = str
.split(",")
.map(|column_name| {
if !crate::util::sanitize_column_name(column_name) {
return Err(Error::custom(
format!("invalid column name: {column_name}",),
));
}
return Ok(column_name.to_string());
})
.collect::<Result<Vec<_>, _>>()?;
if columns.len() > 5 {
return Err(Error::invalid_length(
5,
&"more more than 5 expand dimension",
));
}
return Ok(Expand { columns });
}
}
#[derive(Clone, Default, Debug, PartialEq, Deserialize)]
pub struct Query {
/// Pagination parameters:
///
/// Max number of elements returned per page.
pub limit: Option<usize>,
/// Cursor to page.
pub cursor: Option<String>,
/// Offset to page. Cursor is more efficient when available
pub offset: Option<usize>,
/// Return total number of rows in the table.
pub count: Option<bool>,
/// Which foreign key columns to expand (only when allowed by configuration).
pub expand: Option<Expand>,
/// Ordering. It's a vector for &order=-col0,+col1,col2
pub order: Option<Order>,
/// Map from filter params to filter value. It's a vector in cases like:
/// `col0[gte]=2&col0[lte]=10`.
pub filter: Option<ValueOrComposite>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_qs::Config;
use crate::column_rel_value::{ColumnOpValue, CompareOp};
use crate::filter::Combiner;
use crate::value::Value;
#[test]
fn test_query_basic_parsing() {
let qs = Config::new(5, true);
assert_eq!(qs.deserialize_str::<Query>("").unwrap(), Query::default());
}
#[test]
fn test_query_order_parsing() {
let qs = Config::new(5, true);
assert_eq!(
qs.deserialize_str::<Query>("order=").unwrap(),
Query {
order: None,
..Default::default()
},
);
assert!(qs.deserialize_str::<Query>("order=$").is_err());
assert!(qs.deserialize_str::<Query>("order=a,b,c,d,e").is_ok());
assert!(qs.deserialize_str::<Query>("order=a,b,c,d,e,f").is_err());
assert_eq!(
qs.deserialize_str::<Query>("order=a,-b,+c").unwrap(),
Query {
order: Some(Order {
columns: vec![
("a".to_string(), OrderPrecedent::Ascending),
("b".to_string(), OrderPrecedent::Descending),
("c".to_string(), OrderPrecedent::Ascending),
]
}),
..Default::default()
}
);
}
#[test]
fn test_query_expand_parsing() {
let qs = Config::new(5, true);
assert_eq!(
qs.deserialize_str::<Query>("expand=").unwrap(),
Query {
expand: None,
..Default::default()
},
);
assert!(qs.deserialize_str::<Query>("expand=$").is_err());
assert!(qs.deserialize_str::<Query>("expand=a,b,c,d,e").is_ok());
assert!(qs.deserialize_str::<Query>("expand=a,b,c,d,e,f").is_err());
}
#[test]
fn test_query_filter_parsing() {
let qs = Config::new(5, true);
assert_eq!(
qs.deserialize_str::<Query>("filter=").unwrap(),
Query::default()
);
let q0: Query = qs
.deserialize_str("filter[col0][gt]=0&filter[col1]=val1")
.unwrap();
assert_eq!(
q0.filter.unwrap(),
ValueOrComposite::Composite(
Combiner::And,
vec![
ValueOrComposite::Value(ColumnOpValue {
column: "col0".to_string(),
op: CompareOp::GreaterThan,
value: Value::Integer(0),
}),
ValueOrComposite::Value(ColumnOpValue {
column: "col1".to_string(),
op: CompareOp::Equal,
value: Value::String("val1".to_string()),
}),
]
)
);
// Implicit and with nested or and out of order.
let q1: Query = qs
.deserialize_str("filter[$or][1][col0][ne]=val0&filter[col1]=1&filter[$or][0][col2]=val2")
.unwrap();
assert_eq!(
q1.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!(
q1.filter.unwrap().to_sql(),
"((col2 = 'val2' OR col0 <> 'val0') AND col1 = 1)"
);
}
}

36
trailbase-qs/src/util.rs Normal file
View File

@@ -0,0 +1,36 @@
use serde::de::Unexpected;
#[inline]
pub(crate) fn sanitize_column_name(name: &str) -> bool {
// Assuming that all uses are quoted correctly, it should be enough to discard names containing
// (", ', `, [, ]), however we're conservative here.
return name
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_');
}
pub(crate) fn unexpected(value: &serde_value::Value) -> Unexpected {
use serde_value::Value;
match *value {
Value::Bool(b) => Unexpected::Bool(b),
Value::U8(n) => Unexpected::Unsigned(n as u64),
Value::U16(n) => Unexpected::Unsigned(n as u64),
Value::U32(n) => Unexpected::Unsigned(n as u64),
Value::U64(n) => Unexpected::Unsigned(n),
Value::I8(n) => Unexpected::Signed(n as i64),
Value::I16(n) => Unexpected::Signed(n as i64),
Value::I32(n) => Unexpected::Signed(n as i64),
Value::I64(n) => Unexpected::Signed(n),
Value::F32(n) => Unexpected::Float(n as f64),
Value::F64(n) => Unexpected::Float(n),
Value::Char(c) => Unexpected::Char(c),
Value::String(ref s) => Unexpected::Str(s),
Value::Unit => Unexpected::Unit,
Value::Option(_) => Unexpected::Option,
Value::Newtype(_) => Unexpected::NewtypeStruct,
Value::Seq(_) => Unexpected::Seq,
Value::Map(_) => Unexpected::Map,
Value::Bytes(ref b) => Unexpected::Bytes(b),
}
}

View File

@@ -1,5 +1,5 @@
use base64::prelude::*;
use serde::de::{Deserialize, Deserializer, Error, Unexpected};
use serde::de::{Deserialize, Deserializer, Error};
use std::str::FromStr;
#[derive(Clone, Debug, PartialEq)]
@@ -69,7 +69,10 @@ where
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")),
_ => Err(Error::invalid_type(
crate::util::unexpected(&value),
&"Value",
)),
};
}
@@ -85,31 +88,6 @@ impl<'de> Deserialize<'de> for 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::*;