1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import {SqlValue} from '../../../../trace_processor/query_result'; 17import {SortDirection} from '../../../../base/comparison_utils'; 18import {arrayEquals} from '../../../../base/array_utils'; 19import {Trace} from '../../../../public/trace'; 20 21// We are dealing with two types of columns here: 22// - Column, which is shown to a user in table (high-level, ColumnTable). 23// - Column in the underlying SQL data (low-level, SqlColumn). 24// They are related, but somewhat separate due to the fact that some table columns need to work with multiple SQL values to display it properly. 25// For example, a "time range" column would need both timestamp and duration to display interactive experience (e.g. highlight the time range on hover). 26// Each TableColumn has a primary SqlColumn, as well as optional dependent columns. 27 28// A source table for a SQL column, representing the joined table and the join constraints. 29export type SourceTable = { 30 table: string; 31 joinOn: {[key: string]: SqlColumn}; 32 // Whether more performant 'INNER JOIN' can be used instead of 'LEFT JOIN'. 33 // Special care should be taken to ensure that a) all rows exist in a target table, and b) the source is not null, otherwise the rows will be filtered out. 34 // false by default. 35 innerJoin?: boolean; 36}; 37 38// A column in the SQL query. It can be either a column from a base table or a "lookup" column from a joined table. 39export type SqlColumn = 40 | string 41 | { 42 column: string; 43 source: SourceTable; 44 }; 45 46// List of columns of args, corresponding to arg values, which cause a short-form of the ID to be generated. 47// (e.g. arg_set_id[foo].int instead of args[arg_set_id,key=foo].int_value). 48const ARG_COLUMN_TO_SUFFIX: {[key: string]: string} = { 49 display_value: '', 50 int_value: '.int', 51 string_value: '.str', 52 real_value: '.real', 53}; 54 55// A unique identifier for the SQL column. 56export function sqlColumnId(column: SqlColumn): string { 57 if (typeof column === 'string') { 58 return column; 59 } 60 // Special case: If the join is performed on a single column `id`, we can use a simpler representation (i.e. `table[id].column`). 61 if (arrayEquals(Object.keys(column.source.joinOn), ['id'])) { 62 return `${column.source.table}[${sqlColumnId(Object.values(column.source.joinOn)[0])}].${column.column}`; 63 } 64 // Special case: args lookup. For it, we can use a simpler representation (i.e. `arg_set_id[key]`). 65 if ( 66 column.column in ARG_COLUMN_TO_SUFFIX && 67 column.source.table === 'args' && 68 arrayEquals(Object.keys(column.source.joinOn).sort(), ['arg_set_id', 'key']) 69 ) { 70 const key = column.source.joinOn['key']; 71 const argSetId = column.source.joinOn['arg_set_id']; 72 return `${sqlColumnId(argSetId)}[${sqlColumnId(key)}]${ARG_COLUMN_TO_SUFFIX[column.column]}`; 73 } 74 // Otherwise, we need to list all the join constraints. 75 const lookup = Object.entries(column.source.joinOn) 76 .map(([key, value]): string => { 77 const valueStr = sqlColumnId(value); 78 if (key === valueStr) return key; 79 return `${key}=${sqlColumnId(value)}`; 80 }) 81 .join(', '); 82 return `${column.source.table}[${lookup}].${column.column}`; 83} 84 85export function isSqlColumnEqual(a: SqlColumn, b: SqlColumn): boolean { 86 return sqlColumnId(a) === sqlColumnId(b); 87} 88 89function sqlColumnName(column: SqlColumn): string { 90 if (typeof column === 'string') { 91 return column; 92 } 93 return column.column; 94} 95 96// Interface which allows TableColumn and TableColumnSet to interact with the table (e.g. add filters, or run the query). 97export interface TableManager { 98 addFilter(filter: Filter): void; 99 100 trace: Trace; 101 getSqlQuery(data: {[key: string]: SqlColumn}): string; 102} 103 104export interface TableColumnParams { 105 // See TableColumn.tag. 106 tag?: string; 107 // See TableColumn.alias. 108 alias?: string; 109 // See TableColumn.startsHidden. 110 startsHidden?: boolean; 111} 112 113export interface AggregationConfig { 114 dataType?: 'nominal' | 'quantitative'; 115} 116 117// Class which represents a column in a table, which can be displayed to the user. 118// It is based on the primary SQL column, but also contains additional information needed for displaying it as a part of a table. 119export abstract class TableColumn { 120 constructor(params?: TableColumnParams) { 121 this.tag = params?.tag; 122 this.alias = params?.alias; 123 this.startsHidden = params?.startsHidden ?? false; 124 } 125 126 // Column title to be displayed. 127 // If not set, then `alias` will be used if it's unique. 128 // If `alias` is not set as well, then `sqlColumnId(primaryColumn())` will be used. 129 // TODO(altimin): This should return m.Children, but a bunch of things, including low-level widgets (Button, MenuItem, Anchor) need to be fixed first. 130 getTitle?(): string | undefined; 131 132 // Some SQL columns can map to multiple table columns. For example, a "utid" can be displayed as an integer column, or as a "thread" column, which displays "$thread_name [$tid]". 133 // Each column should have a unique id, so in these cases `tag` is appended to the primary column id to guarantee uniqueness. 134 readonly tag?: string; 135 136 // Preferred alias to be used in the SQL query. If omitted, column name will be used instead, including postfixing it with an integer if necessary. 137 // However, e.g. explicit aliases like `process_name` and `thread_name` are typically preferred to `name_1`, `name_2`, hence the need for explicit aliasing. 138 readonly alias?: string; 139 140 // Whether the column should be hidden by default. 141 readonly startsHidden: boolean; 142 143 // The SQL column this data corresponds to. Will be also used for sorting and aggregation purposes. 144 abstract primaryColumn(): SqlColumn; 145 146 // Sometimes to display an interactive cell more than a single value is needed (e.g. "time range" corresponds to (ts, dur) pair. While we want to show the duration, we would want to highlight the interval on hover, for which both timestamp and duration are needed. 147 dependentColumns?(): {[key: string]: SqlColumn}; 148 149 // The set of underlying sql columns that should be sorted when this column is sorted. 150 sortColumns?(): SqlColumn[]; 151 152 // Render a table cell. `value` corresponds to the fetched SQL value for the primary column, `dependentColumns` are the fetched values for the dependent columns. 153 abstract renderCell( 154 value: SqlValue, 155 tableManager: TableManager, 156 dependentColumns: {[key: string]: SqlValue}, 157 ): m.Children; 158 159 // Specifies how this column should be aggregated. If not set, then all 160 // numeric columns will be treated as quantitative, and all other columns as 161 // nominal. 162 aggregation?(): AggregationConfig; 163} 164 165// Returns a unique identifier for the table column. 166export function tableColumnId(column: TableColumn): string { 167 const primaryColumnName = sqlColumnId(column.primaryColumn()); 168 if (column.tag) { 169 return `${primaryColumnName}#${column.tag}`; 170 } 171 return primaryColumnName; 172} 173 174export function tableColumnAlias(column: TableColumn): string { 175 return column.alias ?? sqlColumnName(column.primaryColumn()); 176} 177 178// This class represents a set of columns, from which the user can choose which columns to display. It is typically impossible or impractical to list all possible columns, so this class allows to discover them dynamically. 179// Two examples of canonical TableColumnSet usage are: 180// - Argument sets, where the set of arguments can be arbitrary large (and can change when the user changes filters on the table). 181// - Dependent columns, where the id. 182export abstract class TableColumnSet { 183 // TODO(altimin): This should return m.Children, same comment as in TableColumn.getTitle applies here. 184 abstract getTitle(): string; 185 186 // Returns a list of columns from this TableColumnSet which should be displayed by default. 187 initialColumns?(): TableColumn[]; 188 189 // Returns a list of columns which can be added to the table from the current TableColumnSet. 190 abstract discover(manager: TableManager): Promise< 191 { 192 key: string; 193 column: TableColumn | TableColumnSet; 194 }[] 195 >; 196} 197 198// A filter which can be applied to the table. 199export interface Filter { 200 // Operation: it takes a list of column names and should return a valid SQL expression for this filter. 201 op: (cols: string[]) => string; 202 // Columns that the `op` should reference. The number of columns should match the number of interpolations in `op`. 203 columns: SqlColumn[]; 204 // Returns a human-readable title for the filter. If not set, `op` will be used. 205 // TODO(altimin): This probably should return m.Children, but currently Button expects its label to be string. 206 getTitle?(): string; 207} 208 209// Returns a default string representation of the filter. 210export function formatFilter(filter: Filter): string { 211 return filter.op(filter.columns.map((c) => sqlColumnId(c))); 212} 213 214// Returns a human-readable title for the filter. 215export function filterTitle(filter: Filter): string { 216 if (filter.getTitle !== undefined) { 217 return filter.getTitle(); 218 } 219 return formatFilter(filter); 220} 221 222// A column order clause, which specifies the column and the direction in which it should be sorted. 223export interface ColumnOrderClause { 224 column: SqlColumn; 225 direction: SortDirection; 226} 227