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:
John O'Keefe committed 2026-10-20 14:49:50 -04:00
1 parent ebb7f4a5b0
commit 4ee24e7152
18 files changed
+3788 -376

No files matched your search

+121 -32
View File
@@ -226,11 +226,11 @@ type MediaHandler struct {
// NewMediaHandler requires the shared ProgressService — progress writes
// must funnel through SaveProgress (the LWW-seeded canonical writer);
// a nil service has nowhere to fall back to and must not be wired.
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
mh := &MediaHandler{
db: db,
libraryService: libraryService,
searchService: services.NewSearchService(db),
searchService: services.NewSearchService(db, raw),
progressSvc: progressSvc,
annotationSvc: annotationSvc,
}
@@ -2328,30 +2328,29 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
}
// Detect autocomplete queries first (author=value, genre=value, etc.)
if author := c.QueryParam("author"); author != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
}
if genre := c.QueryParam("genre"); genre != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre)
}
if tags := c.QueryParam("tags"); tags != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags)
}
if narrator := c.QueryParam("narrator"); narrator != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator)
}
if series := c.QueryParam("series"); series != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
}
if language := c.QueryParam("language"); language != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "language", language)
// Autocomplete/suggestion queries: ANY registry field key in its
// bare form (author=, genre=, note_text=, collection=, ...) routes
// to the field-values path — static for the classic six without a
// rule tree, composed under the tree otherwise (faceted plan
// Phase 9: same route, no new API surface).
for _, key := range services.FilterFieldKeys() {
if v := c.QueryParam(key); v != "" {
return mh.handleFieldValuesSearch(c, userID.ID, key, v)
}
}
// Handle media item search with filters
params := ParseMediaSearchParams(c)
params.UserID = userID.ID
// Filter rule builder: a malformed qf is a 400 (bad request), never
// a silently-ignored token and never a 500.
if params.FilterParseErr != "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid filter: " + params.FilterParseErr,
})
}
c.Logger().Info("Search parameters",
"query", params.SearchQuery,
"library_id", libraryIDString(c),
@@ -2445,7 +2444,7 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
return pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true}
}
return services.SearchParams{
params := services.SearchParams{
SearchQuery: c.QueryParam("q"),
LibraryID: libUUID,
AuthorFilter: c.QueryParam("author_filter"),
@@ -2471,6 +2470,22 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
Limit: limit,
Offset: offset,
}
// Filter rule builder (faceted plan Phase 9): the qf param carries
// the rule tree. Parse + validate HERE (the shared parser — every
// entry point validates identically); a failure lands in
// FilterParseErr for the endpoints to answer 400, never a silently
// ignored token. Precedence: qf present → flat facet params are
// ignored by the engine (qf wins).
if qf := c.QueryParam("qf"); qf != "" {
tree, err := services.ParseFilterTree(qf)
if err != nil {
params.FilterParseErr = err.Error()
} else {
params.FilterTree = tree
}
}
return params
}
// libraryVisible reports whether the requested library is inside the
@@ -2583,14 +2598,22 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
})
}
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
// Returns distinct field values with counts and similarity scores for dropdown population
// handleFieldValuesSearch handles autocomplete/suggestion queries
// (author=value, genre=value, note_text=value, …). Returns distinct
// field values with counts and similarity scores for dropdown
// population.
//
// library_id is OPTIONAL: absent means "every library the requesting user
// can see" — the per-user family lens is enforced inside the query (the
// library_visibility join), so the All-Books view's suggestions aggregate
// the visible world. An explicit library_id outside the visible set is a
// 403 (AGENTS standing rule: never silently pass).
//
// Routing (faceted plan Phase 9): with a qf rule tree, or for fields
// beyond the classic six, suggestions come from the COMPOSED engine —
// the other rules bind as filters (symmetric: every rule's menu sees
// every other rule; zero-match values are never offered). Without a
// tree, the classic six keep their static queries (pinned contract).
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
var libPg pgtype.UUID
if libraryID := c.QueryParam("library_id"); libraryID != "" {
@@ -2614,27 +2637,93 @@ func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.U
}
offset, _ := strconv.Atoi(c.QueryParam("offset"))
params := services.FieldSearchParams{
UserID: userID,
LibraryID: libPg,
FieldType: fieldType,
SearchQuery: searchQuery,
Limit: limit,
Offset: offset,
// qf: the OTHER completed rules (the client strips the rule being
// edited). Parse + validate here — a malformed tree is a 400.
var tree *services.FilterTree
if qf := c.QueryParam("qf"); qf != "" {
t, err := services.ParseFilterTree(qf)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid filter: " + err.Error()})
}
tree = t
}
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
// Static path: the classic six with no rule tree (unchanged
// contract — the tag-dropdown island and deep links still ride it).
staticSix := tree == nil
if staticSix {
switch fieldType {
case "author", "genre", "tags", "narrator", "series", "language":
params := services.FieldSearchParams{
UserID: userID,
LibraryID: libPg,
FieldType: fieldType,
SearchQuery: searchQuery,
Limit: limit,
Offset: offset,
}
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
if err != nil {
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(results),
})
}
}
// Composed path: registry field under the other rules.
results, err := mh.searchService.ComposedFieldValues(c.Request().Context(), services.ComposedSuggestParams{
UserID: userID,
LibraryID: libPg,
FieldKey: fieldType,
Typed: searchQuery,
Tree: tree,
Limit: limit,
})
if err != nil {
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(results),
})
}
// FilterCount handles GET /api/media-items/filter-count — the rule
// builder's live match count: the composed rules + the library scope,
// no pagination (a true COUNT(*), not the page length).
func (mh *MediaHandler) FilterCount(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
}
params := ParseMediaSearchParams(c)
if params.FilterParseErr != "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid filter: " + params.FilterParseErr,
})
}
if params.LibraryID.Valid {
visible, err := mh.libraryVisible(c, params.LibraryID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
}
}
count, err := mh.searchService.ComposedCount(c.Request().Context(), user.ID, params.LibraryID, params.FilterTree)
if err != nil {
c.Logger().Error("filter count error", "error", err.Error())
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"count": count})
}
// getFullFilePath returns the absolute filesystem path for a media item
// Uses LibraryService for resolution (one source of truth)
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
+9
View File
@@ -29,6 +29,9 @@ func registerSearchRoutes(cfg *Config) {
// Facet-value listings for the filter panel (faceted-navigation
// Batch C): /api/media-items/facets?facet=X&library_id=...
protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets)
// Rule-builder live count (faceted plan Phase 9): the composed
// tree's true match count (COUNT(*)), rules + library scope.
protected.GET("/media-items/filter-count", cfg.MediaHandler.FilterCount)
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
}
@@ -45,6 +48,12 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
// parser fixes that too.)
params := handlers.ParseMediaSearchParams(c)
params.UserID = user.ID
// Filter rule builder: a malformed qf is a 400, never a silently
// ignored token.
if params.FilterParseErr != "" {
return c.HTML(http.StatusBadRequest, `<div style="color: red;">Invalid filter</div>`)
}
limit := params.Limit
offset := params.Offset
libraryID := c.QueryParam("library_id")
File diff suppressed because it is too large. Load diff
+548
View File
@@ -0,0 +1,548 @@
package services
import (
"encoding/json"
"fmt"
"strconv"
"github.com/google/uuid"
)
// ════════════════════════════════════════════════════════════════════
// Filter rule builder — the field registry (faceted plan Phase 9).
//
// The mandate: EVERYTHING is filterable — every attribute on a book and
// everything that pertains to one. This registry is the completeness
// check: fields and operators are typed constants (the allow-list — no
// user string ever reaches a field/operator position), every value is a
// bound pgx parameter, and each entry carries its own match-clause
// closure so the composed engines (search + suggestions + count) grow
// by adding entries, not SQL.
//
// User-relative fields read the VIEWER's rows: the user id is
// server-injected (never a client param) — two users get different
// results for the same tree (the "my data" weld).
//
// Holding structures (library, collections, series — primary AND
// alternate) are fields like any other; any future structure that
// "holds" a book must join this registry.
// ════════════════════════════════════════════════════════════════════
// Filter operators (the allow-list).
const (
OpContains = "contains"
OpNotContains = "not_contains"
OpIs = "is"
OpIsNot = "is_not"
OpEmpty = "empty"
OpIsTrue = "is_true"
OpIsFalse = "is_false"
OpBetween = "between"
OpAtLeast = "at_least"
OpAtMost = "at_most"
OpWithinDays = "within_days"
OpHas = "has"
OpMissing = "missing"
OpUnrated = "unrated"
OpIn = "in"
OpNotIn = "not_in"
OpInAny = "in_any"
OpInNone = "in_none"
)
// FilterKind — how a field's values match and what the value cell renders.
type FilterKind string
const (
KindScalarFuzzy FilterKind = "scalar_fuzzy" // word_similarity on a text column
KindArrayFuzzy FilterKind = "array_fuzzy" // word_similarity per element of a text[]
KindText FilterKind = "text" // fuzzy contains + exact is + empty (title-like)
KindEnum FilterKind = "enum" // exact scalar, = ANY(values)
KindBool FilterKind = "bool"
KindNumber FilterKind = "number"
KindDate FilterKind = "date"
KindPresence FilterKind = "presence" // has / missing
KindIdentifier FilterKind = "identifier" // exact is/is_not + has/missing
KindNoteText FilterKind = "note_text" // joined-table ILIKE substring (viewer's rows)
KindStatus FilterKind = "status" // unread / reading / finished
KindLibrary FilterKind = "library"
KindCollection FilterKind = "collection"
)
// Suggestion sources — where a field's value menu comes from. A source
// of suggestNone means the value cell is free input (numbers, dates,
// bools, fixed enums).
type SuggestSource string
const (
suggestNone SuggestSource = ""
suggestAuthor SuggestSource = "author"
suggestNarrator SuggestSource = "narrator"
suggestContrib SuggestSource = "contributor"
suggestPublisher SuggestSource = "publisher"
suggestTags SuggestSource = "tags"
suggestColumn SuggestSource = "column" // the field's own scalar column
suggestSeries SuggestSource = "series" // primary + alternate_info
suggestNoteText SuggestSource = "note_text" // the viewer's note snippets
suggestHighlight SuggestSource = "highlight_text"
suggestLibrary SuggestSource = "library" // visibility-scoped libraries
suggestCollection SuggestSource = "collection" // the viewer's collections
suggestAddedBy SuggestSource = "added_by" // admins who added books in scope
)
// Filter menu groups (display order for the field menu).
const (
GroupPeople = "People"
GroupPublish = "Publishing"
GroupTopics = "Topics"
GroupBookFacts = "Book facts"
GroupComics = "Comics & manga"
GroupMyReading = "My reading"
GroupLibrary = "Library"
)
// FilterRule is one leaf: field + operator + values (all bound params).
type FilterRule struct {
Field string
Op string
Values []string
}
// FilterTree is the qf wire shape: a group (mode + children) whose
// children are rules or nested groups. Groups AND/OR their children
// together; a group ANDs into its parent.
type FilterTree struct {
Mode string `json:"mode"`
Children []FilterChild `json:"children"`
}
// FilterChild is one node: a rule (Field set) or a group (Mode set).
type FilterChild struct {
Field string `json:"field,omitempty"`
Op string `json:"op,omitempty"`
Values []string `json:"values,omitempty"`
Mode string `json:"mode,omitempty"`
Children []FilterChild `json:"children,omitempty"`
}
// FilterField is one registry entry.
type FilterField struct {
Key string // wire name (the qf "field" value)
Label string // menu label
Group string // menu group
Kind FilterKind
Ops []string // allowed operators, display order
DefaultOp string // operator a new rule starts on (field-switch reset)
Clause func(r FilterRule, b *filterBuilder) string
Suggest SuggestSource
}
// opsWithValue / opsWithoutValue drive validation.
var opsNoValue = map[string]bool{
OpEmpty: true, OpIsTrue: true, OpIsFalse: true, OpUnrated: true,
OpInAny: true, OpInNone: true, OpHas: true, OpMissing: true,
}
// fixedDomains are the operator value domains that are STATIC (validated
// here) rather than data-driven suggestions.
var fixedDomains = map[string][]string{
"status": {"unread", "reading", "finished"},
"file_availability": {"available", "missing"},
}
// filterFields is THE registry. Order within a group = menu order.
var filterFields = []FilterField{
// ── People ────────────────────────────────────────────────────
{Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseAuthorArray, Suggest: suggestAuthor},
{Key: "narrator", Label: "Narrator", Group: GroupPeople, Kind: KindArrayFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseArrayFuzzyFactory("mi.narrators"), Suggest: suggestNarrator},
{Key: "contributor", Label: "Contributor", Group: GroupPeople, Kind: KindArrayFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseArrayFuzzyFactory("mi.contributors"), Suggest: suggestContrib},
// ── Publishing ────────────────────────────────────────────────
{Key: "publisher", Label: "Publisher", Group: GroupPublish, Kind: KindArrayFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseArrayFuzzyFactory("mi.publishers"), Suggest: suggestPublisher},
{Key: "imprint", Label: "Imprint", Group: GroupPublish, Kind: KindScalarFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseScalarFuzzyFactory("mi.imprint"), Suggest: suggestColumn},
{Key: "edition", Label: "Edition", Group: GroupPublish, Kind: KindScalarFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseScalarFuzzyFactory("mi.edition"), Suggest: suggestColumn},
{Key: "published_year", Label: "Published year", Group: GroupPublish, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clausePublishedYear, Suggest: suggestNone},
// ── Topics ────────────────────────────────────────────────────
{Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseScalarFuzzyFactory("mi.genre"), Suggest: suggestColumn},
{Key: "tags", Label: "Tags", Group: GroupTopics, Kind: KindArrayFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseArrayFuzzyFactory("mi.tags_search"), Suggest: suggestTags},
{Key: "story_arc", Label: "Story arc", Group: GroupTopics, Kind: KindScalarFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseScalarFuzzyFactory("mi.story_arc"), Suggest: suggestColumn},
{Key: "age_rating", Label: "Age rating", Group: GroupTopics, Kind: KindEnum,
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn},
// ── Book facts ────────────────────────────────────────────────
{Key: "title", Label: "Title", Group: GroupBookFacts, Kind: KindText,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseScalarFuzzyFactory("mi.title"), Suggest: suggestColumn},
{Key: "subtitle", Label: "Subtitle", Group: GroupBookFacts, Kind: KindText,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseScalarFuzzyFactory("mi.subtitle"), Suggest: suggestColumn},
{Key: "description", Label: "Description", Group: GroupBookFacts, Kind: KindText,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseScalarFuzzyFactory("mi.description"), Suggest: suggestNone},
{Key: "summary", Label: "Summary", Group: GroupBookFacts, Kind: KindPresence,
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
Clause: clausePresenceFactory("mi.summary"), Suggest: suggestNone},
{Key: "language", Label: "Language", Group: GroupBookFacts, Kind: KindScalarFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
Clause: clauseLanguage, Suggest: suggestColumn},
{Key: "page_count", Label: "Page count", Group: GroupBookFacts, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseNumberFactory("mi.page_count", parseNumberInt), Suggest: suggestNone},
{Key: "chapter_count", Label: "Chapter count", Group: GroupBookFacts, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseNumberFactory("mi.chapter_count", parseNumberInt), Suggest: suggestNone},
{Key: "total_characters", Label: "Total characters", Group: GroupBookFacts, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseNumberFactory("mi.total_characters", parseNumberInt), Suggest: suggestNone},
{Key: "community_rating", Label: "Community rating", Group: GroupBookFacts, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseNumberFactory("mi.community_rating", parseNumberFloat), Suggest: suggestNone},
{Key: "added_at", Label: "Date added", Group: GroupBookFacts, Kind: KindDate,
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
Clause: clauseDateFactory("mi.created_at"), Suggest: suggestNone},
{Key: "imported_at", Label: "Date imported", Group: GroupBookFacts, Kind: KindDate,
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
Clause: clauseDateFactory("mi.imported_at"), Suggest: suggestNone},
{Key: "web_url", Label: "Web URL", Group: GroupBookFacts, Kind: KindPresence,
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
Clause: clausePresenceFactory("mi.web_url"), Suggest: suggestNone},
{Key: "metadata_notes", Label: "Metadata notes", Group: GroupBookFacts, Kind: KindPresence,
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
Clause: clausePresenceFactory("mi.metadata_notes"), Suggest: suggestNone},
{Key: "isbn", Label: "ISBN", Group: GroupBookFacts, Kind: KindIdentifier,
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
Clause: clauseIdentifierFactory("mi.isbn"), Suggest: suggestNone},
{Key: "asin", Label: "ASIN", Group: GroupBookFacts, Kind: KindIdentifier,
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
Clause: clauseIdentifierFactory("mi.asin"), Suggest: suggestNone},
{Key: "file_size", Label: "File size", Group: GroupBookFacts, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseNumberFactory("mi.file_size", parseNumberInt), Suggest: suggestNone},
{Key: "file_availability", Label: "File availability", Group: GroupBookFacts, Kind: KindStatus,
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
Clause: clauseFileAvailability, Suggest: suggestNone},
{Key: "explicit", Label: "Explicit", Group: GroupBookFacts, Kind: KindBool,
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone},
{Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool,
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone},
{Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence,
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
Clause: clauseCover, Suggest: suggestNone},
{Key: "added_by", Label: "Added by", Group: GroupBookFacts, Kind: KindIdentifier,
Ops: []string{OpIs, OpHas, OpMissing}, DefaultOp: OpHas,
Clause: clauseAddedBy, Suggest: suggestAddedBy},
// ── Comics & manga ────────────────────────────────────────────
{Key: "series", Label: "Series", Group: GroupComics, Kind: KindScalarFuzzy,
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
// Full series membership: the primary series column AND the
// additional series held in alternate_info — a book whose
// membership of X is secondary must not hide.
Clause: clauseSeriesMembership, Suggest: suggestSeries},
{Key: "series_number", Label: "Series number", Group: GroupComics, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseNumberFactory("mi.series_number", parseNumberFloat), Suggest: suggestNone},
{Key: "series_count", Label: "Series count", Group: GroupComics, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseNumberFactory("mi.series_count", parseNumberInt), Suggest: suggestNone},
{Key: "volume", Label: "Volume", Group: GroupComics, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseNumberFactory("mi.volume", parseNumberInt), Suggest: suggestNone},
{Key: "format_group", Label: "Format group", Group: GroupComics, Kind: KindEnum,
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
Clause: clauseEnumFactory("mi.format_group"), Suggest: suggestColumn},
{Key: "mime_type", Label: "File type (mime)", Group: GroupComics, Kind: KindEnum,
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
Clause: clauseEnumFactory("mi.mime_type"), Suggest: suggestColumn},
{Key: "is_reflowable", Label: "Reflowable", Group: GroupComics, Kind: KindBool,
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
Clause: clauseBoolFactory("mi.is_reflowable"), Suggest: suggestNone},
{Key: "has_fixed_layout", Label: "Fixed layout", Group: GroupComics, Kind: KindBool,
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
Clause: clauseBoolFactory("mi.has_fixed_layout"), Suggest: suggestNone},
{Key: "is_black_and_white", Label: "Black & white", Group: GroupComics, Kind: KindBool,
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
Clause: clauseBoolFactory("mi.is_black_and_white"), Suggest: suggestNone},
{Key: "manga_type", Label: "Manga type", Group: GroupComics, Kind: KindEnum,
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
Clause: clauseEnumFactory("mi.manga_type"), Suggest: suggestColumn},
{Key: "reading_direction", Label: "Reading direction", Group: GroupComics, Kind: KindEnum,
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
Clause: clauseEnumFactory("mi.reading_direction"), Suggest: suggestColumn},
// ── My reading (user-relative — the VIEWER's rows) ────────────
{Key: "status", Label: "Reading status", Group: GroupMyReading, Kind: KindStatus,
Ops: []string{OpIs}, DefaultOp: OpIs,
Clause: clauseStatus, Suggest: suggestNone},
{Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
Clause: clauseProgressPercent, Suggest: suggestNone},
{Key: "last_read", Label: "Last read", Group: GroupMyReading, Kind: KindDate,
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
Clause: clauseLastRead, Suggest: suggestNone},
{Key: "my_rating", Label: "My rating", Group: GroupMyReading, Kind: KindNumber,
Ops: []string{OpIs, OpAtLeast, OpAtMost, OpUnrated}, DefaultOp: OpAtLeast,
Clause: clauseMyRating, Suggest: suggestNone},
{Key: "review", Label: "Has review", Group: GroupMyReading, Kind: KindPresence,
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
Clause: clauseReviewPresence, Suggest: suggestNone},
{Key: "notes", Label: "Notes", Group: GroupMyReading, Kind: KindPresence,
Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas,
Clause: clauseNotesPresence, Suggest: suggestNone},
{Key: "highlights", Label: "Highlights", Group: GroupMyReading, Kind: KindPresence,
Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas,
Clause: clauseHighlightsPresence, Suggest: suggestNone},
{Key: "note_text", Label: "Note text", Group: GroupMyReading, Kind: KindNoteText,
// "Contains" here is substring ILIKE, NOT the system-wide fuzzy:
// your own words deserve exact-ish. A book matches when ANY of
// the viewer's notes on it match (EXISTS + bound values).
Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains,
Clause: clauseNoteText, Suggest: suggestNoteText},
{Key: "highlight_text", Label: "Highlight text", Group: GroupMyReading, Kind: KindNoteText,
Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains,
Clause: clauseHighlightText, Suggest: suggestHighlight},
{Key: "time_spent", Label: "Time spent (minutes)", Group: GroupMyReading, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
Clause: clauseTimeSpent, Suggest: suggestNone},
{Key: "pages_read", Label: "Pages read", Group: GroupMyReading, Kind: KindNumber,
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
Clause: clausePagesRead, Suggest: suggestNone},
{Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection,
// Membership mirrors the collection page: excluded items and
// deleted collections never count; playlists are collections.
Ops: []string{OpIn, OpNotIn, OpInAny, OpInNone}, DefaultOp: OpIn,
Clause: clauseCollection, Suggest: suggestCollection},
// ── Library (holding structure + family lens) ─────────────────
{Key: "library", Label: "Library", Group: GroupLibrary, Kind: KindLibrary,
// Values are library IDs; the menu only ever offers the viewer's
// VISIBLE libraries (family lens — a hidden library can not even
// be offered).
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
Clause: clauseLibrary, Suggest: suggestLibrary},
}
// FilterFieldByKey returns the registry entry for a wire key.
func FilterFieldByKey(key string) (FilterField, bool) {
for _, f := range filterFields {
if f.Key == key {
return f, true
}
}
return FilterField{}, false
}
// FilterFieldsByGroup returns the registry grouped for the field menu,
// groups in display order.
func FilterFieldsByGroup() []struct {
Group string
Fields []FilterField
} {
order := []string{GroupPeople, GroupPublish, GroupTopics, GroupBookFacts, GroupComics, GroupMyReading, GroupLibrary}
out := make([]struct {
Group string
Fields []FilterField
}, 0, len(order))
for _, g := range order {
group := struct {
Group string
Fields []FilterField
}{Group: g}
for _, f := range filterFields {
if f.Group == g {
group.Fields = append(group.Fields, f)
}
}
out = append(out, group)
}
return out
}
// FilterFieldKeys returns every registered key (the completeness check
// for tests).
func FilterFieldKeys() []string {
keys := make([]string, 0, len(filterFields))
for _, f := range filterFields {
keys = append(keys, f.Key)
}
return keys
}
// fieldOpsAllowed reports whether op is allowed on the field.
func fieldOpsAllowed(f FilterField, op string) bool {
for _, o := range f.Ops {
if o == op {
return true
}
}
return false
}
// maxFilterDepth bounds group nesting (BookOrbit allows 5; the UI
// discourages depth but the model allows a little headroom).
const maxFilterDepth = 8
// ParseFilterTree parses + validates the qf wire JSON. Validation is
// the allow-list enforcement: unknown fields/operators and malformed
// values are a parse error (→ 400), never a silently-ignored token.
func ParseFilterTree(raw string) (*FilterTree, error) {
var t FilterTree
if err := json.Unmarshal([]byte(raw), &t); err != nil {
return nil, fmt.Errorf("invalid qf JSON: %w", err)
}
if err := validateGroup(&FilterChild{Mode: t.Mode, Children: t.Children}, 1); err != nil {
return nil, err
}
return &t, nil
}
func validateGroup(g *FilterChild, depth int) error {
if depth > maxFilterDepth {
return fmt.Errorf("filter group nesting deeper than %d", maxFilterDepth)
}
if g.Mode != "all" && g.Mode != "any" {
return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode)
}
if len(g.Children) == 0 {
return fmt.Errorf("filter group has no children")
}
for _, c := range g.Children {
if c.Field == "" && c.Mode != "" {
if err := validateGroup(&c, depth+1); err != nil {
return err
}
continue
}
if err := validateRule(c); err != nil {
return err
}
}
return nil
}
// numericParsers map each numeric field's wire values to the Go value
// bound into SQL — validation and clause-building share one parser per
// field, so a value that passes validation always binds.
var numericParsers = map[string]func(string) (any, error){
"published_year": parseNumberInt,
"page_count": parseNumberInt,
"chapter_count": parseNumberInt,
"total_characters": parseNumberInt,
"file_size": parseNumberInt,
"series_count": parseNumberInt,
"volume": parseNumberInt,
"my_rating": parseNumberInt,
"series_number": parseNumberFloat,
"community_rating": parseNumberFloat,
"progress_percent": parseNumberFloat,
"time_spent": parseNumberFloat,
"pages_read": parseNumberFloat,
}
func validateRule(c FilterChild) error {
f, ok := FilterFieldByKey(c.Field)
if !ok {
return fmt.Errorf("unknown filter field %q", c.Field)
}
if !fieldOpsAllowed(f, c.Op) {
return fmt.Errorf("operator %q not allowed on field %q", c.Op, c.Field)
}
if opsNoValue[c.Op] {
if len(c.Values) != 0 {
return fmt.Errorf("operator %q takes no values", c.Op)
}
return nil
}
if len(c.Values) == 0 {
return fmt.Errorf("operator %q on field %q needs a value", c.Op, c.Field)
}
if c.Op == OpBetween && len(c.Values) != 2 {
return fmt.Errorf("between takes exactly two values")
}
// Numeric fields: values must parse (they bind as numbers).
if parse, ok := numericParsers[f.Key]; ok {
for _, v := range c.Values {
if _, err := parse(v); err != nil {
return fmt.Errorf("field %q needs numeric values, got %q", f.Key, v)
}
}
}
// Date within_days: a positive integer day count.
if c.Op == OpWithinDays {
n, err := strconv.Atoi(c.Values[0])
if err != nil || n <= 0 {
return fmt.Errorf("within_days needs a positive day count, got %q", c.Values[0])
}
}
// Static domains (status, file_availability).
if domain, ok := fixedDomains[f.Key]; ok {
for _, v := range c.Values {
found := false
for _, d := range domain {
if d == v {
found = true
break
}
}
if !found {
return fmt.Errorf("field %q value must be one of %v, got %q", f.Key, domain, v)
}
}
}
// UUID-valued fields (collection, library, added_by "is").
switch f.Key {
case "library", "collection":
for _, v := range c.Values {
if _, err := uuid.Parse(v); err != nil {
return fmt.Errorf("field %q values must be UUIDs, got %q", f.Key, v)
}
}
case "added_by":
if c.Op == OpIs {
if _, err := uuid.Parse(c.Values[0]); err != nil {
return fmt.Errorf("added_by \"is\" needs a user UUID, got %q", c.Values[0])
}
}
}
return nil
}
func parseNumberInt(v string) (any, error) {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, err
}
return n, nil
}
func parseNumberFloat(v string) (any, error) {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return nil, err
}
return f, nil
}
+131
View File
@@ -0,0 +1,131 @@
package services
// Filter registry unit tests (faceted plan Phase 9): every entry is
// complete (kind, operators, default operator, clause, suggestion
// source), the mandated surfaces are present (including the three
// holding structures), and the qf parse is a strict allow-list.
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFilterRegistryCompleteness(t *testing.T) {
keys := FilterFieldKeys()
require.NotEmpty(t, keys)
seen := map[string]bool{}
for _, key := range keys {
require.False(t, seen[key], "duplicate registry key %q", key)
seen[key] = true
f, ok := FilterFieldByKey(key)
require.True(t, ok)
require.NotEmpty(t, f.Label, "%s: label", key)
require.NotEmpty(t, f.Group, "%s: menu group", key)
require.NotEmpty(t, f.Kind, "%s: kind", key)
require.NotEmpty(t, f.Ops, "%s: operators", key)
require.NotNil(t, f.Clause, "%s: match clause", key)
require.Contains(t, f.Ops, f.DefaultOp, "%s: default operator must be allowed", key)
}
// The mandated surfaces — every book attribute class, the user-
// relative class, and the three HOLDING STRUCTURES (library,
// collections, series — the registry is the completeness check).
mandated := []string{
// people / publishing / topics
"author", "narrator", "contributor", "publisher", "imprint",
"edition", "published_year", "genre", "tags", "story_arc", "age_rating",
// reading surface (comics & manga)
"format_group", "mime_type", "is_reflowable", "has_fixed_layout",
"is_black_and_white", "manga_type", "reading_direction", "explicit", "abridged",
// series facts + numbers + dates + text + presence + identifiers
"series", "series_number", "series_count", "volume",
"page_count", "chapter_count", "total_characters", "file_size",
"community_rating", "added_at", "imported_at",
"title", "subtitle", "description", "summary", "language",
"web_url", "metadata_notes", "isbn", "asin", "cover",
"file_availability", "added_by",
// user-relative (everything that pertains to a book)
"status", "progress_percent", "last_read", "my_rating", "review",
"notes", "highlights", "note_text", "highlight_text", "time_spent",
"pages_read", "collection",
// holding structure
"library",
}
for _, key := range mandated {
assert.True(t, seen[key], "mandated filter field %q missing from the registry", key)
}
}
func TestFilterParseValidation(t *testing.T) {
valid := func(raw string) {
_, err := ParseFilterTree(raw)
require.NoError(t, err, "expected %s to parse", raw)
}
invalid := func(raw, wantContain string) {
_, err := ParseFilterTree(raw)
require.Error(t, err, "expected %s to be rejected", raw)
if wantContain != "" {
require.Contains(t, err.Error(), wantContain)
}
}
valid(`{"mode":"all","children":[{"field":"author","op":"contains","values":["Voss"]}]}`)
valid(`{"mode":"any","children":[
{"field":"author","op":"contains","values":["Voss"]},
{"mode":"all","children":[{"field":"genre","op":"is","values":["fantasy"]}]}]}`)
valid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["10","20"]}]}`)
valid(`{"mode":"all","children":[{"field":"cover","op":"has"}]}`)
valid(`{"mode":"all","children":[{"field":"status","op":"is","values":["reading","finished"]}]}`)
valid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["14"]}]}`)
valid(`{"mode":"all","children":[{"field":"my_rating","op":"unrated"}]}`)
valid(`{"mode":"all","children":[{"field":"collection","op":"in_any"}]}`)
valid(`{"mode":"all","children":[{"field":"library","op":"is","values":["` +
"12345678-1234-1234-1234-123456789012" + `"]}]}`)
invalid(`{not json`, "invalid qf JSON")
invalid(`{"mode":"all","children":[]}`, "no children")
invalid(`{"mode":"sometimes","children":[{"field":"author","op":"contains","values":["x"]}]}`, "mode must be all|any")
invalid(`{"mode":"all","children":[{"field":"not_a_field","op":"is","values":["x"]}]}`, "unknown filter field")
invalid(`{"mode":"all","children":[{"field":"author","op":"explodes","values":["x"]}]}`, "not allowed on field")
invalid(`{"mode":"all","children":[{"field":"page_count","op":"contains","values":["1"]}]}`, "not allowed on field")
invalid(`{"mode":"all","children":[{"field":"page_count","op":"at_least","values":["abc"]}]}`, "numeric")
invalid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["1"]}]}`, "exactly two")
invalid(`{"mode":"all","children":[{"field":"cover","op":"has","values":["x"]}]}`, "takes no values")
invalid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["0"]}]}`, "positive day count")
invalid(`{"mode":"all","children":[{"field":"status","op":"is","values":["platypus"]}]}`, "must be one of")
invalid(`{"mode":"all","children":[{"field":"library","op":"is","values":["not-a-uuid"]}]}`, "must be UUIDs")
invalid(`{"mode":"all","children":[{"field":"author","op":"contains"}]}`, "needs a value")
}
func TestFilterParseDepthCap(t *testing.T) {
// Nesting beyond maxFilterDepth is rejected.
raw := `{"mode":"all","children":[`
for i := 0; i < maxFilterDepth+2; i++ {
raw += `{"mode":"all","children":[`
}
raw += `{"field":"author","op":"contains","values":["x"]}`
for i := 0; i < maxFilterDepth+2; i++ {
raw += `]}`
}
raw += `]}`
_, err := ParseFilterTree(raw)
require.Error(t, err)
require.Contains(t, err.Error(), "nesting deeper")
}
func TestFilterFieldsByGroupOrdering(t *testing.T) {
groups := FilterFieldsByGroup()
require.NotEmpty(t, groups)
first := groups[0].Group
if !strings.EqualFold(first, GroupPeople) {
t.Fatalf("first menu group should be %q, got %q", GroupPeople, first)
}
for _, g := range groups {
require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group)
}
}
+39 -9
View File
@@ -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