Skip to content

Commit 2e3b495

Browse files
committed
ui: add support for transforming columns in table viewer
Initial version of supporting transformation of the table columns in the table viewer. For now, transformations of the string columns are supported: - length - substr - extract_regexp - remove_prefix / remove_suffix
1 parent cc357a3 commit 2e3b495

File tree

4 files changed

+367
-1
lines changed

4 files changed

+367
-1
lines changed

ui/src/base/semantic_icons.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export class Icons {
6868
static readonly Analyze = 'analytics';
6969
static readonly Chart = 'bar_chart';
7070
static readonly Pivot = 'pivot_table_chart';
71+
static readonly ApplyFunction = 'function';
7172

7273
static readonly Play = 'play_arrow';
7374
static readonly Edit = 'edit';
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
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 {MenuItem} from '../../../../../widgets/menu';
17+
import {Form, FormLabel} from '../../../../../widgets/form';
18+
import {TextInput} from '../../../../../widgets/text_input';
19+
import {Icons} from '../../../../../base/semantic_icons';
20+
import {TableColumn, RenderedCell, TableManager} from '../table_column';
21+
import {SqlTableState} from '../state';
22+
import {
23+
PerfettoSqlType,
24+
PerfettoSqlTypes,
25+
typesEqual,
26+
} from '../../../../../trace_processor/perfetto_sql_type';
27+
import {createTableColumn} from '../create_column';
28+
import {SqlColumn, SqlExpression} from '../sql_column';
29+
import {SqlValue} from '../../../../../trace_processor/query_result';
30+
import {uuidv4} from '../../../../../base/uuid';
31+
import {range} from '../../../../../base/array_utils';
32+
33+
type Transform = {
34+
// The SQL expresssion to apply.
35+
expression: (colExpr: string, ...params: string[]) => string;
36+
// Optional parameters for the transform
37+
parameters?: TransformParameter[];
38+
requiredType?: PerfettoSqlType;
39+
resultType: PerfettoSqlType;
40+
};
41+
42+
type TransformParameter = {
43+
name: string;
44+
placeholder: string;
45+
defaultValue?: string;
46+
validate?: (value: string) => boolean;
47+
};
48+
49+
const TRANSFORMS = {
50+
'length': {
51+
expression: (col) => `length(${col})`,
52+
requiredType: PerfettoSqlTypes.STRING,
53+
resultType: {kind: 'int'},
54+
},
55+
'substring': {
56+
expression: (col, start, length) => {
57+
if (length) {
58+
return `substr(${col}, ${start}, ${length})`;
59+
}
60+
return `substr(${col}, ${start})`;
61+
},
62+
parameters: [
63+
{
64+
name: 'start',
65+
placeholder: '1-based, can be negative (optional)',
66+
defaultValue: '1',
67+
validate: (value) => {
68+
if (value === '') {
69+
return true;
70+
}
71+
const num = parseInt(value);
72+
return !isNaN(num);
73+
},
74+
},
75+
{
76+
name: 'length',
77+
placeholder: 'optional',
78+
validate: (value) => {
79+
if (value === '') {
80+
return true;
81+
}
82+
const num = parseInt(value);
83+
return !isNaN(num) && num > 0;
84+
},
85+
},
86+
],
87+
requiredType: PerfettoSqlTypes.STRING,
88+
resultType: PerfettoSqlTypes.STRING,
89+
},
90+
'extract regex': {
91+
expression: (col, pattern) => `regexp_extract(${col}, '${pattern}')`,
92+
parameters: [
93+
{
94+
name: 'pattern',
95+
placeholder: 'regex pattern (e.g., \\d+)',
96+
},
97+
],
98+
requiredType: PerfettoSqlTypes.STRING,
99+
resultType: PerfettoSqlTypes.STRING,
100+
},
101+
'strip prefix': {
102+
expression: (col, prefix) =>
103+
`CASE WHEN ${col} GLOB '${prefix}*' THEN substr(${col}, ${prefix.length + 1}) ELSE ${col} END`,
104+
parameters: [
105+
{
106+
name: 'prefix',
107+
placeholder: 'prefix to remove',
108+
},
109+
],
110+
requiredType: PerfettoSqlTypes.STRING,
111+
resultType: PerfettoSqlTypes.STRING,
112+
},
113+
'strip suffix': {
114+
expression: (col, suffix) =>
115+
`CASE WHEN ${col} GLOB '*${suffix}' THEN substr(${col}, 1, length(${col}) - ${suffix.length}) ELSE ${col} END`,
116+
parameters: [
117+
{
118+
name: 'suffix',
119+
placeholder: 'suffix to remove',
120+
},
121+
],
122+
requiredType: PerfettoSqlTypes.STRING,
123+
resultType: PerfettoSqlTypes.STRING,
124+
},
125+
} satisfies Record<string, Transform>;
126+
127+
type TransformType = keyof typeof TRANSFORMS;
128+
129+
export class TransformColumn implements TableColumn {
130+
public readonly column: SqlColumn;
131+
public readonly type: PerfettoSqlType | undefined;
132+
constructor(
133+
public readonly args: {
134+
transformed: TableColumn;
135+
source: TableColumn;
136+
transformType: TransformType;
137+
transformParams: string[];
138+
state: SqlTableState;
139+
},
140+
) {
141+
this.column = args.transformed.column;
142+
this.type = TRANSFORMS[args.transformType].resultType;
143+
}
144+
145+
getTitle(): string | undefined {
146+
return this.args.transformed.getTitle?.();
147+
}
148+
149+
renderCell(
150+
value: SqlValue,
151+
tableManager?: TableManager,
152+
supportingValues?: {} | undefined,
153+
): RenderedCell {
154+
return this.args.transformed.renderCell(
155+
value,
156+
tableManager,
157+
supportingValues,
158+
);
159+
}
160+
161+
supportingColumns() {
162+
return this.args.transformed.supportingColumns?.() || (() => {});
163+
}
164+
165+
listDerivedColumns(manager: TableManager) {
166+
return this.args.transformed.listDerivedColumns?.(manager);
167+
}
168+
169+
getColumnSpecificMenuItems(args: {
170+
replaceColumn: (column: TableColumn) => void;
171+
}): m.Children {
172+
return [
173+
this.args.transformParams.length !== 0 &&
174+
m(
175+
MenuItem,
176+
{
177+
label: 'Edit transform',
178+
icon: Icons.Edit,
179+
},
180+
m(ConfigureTransformMenu, {
181+
column: this.args.source,
182+
state: this.args.state,
183+
transformType: this.args.transformType,
184+
initialValues: this.args.transformParams,
185+
onApply: (newColumn: TableColumn) => args.replaceColumn(newColumn),
186+
formSubmitLabel: 'Edit',
187+
}),
188+
),
189+
m(MenuItem, {
190+
label: 'Undo transform',
191+
icon: Icons.Undo,
192+
onclick: () => args.replaceColumn(this.args.source),
193+
}),
194+
];
195+
}
196+
}
197+
198+
function applyTransform(args: {
199+
column: TableColumn;
200+
transformType: TransformType;
201+
values: string[];
202+
state: SqlTableState;
203+
}): TableColumn {
204+
const transform: Transform = TRANSFORMS[args.transformType];
205+
const values = args.values;
206+
const transformExpression = (cols: string[]) =>
207+
transform.expression(cols[0], ...values);
208+
209+
return new TransformColumn({
210+
source: args.column,
211+
transformed: createTableColumn({
212+
trace: args.state.trace,
213+
column: new SqlExpression(transformExpression, [args.column.column]),
214+
type: transform.resultType,
215+
}),
216+
state: args.state,
217+
transformType: args.transformType,
218+
transformParams: args.values,
219+
});
220+
}
221+
222+
interface TransformMenuItemAttrs {
223+
column: TableColumn;
224+
state: SqlTableState;
225+
transformType: TransformType;
226+
initialValues?: string[];
227+
onApply: (newColumn: TableColumn) => void;
228+
formSubmitLabel: string;
229+
}
230+
231+
class ConfigureTransformMenu
232+
implements m.ClassComponent<TransformMenuItemAttrs>
233+
{
234+
private paramState: {value: string; error: boolean}[] = [];
235+
private readonly uuid = uuidv4();
236+
237+
view({attrs}: m.Vnode<TransformMenuItemAttrs>) {
238+
const transform: Transform = TRANSFORMS[attrs.transformType];
239+
const params = transform.parameters ?? [];
240+
const initialValues = attrs.initialValues ?? [];
241+
if (this.paramState.length !== params.length) {
242+
this.paramState = range(params.length).map((index) => {
243+
if (index < initialValues.length) {
244+
return {value: initialValues[index], error: false};
245+
}
246+
return {value: '', error: false};
247+
});
248+
}
249+
250+
return m(
251+
Form,
252+
{
253+
submitLabel: attrs.formSubmitLabel,
254+
onSubmit: (e: Event) => {
255+
params.forEach((param, index) => {
256+
const value = this.paramState[index].value;
257+
this.paramState[index].error = !(param.validate?.(value) ?? true);
258+
});
259+
260+
const hasError = this.paramState.some((state) => state.error);
261+
if (!hasError) {
262+
attrs.onApply(
263+
applyTransform({
264+
column: attrs.column,
265+
state: attrs.state,
266+
transformType: attrs.transformType,
267+
values: params.map((param, index) => {
268+
const value = this.paramState[index].value;
269+
if (value === '' && param.defaultValue !== undefined) {
270+
return param.defaultValue;
271+
}
272+
return value;
273+
}),
274+
}),
275+
);
276+
} else {
277+
e.stopPropagation();
278+
}
279+
},
280+
},
281+
params.map((param, index) => [
282+
params.length > 1 &&
283+
m(FormLabel, {for: `${this.uuid}_param_${index}`}, param.name),
284+
m(TextInput, {
285+
id: `${this.uuid}_param_${index}`,
286+
placeholder: param.placeholder,
287+
value: this.paramState[index].value,
288+
oninput: (e: InputEvent) => {
289+
this.paramState[index].value = (e.target as HTMLInputElement).value;
290+
this.paramState[index].error = false;
291+
},
292+
style: this.paramState[index].error
293+
? {
294+
border: '1px solid red',
295+
outline: 'none',
296+
}
297+
: {},
298+
}),
299+
]),
300+
);
301+
}
302+
}
303+
304+
export function renderTransformColumnMenu(
305+
column: TableColumn,
306+
columnIndex: number,
307+
state: SqlTableState,
308+
): m.Children {
309+
const applicableTransforms = (
310+
Object.entries(TRANSFORMS) as [TransformType, Transform][]
311+
).filter(
312+
([_, transform]) =>
313+
transform.requiredType === undefined ||
314+
(column.type !== undefined &&
315+
typesEqual(transform.requiredType, column.type)),
316+
);
317+
318+
// Only show the Transform menu if there are applicable transformations
319+
if (applicableTransforms.length === 0) {
320+
return null;
321+
}
322+
323+
return m(
324+
MenuItem,
325+
{label: 'Transform', icon: Icons.ApplyFunction},
326+
applicableTransforms.map(([name, transform]) => {
327+
const paramCount = transform.parameters?.length ?? 0;
328+
return m(
329+
MenuItem,
330+
{
331+
label: name,
332+
onclick:
333+
paramCount === 0
334+
? () =>
335+
state.addColumn(
336+
applyTransform({
337+
column,
338+
state,
339+
transformType: name,
340+
values: [],
341+
}),
342+
columnIndex,
343+
)
344+
: undefined,
345+
},
346+
paramCount !== 0 &&
347+
m(ConfigureTransformMenu, {
348+
column,
349+
state,
350+
transformType: name,
351+
onApply: (column: TableColumn) =>
352+
state.addColumn(column, columnIndex),
353+
formSubmitLabel: 'Add',
354+
}),
355+
);
356+
}),
357+
);
358+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {SqlColumn, sqlColumnId} from './sql_column';
3939
import {SelectColumnMenu} from './menus/select_column_menu';
4040
import {renderColumnFilterOptions} from './menus/add_column_filter_menu';
4141
import {renderCastColumnMenu} from './menus/cast_column_menu';
42+
import {renderTransformColumnMenu} from './menus/transform_column_menu';
4243

4344
export interface SqlTableConfig {
4445
readonly state: SqlTableState;
@@ -205,6 +206,7 @@ export class SqlTable implements m.ClassComponent<SqlTableConfig> {
205206
{label: 'Cast', icon: Icons.Change},
206207
renderCastColumnMenu(column, i, this.state),
207208
),
209+
renderTransformColumnMenu(column, i, this.state),
208210
m(
209211
MenuItem,
210212
{label: 'Add filter', icon: Icons.Filter},

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ export function tableColumnId(column: TableColumn): string {
9393
}
9494

9595
export function tableColumnAlias(column: TableColumn): string {
96-
return tableColumnId(column).replace(/[^a-zA-Z0-9_]/g, '__');
96+
return tableColumnId(column).replace(/[^a-zA-Z0-9_]/g, (char) => {
97+
if (char === '_') {
98+
return '__';
99+
}
100+
return '_' + char.charCodeAt(0);
101+
});
97102
}
98103

99104
export function columnTitle(column: TableColumn): string {

0 commit comments

Comments
 (0)