| // Copyright (C) 2026 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import {assertUnreachable} from '../../../base/assert'; |
| import {QuerySlot} from '../../../base/query_slot'; |
| import type {Engine} from '../../../trace_processor/engine'; |
| import type {QueryResult as TPQueryResult} from '../../../trace_processor/query_result'; |
| import type {Filter} from '../datagrid/model'; |
| import {filterToSql, sqlAggregateExpr} from '../datagrid/sql_utils'; |
| import {type ChartAggregation, validateColumnName} from './chart_utils'; |
| |
| /** Default column alias for the first measure in aggregated queries. */ |
| export const DEFAULT_MEASURE_ALIAS = '_value'; |
| |
| // --------------------------------------------------------------------------- |
| // Schema types |
| // --------------------------------------------------------------------------- |
| |
| /** |
| * Column type in the source query. |
| * 'text' columns are cast to TEXT (dimensions, breakdowns). |
| * 'real' columns are cast to REAL (measures, x/y axes). |
| */ |
| export type ColumnType = 'text' | 'real'; |
| |
| /** |
| * Schema definition mapping column names to their types. |
| * Currently used only for column existence validation; the actual cast |
| * direction is determined by the query config (dimensions → TEXT, |
| * measures → aggregation, point columns → explicit cast spec). |
| */ |
| export type ColumnSchema = Readonly<Record<string, ColumnType>>; |
| |
| // --------------------------------------------------------------------------- |
| // Query configuration types |
| // --------------------------------------------------------------------------- |
| |
| /** |
| * Dimension: a column used for grouping (GROUP BY), cast to TEXT. |
| */ |
| export interface DimensionSpec { |
| readonly column: string; |
| /** Output alias. Defaults to `_dim` for the first, `_dim_1`, `_dim_2`... */ |
| readonly alias?: string; |
| } |
| |
| /** |
| * Measure: a column aggregated numerically. |
| */ |
| export interface MeasureSpec { |
| readonly column: string; |
| readonly aggregation: ChartAggregation; |
| /** Output alias. Defaults to `_value` for the first, `_value_1`, `_value_2`... */ |
| readonly alias?: string; |
| } |
| |
| /** |
| * Point column: a raw column selected in a points query. |
| * When `column` is omitted, produces `NULL AS alias` in the SQL. |
| */ |
| export interface PointColumnSpec { |
| readonly column?: string; |
| readonly alias: string; |
| readonly cast: 'real' | 'text'; |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Three query mode configs (discriminated union) |
| // --------------------------------------------------------------------------- |
| |
| /** |
| * Aggregated query (bar, pie, treemap). |
| * |
| * Produces: `SELECT dims, AGG(measures) FROM source [WHERE] GROUP BY dims` |
| */ |
| export interface AggregatedQueryConfig { |
| readonly type: 'aggregated'; |
| |
| /** Grouping dimensions (at least 1). */ |
| readonly dimensions: ReadonlyArray<DimensionSpec>; |
| |
| /** Measures to compute (at least 1). */ |
| readonly measures: ReadonlyArray<MeasureSpec>; |
| |
| /** Filters applied before aggregation. */ |
| readonly filters?: ReadonlyArray<Filter>; |
| |
| /** Sort direction for the first measure. Defaults to 'desc'. */ |
| readonly orderDirection?: 'asc' | 'desc'; |
| |
| /** Limit number of output rows. */ |
| readonly limit?: number; |
| |
| /** |
| * When true and `limit` is set, remaining rows beyond the top-N are |
| * collapsed into a single "(Other)" row with summed measure values. |
| */ |
| readonly includeOther?: boolean; |
| |
| /** |
| * Limit results per group (first dimension). Uses ROW_NUMBER() OVER |
| * (PARTITION BY first_dim ORDER BY value DESC). Only valid when |
| * dimensions.length >= 2. |
| */ |
| readonly limitPerGroup?: number; |
| } |
| |
| /** |
| * Points/raw query (line, scatter). |
| * |
| * Produces: `SELECT columns [, breakdown] FROM source [WHERE] [ORDER BY]` |
| */ |
| export interface PointsQueryConfig { |
| readonly type: 'points'; |
| |
| /** Columns to select. */ |
| readonly columns: ReadonlyArray<PointColumnSpec>; |
| |
| /** Optional breakdown/series column (cast to TEXT, aliased as `_series`). */ |
| readonly breakdown?: string; |
| |
| /** Filters to apply. */ |
| readonly filters?: ReadonlyArray<Filter>; |
| |
| /** Sort specification. */ |
| readonly orderBy?: ReadonlyArray<{ |
| readonly column: string; |
| readonly direction?: 'asc' | 'desc'; |
| }>; |
| |
| /** |
| * When set, stride-samples each series (PARTITION BY _series) down to at |
| * most this many rows in SQL using ROW_NUMBER window functions. This avoids |
| * fetching millions of rows into JS when only a representative subset is |
| * needed (e.g., scatter charts). |
| */ |
| readonly maxPointsPerSeries?: number; |
| } |
| |
| /** |
| * Histogram query. |
| * |
| * Produces a CTE that buckets values and returns counts per bucket along |
| * with min/max/total metadata. |
| */ |
| export interface HistogramQueryConfig { |
| readonly type: 'histogram'; |
| |
| /** Column to bucket. */ |
| readonly valueColumn: string; |
| |
| /** Number of buckets. */ |
| readonly bucketCount: number; |
| |
| /** |
| * If provided, a fixed bucket size is used (rather than auto-binning the |
| * data range into `bucketCount` buckets). Must be paired with `minValue`. |
| * The histogram covers [minValue, minValue + bucketCount * bucketSize]; |
| * values outside this range are clamped to the edge buckets. |
| */ |
| readonly bucketSize?: number; |
| |
| /** Lower bound of bucket 0. Required when `bucketSize` is provided. */ |
| readonly minValue?: number; |
| |
| /** Filters to apply before bucketing. */ |
| readonly filters?: ReadonlyArray<Filter>; |
| } |
| |
| /** Union of all query config types. */ |
| export type QueryConfig = |
| | AggregatedQueryConfig |
| | PointsQueryConfig |
| | HistogramQueryConfig; |
| |
| // --------------------------------------------------------------------------- |
| // ChartSource |
| // --------------------------------------------------------------------------- |
| |
| /** |
| * Configuration for creating a ChartSource. |
| */ |
| export interface ChartSourceConfig { |
| /** Base SQL query (used as a subquery). */ |
| readonly query: string; |
| |
| /** Column schema mapping column names to types. */ |
| readonly schema: ColumnSchema; |
| } |
| |
| /** |
| * Unified SQL query builder for chart loaders. |
| * |
| * Wraps a base SQL query + typed column schema and generates well-formed |
| * SQL for aggregated, points, and histogram queries. Reuses the datagrid's |
| * `Filter` type and `filterToSql()` for WHERE clause generation. |
| * |
| * Usage: |
| * ```typescript |
| * const source = new ChartSource({ |
| * query: 'SELECT process_name, dur FROM slice WHERE dur > 0', |
| * schema: {process_name: 'text', dur: 'real'}, |
| * }); |
| * |
| * const sql = source.buildQuery({ |
| * type: 'aggregated', |
| * dimensions: [{column: 'process_name'}], |
| * measures: [{column: 'dur', aggregation: 'SUM'}], |
| * limit: 10, |
| * }); |
| * ``` |
| */ |
| export class ChartSource { |
| readonly query: string; |
| readonly schema: ColumnSchema; |
| |
| constructor(config: ChartSourceConfig) { |
| for (const col of Object.keys(config.schema)) { |
| validateColumnName(col); |
| } |
| this.query = config.query; |
| this.schema = config.schema; |
| } |
| |
| /** |
| * Build a SQL query string from the given configuration. |
| * Validates that all referenced columns exist in the schema. |
| */ |
| buildQuery(config: QueryConfig): string { |
| switch (config.type) { |
| case 'aggregated': |
| return this.buildAggregated(config); |
| case 'points': |
| return this.buildPoints(config); |
| case 'histogram': |
| return this.buildHistogram(config); |
| default: |
| assertUnreachable(config); |
| } |
| } |
| |
| // ------------------------------------------------------------------------- |
| // Aggregated queries |
| // ------------------------------------------------------------------------- |
| |
| private buildAggregated(config: AggregatedQueryConfig): string { |
| for (const dim of config.dimensions) { |
| this.assertColumn(dim.column); |
| } |
| for (const meas of config.measures) { |
| this.assertColumn(meas.column); |
| } |
| |
| // Decide which variant to use |
| if (config.includeOther && config.limit !== undefined) { |
| return this.buildTopNWithOther(config); |
| } |
| if (config.limitPerGroup !== undefined && config.dimensions.length >= 2) { |
| return this.buildHierarchical(config); |
| } |
| return this.buildSimpleAggregated(config); |
| } |
| |
| private buildSimpleAggregated(config: AggregatedQueryConfig): string { |
| const selectParts = [ |
| ...this.dimSelectExprs(config.dimensions), |
| ...this.measureSelectExprs(config.measures), |
| ]; |
| |
| const whereClause = this.buildWhereClause(config.filters); |
| const havingClause = this.buildHavingClause(config.measures); |
| |
| // Scalar aggregation (no dimensions) — single row, no GROUP BY. |
| if (config.dimensions.length === 0 && config.measures.length > 0) { |
| return ` |
| SELECT |
| ${selectParts.join(',\n ')} |
| FROM (${this.query}) |
| ${whereClause} |
| ${havingClause}`.trim(); |
| } |
| |
| const groupByExprs = config.dimensions.map((d) => d.column); |
| const direction = config.orderDirection ?? 'desc'; |
| const orderAlias = this.measureAlias(config.measures, 0); |
| const limitClause = |
| config.limit !== undefined ? `LIMIT ${config.limit}` : ''; |
| |
| return ` |
| SELECT |
| ${selectParts.join(',\n ')} |
| FROM (${this.query}) |
| ${whereClause} |
| GROUP BY ${groupByExprs.join(', ')} |
| ${havingClause} |
| ORDER BY ${orderAlias} ${direction.toUpperCase()} |
| ${limitClause}`.trim(); |
| } |
| |
| private buildTopNWithOther(config: AggregatedQueryConfig): string { |
| const selectParts = [ |
| ...this.dimSelectExprs(config.dimensions), |
| ...this.measureSelectExprs(config.measures), |
| ]; |
| const whereClause = this.buildWhereClause(config.filters); |
| const groupByExprs = config.dimensions.map((d) => d.column); |
| const direction = config.orderDirection ?? 'desc'; |
| const orderAlias = this.measureAlias(config.measures, 0); |
| // Caller guarantees config.limit is defined (checked in buildAggregated). |
| const limit = config.limit ?? 0; |
| |
| // Collect output column aliases |
| const dimAliases = config.dimensions.map((d, i) => this.dimAlias(d, i)); |
| const measAliases = config.measures.map((_, i) => |
| this.measureAlias(config.measures, i), |
| ); |
| const allAliases = [...dimAliases, ...measAliases]; |
| const aliasList = allAliases.join(', '); |
| |
| // First dim alias for the "Other" label |
| const firstDimAlias = dimAliases[0]; |
| |
| // Build "(Other)" select: first dim = '(Other)', rest of dims = NULL, |
| // each measure = SUM(measure_alias) |
| const otherDimExprs = dimAliases.map((alias, i) => |
| i === 0 ? `'(Other)' AS ${alias}` : `NULL AS ${alias}`, |
| ); |
| const otherMeasExprs = measAliases.map( |
| (alias) => `SUM(${alias}) AS ${alias}`, |
| ); |
| const otherSelectParts = [...otherDimExprs, ...otherMeasExprs]; |
| |
| return ` |
| WITH _agg AS ( |
| SELECT |
| ${selectParts.join(',\n ')} |
| FROM (${this.query}) |
| ${whereClause} |
| GROUP BY ${groupByExprs.join(', ')} |
| ${this.buildHavingClause(config.measures)} |
| ORDER BY ${orderAlias} ${direction.toUpperCase()} |
| ), |
| _top AS ( |
| SELECT ${aliasList} FROM _agg ORDER BY ${orderAlias} ${direction.toUpperCase()} LIMIT ${limit} |
| ), |
| _other AS ( |
| SELECT |
| ${otherSelectParts.join(',\n ')} |
| FROM _agg |
| WHERE ${firstDimAlias} NOT IN (SELECT ${firstDimAlias} FROM _top) |
| ) |
| SELECT ${aliasList} FROM _top |
| UNION ALL |
| SELECT ${aliasList} FROM _other WHERE ${measAliases[0]} > 0`.trim(); |
| } |
| |
| private buildHierarchical(config: AggregatedQueryConfig): string { |
| const selectParts = [ |
| ...this.dimSelectExprs(config.dimensions), |
| ...this.measureSelectExprs(config.measures), |
| ]; |
| const whereClause = this.buildWhereClause(config.filters); |
| const groupByExprs = config.dimensions.map((d) => d.column); |
| const direction = config.orderDirection ?? 'desc'; |
| const firstDimAlias = this.dimAlias(config.dimensions[0], 0); |
| const orderAlias = this.measureAlias(config.measures, 0); |
| |
| const dimAliases = config.dimensions.map((d, i) => this.dimAlias(d, i)); |
| const measAliases = config.measures.map((_, i) => |
| this.measureAlias(config.measures, i), |
| ); |
| const allAliases = [...dimAliases, ...measAliases]; |
| const aliasList = allAliases.join(', '); |
| // Caller guarantees config.limitPerGroup is defined (checked in |
| // buildAggregated). |
| const limitPerGroup = config.limitPerGroup ?? 0; |
| |
| return ` |
| WITH _agg AS ( |
| SELECT |
| ${selectParts.join(',\n ')} |
| FROM (${this.query}) |
| ${whereClause} |
| GROUP BY ${groupByExprs.join(', ')} |
| ${this.buildHavingClause(config.measures)} |
| ), |
| _ranked AS ( |
| SELECT |
| ${aliasList}, |
| ROW_NUMBER() OVER (PARTITION BY ${firstDimAlias} ORDER BY ${orderAlias} ${direction.toUpperCase()}) AS _rank |
| FROM _agg |
| ) |
| SELECT ${aliasList} |
| FROM _ranked |
| WHERE _rank <= ${limitPerGroup} |
| ORDER BY ${firstDimAlias}, ${orderAlias} ${direction.toUpperCase()}`.trim(); |
| } |
| |
| // ------------------------------------------------------------------------- |
| // Points queries |
| // ------------------------------------------------------------------------- |
| |
| private buildPoints(config: PointsQueryConfig): string { |
| for (const col of config.columns) { |
| if (col.column !== undefined) { |
| this.assertColumn(col.column); |
| } |
| validateColumnName(col.alias); |
| } |
| if (config.breakdown !== undefined) { |
| this.assertColumn(config.breakdown); |
| } |
| |
| const selectParts = config.columns.map((col) => |
| col.column !== undefined |
| ? `CAST(${col.column} AS ${sqlCastType(col.cast)}) AS ${col.alias}` |
| : `NULL AS ${col.alias}`, |
| ); |
| |
| if (config.breakdown !== undefined) { |
| selectParts.push(`CAST(${config.breakdown} AS TEXT) AS _series`); |
| } |
| |
| const whereClause = this.buildWhereClause(config.filters); |
| |
| let orderByClause = ''; |
| if (config.orderBy !== undefined && config.orderBy.length > 0) { |
| const orderParts = config.orderBy.map( |
| (o) => `${o.column} ${(o.direction ?? 'asc').toUpperCase()}`, |
| ); |
| orderByClause = `ORDER BY ${orderParts.join(', ')}`; |
| } |
| |
| if (config.maxPointsPerSeries !== undefined) { |
| return this.buildStrideSampledPoints( |
| selectParts, |
| whereClause, |
| orderByClause, |
| config, |
| ); |
| } |
| |
| return ` |
| SELECT |
| ${selectParts.join(',\n ')} |
| FROM (${this.query}) |
| ${whereClause} |
| ${orderByClause}`.trim(); |
| } |
| |
| /** |
| * Wraps the base points query in a subquery that uses ROW_NUMBER() and |
| * COUNT() window functions to stride-sample each series down to at most |
| * `maxPointsPerSeries` rows. This keeps large datasets from being fully |
| * transferred into JS memory. |
| */ |
| private buildStrideSampledPoints( |
| selectParts: string[], |
| whereClause: string, |
| orderByClause: string, |
| config: PointsQueryConfig, |
| ): string { |
| const maxPts = config.maxPointsPerSeries ?? 0; |
| const aliases = config.columns.map((col) => col.alias); |
| if (config.breakdown !== undefined) { |
| aliases.push('_series'); |
| } |
| const aliasList = aliases.join(', '); |
| |
| // Use the full expression (not the alias) because SQLite cannot |
| // resolve column aliases inside OVER clauses of the same SELECT. |
| const partitionExpr = |
| config.breakdown !== undefined |
| ? `PARTITION BY CAST(${config.breakdown} AS TEXT)` |
| : 'PARTITION BY 1'; |
| |
| return ` |
| SELECT ${aliasList} |
| FROM ( |
| SELECT |
| ${selectParts.join(',\n ')}, |
| ROW_NUMBER() OVER (${partitionExpr}) AS _rn, |
| COUNT(*) OVER (${partitionExpr}) AS _cnt |
| FROM (${this.query}) |
| ${whereClause} |
| ) |
| WHERE (_rn - 1) % MAX(1, (_cnt + ${maxPts} - 1) / ${maxPts}) = 0 |
| ${orderByClause}`.trim(); |
| } |
| |
| // ------------------------------------------------------------------------- |
| // Histogram queries |
| // ------------------------------------------------------------------------- |
| |
| private buildHistogram(config: HistogramQueryConfig): string { |
| this.assertColumn(config.valueColumn); |
| |
| const col = config.valueColumn; |
| const bucketCount = config.bucketCount; |
| const whereClause = this.buildWhereClause(config.filters); |
| |
| // Fixed-bucketization variant: callers compute "nice" boundaries (e.g., |
| // rounded to 1/2/5 * 10^n) externally and pass them in. The histogram |
| // covers [minValue, minValue + bucketCount * bucketSize]; values outside |
| // are clamped to the edge buckets so the tail isn't lost. |
| if (config.bucketSize !== undefined && config.minValue !== undefined) { |
| const min = config.minValue; |
| const size = config.bucketSize; |
| const max = min + bucketCount * size; |
| return ` |
| WITH _data AS ( |
| SELECT ${col} AS _value |
| FROM (${this.query}) |
| ${whereClause} |
| ) |
| SELECT |
| ${min} AS _min, |
| ${max} AS _max, |
| (SELECT COUNT(*) FROM _data) AS _total, |
| MIN(${bucketCount - 1}, MAX(0, CAST((_value - ${min}) / CAST(${size} AS REAL) AS INT))) AS _bucket_idx, |
| COUNT(*) AS _count |
| FROM _data |
| GROUP BY _bucket_idx |
| ORDER BY _bucket_idx`.trim(); |
| } |
| |
| return ` |
| WITH _data AS ( |
| SELECT ${col} AS _value |
| FROM (${this.query}) |
| ${whereClause} |
| ) |
| SELECT |
| (SELECT MIN(_value) FROM _data) AS _min, |
| (SELECT MAX(_value) FROM _data) AS _max, |
| (SELECT COUNT(*) FROM _data) AS _total, |
| CASE |
| WHEN (SELECT MAX(_value) FROM _data) = (SELECT MIN(_value) FROM _data) THEN 0 |
| WHEN _value = (SELECT MAX(_value) FROM _data) THEN ${bucketCount - 1} |
| ELSE MIN(${bucketCount - 1}, CAST( |
| (_value - (SELECT MIN(_value) FROM _data)) / |
| (((SELECT MAX(_value) FROM _data) - (SELECT MIN(_value) FROM _data)) / ${bucketCount}.0) |
| AS INT)) |
| END AS _bucket_idx, |
| COUNT(*) AS _count |
| FROM _data |
| GROUP BY _bucket_idx |
| ORDER BY _bucket_idx`.trim(); |
| } |
| |
| // ------------------------------------------------------------------------- |
| // Helpers |
| // ------------------------------------------------------------------------- |
| |
| private assertColumn(name: string): void { |
| if (!(name in this.schema)) { |
| throw new Error( |
| `Column '${name}' not found in schema. ` + |
| `Available: ${Object.keys(this.schema).join(', ')}`, |
| ); |
| } |
| } |
| |
| private buildWhereClause(filters: ReadonlyArray<Filter> | undefined): string { |
| if (filters === undefined || filters.length === 0) return ''; |
| const conditions = filters.map((f) => `(${chartFilterToSql(f)})`); |
| return `WHERE ${conditions.join(' AND ')}`; |
| } |
| |
| /** |
| * Build a HAVING clause that excludes groups where any non-COUNT measure |
| * aggregated to NULL (i.e. all values in the group were NULL). |
| */ |
| private buildHavingClause(measures: ReadonlyArray<MeasureSpec>): string { |
| const conditions: string[] = []; |
| for (const m of measures) { |
| if (m.aggregation === 'COUNT' || m.aggregation === 'COUNT_DISTINCT') { |
| continue; |
| } |
| conditions.push( |
| `${sqlAggregateExpr(m.aggregation, m.column)} IS NOT NULL`, |
| ); |
| } |
| if (conditions.length === 0) return ''; |
| return `HAVING ${conditions.join(' AND ')}`; |
| } |
| |
| private dimAlias(dim: DimensionSpec, index: number): string { |
| if (dim.alias !== undefined) { |
| validateColumnName(dim.alias); |
| return dim.alias; |
| } |
| return index === 0 ? '_dim' : `_dim_${index}`; |
| } |
| |
| private measureAlias( |
| measures: ReadonlyArray<MeasureSpec>, |
| index: number, |
| ): string { |
| const meas = measures[index]; |
| if (meas.alias !== undefined) { |
| validateColumnName(meas.alias); |
| return meas.alias; |
| } |
| return index === 0 ? DEFAULT_MEASURE_ALIAS : `_value_${index}`; |
| } |
| |
| private dimSelectExprs(dims: ReadonlyArray<DimensionSpec>): string[] { |
| return dims.map( |
| (dim, i) => `CAST(${dim.column} AS TEXT) AS ${this.dimAlias(dim, i)}`, |
| ); |
| } |
| |
| private measureSelectExprs(measures: ReadonlyArray<MeasureSpec>): string[] { |
| return measures.map((meas, i) => { |
| // COUNT is field-independent (counts all rows), so we use COUNT(*) rather |
| // than COUNT(column) which would exclude NULLs. All other aggregations |
| // delegate to sqlAggregateExpr which references the column directly. |
| const aggExpr = |
| meas.aggregation === 'COUNT' |
| ? 'COUNT(*)' |
| : sqlAggregateExpr(meas.aggregation, meas.column); |
| return `${aggExpr} AS ${this.measureAlias(measures, i)}`; |
| }); |
| } |
| } |
| |
| function sqlCastType(cast: 'real' | 'text'): string { |
| return cast === 'real' ? 'REAL' : 'TEXT'; |
| } |
| |
| /** |
| * Convert a chart filter to SQL. Extends the base `filterToSql` with |
| * proper NULL handling for IN / NOT IN filters: SQL `IN (...)` never |
| * matches NULL, so null values are split into a separate IS NULL / IS NOT |
| * NULL clause joined with OR / AND respectively. |
| */ |
| function chartFilterToSql(filter: Filter): string { |
| if ( |
| (filter.op === 'in' || filter.op === 'not in') && |
| filter.value.some((v) => v === null) |
| ) { |
| const nonNull = filter.value.filter((v) => v !== null); |
| const negated = filter.op === 'not in'; |
| const parts: string[] = []; |
| if (nonNull.length > 0) { |
| parts.push(filterToSql({...filter, value: nonNull}, filter.field)); |
| } |
| parts.push( |
| filterToSql( |
| {field: filter.field, op: negated ? 'is not null' : 'is null'}, |
| filter.field, |
| ), |
| ); |
| if (parts.length === 1) return parts[0]; |
| const joiner = negated ? ' AND ' : ' OR '; |
| return `(${parts.join(joiner)})`; |
| } |
| return filterToSql(filter, filter.field); |
| } |
| |
| // --------------------------------------------------------------------------- |
| // SQLChartLoader base class |
| // --------------------------------------------------------------------------- |
| |
| /** |
| * Result returned by SQL chart loaders. |
| */ |
| export interface ChartLoaderResult<TData> { |
| readonly data: TData | undefined; |
| readonly isPending: boolean; |
| } |
| |
| /** |
| * Abstract base for SQL-backed chart loaders. |
| * |
| * Handles QuerySlot lifecycle, query execution via Engine, and caching. |
| * Subclasses implement: |
| * - `buildQueryConfig()` to turn per-use config into a QueryConfig |
| * - `parseResult()` to turn query rows into chart-specific data |
| */ |
| export abstract class SQLChartLoader<TConfig, TData> { |
| private readonly engine: Engine; |
| protected readonly source: ChartSource; |
| private readonly querySlot = new QuerySlot<TData>(); |
| |
| constructor(engine: Engine, source: ChartSource) { |
| this.engine = engine; |
| this.source = source; |
| } |
| |
| use(config: TConfig): ChartLoaderResult<TData> { |
| const queryConfig = this.buildQueryConfig(config); |
| const sql = this.source.buildQuery(queryConfig); |
| const extra = this.extraCacheKey(config); |
| const key = {sql, ...extra}; |
| const result = this.querySlot.use({ |
| key, |
| queryFn: async () => { |
| const queryResult = await this.engine.query(sql); |
| return this.parseResult(queryResult, config); |
| }, |
| // Retain stale chart data while new queries are in flight (e.g., during |
| // brush/filter changes) to avoid flashing a loading spinner. |
| retainOn: Object.keys(key) as (keyof typeof key)[], |
| }); |
| return {data: result.data, isPending: result.isPending}; |
| } |
| |
| dispose(): void { |
| this.querySlot.dispose(); |
| } |
| |
| /** Build the QueryConfig from the per-use config. */ |
| protected abstract buildQueryConfig(config: TConfig): QueryConfig; |
| |
| /** Parse query result rows into chart-specific data. */ |
| protected abstract parseResult( |
| queryResult: TPQueryResult, |
| config: TConfig, |
| ): TData; |
| |
| /** |
| * Override to add extra fields to the cache key for post-processing |
| * params that don't affect the SQL but do affect the output. |
| */ |
| protected extraCacheKey( |
| _config: TConfig, |
| ): Record<string, string | number | boolean | undefined> { |
| return {}; |
| } |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Filter construction helpers |
| // --------------------------------------------------------------------------- |
| |
| /** |
| * Create an IN filter if values are provided, otherwise returns empty array. |
| * NULL values in the array are supported — `buildWhereClause` generates |
| * the proper `(col IN (...) OR col IS NULL)` SQL since SQL `IN (...)` |
| * does not match NULLs on its own. |
| */ |
| export function inFilter( |
| field: string, |
| values: ReadonlyArray<string | number | null> | undefined, |
| ): Filter[] { |
| if (values === undefined || values.length === 0) return []; |
| return [{field, op: 'in', value: [...values]}]; |
| } |
| |
| /** |
| * Create range filters (>= min, <= max) if range is provided. |
| */ |
| export function rangeFilters( |
| field: string, |
| range: {readonly min: number; readonly max: number} | undefined, |
| ): Filter[] { |
| if (range === undefined) return []; |
| return [ |
| {field, op: '>=', value: range.min}, |
| {field, op: '<=', value: range.max}, |
| ]; |
| } |