Skip to content

**Phase D6 — curated filters**: filter:/filter[]: naming convention → dropdown/multiselect, option-query execution (parallel, cached, re-run on Refresh), cascading #160

Description

@BorisTyshkevich

Goal

Upgrade a Dashboard filter from D3's free-text input to a dropdown (filter: <param>) or
multiselect (filter[]: <param>)
when a specially-named Library query supplies the option
list — plus the machinery D3 explicitly deferred: parallel option-query execution, caching,
and cascading
(a filter's option list can itself depend on another filter's current value).

Phase D6 of the Dashboard epic (#149 §6, "Global filters" items 2-4, and the "Open questions"
entry "Cascading evaluation order"). Builds on D3 (#152, merged) — the param-detection,
state.varValues, per-tile debounce/generation-guard, and unfilled-param gating this phase
reuses wholesale, just fanned out to a second class of query (option-queries) alongside tiles.

Already decided (from #149 §6 — not reopened here)

  • Naming convention, no schema change: a saved query named exactly filter: <param> (or
    filter[]: <param> for multiselect) supplies that param's options — matched by name, not a
    new field on the saved-query object.
  • Result-column convention: 1 column → values only; 2 columns → value (bound/injected) +
    label (displayed) — e.g. store an airport code, show its name.
  • Author controls cost: the filter query is arbitrary SQL (joins, dimension tables,
    DISTINCT, ORDER BY) — we deliberately do not introspect the schema to auto-derive options,
    since a tile may join arbitrary tables with no single reliable column to DISTINCT.
  • filters: bundle (one query, many params) stays deferred — per-param queries remain the
    only supported shape; revisit only if a real dashboard accumulates many cheap enums worth
    batching.

Prior art — what we take from Grafana, and what we deliberately don't

Grafana's variable system is the closest existing design for "curated, cascading dashboard
filters," and its evaluation model is worth reusing directly:

  • Parent-first evaluation order via a dependency graph among variables ("chained variables"
    in Grafana's docs) — a variable whose query references another variable is evaluated only
    after that other variable has a value.
  • Dependent-selection invalidation — when a parent variable changes, Grafana re-runs every
    variable that depends on it and, if the previously-selected value is no longer in the
    refreshed option list, resets it (cascading further if that variable has its own dependents).
  • "All" for multiselect degrades to "no filter", not a special sentinel value.

What we do not take from Grafana: its $variable/macro text-substitution layer
($conditionalTest, $__timeFilter, raw $var interpolation into SQL strings). That exists in
Grafana only because its variables are untyped string substitution with no query-parameter
protocol underneath. We already have a type-safe, injection-safe substitution mechanism
(ClickHouse-native {name:Type}, #134) — a filter:/filter[]: query is just an ordinary saved
SELECT, and cascading dependency is just "this SQL happens to reference another {param:Type}"
— detected with the exact same detectParams/readStatementParams this codebase already
has. No macro syntax, no SQL string-rewriting, nothing new to parse.

Scope

src/core/dashboard.js — new pure functions (100%-covered), building directly on D3's
dashboardParams:

// A curated filter: a saved query named "filter: <param>" or "filter[]: <param>"
// (whitespace-trimmed, case-sensitive prefix). Detection is name-based, reusing
// no new parsing beyond a name match + the existing readStatementParams for its SQL.
const FILTER_NAME_RE = /^filter(\[\])?:\s*(\S.*)$/;

export function detectCuratedFilters(savedQueries) {
  // → [{ param, multi, sql, dependsOn: string[] }], deduped by param (first match wins,
  // consistent with dashboardParams' first-appearance-order convention). `dependsOn` is
  // readStatementParams(sql) minus `param` itself — the cascading dependency edges.
}

export function orderCuratedFilters(filters) {
  // Topological sort of the dependsOn DAG, parent-first. Returns
  // { order: [...], cycles: [...] } — a filter caught in a cycle (or depending on an
  // undetected/non-curated param) is excluded from `order` and reported in `cycles`
  // rather than throwing, so one bad filter degrades instead of breaking the dashboard.
}

src/net/ch-client.js — a thin queryFilterOptions(ctx, sql, signal, params) wrapper
alongside D3's queryDashboardTile (same readonly:2, same queryJson core) — option-queries
are read-only tile-shaped requests, nothing new at the transport layer.

src/ui/dashboard.js — the new behavior:

  1. Execution order: on load (and on Refresh), run every filter in order from
    orderCuratedFilters. Root filters (no dependsOn) fire immediately and in parallel
    (reuse the existing bounded-concurrency fan-out already used for tile fetches — one shared
    limiter, so N curated filters + M tiles never exceed the current concurrency ceiling
    combined). A dependent filter waits until every param in its dependsOn has a value; while
    waiting, its control renders disabled with a "depends on: year" hint — the same visual
    language as D3's per-tile "enter a value for…" placeholder.
  2. Caching: memoize each curated filter's last-fetched option list keyed by a stable
    serialization of the subset of varValues it depends on ({} for a root filter — i.e. fetch
    once, never again until Refresh). Navigating back to a previously-seen parent value is a
    cache hit, no network call. Refresh clears the cache map entirely and re-fetches
    everything from scratch — identical semantics to how Refresh already re-runs every tile.
  3. Cascading on value change: when any filter's value changes —
    • re-run tiles referencing it (unchanged D3 mechanism), and
    • re-run every curated filter whose dependsOn includes it (its direct children in the DAG),
      with a cache-bypassing fetch;
    • for each such child, if its current selection is no longer present in the refreshed
      option list, clear it — which recursively repeats this step for the child's own children
      (grandchildren re-run too) and also triggers the tile re-run for the now-empty param;
    • if the current selection is still valid, leave it untouched (no spurious tile re-runs from
      an option-list refresh alone — only an actual value change propagates further).
      Reuses D3's per-slot generation counter (extended to curated-filter slots, not just tiles) so
      a stale in-flight option-query response can never overwrite a newer one.
  4. Cycle / bad-dependency degradation: a filter reported in orderCuratedFilters's cycles
    renders as a plain text input (D3's existing fallback control) with a small warning
    affordance — the dashboard as a whole still works, only that one filter loses curation.
  5. Multiselect control: filter[]: renders a native <select multiple> bound to
    {param:Array(T)} (paramArgs already threads array values through as ClickHouse expects —
    no new encoding). A richer checklist-popover control is a future polish, not required for v1.
    "Select none"/"All" is just "no value" — the predicate is omitted, identical to a text
    filter's empty-string semantics from D3; no (All) sentinel option is added to the list.

Teststests/unit/dashboard.test.js: detectCuratedFilters (name matching, both prefixes,
dedup, dependsOn extraction); orderCuratedFilters (linear chain, diamond dependency, a
2-cycle reported and excluded, a filter depending on a non-curated/undetected param treated as a
root with a permanently-unfilled dependency — same "enter a value" gating as a normal param).
UI-layer (src/ui/dashboard.js, integration-tested): parallel root fan-out fires together;
dependent waits then fires once its dependency is filled; changing a parent re-fetches only
direct children, invalidates a now-invalid child selection, and cascades to grandchildren;
Refresh busts the cache; a superseded option-query response is discarded (generation guard);
cache hit skips the network call on a revisited parent value.

Design decisions

  • Dependency detection reuses readStatementParams verbatim — a curated filter's SQL is
    parsed exactly like tile SQL; "cascading" is not a new concept, just "this query happens to
    reference another dashboard param."
  • Degrade, don't fail: a cyclic or otherwise-unresolvable filter dependency falls back to a
    plain text input rather than blocking the whole dashboard from rendering.
  • One shared concurrency limiter across tiles and option-queries (not a second, independent
    one) — avoids doubling worst-case concurrent-request pressure on the cluster when a dashboard
    has both many tiles and many curated filters.
  • Cache keyed by dependency values, busted only by Refresh or a dependency change — no TTL,
    no background polling (consistent with Feature: open favorited Library queries as an interactive Dashboard #149's pinned "manual Refresh only in v1").

Acceptance criteria

  • A saved query named filter: year upgrades the year filter from D3's text input to a
    dropdown populated by that query's results; 1-column results use the value as its own
    label, 2-column results show label while binding value.
  • A saved query named filter[]: carrier renders a multiselect; selecting 0 values omits
    the predicate entirely (same as an empty text filter), selecting N values binds
    {carrier:Array(T)}.
  • Root curated filters (no dependency on another param) fetch their options in parallel
    on dashboard load, sharing the existing tile-fetch concurrency limiter.
  • A filter query referencing another dashboard param (e.g. filter: origin selecting
    WHERE {year:UInt16} = ...) renders disabled until year has a value, then fetches.
  • Changing year re-fetches origin's option list; if the previously-selected origin is no
    longer present, it's cleared (and any tile/filter depending on origin reacts
    accordingly); if it's still present, it stays selected and nothing downstream re-runs.
  • A 2-filter cycle (or a filter depending on itself) is detected, excluded from normal
    evaluation, and rendered as a plain text input — the rest of the dashboard is unaffected.
  • Revisiting a previously-seen parent value is a cache hit (no network request); Refresh
    clears all curated-filter caches and re-fetches everything.
  • A stale (superseded) option-query response is discarded, never overwriting a newer one.
  • Coverage gates hold: new src/core/dashboard.js functions at 100%; src/net/ch-client.js
    at 100%; src/ui/dashboard.js at its existing UI-layer gate; no new runtime dependency.

Non-goals

A richer checklist/popover multiselect control (native <select multiple> is the v1 UI); the
filters: single-query-many-params bundle (#149, deferred); server-side/persisted caching
(client-side, cleared on tab close, same as D3); auto-refresh/polling of option lists; per-tile
Type/X/Y overrides, expand modal, drill-down (D7, #149); export (D8, #149).

Tracking

Phase D6 of #149. Depends on D3 (#152, merged) for the param-detection/varValues/generation-
guard primitives this phase fans out to option-queries.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions