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 {MenuDivider, MenuItem, PopupMenu} from '../../../../widgets/menu'; 17import {buildSqlQuery} from './query_builder'; 18import {Icons} from '../../../../base/semantic_icons'; 19import {sqliteString} from '../../../../base/string_utils'; 20import { 21 ColumnType, 22 Row, 23 SqlValue, 24} from '../../../../trace_processor/query_result'; 25import {Anchor} from '../../../../widgets/anchor'; 26import {BasicTable} from '../../../../widgets/basic_table'; 27import {Spinner} from '../../../../widgets/spinner'; 28 29import { 30 LegacySqlTableFilterOptions, 31 LegacySqlTableFilterLabel, 32} from './render_cell_utils'; 33import {SqlTableState} from './state'; 34import {SqlTableDescription} from './table_description'; 35import {Form} from '../../../../widgets/form'; 36import {TextInput} from '../../../../widgets/text_input'; 37import {TableColumn, TableManager, tableColumnId} from './table_column'; 38import {SqlColumn, sqlColumnId} from './sql_column'; 39import {SelectColumnMenu} from './select_column_menu'; 40import {renderColumnIcon, renderSortMenuItems} from './table_header'; 41 42export interface SqlTableConfig { 43 readonly state: SqlTableState; 44 // For additional menu items to add to the column header menus 45 readonly addColumnMenuItems?: ( 46 column: TableColumn, 47 columnAlias: string, 48 ) => m.Children; 49 // For additional filter actions 50 readonly extraAddFilterActions?: ( 51 op: string, 52 column: string, 53 value?: string, 54 ) => void; 55 readonly extraRemoveFilterActions?: (filterSqlStr: string) => void; 56} 57 58type AdditionalColumnMenuItems = Record<string, m.Children>; 59 60function renderCell( 61 column: TableColumn, 62 row: Row, 63 state: SqlTableState, 64): m.Children { 65 const {columns} = state.getCurrentRequest(); 66 const sqlValue = row[columns[sqlColumnId(column.column)]]; 67 68 const additionalValues: {[key: string]: SqlValue} = {}; 69 const supportingColumns: {[key: string]: SqlColumn} = 70 column.supportingColumns?.() ?? {}; 71 for (const [key, col] of Object.entries(supportingColumns)) { 72 additionalValues[key] = row[columns[sqlColumnId(col)]]; 73 } 74 75 return column.renderCell(sqlValue, getTableManager(state), additionalValues); 76} 77 78export function columnTitle(column: TableColumn): string { 79 if (column.getTitle !== undefined) { 80 const title = column.getTitle(); 81 if (title !== undefined) return title; 82 } 83 return sqlColumnId(column.column); 84} 85 86interface AddColumnMenuItemAttrs { 87 table: SqlTable; 88 state: SqlTableState; 89 index: number; 90} 91 92// This is separated into a separate class to store the index of the column to be 93// added and increment it when multiple columns are added from the same popup menu. 94class AddColumnMenuItem implements m.ClassComponent<AddColumnMenuItemAttrs> { 95 // Index where the new column should be inserted. 96 // In the regular case, a click would close the popup (destroying this class) and 97 // the `index` would not change during its lifetime. 98 // However, for mod-click, we want to keep adding columns to the right of the recently 99 // added column, so to achieve that we keep track of the index and increment it for 100 // each new column added. 101 index: number; 102 103 constructor({attrs}: m.Vnode<AddColumnMenuItemAttrs>) { 104 this.index = attrs.index; 105 } 106 107 view({attrs}: m.Vnode<AddColumnMenuItemAttrs>) { 108 return m( 109 MenuItem, 110 {label: 'Add column', icon: Icons.Add}, 111 attrs.table.renderAddColumnOptions((column) => { 112 attrs.state.addColumn(column, this.index++); 113 }), 114 ); 115 } 116} 117 118interface ColumnFilterAttrs { 119 filterOption: LegacySqlTableFilterLabel; 120 columns: SqlColumn[]; 121 state: SqlTableState; 122} 123 124// Separating out an individual column filter into a class 125// so that we can store the raw input value. 126class ColumnFilter implements m.ClassComponent<ColumnFilterAttrs> { 127 // Holds the raw string value from the filter text input element 128 private inputValue: string; 129 130 constructor() { 131 this.inputValue = ''; 132 } 133 134 view({attrs}: m.Vnode<ColumnFilterAttrs>) { 135 const {filterOption, columns, state} = attrs; 136 137 const {op, requiresParam} = LegacySqlTableFilterOptions[filterOption]; 138 139 return m( 140 MenuItem, 141 { 142 label: filterOption, 143 // Filter options that do not need an input value will filter the 144 // table directly when clicking on the menu item 145 // (ex: IS NULL or IS NOT NULL) 146 onclick: !requiresParam 147 ? () => { 148 state.filters.addFilter({ 149 op: (cols) => `${cols[0]} ${op}`, 150 columns, 151 }); 152 } 153 : undefined, 154 }, 155 // All non-null filter options will have a submenu that allows 156 // the user to enter a value into textfield and filter using 157 // the Filter button. 158 requiresParam && 159 m( 160 Form, 161 { 162 onSubmit: () => { 163 // Convert the string extracted from 164 // the input text field into the correct data type for 165 // filtering. The order in which each data type is 166 // checked matters: string, number (floating), and bigint. 167 if (this.inputValue === '') return; 168 169 let filterValue: ColumnType; 170 171 if (Number.isNaN(Number.parseFloat(this.inputValue))) { 172 filterValue = sqliteString(this.inputValue); 173 } else if ( 174 !Number.isInteger(Number.parseFloat(this.inputValue)) 175 ) { 176 filterValue = Number(this.inputValue); 177 } else { 178 filterValue = BigInt(this.inputValue); 179 } 180 181 state.filters.addFilter({ 182 op: (cols) => `${cols[0]} ${op} ${filterValue}`, 183 columns, 184 }); 185 }, 186 submitLabel: 'Filter', 187 }, 188 m(TextInput, { 189 id: 'column_filter_value', 190 ref: 'COLUMN_FILTER_VALUE', 191 autofocus: true, 192 oninput: (e: KeyboardEvent) => { 193 if (!e.target) return; 194 195 this.inputValue = (e.target as HTMLInputElement).value; 196 }, 197 }), 198 ), 199 ); 200 } 201} 202 203export class SqlTable implements m.ClassComponent<SqlTableConfig> { 204 private readonly table: SqlTableDescription; 205 206 private state: SqlTableState; 207 208 constructor(vnode: m.Vnode<SqlTableConfig>) { 209 this.state = vnode.attrs.state; 210 this.table = this.state.config; 211 } 212 213 renderAddColumnOptions(addColumn: (column: TableColumn) => void): m.Children { 214 // We do not want to add columns which already exist, so we track the 215 // columns which we are already showing here. 216 // TODO(altimin): Theoretically a single table can have two different 217 // arg_set_ids, so we should track (arg_set_id_column, arg_name) pairs here. 218 const existingColumnIds = new Set<string>(); 219 220 for (const column of this.state.getSelectedColumns()) { 221 existingColumnIds.add(tableColumnId(column)); 222 } 223 224 return m(SelectColumnMenu, { 225 columns: this.table.columns.map((column) => ({ 226 key: columnTitle(column), 227 column, 228 })), 229 manager: getTableManager(this.state), 230 existingColumnIds, 231 onColumnSelected: addColumn, 232 }); 233 } 234 235 renderColumnFilterOptions( 236 c: TableColumn, 237 ): m.Vnode<ColumnFilterAttrs, unknown>[] { 238 return Object.keys(LegacySqlTableFilterOptions).map((label) => 239 m(ColumnFilter, { 240 filterOption: label as LegacySqlTableFilterLabel, 241 columns: [c.column], 242 state: this.state, 243 }), 244 ); 245 } 246 247 renderColumnHeader( 248 column: TableColumn, 249 index: number, 250 additionalColumnHeaderMenuItems?: m.Children, 251 ) { 252 const sorted = this.state.isSortedBy(column); 253 254 return m( 255 PopupMenu, 256 { 257 trigger: m( 258 Anchor, 259 {icon: renderColumnIcon(sorted)}, 260 columnTitle(column), 261 ), 262 }, 263 renderSortMenuItems(sorted, (direction) => 264 this.state.sortBy({column, direction}), 265 ), 266 this.state.getSelectedColumns().length > 1 && 267 m(MenuItem, { 268 label: 'Hide', 269 icon: Icons.Hide, 270 onclick: () => this.state.hideColumnAtIndex(index), 271 }), 272 m( 273 MenuItem, 274 {label: 'Add filter', icon: Icons.Filter}, 275 this.renderColumnFilterOptions(column), 276 ), 277 additionalColumnHeaderMenuItems, 278 // Menu items before divider apply to selected column 279 m(MenuDivider), 280 // Menu items after divider apply to entire table 281 m(AddColumnMenuItem, {table: this, state: this.state, index}), 282 ); 283 } 284 285 getAdditionalColumnMenuItems( 286 addColumnMenuItems?: ( 287 column: TableColumn, 288 columnAlias: string, 289 ) => m.Children, 290 ) { 291 if (addColumnMenuItems === undefined) return; 292 293 const additionalColumnMenuItems: AdditionalColumnMenuItems = {}; 294 this.state.getSelectedColumns().forEach((column) => { 295 const columnAlias = 296 this.state.getCurrentRequest().columns[sqlColumnId(column.column)]; 297 298 additionalColumnMenuItems[columnAlias] = addColumnMenuItems( 299 column, 300 columnAlias, 301 ); 302 }); 303 304 return additionalColumnMenuItems; 305 } 306 307 view({attrs}: m.Vnode<SqlTableConfig>) { 308 const rows = this.state.getDisplayedRows(); 309 const additionalColumnMenuItems = this.getAdditionalColumnMenuItems( 310 attrs.addColumnMenuItems, 311 ); 312 313 const columns = this.state.getSelectedColumns(); 314 const columnDescriptors = columns.map((column, i) => { 315 return { 316 title: this.renderColumnHeader( 317 column, 318 i, 319 additionalColumnMenuItems && 320 additionalColumnMenuItems[ 321 this.state.getCurrentRequest().columns[sqlColumnId(column.column)] 322 ], 323 ), 324 render: (row: Row) => renderCell(column, row, this.state), 325 }; 326 }); 327 328 return [ 329 m( 330 BasicTable<Row>, 331 { 332 data: rows, 333 columns: columnDescriptors, 334 onreorder: (from: number, to: number) => 335 this.state.moveColumn(from, to), 336 }, 337 this.state.isLoading() && m(Spinner), 338 this.state.getQueryError() !== undefined && 339 m('.query-error', this.state.getQueryError()), 340 ), 341 ]; 342 } 343} 344 345export function getTableManager(state: SqlTableState): TableManager { 346 return { 347 filters: state.filters, 348 trace: state.trace, 349 getSqlQuery: (columns: {[key: string]: SqlColumn}) => 350 buildSqlQuery({ 351 table: state.config.name, 352 columns, 353 filters: state.filters.get(), 354 orderBy: state.getOrderedBy(), 355 }), 356 }; 357} 358