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 {NUM, Row} from '../../../../trace_processor/query_result'; 16import { 17 tableColumnAlias, 18 ColumnOrderClause, 19 Filter, 20 isSqlColumnEqual, 21 SqlColumn, 22 sqlColumnId, 23 TableColumn, 24 tableColumnId, 25} from './column'; 26import {buildSqlQuery} from './query_builder'; 27import {raf} from '../../../../core/raf_scheduler'; 28import {SortDirection} from '../../../../base/comparison_utils'; 29import {assertTrue} from '../../../../base/logging'; 30import {SqlTableDescription} from './table_description'; 31import {Trace} from '../../../../public/trace'; 32 33const ROW_LIMIT = 100; 34 35interface Request { 36 // Select statement, without the includes and the LIMIT and OFFSET clauses. 37 selectStatement: string; 38 // Query, including the LIMIT and OFFSET clauses. 39 query: string; 40 // Map of SqlColumn's id to the column name in the query. 41 columns: {[key: string]: string}; 42} 43 44// Result of the execution of the query. 45interface Data { 46 // Rows to show, including pagination. 47 rows: Row[]; 48 error?: string; 49} 50 51interface RowCount { 52 // Total number of rows in view, excluding the pagination. 53 // Undefined if the query returned an error. 54 count: number; 55 // Filters which were used to compute this row count. 56 // We need to recompute the totalRowCount only when filters change and not 57 // when the set of columns / order by changes. 58 filters: Filter[]; 59} 60 61function isFilterEqual(a: Filter, b: Filter) { 62 return ( 63 a.op === b.op && 64 a.columns.length === b.columns.length && 65 a.columns.every((c, i) => isSqlColumnEqual(c, b.columns[i])) 66 ); 67} 68 69function areFiltersEqual(a: Filter[], b: Filter[]) { 70 if (a.length !== b.length) return false; 71 return a.every((f, i) => isFilterEqual(f, b[i])); 72} 73 74export class SqlTableState { 75 private readonly additionalImports: string[]; 76 77 // Columns currently displayed to the user. All potential columns can be found `this.table.columns`. 78 private columns: TableColumn[]; 79 private filters: Filter[]; 80 private orderBy: { 81 column: TableColumn; 82 direction: SortDirection; 83 }[]; 84 private offset = 0; 85 private request: Request; 86 private data?: Data; 87 private rowCount?: RowCount; 88 89 constructor( 90 readonly trace: Trace, 91 readonly config: SqlTableDescription, 92 private readonly args?: { 93 initialColumns?: TableColumn[]; 94 additionalColumns?: TableColumn[]; 95 imports?: string[]; 96 filters?: Filter[]; 97 orderBy?: { 98 column: TableColumn; 99 direction: SortDirection; 100 }[]; 101 }, 102 ) { 103 this.additionalImports = args?.imports || []; 104 105 this.filters = args?.filters || []; 106 this.columns = []; 107 108 if (args?.initialColumns !== undefined) { 109 assertTrue( 110 args?.additionalColumns === undefined, 111 'Only one of `initialColumns` and `additionalColumns` can be set', 112 ); 113 this.columns.push(...args.initialColumns); 114 } else { 115 for (const column of this.config.columns) { 116 if (column instanceof TableColumn) { 117 if (column.startsHidden !== true) { 118 this.columns.push(column); 119 } 120 } else { 121 const cols = column.initialColumns?.(); 122 for (const col of cols ?? []) { 123 this.columns.push(col); 124 } 125 } 126 } 127 if (args?.additionalColumns !== undefined) { 128 this.columns.push(...args.additionalColumns); 129 } 130 } 131 132 this.orderBy = args?.orderBy ?? []; 133 134 this.request = this.buildRequest(); 135 this.reload(); 136 } 137 138 clone(): SqlTableState { 139 return new SqlTableState(this.trace, this.config, { 140 initialColumns: this.columns, 141 imports: this.args?.imports, 142 filters: this.filters, 143 orderBy: this.orderBy, 144 }); 145 } 146 147 private getSQLImports() { 148 const tableImports = this.config.imports || []; 149 return [...tableImports, ...this.additionalImports] 150 .map((i) => `INCLUDE PERFETTO MODULE ${i};`) 151 .join('\n'); 152 } 153 154 private getCountRowsSQLQuery(): string { 155 return ` 156 ${this.getSQLImports()} 157 158 ${this.getSqlQuery({count: 'COUNT()'})} 159 `; 160 } 161 162 // Return a query which selects the given columns, applying the filters and ordering currently in effect. 163 getSqlQuery(columns: {[key: string]: SqlColumn}): string { 164 return buildSqlQuery({ 165 table: this.config.name, 166 columns, 167 filters: this.filters, 168 orderBy: this.getOrderedBy(), 169 }); 170 } 171 172 // We need column names to pass to the debug track creation logic. 173 private buildSqlSelectStatement(): { 174 selectStatement: string; 175 columns: {[key: string]: string}; 176 } { 177 const columns: {[key: string]: SqlColumn} = {}; 178 // A set of columnIds for quick lookup. 179 const sqlColumnIds: Set<string> = new Set(); 180 // We want to use the shortest posible name for each column, but we also need to mindful of potential collisions. 181 // To avoid collisions, we append a number to the column name if there are multiple columns with the same name. 182 const columnNameCount: {[key: string]: number} = {}; 183 184 const tableColumns: {column: TableColumn; name: string; alias: string}[] = 185 []; 186 187 for (const column of this.columns) { 188 // If TableColumn has an alias, use it. Otherwise, use the column name. 189 const name = tableColumnAlias(column); 190 if (!(name in columnNameCount)) { 191 columnNameCount[name] = 0; 192 } 193 194 // Note: this can break if the user specifies a column which ends with `__<number>`. 195 // We intentionally use two underscores to avoid collisions and will fix it down the line if it turns out to be a problem. 196 const alias = `${name}__${++columnNameCount[name]}`; 197 tableColumns.push({column, name, alias}); 198 } 199 200 for (const column of tableColumns) { 201 const sqlColumn = column.column.primaryColumn(); 202 // If we have only one column with this name, we don't need to disambiguate it. 203 if (columnNameCount[column.name] === 1) { 204 columns[column.name] = sqlColumn; 205 } else { 206 columns[column.alias] = sqlColumn; 207 } 208 sqlColumnIds.add(sqlColumnId(sqlColumn)); 209 } 210 211 // We are going to be less fancy for the dependendent columns can just always suffix them with a unique integer. 212 let dependentColumnCount = 0; 213 for (const column of tableColumns) { 214 const dependentColumns = 215 column.column.dependentColumns !== undefined 216 ? column.column.dependentColumns() 217 : {}; 218 for (const col of Object.values(dependentColumns)) { 219 if (sqlColumnIds.has(sqlColumnId(col))) continue; 220 const name = typeof col === 'string' ? col : col.column; 221 const alias = `__${name}_${dependentColumnCount++}`; 222 columns[alias] = col; 223 sqlColumnIds.add(sqlColumnId(col)); 224 } 225 } 226 227 return { 228 selectStatement: this.getSqlQuery(columns), 229 columns: Object.fromEntries( 230 Object.entries(columns).map(([key, value]) => [ 231 sqlColumnId(value), 232 key, 233 ]), 234 ), 235 }; 236 } 237 238 getNonPaginatedSQLQuery(): string { 239 return ` 240 ${this.getSQLImports()} 241 242 ${this.buildSqlSelectStatement().selectStatement} 243 `; 244 } 245 246 getPaginatedSQLQuery(): Request { 247 return this.request; 248 } 249 250 canGoForward(): boolean { 251 if (this.data === undefined) return false; 252 return this.data.rows.length > ROW_LIMIT; 253 } 254 255 canGoBack(): boolean { 256 if (this.data === undefined) return false; 257 return this.offset > 0; 258 } 259 260 goForward() { 261 if (!this.canGoForward()) return; 262 this.offset += ROW_LIMIT; 263 this.reload({offset: 'keep'}); 264 } 265 266 goBack() { 267 if (!this.canGoBack()) return; 268 this.offset -= ROW_LIMIT; 269 this.reload({offset: 'keep'}); 270 } 271 272 getDisplayedRange(): {from: number; to: number} | undefined { 273 if (this.data === undefined) return undefined; 274 return { 275 from: this.offset + 1, 276 to: this.offset + Math.min(this.data.rows.length, ROW_LIMIT), 277 }; 278 } 279 280 private async loadRowCount(): Promise<RowCount | undefined> { 281 const filters = Array.from(this.filters); 282 const res = await this.trace.engine.query(this.getCountRowsSQLQuery()); 283 if (res.error() !== undefined) return undefined; 284 return { 285 count: res.firstRow({count: NUM}).count, 286 filters: filters, 287 }; 288 } 289 290 private buildRequest(): Request { 291 const {selectStatement, columns} = this.buildSqlSelectStatement(); 292 // We fetch one more row to determine if we can go forward. 293 const query = ` 294 ${this.getSQLImports()} 295 ${selectStatement} 296 LIMIT ${ROW_LIMIT + 1} 297 OFFSET ${this.offset} 298 `; 299 return {selectStatement, query, columns}; 300 } 301 302 private async loadData(): Promise<Data> { 303 const queryRes = await this.trace.engine.query(this.request.query); 304 const rows: Row[] = []; 305 for (const it = queryRes.iter({}); it.valid(); it.next()) { 306 const row: Row = {}; 307 for (const column of queryRes.columns()) { 308 row[column] = it.get(column); 309 } 310 rows.push(row); 311 } 312 313 return { 314 rows, 315 error: queryRes.error(), 316 }; 317 } 318 319 private async reload(params?: {offset: 'reset' | 'keep'}) { 320 if ((params?.offset ?? 'reset') === 'reset') { 321 this.offset = 0; 322 } 323 324 const newFilters = this.rowCount?.filters; 325 const filtersMatch = 326 newFilters && areFiltersEqual(newFilters, this.filters); 327 this.data = undefined; 328 const request = this.buildRequest(); 329 this.request = request; 330 if (!filtersMatch) { 331 this.rowCount = undefined; 332 } 333 334 // Schedule a full redraw to happen after a short delay (50 ms). 335 // This is done to prevent flickering / visual noise and allow the UI to fetch 336 // the initial data from the Trace Processor. 337 // There is a chance that someone else schedules a full redraw in the 338 // meantime, forcing the flicker, but in practice it works quite well and 339 // avoids a lot of complexity for the callers. 340 // 50ms is half of the responsiveness threshold (100ms): 341 // https://web.dev/rail/#response-process-events-in-under-50ms 342 setTimeout(() => raf.scheduleFullRedraw(), 50); 343 344 if (!filtersMatch) { 345 this.rowCount = await this.loadRowCount(); 346 } 347 348 const data = await this.loadData(); 349 350 // If the request has changed since we started loading the data, do not update the state. 351 if (this.request !== request) return; 352 this.data = data; 353 354 raf.scheduleFullRedraw(); 355 } 356 357 getTotalRowCount(): number | undefined { 358 return this.rowCount?.count; 359 } 360 361 getCurrentRequest(): Request { 362 return this.request; 363 } 364 365 getDisplayedRows(): Row[] { 366 return this.data?.rows || []; 367 } 368 369 getQueryError(): string | undefined { 370 return this.data?.error; 371 } 372 373 isLoading() { 374 return this.data === undefined; 375 } 376 377 addFilter(filter: Filter) { 378 this.filters.push(filter); 379 this.reload(); 380 } 381 382 removeFilter(filter: Filter) { 383 this.filters = this.filters.filter((f) => !isFilterEqual(f, filter)); 384 this.reload(); 385 } 386 387 getFilters(): Filter[] { 388 return this.filters; 389 } 390 391 sortBy(clause: {column: TableColumn; direction: SortDirection}) { 392 // Remove previous sort by the same column. 393 this.orderBy = this.orderBy.filter( 394 (c) => tableColumnId(c.column) != tableColumnId(clause.column), 395 ); 396 // Add the new sort clause to the front, so we effectively stable-sort the 397 // data currently displayed to the user. 398 this.orderBy.unshift(clause); 399 this.reload(); 400 } 401 402 unsort() { 403 this.orderBy = []; 404 this.reload(); 405 } 406 407 isSortedBy(column: TableColumn): SortDirection | undefined { 408 if (this.orderBy.length === 0) return undefined; 409 if (tableColumnId(this.orderBy[0].column) !== tableColumnId(column)) { 410 return undefined; 411 } 412 return this.orderBy[0].direction; 413 } 414 415 getOrderedBy(): ColumnOrderClause[] { 416 const result: ColumnOrderClause[] = []; 417 for (const orderBy of this.orderBy) { 418 const sortColumns = orderBy.column.sortColumns?.() ?? [ 419 orderBy.column.primaryColumn(), 420 ]; 421 for (const column of sortColumns) { 422 result.push({column, direction: orderBy.direction}); 423 } 424 } 425 return result; 426 } 427 428 addColumn(column: TableColumn, index: number) { 429 this.columns.splice(index + 1, 0, column); 430 this.reload({offset: 'keep'}); 431 } 432 433 hideColumnAtIndex(index: number) { 434 const column = this.columns[index]; 435 this.columns.splice(index, 1); 436 // We can only filter by the visibile columns to avoid confusing the user, 437 // so we remove order by clauses that refer to the hidden column. 438 this.orderBy = this.orderBy.filter( 439 (c) => tableColumnId(c.column) !== tableColumnId(column), 440 ); 441 // TODO(altimin): we can avoid the fetch here if the orderBy hasn't changed. 442 this.reload({offset: 'keep'}); 443 } 444 445 moveColumn(fromIndex: number, toIndex: number) { 446 if (fromIndex === toIndex) return; 447 const column = this.columns[fromIndex]; 448 this.columns.splice(fromIndex, 1); 449 if (fromIndex < toIndex) { 450 // We have deleted a column, therefore we need to adjust the target index. 451 --toIndex; 452 } 453 this.columns.splice(toIndex, 0, column); 454 raf.scheduleFullRedraw(); 455 } 456 457 getSelectedColumns(): TableColumn[] { 458 return this.columns; 459 } 460} 461