feat: Implement advanced filtering with new filter conditions and a strategy-based query builder.

This commit is contained in:
2025-12-03 23:39:47 +01:00
parent 15177f218b
commit 333c180b17
9 changed files with 481 additions and 74 deletions

View File

@@ -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 ");

View File

@@ -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)),
}
}