app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit eac72d974c47c02b79803145c10acdf3344423a8
parent 895a304648cad059f9eb4e1f71c1899358286c86
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 07:28:22 +0000

app-utils: add model query builder

- add model filter map structures
- add parse_model_filter_map helper
- add query condition handling helpers
- add unit tests for query parsing

Diffstat:
Mcrates/utils/src/lib.rs | 9+++++----
Mcrates/utils/src/model/mod.rs | 169++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 173 insertions(+), 5 deletions(-)

diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs @@ -32,10 +32,11 @@ pub use id::{d_tag_create, uuidv4, uuidv4_b64url, uuidv7, uuidv7_b64url}; pub use media::{fmt_media_image_upload_result_url, MediaImageUploadResult, MediaResource}; pub use model::{ is_model_query_filter_option, is_model_query_filter_option_list, is_model_query_values, - list_model_query_values_assert, parse_model_query_value, ModelForm, ModelFormErrorTuple, - ModelFormValidationTuple, ModelQueryBindValue, ModelQueryBindValueOpt, ModelQueryBindValueTuple, - ModelQueryFilterCondition, ModelQueryFilterOption, ModelQueryFilterOptionList, - ModelQueryParam, ModelQueryValue, ModelSchemaErrors, ModelSortCreatedAt, + list_model_query_values_assert, parse_model_filter_map, parse_model_query_value, ModelForm, + ModelFormErrorTuple, ModelFormValidationTuple, ModelQueryBindValue, ModelQueryBindValueOpt, + ModelQueryBindValueTuple, ModelQueryFilter, ModelQueryFilterCondition, ModelQueryFilterMap, + ModelQueryFilterMapParsed, ModelQueryFilterOption, ModelQueryFilterOptionList, ModelQueryParam, + ModelQueryValue, ModelSchemaErrors, ModelSortCreatedAt, }; pub use errors::{err_msg, handle_err, throw_err, ERR_PREFIX_APP, ERR_PREFIX_UTILS}; pub use numbers::{num_interval_range, num_str, parse_float, parse_int}; diff --git a/crates/utils/src/model/mod.rs b/crates/utils/src/model/mod.rs @@ -1,6 +1,8 @@ #![forbid(unsafe_code)] use crate::types::ValidationRegex; +use crate::error::RadrootsAppUtilsError; +use std::collections::BTreeMap; #[derive(Debug, Clone, PartialEq)] pub enum ModelQueryValue { @@ -74,6 +76,29 @@ pub struct ModelForm { pub rxpv: ValidationRegex, } +#[derive(Debug, Clone, PartialEq)] +pub enum ModelQueryFilter { + Value(ModelQueryValue), + Single { + value: ModelQueryValue, + option: ModelQueryFilterOption, + condition: Option<ModelQueryFilterCondition>, + }, + List { + values: Vec<ModelQueryValue>, + option: ModelQueryFilterOptionList, + condition: Option<ModelQueryFilterCondition>, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ModelQueryFilterMapParsed { + pub query_values: Vec<String>, + pub bind_values: Vec<ModelQueryBindValue>, +} + +pub type ModelQueryFilterMap = BTreeMap<String, ModelQueryFilter>; + pub fn parse_model_query_value(value: &ModelQueryValue) -> ModelQueryBindValue { match value { ModelQueryValue::Bool(true) => ModelQueryBindValue::String("1".to_string()), @@ -109,11 +134,133 @@ pub fn list_model_query_values_assert(values: &[Option<ModelQueryValue>]) -> Vec values.iter().filter_map(|value| value.clone()).collect() } +pub fn parse_model_filter_map( + filters: &ModelQueryFilterMap, +) -> Result<ModelQueryFilterMapParsed, RadrootsAppUtilsError> { + let mut bind_values = Vec::new(); + let mut query_values = Vec::new(); + for (index, (field, filter)) in filters.iter().enumerate() { + let filter_condition = if index == 0 { + String::new() + } else if let Some(condition) = filter_condition_for(filter) { + format!("{} ", condition.as_str()) + } else { + "AND ".to_string() + }; + match filter { + ModelQueryFilter::Value(value) => { + query_values.push(format!("{filter_condition}{field} = ?")); + bind_values.push(parse_model_query_value(value)); + } + ModelQueryFilter::Single { + value, + option, + .. + } => match option { + ModelQueryFilterOption::StartsWith => { + query_values.push(format!("{filter_condition}{field} LIKE ?")); + bind_values.push(ModelQueryBindValue::String(format!( + "{}%", + value_to_string(value) + ))); + } + ModelQueryFilterOption::EndsWith => { + query_values.push(format!("{filter_condition}{field} LIKE ?")); + bind_values.push(ModelQueryBindValue::String(format!( + "%{}", + value_to_string(value) + ))); + } + ModelQueryFilterOption::Contains => { + query_values.push(format!("{filter_condition}{field} LIKE ?")); + bind_values.push(ModelQueryBindValue::String(format!( + "%{}%", + value_to_string(value) + ))); + } + ModelQueryFilterOption::NotEquals => { + query_values.push(format!("{filter_condition}{field} != ?")); + bind_values.push(parse_model_query_value(value)); + } + ModelQueryFilterOption::Equals => { + query_values.push(format!("{filter_condition}{field} = ?")); + bind_values.push(parse_model_query_value(value)); + } + }, + ModelQueryFilter::List { + values, + option, + .. + } => match option { + ModelQueryFilterOptionList::Between => { + if values.len() < 2 { + return Err(RadrootsAppUtilsError::InvalidInput); + } + query_values.push(format!("{filter_condition}{field} BETWEEN ? AND ?")); + bind_values.push(parse_model_query_value(&values[0])); + bind_values.push(parse_model_query_value(&values[1])); + } + ModelQueryFilterOptionList::In => { + if values.is_empty() { + return Err(RadrootsAppUtilsError::InvalidInput); + } + let placeholders = std::iter::repeat("?") + .take(values.len()) + .collect::<Vec<_>>() + .join(", "); + query_values.push(format!( + "{filter_condition}{field} IN ({placeholders})" + )); + for value in values { + bind_values.push(parse_model_query_value(value)); + } + } + }, + } + } + if query_values.is_empty() || bind_values.is_empty() { + return Err(RadrootsAppUtilsError::InvalidInput); + } + Ok(ModelQueryFilterMapParsed { + query_values, + bind_values, + }) +} + +fn value_to_string(value: &ModelQueryValue) -> String { + match value { + ModelQueryValue::String(value) => value.clone(), + ModelQueryValue::Number(value) => value.to_string(), + ModelQueryValue::Bool(true) => "1".to_string(), + ModelQueryValue::Bool(false) => "0".to_string(), + ModelQueryValue::Null => String::new(), + } +} + +fn filter_condition_for(filter: &ModelQueryFilter) -> Option<ModelQueryFilterCondition> { + match filter { + ModelQueryFilter::Value(_) => None, + ModelQueryFilter::Single { condition, .. } => *condition, + ModelQueryFilter::List { condition, .. } => *condition, + } +} + +impl ModelQueryFilterCondition { + pub const fn as_str(self) -> &'static str { + match self { + ModelQueryFilterCondition::And => "and", + ModelQueryFilterCondition::Or => "or", + ModelQueryFilterCondition::Not => "not", + } + } +} + #[cfg(test)] mod tests { use super::{ is_model_query_filter_option, is_model_query_filter_option_list, is_model_query_values, - list_model_query_values_assert, parse_model_query_value, ModelQueryBindValue, + list_model_query_values_assert, parse_model_filter_map, parse_model_query_value, + ModelQueryBindValue, ModelQueryFilter, ModelQueryFilterMap, ModelQueryFilterOption, ModelQueryValue, }; @@ -171,4 +318,24 @@ mod tests { ] ); } + + #[test] + fn parse_model_filter_map_builds_query() { + let mut filters = ModelQueryFilterMap::new(); + filters.insert( + "name".to_string(), + ModelQueryFilter::Single { + value: ModelQueryValue::String("rad".to_string()), + option: ModelQueryFilterOption::Contains, + condition: None, + }, + ); + filters.insert( + "status".to_string(), + ModelQueryFilter::Value(ModelQueryValue::String("ok".to_string())), + ); + let parsed = parse_model_filter_map(&filters).expect("parsed"); + assert_eq!(parsed.query_values.len(), 2); + assert_eq!(parsed.bind_values.len(), 2); + } }