xref: /aosp_15_r20/external/perfetto/ui/src/components/widgets/sql/table/state.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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