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).
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user