Add comment to document that filter parameters use pgtype.Text with explicit Valid=true flag to ensure proper SQL parameter handling. This clarifies the intent behind the parameter building logic. Improves code documentation for future maintenance.
191 lines
6.3 KiB
Go
191 lines
6.3 KiB
Go
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
|
|
Sort 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 - only set valid true if filter
|
|
dbParams := database.SearchMediaItemsUnifiedParams{
|
|
UserID: params.UserID,
|
|
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},
|
|
Sort: pgtype.Text{String: params.Sort, Valid: true},
|
|
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
|
|
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
|
|
}
|
|
|
|
if params.LibraryID.Valid {
|
|
dbParams.LibraryID = params.LibraryID
|
|
}
|
|
|
|
// 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: pgtype.Text{String: params.SearchQuery, Valid: true},
|
|
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: pgtype.Text{String: params.SearchQuery, Valid: true},
|
|
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: pgtype.Text{String: params.SearchQuery, Valid: true},
|
|
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: pgtype.Text{String: params.SearchQuery, Valid: true},
|
|
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
|
|
}
|
|
}
|