Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b3b65a5
feat(#360): allow parameters in Filter sources; add shared prepareFil…
BorisTyshkevich Jul 21, 2026
644a670
feat(#360): parameterized Filter sources in the Dashboard viewer runtime
BorisTyshkevich Jul 21, 2026
b374f8a
feat(#360): filter-bar waiting/stale/error affordance for source-back…
BorisTyshkevich Jul 21, 2026
08df743
fix(#360): clear a filter's options during a selective rerun so stale…
BorisTyshkevich Jul 21, 2026
4811601
feat(#360): Workbench Filter preview uses the shared prepareFilterSou…
BorisTyshkevich Jul 21, 2026
ff60716
fix(#360): preflight once per commit for the selective source+panel w…
BorisTyshkevich Jul 21, 2026
b3f550c
refactor(#360): skip redundant tab analysis for Filter tabs; style th…
BorisTyshkevich Jul 21, 2026
7278a5b
docs(#360): migrate the query-log-explorer example + CHANGELOG (recon…
BorisTyshkevich Jul 21, 2026
1c49dc8
test(#360): e2e — parameterized Filter source waits then runs once wi…
BorisTyshkevich Jul 21, 2026
28e9947
fix(#360): Filter source param type-conflicts are errors, not runnabl…
BorisTyshkevich Jul 21, 2026
bd81ccc
fix(#360): a superseded or destroyed selective commit no longer runs …
BorisTyshkevich Jul 21, 2026
4b01df1
fix(#360): render source-backed filters by topology, update status im…
BorisTyshkevich Jul 21, 2026
a49f02c
docs(#360): make the example qle-filter {to} optional to match its pa…
BorisTyshkevich Jul 21, 2026
2d2c1ec
fix(#360): drop over-broad commit-generation guard; rely on per-sourc…
BorisTyshkevich Jul 21, 2026
58063aa
fix(#360): full refresh defers affected panels when its Filter wave w…
BorisTyshkevich Jul 21, 2026
6161d9c
docs(#360): trim review-history comments to stable invariants (review)
BorisTyshkevich Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,39 @@ auto-generated per-PR notes; this file is the curated, human-readable history.

## [Unreleased]

### Added
- **Parameterized Dashboard Filter sources with single-layer dependencies**
(#360). A Filter-role source query may now declare its own `{name:Type}`
query parameters and bind committed *root* Dashboard filter values through the
existing native `param_<name>` pipeline (no textual SQL interpolation) — so a
query-log option source can compute its user / query-kind / exception-code
lists for the selected `{from:DateTime}` / `{to:DateTime}` window instead of a
hard-coded range. A source **waits** (issues no ClickHouse request) until every
required root parameter is committed, active, valid, and serializable; relative
values (`-1d`, `now`) resolve against one wall clock per wave; changing a root
filter reruns **only** the sources that declare it, then the affected panels, in
one combined wave; a refreshed option set that drops a filter's active value
deactivates it before panels run; and a Filter source that depends on *another*
source-backed parameter is rejected with a visible diagnostic (cascading Filter
sources are unsupported — single-layer only). Dashboard execution and the
Workbench Filter preview share one preparation operation (`prepareFilterSource`
in `core/filter-execution.ts`), so structural diagnostics, relative-value
resolution, optional-block materialization, validation/serialization, and
native arguments are identical on both surfaces. Source-backed filters render a
waiting / stale / error affordance in the filter bar (chosen by source
topology, not transient status) instead of silently degrading to a plain
control; a superseded or session-destroyed commit can never publish stale
results or run its panels, and a dependency change clears the affected options
so a stale set can't appear current while the new source loads. Filter
execution injects no `readonly` setting (kept from #359).
`examples/query-log-explorer.json`'s `qle-filter` source is migrated to a
`{from:DateTime}` / optional `{to:DateTime}` window (matching its panels, where
`to` is optional and means "up to now") as a worked example.

### Fixed
- Removed a stray NUL byte embedded in `dashboard-viewer-session.ts`'s
`optionsSignature` (introduced by #361), which silently made the file look
binary to plain `grep`/`rg`.
- **Dashboard filters sharing one Filter-source query now populate** (#359).
When several Dashboard filter definitions referenced the same Filter-role
source query, the viewer ran that query once per definition and keyed each
Expand Down
2 changes: 1 addition & 1 deletion examples/query-log-explorer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"queries": [
{
"id": "qle-filter",
"sql": "SELECT\n arraySort(item -> item.label, groupUniqArray((user AS value, splitByChar('@', user)[1] AS label))) AS user,\n arraySort(groupUniqArray(query_kind)) AS query_kind\nFROM system.query_log WHERE query_log.user != ''\nSETTINGS enable_named_columns_in_function_tuple = 1",
"sql": "SELECT\n arraySort(item -> item.label, groupUniqArray((user AS value, splitByChar('@', user)[1] AS label))) AS user,\n arraySort(groupUniqArray(query_kind)) AS query_kind\nFROM system.query_log\nWHERE query_log.user != ''\n AND event_time >= {from:DateTime}\n /*[ AND event_time <= {to:DateTime} ]*/\nSETTINGS enable_named_columns_in_function_tuple = 1",
"specVersion": 1,
"spec": {
"name": "Filter",
Expand Down
31 changes: 31 additions & 0 deletions src/application/workbench-parameter-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import {
import type {
ParameterAnalysis, PreparedSource, PreparedBatch, PreparedFieldState, ValidationMode, FieldControl,
} from '../core/param-pipeline.js';
import { analyzeFilterSource, prepareFilterSource } from '../core/filter-execution.js';
import type { FilterSourcePreparation } from '../core/filter-execution.js';
import { isRowReturning } from '../core/sql-split.js';
import { effectiveFilterActive } from '../state.js';
import type { QueryTab, SaveJSON } from '../state.js';
Expand Down Expand Up @@ -118,6 +120,16 @@ export interface WorkbenchParameterSession {
prepareAnalyzedBatch(analysis: ParameterAnalysis, wallNowMs: number, validationMode?: ValidationMode): PreparedBatch;
prepareTabBatch(sql: string, wallNowMs: number, validationMode?: ValidationMode): PreparedBatch;
prepareTabSource(sql: string, wallNowMs: number, validationMode?: ValidationMode): PreparedSource;
/** #360 Workbench parity: a Filter tab's own preview, prepared through the
* SAME shared `analyzeFilterSource`/`prepareFilterSource` pipeline the
* Dashboard's `runFilterSource` calls (`core/filter-execution.js`) —
* never a second, independently-maintained parameter-binding path (issue
* #360's explicit rule). Driven by the same live `varValues`/
* `filterActive` (#165 activation map) every other `prepare*` method here
* reads; no `sourceBackedParams` — the Workbench has no Dashboard
* topology (no other source could be "backing" one of these params), so
* the cascading-Filter-sources rule has nothing to check here. */
prepareFilterPreview(sql: string, wallNowMs: number): FilterSourcePreparation;
execStatementSql(stmt: string): string;
varGateBlocked(wallNowMs?: number): boolean;
hardenVar(name: string, field?: PreparedFieldState): void;
Expand Down Expand Up @@ -182,6 +194,24 @@ export function createWorkbenchParameterSession(deps: WorkbenchParameterSessionD
return prepareTabBatch(sql, wallNowMs, validationMode).sources[0];
}

// #360 Workbench parity: the Filter tab's OWN preview shares the EXACT same
// analyze/prepare pipeline the Dashboard's runFilterSource calls — never a
// second, independently-maintained parameter-binding path (issue #360's
// explicit rule: "Do not independently implement parameter binding in
// workbench-session.ts and dashboard-viewer-session.ts"). Unlike the
// Dashboard session's own once-per-construction `analyzed` (cached against
// a stable per-source runtime), `analyzeFilterSource` re-scans `sql` fresh
// on every call here — a Workbench tab's Filter SQL can change on every
// keystroke, so there is no stable runtime to cache the analysis against.
// No `sourceBackedParams`: the Workbench has no Dashboard topology (no
// other source could be "backing" one of these params), so the cascading-
// Filter-sources rule has nothing to check.
function prepareFilterPreview(sql: string, wallNowMs: number): FilterSourcePreparation {
return prepareFilterSource(analyzeFilterSource(sql), {
values: deps.varValues(), active: activeMap(), wallNowMs,
});
}

// The execution text of one statement (#165): only active optional blocks
// retained, markers stripped — byte-identical for SQL without blocks. Follows
// the #134 bind gate: a non-row-returning statement passes through verbatim.
Expand Down Expand Up @@ -311,6 +341,7 @@ export function createWorkbenchParameterSession(deps: WorkbenchParameterSessionD
prepareAnalyzedBatch,
prepareTabBatch,
prepareTabSource,
prepareFilterPreview,
execStatementSql,
varGateBlocked,
hardenVar,
Expand Down
182 changes: 169 additions & 13 deletions src/core/filter-execution.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { detectSqlFormat as _detectSqlFormat } from './format.js';
import { analysisView } from './param-pipeline.js';
import { scanParamDeclarations } from './param-scan.js';
import {
analyzeParameterizedSources, prepareParameterizedBatch, mergedSourceArgs, mergedSourceSql,
} from './param-pipeline.js';
import type { ParameterAnalysis, BoundParamSnapshot } from './param-pipeline.js';
import { isRowReturning as _isRowReturning, splitStatements as _splitStatements } from './sql-split.js';
import { diagnostic as makeDiagnostic } from './diagnostics.js';
import type { Diagnostic } from './diagnostics.js';
Expand Down Expand Up @@ -46,9 +48,6 @@ export function filterSqlDiagnostics(sql?: string | null): FilterSqlDiagnostic[]
} else if (!isRowReturning(statements[0])) {
out.push(diagnostic('filter-sql-not-row-returning', 'Filter SQL must be a row-returning statement.'));
}
if (scanParamDeclarations(analysisView(text)).length) {
out.push(diagnostic('filter-source-parameters', 'Filter SQL cannot declare query parameters.'));
}
if (detectSqlFormat(text)) {
out.push(diagnostic('filter-owned-format', 'Filter SQL cannot include a trailing FORMAT clause.'));
}
Expand All @@ -72,21 +71,178 @@ export interface FilterExecutionPlan {
error: string | null;
}

/** The owned, lossless, bounded transport params every Filter-role query runs
* under — `max_result_bytes` plus the four JSON `output_format` flags that
* make the numeric/decimal round-trip lossless over JSON. Shared by
* `filterExecution` and `prepareFilterSource` so the caps are defined once;
* `overrides` layers on top (a caller-supplied extra/overriding param, same
* as `FilterExecutionDefaults.params`). Pure. */
function filterOwnedParams(overrides: Record<string, string | number> = {}): Record<string, string | number> {
return {
max_result_bytes: FILTER_RESULT_BYTE_CAP,
output_format_json_named_tuples_as_objects: 1,
output_format_json_quote_64bit_integers: 1,
output_format_json_quote_decimals: 1,
output_format_json_quote_64bit_floats: 1,
...overrides,
};
}

export function filterExecution(sql?: string | null, defaults: FilterExecutionDefaults = {}): FilterExecutionPlan {
const diagnostics = filterSqlDiagnostics(sql);
return {
owned: true,
format: 'Filter',
rowLimit: FILTER_TOP_LEVEL_ROW_LIMIT,
params: {
max_result_bytes: FILTER_RESULT_BYTE_CAP,
output_format_json_named_tuples_as_objects: 1,
output_format_json_quote_64bit_integers: 1,
output_format_json_quote_decimals: 1,
output_format_json_quote_64bit_floats: 1,
...(defaults.params || {}),
},
params: filterOwnedParams(defaults.params),
diagnostics,
error: diagnostics.length ? diagnostics[0].message : null,
};
}

// #360: a Filter-role source may now declare its OWN `{name:Type}` parameters
// (previously banned outright by the `filter-source-parameters` diagnostic
// removed above) — as long as every one of them is backed by ANOTHER source's
// own control (a workbench tab param, a dashboard-level filter), never by a
// second Filter source: a Filter depending on a Filter would need to re-run
// in a strict dependency order this app has no scheduler for (the single-
// layer cascading rule). `analyzeFilterSource` wraps the shared
// `param-pipeline.js` analysis phase for exactly one Filter source and folds
// that rule in as a diagnostic alongside the structural ones
// (`filterSqlDiagnostics`).

/** `analyzeFilterSource`'s return shape: the analyzed pipeline source (kept,
* not re-derived, by `prepareFilterSource`), the parameter names this
* source's SQL depends on, and every static reason it can't run. */
export interface FilterSourceAnalysis {
sql: string;
analysis: ParameterAnalysis;
dependsOn: string[];
diagnostics: FilterSqlDiagnostic[];
}

/**
* Analyze one Filter source's SQL: the structural contract
* (`filterSqlDiagnostics`) plus the shared parameter pipeline's analysis of
* its own declared `{name:Type}` params (#360). `dependsOn` is every
* parameter name this source's SQL declares — required outside any block or
* confined to an optional block — in first-appearance order.
* `opts.sourceBackedParams` is the set of names backed by ANOTHER Filter
* source in the same dashboard; depending on any of them is the one
* cascading violation this dashboard model disallows, and each becomes its
* own `filter-source-cascading` diagnostic naming `opts.label` and the
* offending parameter. `analysis.diagnostics` (the shared pipeline's own
* cross-declaration type-conflict findings, e.g. `{x:UInt8}` vs `{x:String}`
* in the same source) are folded in too, each as its own
* `filter-source-param-type-conflict` diagnostic (#360) —
* without this, a type-conflicted source would classify `runnable` in
* `prepareFilterSource` and get sent. Pure.
*/
export function analyzeFilterSource(
sql: string | null | undefined,
opts: { sourceBackedParams?: Iterable<string>; label?: string } = {},
): FilterSourceAnalysis {
const text = String(sql || '');
const structural = filterSqlDiagnostics(text);
const analysis = analyzeParameterizedSources([
{ id: 'filter', label: opts.label, kind: 'filter', sql: text, bindPolicy: 'row-returning' },
]);
const dependsOn = Object.keys(analysis.fields).filter((name) => {
const f = analysis.fields[name];
return f.requiredIn.includes('filter') || f.optionalIn.includes('filter');
});
const backed = new Set(opts.sourceBackedParams || []);
const cascading: FilterSqlDiagnostic[] = [];
for (const name of dependsOn) {
if (backed.has(name)) {
cascading.push(diagnostic(
'filter-source-cascading',
`Filter source "${opts.label || 'source'}" depends on source-backed parameter "${name}". Cascading Filter sources are not supported.`,
));
}
}
const typeConflicts = analysis.diagnostics.map((d) =>
diagnostic('filter-source-param-type-conflict', d.message));
return { sql: text, analysis, dependsOn, diagnostics: [...structural, ...cascading, ...typeConflicts] };
}

/** The three states `prepareFilterSource` classifies a Filter source into:
* `'error'` (a structural/cascading diagnostic, an invalid committed value,
* or a source-level template error), `'waiting'` (a required param has no
* value yet — a normal mid-fill state, not an error), or `'runnable'`. */
export type FilterSourceReadiness = 'runnable' | 'waiting' | 'error';

/** `prepareFilterSource`'s return shape: everything a caller needs to either
* show a banner/spinner or actually send the request. */
export interface FilterSourcePreparation {
readiness: FilterSourceReadiness;
diagnostics: FilterSqlDiagnostic[];
dependsOn: string[];
missing: string[];
invalid: string[];
errors: string[];
error: string | null;
execSql: string;
params: Record<string, string | number>;
format: 'Filter';
rowLimit: number;
boundParams: BoundParamSnapshot[];
}

/**
* Prepare one already-analyzed Filter source against concrete `values`
* (#360): wraps `prepareParameterizedBatch` (always `validationMode:
* 'execute'` — a Filter source runs as soon as it's ready; there's no
* separate blur/Enter commit step) and folds the result together with
* `analyzed.diagnostics` into one readiness verdict, the owned transport
* `params` (`filterOwnedParams()`'s caps ∪ the bound `param_<name>` args —
* the same helper `filterExecution` builds its own `params` from, called
* directly here rather than through `filterExecution` so this doesn't re-run
* `filterSqlDiagnostics`, already computed by `analyzeFilterSource`), and
* the materialized `execSql` to send. Takes the pre-analyzed source so a
* caller re-running this every value edit derives `dependsOn` (and re-scans
* the SQL) exactly once per SQL edit, not once per value edit. Pure.
*/
export function prepareFilterSource(
analyzed: FilterSourceAnalysis,
opts: { values?: Record<string, unknown>; active?: Record<string, boolean>; wallNowMs?: number } = {},
): FilterSourcePreparation {
const prepared = prepareParameterizedBatch(analyzed.analysis, {
values: opts.values,
active: opts.active,
wallNowMs: opts.wallNowMs,
validationMode: 'execute',
});
// `analyzed.analysis` was built from exactly one source (`analyzeFilterSource`
// above) — `prepareParameterizedBatch` preserves that 1:1 source cardinality,
// so `sources[0]` always exists.
const src = prepared.sources[0];
const diagnostics = analyzed.diagnostics;
const readiness: FilterSourceReadiness = diagnostics.length || src.invalid.length || src.errors.length
? 'error'
: src.missing.length
? 'waiting'
: 'runnable';
const params = { ...filterOwnedParams(), ...mergedSourceArgs(src) };
const error = diagnostics.length
? diagnostics[0].message
: src.invalid.length
? `Invalid value for: ${src.invalid.join(', ')}`
: src.errors.length
? src.errors[0]
: null;
return {
readiness,
diagnostics,
dependsOn: analyzed.dependsOn,
missing: src.missing.slice(),
invalid: src.invalid.slice(),
errors: src.errors.slice(),
error,
execSql: mergedSourceSql(src, analyzed.sql),
params,
format: 'Filter',
rowLimit: FILTER_TOP_LEVEL_ROW_LIMIT,
boundParams: src.statements.flatMap((s) => s.boundParams),
};
}
Loading
Loading