Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
full validation, with a nightly cross-browser E2E safety run and stable gate.

### Added
- **Saved-query time-range metadata and synchronized Dashboard chart
interaction** (#334). Query Spec v1 now carries zero or one authoritative
`{from,to}` parameter pair. Normal saved-query create/SQL-update flows infer
one conservative recognized date/time pair when metadata is absent, while
explicit `[]` opts out and Dashboard viewing never writes metadata. The
Dashboard derives runtime groups from that saved metadata, synchronizes an
exact-time vertical crosshair across compatible temporal charts, and lets a
plain mouse drag select a forward or reverse range. Selection formats each
bound through its declared Date/Date32/DateTime/DateTime64 contract, commits
and activates both filters atomically, reserves affected generations before
asynchronous preflight/source work, and reruns each dependent tile once;
Command/Ctrl-drag remains tile movement and manual filter inputs remain the
keyboard-accessible fallback.
- **Cross-tab workspace consistency: refresh before write and stale-tab
invalidation** (#343). Workbench and editable-Dashboard tabs editing the same
workspace now stay consistent under normal sequential editing. Every
Expand Down
12 changes: 10 additions & 2 deletions build/emit-schema-types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ const IGNORED_KEYWORDS = new Set([
...ANNOTATION_KEYWORDS,
'$schema', '$id', 'title', 'description', 'default', 'examples', 'deprecated',
'minLength', 'maxLength', 'pattern', 'format',
'minItems', 'maxItems', 'uniqueItems',
'uniqueItems',
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
'minProperties', 'maxProperties', 'propertyNames',
]);
const HANDLED_KEYWORDS = new Set([
'type', 'const', 'enum', 'anyOf', 'oneOf', 'allOf', 'not', '$ref',
'properties', 'required', 'additionalProperties', 'unevaluatedProperties', 'items',
'minItems', 'maxItems',
]);

const pascalCase = (name) => String(name).split(/[^A-Za-z0-9]+/).filter(Boolean)
Expand Down Expand Up @@ -76,7 +77,8 @@ function renderJsdoc(doc, extra = []) {
// branch's maxItems refinement of the inherited measure list). Merging keeps
// the earlier, richer declaration instead.
function isConstraintOnly(schema) {
const keys = Object.keys(schema).filter((key) => !IGNORED_KEYWORDS.has(key));
const keys = Object.keys(schema).filter((key) => !IGNORED_KEYWORDS.has(key)
&& key !== 'minItems' && key !== 'maxItems');
return keys.length === 1 && keys[0] === 'type' && schema.type === 'array' && schema.items === undefined;
}

Expand Down Expand Up @@ -179,6 +181,12 @@ export function buildSchemaTypes(records) {
if (type === 'array') {
if (schema.items === undefined) return 'unknown[]';
const item = typeExpr(schema.items, record, `${where}.items`);
if (schema.maxItems === 1) {
if (schema.minItems !== undefined && schema.minItems !== 0 && schema.minItems !== 1) {
throw new Error(`Unsupported minItems ${JSON.stringify(schema.minItems)} with maxItems 1 at ${where}`);
}
return schema.minItems === 1 ? `[${item}]` : `[] | [${item}]`;
}
return /[|&(]| /.test(item) ? `(${item})[]` : `${item}[]`;
}
if (type === 'object') return objectExpr(schema, record, where);
Expand Down
40 changes: 39 additions & 1 deletion schemas/generated/library-v2.bundle.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@
},
"dashboard": {
"$ref": "#/$defs/queryDashboardPresentationV1"
},
"timeRanges": {
"title": "Time-range parameter semantics",
"description": "Zero or one authoritative From/To parameter pair. Omission allows conservative authoring inference; an empty array explicitly disables inference.",
"type": "array",
"maxItems": 1,
"items": {
"$ref": "#/$defs/queryTimeRangeV1"
}
}
},
"additionalProperties": true,
Expand All @@ -58,9 +67,38 @@
"favorite",
"view",
"panel",
"dashboard"
"dashboard",
"timeRanges"
],
"$defs": {
"queryTimeRangeV1": {
"title": "Time-range parameter pair",
"description": "The exact saved-query parameter names used as the lower and upper bounds of one time range.",
"type": "object",
"required": [
"from",
"to"
],
"properties": {
"from": {
"title": "From parameter",
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"to": {
"title": "To parameter",
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
},
"additionalProperties": false,
"x-altinity-order": [
"from",
"to"
]
},
"columnName": {
"title": "Result column",
"description": "Exact top-level ClickHouse result-column name.",
Expand Down
23 changes: 21 additions & 2 deletions schemas/query-spec-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,30 @@
"default": "table"
},
"panel": { "$ref": "#/$defs/panel" },
"dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" }
"dashboard": { "$ref": "#/$defs/queryDashboardPresentationV1" },
"timeRanges": {
"title": "Time-range parameter semantics",
"description": "Zero or one authoritative From/To parameter pair. Omission allows conservative authoring inference; an empty array explicitly disables inference.",
"type": "array",
"maxItems": 1,
"items": { "$ref": "#/$defs/queryTimeRangeV1" }
}
},
"additionalProperties": true,
"x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard"],
"x-altinity-order": ["name", "description", "favorite", "view", "panel", "dashboard", "timeRanges"],
"$defs": {
"queryTimeRangeV1": {
"title": "Time-range parameter pair",
"description": "The exact saved-query parameter names used as the lower and upper bounds of one time range.",
"type": "object",
"required": ["from", "to"],
"properties": {
"from": { "title": "From parameter", "type": "string", "minLength": 1, "pattern": "\\S" },
"to": { "title": "To parameter", "type": "string", "minLength": 1, "pattern": "\\S" }
},
"additionalProperties": false,
"x-altinity-order": ["from", "to"]
},
"columnName": {
"title": "Result column",
"description": "Exact top-level ClickHouse result-column name.",
Expand Down
14 changes: 10 additions & 4 deletions src/application/saved-query-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { isQuerylessPanel } from '../core/panel-cfg.js';
import { encodeShare } from '../core/share.js';
import type { SavedQueryV2 } from '../generated/json-schema.types.js';
import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js';
import type { QueryTimeRangeInferenceDiagnostic } from '../core/query-time-range.js';

// ── Construction deps ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -78,7 +79,7 @@ export interface SavedQueryServiceDeps {
// ── Result types ─────────────────────────────────────────────────────────────

export type CreateSavedResult =
| { ok: true; entry: SavedQueryV2 }
| { ok: true; entry: SavedQueryV2; diagnostics?: QueryTimeRangeInferenceDiagnostic[] }
/** `createSavedQuery` itself rejected the entry — either a pre-commit
* compute guard (still-linked tab, blank SQL on a non-text panel, blank
* name, or a blocking validation diagnostic — the pre-#287 inline code
Expand All @@ -89,7 +90,7 @@ export type CreateSavedResult =
| { ok: false; diagnostics?: WorkspaceDiagnostic[] };

export type CommitLinkedResult =
| { ok: true; entry: SavedQueryV2 }
| { ok: true; entry: SavedQueryV2; diagnostics?: QueryTimeRangeInferenceDiagnostic[] }
| {
ok: false;
reason:
Expand Down Expand Up @@ -160,7 +161,9 @@ export function createSavedQueryService(deps: SavedQueryServiceDeps): SavedQuery
const result = await createSavedQuery(
deps.state, tab, name, description, deps.mutateWorkspace, deps.now(), deps.specValidators,
);
return result.ok ? { ok: true, entry: result.entry } : { ok: false, diagnostics: result.diagnostics };
return result.ok
? { ok: true, entry: result.entry, ...(result.diagnostics?.length ? { diagnostics: result.diagnostics } : {}) }
: { ok: false, diagnostics: result.diagnostics };
}

async function commit(
Expand All @@ -176,7 +179,10 @@ export function createSavedQueryService(deps: SavedQueryServiceDeps): SavedQuery
const result = await commitSavedQuery(
deps.state, tab, evaluated.parsed as QuerySpecDraft | null, deps.mutateWorkspace, deps.specValidators,
);
if (result.ok) return { ok: true, entry: result.entry };
if (result.ok) return {
ok: true, entry: result.entry,
...(result.diagnostics?.length ? { diagnostics: result.diagnostics } : {}),
};
return result.deletedExternally
? { ok: false, reason: 'deleted' }
: { ok: false, reason: 'rejected', diagnostics: result.diagnostics };
Expand Down
21 changes: 18 additions & 3 deletions src/core/library-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { migrateLibraryV1ToV2, migrateSequential } from './library-migrations.js
import type { MigratedLibraryV2, MigrateSequentialResult } from './library-migrations.js';
import { migrateSavedQuerySpec as migrateSpec } from './spec-migrations.js';
import type { MigratedSavedQuery } from './spec-migrations.js';
import { hasSameTimeRangeParameter } from './query-time-range.js';

export const LIBRARY_FORMAT = 'altinity-sql-browser/saved-queries';
export const CURRENT_LIBRARY_VERSION = 2;
Expand Down Expand Up @@ -58,6 +59,12 @@ type FailResult = { ok: false; diagnostics: LibraryDiagnostic[] };
const resultError = (...diagnostics: (LibraryDiagnostic | LibraryDiagnostic[])[]): FailResult =>
({ ok: false, diagnostics: diagnostics.flat() });

const timeRangeSemanticDiagnostics = (spec: unknown, path: (string | number)[] = []): LibraryDiagnostic[] =>
hasSameTimeRangeParameter(spec)
? [diagnostic([...path, 'timeRanges', 0, 'to'], 'time-range-same-parameter',
'Time-range From and To parameters must be different.')]
: [];

function validateLegacyLibraryV1(document: unknown): LibraryDiagnostic[] {
// Version identification establishes the v1 envelope before this codec runs.
const queries = (document as { queries?: unknown }).queries;
Expand Down Expand Up @@ -152,8 +159,11 @@ function validateLibraryV2Source(document: unknown, options?: unknown): LibraryD
? SAVED_QUERY_V2_SCHEMA_ID
: LIBRARY_V2_SCHEMA_ID,
}));
const semantics = queries ? queries.flatMap((query, index) =>
isPlainObject(query) && !unsupportedIndexes.has(index)
? timeRangeSemanticDiagnostics(query.spec, ['queries', index, 'spec']) : []) : [];
const duplicates = queries ? duplicateIdDiagnostics(queries) : [];
return [...unsupportedSpecs, ...structural, ...duplicates];
return [...unsupportedSpecs, ...structural, ...semantics, ...duplicates];
}

/** One saved-query Spec version's codec — `library-migrations.ts`'s
Expand All @@ -174,7 +184,10 @@ export const SPEC_CODECS: Map<number, SpecCodecEntry> = new Map([
// same as `validateLibraryV2Source` above.
const { validationService = jsonSchemaValidationService } =
options as { validationService?: JsonSchemaValidationService };
return validationService.validate(QUERY_SPEC_V1_SCHEMA_ID, value);
return [
...validationService.validate(QUERY_SPEC_V1_SCHEMA_ID, value),
...timeRangeSemanticDiagnostics(value),
];
},
migrateToNext: null,
}],
Expand Down Expand Up @@ -225,10 +238,12 @@ export function validateSavedQueryDocument(
return [diagnostic(['specVersion'], 'spec-version-unsupported',
`specVersion uses unsupported saved-query Spec version ${query.specVersion}`)];
}
return validationService.validate(SAVED_QUERY_V2_SCHEMA_ID, query).map((item) => ({
const structural = validationService.validate(SAVED_QUERY_V2_SCHEMA_ID, query).map((item) => ({
...item,
schemaId: item.path[0] === 'spec' ? QUERY_SPEC_V1_SCHEMA_ID : SAVED_QUERY_V2_SCHEMA_ID,
}));
const semantics = isPlainObject(query) ? timeRangeSemanticDiagnostics(query.spec, ['spec']) : [];
return [...structural, ...semantics];
}

export function validateLibraryDocument(
Expand Down
35 changes: 35 additions & 0 deletions src/core/param-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,41 @@ export function parseParamType(raw?: string | null): ParsedParamType {
return { ...fromNode(node), raw: text };
}

/** Strict declaration predicate for saved-query time-range metadata. Unlike
* the permissive value pipeline, this accepts only syntactically valid scalar
* ClickHouse date/time declarations, valid wrapper ordering, DateTime's
* optional timezone, and DateTime64 precision 0..9 plus optional timezone. */
export function isSupportedTimeRangeParamType(raw?: string | null): boolean {
const node = parseClickHouseType(String(raw || '').trim());
if (!node) return false;
const mods = analyzeTypeModifiers(node);
if (!mods.valid) return false;
const value = mods.valueType;
if (value.name === 'Date' || value.name === 'Date32') return value.args.length === 0;
if (value.name === 'DateTime') {
return value.args.length === 0
|| (value.args.length === 1 && value.args[0].kind === 'string' && value.args[0].value.trim().length > 0);
}
if (value.name !== 'DateTime64' || value.args.length < 1 || value.args.length > 2) return false;
const precision = value.args[0];
if (precision.kind !== 'number' || !/^\d+$/.test(precision.value)) return false;
const n = Number(precision.value);
if (!Number.isInteger(n) || n < 0 || n > 9) return false;
return value.args.length === 1
|| (value.args[1].kind === 'string' && value.args[1].value.trim().length > 0);
}

/** Explicit IANA timezone carried by a valid DateTime/DateTime64 declaration,
* or null when the type uses the existing server-timezone default. */
export function dateTimeTimeZone(type: string | ParsedParamType): string | null {
const parsed = typeof type === 'string' ? parseParamType(type) : type;
if (!parsed.node) return null;
const value = analyzeTypeModifiers(parsed.node).valueType;
if (value.name === 'DateTime' && value.args.length === 1 && value.args[0].kind === 'string') return value.args[0].value;
if (value.name === 'DateTime64' && value.args.length === 2 && value.args[1].kind === 'string') return value.args[1].value;
return null;
}

/**
* The lexical family of a parsed (or raw) type, deciding how the typed
* serializer emits an array *element* of that type:
Expand Down
93 changes: 93 additions & 0 deletions src/core/query-time-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Saved-query time-range authoring inference (#334). Pure: SQL is analyzed
// through the shared parameter pipeline, and the supplied Spec is cloned only
// when one conservative pair can be materialized.

import { analyzeParameterizedSources } from './param-pipeline.js';
import type { ParameterAnalysis } from './param-pipeline.js';
import { cloneJson } from './saved-query.js';
import { isSupportedTimeRangeParamType } from './param-type.js';
import type { QuerySpecV1 } from '../generated/json-schema.types.js';

export const TIME_RANGE_NAME_PAIRS: ReadonlyArray<readonly [string, string]> = [
['from', 'to'],
['from_time', 'to_time'],
['start', 'end'],
['start_time', 'end_time'],
];

export interface QueryTimeRangeInferenceDiagnostic {
path: (string | number)[];
severity: 'warning';
code: 'time-range-inference-ambiguous';
message: string;
}

export interface QueryTimeRangeInferenceResult {
spec: QuerySpecV1;
inferred: boolean;
diagnostics: QueryTimeRangeInferenceDiagnostic[];
}

export function hasSameTimeRangeParameter(spec: unknown): boolean {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return false;
const ranges = (spec as { timeRanges?: unknown }).timeRanges;
if (!Array.isArray(ranges) || ranges.length !== 1) return false;
const pair = ranges[0];
return !!pair && typeof pair === 'object' && !Array.isArray(pair)
&& typeof (pair as { from?: unknown }).from === 'string'
&& (pair as { from: string; to?: unknown }).from === (pair as { to?: unknown }).to;
}

export function analyzeQueryTimeRangeSql(sql: string): ParameterAnalysis {
return analyzeParameterizedSources([{
id: 'saved-query', kind: 'saved-query', sql, bindPolicy: 'row-returning',
}]);
}

/** Infer one pair only when the property is absent. Candidate counting happens
* before type gating: two recognized name pairs are ambiguous even if only one
* later proves date-like, because choosing it would no longer be conservative. */
export function inferQueryTimeRange(
spec: QuerySpecV1,
analysis: ParameterAnalysis,
): QueryTimeRangeInferenceResult {
if (Object.hasOwn(spec, 'timeRanges')) return { spec, inferred: false, diagnostics: [] };
if (Object.keys(analysis.sourceErrors).length) return { spec, inferred: false, diagnostics: [] };

const namesByFolded = new Map<string, string[]>();
for (const name of Object.keys(analysis.fields)) {
const key = name.toLowerCase();
const names = namesByFolded.get(key) || [];
names.push(name);
namesByFolded.set(key, names);
}
const candidates = TIME_RANGE_NAME_PAIRS.flatMap(([fromKey, toKey]) => {
const from = namesByFolded.get(fromKey) || [];
const to = namesByFolded.get(toKey) || [];
return from.length && to.length ? [{ from, to }] : [];
});
if (candidates.length === 0) return { spec, inferred: false, diagnostics: [] };
if (candidates.length !== 1 || candidates[0].from.length !== 1 || candidates[0].to.length !== 1) {
return {
spec, inferred: false,
diagnostics: [{
path: ['timeRanges'], severity: 'warning', code: 'time-range-inference-ambiguous',
message: 'Several recognized time-range parameter pairs exist. Author spec.timeRanges explicitly, or use [] to opt out.',
}],
};
}

const from = candidates[0].from[0];
const to = candidates[0].to[0];
const fields = [analysis.fields[from], analysis.fields[to]];
const supported = fields.every((field) => !field.conflict && field.declarations.length > 0
&& field.declarations.every((declaration) => isSupportedTimeRangeParamType(declaration.type)));
if (!supported) return { spec, inferred: false, diagnostics: [] };
const next = cloneJson(spec);
next.timeRanges = [{ from, to }];
return { spec: next, inferred: true, diagnostics: [] };
}

export function materializeQueryTimeRange(spec: QuerySpecV1, sql: string): QueryTimeRangeInferenceResult {
return inferQueryTimeRange(spec, analyzeQueryTimeRangeSql(sql));
}
Loading
Loading