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