• 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 m from 'mithril';
16import {MenuDivider, MenuItem, PopupMenu} from '../../../../widgets/menu';
17import {buildSqlQuery} from './query_builder';
18import {Icons} from '../../../../base/semantic_icons';
19import {sqliteString} from '../../../../base/string_utils';
20import {
21  ColumnType,
22  Row,
23  SqlValue,
24} from '../../../../trace_processor/query_result';
25import {Anchor} from '../../../../widgets/anchor';
26import {BasicTable} from '../../../../widgets/basic_table';
27import {Spinner} from '../../../../widgets/spinner';
28
29import {
30  LegacySqlTableFilterOptions,
31  LegacySqlTableFilterLabel,
32} from './render_cell_utils';
33import {SqlTableState} from './state';
34import {SqlTableDescription} from './table_description';
35import {Form} from '../../../../widgets/form';
36import {TextInput} from '../../../../widgets/text_input';
37import {TableColumn, TableManager, tableColumnId} from './table_column';
38import {SqlColumn, sqlColumnId} from './sql_column';
39import {SelectColumnMenu} from './select_column_menu';
40import {renderColumnIcon, renderSortMenuItems} from './table_header';
41
42export interface SqlTableConfig {
43  readonly state: SqlTableState;
44  // For additional menu items to add to the column header menus
45  readonly addColumnMenuItems?: (
46    column: TableColumn,
47    columnAlias: string,
48  ) => m.Children;
49  // For additional filter actions
50  readonly extraAddFilterActions?: (
51    op: string,
52    column: string,
53    value?: string,
54  ) => void;
55  readonly extraRemoveFilterActions?: (filterSqlStr: string) => void;
56}
57
58type AdditionalColumnMenuItems = Record<string, m.Children>;
59
60function renderCell(
61  column: TableColumn,
62  row: Row,
63  state: SqlTableState,
64): m.Children {
65  const {columns} = state.getCurrentRequest();
66  const sqlValue = row[columns[sqlColumnId(column.column)]];
67
68  const additionalValues: {[key: string]: SqlValue} = {};
69  const supportingColumns: {[key: string]: SqlColumn} =
70    column.supportingColumns?.() ?? {};
71  for (const [key, col] of Object.entries(supportingColumns)) {
72    additionalValues[key] = row[columns[sqlColumnId(col)]];
73  }
74
75  return column.renderCell(sqlValue, getTableManager(state), additionalValues);
76}
77
78export function columnTitle(column: TableColumn): string {
79  if (column.getTitle !== undefined) {
80    const title = column.getTitle();
81    if (title !== undefined) return title;
82  }
83  return sqlColumnId(column.column);
84}
85
86interface AddColumnMenuItemAttrs {
87  table: SqlTable;
88  state: SqlTableState;
89  index: number;
90}
91
92// This is separated into a separate class to store the index of the column to be
93// added and increment it when multiple columns are added from the same popup menu.
94class AddColumnMenuItem implements m.ClassComponent<AddColumnMenuItemAttrs> {
95  // Index where the new column should be inserted.
96  // In the regular case, a click would close the popup (destroying this class) and
97  // the `index` would not change during its lifetime.
98  // However, for mod-click, we want to keep adding columns to the right of the recently
99  // added column, so to achieve that we keep track of the index and increment it for
100  // each new column added.
101  index: number;
102
103  constructor({attrs}: m.Vnode<AddColumnMenuItemAttrs>) {
104    this.index = attrs.index;
105  }
106
107  view({attrs}: m.Vnode<AddColumnMenuItemAttrs>) {
108    return m(
109      MenuItem,
110      {label: 'Add column', icon: Icons.Add},
111      attrs.table.renderAddColumnOptions((column) => {
112        attrs.state.addColumn(column, this.index++);
113      }),
114    );
115  }
116}
117
118interface ColumnFilterAttrs {
119  filterOption: LegacySqlTableFilterLabel;
120  columns: SqlColumn[];
121  state: SqlTableState;
122}
123
124// Separating out an individual column filter into a class
125// so that we can store the raw input value.
126class ColumnFilter implements m.ClassComponent<ColumnFilterAttrs> {
127  // Holds the raw string value from the filter text input element
128  private inputValue: string;
129
130  constructor() {
131    this.inputValue = '';
132  }
133
134  view({attrs}: m.Vnode<ColumnFilterAttrs>) {
135    const {filterOption, columns, state} = attrs;
136
137    const {op, requiresParam} = LegacySqlTableFilterOptions[filterOption];
138
139    return m(
140      MenuItem,
141      {
142        label: filterOption,
143        // Filter options that do not need an input value will filter the
144        // table directly when clicking on the menu item
145        // (ex: IS NULL or IS NOT NULL)
146        onclick: !requiresParam
147          ? () => {
148              state.filters.addFilter({
149                op: (cols) => `${cols[0]} ${op}`,
150                columns,
151              });
152            }
153          : undefined,
154      },
155      // All non-null filter options will have a submenu that allows
156      // the user to enter a value into textfield and filter using
157      // the Filter button.
158      requiresParam &&
159        m(
160          Form,
161          {
162            onSubmit: () => {
163              // Convert the string extracted from
164              // the input text field into the correct data type for
165              // filtering. The order in which each data type is
166              // checked matters: string, number (floating), and bigint.
167              if (this.inputValue === '') return;
168
169              let filterValue: ColumnType;
170
171              if (Number.isNaN(Number.parseFloat(this.inputValue))) {
172                filterValue = sqliteString(this.inputValue);
173              } else if (
174                !Number.isInteger(Number.parseFloat(this.inputValue))
175              ) {
176                filterValue = Number(this.inputValue);
177              } else {
178                filterValue = BigInt(this.inputValue);
179              }
180
181              state.filters.addFilter({
182                op: (cols) => `${cols[0]} ${op} ${filterValue}`,
183                columns,
184              });
185            },
186            submitLabel: 'Filter',
187          },
188          m(TextInput, {
189            id: 'column_filter_value',
190            ref: 'COLUMN_FILTER_VALUE',
191            autofocus: true,
192            oninput: (e: KeyboardEvent) => {
193              if (!e.target) return;
194
195              this.inputValue = (e.target as HTMLInputElement).value;
196            },
197          }),
198        ),
199    );
200  }
201}
202
203export class SqlTable implements m.ClassComponent<SqlTableConfig> {
204  private readonly table: SqlTableDescription;
205
206  private state: SqlTableState;
207
208  constructor(vnode: m.Vnode<SqlTableConfig>) {
209    this.state = vnode.attrs.state;
210    this.table = this.state.config;
211  }
212
213  renderAddColumnOptions(addColumn: (column: TableColumn) => void): m.Children {
214    // We do not want to add columns which already exist, so we track the
215    // columns which we are already showing here.
216    // TODO(altimin): Theoretically a single table can have two different
217    // arg_set_ids, so we should track (arg_set_id_column, arg_name) pairs here.
218    const existingColumnIds = new Set<string>();
219
220    for (const column of this.state.getSelectedColumns()) {
221      existingColumnIds.add(tableColumnId(column));
222    }
223
224    return m(SelectColumnMenu, {
225      columns: this.table.columns.map((column) => ({
226        key: columnTitle(column),
227        column,
228      })),
229      manager: getTableManager(this.state),
230      existingColumnIds,
231      onColumnSelected: addColumn,
232    });
233  }
234
235  renderColumnFilterOptions(
236    c: TableColumn,
237  ): m.Vnode<ColumnFilterAttrs, unknown>[] {
238    return Object.keys(LegacySqlTableFilterOptions).map((label) =>
239      m(ColumnFilter, {
240        filterOption: label as LegacySqlTableFilterLabel,
241        columns: [c.column],
242        state: this.state,
243      }),
244    );
245  }
246
247  renderColumnHeader(
248    column: TableColumn,
249    index: number,
250    additionalColumnHeaderMenuItems?: m.Children,
251  ) {
252    const sorted = this.state.isSortedBy(column);
253
254    return m(
255      PopupMenu,
256      {
257        trigger: m(
258          Anchor,
259          {icon: renderColumnIcon(sorted)},
260          columnTitle(column),
261        ),
262      },
263      renderSortMenuItems(sorted, (direction) =>
264        this.state.sortBy({column, direction}),
265      ),
266      this.state.getSelectedColumns().length > 1 &&
267        m(MenuItem, {
268          label: 'Hide',
269          icon: Icons.Hide,
270          onclick: () => this.state.hideColumnAtIndex(index),
271        }),
272      m(
273        MenuItem,
274        {label: 'Add filter', icon: Icons.Filter},
275        this.renderColumnFilterOptions(column),
276      ),
277      additionalColumnHeaderMenuItems,
278      // Menu items before divider apply to selected column
279      m(MenuDivider),
280      // Menu items after divider apply to entire table
281      m(AddColumnMenuItem, {table: this, state: this.state, index}),
282    );
283  }
284
285  getAdditionalColumnMenuItems(
286    addColumnMenuItems?: (
287      column: TableColumn,
288      columnAlias: string,
289    ) => m.Children,
290  ) {
291    if (addColumnMenuItems === undefined) return;
292
293    const additionalColumnMenuItems: AdditionalColumnMenuItems = {};
294    this.state.getSelectedColumns().forEach((column) => {
295      const columnAlias =
296        this.state.getCurrentRequest().columns[sqlColumnId(column.column)];
297
298      additionalColumnMenuItems[columnAlias] = addColumnMenuItems(
299        column,
300        columnAlias,
301      );
302    });
303
304    return additionalColumnMenuItems;
305  }
306
307  view({attrs}: m.Vnode<SqlTableConfig>) {
308    const rows = this.state.getDisplayedRows();
309    const additionalColumnMenuItems = this.getAdditionalColumnMenuItems(
310      attrs.addColumnMenuItems,
311    );
312
313    const columns = this.state.getSelectedColumns();
314    const columnDescriptors = columns.map((column, i) => {
315      return {
316        title: this.renderColumnHeader(
317          column,
318          i,
319          additionalColumnMenuItems &&
320            additionalColumnMenuItems[
321              this.state.getCurrentRequest().columns[sqlColumnId(column.column)]
322            ],
323        ),
324        render: (row: Row) => renderCell(column, row, this.state),
325      };
326    });
327
328    return [
329      m(
330        BasicTable<Row>,
331        {
332          data: rows,
333          columns: columnDescriptors,
334          onreorder: (from: number, to: number) =>
335            this.state.moveColumn(from, to),
336        },
337        this.state.isLoading() && m(Spinner),
338        this.state.getQueryError() !== undefined &&
339          m('.query-error', this.state.getQueryError()),
340      ),
341    ];
342  }
343}
344
345export function getTableManager(state: SqlTableState): TableManager {
346  return {
347    filters: state.filters,
348    trace: state.trace,
349    getSqlQuery: (columns: {[key: string]: SqlColumn}) =>
350      buildSqlQuery({
351        table: state.config.name,
352        columns,
353        filters: state.filters.get(),
354        orderBy: state.getOrderedBy(),
355      }),
356  };
357}
358