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 { 16 PivotTableQuery, 17 PivotTableQueryMetadata, 18 PivotTableResult, 19 PivotTableState, 20 COUNT_AGGREGATION, 21 TableColumn, 22 toggleEnabled, 23 tableColumnEquals, 24 AggregationFunction, 25} from './pivot_table_types'; 26import {AreaSelection} from '../public/selection'; 27import { 28 aggregationIndex, 29 generateQueryFromState, 30} from './pivot_table_query_generator'; 31import {Aggregation, PivotTree} from './pivot_table_types'; 32import {Engine} from '../trace_processor/engine'; 33import {ColumnType} from '../trace_processor/query_result'; 34import {SortDirection} from '../base/comparison_utils'; 35import {assertTrue} from '../base/logging'; 36import {featureFlags} from './feature_flags'; 37 38export const PIVOT_TABLE_REDUX_FLAG = featureFlags.register({ 39 id: 'pivotTable', 40 name: 'Pivot tables V2', 41 description: 'Second version of pivot table', 42 defaultValue: true, 43}); 44 45function expectNumber(value: ColumnType): number { 46 if (typeof value === 'number') { 47 return value; 48 } else if (typeof value === 'bigint') { 49 return Number(value); 50 } 51 throw new Error(`number or bigint was expected, got ${typeof value}`); 52} 53 54// Auxiliary class to build the tree from query response. 55export class PivotTableTreeBuilder { 56 private readonly root: PivotTree; 57 queryMetadata: PivotTableQueryMetadata; 58 59 get pivotColumnsCount(): number { 60 return this.queryMetadata.pivotColumns.length; 61 } 62 63 get aggregateColumns(): Aggregation[] { 64 return this.queryMetadata.aggregationColumns; 65 } 66 67 constructor(queryMetadata: PivotTableQueryMetadata, firstRow: ColumnType[]) { 68 this.queryMetadata = queryMetadata; 69 this.root = this.createNode(firstRow); 70 let tree = this.root; 71 for (let i = 0; i + 1 < this.pivotColumnsCount; i++) { 72 const value = firstRow[i]; 73 tree = this.insertChild(tree, value, this.createNode(firstRow)); 74 } 75 tree.rows.push(firstRow); 76 } 77 78 // Add incoming row to the tree being built. 79 ingestRow(row: ColumnType[]) { 80 let tree = this.root; 81 this.updateAggregates(tree, row); 82 for (let i = 0; i + 1 < this.pivotColumnsCount; i++) { 83 const nextTree = tree.children.get(row[i]); 84 if (nextTree === undefined) { 85 // Insert the new node into the tree, and make variable `tree` point 86 // to the newly created node. 87 tree = this.insertChild(tree, row[i], this.createNode(row)); 88 } else { 89 this.updateAggregates(nextTree, row); 90 tree = nextTree; 91 } 92 } 93 tree.rows.push(row); 94 } 95 96 build(): PivotTree { 97 return this.root; 98 } 99 100 updateAggregates(tree: PivotTree, row: ColumnType[]) { 101 const countIndex = this.queryMetadata.countIndex; 102 const treeCount = 103 countIndex >= 0 ? expectNumber(tree.aggregates[countIndex]) : 0; 104 const rowCount = 105 countIndex >= 0 106 ? expectNumber( 107 row[aggregationIndex(this.pivotColumnsCount, countIndex)], 108 ) 109 : 0; 110 111 for (let i = 0; i < this.aggregateColumns.length; i++) { 112 const agg = this.aggregateColumns[i]; 113 114 const currAgg = tree.aggregates[i]; 115 const childAgg = row[aggregationIndex(this.pivotColumnsCount, i)]; 116 if (typeof currAgg === 'number' && typeof childAgg === 'number') { 117 switch (agg.aggregationFunction) { 118 case 'SUM': 119 case 'COUNT': 120 tree.aggregates[i] = currAgg + childAgg; 121 break; 122 case 'MAX': 123 tree.aggregates[i] = Math.max(currAgg, childAgg); 124 break; 125 case 'MIN': 126 tree.aggregates[i] = Math.min(currAgg, childAgg); 127 break; 128 case 'AVG': { 129 const currSum = currAgg * treeCount; 130 const addSum = childAgg * rowCount; 131 tree.aggregates[i] = (currSum + addSum) / (treeCount + rowCount); 132 break; 133 } 134 } 135 } 136 } 137 tree.aggregates[this.aggregateColumns.length] = treeCount + rowCount; 138 } 139 140 // Helper method that inserts child node into the tree and returns it, used 141 // for more concise modification of local variable pointing to the current 142 // node being built. 143 insertChild(tree: PivotTree, key: ColumnType, child: PivotTree): PivotTree { 144 tree.children.set(key, child); 145 146 return child; 147 } 148 149 // Initialize PivotTree from a row. 150 createNode(row: ColumnType[]): PivotTree { 151 const aggregates = []; 152 153 for (let j = 0; j < this.aggregateColumns.length; j++) { 154 aggregates.push(row[aggregationIndex(this.pivotColumnsCount, j)]); 155 } 156 aggregates.push( 157 row[ 158 aggregationIndex(this.pivotColumnsCount, this.aggregateColumns.length) 159 ], 160 ); 161 162 return { 163 isCollapsed: false, 164 children: new Map(), 165 aggregates, 166 rows: [], 167 }; 168 } 169} 170 171function createEmptyQueryResult( 172 metadata: PivotTableQueryMetadata, 173): PivotTableResult { 174 return { 175 tree: { 176 aggregates: [], 177 isCollapsed: false, 178 children: new Map(), 179 rows: [], 180 }, 181 metadata, 182 }; 183} 184 185// Controller responsible for showing the panel with pivot table, as well as 186// executing its queries and post-processing query results. 187export class PivotTableManager { 188 state: PivotTableState = createEmptyPivotTableState(); 189 190 constructor(private engine: Engine) {} 191 192 setSelectionArea(area: AreaSelection) { 193 if (!PIVOT_TABLE_REDUX_FLAG.get()) { 194 return; 195 } 196 this.state.selectionArea = area; 197 this.refresh(); 198 } 199 200 addAggregation(aggregation: Aggregation, after: number) { 201 this.state.selectedAggregations.splice(after, 0, aggregation); 202 this.refresh(); 203 } 204 205 removeAggregation(index: number) { 206 this.state.selectedAggregations.splice(index, 1); 207 this.refresh(); 208 } 209 210 setPivotSelected(args: {column: TableColumn; selected: boolean}) { 211 toggleEnabled( 212 tableColumnEquals, 213 this.state.selectedPivots, 214 args.column, 215 args.selected, 216 ); 217 this.refresh(); 218 } 219 220 setAggregationFunction(index: number, fn: AggregationFunction) { 221 this.state.selectedAggregations[index].aggregationFunction = fn; 222 this.refresh(); 223 } 224 225 setSortColumn(aggregationIndex: number, order: SortDirection) { 226 this.state.selectedAggregations = this.state.selectedAggregations.map( 227 (agg, index) => ({ 228 column: agg.column, 229 aggregationFunction: agg.aggregationFunction, 230 sortDirection: index === aggregationIndex ? order : undefined, 231 }), 232 ); 233 this.refresh(); 234 } 235 236 setOrder(from: number, to: number, direction: DropDirection) { 237 const pivots = this.state.selectedPivots; 238 this.state.selectedPivots = performReordering( 239 computeIntervals(pivots.length, from, to, direction), 240 pivots, 241 ); 242 this.refresh(); 243 } 244 245 setAggregationOrder(from: number, to: number, direction: DropDirection) { 246 const aggregations = this.state.selectedAggregations; 247 this.state.selectedAggregations = performReordering( 248 computeIntervals(aggregations.length, from, to, direction), 249 aggregations, 250 ); 251 this.refresh(); 252 } 253 254 setConstrainedToArea(constrain: boolean) { 255 this.state.constrainToArea = constrain; 256 this.refresh(); 257 } 258 259 private refresh() { 260 this.state.queryResult = undefined; 261 if (!PIVOT_TABLE_REDUX_FLAG.get()) { 262 return; 263 } 264 this.processQuery(generateQueryFromState(this.state)); 265 } 266 267 private async processQuery(query: PivotTableQuery) { 268 const result = await this.engine.query(query.text); 269 try { 270 await result.waitAllRows(); 271 } catch { 272 // waitAllRows() frequently throws an exception, which is ignored in 273 // its other calls, so it's ignored here as well. 274 } 275 276 const columns = result.columns(); 277 278 const it = result.iter({}); 279 function nextRow(): ColumnType[] { 280 const row: ColumnType[] = []; 281 for (const column of columns) { 282 row.push(it.get(column)); 283 } 284 it.next(); 285 return row; 286 } 287 288 if (!it.valid()) { 289 // Iterator is invalid after creation; means that there are no rows 290 // satisfying filtering criteria. Return an empty tree. 291 this.state.queryResult = createEmptyQueryResult(query.metadata); 292 return; 293 } 294 295 const treeBuilder = new PivotTableTreeBuilder(query.metadata, nextRow()); 296 while (it.valid()) { 297 treeBuilder.ingestRow(nextRow()); 298 } 299 this.state.queryResult = { 300 tree: treeBuilder.build(), 301 metadata: query.metadata, 302 }; 303 } 304} 305 306function createEmptyPivotTableState(): PivotTableState { 307 return { 308 queryResult: undefined, 309 selectedPivots: [ 310 { 311 kind: 'regular', 312 table: '_slice_with_thread_and_process_info', 313 column: 'name', 314 }, 315 ], 316 selectedAggregations: [ 317 { 318 aggregationFunction: 'SUM', 319 column: { 320 kind: 'regular', 321 table: '_slice_with_thread_and_process_info', 322 column: 'dur', 323 }, 324 sortDirection: 'DESC', 325 }, 326 { 327 aggregationFunction: 'SUM', 328 column: { 329 kind: 'regular', 330 table: '_slice_with_thread_and_process_info', 331 column: 'thread_dur', 332 }, 333 }, 334 COUNT_AGGREGATION, 335 ], 336 constrainToArea: true, 337 }; 338} 339 340// Drag&Drop logic 341 342export type DropDirection = 'left' | 'right'; 343 344export interface Interval { 345 from: number; 346 to: number; 347} 348 349/* 350 * When a drag'n'drop is performed in a linear sequence, the resulting reordered 351 * array will consist of several contiguous subarrays of the original glued 352 * together. 353 * 354 * This function implements the computation of these intervals. 355 * 356 * The drag'n'drop operation performed is as follows: in the sequence with given 357 * length, the element with index `dragFrom` is dropped on the `direction` to 358 * the element `dragTo`. 359 */ 360 361export function computeIntervals( 362 length: number, 363 dragFrom: number, 364 dragTo: number, 365 direction: DropDirection, 366): Interval[] { 367 assertTrue(dragFrom !== dragTo); 368 369 if (dragTo < dragFrom) { 370 const prefixLen = direction == 'left' ? dragTo : dragTo + 1; 371 return [ 372 // First goes unchanged prefix. 373 {from: 0, to: prefixLen}, 374 // Then goes dragged element. 375 {from: dragFrom, to: dragFrom + 1}, 376 // Then goes suffix up to dragged element (which has already been moved). 377 {from: prefixLen, to: dragFrom}, 378 // Then the rest of an array. 379 {from: dragFrom + 1, to: length}, 380 ]; 381 } 382 383 // Other case: dragTo > dragFrom 384 const prefixLen = direction == 'left' ? dragTo : dragTo + 1; 385 return [ 386 {from: 0, to: dragFrom}, 387 {from: dragFrom + 1, to: prefixLen}, 388 {from: dragFrom, to: dragFrom + 1}, 389 {from: prefixLen, to: length}, 390 ]; 391} 392 393export function performReordering<T>(intervals: Interval[], arr: T[]): T[] { 394 const result: T[] = []; 395 396 for (const interval of intervals) { 397 result.push(...arr.slice(interval.from, interval.to)); 398 } 399 400 return result; 401} 402