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:
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);
+ }
}