MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-327

6.1 Structured search + filter builder

Done
Description

The advanced-search layer: a filter builder — field / operator / value rows under a flat Match all / Match any combinator — compiling to a safe parameterized query over the project's issues, including custom-field values (5.3), labels and components (5.4) through the join-predicate contracts their schemas documented for this story. Free-text contains-match on title/description. No query-language parser — the builder IS the advanced mode.

Where it sits relative to the mirror (verified, and the deviation recorded). Jira basic search is AND-of-facets with IN-semantics per field; its OWN docs blacklist what basic cannot say — OR across fields, NOT, EMPTY, comparisons (!=, >, <, ≥, ≤) — all JQL-only. The builder deliberately sits between: it delivers exactly that blacklist (negation, empty/not-empty, comparisons, match-ANY) as structured rows — the no-parser route to JQL's most-used power. ONE level only (no nested groups — that is where builders become parsers; documented extension). The 2.5.4 facet bar REMAINS as the quick path (Jira keeps basic next to advanced); the builder is the "Advanced" surface beside it, and a facet state upgrades losslessly into builder rows (the one-way basic→advanced conversion the mirror ships; complex builder states do not down-convert — also the mirror rule).

The FilterAST + the operator registry (the load-bearing piece). One typed shape — { combinator, conditions: [{ field, operator, value }] } — edited by the UI, carried in a versioned URL param (?filter=v1:…, composing with the shipped ?view/?sort/?page substrate — THE serialization Story 6.2 persists as saved filters), and compiled by a per-field-type operator registry: enum-ish fields (kind, status, priority, assignee, reporter, sprint, label, component, select-CF, user-CF) get is any of / is none of / is empty / is not empty; text (title/description + text-CF) gets contains / does not contain; numbers (story points, estimate, number-CF) get = ≠ < ≤ > ≥ / empty; dates (created, updated, due, date-CF) get on or before / on or after / between / in the last N days / in the next N days / empty (the relative forms mirror Jira basic's verified date pickers — and keep 6.2's saved filters like "due this week" durable). The registry is TOTAL (mistake #29): every field the builder offers has an explicit operator set + compile function + value-editor kind; unknown field/operator ids are typed 422s.

Safe compilation (the injection AC). Conditions compile to Prisma where-args / bound $queryRaw parameters ONLY — no string-built SQL anywhere on the path; the Epic-5 predicates ride the documented contracts (typed-EAV [fieldId, value*] indexed JOINs for custom fields; the label/component join tables). The compiled WHERE fragment feeds the EXISTING reads — the flat List (2.5.8/2.5.12 sort + pagination + count: the navigator-faithful result surface, 50/page like the mirror) AND the Tree (2.5.1's ancestor-retaining read composes the same fragment, matches full-strength + muted ancestors) — one compiler, both views, no second query path.

Bounded + complete (finding #57 + the real-product states). Free-text ILIKE is backed by a pg_trgm GIN index (a contains-scan over 10k titles must not table-scan); every value-editor option list is the bounded read its owner ships (members, labels autocomplete, options ≤55); the result count tracks the filter; zero-result, invalid-URL-param (typed, recoverable — never a crash), stale-referent (a deleted option/label id in a shared URL → that condition reports "unknown value" and matches nothing rather than erroring the page), loading, and over-long-filter (row cap, e.g. 20 conditions — a sanity guard) states are all designed + asserted.

Out of scope (documented extension slots, each justified): a JQL-style text query language (the stub's hard NO); nested condition groups (builder→parser line); cross-project/workspace-wide search (the shipped substrate is active-project-scoped — rung 2; revisit with a real use case); stemmed/full-text search (Lucene-class infra; ILIKE+trgm covers the title/description scope); saving/naming filters (Story 6.2 — this story ships the serialization it persists); filtering by watcher/"watched by me" and comment-count-style meta fields (additive registry entries when a use case lands); ORDER-BY as a filter concern (sorting is the List's shipped surface).

Verification

  • Pull the Story branch, pnpm install, pnpm prisma migrate dev (the 6.1.1 pg_trgm index migration applies cleanly; re-run reports "No difference detected"), pnpm db:seed, pnpm dev.
  • pnpm test:coverage — Vitest (real Postgres) over the AST/registry/compiler (operator matrix, injection fuzz, serialization round-trip) ≥90% per-file branch/fn/line.
  • Builder flow: sign in as zhuyue@motir.co / !QAZ1qaz → /issues → the "Advanced" affordance beside the 2.5.4 facet bar (matching design/work-items/filter-builder.mock.html). Build: Status is any of (To do, In progress) AND Priority is none of (Lowest) AND Due in the next 14 days → the List shows the matching set + count; switch the combinator to Match any → the set widens accordingly; add "Story points > 5" and "Description contains oauth" rows → results track; the URL carries ?filter=v1:… (reload + share-tab restores the exact builder state, composing with ?view/?sort/?page).
  • Negation/empty (the beyond-basic operators): "Assignee is empty" and "Label is none of (perf-q3)" both compile and match correctly (the JQL-blacklist set works parser-free).
  • Epic-5 predicates: filter by a select custom field (Severity is any of High), a number CF (Effort ≥ 3), a date CF (between), a user CF, a label, and a component — each via the indexed joins (EXPLAIN spot-check: index scans, no seq-scan over the value table); a deleted/archived referent in a shared URL degrades to the "unknown value" condition state, never a crash.
  • Both views: the same filter applied in Tree mode retains ancestor context (muted non-matching ancestors — the 2.5.1 behaviour) with identical match sets; the List paginates the result at 50/page with the count.
  • Facet upgrade: set kind+assignee in the quick facet bar → "Edit in Advanced" carries them in as rows losslessly; a builder state using OR/negation shows the facet bar as superseded (no silent down-conversion — the mirror rule).
  • Injection check: the fuzz suite (quotes, SQL meta-chars, operator smuggling in values/field ids) produces parameterized queries only — assert via query logging that no user string reaches SQL unparameterized; malformed ?filter= params yield the typed recoverable state.
  • pnpm test:e2e --grep filter-builder — Playwright over the real stack: the build → results → URL round-trip → Epic-5-predicate journey.
  • a11y check: the builder (rows, operator menus, per-type editors, combinator toggle) passes the strict axe sweep; fully keyboard-operable; colour via --el-*, shape via element tokens.