Files
bookhoard/internal/services/search.go
T
John O'Keefe 4ee24e7152 feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields —
  every book column, file/lifecycle facts, the three HOLDING
  STRUCTURES (library, collections with excluded-aware membership,
  series with FULL primary+alternate_info membership), and the whole
  user-relative class (status, progress, last read, my rating, review,
  notes/highlights presence, note/highlight text, time spent, pages
  read, collections, added by) reading the VIEWER's rows. Operators
  are typed constants; negation is an operator.
- Composed engines (services/filter_query.go): one Go clause builder
  over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow
  shape), the field-aware suggestions, and a true-COUNT live count.
  Mandatory WHERE head everywhere (family lens + integrity facts).
  Values are always bound parameters — no user string reaches a
  field/operator position, ever.
- Wire: the qf JSON param (validated strictly — unknown
  fields/operators/malformed values are 400); qf wins over flat params
  on hand-crafted URLs; flat deep links keep working unchanged.
- Endpoints: GET /api/media-items/filter-count (live count);
  field-values autocomplete upgraded in place — any registry field
  key, qf binds the OTHER rules (symmetric under-filter suggestions,
  zero-match values never offered); classic six without a tree keep
  their static pinned contract.
- UI: the drawer becomes the rule builder — the ONLY filter UI (templ
  markup, Alpine tree state, sanctioned island; grouped field menus,
  operator menus with red negations, field-aware value menus, live
  count, chips per rule with red negations, URL rehydration, smart
  serialization flat-when-simple else qf).
- Tests: composed semantics (AND/OR/negation/nesting/enums/bools/
  numbers/dates/empty), every user-relative field class with the
  my-data weld (two readers, different worlds), note-text substring
  pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere,
  403s), suggestion-under-rules exactness, qf validation + precedence,
  flat-vs-tree equivalence; registry completeness unit test.
2026-10-20 14:49:50 -04:00

442 lines
17 KiB
Go

package services
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// SearchService handles all search and filter operations
type SearchService struct {
db *database.Queries
raw database.DBTX // the raw pgx handle — the composed filter engines' path
}
// NewSearchService creates a new search service instance. raw is the
// same pool db wraps (database.DBTX) — the composed filter engines run
// hand-built, allow-listed SQL with bound parameters, which static sqlc
// queries cannot express.
func NewSearchService(db *database.Queries, raw database.DBTX) *SearchService {
return &SearchService{db: db, raw: raw}
}
// SearchParams contains parameters for unified search
type SearchParams struct {
UserID pgtype.UUID
LibraryID pgtype.UUID
AuthorFilter string
SeriesFilter string
GenreFilter string
PublisherFilter string
TagsFilter string
NarratorFilter string
LanguageFilter string
YearMin int
YearMax int
HasCover pgtype.Bool
SearchQuery string
Sort string
Limit int
Offset int
// Facet registry Batch A additions (services/facet_registry.go).
// Empty strings / invalid Bools mean "facet not active".
StoryArcFilter string
ImprintFilter string
EditionFilter string
AgeRatingFilter string
FormatGroupFilter string
MangaTypeFilter string
ReadingDirectionFilter string
ExplicitFilter pgtype.Bool
AbridgedFilter pgtype.Bool
// Filter rule builder (faceted plan Phase 9). When FilterTree is
// set the composed engine runs INSTEAD of the flat facet params
// (qf wins over flat on a hand-crafted URL — precedence pinned by
// test); FilterParseErr carries a qf parse/validation failure for
// the endpoints to answer 400.
FilterTree *FilterTree
FilterParseErr string
}
// 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)
// Language filter UX: the UI sends display names ("English"); the
// column stores ISO codes ("en"). Normalize each comma element the
// SAME way ingest does (normalizeLanguageCode), so a user-typed or
// deep-linked display name matches the stored code — and unknown
// values that fit still match verbatim rows. (Facet matching and
// ingest can never disagree because both run the same map.)
params.LanguageFilter = normalizeLanguageFilter(params.LanguageFilter)
// 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
// 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},
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
FormatGroupFilter: pgtype.Text{
String: params.FormatGroupFilter, Valid: true,
},
MangaTypeFilter: pgtype.Text{String: params.MangaTypeFilter, Valid: true},
ReadingDirectionFilter: pgtype.Text{
String: params.ReadingDirectionFilter, Valid: true,
},
ExplicitFilter: params.ExplicitFilter,
AbridgedFilter: params.AbridgedFilter,
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
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
}
// normalizeLanguageFilter normalizes every comma element of a language
// filter value through the ingest-side language map (display name →
// ISO code), so filtering and ingest share one source of truth.
func normalizeLanguageFilter(wireValue string) string {
if wireValue == "" {
return ""
}
parts := strings.Split(wireValue, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, normalizeLanguageCode(t))
}
}
return strings.Join(out, ",")
}
// NormalizeLanguageFilter is the exported form of the search-path
// normalization for callers that must compare RAW filter values
// against stored codes without issuing a query (the facet panel marks
// a value Active by matching the bound filter set).
func NormalizeLanguageFilter(wireValue string) string {
return normalizeLanguageFilter(wireValue)
}
// FacetValue is one facet value + count for the filter panel.
type FacetValue struct {
Value string `json:"value"` // the raw value (rides the filter param)
// Display is the prettified form when it differs from Value
// (language codes → names); empty → the client shows Value.
Display string `json:"display,omitempty"`
Count int64 `json:"count"`
}
// facetCountsLimit is the per-facet top-N bound the panel renders.
const facetCountsLimit = 50
// FacetCounts computes every listable facet's top values + counts under
// the active filters (faceted plan Phase 7): ONE UNION-ALL round trip
// (FacetCountsUnderFilters) in which each facet is counted under the
// OTHER facets' filters — the SQL omits each arm's own block, classic
// faceted-search semantics. With no filters bound every block is
// neutral and the counts are the v1 global-per-library ones, so one
// query serves both cases. libraryID may be NULL (the All-Libraries
// view). Display enrichment matches the wire contract: language codes
// → display names, genre values prettified — both only when they
// differ from the raw value (Display is "the prettified form when it
// differs", per FacetValue).
func (s *SearchService) FacetCounts(ctx context.Context, userID, libraryID pgtype.UUID, params SearchParams, limit int) (map[string][]FacetValue, error) {
if limit <= 0 {
limit = facetCountsLimit
}
// Language filter UX (same normalization the search path runs —
// see SearchMediaItemsUnified): display names in, ISO codes bound.
langFilter := normalizeLanguageFilter(params.LanguageFilter)
rows, err := s.db.FacetCountsUnderFilters(ctx, database.FacetCountsUnderFiltersParams{
UserID: userID,
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
LanguageFilter: pgtype.Text{String: langFilter, Valid: true},
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
FormatGroupFilter: pgtype.Text{String: params.FormatGroupFilter, Valid: true},
MangaTypeFilter: pgtype.Text{String: params.MangaTypeFilter, Valid: true},
ReadingDirectionFilter: pgtype.Text{String: params.ReadingDirectionFilter, Valid: true},
ExplicitFilter: params.ExplicitFilter,
AbridgedFilter: params.AbridgedFilter,
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
})
if err != nil {
return nil, err
}
out := make(map[string][]FacetValue, 14)
for _, r := range rows {
v := FacetValue{Value: r.Value, Count: r.Count}
switch r.Facet {
case "language":
if name := LanguageDisplayName(r.Value); name != "" && name != r.Value {
v.Display = name
}
case "genre":
if d := utils.PrettifyGenre(r.Value); d != r.Value {
v.Display = d
}
}
out[r.Facet] = append(out[r.Facet], v)
}
return out, nil
}
// FacetValues lists ONE facet's top values + counts — the pinned
// single-facet shape behind GET /api/media-items/facets?facet=X. It is
// the all-facets query with every filter neutral (v1 counts:
// global-per-library, not recomputed under other filters).
// Registry-driven: only FacetArray and FacetScalar facets list values —
// bool/range facets render as toggles/ranges in the panel, not value
// lists.
func (s *SearchService) FacetValues(ctx context.Context, userID pgtype.UUID, facet Facet, libraryID pgtype.UUID, limit int) ([]FacetValue, error) {
if facet.Kind != FacetArray && facet.Kind != FacetScalar {
return nil, fmt.Errorf("facet %q does not list values", facet.Param)
}
counts, err := s.FacetCounts(ctx, userID, libraryID, SearchParams{}, limit)
if err != nil {
return nil, err
}
values := counts[facet.Param]
if values == nil {
return []FacetValue{}, nil
}
return values, 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 is a single field value with metadata. Display carries a
// human label when the wire value is an ID (library/collection/added_by
// suggestions) — omitted everywhere else, so the classic static shape
// {value, count, score} is unchanged.
type FieldValue struct {
Value string `json:"value"`
Count int64 `json:"count"`
Score float64 `json:"score"`
Display string `json:"display,omitempty"`
}
// 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 "tags":
results, err := s.db.SearchTagsValues(ctx, database.SearchTagsValuesParams{
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, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "narrator":
results, err := s.db.SearchNarratorValues(ctx, database.SearchNarratorValuesParams{
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, 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 {
// Serve display names ("English") — the user clicks a name,
// the search service normalizes it back to the stored ISO
// code ("en"). Unknown codes pass through verbatim.
shown := r.Value.String
if name := LanguageDisplayName(shown); name != "" {
shown = name
}
fieldValues[i] = FieldValue{Value: shown, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
default:
return []FieldValue{}, nil
}
}
// ExecuteSearch performs search and returns results with count
// Shared between JSON endpoint and HTML rendering.
// Routing: a qf filter tree → the composed engine (flat params ignored
// — qf wins); else the static unified query. A parse/validation failure
// surfaces as ErrInvalidFilterTree (→ 400 at the endpoints).
func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
if params.FilterParseErr != "" {
return nil, 0, fmt.Errorf("%w: %s", ErrInvalidFilterTree, params.FilterParseErr)
}
if params.FilterTree != nil {
rows, err := s.ComposedSearch(ctx, params)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, err
}
count := len(rows)
return rows, count, nil
}
results, err := s.SearchMediaItemsUnified(ctx, params)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, err
}
count := len(results)
return results, count, nil
}