From 9ab27969028541e8f7ac8d1d72683985051184fe Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 22 Mar 2026 20:35:09 -0400 Subject: [PATCH] feat: implement SearchService with unified search logic Create new SearchService to encapsulate all search business logic: Features: - Unified search combining text search with filters - Fuzzy matching using pg_trgm word_similarity (threshold: 0.3) - Exact search when query is wrapped in quotes - Field-specific autocomplete for dropdowns (author, genre, series, language) - Proper pagination with configurable limit/offset Implementation details: - SearchMediaItems: Routes to SearchMediaItemsUnified query * Detects exact search by checking for quotes in query * Builds search pattern for ILIKE matching (%term%) * Converts string filters to pgtype.Text with proper Valid flags - SearchFieldValues: Routes to appropriate field-specific query * Uses switch statement to call correct query based on field_type * Returns []FieldValue with value, count, and similarity score * Handles all 4 field types: author, genre, series, language Design pattern: Service layer separates business logic from handlers, following project's established architecture (FiltersService, CollectionService). --- internal/services/search.go | 185 ++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 internal/services/search.go diff --git a/internal/services/search.go b/internal/services/search.go new file mode 100644 index 0000000..e15c220 --- /dev/null +++ b/internal/services/search.go @@ -0,0 +1,185 @@ +package services + +import ( + "bookhoard/internal/database" + "context" + "strings" + + "github.com/jackc/pgx/v5/pgtype" +) + +// SearchService handles all search and filter operations +type SearchService struct { + db *database.Queries +} + +// NewSearchService creates a new search service instance +func NewSearchService(db *database.Queries) *SearchService { + return &SearchService{db: db} +} + +// SearchParams contains parameters for unified search +type SearchParams struct { + UserID pgtype.UUID + LibraryID pgtype.UUID + AuthorFilter string + SeriesFilter string + GenreFilter string + LanguageFilter string + YearMin int + YearMax int + HasCover bool + SearchQuery string + Limit int + Offset int +} + +// parseSearchQuery detects quoted strings for exact match search +// Returns (isExact, processedQuery) +// Examples: +// +// "asimov" → (false, "asimov") +// "\"Asimov, Isaac\"" → (true, "Asimov, Isaac") +func (s *SearchService) parseSearchQuery(query string) (bool, string) { + query = strings.TrimSpace(query) + if strings.HasPrefix(query, "\"") && strings.HasSuffix(query, "\"") && len(query) >= 2 { + return true, strings.Trim(query, "\"") + } + return false, query +} + +// SearchMediaItemsUnified handles combined search + filters +// Supports: +// - Fuzzy text filters (author, series, genre, language) +// - Exact filters (year range, has_cover boolean) +// - Fuzzy search query (or exact match with quotes) +// - Combined search + filters +func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) { + // Parse search query for exact match detection + isExact, searchQuery := s.parseSearchQuery(params.SearchQuery) + searchPattern := "" + if isExact { + searchPattern = "%" + searchQuery + "%" + } + + // Build database parameters + dbParams := database.SearchMediaItemsUnifiedParams{ + UserID: params.UserID, + LibraryID: params.LibraryID, + AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true}, + SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true}, + GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true}, + LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true}, + YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true}, + YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true}, + HasCover: pgtype.Bool{Bool: params.HasCover, Valid: true}, + SearchQuery: pgtype.Text{String: searchQuery, Valid: true}, + IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true}, + SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact}, + Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, + } + + // Execute unified search query + results, err := s.db.SearchMediaItemsUnified(ctx, dbParams) + if err != nil { + return nil, err + } + + return results, nil +} + +// FieldSearchParams contains parameters for field-specific search (autocomplete) +type FieldSearchParams struct { + UserID pgtype.UUID + LibraryID pgtype.UUID + FieldType string // "author", "genre", "series", "language" + SearchQuery string + Limit int + Offset int +} + +// FieldValue represents a single field value with metadata +type FieldValue struct { + Value string + Count int64 + Score float64 +} + +// SearchFieldValues handles field-specific search for autocomplete dropdowns +// Returns distinct values with counts and similarity scores +// Uses 4 separate queries (one per field type) for sqlc v1.30.0 compatibility +func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearchParams) ([]FieldValue, error) { + switch params.FieldType { + case "author": + results, err := s.db.SearchAuthorValues(ctx, database.SearchAuthorValuesParams{ + SearchQuery: params.SearchQuery, + UserID: params.UserID, + LibraryID: params.LibraryID, + Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, + }) + if err != nil { + return nil, err + } + fieldValues := make([]FieldValue, len(results)) + for i, r := range results { + fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score} + } + return fieldValues, nil + + case "genre": + results, err := s.db.SearchGenreValues(ctx, database.SearchGenreValuesParams{ + SearchQuery: params.SearchQuery, + UserID: params.UserID, + LibraryID: params.LibraryID, + Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, + }) + if err != nil { + return nil, err + } + fieldValues := make([]FieldValue, len(results)) + for i, r := range results { + fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score} + } + return fieldValues, nil + + case "series": + results, err := s.db.SearchSeriesValues(ctx, database.SearchSeriesValuesParams{ + SearchQuery: params.SearchQuery, + UserID: params.UserID, + LibraryID: params.LibraryID, + Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, + }) + if err != nil { + return nil, err + } + fieldValues := make([]FieldValue, len(results)) + for i, r := range results { + fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score} + } + return fieldValues, nil + + case "language": + results, err := s.db.SearchLanguageValues(ctx, database.SearchLanguageValuesParams{ + SearchQuery: params.SearchQuery, + UserID: params.UserID, + LibraryID: params.LibraryID, + Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, + }) + if err != nil { + return nil, err + } + fieldValues := make([]FieldValue, len(results)) + for i, r := range results { + fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score} + } + return fieldValues, nil + + default: + return []FieldValue{}, nil + } +}