feat: Implement advanced filtering with new filter conditions and a strategy-based query builder.
This commit is contained in:
@@ -120,6 +120,8 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
|
||||
value={`${cameraMake} ${cameraModel}`}
|
||||
/>
|
||||
)}
|
||||
{cameraMake && <DetailRow label="Make" value={cameraMake} />}
|
||||
{cameraModel && <DetailRow label="Model" value={cameraModel} />}
|
||||
<DetailRow label="MIME Type" value={media.mime_type} />
|
||||
<DetailRow label="File Hash" value={media.hash} isMono />
|
||||
|
||||
|
||||
@@ -18,16 +18,24 @@ const MEDIA_KEY = ["media"];
|
||||
* This uses `useInfiniteQuery` for "load more" functionality.
|
||||
*/
|
||||
export const useGetMediaList = (
|
||||
params: {
|
||||
sort_by?: string;
|
||||
order?: "asc" | "desc";
|
||||
mime_type?: string;
|
||||
} = {}
|
||||
page: number,
|
||||
limit: number,
|
||||
sortBy?: string,
|
||||
order?: 'asc' | 'desc',
|
||||
mimeType?: string,
|
||||
filters?: string[]
|
||||
) => {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [MEDIA_KEY, "list", params],
|
||||
queryKey: [MEDIA_KEY, "list", page, limit, sortBy, order, mimeType, filters],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
getMediaList({ page: pageParam, limit: 20, ...params }),
|
||||
getMediaList({
|
||||
page: pageParam,
|
||||
limit,
|
||||
sort_by: sortBy,
|
||||
order,
|
||||
mime_type: mimeType,
|
||||
filters,
|
||||
}),
|
||||
getNextPageParam: (lastPage) => {
|
||||
return lastPage.has_next_page ? lastPage.page + 1 : undefined;
|
||||
},
|
||||
|
||||
@@ -22,6 +22,13 @@ function MediaPage() {
|
||||
const [sortBy, setSortBy] = useState<string>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [mimeType, setMimeType] = useState<string | undefined>(undefined);
|
||||
const [filters, setFilters] = useState<string[]>([]);
|
||||
|
||||
// Filter input state
|
||||
const [filterField, setFilterField] = useState('original_filename');
|
||||
const [customFieldName, setCustomFieldName] = useState('');
|
||||
const [filterOperator, setFilterOperator] = useState('like');
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -30,11 +37,7 @@ function MediaPage() {
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useGetMediaList({
|
||||
sort_by: sortBy,
|
||||
order: sortOrder,
|
||||
mime_type: mimeType === "all" ? undefined : mimeType,
|
||||
});
|
||||
} = useGetMediaList(1, 20, sortBy, sortOrder, mimeType, filters);
|
||||
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||
|
||||
@@ -50,67 +53,145 @@ function MediaPage() {
|
||||
[groupedMedia]
|
||||
);
|
||||
|
||||
const handleAddFilter = () => {
|
||||
const field = filterField === 'custom' ? customFieldName : filterField;
|
||||
if (field && filterValue) {
|
||||
const newFilter = `${field}:${filterOperator}:${filterValue}`;
|
||||
setFilters([...filters, newFilter]);
|
||||
setFilterValue(''); // Clear value after adding
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFilter = (index: number) => {
|
||||
const newFilters = [...filters];
|
||||
newFilters.splice(index, 1);
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold">All Photos</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold">All Photos</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={mimeType || "all"} onValueChange={(val) => setMimeType(val === "all" ? undefined : val)}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="image/jpeg">JPEG</SelectItem>
|
||||
<SelectItem value="image/png">PNG</SelectItem>
|
||||
<SelectItem value="video/mp4">MP4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="created_at">Date Created</SelectItem>
|
||||
<SelectItem value="date_taken">Date Taken</SelectItem>
|
||||
<SelectItem value="original_filename">Filename</SelectItem>
|
||||
<SelectItem value="custom">Custom Tag...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{sortBy === "custom" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter tag name"
|
||||
className="border rounded px-2 py-1 text-sm w-[140px]"
|
||||
onBlur={(e) => {
|
||||
if (e.target.value) setSortBy(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setSortBy(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={sortOrder}
|
||||
onValueChange={(val) => setSortOrder(val as "asc" | "desc")}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Order" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="desc">Newest First</SelectItem>
|
||||
<SelectItem value="asc">Oldest First</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
<div className="flex flex-wrap gap-2 items-center border p-2 rounded bg-gray-50">
|
||||
<span className="text-sm font-semibold">Add Filter:</span>
|
||||
<Select value={filterField} onValueChange={setFilterField}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
<SelectValue placeholder="Field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="created_at">Date Created</SelectItem>
|
||||
<SelectItem value="date_taken">Date Taken</SelectItem>
|
||||
<SelectItem value="original_filename">Filename</SelectItem>
|
||||
<SelectItem value="custom">Custom Tag...</SelectItem>
|
||||
<SelectItem value="metadata.Make">Make</SelectItem>
|
||||
<SelectItem value="metadata.Model">Model</SelectItem>
|
||||
<SelectItem value="metadata.ISO">ISO</SelectItem>
|
||||
<SelectItem value="metadata.FNumber">F-Number</SelectItem>
|
||||
<SelectItem value="custom">Custom Field...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{sortBy === "custom" && (
|
||||
{filterField === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter tag name"
|
||||
className="border rounded px-2 py-1 text-sm w-[140px]"
|
||||
onBlur={(e) => {
|
||||
if (e.target.value) setSortBy(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setSortBy(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm w-[120px]"
|
||||
placeholder="Field name"
|
||||
value={customFieldName}
|
||||
onChange={(e) => setCustomFieldName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={sortOrder}
|
||||
onValueChange={(val) => setSortOrder(val as "asc" | "desc")}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Order" />
|
||||
<Select value={filterOperator} onValueChange={setFilterOperator}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Op" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="desc">Newest First</SelectItem>
|
||||
<SelectItem value="asc">Oldest First</SelectItem>
|
||||
<SelectItem value="eq">=</SelectItem>
|
||||
<SelectItem value="neq">!=</SelectItem>
|
||||
<SelectItem value="like">Like</SelectItem>
|
||||
<SelectItem value="gt">></SelectItem>
|
||||
<SelectItem value="lt"><</SelectItem>
|
||||
<SelectItem value="gte">>=</SelectItem>
|
||||
<SelectItem value="lte"><=</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={mimeType ?? "all"}
|
||||
onValueChange={(val) => setMimeType(val === "all" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Filter by Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="image/jpeg">JPEG</SelectItem>
|
||||
<SelectItem value="image/png">PNG</SelectItem>
|
||||
<SelectItem value="video/mp4">MP4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input
|
||||
type="text"
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
placeholder="Value"
|
||||
className="border rounded px-2 py-1 text-sm w-[150px]"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddFilter()}
|
||||
/>
|
||||
<Button onClick={handleAddFilter} size="sm">Add</Button>
|
||||
</div>
|
||||
|
||||
{/* Active Filters List */}
|
||||
{filters.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{filters.map((f, i) => (
|
||||
<div key={i} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm flex items-center gap-2 border border-blue-200">
|
||||
<span>{f.replace(/:/g, ' ')}</span>
|
||||
<button onClick={() => handleRemoveFilter(i)} className="text-red-500 font-bold hover:text-red-700">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <p>Loading photos...</p>}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Media, MediaDetails, PaginatedResponse } from "@/domain/types"
|
||||
import apiClient from "@/services/api-client"
|
||||
|
||||
type MediaListParams = {
|
||||
page: number;
|
||||
limit: number;
|
||||
export interface MediaListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sort_by?: string;
|
||||
order?: "asc" | "desc";
|
||||
order?: 'asc' | 'desc';
|
||||
mime_type?: string;
|
||||
filters?: string[];
|
||||
};
|
||||
|
||||
const API_PREFIX = import.meta.env.VITE_PREFIX_PATH || "";
|
||||
@@ -28,10 +29,20 @@ export const getMediaList = async ({
|
||||
sort_by,
|
||||
order,
|
||||
mime_type,
|
||||
filters,
|
||||
}: MediaListParams): Promise<PaginatedResponse<Media>> => {
|
||||
const { data } = await apiClient.get("/media", {
|
||||
params: { page, limit, sort_by, order, mime_type },
|
||||
});
|
||||
const params = new URLSearchParams();
|
||||
if (page) params.append("page", page.toString());
|
||||
if (limit) params.append("limit", limit.toString());
|
||||
if (sort_by) params.append("sort_by", sort_by);
|
||||
if (order) params.append("order", order);
|
||||
if (mime_type) params.append("mime_type", mime_type);
|
||||
|
||||
if (filters && filters.length > 0) {
|
||||
filters.forEach(f => params.append("filters", f));
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get(`/media?${params.toString()}`);
|
||||
|
||||
data.data = data.data.map(processMediaUrls);
|
||||
return data;
|
||||
|
||||
@@ -5,7 +5,8 @@ use axum::{
|
||||
use libertas_core::{
|
||||
error::CoreError,
|
||||
schema::{
|
||||
FilterParams, ListMediaOptions, MetadataFilter, PaginationParams, SortOrder, SortParams,
|
||||
FilterCondition, FilterOperator, FilterParams, ListMediaOptions, MetadataFilter,
|
||||
PaginationParams, SortOrder, SortParams,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -61,9 +62,44 @@ impl From<ListMediaParams> for ListMediaOptions {
|
||||
Some(PaginationParams { page, limit })
|
||||
};
|
||||
|
||||
let conditions = if params.filters.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut conds = Vec::new();
|
||||
for filter_str in params.filters {
|
||||
let parts: Vec<&str> = filter_str.splitn(3, ':').collect();
|
||||
if parts.len() == 3 {
|
||||
let field = parts[0].to_string();
|
||||
let op_str = parts[1];
|
||||
let value = parts[2].to_string();
|
||||
|
||||
let operator = match op_str.to_lowercase().as_str() {
|
||||
"eq" => Some(FilterOperator::Eq),
|
||||
"neq" => Some(FilterOperator::Neq),
|
||||
"like" => Some(FilterOperator::Like),
|
||||
"gt" => Some(FilterOperator::Gt),
|
||||
"lt" => Some(FilterOperator::Lt),
|
||||
"gte" => Some(FilterOperator::Gte),
|
||||
"lte" => Some(FilterOperator::Lte),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(op) = operator {
|
||||
conds.push(FilterCondition {
|
||||
field,
|
||||
operator: op,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if conds.is_empty() { None } else { Some(conds) }
|
||||
};
|
||||
|
||||
let filter = Some(FilterParams {
|
||||
mime_type: params.mime_type,
|
||||
metadata_filters,
|
||||
conditions,
|
||||
});
|
||||
|
||||
ListMediaOptions {
|
||||
@@ -81,10 +117,33 @@ impl FromRequestParts<AppState> for ApiListMediaOptions {
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Query(params) = Query::<ListMediaParams>::from_request_parts(parts, state)
|
||||
let Query(raw_params) = Query::<Vec<(String, String)>>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|e| ApiError::from(CoreError::Validation(e.to_string())))?;
|
||||
|
||||
let mut params = ListMediaParams {
|
||||
sort_by: None,
|
||||
order: None,
|
||||
mime_type: None,
|
||||
metadata: Vec::new(),
|
||||
filters: Vec::new(),
|
||||
page: None,
|
||||
limit: None,
|
||||
};
|
||||
|
||||
for (key, value) in raw_params {
|
||||
match key.as_str() {
|
||||
"sort_by" => params.sort_by = Some(value),
|
||||
"order" => params.order = Some(value),
|
||||
"mime_type" => params.mime_type = Some(value),
|
||||
"metadata" => params.metadata.push(value),
|
||||
"filters" => params.filters.push(value),
|
||||
"page" => params.page = value.parse().ok(),
|
||||
"limit" => params.limit = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ApiListMediaOptions(params.into()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ pub struct ListMediaParams {
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub filters: Vec<String>,
|
||||
pub page: Option<u32>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ pub struct SortParams {
|
||||
pub struct FilterParams {
|
||||
pub mime_type: Option<String>,
|
||||
pub metadata_filters: Option<Vec<MetadataFilter>>,
|
||||
pub conditions: Option<Vec<FilterCondition>>,
|
||||
// In the future, we can add fields like:
|
||||
// pub date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
|
||||
}
|
||||
@@ -82,6 +83,24 @@ pub struct MetadataFilter {
|
||||
pub tag_value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FilterCondition {
|
||||
pub field: String,
|
||||
pub operator: FilterOperator,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FilterOperator {
|
||||
Eq,
|
||||
Neq,
|
||||
Like,
|
||||
Gt,
|
||||
Lt,
|
||||
Gte,
|
||||
Lte,
|
||||
}
|
||||
|
||||
pub struct MediaImportBundle {
|
||||
pub media_model: Media,
|
||||
pub metadata_models: Vec<MediaMetadata>,
|
||||
|
||||
@@ -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