blob: 9af2abd6d92a74ceb67329f5057f6a3a5835eefa [file]
// 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 {QuerySlot, type QueryResult} from '../../../base/query_slot';
import type {Engine} from '../../../trace_processor/engine';
import {NUM, STR} from '../../../trace_processor/query_result';
import type {BoxplotData} from './boxplot';
import {validateColumnName} from './chart_utils';
/**
* Configuration for boxplot loaders.
*/
export interface BoxplotLoaderConfig {
/**
* Maximum number of categories to show.
* Categories are ordered by median value descending.
*/
readonly limit?: number;
}
/**
* Configuration for SQLBoxplotLoader.
*/
export interface SQLBoxplotLoaderOpts {
/** The trace processor engine to run queries against. */
readonly engine: Engine;
/** SQL query that provides the data. */
readonly query: string;
/** Column name containing the category/grouping values. */
readonly categoryColumn: string;
/** Column name containing the numeric values to compute stats over. */
readonly valueColumn: string;
}
/**
* SQL-based boxplot loader.
*
* Computes quartile statistics (min, Q1, median, Q3, max) per category
* using ROW_NUMBER and conditional aggregation to approximate percentiles
* in SQLite (which lacks native PERCENTILE_CONT).
*/
export class SQLBoxplotLoader {
private readonly engine: Engine;
private readonly query: string;
private readonly categoryColumn: string;
private readonly valueColumn: string;
private readonly querySlot = new QuerySlot<BoxplotData>();
constructor(opts: SQLBoxplotLoaderOpts) {
validateColumnName(opts.categoryColumn);
validateColumnName(opts.valueColumn);
this.engine = opts.engine;
this.query = opts.query;
this.categoryColumn = opts.categoryColumn;
this.valueColumn = opts.valueColumn;
}
use(config: BoxplotLoaderConfig): QueryResult<BoxplotData> {
const limit = config.limit ?? 20;
const cat = this.categoryColumn;
const val = this.valueColumn;
const sql = `
WITH _src AS (
SELECT
CAST(${cat} AS TEXT) AS _cat,
CAST(${val} AS REAL) AS _val
FROM (${this.query})
WHERE ${val} IS NOT NULL
),
_stats AS (
SELECT
_cat,
MIN(_val) AS _min,
MAX(_val) AS _max,
COUNT(*) AS _cnt
FROM _src
GROUP BY _cat
),
_ranked AS (
SELECT
s._cat,
s._val,
ROW_NUMBER() OVER (PARTITION BY s._cat ORDER BY s._val) AS _rn,
st._cnt
FROM _src s
JOIN _stats st ON s._cat = st._cat
),
_quartiles AS (
SELECT
_cat,
MAX(CASE WHEN _rn = MAX(1, CAST((_cnt + 1) * 0.25 AS INT)) THEN _val END) AS _q1,
MAX(CASE WHEN _rn = MAX(1, CAST((_cnt + 1) * 0.50 AS INT)) THEN _val END) AS _median,
MAX(CASE WHEN _rn = MAX(1, CAST((_cnt + 1) * 0.75 AS INT)) THEN _val END) AS _q3
FROM _ranked
GROUP BY _cat
)
SELECT
s._cat,
s._min,
q._q1,
q._median,
q._q3,
s._max
FROM _stats s
JOIN _quartiles q ON s._cat = q._cat
ORDER BY q._median DESC
LIMIT ${limit}`.trim();
return this.querySlot.use({
key: {sql},
retainOn: ['sql'],
queryFn: async () => {
const queryResult = await this.engine.query(sql);
const items = [];
const iter = queryResult.iter({
_cat: STR,
_min: NUM,
_q1: NUM,
_median: NUM,
_q3: NUM,
_max: NUM,
});
for (; iter.valid(); iter.next()) {
items.push({
label: iter._cat,
min: iter._min,
q1: iter._q1,
median: iter._median,
q3: iter._q3,
max: iter._max,
});
}
return {items};
},
});
}
dispose(): void {
this.querySlot.dispose();
}
}