Use more column type information when un-parsing query params for filtered record listings.

This commit is contained in:
Sebastian Jeltsch
2025-10-09 14:17:17 +02:00
parent 4cfffe1901
commit e149a065fa
7 changed files with 62 additions and 68 deletions

1
Cargo.lock generated
View File

@@ -8061,6 +8061,7 @@ name = "trailbase-qs"
version = "0.1.0"
dependencies = [
"base64",
"rusqlite",
"serde",
"serde-value",
"serde_qs",

View File

@@ -41,41 +41,34 @@ pub(crate) fn build_filter_where_clause(
});
};
let validator = |column_name: &str| -> Result<(), WhereClauseError> {
let convert = |column_name: &str,
value: trailbase_qs::Value|
-> Result<trailbase_sqlite::Value, WhereClauseError> {
if column_name.starts_with("_") {
return Err(WhereClauseError::UnrecognizedParam(format!(
"Invalid parameter: {column_name}"
)));
}
// IMPORTANT: We only include parameters with known columns to avoid building an invalid
// query early and prevent injections.
if !columns.iter().any(|c| c.name == column_name) {
let Some(column) = columns.iter().find(|c| c.name == column_name) else {
return Err(WhereClauseError::UnrecognizedParam(format!(
"Unrecognized parameter: {column_name}"
)));
};
return Ok(());
// TODO: Improve hacky error handling.
return crate::records::filter::qs_value_to_sql_with_constraints(column, value)
.map_err(|err| WhereClauseError::UnrecognizedParam(err.to_string()));
};
let (sql, params) = filter_params.into_sql(Some(table_name), &validator)?;
use trailbase_sqlite::Value;
type Param = (Cow<'static, str>, Value);
let sql_params: Vec<Param> = params
.into_iter()
.map(|(name, value)| {
return (
Cow::Owned(name),
crate::records::filter::qs_value_to_sql(value),
);
})
.collect();
let (sql, params) = filter_params.into_sql(Some(table_name), &convert)?;
return Ok(WhereClause {
clause: sql,
params: sql_params,
params: params
.into_iter()
.map(|(name, v)| (Cow::Owned(name), v))
.collect(),
});
}

View File

@@ -22,7 +22,7 @@ pub enum Filter {
Record(ValueOrComposite),
}
pub(crate) fn qs_value_to_sql(value: trailbase_qs::Value) -> rusqlite::types::Value {
fn any_qs_value_to_sql(value: trailbase_qs::Value) -> rusqlite::types::Value {
use base64::prelude::*;
use rusqlite::types::Value;
use trailbase_qs::Value as QsValue;
@@ -50,7 +50,7 @@ pub(crate) fn qs_value_to_sql_with_constraints(
return match column.data_type {
ColumnDataType::Null => Err(RecordError::BadRequest("Invalid query")),
ColumnDataType::Any => Ok(qs_value_to_sql(value)),
ColumnDataType::Any => Ok(any_qs_value_to_sql(value)),
ColumnDataType::Blob => match value {
QsValue::String(s) => Ok(Value::Blob(
BASE64_URL_SAFE

View File

@@ -10,6 +10,7 @@ readme = "../README.md"
[dependencies]
base64 = { version = "0.22.1", default-features = false, features = ["alloc"] }
rusqlite = { workspace = true }
serde = "1.0.219"
serde-value = "0.7.0"
serde_qs = "0.15.0"

View File

@@ -1,4 +1,5 @@
use base64::prelude::*;
use rusqlite::types::Value as SqlValue;
use serde::de::{Deserializer, Error};
use crate::value::Value;
@@ -57,48 +58,38 @@ pub struct ColumnOpValue {
}
impl ColumnOpValue {
pub fn into_sql(
pub fn into_sql<E>(
self,
column_prefix: Option<&str>,
convert: &dyn Fn(&str, Value) -> Result<SqlValue, E>,
index: &mut usize,
) -> (String, Option<(String, Value)>) {
) -> Result<(String, Option<(String, SqlValue)>), E> {
let v = self.value;
let c = self.column;
return match self.op {
CompareOp::Is => {
assert!(matches!(self.value, Value::String(_)), "{:?}", self.value);
assert!(matches!(v, Value::String(_)), "{v:?}");
match column_prefix {
Some(p) => (
format!(r#"{p}."{c}" IS {v}"#, c = self.column, v = self.value),
None,
),
None => (
format!(r#""{c}" IS {v}"#, c = self.column, v = self.value),
None,
),
}
Ok(match column_prefix {
Some(p) => (format!(r#"{p}."{c}" IS {v}"#), None),
None => (format!(r#""{c}" IS {v}"#), None),
})
}
_ => {
let param = param_name(*index);
*index += 1;
match column_prefix {
Ok(match column_prefix {
Some(p) => (
format!(
r#"{p}."{c}" {o} {param}"#,
c = self.column,
o = self.op.as_sql()
),
Some((param, self.value)),
format!(r#"{p}."{c}" {o} {param}"#, o = self.op.as_sql()),
Some((param, convert(&c, v)?)),
),
None => (
format!(
r#""{c}" {o} {param}"#,
c = self.column,
o = self.op.as_sql()
),
Some((param, self.value)),
format!(r#""{c}" {o} {param}"#, o = self.op.as_sql()),
Some((param, convert(&c, v)?)),
),
}
})
}
};
}

View File

@@ -7,6 +7,7 @@
/// 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 rusqlite::types::Value as SqlValue;
use std::collections::BTreeMap;
use crate::column_rel_value::{ColumnOpValue, serde_value_to_single_column_rel_value};
@@ -28,33 +29,31 @@ impl ValueOrComposite {
pub fn into_sql<E>(
self,
column_prefix: Option<&str>,
validator: &dyn Fn(&str) -> Result<(), E>,
) -> Result<(String, Vec<(String, Value)>), E> {
convert: &dyn Fn(&str, Value) -> Result<SqlValue, E>,
) -> Result<(String, Vec<(String, SqlValue)>), E> {
let mut index: usize = 0;
return self.into_sql_impl(column_prefix, validator, &mut index);
return self.into_sql_impl(column_prefix, convert, &mut index);
}
fn into_sql_impl<E>(
self,
column_prefix: Option<&str>,
validator: &dyn Fn(&str) -> Result<(), E>,
convert: &dyn Fn(&str, Value) -> Result<SqlValue, E>,
index: &mut usize,
) -> Result<(String, Vec<(String, Value)>), E> {
) -> Result<(String, Vec<(String, SqlValue)>), E> {
match self {
Self::Value(v) => {
validator(&v.column)?;
return Ok(match v.into_sql(column_prefix, index) {
return Ok(match v.into_sql(column_prefix, convert, index)? {
(sql, Some(param)) => (sql, vec![param]),
(sql, None) => (sql, vec![]),
});
}
Self::Composite(combiner, vec) => {
let mut fragments = Vec::<String>::with_capacity(vec.len());
let mut params = Vec::<(String, Value)>::with_capacity(vec.len());
let mut params = Vec::<(String, SqlValue)>::with_capacity(vec.len());
for value_or_composite in vec {
let (f, p) = value_or_composite.into_sql_impl::<E>(column_prefix, validator, index)?;
let (f, p) = value_or_composite.into_sql_impl::<E>(column_prefix, convert, index)?;
fragments.push(f);
params.extend(p);
}
@@ -280,16 +279,20 @@ mod tests {
value: Value::String("val0".to_string()),
});
let validator = |_: &str| -> Result<(), String> {
return Ok(());
let convert = |_: &str, value: Value| -> Result<SqlValue, String> {
return Ok(match value {
Value::String(s) => SqlValue::Text(s),
Value::Integer(i) => SqlValue::Integer(i),
Value::Double(d) => SqlValue::Real(d),
});
};
let sql0 = v0
.clone()
.into_sql(/* column_prefix= */ None, &validator)
.into_sql(/* column_prefix= */ None, &convert)
.unwrap();
assert_eq!(sql0.0, r#""col0" = :__p0"#);
let sql0 = v0
.into_sql(/* column_prefix= */ Some("p"), &validator)
.into_sql(/* column_prefix= */ Some("p"), &convert)
.unwrap();
assert_eq!(sql0.0, r#"p."col0" = :__p0"#);
@@ -298,7 +301,7 @@ mod tests {
op: CompareOp::Is,
value: Value::String("NULL".to_string()),
});
let sql1 = v1.into_sql(None, &validator).unwrap();
let sql1 = v1.into_sql(None, &convert).unwrap();
assert_eq!(sql1.0, r#""col0" IS NULL"#, "{sql1:?}",);
}
}

View File

@@ -203,6 +203,7 @@ impl FilterQuery {
mod tests {
use super::*;
use rusqlite::types::Value as SqlValue;
use serde_qs::Config;
use crate::column_rel_value::{ColumnOpValue, CompareOp};
@@ -395,8 +396,12 @@ mod tests {
)
);
let filter = |_: &str| -> Result<(), String> {
return Ok(());
let filter = |_: &str, value: Value| -> Result<SqlValue, String> {
return Ok(match value {
Value::String(s) => SqlValue::Text(s),
Value::Integer(i) => SqlValue::Integer(i),
Value::Double(d) => SqlValue::Real(d),
});
};
let (sql, params) = q1.filter.clone().unwrap().into_sql(None, &filter).unwrap();
assert_eq!(
@@ -406,9 +411,9 @@ mod tests {
assert_eq!(
params,
vec![
(":__p0".to_string(), Value::String("val2".to_string())),
(":__p1".to_string(), Value::String("val0".to_string())),
(":__p2".to_string(), Value::Integer(1)),
(":__p0".to_string(), SqlValue::Text("val2".to_string())),
(":__p1".to_string(), SqlValue::Text("val0".to_string())),
(":__p2".to_string(), SqlValue::Integer(1)),
]
);
let (sql, _) = q1.filter.unwrap().into_sql(Some("p"), &filter).unwrap();