• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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