feat: enhance search with date-prioritized year filtering and true exact matching

Improve media item search functionality with two key enhancements:

1. Date-prioritized year filtering:
   - Prioritize date_published over copyright_year for year range queries
   - Fall back to copyright_year when date_published is NULL
   - Extract year from date_published timestamp for comparison

2. True exact search matching:
   - Replace ILIKE pattern matching with exact equality for quoted queries
   - Use search_query directly instead of wildcard pattern for exact matches
   - Remove SearchPattern parameter and related wildcard logic
   - Add COALESCE handling for author/series NULL values in exact matches

These changes make year filtering more accurate with published dates
and provide genuine exact matching when users wrap queries in quotes.

Refs internal/database/queries/queries.sql:475, internal/services/search.go:62
This commit is contained in:
2026-03-26 15:35:31 -04:00
parent 6b518462f9
commit be31cc88f1
4 changed files with 72 additions and 39 deletions
+34 -19
View File
@@ -7259,9 +7259,17 @@ WHERE COALESCE(lv.is_visible, true) = true
SELECT 1 FROM unnest(mi.tags_search) AS tag SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity($7, tag) > 0.3 WHERE word_similarity($7, tag) > 0.3
)) ))
-- Year range (exact) -- Year range (exact) - prioritize date_published, fallback to copyright_year
AND ($8 = 0 OR mi.copyright_year >= $8) AND (
AND ($9 = 0 OR mi.copyright_year <= $9) $8 = 0 OR
EXTRACT(YEAR FROM mi.date_published) >= $8 OR
(mi.date_published IS NULL AND mi.copyright_year >= $8)
)
AND (
$9 = 0 OR
EXTRACT(YEAR FROM mi.date_published) <= $9 OR
(mi.date_published IS NULL AND mi.copyright_year <= $9)
)
-- Boolean (exact) -- Boolean (exact)
AND ($10 = false OR mi.cover_image_path IS NOT NULL) AND ($10 = false OR mi.cover_image_path IS NOT NULL)
-- Search query (fuzzy or exact based on quotes) -- Search query (fuzzy or exact based on quotes)
@@ -7284,12 +7292,21 @@ WHERE COALESCE(lv.is_visible, true) = true
) )
) OR ) OR
-- Exact search (with quotes) -- Exact search (with quotes)
-- Keeping old pattern commented out in case we want wildcard exact back. See search.go line 62
-- 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)
-- )
-- Exact search (with quotes) - true exact match, not substring
$12 = true AND ( $12 = true AND (
mi.title ILIKE $13 OR mi.title = $11 OR
mi.author ILIKE $13 OR COALESCE(mi.author, '') = $11 OR
mi.series ILIKE $13 OR COALESCE(mi.series, '') = $11 OR
$13 = ANY(mi.tags_search) OR $11 = ANY(mi.tags_search) OR
$13 = ANY(mi.contributors_search) $11 = ANY(mi.contributors_search)
) )
) )
ORDER BY ORDER BY
@@ -7313,40 +7330,40 @@ ORDER BY
END DESC, END DESC,
-- Secondary sort: user-specified sort parameter -- Secondary sort: user-specified sort parameter
CASE CASE
WHEN $14 = 'title ASC' THEN mi.title WHEN $13 = 'title ASC' THEN mi.title
ELSE '' ELSE ''
END ASC, END ASC,
CASE CASE
WHEN $14 = 'title DESC' THEN mi.title WHEN $13 = 'title DESC' THEN mi.title
ELSE '' ELSE ''
END DESC, END DESC,
CASE CASE
WHEN $14 = 'author ASC' THEN COALESCE(mi.author, '') WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '')
ELSE '' ELSE ''
END ASC, END ASC,
CASE CASE
WHEN $14 = 'author DESC' THEN COALESCE(mi.author, '') WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '')
ELSE '' ELSE ''
END DESC, END DESC,
CASE CASE
WHEN $14 = 'created_at ASC' THEN mi.created_at WHEN $13 = 'created_at ASC' THEN mi.created_at
ELSE '1970-01-01'::timestamp ELSE '1970-01-01'::timestamp
END ASC, END ASC,
CASE CASE
WHEN $14 = 'created_at DESC' THEN mi.created_at WHEN $13 = 'created_at DESC' THEN mi.created_at
ELSE '1970-01-01'::timestamp ELSE '1970-01-01'::timestamp
END DESC, END DESC,
CASE CASE
WHEN $14 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') WHEN $13 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
ELSE '' ELSE ''
END ASC, END ASC,
CASE CASE
WHEN $14 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') WHEN $13 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
ELSE '' ELSE ''
END DESC, END DESC,
-- Tertiary sort: title (default fallback) -- Tertiary sort: title (default fallback)
mi.title ASC mi.title ASC
LIMIT $16 OFFSET $15 LIMIT $15 OFFSET $14
` `
type SearchMediaItemsUnifiedParams struct { type SearchMediaItemsUnifiedParams struct {
@@ -7362,7 +7379,6 @@ type SearchMediaItemsUnifiedParams struct {
HasCover interface{} `db:"has_cover" json:"has_cover"` HasCover interface{} `db:"has_cover" json:"has_cover"`
SearchQuery interface{} `db:"search_query" json:"search_query"` SearchQuery interface{} `db:"search_query" json:"search_query"`
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"` IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
SearchPattern pgtype.Text `db:"search_pattern" json:"search_pattern"`
Sort interface{} `db:"sort" json:"sort"` Sort interface{} `db:"sort" json:"sort"`
Offset pgtype.Int4 `db:"offset" json:"offset"` Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"` Limit pgtype.Int4 `db:"limit" json:"limit"`
@@ -7431,7 +7447,6 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
arg.HasCover, arg.HasCover,
arg.SearchQuery, arg.SearchQuery,
arg.IsExactSearch, arg.IsExactSearch,
arg.SearchPattern,
arg.Sort, arg.Sort,
arg.Offset, arg.Offset,
arg.Limit, arg.Limit,
+25 -8
View File
@@ -440,9 +440,17 @@ WHERE COALESCE(lv.is_visible, true) = true
SELECT 1 FROM unnest(mi.tags_search) AS tag SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3 WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
)) ))
-- Year range (exact) -- Year range (exact) - prioritize date_published, fallback to copyright_year
AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min')) AND (
AND (sqlc.narg('year_max') = 0 OR mi.copyright_year <= sqlc.narg('year_max')) sqlc.narg('year_min') = 0 OR
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
)
AND (
sqlc.narg('year_max') = 0 OR
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
)
-- Boolean (exact) -- Boolean (exact)
AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL) AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL)
-- Search query (fuzzy or exact based on quotes) -- Search query (fuzzy or exact based on quotes)
@@ -465,12 +473,21 @@ WHERE COALESCE(lv.is_visible, true) = true
) )
) OR ) OR
-- Exact search (with quotes) -- Exact search (with quotes)
-- Keeping old pattern commented out in case we want wildcard exact back. See search.go line 62
-- 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)
-- )
-- Exact search (with quotes) - true exact match, not substring
sqlc.narg('is_exact_search') = true AND ( sqlc.narg('is_exact_search') = true AND (
mi.title ILIKE sqlc.narg('search_pattern') OR mi.title = sqlc.narg('search_query') OR
mi.author ILIKE sqlc.narg('search_pattern') OR COALESCE(mi.author, '') = sqlc.narg('search_query') OR
mi.series ILIKE sqlc.narg('search_pattern') OR COALESCE(mi.series, '') = sqlc.narg('search_query') OR
sqlc.narg('search_pattern') = ANY(mi.tags_search) OR sqlc.narg('search_query') = ANY(mi.tags_search) OR
sqlc.narg('search_pattern') = ANY(mi.contributors_search) sqlc.narg('search_query') = ANY(mi.contributors_search)
) )
) )
ORDER BY ORDER BY
+1 -1
View File
@@ -208,7 +208,7 @@ func registerFrontendRoutes(cfg *Config) {
HasCover: pgtype.Bool{Bool: false, Valid: true}, // Changed HasCover: pgtype.Bool{Bool: false, Valid: true}, // Changed
SearchQuery: pgtype.Text{String: "", Valid: true}, // NEW: Required SearchQuery: pgtype.Text{String: "", Valid: true}, // NEW: Required
IsExactSearch: pgtype.Bool{Bool: false, Valid: true}, // NEW: Required IsExactSearch: pgtype.Bool{Bool: false, Valid: true}, // NEW: Required
SearchPattern: pgtype.Text{Valid: false}, // NEW: Empty for no exact search // SearchPattern: pgtype.Text{Valid: false}, // NEW: Empty for no exact search
Sort: pgtype.Text{String: "created_at DESC", Valid: true}, Sort: pgtype.Text{String: "created_at DESC", Valid: true},
Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(offset), Valid: true}, Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
+6 -5
View File
@@ -59,10 +59,11 @@ func (s *SearchService) parseSearchQuery(query string) (bool, string) {
func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) { func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) {
// Parse search query for exact match detection // Parse search query for exact match detection
isExact, searchQuery := s.parseSearchQuery(params.SearchQuery) isExact, searchQuery := s.parseSearchQuery(params.SearchQuery)
searchPattern := "" // For now implementing real exact search. If you want to reimplement wildcard patterns uncomment this and line 81 and check queries.sql line 475 and frontend.go line 211
if isExact { // searchPattern := ""
searchPattern = "%" + searchQuery + "%" // if isExact {
} // searchPattern = "%" + searchQuery + "%"
// }
// Build database parameters - only set valid true if filter // Build database parameters - only set valid true if filter
dbParams := database.SearchMediaItemsUnifiedParams{ dbParams := database.SearchMediaItemsUnifiedParams{
@@ -77,7 +78,7 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
HasCover: pgtype.Bool{Bool: params.HasCover, Valid: true}, HasCover: pgtype.Bool{Bool: params.HasCover, Valid: true},
SearchQuery: pgtype.Text{String: searchQuery, Valid: true}, SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true}, IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact}, // SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
Sort: pgtype.Text{String: params.Sort, Valid: true}, Sort: pgtype.Text{String: params.Sort, Valid: true},
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},