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