From 26c81c87937fcdafc9612c9749486f8c6f3d6018 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 22 Mar 2026 20:35:04 -0400 Subject: [PATCH] feat: add unified search queries with fuzzy matching Add comprehensive search queries supporting both fuzzy and exact matching: 1. SearchMediaItemsUnified - Main search query with: - Fuzzy matching on author, series, genre, language filters - Fuzzy search on title, author, series, tags, contributors - Exact matching with quotes (is_exact_search flag) - Year range and boolean filters - Relevance-based ordering using word_similarity scores 2. Field-specific autocomplete queries: - SearchAuthorValues, SearchGenreValues, SearchSeriesValues, SearchLanguageValues - Each returns distinct values with counts and similarity scores - Threshold of 0.3 for word_similarity filter - Ordered by relevance (score DESC, count DESC) Note: Using 4 separate field value queries instead of 1 complex query due to sqlc v1.30.0 limitation with CASE expressions in GROUP BY clauses. --- internal/database/queries/queries.sql | 135 ++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 81734cf..51861d3 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -458,6 +458,141 @@ WHERE COALESCE(lv.is_visible, true) = true mi.title ASC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); +-- name: SearchMediaItemsUnified :many +SELECT mi.*, l.name as library_name, lt.name as library_type_name +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.library_id = sqlc.narg('library_id') + -- Fuzzy author filter + AND (sqlc.narg('author_filter') = '' OR word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')) > 0.3) + -- Fuzzy series filter + AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3) + -- Fuzzy genre filter + AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3) + -- Fuzzy language filter + AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3) + -- Year range (exact) + AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min')) + AND (sqlc.narg('year_max') = 0 OR mi.copyright_year <= sqlc.narg('year_max')) + -- Boolean (exact) + AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL) + -- Search query (fuzzy or exact based on quotes) + AND ( + sqlc.narg('search_query') = '' OR + -- Fuzzy search (default) + sqlc.narg('is_exact_search') = false AND ( + word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR + word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR + word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 OR + EXISTS ( + SELECT 1 FROM unnest(mi.tags_search) AS tag + WHERE word_similarity(sqlc.narg('search_query'), tag) > 0.3 + LIMIT 1 + ) OR + EXISTS ( + SELECT 1 FROM unnest(mi.contributors_search) AS contributor + WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3 + LIMIT 1 + ) + ) OR + -- Exact search (with quotes) + sqlc.narg('is_exact_search') = true AND ( + mi.title ILIKE sqlc.narg('search_pattern') OR + mi.author ILIKE sqlc.narg('search_pattern') OR + mi.series ILIKE sqlc.narg('search_pattern') OR + sqlc.narg('search_pattern') = ANY(mi.tags_search) OR + sqlc.narg('search_pattern') = ANY(mi.contributors_search) + ) + ) +ORDER BY + CASE + WHEN sqlc.narg('search_query') != '' THEN + GREATEST( + CASE WHEN sqlc.narg('is_exact_search') = false THEN + word_similarity(sqlc.narg('search_query'), mi.title) + ELSE 0 END, + CASE WHEN sqlc.narg('is_exact_search') = false THEN + word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) + ELSE 0 END, + word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')), + word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) + ) + ELSE 0 + END DESC, + mi.title ASC +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); + +-- name: SearchAuthorValues :many +SELECT + mi.author as value, + COUNT(*) as count, + word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))::float8 as score +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.library_id = sqlc.narg('library_id') + AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 + AND mi.author IS NOT NULL + AND mi.author != '' +GROUP BY mi.author, word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))::float8 +ORDER BY score DESC, count DESC +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); + +-- name: SearchGenreValues :many +SELECT + mi.genre as value, + COUNT(*) as count, + word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ''))::float8 as score +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.library_id = sqlc.narg('library_id') + AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3 + AND mi.genre IS NOT NULL + AND mi.genre != '' +GROUP BY mi.genre, word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ''))::float8 +ORDER BY score DESC, count DESC +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); + +-- name: SearchSeriesValues :many +SELECT + mi.series as value, + COUNT(*) as count, + word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))::float8 as score +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.library_id = sqlc.narg('library_id') + AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 + AND mi.series IS NOT NULL + AND mi.series != '' +GROUP BY mi.series, word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))::float8 +ORDER BY score DESC, count DESC +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); + +-- name: SearchLanguageValues :many +SELECT + mi.language as value, + COUNT(*) as count, + word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, ''))::float8 as score +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.library_id = sqlc.narg('library_id') + AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3 + AND mi.language IS NOT NULL + AND mi.language != '' +GROUP BY mi.language, word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, ''))::float8 +ORDER BY score DESC, count DESC +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); + -- Media Notes queries -- name: CreateMediaNote :one INSERT INTO media_notes (media_item_id, user_id, content, position)