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.
This commit is contained in:
1 parent
ebb7f4a5b0
commit
4ee24e7152
18 files changed
+3788
-376
No files matched your search
@@ -14,12 +14,16 @@ import (
|
||||
|
||||
// SearchService handles all search and filter operations
|
||||
type SearchService struct {
|
||||
db *database.Queries
|
||||
db *database.Queries
|
||||
raw database.DBTX // the raw pgx handle — the composed filter engines' path
|
||||
}
|
||||
|
||||
// NewSearchService creates a new search service instance
|
||||
func NewSearchService(db *database.Queries) *SearchService {
|
||||
return &SearchService{db: db}
|
||||
// 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
|
||||
@@ -52,6 +56,14 @@ type SearchParams struct {
|
||||
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
|
||||
@@ -272,11 +284,15 @@ type FieldSearchParams struct {
|
||||
Offset int
|
||||
}
|
||||
|
||||
// FieldValue represents a single field value with metadata
|
||||
// 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"`
|
||||
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
|
||||
@@ -399,8 +415,22 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
|
||||
}
|
||||
|
||||
// ExecuteSearch performs search and returns results with count
|
||||
// Shared between JSON endpoint and HTML rendering
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user