fix(search): tags not included in facet query and filter deserialization

This commit is contained in:
aaldebs99
2025-12-16 20:11:19 +00:00
parent 535a572709
commit 54d6af5cd3
8 changed files with 116 additions and 39 deletions

43
Cargo.lock generated
View File

@@ -771,6 +771,31 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
dependencies = [
"axum",
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde_core",
"serde_html_form",
"serde_path_to_error",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base16ct"
version = "0.1.1"
@@ -3714,6 +3739,7 @@ dependencies = [
"aws-sdk-s3",
"aws-types",
"axum",
"axum-extra",
"base64ct",
"bcrypt",
"chrono",
@@ -4296,6 +4322,19 @@ dependencies = [
"syn 2.0.103",
]
[[package]]
name = "serde_html_form"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
dependencies = [
"form_urlencoded",
"indexmap 2.9.0",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.145"
@@ -4907,7 +4946,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.7",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5746,7 +5785,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]

View File

@@ -21,6 +21,7 @@ path = "src/bin/analyze-webdav-performance.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["multipart", "ws"] }
axum-extra = { version = "0.10", features = ["query"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
serde = { version = "1", features = ["derive"] }

View File

@@ -62,7 +62,7 @@ import {
TrendingUp as TrendingIcon,
TextFormat as TextFormatIcon,
} from '@mui/icons-material';
import { documentService, SearchRequest } from '../services/api';
import { documentService, SearchRequest, api } from '../services/api';
import SearchGuidance from '../components/SearchGuidance';
import EnhancedSearchGuide from '../components/EnhancedSearchGuide';
import MimeTypeFacetFilter from '../components/MimeTypeFacetFilter';
@@ -250,7 +250,9 @@ const SearchPage: React.FC = () => {
}, []);
const performSearch = useCallback(async (query: string, filters: SearchFilters = {}, page: number = 1): Promise<void> => {
if (!query.trim()) {
const hasFilters = (filters.tags?.length ?? 0) > 0 ||
(filters.mimeTypes?.length ?? 0) > 0;
if (!query.trim() && !hasFilters) {
setSearchResults([]);
setTotalResults(0);
setQueryTime(0);
@@ -321,11 +323,7 @@ const SearchPage: React.FC = () => {
setTotalResults(response.data.total || results.length);
setQueryTime(response.data.query_time_ms || 0);
setSuggestions(response.data.suggestions || []);
// Extract unique tags for filter options
const tags = [...new Set(results.flatMap(doc => doc.tags || []))].filter(tag => typeof tag === 'string');
setAvailableTags(tags);
// Clear progress after a brief delay
setTimeout(() => setSearchProgress(0), 500);
@@ -356,6 +354,20 @@ const SearchPage: React.FC = () => {
[generateQuickSuggestions]
);
// Load available tags from labels API on component mount
useEffect(() => {
const loadLabels = async () => {
try {
const response = await api.get('/labels?include_counts=true');
const labelNames = (response.data || []).map((label: any) => label.name);
setAvailableTags(labelNames);
} catch (error) {
console.error('Failed to load labels:', error);
}
};
loadLabels();
}, []);
// Handle URL search params
useEffect(() => {
const queryFromUrl = searchParams.get('q');

View File

@@ -5,6 +5,19 @@ const api = axios.create({
headers: {
'Content-Type': 'application/json',
},
paramsSerializer: {
serialize: (params) => {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => searchParams.append(key, String(v)))
} else if (value !== undefined && value !== null) {
searchParams.append(key, String(value))
}
})
return searchParams.toString()
}
}
})
export { api }

View File

@@ -166,20 +166,25 @@ impl Database {
}).collect())
}
/// Gets tag facets (aggregated counts by tag)
pub async fn get_tag_facets(&self, user_id: Uuid, user_role: UserRole) -> Result<Vec<FacetItem>> {
let mut query = QueryBuilder::<Postgres>::new(
"SELECT unnest(tags) as value, COUNT(*) as count FROM documents WHERE 1=1"
);
/// Gets tag facets (aggregated counts by label)
pub async fn get_tag_facets(&self, user_id: Uuid, _user_role: UserRole) -> Result<Vec<FacetItem>> {
let query = sqlx::query_as::<_, (String, i64)>(
r#"
SELECT l.name as value, COUNT(DISTINCT dl.document_id) as count
FROM labels l
LEFT JOIN document_labels dl ON l.id = dl.label_id
WHERE (l.user_id = $1 OR l.is_system = true)
GROUP BY l.id, l.name
ORDER BY count DESC, l.name
"#
)
.bind(user_id);
apply_role_based_filter(&mut query, user_id, user_role);
query.push(" GROUP BY unnest(tags) ORDER BY count DESC, value");
let rows = query.fetch_all(&self.pool).await?;
let rows = query.build().fetch_all(&self.pool).await?;
Ok(rows.into_iter().map(|row| FacetItem {
value: row.get("value"),
count: row.get("count"),
Ok(rows.into_iter().map(|(value, count)| FacetItem {
value,
count,
}).collect())
}

View File

@@ -23,11 +23,12 @@ impl Database {
query.push("))");
}
// Add tag filtering
// Add label filtering (tags param contains label names)
if let Some(ref tags) = search_request.tags {
if !tags.is_empty() {
query.push(" AND tags && ");
query.push(" AND documents.id IN (SELECT dl.document_id FROM document_labels dl JOIN labels l ON dl.label_id = l.id WHERE l.name = ANY(");
query.push_bind(tags);
query.push("))");
}
}
@@ -128,11 +129,12 @@ impl Database {
}
}
// Add filtering
// Add label filtering (tags param contains label names)
if let Some(ref tags) = search_request.tags {
if !tags.is_empty() {
query.push(" AND tags && ");
query.push(" AND documents.id IN (SELECT dl.document_id FROM document_labels dl JOIN labels l ON dl.label_id = l.id WHERE l.name = ANY(");
query.push_bind(tags);
query.push("))");
}
}

View File

@@ -6,10 +6,13 @@ use super::responses::EnhancedDocumentResponse;
#[derive(Debug, Serialize, Deserialize, ToSchema, IntoParams)]
pub struct SearchRequest {
/// Search query text (searches both document content and OCR-extracted text)
#[serde(default)]
pub query: String,
/// Filter by specific tags
/// Filter by specific tags (label names)
#[serde(default)]
pub tags: Option<Vec<String>>,
/// Filter by MIME types (e.g., "application/pdf", "image/png")
#[serde(default)]
pub mime_types: Option<Vec<String>>,
/// Maximum number of results to return (default: 25)
pub limit: Option<i64>,
@@ -48,28 +51,20 @@ impl Default for SearchMode {
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SearchResponse {
/// List of matching documents with enhanced metadata and snippets
pub documents: Vec<EnhancedDocumentResponse>,
/// Total number of documents matching the search criteria
pub total: i64,
/// Time taken to execute the search in milliseconds
pub query_time_ms: u64,
/// Search suggestions for query improvement
pub suggestions: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct FacetItem {
/// The facet value (e.g., mime type or tag)
pub value: String,
/// Number of documents with this value
pub count: i64,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SearchFacetsResponse {
/// MIME type facets with counts
pub mime_types: Vec<FacetItem>,
/// Tag facets with counts
pub tags: Vec<FacetItem>,
}
}

View File

@@ -1,10 +1,11 @@
use axum::{
extract::{Query, State},
extract::State,
http::StatusCode,
response::Json,
routing::get,
Router,
};
use axum_extra::extract::Query;
use std::sync::Arc;
use crate::{
@@ -43,8 +44,10 @@ async fn search_documents(
auth_user: AuthUser,
Query(search_request): Query<SearchRequest>,
) -> Result<Json<SearchResponse>, SearchError> {
// Validate query length
if search_request.query.len() < 2 {
// Validate query length (allow empty query if filters are present)
let has_filters = search_request.tags.as_ref().map_or(false, |t| !t.is_empty())
|| search_request.mime_types.as_ref().map_or(false, |m| !m.is_empty());
if search_request.query.len() < 2 && !has_filters {
return Err(SearchError::query_too_short(search_request.query.len(), 2));
}
if search_request.query.len() > 1000 {
@@ -118,9 +121,16 @@ async fn enhanced_search_documents(
auth_user: AuthUser,
Query(search_request): Query<SearchRequest>,
) -> Result<Json<SearchResponse>, StatusCode> {
// Validate query length (allow empty query if filters are present)
let has_filters = search_request.tags.as_ref().map_or(false, |t| !t.is_empty())
|| search_request.mime_types.as_ref().map_or(false, |m| !m.is_empty());
if search_request.query.len() < 2 && !has_filters {
return Err(StatusCode::BAD_REQUEST);
}
// Generate suggestions before moving search_request
let suggestions = generate_search_suggestions(&search_request.query);
let start_time = std::time::Instant::now();
let documents = state
.db