blob: 1c244fd113ba34d4dd232be59821319b4a857f4e [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import m from 'mithril';
import {SortDirection} from '../base/comparison_utils';
import {sqliteString} from '../base/string_utils';
import {Actions} from '../common/actions';
import {DropDirection} from '../common/dragndrop_logic';
import {COUNT_AGGREGATION} from '../common/empty_state';
import {PivotTableResult} from '../common/state';
import {Area} from '../public/selection';
import {raf} from '../core/raf_scheduler';
import {ColumnType} from '../trace_processor/query_result';
import {globals} from './globals';
import {
aggregationIndex,
areaFilters,
sliceAggregationColumns,
tables,
} from './pivot_table_query_generator';
import {
Aggregation,
AggregationFunction,
columnKey,
PivotTree,
TableColumn,
} from './pivot_table_types';
import {PopupMenuButton, popupMenuIcon, PopupMenuItem} from './popup_menu';
import {ReorderableCell, ReorderableCellGroup} from './reorderable_cells';
import {AttributeModalHolder} from './tables/attribute_modal_holder';
import {DurationWidget} from './widgets/duration';
import {addSqlTableTab} from './sql_table_tab_command';
import {getSqlTableDescription} from './widgets/sql/table/sql_table_registry';
import {assertExists, assertFalse} from '../base/logging';
import {Filter, SqlColumn} from './widgets/sql/table/column';
import {argSqlColumn} from './widgets/sql/table/well_known_columns';
interface PathItem {
tree: PivotTree;
nextKey: ColumnType;
}
interface PivotTableAttrs {
selectionArea: Area;
}
interface DrillFilter {
column: TableColumn;
value: ColumnType;
}
function drillFilterColumnName(column: TableColumn): SqlColumn {
switch (column.kind) {
case 'argument':
return argSqlColumn('arg_set_id', column.argument);
case 'regular':
return `${column.column}`;
}
}
// Convert DrillFilter to SQL condition to be used in WHERE clause.
function renderDrillFilter(filter: DrillFilter): Filter {
const column = drillFilterColumnName(filter.column);
const value = filter.value;
if (value === null) {
return {op: (cols) => `${cols[0]} IS NULL`, columns: [column]};
} else if (typeof value === 'number' || typeof value === 'bigint') {
return {op: (cols) => `${cols[0]} = ${filter.value}`, columns: [column]};
} else if (value instanceof Uint8Array) {
throw new Error(`BLOB as DrillFilter not implemented`);
}
return {
op: (cols) => `${cols[0]} = ${sqliteString(value)}`,
columns: [column],
};
}
function readableColumnName(column: TableColumn) {
switch (column.kind) {
case 'argument':
return `Argument ${column.argument}`;
case 'regular':
return `${column.column}`;
}
}
export function markFirst(index: number) {
if (index === 0) {
return '.first';
}
return '';
}
export class PivotTable implements m.ClassComponent<PivotTableAttrs> {
constructor() {
this.attributeModalHolder = new AttributeModalHolder((arg) => {
globals.dispatch(
Actions.setPivotTablePivotSelected({
column: {kind: 'argument', argument: arg},
selected: true,
}),
);
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}),
);
});
}
get pivotState() {
return globals.state.nonSerializableState.pivotTable;
}
get constrainToArea() {
return globals.state.nonSerializableState.pivotTable.constrainToArea;
}
renderDrillDownCell(area: Area, filters: DrillFilter[]) {
return m(
'td',
m(
'button',
{
title: 'All corresponding slices',
onclick: () => {
const queryFilters = filters.map(renderDrillFilter);
if (this.constrainToArea) {
queryFilters.push(...areaFilters(area));
}
addSqlTableTab({
table: assertExists(getSqlTableDescription('slice')),
// TODO(altimin): this should properly reference the required columns, but it works for now (until the pivot table is going to be rewritten to be more flexible).
filters: queryFilters,
});
},
},
m('i.material-icons', 'arrow_right'),
),
);
}
renderSectionRow(
area: Area,
path: PathItem[],
tree: PivotTree,
result: PivotTableResult,
): m.Vnode {
const renderedCells = [];
for (let j = 0; j + 1 < path.length; j++) {
renderedCells.push(m('td', m('span.indent', ' '), `${path[j].nextKey}`));
}
const treeDepth = result.metadata.pivotColumns.length;
const colspan = treeDepth - path.length + 1;
const button = m(
'button',
{
onclick: () => {
tree.isCollapsed = !tree.isCollapsed;
raf.scheduleFullRedraw();
},
},
m('i.material-icons', tree.isCollapsed ? 'expand_more' : 'expand_less'),
);
renderedCells.push(
m('td', {colspan}, button, `${path[path.length - 1].nextKey}`),
);
for (let i = 0; i < result.metadata.aggregationColumns.length; i++) {
const renderedValue = this.renderCell(
result.metadata.aggregationColumns[i].column,
tree.aggregates[i],
);
renderedCells.push(m('td' + markFirst(i), renderedValue));
}
const drillFilters: DrillFilter[] = [];
for (let i = 0; i < path.length; i++) {
drillFilters.push({
value: `${path[i].nextKey}`,
column: result.metadata.pivotColumns[i],
});
}
renderedCells.push(this.renderDrillDownCell(area, drillFilters));
return m('tr', renderedCells);
}
renderCell(column: TableColumn, value: ColumnType): m.Children {
if (
column.kind === 'regular' &&
(column.column === 'dur' || column.column === 'thread_dur')
) {
if (typeof value === 'bigint') {
return m(DurationWidget, {dur: value});
} else if (typeof value === 'number') {
return m(DurationWidget, {dur: BigInt(Math.round(value))});
}
}
return `${value}`;
}
renderTree(
area: Area,
path: PathItem[],
tree: PivotTree,
result: PivotTableResult,
sink: m.Vnode[],
) {
if (tree.isCollapsed) {
sink.push(this.renderSectionRow(area, path, tree, result));
return;
}
if (tree.children.size > 0) {
// Avoid rendering the intermediate results row for the root of tree
// and in case there's only one child subtree.
if (!tree.isCollapsed && path.length > 0 && tree.children.size !== 1) {
sink.push(this.renderSectionRow(area, path, tree, result));
}
for (const [key, childTree] of tree.children.entries()) {
path.push({tree: childTree, nextKey: key});
this.renderTree(area, path, childTree, result, sink);
path.pop();
}
return;
}
// Avoid rendering the intermediate results row if it has only one leaf
// row.
if (!tree.isCollapsed && path.length > 0 && tree.rows.length > 1) {
sink.push(this.renderSectionRow(area, path, tree, result));
}
for (const row of tree.rows) {
const renderedCells = [];
const drillFilters: DrillFilter[] = [];
const treeDepth = result.metadata.pivotColumns.length;
for (let j = 0; j < treeDepth; j++) {
const value = this.renderCell(result.metadata.pivotColumns[j], row[j]);
if (j < path.length) {
renderedCells.push(m('td', m('span.indent', ' '), value));
} else {
renderedCells.push(m(`td`, value));
}
drillFilters.push({
column: result.metadata.pivotColumns[j],
value: row[j],
});
}
for (let j = 0; j < result.metadata.aggregationColumns.length; j++) {
const value = row[aggregationIndex(treeDepth, j)];
const renderedValue = this.renderCell(
result.metadata.aggregationColumns[j].column,
value,
);
renderedCells.push(m('td.aggregation' + markFirst(j), renderedValue));
}
renderedCells.push(this.renderDrillDownCell(area, drillFilters));
sink.push(m('tr', renderedCells));
}
}
renderTotalsRow(queryResult: PivotTableResult) {
const overallValuesRow = [
m(
'td.total-values',
{colspan: queryResult.metadata.pivotColumns.length},
m('strong', 'Total values:'),
),
];
for (let i = 0; i < queryResult.metadata.aggregationColumns.length; i++) {
overallValuesRow.push(
m(
'td' + markFirst(i),
this.renderCell(
queryResult.metadata.aggregationColumns[i].column,
queryResult.tree.aggregates[i],
),
),
);
}
overallValuesRow.push(m('td'));
return m('tr', overallValuesRow);
}
sortingItem(aggregationIndex: number, order: SortDirection): PopupMenuItem {
return {
itemType: 'regular',
text: order === 'DESC' ? 'Highest first' : 'Lowest first',
callback() {
globals.dispatch(
Actions.setPivotTableSortColumn({aggregationIndex, order}),
);
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}),
);
},
};
}
readableAggregationName(aggregation: Aggregation) {
if (aggregation.aggregationFunction === 'COUNT') {
return 'Count';
}
return `${aggregation.aggregationFunction}(${readableColumnName(
aggregation.column,
)})`;
}
aggregationPopupItem(
aggregation: Aggregation,
index: number,
nameOverride?: string,
): PopupMenuItem {
return {
itemType: 'regular',
text: nameOverride ?? readableColumnName(aggregation.column),
callback: () => {
globals.dispatch(
Actions.addPivotTableAggregation({aggregation, after: index}),
);
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}),
);
},
};
}
aggregationPopupTableGroup(
table: string,
columns: string[],
index: number,
): PopupMenuItem | undefined {
const items = [];
for (const column of columns) {
const tableColumn: TableColumn = {kind: 'regular', table, column};
items.push(
this.aggregationPopupItem(
{aggregationFunction: 'SUM', column: tableColumn},
index,
),
);
}
if (items.length === 0) {
return undefined;
}
return {
itemType: 'group',
itemId: `aggregations-${table}`,
text: `Add ${table} aggregation`,
children: items,
};
}
renderAggregationHeaderCell(
aggregation: Aggregation,
index: number,
removeItem: boolean,
): ReorderableCell {
const popupItems: PopupMenuItem[] = [];
const state = globals.state.nonSerializableState.pivotTable;
if (aggregation.sortDirection === undefined) {
popupItems.push(
this.sortingItem(index, 'DESC'),
this.sortingItem(index, 'ASC'),
);
} else {
// Table is already sorted by the same column, return one item with
// opposite direction.
popupItems.push(
this.sortingItem(
index,
aggregation.sortDirection === 'DESC' ? 'ASC' : 'DESC',
),
);
}
const otherAggs: AggregationFunction[] = ['SUM', 'MAX', 'MIN', 'AVG'];
if (aggregation.aggregationFunction !== 'COUNT') {
for (const otherAgg of otherAggs) {
if (aggregation.aggregationFunction === otherAgg) {
continue;
}
popupItems.push({
itemType: 'regular',
text: otherAgg,
callback() {
globals.dispatch(
Actions.setPivotTableAggregationFunction({
index,
function: otherAgg,
}),
);
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}),
);
},
});
}
}
if (removeItem) {
popupItems.push({
itemType: 'regular',
text: 'Remove',
callback: () => {
globals.dispatch(Actions.removePivotTableAggregation({index}));
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}),
);
},
});
}
let hasCount = false;
for (const agg of state.selectedAggregations.values()) {
if (agg.aggregationFunction === 'COUNT') {
hasCount = true;
}
}
if (!hasCount) {
popupItems.push(
this.aggregationPopupItem(
COUNT_AGGREGATION,
index,
'Add count aggregation',
),
);
}
const sliceAggregationsItem = this.aggregationPopupTableGroup(
assertExists(getSqlTableDescription('slice')).name,
sliceAggregationColumns,
index,
);
if (sliceAggregationsItem !== undefined) {
popupItems.push(sliceAggregationsItem);
}
return {
extraClass: '.aggregation' + markFirst(index),
content: [
this.readableAggregationName(aggregation),
m(PopupMenuButton, {
icon: popupMenuIcon(aggregation.sortDirection),
items: popupItems,
}),
],
};
}
attributeModalHolder: AttributeModalHolder;
renderPivotColumnHeader(
queryResult: PivotTableResult,
pivot: TableColumn,
selectedPivots: Set<string>,
): ReorderableCell {
const items: PopupMenuItem[] = [
{
itemType: 'regular',
text: 'Add argument pivot',
callback: () => {
this.attributeModalHolder.start();
},
},
];
if (queryResult.metadata.pivotColumns.length > 1) {
items.push({
itemType: 'regular',
text: 'Remove',
callback() {
globals.dispatch(
Actions.setPivotTablePivotSelected({
column: pivot,
selected: false,
}),
);
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}),
);
},
});
}
for (const table of tables) {
const group: PopupMenuItem[] = [];
for (const columnName of table.columns) {
const column: TableColumn = {
kind: 'regular',
table: table.name,
column: columnName,
};
if (selectedPivots.has(columnKey(column))) {
continue;
}
group.push({
itemType: 'regular',
text: columnName,
callback() {
globals.dispatch(
Actions.setPivotTablePivotSelected({column, selected: true}),
);
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}),
);
},
});
}
items.push({
itemType: 'group',
itemId: `pivot-${table.name}`,
text: `Add ${table.displayName} pivot`,
children: group,
});
}
return {
content: [
readableColumnName(pivot),
m(PopupMenuButton, {icon: 'more_horiz', items}),
],
};
}
renderResultsTable(attrs: PivotTableAttrs) {
const state = globals.state.nonSerializableState.pivotTable;
if (state.queryResult === null) {
return m('div', 'Loading...');
}
const queryResult: PivotTableResult = state.queryResult;
const renderedRows: m.Vnode[] = [];
// We should not even be showing the tab if there's no results.
const tree = state.queryResult.tree;
assertFalse(tree.children.size === 0 && tree.rows.length === 0);
this.renderTree(
attrs.selectionArea,
[],
tree,
state.queryResult,
renderedRows,
);
const selectedPivots = new Set(
this.pivotState.selectedPivots.map(columnKey),
);
const pivotTableHeaders = state.selectedPivots.map((pivot) =>
this.renderPivotColumnHeader(queryResult, pivot, selectedPivots),
);
const removeItem = state.queryResult.metadata.aggregationColumns.length > 1;
const aggregationTableHeaders =
state.queryResult.metadata.aggregationColumns.map((aggregation, index) =>
this.renderAggregationHeaderCell(aggregation, index, removeItem),
);
return m(
'table.pivot-table',
m(
'thead',
// First row of the table, containing names of pivot and aggregation
// columns, as well as popup menus to modify the columns. Last cell
// is empty because of an extra column with "drill down" button for
// each pivot table row.
m(
'tr.header',
m(ReorderableCellGroup, {
cells: pivotTableHeaders,
onReorder: (from: number, to: number, direction: DropDirection) => {
globals.dispatch(
Actions.changePivotTablePivotOrder({from, to, direction}),
);
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}),
);
},
}),
m(ReorderableCellGroup, {
cells: aggregationTableHeaders,
onReorder: (from: number, to: number, direction: DropDirection) => {
globals.dispatch(
Actions.changePivotTableAggregationOrder({from, to, direction}),
);
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}),
);
},
}),
m(
'td.menu',
m(PopupMenuButton, {
icon: 'menu',
items: [
{
itemType: 'regular',
text: state.constrainToArea
? 'Query data for the whole timeline'
: 'Constrain to selected area',
callback: () => {
globals.dispatch(
Actions.setPivotTableConstrainToArea({
constrain: !state.constrainToArea,
}),
);
globals.dispatch(
Actions.setPivotTableQueryRequested({
queryRequested: true,
}),
);
},
},
],
}),
),
),
),
m('tbody', this.renderTotalsRow(state.queryResult), renderedRows),
);
}
view({attrs}: m.Vnode<PivotTableAttrs>): m.Children {
return m('.pivot-table', this.renderResultsTable(attrs));
}
}