feat: Implement advanced filtering with new filter conditions and a strategy-based query builder.
This commit is contained in:
@@ -88,10 +88,187 @@ impl SortStrategy for MetadataSortStrategy {
|
||||
} else {
|
||||
"NULLS FIRST"
|
||||
};
|
||||
|
||||
|
||||
let order_by_clause = format!(" ORDER BY sort_mm.tag_value {} {}", direction, nulls_order);
|
||||
query.push(order_by_clause);
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait FilterStrategy: Send + Sync {
|
||||
fn can_handle(&self, field: &str) -> bool;
|
||||
fn apply_join<'a>(
|
||||
&self,
|
||||
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
|
||||
field: &'a str,
|
||||
) -> CoreResult<()>;
|
||||
fn apply_condition<'a>(
|
||||
&self,
|
||||
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
|
||||
condition: &'a libertas_core::schema::FilterCondition,
|
||||
) -> CoreResult<()>;
|
||||
}
|
||||
|
||||
pub struct StandardFilterStrategy {
|
||||
allowed_columns: Vec<String>,
|
||||
}
|
||||
|
||||
impl StandardFilterStrategy {
|
||||
pub fn new(allowed_columns: Vec<String>) -> Self {
|
||||
Self { allowed_columns }
|
||||
}
|
||||
}
|
||||
|
||||
impl FilterStrategy for StandardFilterStrategy {
|
||||
fn can_handle(&self, field: &str) -> bool {
|
||||
self.allowed_columns.contains(&field.to_string())
|
||||
}
|
||||
|
||||
fn apply_join<'a>(
|
||||
&self,
|
||||
_query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
|
||||
_field: &'a str,
|
||||
) -> CoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_condition<'a>(
|
||||
&self,
|
||||
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
|
||||
condition: &'a libertas_core::schema::FilterCondition,
|
||||
) -> CoreResult<()> {
|
||||
use libertas_core::schema::FilterOperator;
|
||||
|
||||
let is_timestamp =
|
||||
["date_taken", "created_at", "updated_at"].contains(&condition.field.as_str());
|
||||
let is_year = condition.value.len() == 4 && condition.value.chars().all(char::is_numeric);
|
||||
|
||||
if is_timestamp && is_year {
|
||||
match condition.operator {
|
||||
FilterOperator::Eq => {
|
||||
query.push(format!(
|
||||
" AND EXTRACT(YEAR FROM media.{}) = ",
|
||||
condition.field
|
||||
));
|
||||
query.push_bind(condition.value.parse::<i32>().unwrap_or(0));
|
||||
return Ok(());
|
||||
}
|
||||
FilterOperator::Neq => {
|
||||
query.push(format!(
|
||||
" AND EXTRACT(YEAR FROM media.{}) != ",
|
||||
condition.field
|
||||
));
|
||||
query.push_bind(condition.value.parse::<i32>().unwrap_or(0));
|
||||
return Ok(());
|
||||
}
|
||||
FilterOperator::Gt => {
|
||||
query.push(format!(" AND media.{} > ", condition.field));
|
||||
query.push_bind(format!("{}-12-31 23:59:59.999Z", condition.value));
|
||||
query.push("::timestamptz");
|
||||
return Ok(());
|
||||
}
|
||||
FilterOperator::Lt => {
|
||||
query.push(format!(" AND media.{} < ", condition.field));
|
||||
query.push_bind(format!("{}-01-01 00:00:00.000Z", condition.value));
|
||||
query.push("::timestamptz");
|
||||
return Ok(());
|
||||
}
|
||||
FilterOperator::Gte => {
|
||||
query.push(format!(" AND media.{} >= ", condition.field));
|
||||
query.push_bind(format!("{}-01-01 00:00:00.000Z", condition.value));
|
||||
query.push("::timestamptz");
|
||||
return Ok(());
|
||||
}
|
||||
FilterOperator::Lte => {
|
||||
query.push(format!(" AND media.{} <= ", condition.field));
|
||||
query.push_bind(format!("{}-12-31 23:59:59.999Z", condition.value));
|
||||
query.push("::timestamptz");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {} // Fallthrough for Like
|
||||
}
|
||||
}
|
||||
|
||||
let op = match condition.operator {
|
||||
FilterOperator::Eq => "=",
|
||||
FilterOperator::Neq => "!=",
|
||||
FilterOperator::Like => "ILIKE",
|
||||
FilterOperator::Gt => ">",
|
||||
FilterOperator::Lt => "<",
|
||||
FilterOperator::Gte => ">=",
|
||||
FilterOperator::Lte => "<=",
|
||||
};
|
||||
|
||||
query.push(format!(" AND media.{} {} ", condition.field, op));
|
||||
|
||||
if condition.operator == FilterOperator::Like {
|
||||
query.push_bind(format!("%{}%", condition.value));
|
||||
} else {
|
||||
query.push_bind(&condition.value);
|
||||
}
|
||||
|
||||
if is_timestamp {
|
||||
query.push("::timestamptz");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MetadataFilterStrategy;
|
||||
|
||||
impl FilterStrategy for MetadataFilterStrategy {
|
||||
fn can_handle(&self, field: &str) -> bool {
|
||||
field.starts_with("metadata.")
|
||||
}
|
||||
|
||||
fn apply_join<'a>(
|
||||
&self,
|
||||
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
|
||||
field: &'a str,
|
||||
) -> CoreResult<()> {
|
||||
// Alias based on field name to allow multiple metadata filters
|
||||
// e.g. metadata.Camera -> filter_metadata_Camera
|
||||
let alias = format!("filter_{}", field.replace(".", "_"));
|
||||
let tag_name = field.strip_prefix("metadata.").unwrap_or(field);
|
||||
|
||||
query.push(format!(
|
||||
" JOIN media_metadata {} ON media.id = {}.media_id AND {}.tag_name = ",
|
||||
alias, alias, alias
|
||||
));
|
||||
query.push_bind(tag_name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_condition<'a>(
|
||||
&self,
|
||||
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
|
||||
condition: &'a libertas_core::schema::FilterCondition,
|
||||
) -> CoreResult<()> {
|
||||
use libertas_core::schema::FilterOperator;
|
||||
|
||||
let alias = format!("filter_{}", condition.field.replace(".", "_"));
|
||||
|
||||
let op = match condition.operator {
|
||||
FilterOperator::Eq => "=",
|
||||
FilterOperator::Neq => "!=",
|
||||
FilterOperator::Like => "ILIKE",
|
||||
FilterOperator::Gt => ">",
|
||||
FilterOperator::Lt => "<",
|
||||
FilterOperator::Gte => ">=",
|
||||
FilterOperator::Lte => "<=",
|
||||
};
|
||||
|
||||
query.push(format!(" AND {}.tag_value {} ", alias, op));
|
||||
|
||||
if condition.operator == FilterOperator::Like {
|
||||
query.push_bind(format!("%{}%", condition.value));
|
||||
} else {
|
||||
query.push_bind(&condition.value);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -106,12 +283,17 @@ pub trait QueryBuilder<T> {
|
||||
|
||||
pub struct MediaQueryBuilder {
|
||||
sort_strategies: Vec<Box<dyn SortStrategy>>,
|
||||
filter_strategies: Vec<Box<dyn FilterStrategy>>,
|
||||
}
|
||||
|
||||
impl MediaQueryBuilder {
|
||||
pub fn new(sort_strategies: Vec<Box<dyn SortStrategy>>) -> Self {
|
||||
pub fn new(
|
||||
sort_strategies: Vec<Box<dyn SortStrategy>>,
|
||||
filter_strategies: Vec<Box<dyn FilterStrategy>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sort_strategies,
|
||||
filter_strategies,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +308,30 @@ impl MediaQueryBuilder {
|
||||
query.push(" JOIN media_metadata mm ON media.id = mm.media_id ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(conditions) = &filter.conditions {
|
||||
let mut joined_fields = std::collections::HashSet::new();
|
||||
for condition in conditions {
|
||||
if joined_fields.contains(&condition.field) {
|
||||
continue;
|
||||
}
|
||||
let strategy = self
|
||||
.filter_strategies
|
||||
.iter()
|
||||
.find(|s| s.can_handle(&condition.field));
|
||||
if let Some(strategy) = strategy {
|
||||
strategy.apply_join(&mut query, &condition.field)?;
|
||||
joined_fields.insert(condition.field.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sort) = &options.sort {
|
||||
let strategy = self.sort_strategies.iter().find(|s| s.can_handle(&sort.sort_by));
|
||||
let strategy = self
|
||||
.sort_strategies
|
||||
.iter()
|
||||
.find(|s| s.can_handle(&sort.sort_by));
|
||||
if let Some(strategy) = strategy {
|
||||
strategy.apply_join(&mut query, &sort.sort_by)?;
|
||||
}
|
||||
@@ -168,6 +370,18 @@ impl MediaQueryBuilder {
|
||||
query.push(" ) ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(conditions) = &filter.conditions {
|
||||
for condition in conditions {
|
||||
let strategy = self
|
||||
.filter_strategies
|
||||
.iter()
|
||||
.find(|s| s.can_handle(&condition.field));
|
||||
if let Some(strategy) = strategy {
|
||||
strategy.apply_condition(&mut query, condition)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((query, metadata_filter_count))
|
||||
}
|
||||
@@ -183,13 +397,19 @@ impl MediaQueryBuilder {
|
||||
SortOrder::Desc => "DESC",
|
||||
};
|
||||
|
||||
let strategy = self.sort_strategies.iter().find(|s| s.can_handle(&sort.sort_by));
|
||||
|
||||
let strategy = self
|
||||
.sort_strategies
|
||||
.iter()
|
||||
.find(|s| s.can_handle(&sort.sort_by));
|
||||
|
||||
if let Some(strategy) = strategy {
|
||||
strategy.apply_sort(&mut query, &sort.sort_by, direction)?;
|
||||
} else {
|
||||
// Should not happen if we have a default/catch-all strategy, but good to handle
|
||||
return Err(CoreError::Validation(format!("No sort strategy found for column: {}", sort.sort_by)));
|
||||
// Should not happen if we have a default/catch-all strategy, but good to handle
|
||||
return Err(CoreError::Validation(format!(
|
||||
"No sort strategy found for column: {}",
|
||||
sort.sort_by
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
query.push(" ORDER BY media.created_at DESC NULLS LAST ");
|
||||
|
||||
@@ -28,14 +28,19 @@ impl PostgresMediaRepository {
|
||||
|
||||
allowed_columns.push("date_taken".to_string());
|
||||
|
||||
let strategies: Vec<Box<dyn crate::query_builder::SortStrategy>> = vec![
|
||||
Box::new(crate::query_builder::StandardSortStrategy::new(allowed_columns)),
|
||||
let sort_strategies: Vec<Box<dyn crate::query_builder::SortStrategy>> = vec![
|
||||
Box::new(crate::query_builder::StandardSortStrategy::new(allowed_columns.clone())),
|
||||
Box::new(crate::query_builder::MetadataSortStrategy),
|
||||
];
|
||||
|
||||
let filter_strategies: Vec<Box<dyn crate::query_builder::FilterStrategy>> = vec![
|
||||
Box::new(crate::query_builder::StandardFilterStrategy::new(allowed_columns)),
|
||||
Box::new(crate::query_builder::MetadataFilterStrategy),
|
||||
];
|
||||
|
||||
Self {
|
||||
pool,
|
||||
query_builder: Arc::new(MediaQueryBuilder::new(strategies)),
|
||||
query_builder: Arc::new(MediaQueryBuilder::new(sort_strategies, filter_strategies)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user