Skip to content

Commit cc357a3

Browse files
committed
ui: add support for casting columns to the table viewer
Allow the user to change the type of the column explicitly by casting the column to one of the types in PerfettoSQL.
1 parent efc223a commit cc357a3

File tree

12 files changed

+369
-110
lines changed

12 files changed

+369
-110
lines changed

ui/src/base/semantic_icons.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export class Icons {
5050
static readonly Check = 'check';
5151
static readonly Search = 'search';
5252
static readonly Save = 'save';
53+
static readonly Undo = 'undo';
5354

5455
// Page control
5556
static readonly NextPage = 'chevron_right';

ui/src/components/widgets/sql/pivot_table/pivot_table.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {Button} from '../../../../widgets/button';
2020
import {Icons} from '../../../../base/semantic_icons';
2121
import {TableColumn, tableColumnId} from '../table/table_column';
2222
import {MenuDivider, MenuItem} from '../../../../widgets/menu';
23-
import {SelectColumnMenu} from '../table/select_column_menu';
23+
import {SelectColumnMenu} from '../table/menus/select_column_menu';
2424
import {SqlColumn} from '../table/sql_column';
2525
import {buildSqlQuery} from '../table/query_builder';
2626
import {Aggregation, AGGREGATIONS} from './aggregations';

ui/src/components/widgets/sql/table/create_column.ts

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ import {
2525
ThreadStateIdColumn,
2626
TimestampColumn,
2727
} from './columns';
28+
import {SqlColumn} from './sql_column';
2829
import {TableColumn} from './table_column';
2930

3031
export function createTableColumn(args: {
3132
trace: Trace;
32-
table: string;
33-
column: string;
33+
column: SqlColumn;
3434
type?: PerfettoSqlType;
3535
}): TableColumn {
3636
if (args.type?.kind === 'timestamp') {
@@ -42,33 +42,26 @@ export function createTableColumn(args: {
4242
if (args.type?.kind === 'arg_set_id') {
4343
return new ArgSetIdColumn(args.column);
4444
}
45-
if (args.type?.kind === 'id') {
46-
switch (args.table.toLowerCase()) {
47-
case 'slice':
48-
return new SliceIdColumn(args.trace, args.column, {type: 'id'});
49-
case 'thread':
50-
return new ThreadIdColumn(args.trace, args.column, {type: 'id'});
51-
case 'process':
52-
return new ProcessIdColumn(args.trace, args.column, {type: 'id'});
53-
case 'thread_state':
54-
return new ThreadStateIdColumn(args.trace, args.column);
55-
case 'sched':
56-
return new SchedIdColumn(args.trace, args.column);
57-
}
58-
return new StandardColumn(args.column, args.type);
59-
}
60-
if (args.type?.kind === 'joinid' && args.type.source.column === 'id') {
61-
switch (args.type.source.table.toLowerCase()) {
62-
case 'slice':
63-
return new SliceIdColumn(args.trace, args.column);
64-
case 'thread':
65-
return new ThreadIdColumn(args.trace, args.column);
66-
case 'process':
67-
return new ProcessIdColumn(args.trace, args.column);
68-
case 'thread_state':
69-
return new ThreadStateIdColumn(args.trace, args.column);
70-
case 'sched':
71-
return new SchedIdColumn(args.trace, args.column);
45+
if (args.type?.kind === 'id' || args.type?.kind === 'joinid') {
46+
if (args.type.source.column === 'id') {
47+
switch (args.type.source?.table.toLowerCase()) {
48+
case 'slice':
49+
return new SliceIdColumn(args.trace, args.column, {
50+
type: 'id',
51+
});
52+
case 'thread':
53+
return new ThreadIdColumn(args.trace, args.column, {
54+
type: 'id',
55+
});
56+
case 'process':
57+
return new ProcessIdColumn(args.trace, args.column, {
58+
type: 'id',
59+
});
60+
case 'thread_state':
61+
return new ThreadStateIdColumn(args.trace, args.column);
62+
case 'sched':
63+
return new SchedIdColumn(args.trace, args.column);
64+
}
7265
}
7366
}
7467
return new StandardColumn(args.column, args.type);

ui/src/components/widgets/sql/table/add_column_filter_menu.ts renamed to ui/src/components/widgets/sql/table/menus/add_column_filter_menu.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,18 @@
1313
// limitations under the License.
1414

1515
import m from 'mithril';
16-
import {MenuItem} from '../../../../widgets/menu';
17-
import {Form} from '../../../../widgets/form';
18-
import {TextInput} from '../../../../widgets/text_input';
19-
import {SqlValue} from '../../../../trace_processor/query_result';
16+
import {MenuItem} from '../../../../../widgets/menu';
17+
import {Form} from '../../../../../widgets/form';
18+
import {TextInput} from '../../../../../widgets/text_input';
19+
import {SqlValue} from '../../../../../trace_processor/query_result';
2020
import {
2121
isQuantitativeType,
2222
PerfettoSqlType,
23-
} from '../../../../trace_processor/perfetto_sql_type';
24-
import {SqlTableState} from './state';
25-
import {TableColumn} from './table_column';
26-
import {sqlValueToSqliteString} from '../../../../trace_processor/sql_utils';
27-
import {Result, errResult, okResult} from '../../../../base/result';
23+
} from '../../../../../trace_processor/perfetto_sql_type';
24+
import {SqlTableState} from '../state';
25+
import {TableColumn} from '../table_column';
26+
import {sqlValueToSqliteString} from '../../../../../trace_processor/sql_utils';
27+
import {Result, errResult, okResult} from '../../../../../base/result';
2828

2929
type FilterParams = {
3030
op: string;
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
15+
import m from 'mithril';
16+
import {Icons} from '../../../../../base/semantic_icons';
17+
import {MenuItem} from '../../../../../widgets/menu';
18+
import {TableColumn, RenderedCell, TableManager} from '../table_column';
19+
import {SqlTableState} from '../state';
20+
import {
21+
PerfettoSqlType,
22+
PerfettoSqlTypes,
23+
typesEqual,
24+
underlyingSqlType,
25+
} from '../../../../../trace_processor/perfetto_sql_type';
26+
import {SqlColumn, sqlColumnId, SqlExpression} from '../sql_column';
27+
import {SqlValue} from '../../../../../trace_processor/query_result';
28+
import {createTableColumn} from '../create_column';
29+
30+
type CastParams = {
31+
type: PerfettoSqlType;
32+
};
33+
34+
const CASTS = {
35+
int: {
36+
type: PerfettoSqlTypes.INT,
37+
},
38+
double: {
39+
type: PerfettoSqlTypes.DOUBLE,
40+
},
41+
string: {
42+
type: PerfettoSqlTypes.STRING,
43+
},
44+
boolean: {
45+
type: PerfettoSqlTypes.BOOLEAN,
46+
},
47+
timestamp: {
48+
type: PerfettoSqlTypes.TIMESTAMP,
49+
},
50+
duration: {
51+
type: PerfettoSqlTypes.DURATION,
52+
},
53+
slice_id: {
54+
type: {
55+
kind: 'joinid',
56+
source: {
57+
table: 'slice',
58+
column: 'id',
59+
},
60+
},
61+
},
62+
utid: {
63+
type: {
64+
kind: 'joinid',
65+
source: {
66+
table: 'thread',
67+
column: 'id',
68+
},
69+
},
70+
},
71+
upid: {
72+
type: {
73+
kind: 'joinid',
74+
source: {
75+
table: 'process',
76+
column: 'id',
77+
},
78+
},
79+
},
80+
} satisfies Record<string, CastParams>;
81+
82+
// CastColumn wraps another column and provides casting functionality
83+
export class CastColumn implements TableColumn {
84+
public readonly column: SqlColumn;
85+
constructor(
86+
public readonly wrappedColumn: TableColumn,
87+
public readonly sourceColumn: TableColumn,
88+
public readonly type: PerfettoSqlType | undefined,
89+
) {
90+
this.column = wrappedColumn.column;
91+
}
92+
93+
getTitle(): string | undefined {
94+
return this.wrappedColumn.getTitle?.();
95+
}
96+
97+
renderCell(
98+
value: SqlValue,
99+
tableManager?: TableManager,
100+
supportingValues?: {} | undefined,
101+
): RenderedCell {
102+
// Delegate rendering to the appropriate column type based on the cast type
103+
// This allows proper formatting for timestamps, durations, etc.
104+
return this.wrappedColumn.renderCell(value, tableManager, supportingValues);
105+
}
106+
107+
supportingColumns() {
108+
return this.wrappedColumn.supportingColumns?.() || (() => {});
109+
}
110+
111+
listDerivedColumns(manager: TableManager) {
112+
return this.wrappedColumn.listDerivedColumns?.(manager);
113+
}
114+
115+
getColumnSpecificMenuItems(args: {
116+
replaceColumn: (column: TableColumn) => void;
117+
}): m.Children {
118+
return m(MenuItem, {
119+
label: 'Remove cast',
120+
icon: Icons.Undo,
121+
onclick: () => args.replaceColumn(this.sourceColumn),
122+
});
123+
}
124+
}
125+
126+
export function renderCastColumnMenu(
127+
column: TableColumn,
128+
columnIndex: number,
129+
state: SqlTableState,
130+
): m.Children {
131+
return Object.entries(CASTS)
132+
.filter(([_, params]) => {
133+
if (column.type === undefined) {
134+
return true;
135+
}
136+
return !typesEqual(params.type, column.type);
137+
})
138+
.map(([label, params]) =>
139+
m(MenuItem, {
140+
label,
141+
onclick: () => {
142+
// If this is already a CastColumn, get the original source column.
143+
const columnToCast: TableColumn =
144+
column instanceof CastColumn ? column.sourceColumn : column;
145+
146+
const castExpression = (() => {
147+
if (
148+
columnToCast.type !== undefined &&
149+
underlyingSqlType(columnToCast.type) ===
150+
underlyingSqlType(params.type)
151+
) {
152+
// If the underlying types are the same, there is no need for a SQL cast, we only need to reinterpret the data.
153+
return (cols: string[]) => cols[0];
154+
}
155+
return (cols: string[]) =>
156+
`CAST(${cols[0]} AS ${underlyingSqlType(params.type)})`;
157+
})();
158+
159+
// Create a CastColumn wrapping the source column
160+
const castColumn = new CastColumn(
161+
createTableColumn({
162+
column: new SqlExpression(
163+
castExpression,
164+
[columnToCast.column],
165+
`cast<${label}>(${sqlColumnId(columnToCast.column)})`,
166+
),
167+
trace: state.trace,
168+
type: params.type,
169+
}),
170+
columnToCast,
171+
params.type,
172+
);
173+
174+
return state.replaceColumnAtIndex(columnIndex, castColumn);
175+
},
176+
}),
177+
);
178+
}

ui/src/components/widgets/sql/table/select_column_menu.ts renamed to ui/src/components/widgets/sql/table/menus/select_column_menu.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
// limitations under the License.
1414

1515
import m from 'mithril';
16-
import {TableColumn, TableManager, tableColumnId} from './table_column';
17-
import {MenuDivider, MenuItem} from '../../../../widgets/menu';
18-
import {raf} from '../../../../core/raf_scheduler';
19-
import {uuidv4} from '../../../../base/uuid';
20-
import {hasModKey, modKey} from '../../../../base/hotkeys';
21-
import {TextInput} from '../../../../widgets/text_input';
22-
import {Spinner} from '../../../../widgets/spinner';
16+
import {TableColumn, TableManager, tableColumnId} from '../table_column';
17+
import {MenuDivider, MenuItem} from '../../../../../widgets/menu';
18+
import {raf} from '../../../../../core/raf_scheduler';
19+
import {uuidv4} from '../../../../../base/uuid';
20+
import {hasModKey, modKey} from '../../../../../base/hotkeys';
21+
import {TextInput} from '../../../../../widgets/text_input';
22+
import {Spinner} from '../../../../../widgets/spinner';
2323

2424
export type SelectColumnMenuAttrs = {
2525
columns:

ui/src/components/widgets/sql/table/state.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -408,13 +408,23 @@ export class SqlTableState {
408408
hideColumnAtIndex(index: number) {
409409
const column = this.columns[index];
410410
this.columns.splice(index, 1);
411-
// We can only filter by the visibile columns to avoid confusing the user,
411+
this.willRemoveColumn(column);
412+
// TODO(altimin): we can avoid the fetch here if the orderBy hasn't changed.
413+
this.reload({offset: 'keep'});
414+
}
415+
416+
replaceColumnAtIndex(index: number, column: TableColumn) {
417+
this.willRemoveColumn(this.columns[index]);
418+
this.columns[index] = column;
419+
this.reload({offset: 'keep'});
420+
}
421+
422+
private willRemoveColumn(column: TableColumn) {
423+
// We can only filter by the visible columns to avoid confusing the user,
412424
// so we remove order by clauses that refer to the hidden column.
413425
this.orderBy = this.orderBy.filter(
414426
(c) => tableColumnId(c.column) !== tableColumnId(column),
415427
);
416-
// TODO(altimin): we can avoid the fetch here if the orderBy hasn't changed.
417-
this.reload({offset: 'keep'});
418428
}
419429

420430
moveColumn(fromIndex: number, toIndex: number) {

ui/src/components/widgets/sql/table/table.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ import {
3636
tableColumnId,
3737
} from './table_column';
3838
import {SqlColumn, sqlColumnId} from './sql_column';
39-
import {SelectColumnMenu} from './select_column_menu';
40-
import {renderColumnFilterOptions} from './add_column_filter_menu';
39+
import {SelectColumnMenu} from './menus/select_column_menu';
40+
import {renderColumnFilterOptions} from './menus/add_column_filter_menu';
41+
import {renderCastColumnMenu} from './menus/cast_column_menu';
4142

4243
export interface SqlTableConfig {
4344
readonly state: SqlTableState;
@@ -150,10 +151,6 @@ export class SqlTable implements m.ClassComponent<SqlTableConfig> {
150151
});
151152
}
152153

153-
renderColumnFilterOptions(c: TableColumn): m.Children {
154-
return renderColumnFilterOptions(c, this.state);
155-
}
156-
157154
getAdditionalColumnMenuItems(
158155
addColumnMenuItems?: (
159156
column: TableColumn,
@@ -198,10 +195,20 @@ export class SqlTable implements m.ClassComponent<SqlTableConfig> {
198195
icon: Icons.Hide,
199196
onclick: () => this.state.hideColumnAtIndex(i),
200197
}),
198+
// Use the new getColumnSpecificMenuItems method if available
199+
column.getColumnSpecificMenuItems?.({
200+
replaceColumn: (newColumn: TableColumn) =>
201+
this.state.replaceColumnAtIndex(i, newColumn),
202+
}),
203+
m(
204+
MenuItem,
205+
{label: 'Cast', icon: Icons.Change},
206+
renderCastColumnMenu(column, i, this.state),
207+
),
201208
m(
202209
MenuItem,
203210
{label: 'Add filter', icon: Icons.Filter},
204-
this.renderColumnFilterOptions(column),
211+
renderColumnFilterOptions(column, this.state),
205212
),
206213
additionalColumnMenuItems &&
207214
additionalColumnMenuItems[
@@ -216,7 +223,6 @@ export class SqlTable implements m.ClassComponent<SqlTableConfig> {
216223
index: i,
217224
}),
218225
];
219-
220226
const columnKey = tableColumnId(column);
221227

222228
const gridColumn: GridColumn = {

0 commit comments

Comments
 (0)