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}`}
|
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="MIME Type" value={media.mime_type} />
|
||||||
<DetailRow label="File Hash" value={media.hash} isMono />
|
<DetailRow label="File Hash" value={media.hash} isMono />
|
||||||
|
|
||||||
|
|||||||
@@ -18,16 +18,24 @@ const MEDIA_KEY = ["media"];
|
|||||||
* This uses `useInfiniteQuery` for "load more" functionality.
|
* This uses `useInfiniteQuery` for "load more" functionality.
|
||||||
*/
|
*/
|
||||||
export const useGetMediaList = (
|
export const useGetMediaList = (
|
||||||
params: {
|
page: number,
|
||||||
sort_by?: string;
|
limit: number,
|
||||||
order?: "asc" | "desc";
|
sortBy?: string,
|
||||||
mime_type?: string;
|
order?: 'asc' | 'desc',
|
||||||
} = {}
|
mimeType?: string,
|
||||||
|
filters?: string[]
|
||||||
) => {
|
) => {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: [MEDIA_KEY, "list", params],
|
queryKey: [MEDIA_KEY, "list", page, limit, sortBy, order, mimeType, filters],
|
||||||
queryFn: ({ pageParam = 1 }) =>
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
getMediaList({ page: pageParam, limit: 20, ...params }),
|
getMediaList({
|
||||||
|
page: pageParam,
|
||||||
|
limit,
|
||||||
|
sort_by: sortBy,
|
||||||
|
order,
|
||||||
|
mime_type: mimeType,
|
||||||
|
filters,
|
||||||
|
}),
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => {
|
||||||
return lastPage.has_next_page ? lastPage.page + 1 : undefined;
|
return lastPage.has_next_page ? lastPage.page + 1 : undefined;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ function MediaPage() {
|
|||||||
const [sortBy, setSortBy] = useState<string>("created_at");
|
const [sortBy, setSortBy] = useState<string>("created_at");
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||||
const [mimeType, setMimeType] = useState<string | undefined>(undefined);
|
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 {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -30,11 +37,7 @@ function MediaPage() {
|
|||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useGetMediaList({
|
} = useGetMediaList(1, 20, sortBy, sortOrder, mimeType, filters);
|
||||||
sort_by: sortBy,
|
|
||||||
order: sortOrder,
|
|
||||||
mime_type: mimeType === "all" ? undefined : mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||||
|
|
||||||
@@ -50,67 +53,145 @@ function MediaPage() {
|
|||||||
[groupedMedia]
|
[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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="text-3xl font-bold">All Photos</h1>
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<h1 className="text-3xl font-bold">All Photos</h1>
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
<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]">
|
<SelectTrigger className="w-[140px]">
|
||||||
<SelectValue placeholder="Sort by" />
|
<SelectValue placeholder="Field" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="created_at">Date Created</SelectItem>
|
|
||||||
<SelectItem value="date_taken">Date Taken</SelectItem>
|
|
||||||
<SelectItem value="original_filename">Filename</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{sortBy === "custom" && (
|
{filterField === 'custom' && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
className="border rounded px-2 py-1 text-sm w-[120px]"
|
||||||
placeholder="Enter tag name"
|
placeholder="Field name"
|
||||||
className="border rounded px-2 py-1 text-sm w-[140px]"
|
value={customFieldName}
|
||||||
onBlur={(e) => {
|
onChange={(e) => setCustomFieldName(e.target.value)}
|
||||||
if (e.target.value) setSortBy(e.target.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
setSortBy(e.currentTarget.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Select
|
<Select value={filterOperator} onValueChange={setFilterOperator}>
|
||||||
value={sortOrder}
|
<SelectTrigger className="w-[100px]">
|
||||||
onValueChange={(val) => setSortOrder(val as "asc" | "desc")}
|
<SelectValue placeholder="Op" />
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="Order" />
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="desc">Newest First</SelectItem>
|
<SelectItem value="eq">=</SelectItem>
|
||||||
<SelectItem value="asc">Oldest First</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select
|
<input
|
||||||
value={mimeType ?? "all"}
|
type="text"
|
||||||
onValueChange={(val) => setMimeType(val === "all" ? undefined : val)}
|
value={filterValue}
|
||||||
>
|
onChange={(e) => setFilterValue(e.target.value)}
|
||||||
<SelectTrigger className="w-[140px]">
|
placeholder="Value"
|
||||||
<SelectValue placeholder="Filter by Type" />
|
className="border rounded px-2 py-1 text-sm w-[150px]"
|
||||||
</SelectTrigger>
|
onKeyDown={(e) => e.key === 'Enter' && handleAddFilter()}
|
||||||
<SelectContent>
|
/>
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<Button onClick={handleAddFilter} size="sm">Add</Button>
|
||||||
<SelectItem value="image/jpeg">JPEG</SelectItem>
|
|
||||||
<SelectItem value="image/png">PNG</SelectItem>
|
|
||||||
<SelectItem value="video/mp4">MP4</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{isLoading && <p>Loading photos...</p>}
|
{isLoading && <p>Loading photos...</p>}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Media, MediaDetails, PaginatedResponse } from "@/domain/types"
|
import type { Media, MediaDetails, PaginatedResponse } from "@/domain/types"
|
||||||
import apiClient from "@/services/api-client"
|
import apiClient from "@/services/api-client"
|
||||||
|
|
||||||
type MediaListParams = {
|
export interface MediaListParams {
|
||||||
page: number;
|
page?: number;
|
||||||
limit: number;
|
limit?: number;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
order?: "asc" | "desc";
|
order?: 'asc' | 'desc';
|
||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
|
filters?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_PREFIX = import.meta.env.VITE_PREFIX_PATH || "";
|
const API_PREFIX = import.meta.env.VITE_PREFIX_PATH || "";
|
||||||
@@ -28,10 +29,20 @@ export const getMediaList = async ({
|
|||||||
sort_by,
|
sort_by,
|
||||||
order,
|
order,
|
||||||
mime_type,
|
mime_type,
|
||||||
|
filters,
|
||||||
}: MediaListParams): Promise<PaginatedResponse<Media>> => {
|
}: MediaListParams): Promise<PaginatedResponse<Media>> => {
|
||||||
const { data } = await apiClient.get("/media", {
|
const params = new URLSearchParams();
|
||||||
params: { page, limit, sort_by, order, mime_type },
|
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);
|
data.data = data.data.map(processMediaUrls);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use axum::{
|
|||||||
use libertas_core::{
|
use libertas_core::{
|
||||||
error::CoreError,
|
error::CoreError,
|
||||||
schema::{
|
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 })
|
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 {
|
let filter = Some(FilterParams {
|
||||||
mime_type: params.mime_type,
|
mime_type: params.mime_type,
|
||||||
metadata_filters,
|
metadata_filters,
|
||||||
|
conditions,
|
||||||
});
|
});
|
||||||
|
|
||||||
ListMediaOptions {
|
ListMediaOptions {
|
||||||
@@ -81,10 +117,33 @@ impl FromRequestParts<AppState> for ApiListMediaOptions {
|
|||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> 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
|
.await
|
||||||
.map_err(|e| ApiError::from(CoreError::Validation(e.to_string())))?;
|
.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()))
|
Ok(ApiListMediaOptions(params.into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ pub struct ListMediaParams {
|
|||||||
pub mime_type: Option<String>,
|
pub mime_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metadata: Vec<String>,
|
pub metadata: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub filters: Vec<String>,
|
||||||
pub page: Option<u32>,
|
pub page: Option<u32>,
|
||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub struct SortParams {
|
|||||||
pub struct FilterParams {
|
pub struct FilterParams {
|
||||||
pub mime_type: Option<String>,
|
pub mime_type: Option<String>,
|
||||||
pub metadata_filters: Option<Vec<MetadataFilter>>,
|
pub metadata_filters: Option<Vec<MetadataFilter>>,
|
||||||
|
pub conditions: Option<Vec<FilterCondition>>,
|
||||||
// In the future, we can add fields like:
|
// In the future, we can add fields like:
|
||||||
// pub date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
|
// pub date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,24 @@ pub struct MetadataFilter {
|
|||||||
pub tag_value: String,
|
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 struct MediaImportBundle {
|
||||||
pub media_model: Media,
|
pub media_model: Media,
|
||||||
pub metadata_models: Vec<MediaMetadata>,
|
pub metadata_models: Vec<MediaMetadata>,
|
||||||
|
|||||||
@@ -88,10 +88,187 @@ impl SortStrategy for MetadataSortStrategy {
|
|||||||
} else {
|
} else {
|
||||||
"NULLS FIRST"
|
"NULLS FIRST"
|
||||||
};
|
};
|
||||||
|
|
||||||
let order_by_clause = format!(" ORDER BY sort_mm.tag_value {} {}", direction, nulls_order);
|
let order_by_clause = format!(" ORDER BY sort_mm.tag_value {} {}", direction, nulls_order);
|
||||||
query.push(order_by_clause);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,12 +283,17 @@ pub trait QueryBuilder<T> {
|
|||||||
|
|
||||||
pub struct MediaQueryBuilder {
|
pub struct MediaQueryBuilder {
|
||||||
sort_strategies: Vec<Box<dyn SortStrategy>>,
|
sort_strategies: Vec<Box<dyn SortStrategy>>,
|
||||||
|
filter_strategies: Vec<Box<dyn FilterStrategy>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaQueryBuilder {
|
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 {
|
Self {
|
||||||
sort_strategies,
|
sort_strategies,
|
||||||
|
filter_strategies,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,10 +308,30 @@ impl MediaQueryBuilder {
|
|||||||
query.push(" JOIN media_metadata mm ON media.id = mm.media_id ");
|
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 {
|
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 {
|
if let Some(strategy) = strategy {
|
||||||
strategy.apply_join(&mut query, &sort.sort_by)?;
|
strategy.apply_join(&mut query, &sort.sort_by)?;
|
||||||
}
|
}
|
||||||
@@ -168,6 +370,18 @@ impl MediaQueryBuilder {
|
|||||||
query.push(" ) ");
|
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))
|
Ok((query, metadata_filter_count))
|
||||||
}
|
}
|
||||||
@@ -183,13 +397,19 @@ impl MediaQueryBuilder {
|
|||||||
SortOrder::Desc => "DESC",
|
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 {
|
if let Some(strategy) = strategy {
|
||||||
strategy.apply_sort(&mut query, &sort.sort_by, direction)?;
|
strategy.apply_sort(&mut query, &sort.sort_by, direction)?;
|
||||||
} else {
|
} else {
|
||||||
// Should not happen if we have a default/catch-all strategy, but good to handle
|
// 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)));
|
return Err(CoreError::Validation(format!(
|
||||||
|
"No sort strategy found for column: {}",
|
||||||
|
sort.sort_by
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
query.push(" ORDER BY media.created_at DESC NULLS LAST ");
|
query.push(" ORDER BY media.created_at DESC NULLS LAST ");
|
||||||
|
|||||||
@@ -28,14 +28,19 @@ impl PostgresMediaRepository {
|
|||||||
|
|
||||||
allowed_columns.push("date_taken".to_string());
|
allowed_columns.push("date_taken".to_string());
|
||||||
|
|
||||||
let strategies: Vec<Box<dyn crate::query_builder::SortStrategy>> = vec![
|
let sort_strategies: Vec<Box<dyn crate::query_builder::SortStrategy>> = vec![
|
||||||
Box::new(crate::query_builder::StandardSortStrategy::new(allowed_columns)),
|
Box::new(crate::query_builder::StandardSortStrategy::new(allowed_columns.clone())),
|
||||||
Box::new(crate::query_builder::MetadataSortStrategy),
|
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 {
|
Self {
|
||||||
pool,
|
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