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:
@@ -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 ");
|
||||
}
|
||||
|
||||
@@ -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 ");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user