• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/*
2 * Copyright (C) 2022 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import m from 'mithril';
18
19import {SortDirection} from '../base/comparison_utils';
20import {sqliteString} from '../base/string_utils';
21import {Actions} from '../common/actions';
22import {DropDirection} from '../common/dragndrop_logic';
23import {COUNT_AGGREGATION} from '../common/empty_state';
24import {Area, PivotTableResult} from '../common/state';
25import {raf} from '../core/raf_scheduler';
26import {ColumnType} from '../trace_processor/query_result';
27
28import {globals} from './globals';
29import {
30  aggregationIndex,
31  areaFilters,
32  extractArgumentExpression,
33  sliceAggregationColumns,
34  tables,
35} from './pivot_table_query_generator';
36import {
37  Aggregation,
38  AggregationFunction,
39  columnKey,
40  PivotTree,
41  TableColumn,
42} from './pivot_table_types';
43import {PopupMenuButton, popupMenuIcon, PopupMenuItem} from './popup_menu';
44import {ReorderableCell, ReorderableCellGroup} from './reorderable_cells';
45import {addSqlTableTab} from './sql_table/tab';
46import {SqlTables} from './sql_table/well_known_tables';
47import {AttributeModalHolder} from './tables/attribute_modal_holder';
48import {DurationWidget} from './widgets/duration';
49
50interface PathItem {
51  tree: PivotTree;
52  nextKey: ColumnType;
53}
54
55interface PivotTableAttrs {
56  selectionArea: Area;
57}
58
59interface DrillFilter {
60  column: TableColumn;
61  value: ColumnType;
62}
63
64function drillFilterColumnName(column: TableColumn): string {
65  switch (column.kind) {
66    case 'argument':
67      return extractArgumentExpression(column.argument, SqlTables.slice.name);
68    case 'regular':
69      return `${column.column}`;
70  }
71}
72
73// Convert DrillFilter to SQL condition to be used in WHERE clause.
74function renderDrillFilter(filter: DrillFilter): string {
75  const column = drillFilterColumnName(filter.column);
76  if (filter.value === null) {
77    return `${column} IS NULL`;
78  } else if (typeof filter.value === 'number') {
79    return `${column} = ${filter.value}`;
80  } else if (filter.value instanceof Uint8Array) {
81    throw new Error(`BLOB as DrillFilter not implemented`);
82  } else if (typeof filter.value === 'bigint') {
83    return `${column} = ${filter.value}`;
84  }
85  return `${column} = ${sqliteString(filter.value)}`;
86}
87
88function readableColumnName(column: TableColumn) {
89  switch (column.kind) {
90    case 'argument':
91      return `Argument ${column.argument}`;
92    case 'regular':
93      return `${column.column}`;
94  }
95}
96
97export function markFirst(index: number) {
98  if (index === 0) {
99    return '.first';
100  }
101  return '';
102}
103
104export class PivotTable implements m.ClassComponent<PivotTableAttrs> {
105  constructor() {
106    this.attributeModalHolder = new AttributeModalHolder((arg) => {
107      globals.dispatch(
108        Actions.setPivotTablePivotSelected({
109          column: {kind: 'argument', argument: arg},
110          selected: true,
111        }),
112      );
113      globals.dispatch(
114        Actions.setPivotTableQueryRequested({queryRequested: true}),
115      );
116    });
117  }
118
119  get pivotState() {
120    return globals.state.nonSerializableState.pivotTable;
121  }
122  get constrainToArea() {
123    return globals.state.nonSerializableState.pivotTable.constrainToArea;
124  }
125
126  renderDrillDownCell(area: Area, filters: DrillFilter[]) {
127    return m(
128      'td',
129      m(
130        'button',
131        {
132          title: 'All corresponding slices',
133          onclick: () => {
134            const queryFilters = filters.map(renderDrillFilter);
135            if (this.constrainToArea) {
136              queryFilters.push(...areaFilters(area));
137            }
138            addSqlTableTab({
139              table: SqlTables.slice,
140              filters: queryFilters,
141            });
142          },
143        },
144        m('i.material-icons', 'arrow_right'),
145      ),
146    );
147  }
148
149  renderSectionRow(
150    area: Area,
151    path: PathItem[],
152    tree: PivotTree,
153    result: PivotTableResult,
154  ): m.Vnode {
155    const renderedCells = [];
156    for (let j = 0; j + 1 < path.length; j++) {
157      renderedCells.push(m('td', m('span.indent', ' '), `${path[j].nextKey}`));
158    }
159
160    const treeDepth = result.metadata.pivotColumns.length;
161    const colspan = treeDepth - path.length + 1;
162    const button = m(
163      'button',
164      {
165        onclick: () => {
166          tree.isCollapsed = !tree.isCollapsed;
167          raf.scheduleFullRedraw();
168        },
169      },
170      m('i.material-icons', tree.isCollapsed ? 'expand_more' : 'expand_less'),
171    );
172
173    renderedCells.push(
174      m('td', {colspan}, button, `${path[path.length - 1].nextKey}`),
175    );
176
177    for (let i = 0; i < result.metadata.aggregationColumns.length; i++) {
178      const renderedValue = this.renderCell(
179        result.metadata.aggregationColumns[i].column,
180        tree.aggregates[i],
181      );
182      renderedCells.push(m('td' + markFirst(i), renderedValue));
183    }
184
185    const drillFilters: DrillFilter[] = [];
186    for (let i = 0; i < path.length; i++) {
187      drillFilters.push({
188        value: `${path[i].nextKey}`,
189        column: result.metadata.pivotColumns[i],
190      });
191    }
192
193    renderedCells.push(this.renderDrillDownCell(area, drillFilters));
194    return m('tr', renderedCells);
195  }
196
197  renderCell(column: TableColumn, value: ColumnType): m.Children {
198    if (
199      column.kind === 'regular' &&
200      (column.column === 'dur' || column.column === 'thread_dur')
201    ) {
202      if (typeof value === 'bigint') {
203        return m(DurationWidget, {dur: value});
204      }
205    }
206    return `${value}`;
207  }
208
209  renderTree(
210    area: Area,
211    path: PathItem[],
212    tree: PivotTree,
213    result: PivotTableResult,
214    sink: m.Vnode[],
215  ) {
216    if (tree.isCollapsed) {
217      sink.push(this.renderSectionRow(area, path, tree, result));
218      return;
219    }
220    if (tree.children.size > 0) {
221      // Avoid rendering the intermediate results row for the root of tree
222      // and in case there's only one child subtree.
223      if (!tree.isCollapsed && path.length > 0 && tree.children.size !== 1) {
224        sink.push(this.renderSectionRow(area, path, tree, result));
225      }
226      for (const [key, childTree] of tree.children.entries()) {
227        path.push({tree: childTree, nextKey: key});
228        this.renderTree(area, path, childTree, result, sink);
229        path.pop();
230      }
231      return;
232    }
233
234    // Avoid rendering the intermediate results row if it has only one leaf
235    // row.
236    if (!tree.isCollapsed && path.length > 0 && tree.rows.length > 1) {
237      sink.push(this.renderSectionRow(area, path, tree, result));
238    }
239    for (const row of tree.rows) {
240      const renderedCells = [];
241      const drillFilters: DrillFilter[] = [];
242      const treeDepth = result.metadata.pivotColumns.length;
243      for (let j = 0; j < treeDepth; j++) {
244        const value = this.renderCell(result.metadata.pivotColumns[j], row[j]);
245        if (j < path.length) {
246          renderedCells.push(m('td', m('span.indent', ' '), value));
247        } else {
248          renderedCells.push(m(`td`, value));
249        }
250        drillFilters.push({
251          column: result.metadata.pivotColumns[j],
252          value: row[j],
253        });
254      }
255      for (let j = 0; j < result.metadata.aggregationColumns.length; j++) {
256        const value = row[aggregationIndex(treeDepth, j)];
257        const renderedValue = this.renderCell(
258          result.metadata.aggregationColumns[j].column,
259          value,
260        );
261        renderedCells.push(m('td.aggregation' + markFirst(j), renderedValue));
262      }
263
264      renderedCells.push(this.renderDrillDownCell(area, drillFilters));
265      sink.push(m('tr', renderedCells));
266    }
267  }
268
269  renderTotalsRow(queryResult: PivotTableResult) {
270    const overallValuesRow = [
271      m(
272        'td.total-values',
273        {colspan: queryResult.metadata.pivotColumns.length},
274        m('strong', 'Total values:'),
275      ),
276    ];
277    for (let i = 0; i < queryResult.metadata.aggregationColumns.length; i++) {
278      overallValuesRow.push(
279        m(
280          'td' + markFirst(i),
281          this.renderCell(
282            queryResult.metadata.aggregationColumns[i].column,
283            queryResult.tree.aggregates[i],
284          ),
285        ),
286      );
287    }
288    overallValuesRow.push(m('td'));
289    return m('tr', overallValuesRow);
290  }
291
292  sortingItem(aggregationIndex: number, order: SortDirection): PopupMenuItem {
293    return {
294      itemType: 'regular',
295      text: order === 'DESC' ? 'Highest first' : 'Lowest first',
296      callback() {
297        globals.dispatch(
298          Actions.setPivotTableSortColumn({aggregationIndex, order}),
299        );
300        globals.dispatch(
301          Actions.setPivotTableQueryRequested({queryRequested: true}),
302        );
303      },
304    };
305  }
306
307  readableAggregationName(aggregation: Aggregation) {
308    if (aggregation.aggregationFunction === 'COUNT') {
309      return 'Count';
310    }
311    return `${aggregation.aggregationFunction}(${readableColumnName(
312      aggregation.column,
313    )})`;
314  }
315
316  aggregationPopupItem(
317    aggregation: Aggregation,
318    index: number,
319    nameOverride?: string,
320  ): PopupMenuItem {
321    return {
322      itemType: 'regular',
323      text: nameOverride ?? readableColumnName(aggregation.column),
324      callback: () => {
325        globals.dispatch(
326          Actions.addPivotTableAggregation({aggregation, after: index}),
327        );
328        globals.dispatch(
329          Actions.setPivotTableQueryRequested({queryRequested: true}),
330        );
331      },
332    };
333  }
334
335  aggregationPopupTableGroup(
336    table: string,
337    columns: string[],
338    index: number,
339  ): PopupMenuItem | undefined {
340    const items = [];
341    for (const column of columns) {
342      const tableColumn: TableColumn = {kind: 'regular', table, column};
343      items.push(
344        this.aggregationPopupItem(
345          {aggregationFunction: 'SUM', column: tableColumn},
346          index,
347        ),
348      );
349    }
350
351    if (items.length === 0) {
352      return undefined;
353    }
354
355    return {
356      itemType: 'group',
357      itemId: `aggregations-${table}`,
358      text: `Add ${table} aggregation`,
359      children: items,
360    };
361  }
362
363  renderAggregationHeaderCell(
364    aggregation: Aggregation,
365    index: number,
366    removeItem: boolean,
367  ): ReorderableCell {
368    const popupItems: PopupMenuItem[] = [];
369    const state = globals.state.nonSerializableState.pivotTable;
370    if (aggregation.sortDirection === undefined) {
371      popupItems.push(
372        this.sortingItem(index, 'DESC'),
373        this.sortingItem(index, 'ASC'),
374      );
375    } else {
376      // Table is already sorted by the same column, return one item with
377      // opposite direction.
378      popupItems.push(
379        this.sortingItem(
380          index,
381          aggregation.sortDirection === 'DESC' ? 'ASC' : 'DESC',
382        ),
383      );
384    }
385    const otherAggs: AggregationFunction[] = ['SUM', 'MAX', 'MIN', 'AVG'];
386    if (aggregation.aggregationFunction !== 'COUNT') {
387      for (const otherAgg of otherAggs) {
388        if (aggregation.aggregationFunction === otherAgg) {
389          continue;
390        }
391
392        popupItems.push({
393          itemType: 'regular',
394          text: otherAgg,
395          callback() {
396            globals.dispatch(
397              Actions.setPivotTableAggregationFunction({
398                index,
399                function: otherAgg,
400              }),
401            );
402            globals.dispatch(
403              Actions.setPivotTableQueryRequested({queryRequested: true}),
404            );
405          },
406        });
407      }
408    }
409
410    if (removeItem) {
411      popupItems.push({
412        itemType: 'regular',
413        text: 'Remove',
414        callback: () => {
415          globals.dispatch(Actions.removePivotTableAggregation({index}));
416          globals.dispatch(
417            Actions.setPivotTableQueryRequested({queryRequested: true}),
418          );
419        },
420      });
421    }
422
423    let hasCount = false;
424    for (const agg of state.selectedAggregations.values()) {
425      if (agg.aggregationFunction === 'COUNT') {
426        hasCount = true;
427      }
428    }
429
430    if (!hasCount) {
431      popupItems.push(
432        this.aggregationPopupItem(
433          COUNT_AGGREGATION,
434          index,
435          'Add count aggregation',
436        ),
437      );
438    }
439
440    const sliceAggregationsItem = this.aggregationPopupTableGroup(
441      SqlTables.slice.name,
442      sliceAggregationColumns,
443      index,
444    );
445    if (sliceAggregationsItem !== undefined) {
446      popupItems.push(sliceAggregationsItem);
447    }
448
449    return {
450      extraClass: '.aggregation' + markFirst(index),
451      content: [
452        this.readableAggregationName(aggregation),
453        m(PopupMenuButton, {
454          icon: popupMenuIcon(aggregation.sortDirection),
455          items: popupItems,
456        }),
457      ],
458    };
459  }
460
461  attributeModalHolder: AttributeModalHolder;
462
463  renderPivotColumnHeader(
464    queryResult: PivotTableResult,
465    pivot: TableColumn,
466    selectedPivots: Set<string>,
467  ): ReorderableCell {
468    const items: PopupMenuItem[] = [
469      {
470        itemType: 'regular',
471        text: 'Add argument pivot',
472        callback: () => {
473          this.attributeModalHolder.start();
474        },
475      },
476    ];
477    if (queryResult.metadata.pivotColumns.length > 1) {
478      items.push({
479        itemType: 'regular',
480        text: 'Remove',
481        callback() {
482          globals.dispatch(
483            Actions.setPivotTablePivotSelected({
484              column: pivot,
485              selected: false,
486            }),
487          );
488          globals.dispatch(
489            Actions.setPivotTableQueryRequested({queryRequested: true}),
490          );
491        },
492      });
493    }
494
495    for (const table of tables) {
496      const group: PopupMenuItem[] = [];
497      for (const columnName of table.columns) {
498        const column: TableColumn = {
499          kind: 'regular',
500          table: table.name,
501          column: columnName,
502        };
503        if (selectedPivots.has(columnKey(column))) {
504          continue;
505        }
506
507        group.push({
508          itemType: 'regular',
509          text: columnName,
510          callback() {
511            globals.dispatch(
512              Actions.setPivotTablePivotSelected({column, selected: true}),
513            );
514            globals.dispatch(
515              Actions.setPivotTableQueryRequested({queryRequested: true}),
516            );
517          },
518        });
519      }
520      items.push({
521        itemType: 'group',
522        itemId: `pivot-${table.name}`,
523        text: `Add ${table.displayName} pivot`,
524        children: group,
525      });
526    }
527
528    return {
529      content: [
530        readableColumnName(pivot),
531        m(PopupMenuButton, {icon: 'more_horiz', items}),
532      ],
533    };
534  }
535
536  renderResultsTable(attrs: PivotTableAttrs) {
537    const state = globals.state.nonSerializableState.pivotTable;
538    if (state.queryResult === null) {
539      return m('div', 'Loading...');
540    }
541    const queryResult: PivotTableResult = state.queryResult;
542
543    const renderedRows: m.Vnode[] = [];
544    const tree = state.queryResult.tree;
545
546    if (tree.children.size === 0 && tree.rows.length === 0) {
547      // Empty result, render a special message
548      return m('.empty-result', 'No slices in the current selection.');
549    }
550
551    this.renderTree(
552      attrs.selectionArea,
553      [],
554      tree,
555      state.queryResult,
556      renderedRows,
557    );
558
559    const selectedPivots = new Set(
560      this.pivotState.selectedPivots.map(columnKey),
561    );
562    const pivotTableHeaders = state.selectedPivots.map((pivot) =>
563      this.renderPivotColumnHeader(queryResult, pivot, selectedPivots),
564    );
565
566    const removeItem = state.queryResult.metadata.aggregationColumns.length > 1;
567    const aggregationTableHeaders =
568      state.queryResult.metadata.aggregationColumns.map((aggregation, index) =>
569        this.renderAggregationHeaderCell(aggregation, index, removeItem),
570      );
571
572    return m(
573      'table.pivot-table',
574      m(
575        'thead',
576        // First row of the table, containing names of pivot and aggregation
577        // columns, as well as popup menus to modify the columns. Last cell
578        // is empty because of an extra column with "drill down" button for
579        // each pivot table row.
580        m(
581          'tr.header',
582          m(ReorderableCellGroup, {
583            cells: pivotTableHeaders,
584            onReorder: (from: number, to: number, direction: DropDirection) => {
585              globals.dispatch(
586                Actions.changePivotTablePivotOrder({from, to, direction}),
587              );
588              globals.dispatch(
589                Actions.setPivotTableQueryRequested({queryRequested: true}),
590              );
591            },
592          }),
593          m(ReorderableCellGroup, {
594            cells: aggregationTableHeaders,
595            onReorder: (from: number, to: number, direction: DropDirection) => {
596              globals.dispatch(
597                Actions.changePivotTableAggregationOrder({from, to, direction}),
598              );
599              globals.dispatch(
600                Actions.setPivotTableQueryRequested({queryRequested: true}),
601              );
602            },
603          }),
604          m(
605            'td.menu',
606            m(PopupMenuButton, {
607              icon: 'menu',
608              items: [
609                {
610                  itemType: 'regular',
611                  text: state.constrainToArea
612                    ? 'Query data for the whole timeline'
613                    : 'Constrain to selected area',
614                  callback: () => {
615                    globals.dispatch(
616                      Actions.setPivotTableConstrainToArea({
617                        constrain: !state.constrainToArea,
618                      }),
619                    );
620                    globals.dispatch(
621                      Actions.setPivotTableQueryRequested({
622                        queryRequested: true,
623                      }),
624                    );
625                  },
626                },
627              ],
628            }),
629          ),
630        ),
631      ),
632      m('tbody', this.renderTotalsRow(state.queryResult), renderedRows),
633    );
634  }
635
636  view({attrs}: m.Vnode<PivotTableAttrs>): m.Children {
637    return m('.pivot-table', this.renderResultsTable(attrs));
638  }
639}
640