1// Copyright (C) 2023 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 {arrayEquals} from '../../base/array_utils'; 16import {SortDirection} from '../../base/comparison_utils'; 17import {isString} from '../../base/object_utils'; 18import {sqliteString} from '../../base/string_utils'; 19import {raf} from '../../core/raf_scheduler'; 20import {Engine} from '../../trace_processor/engine'; 21import {NUM, Row} from '../../trace_processor/query_result'; 22import { 23 constraintsToQueryPrefix, 24 constraintsToQuerySuffix, 25 SQLConstraints, 26} from '../../trace_processor/sql_utils'; 27 28import { 29 Column, 30 columnFromSqlTableColumn, 31 formatSqlProjection, 32 SqlProjection, 33 sqlProjectionsForColumn, 34} from './column'; 35import {SqlTableDescription, startsHidden} from './table_description'; 36 37interface ColumnOrderClause { 38 // We only allow the table to be sorted by the columns which are displayed to 39 // the user to avoid confusion, so we use a reference to the underlying Column 40 // here and compare it by reference down the line. 41 column: Column; 42 direction: SortDirection; 43} 44 45const ROW_LIMIT = 100; 46 47// Result of the execution of the query. 48interface Data { 49 // Rows to show, including pagination. 50 rows: Row[]; 51 error?: string; 52} 53 54// In the common case, filter is an expression which evaluates to a boolean. 55// However, when filtering args, it's substantially (10x) cheaper to do a 56// join with the args table, as it means that trace processor can cache the 57// query on the key instead of invoking a function for each row of the entire 58// `slice` table. 59export type Filter = 60 | string 61 | { 62 type: 'arg_filter'; 63 argSetIdColumn: string; 64 argName: string; 65 op: string; 66 }; 67 68interface RowCount { 69 // Total number of rows in view, excluding the pagination. 70 // Undefined if the query returned an error. 71 count: number; 72 // Filters which were used to compute this row count. 73 // We need to recompute the totalRowCount only when filters change and not 74 // when the set of columns / order by changes. 75 filters: Filter[]; 76} 77 78export class SqlTableState { 79 private readonly engine_: Engine; 80 private readonly table_: SqlTableDescription; 81 private readonly additionalImports: string[]; 82 83 get engine() { 84 return this.engine_; 85 } 86 get table() { 87 return this.table_; 88 } 89 90 private filters: Filter[]; 91 private columns: Column[]; 92 private orderBy: ColumnOrderClause[]; 93 private offset = 0; 94 private data?: Data; 95 private rowCount?: RowCount; 96 97 constructor( 98 engine: Engine, 99 table: SqlTableDescription, 100 filters?: Filter[], 101 imports?: string[], 102 ) { 103 this.engine_ = engine; 104 this.table_ = table; 105 this.additionalImports = imports || []; 106 107 this.filters = filters || []; 108 this.columns = []; 109 for (const column of this.table.columns) { 110 if (startsHidden(column)) continue; 111 this.columns.push(columnFromSqlTableColumn(column)); 112 } 113 this.orderBy = []; 114 115 this.reload(); 116 } 117 118 // Compute the actual columns to fetch. Some columns can appear multiple times 119 // (e.g. we might need "ts" to be able to show it, as well as a dependency for 120 // "slice_id" to be able to jump to it, so this function will deduplicate 121 // projections by alias. 122 private getSQLProjections(): SqlProjection[] { 123 const projections = []; 124 const aliases = new Set<string>(); 125 for (const column of this.columns) { 126 for (const p of sqlProjectionsForColumn(column)) { 127 if (aliases.has(p.alias)) continue; 128 aliases.add(p.alias); 129 projections.push(p); 130 } 131 } 132 return projections; 133 } 134 135 getQueryConstraints(): SQLConstraints { 136 const result: SQLConstraints = { 137 commonTableExpressions: {}, 138 joins: [], 139 filters: [], 140 }; 141 let cteId = 0; 142 for (const filter of this.filters) { 143 if (isString(filter)) { 144 result.filters!.push(filter); 145 } else { 146 const cteName = `arg_sets_${cteId++}`; 147 result.commonTableExpressions![cteName] = ` 148 SELECT DISTINCT arg_set_id 149 FROM args 150 WHERE key = ${sqliteString(filter.argName)} 151 AND display_value ${filter.op} 152 `; 153 result.joins!.push( 154 `JOIN ${cteName} ON ${cteName}.arg_set_id = ${this.table.name}.${filter.argSetIdColumn}`, 155 ); 156 } 157 } 158 return result; 159 } 160 161 private getSQLImports() { 162 const tableImports = this.table.imports || []; 163 return [...tableImports, ...this.additionalImports] 164 .map((i) => `INCLUDE PERFETTO MODULE ${i};`) 165 .join('\n'); 166 } 167 168 private getCountRowsSQLQuery(): string { 169 const constraints = this.getQueryConstraints(); 170 return ` 171 ${this.getSQLImports()} 172 173 ${constraintsToQueryPrefix(constraints)} 174 SELECT 175 COUNT() AS count 176 FROM ${this.table.name} 177 ${constraintsToQuerySuffix(constraints)} 178 `; 179 } 180 181 buildSqlSelectStatement(): { 182 selectStatement: string; 183 columns: string[]; 184 } { 185 const projections = this.getSQLProjections(); 186 const orderBy = this.orderBy.map((c) => ({ 187 fieldName: c.column.alias, 188 direction: c.direction, 189 })); 190 const constraints = this.getQueryConstraints(); 191 constraints.orderBy = orderBy; 192 const statement = ` 193 ${constraintsToQueryPrefix(constraints)} 194 SELECT 195 ${projections.map(formatSqlProjection).join(',\n')} 196 FROM ${this.table.name} 197 ${constraintsToQuerySuffix(constraints)} 198 `; 199 return { 200 selectStatement: statement, 201 columns: projections.map((p) => p.alias), 202 }; 203 } 204 205 getNonPaginatedSQLQuery(): string { 206 return ` 207 ${this.getSQLImports()} 208 209 ${this.buildSqlSelectStatement().selectStatement} 210 `; 211 } 212 213 getPaginatedSQLQuery(): string { 214 // We fetch one more row to determine if we can go forward. 215 return ` 216 ${this.getNonPaginatedSQLQuery()} 217 LIMIT ${ROW_LIMIT + 1} 218 OFFSET ${this.offset} 219 `; 220 } 221 222 canGoForward(): boolean { 223 if (this.data === undefined) return false; 224 return this.data.rows.length > ROW_LIMIT; 225 } 226 227 canGoBack(): boolean { 228 if (this.data === undefined) return false; 229 return this.offset > 0; 230 } 231 232 goForward() { 233 if (!this.canGoForward()) return; 234 this.offset += ROW_LIMIT; 235 this.reload({offset: 'keep'}); 236 } 237 238 goBack() { 239 if (!this.canGoBack()) return; 240 this.offset -= ROW_LIMIT; 241 this.reload({offset: 'keep'}); 242 } 243 244 getDisplayedRange(): {from: number; to: number} | undefined { 245 if (this.data === undefined) return undefined; 246 return { 247 from: this.offset + 1, 248 to: this.offset + Math.min(this.data.rows.length, ROW_LIMIT), 249 }; 250 } 251 252 private async loadRowCount(): Promise<RowCount | undefined> { 253 const filters = Array.from(this.filters); 254 const res = await this.engine.query(this.getCountRowsSQLQuery()); 255 if (res.error() !== undefined) return undefined; 256 return { 257 count: res.firstRow({count: NUM}).count, 258 filters: filters, 259 }; 260 } 261 262 private async loadData(): Promise<Data> { 263 const queryRes = await this.engine.query(this.getPaginatedSQLQuery()); 264 const rows: Row[] = []; 265 for (const it = queryRes.iter({}); it.valid(); it.next()) { 266 const row: Row = {}; 267 for (const column of queryRes.columns()) { 268 row[column] = it.get(column); 269 } 270 rows.push(row); 271 } 272 273 return { 274 rows, 275 error: queryRes.error(), 276 }; 277 } 278 279 private async reload(params?: {offset: 'reset' | 'keep'}) { 280 if ((params?.offset ?? 'reset') === 'reset') { 281 this.offset = 0; 282 } 283 284 const newFilters = this.rowCount?.filters; 285 const filtersMatch = newFilters && arrayEquals(newFilters, this.filters); 286 this.data = undefined; 287 if (!filtersMatch) { 288 this.rowCount = undefined; 289 } 290 291 // Delay the visual update by 50ms to avoid flickering (if the query returns 292 // before the data is loaded. 293 setTimeout(() => raf.scheduleFullRedraw(), 50); 294 295 if (!filtersMatch) { 296 this.rowCount = await this.loadRowCount(); 297 } 298 this.data = await this.loadData(); 299 300 raf.scheduleFullRedraw(); 301 } 302 303 getTotalRowCount(): number | undefined { 304 return this.rowCount?.count; 305 } 306 307 getDisplayedRows(): Row[] { 308 return this.data?.rows || []; 309 } 310 311 getQueryError(): string | undefined { 312 return this.data?.error; 313 } 314 315 isLoading() { 316 return this.data === undefined; 317 } 318 319 // Filters are compared by reference, so the caller is required to pass an 320 // object which was previously returned by getFilters. 321 removeFilter(filter: Filter) { 322 this.filters = this.filters.filter((f) => f !== filter); 323 this.reload(); 324 } 325 326 addFilter(filter: string) { 327 this.filters.push(filter); 328 this.reload(); 329 } 330 331 getFilters(): Filter[] { 332 return this.filters; 333 } 334 335 sortBy(clause: ColumnOrderClause) { 336 // Remove previous sort by the same column. 337 this.orderBy = this.orderBy.filter((c) => c.column !== clause.column); 338 // Add the new sort clause to the front, so we effectively stable-sort the 339 // data currently displayed to the user. 340 this.orderBy.unshift(clause); 341 this.reload(); 342 } 343 344 unsort() { 345 this.orderBy = []; 346 this.reload(); 347 } 348 349 isSortedBy(column: Column): SortDirection | undefined { 350 if (this.orderBy.length === 0) return undefined; 351 if (this.orderBy[0].column !== column) return undefined; 352 return this.orderBy[0].direction; 353 } 354 355 addColumn(column: Column, index: number) { 356 this.columns.splice(index + 1, 0, column); 357 this.reload({offset: 'keep'}); 358 } 359 360 hideColumnAtIndex(index: number) { 361 const column = this.columns[index]; 362 this.columns.splice(index, 1); 363 // We can only filter by the visibile columns to avoid confusing the user, 364 // so we remove order by clauses that refer to the hidden column. 365 this.orderBy = this.orderBy.filter((c) => c.column !== column); 366 // TODO(altimin): we can avoid the fetch here if the orderBy hasn't changed. 367 this.reload({offset: 'keep'}); 368 } 369 370 getSelectedColumns(): Column[] { 371 return this.columns; 372 } 373} 374