feat: Implement flexible media sorting by standard columns and metadata tags by refactoring backend query building and updating frontend API parameters.

This commit is contained in:
2025-12-03 22:53:27 +01:00
parent c8403d70da
commit 15177f218b
7 changed files with 249 additions and 52 deletions

View File

@@ -4,6 +4,98 @@ use libertas_core::{
};
use sqlx::QueryBuilder as SqlxQueryBuilder;
pub trait SortStrategy: Send + Sync {
fn can_handle(&self, column: &str) -> bool;
fn apply_join<'a>(
&self,
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
column: &'a str,
) -> CoreResult<()>;
fn apply_sort<'a>(
&self,
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
column: &'a str,
direction: &str,
) -> CoreResult<()>;
}
pub struct StandardSortStrategy {
allowed_columns: Vec<String>,
}
impl StandardSortStrategy {
pub fn new(allowed_columns: Vec<String>) -> Self {
Self { allowed_columns }
}
}
impl SortStrategy for StandardSortStrategy {
fn can_handle(&self, column: &str) -> bool {
self.allowed_columns.contains(&column.to_string())
}
fn apply_join<'a>(
&self,
_query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
_column: &'a str,
) -> CoreResult<()> {
Ok(())
}
fn apply_sort<'a>(
&self,
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
column: &'a str,
direction: &str,
) -> CoreResult<()> {
let nulls_order = if direction == "ASC" {
"NULLS LAST"
} else {
"NULLS FIRST"
};
let order_by_clause = format!(" ORDER BY {} {} {}", column, direction, nulls_order);
query.push(order_by_clause);
Ok(())
}
}
pub struct MetadataSortStrategy;
impl SortStrategy for MetadataSortStrategy {
fn can_handle(&self, _column: &str) -> bool {
true // Handles everything else
}
fn apply_join<'a>(
&self,
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
column: &'a str,
) -> CoreResult<()> {
// Join with media_metadata to sort by tag value
query.push(" LEFT JOIN media_metadata sort_mm ON media.id = sort_mm.media_id AND sort_mm.tag_name = ");
query.push_bind(column);
Ok(())
}
fn apply_sort<'a>(
&self,
query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>,
_column: &'a str,
direction: &str,
) -> CoreResult<()> {
let nulls_order = if direction == "ASC" {
"NULLS LAST"
} 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 QueryBuilder<T> {
fn apply_options_to_query<'a>(
&self,
@@ -13,28 +105,40 @@ pub trait QueryBuilder<T> {
}
pub struct MediaQueryBuilder {
allowed_sort_columns: Vec<String>,
sort_strategies: Vec<Box<dyn SortStrategy>>,
}
impl MediaQueryBuilder {
pub fn new(allowed_sort_columns: Vec<String>) -> Self {
pub fn new(sort_strategies: Vec<Box<dyn SortStrategy>>) -> Self {
Self {
allowed_sort_columns,
sort_strategies,
}
}
fn validate_sort_column<'a>(&self, column: &'a str) -> CoreResult<&'a str> {
if self.allowed_sort_columns.contains(&column.to_string()) {
Ok(column)
} else {
Err(CoreError::Validation(format!(
"Sorting by '{}' is not supported",
column
)))
pub fn apply_joins<'a>(
&self,
mut query: SqlxQueryBuilder<'a, sqlx::Postgres>,
options: &'a ListMediaOptions,
) -> CoreResult<SqlxQueryBuilder<'a, sqlx::Postgres>> {
if let Some(filter) = &options.filter {
if let Some(metadata_filters) = &filter.metadata_filters {
if !metadata_filters.is_empty() {
query.push(" JOIN media_metadata mm ON media.id = mm.media_id ");
}
}
}
if let Some(sort) = &options.sort {
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)?;
}
}
Ok(query)
}
pub fn apply_filters_to_query<'a>(
pub fn apply_conditions<'a>(
&self,
mut query: SqlxQueryBuilder<'a, sqlx::Postgres>,
options: &'a ListMediaOptions,
@@ -49,7 +153,6 @@ impl MediaQueryBuilder {
if let Some(metadata_filters) = &filter.metadata_filters {
if !metadata_filters.is_empty() {
metadata_filter_count = metadata_filters.len() as i64;
query.push(" JOIN media_metadata mm ON media.id = mm.media_id ");
query.push(" AND ( ");
for (i, filter) in metadata_filters.iter().enumerate() {
@@ -75,18 +178,19 @@ impl MediaQueryBuilder {
options: &'a ListMediaOptions,
) -> CoreResult<SqlxQueryBuilder<'a, sqlx::Postgres>> {
if let Some(sort) = &options.sort {
let column = self.validate_sort_column(&sort.sort_by)?;
let direction = match sort.sort_order {
SortOrder::Asc => "ASC",
SortOrder::Desc => "DESC",
};
let nulls_order = if direction == "ASC" {
"NULLS LAST"
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 {
"NULLS FIRST"
};
let order_by_clause = format!("ORDER BY {} {} {}", column, direction, nulls_order);
query.push(order_by_clause);
// 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

@@ -21,14 +21,21 @@ pub struct PostgresMediaRepository {
impl PostgresMediaRepository {
pub fn new(pool: PgPool, config: &AppConfig) -> Self {
let allowed_columns = config
let mut allowed_columns = config
.allowed_sort_columns
.clone()
.unwrap_or_else(|| vec!["created_at".to_string(), "original_filename".to_string()]);
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)),
Box::new(crate::query_builder::MetadataSortStrategy),
];
Self {
pool,
query_builder: Arc::new(MediaQueryBuilder::new(allowed_columns)),
query_builder: Arc::new(MediaQueryBuilder::new(strategies)),
}
}
@@ -107,12 +114,15 @@ impl MediaRepository for PostgresMediaRepository {
) -> CoreResult<(Vec<Media>, i64)> {
let count_base_sql = "SELECT COUNT(DISTINCT media.id) as total FROM media";
let mut count_query = sqlx::QueryBuilder::new(count_base_sql);
count_query = self.query_builder.apply_joins(count_query, options)?;
count_query.push(" WHERE media.owner_id = ");
count_query.push_bind(user_id);
let (mut count_query, metadata_filter_count) = self
.query_builder
.apply_filters_to_query(count_query, options)?;
.apply_conditions(count_query, options)?;
if metadata_filter_count > 0 {
count_query.push(" GROUP BY media.id ");
@@ -133,12 +143,15 @@ impl MediaRepository for PostgresMediaRepository {
let data_base_sql = "SELECT media.id, media.owner_id, media.storage_path, media.original_filename, media.mime_type, media.hash, media.created_at, media.thumbnail_path, media.date_taken FROM media";
let mut data_query = sqlx::QueryBuilder::new(data_base_sql);
data_query = self.query_builder.apply_joins(data_query, options)?;
data_query.push(" WHERE media.owner_id = ");
data_query.push_bind(user_id);
let (mut data_query, metadata_filter_count) = self
.query_builder
.apply_filters_to_query(data_query, options)?;
.apply_conditions(data_query, options)?;
if metadata_filter_count > 0 {
data_query.push(" GROUP BY media.id ");
@@ -174,12 +187,15 @@ impl MediaRepository for PostgresMediaRepository {
JOIN face_regions fr ON media.id = fr.media_id
";
let mut count_query = sqlx::QueryBuilder::new(count_base_sql);
count_query = self.query_builder.apply_joins(count_query, options)?;
count_query.push(" WHERE fr.person_id = ");
count_query.push_bind(person_id);
let (mut count_query, _metadata_filter_count) = self
.query_builder
.apply_filters_to_query(count_query, options)?;
.apply_conditions(count_query, options)?;
let total_items_result = count_query
.build_query_scalar()
@@ -195,12 +211,15 @@ impl MediaRepository for PostgresMediaRepository {
JOIN face_regions fr ON media.id = fr.media_id
";
let mut data_query = sqlx::QueryBuilder::new(data_base_sql);
data_query = self.query_builder.apply_joins(data_query, options)?;
data_query.push(" WHERE fr.person_id = ");
data_query.push_bind(person_id);
let (mut data_query, _metadata_filter_count) = self
.query_builder
.apply_filters_to_query(data_query, options)?;
.apply_conditions(data_query, options)?;
data_query.push(" GROUP BY media.id ");