| // Copyright (C) 2023 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import {arrayEquals} from '../../../../base/array_utils'; |
| import {SortDirection} from '../../../../base/comparison_utils'; |
| import {isString} from '../../../../base/object_utils'; |
| import {sqliteString} from '../../../../base/string_utils'; |
| import {raf} from '../../../../core/raf_scheduler'; |
| import {Engine} from '../../../../trace_processor/engine'; |
| import {NUM, Row} from '../../../../trace_processor/query_result'; |
| import { |
| constraintsToQueryPrefix, |
| constraintsToQuerySuffix, |
| SQLConstraints, |
| } from '../../../../trace_processor/sql_utils'; |
| |
| import { |
| Column, |
| columnFromSqlTableColumn, |
| formatSqlProjection, |
| SqlProjection, |
| sqlProjectionsForColumn, |
| } from './column'; |
| import {SqlTableDescription, startsHidden} from './table_description'; |
| |
| interface ColumnOrderClause { |
| // We only allow the table to be sorted by the columns which are displayed to |
| // the user to avoid confusion, so we use a reference to the underlying Column |
| // here and compare it by reference down the line. |
| column: Column; |
| direction: SortDirection; |
| } |
| |
| const ROW_LIMIT = 100; |
| |
| // Result of the execution of the query. |
| interface Data { |
| // Rows to show, including pagination. |
| rows: Row[]; |
| error?: string; |
| } |
| |
| // In the common case, filter is an expression which evaluates to a boolean. |
| // However, when filtering args, it's substantially (10x) cheaper to do a |
| // join with the args table, as it means that trace processor can cache the |
| // query on the key instead of invoking a function for each row of the entire |
| // `slice` table. |
| export type Filter = |
| | string |
| | { |
| type: 'arg_filter'; |
| argSetIdColumn: string; |
| argName: string; |
| op: string; |
| }; |
| |
| interface RowCount { |
| // Total number of rows in view, excluding the pagination. |
| // Undefined if the query returned an error. |
| count: number; |
| // Filters which were used to compute this row count. |
| // We need to recompute the totalRowCount only when filters change and not |
| // when the set of columns / order by changes. |
| filters: Filter[]; |
| } |
| |
| export class SqlTableState { |
| private readonly engine_: Engine; |
| private readonly table_: SqlTableDescription; |
| private readonly additionalImports: string[]; |
| |
| get engine() { |
| return this.engine_; |
| } |
| get table() { |
| return this.table_; |
| } |
| |
| private filters: Filter[]; |
| private columns: Column[]; |
| private orderBy: ColumnOrderClause[]; |
| private offset = 0; |
| private data?: Data; |
| private rowCount?: RowCount; |
| |
| constructor( |
| engine: Engine, |
| table: SqlTableDescription, |
| filters?: Filter[], |
| imports?: string[], |
| ) { |
| this.engine_ = engine; |
| this.table_ = table; |
| this.additionalImports = imports || []; |
| |
| this.filters = filters || []; |
| this.columns = []; |
| for (const column of this.table.columns) { |
| if (startsHidden(column)) continue; |
| this.columns.push(columnFromSqlTableColumn(column)); |
| } |
| this.orderBy = []; |
| |
| this.reload(); |
| } |
| |
| // Compute the actual columns to fetch. Some columns can appear multiple times |
| // (e.g. we might need "ts" to be able to show it, as well as a dependency for |
| // "slice_id" to be able to jump to it, so this function will deduplicate |
| // projections by alias. |
| private getSQLProjections(): SqlProjection[] { |
| const projections = []; |
| const aliases = new Set<string>(); |
| for (const column of this.columns) { |
| for (const p of sqlProjectionsForColumn(column)) { |
| if (aliases.has(p.alias)) continue; |
| aliases.add(p.alias); |
| projections.push(p); |
| } |
| } |
| return projections; |
| } |
| |
| getQueryConstraints(): SQLConstraints { |
| const result: SQLConstraints = { |
| commonTableExpressions: {}, |
| joins: [], |
| filters: [], |
| }; |
| let cteId = 0; |
| for (const filter of this.filters) { |
| if (isString(filter)) { |
| result.filters!.push(filter); |
| } else { |
| const cteName = `arg_sets_${cteId++}`; |
| result.commonTableExpressions![cteName] = ` |
| SELECT DISTINCT arg_set_id |
| FROM args |
| WHERE key = ${sqliteString(filter.argName)} |
| AND display_value ${filter.op} |
| `; |
| result.joins!.push( |
| `JOIN ${cteName} ON ${cteName}.arg_set_id = ${this.table.name}.${filter.argSetIdColumn}`, |
| ); |
| } |
| } |
| return result; |
| } |
| |
| private getSQLImports() { |
| const tableImports = this.table.imports || []; |
| return [...tableImports, ...this.additionalImports] |
| .map((i) => `INCLUDE PERFETTO MODULE ${i};`) |
| .join('\n'); |
| } |
| |
| private getCountRowsSQLQuery(): string { |
| const constraints = this.getQueryConstraints(); |
| return ` |
| ${this.getSQLImports()} |
| |
| ${constraintsToQueryPrefix(constraints)} |
| SELECT |
| COUNT() AS count |
| FROM ${this.table.name} |
| ${constraintsToQuerySuffix(constraints)} |
| `; |
| } |
| |
| buildSqlSelectStatement(): { |
| selectStatement: string; |
| columns: string[]; |
| } { |
| const projections = this.getSQLProjections(); |
| const orderBy = this.orderBy.map((c) => ({ |
| fieldName: c.column.alias, |
| direction: c.direction, |
| })); |
| const constraints = this.getQueryConstraints(); |
| constraints.orderBy = orderBy; |
| const statement = ` |
| ${constraintsToQueryPrefix(constraints)} |
| SELECT |
| ${projections.map(formatSqlProjection).join(',\n')} |
| FROM ${this.table.name} |
| ${constraintsToQuerySuffix(constraints)} |
| `; |
| return { |
| selectStatement: statement, |
| columns: projections.map((p) => p.alias), |
| }; |
| } |
| |
| getNonPaginatedSQLQuery(): string { |
| return ` |
| ${this.getSQLImports()} |
| |
| ${this.buildSqlSelectStatement().selectStatement} |
| `; |
| } |
| |
| getPaginatedSQLQuery(): string { |
| // We fetch one more row to determine if we can go forward. |
| return ` |
| ${this.getNonPaginatedSQLQuery()} |
| LIMIT ${ROW_LIMIT + 1} |
| OFFSET ${this.offset} |
| `; |
| } |
| |
| canGoForward(): boolean { |
| if (this.data === undefined) return false; |
| return this.data.rows.length > ROW_LIMIT; |
| } |
| |
| canGoBack(): boolean { |
| if (this.data === undefined) return false; |
| return this.offset > 0; |
| } |
| |
| goForward() { |
| if (!this.canGoForward()) return; |
| this.offset += ROW_LIMIT; |
| this.reload({offset: 'keep'}); |
| } |
| |
| goBack() { |
| if (!this.canGoBack()) return; |
| this.offset -= ROW_LIMIT; |
| this.reload({offset: 'keep'}); |
| } |
| |
| getDisplayedRange(): {from: number; to: number} | undefined { |
| if (this.data === undefined) return undefined; |
| return { |
| from: this.offset + 1, |
| to: this.offset + Math.min(this.data.rows.length, ROW_LIMIT), |
| }; |
| } |
| |
| private async loadRowCount(): Promise<RowCount | undefined> { |
| const filters = Array.from(this.filters); |
| const res = await this.engine.query(this.getCountRowsSQLQuery()); |
| if (res.error() !== undefined) return undefined; |
| return { |
| count: res.firstRow({count: NUM}).count, |
| filters: filters, |
| }; |
| } |
| |
| private async loadData(): Promise<Data> { |
| const queryRes = await this.engine.query(this.getPaginatedSQLQuery()); |
| const rows: Row[] = []; |
| for (const it = queryRes.iter({}); it.valid(); it.next()) { |
| const row: Row = {}; |
| for (const column of queryRes.columns()) { |
| row[column] = it.get(column); |
| } |
| rows.push(row); |
| } |
| |
| return { |
| rows, |
| error: queryRes.error(), |
| }; |
| } |
| |
| private async reload(params?: {offset: 'reset' | 'keep'}) { |
| if ((params?.offset ?? 'reset') === 'reset') { |
| this.offset = 0; |
| } |
| |
| const newFilters = this.rowCount?.filters; |
| const filtersMatch = newFilters && arrayEquals(newFilters, this.filters); |
| this.data = undefined; |
| if (!filtersMatch) { |
| this.rowCount = undefined; |
| } |
| |
| // Delay the visual update by 50ms to avoid flickering (if the query returns |
| // before the data is loaded. |
| setTimeout(() => raf.scheduleFullRedraw(), 50); |
| |
| if (!filtersMatch) { |
| this.rowCount = await this.loadRowCount(); |
| } |
| this.data = await this.loadData(); |
| |
| raf.scheduleFullRedraw(); |
| } |
| |
| getTotalRowCount(): number | undefined { |
| return this.rowCount?.count; |
| } |
| |
| getDisplayedRows(): Row[] { |
| return this.data?.rows || []; |
| } |
| |
| getQueryError(): string | undefined { |
| return this.data?.error; |
| } |
| |
| isLoading() { |
| return this.data === undefined; |
| } |
| |
| // Filters are compared by reference, so the caller is required to pass an |
| // object which was previously returned by getFilters. |
| removeFilter(filter: Filter) { |
| this.filters = this.filters.filter((f) => f !== filter); |
| this.reload(); |
| } |
| |
| addFilter(filter: string) { |
| this.filters.push(filter); |
| this.reload(); |
| } |
| |
| getFilters(): Filter[] { |
| return this.filters; |
| } |
| |
| sortBy(clause: ColumnOrderClause) { |
| // Remove previous sort by the same column. |
| this.orderBy = this.orderBy.filter((c) => c.column !== clause.column); |
| // Add the new sort clause to the front, so we effectively stable-sort the |
| // data currently displayed to the user. |
| this.orderBy.unshift(clause); |
| this.reload(); |
| } |
| |
| unsort() { |
| this.orderBy = []; |
| this.reload(); |
| } |
| |
| isSortedBy(column: Column): SortDirection | undefined { |
| if (this.orderBy.length === 0) return undefined; |
| if (this.orderBy[0].column !== column) return undefined; |
| return this.orderBy[0].direction; |
| } |
| |
| addColumn(column: Column, index: number) { |
| this.columns.splice(index + 1, 0, column); |
| this.reload({offset: 'keep'}); |
| } |
| |
| hideColumnAtIndex(index: number) { |
| const column = this.columns[index]; |
| this.columns.splice(index, 1); |
| // We can only filter by the visibile columns to avoid confusing the user, |
| // so we remove order by clauses that refer to the hidden column. |
| this.orderBy = this.orderBy.filter((c) => c.column !== column); |
| // TODO(altimin): we can avoid the fetch here if the orderBy hasn't changed. |
| this.reload({offset: 'keep'}); |
| } |
| |
| getSelectedColumns(): Column[] { |
| return this.columns; |
| } |
| } |