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