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 {arrayEquals} from '../base/array_utils'; 18import {Actions} from '../common/actions'; 19import { 20 Area, 21 AreaSelection, 22 PivotTableAreaState, 23 PivotTableQuery, 24 PivotTableQueryMetadata, 25 PivotTableResult, 26 PivotTableState, 27} from '../common/state'; 28import {featureFlags} from '../core/feature_flags'; 29import {globals} from '../frontend/globals'; 30import { 31 aggregationIndex, 32 generateQueryFromState, 33} from '../frontend/pivot_table_query_generator'; 34import {Aggregation, PivotTree} from '../frontend/pivot_table_types'; 35import {Engine} from '../trace_processor/engine'; 36import {ColumnType} from '../trace_processor/query_result'; 37 38import {Controller} from './controller'; 39 40export const PIVOT_TABLE_REDUX_FLAG = featureFlags.register({ 41 id: 'pivotTable', 42 name: 'Pivot tables V2', 43 description: 'Second version of pivot table', 44 defaultValue: true, 45}); 46 47function expectNumber(value: ColumnType): number { 48 if (typeof value === 'number') { 49 return value; 50 } else if (typeof value === 'bigint') { 51 return Number(value); 52 } 53 throw new Error(`number or bigint was expected, got ${typeof value}`); 54} 55 56// Auxiliary class to build the tree from query response. 57export class PivotTableTreeBuilder { 58 private readonly root: PivotTree; 59 queryMetadata: PivotTableQueryMetadata; 60 61 get pivotColumnsCount(): number { 62 return this.queryMetadata.pivotColumns.length; 63 } 64 65 get aggregateColumns(): Aggregation[] { 66 return this.queryMetadata.aggregationColumns; 67 } 68 69 constructor(queryMetadata: PivotTableQueryMetadata, firstRow: ColumnType[]) { 70 this.queryMetadata = queryMetadata; 71 this.root = this.createNode(firstRow); 72 let tree = this.root; 73 for (let i = 0; i + 1 < this.pivotColumnsCount; i++) { 74 const value = firstRow[i]; 75 tree = this.insertChild(tree, value, this.createNode(firstRow)); 76 } 77 tree.rows.push(firstRow); 78 } 79 80 // Add incoming row to the tree being built. 81 ingestRow(row: ColumnType[]) { 82 let tree = this.root; 83 this.updateAggregates(tree, row); 84 for (let i = 0; i + 1 < this.pivotColumnsCount; i++) { 85 const nextTree = tree.children.get(row[i]); 86 if (nextTree === undefined) { 87 // Insert the new node into the tree, and make variable `tree` point 88 // to the newly created node. 89 tree = this.insertChild(tree, row[i], this.createNode(row)); 90 } else { 91 this.updateAggregates(nextTree, row); 92 tree = nextTree; 93 } 94 } 95 tree.rows.push(row); 96 } 97 98 build(): PivotTree { 99 return this.root; 100 } 101 102 updateAggregates(tree: PivotTree, row: ColumnType[]) { 103 const countIndex = this.queryMetadata.countIndex; 104 const treeCount = 105 countIndex >= 0 ? expectNumber(tree.aggregates[countIndex]) : 0; 106 const rowCount = 107 countIndex >= 0 108 ? expectNumber( 109 row[aggregationIndex(this.pivotColumnsCount, countIndex)], 110 ) 111 : 0; 112 113 for (let i = 0; i < this.aggregateColumns.length; i++) { 114 const agg = this.aggregateColumns[i]; 115 116 const currAgg = tree.aggregates[i]; 117 const childAgg = row[aggregationIndex(this.pivotColumnsCount, i)]; 118 if (typeof currAgg === 'number' && typeof childAgg === 'number') { 119 switch (agg.aggregationFunction) { 120 case 'SUM': 121 case 'COUNT': 122 tree.aggregates[i] = currAgg + childAgg; 123 break; 124 case 'MAX': 125 tree.aggregates[i] = Math.max(currAgg, childAgg); 126 break; 127 case 'MIN': 128 tree.aggregates[i] = Math.min(currAgg, childAgg); 129 break; 130 case 'AVG': { 131 const currSum = currAgg * treeCount; 132 const addSum = childAgg * rowCount; 133 tree.aggregates[i] = (currSum + addSum) / (treeCount + rowCount); 134 break; 135 } 136 } 137 } 138 } 139 tree.aggregates[this.aggregateColumns.length] = treeCount + rowCount; 140 } 141 142 // Helper method that inserts child node into the tree and returns it, used 143 // for more concise modification of local variable pointing to the current 144 // node being built. 145 insertChild(tree: PivotTree, key: ColumnType, child: PivotTree): PivotTree { 146 tree.children.set(key, child); 147 148 return child; 149 } 150 151 // Initialize PivotTree from a row. 152 createNode(row: ColumnType[]): PivotTree { 153 const aggregates = []; 154 155 for (let j = 0; j < this.aggregateColumns.length; j++) { 156 aggregates.push(row[aggregationIndex(this.pivotColumnsCount, j)]); 157 } 158 aggregates.push( 159 row[ 160 aggregationIndex(this.pivotColumnsCount, this.aggregateColumns.length) 161 ], 162 ); 163 164 return { 165 isCollapsed: false, 166 children: new Map(), 167 aggregates, 168 rows: [], 169 }; 170 } 171} 172 173function createEmptyQueryResult( 174 metadata: PivotTableQueryMetadata, 175): PivotTableResult { 176 return { 177 tree: { 178 aggregates: [], 179 isCollapsed: false, 180 children: new Map(), 181 rows: [], 182 }, 183 metadata, 184 }; 185} 186 187// Controller responsible for showing the panel with pivot table, as well as 188// executing its queries and post-processing query results. 189export class PivotTableController extends Controller<{}> { 190 static detailsCount = 0; 191 engine: Engine; 192 lastQueryArea?: PivotTableAreaState; 193 lastQueryAreaTracks = new Set<string>(); 194 195 constructor(args: {engine: Engine}) { 196 super({}); 197 this.engine = args.engine; 198 } 199 200 sameTracks(tracks: Set<string>) { 201 if (this.lastQueryAreaTracks.size !== tracks.size) { 202 return false; 203 } 204 205 // ES6 Set does not have .every method, only Array does. 206 for (const track of tracks) { 207 if (!this.lastQueryAreaTracks.has(track)) { 208 return false; 209 } 210 } 211 212 return true; 213 } 214 215 shouldRerun(state: PivotTableState, selection: AreaSelection) { 216 if (state.selectionArea === undefined) { 217 return false; 218 } 219 220 const newTracks = new Set(selection.tracks); 221 if ( 222 this.lastQueryArea !== state.selectionArea || 223 !this.sameTracks(newTracks) 224 ) { 225 this.lastQueryArea = state.selectionArea; 226 this.lastQueryAreaTracks = newTracks; 227 return true; 228 } 229 return false; 230 } 231 232 async processQuery(query: PivotTableQuery) { 233 const result = await this.engine.query(query.text); 234 try { 235 await result.waitAllRows(); 236 } catch { 237 // waitAllRows() frequently throws an exception, which is ignored in 238 // its other calls, so it's ignored here as well. 239 } 240 241 const columns = result.columns(); 242 243 const it = result.iter({}); 244 function nextRow(): ColumnType[] { 245 const row: ColumnType[] = []; 246 for (const column of columns) { 247 row.push(it.get(column)); 248 } 249 it.next(); 250 return row; 251 } 252 253 if (!it.valid()) { 254 // Iterator is invalid after creation; means that there are no rows 255 // satisfying filtering criteria. Return an empty tree. 256 globals.dispatch( 257 Actions.setPivotStateQueryResult({ 258 queryResult: createEmptyQueryResult(query.metadata), 259 }), 260 ); 261 return; 262 } 263 264 const treeBuilder = new PivotTableTreeBuilder(query.metadata, nextRow()); 265 while (it.valid()) { 266 treeBuilder.ingestRow(nextRow()); 267 } 268 269 globals.dispatch( 270 Actions.setPivotStateQueryResult({ 271 queryResult: {tree: treeBuilder.build(), metadata: query.metadata}, 272 }), 273 ); 274 } 275 276 run() { 277 if (!PIVOT_TABLE_REDUX_FLAG.get()) { 278 return; 279 } 280 281 const pivotTableState = globals.state.nonSerializableState.pivotTable; 282 const selection = globals.state.selection; 283 284 if ( 285 pivotTableState.queryRequested || 286 (selection.kind === 'area' && 287 this.shouldRerun(pivotTableState, selection)) 288 ) { 289 globals.dispatch( 290 Actions.setPivotTableQueryRequested({queryRequested: false}), 291 ); 292 // Need to re-run the existing query, clear the current result. 293 globals.dispatch(Actions.setPivotStateQueryResult({queryResult: null})); 294 this.processQuery(generateQueryFromState(pivotTableState)); 295 } 296 297 if ( 298 selection.kind === 'area' && 299 (pivotTableState.selectionArea === undefined || 300 !areasEqual(pivotTableState.selectionArea, selection)) 301 ) { 302 globals.dispatch(Actions.togglePivotTable({area: selection})); 303 } 304 } 305} 306 307// Returns true if two areas and exactly equivalent, false otherwise 308function areasEqual(a: Area, b: Area): boolean { 309 if (a.start !== b.start) return false; 310 if (a.end !== b.end) return false; 311 if (!arrayEquals(a.tracks, b.tracks)) return false; 312 return true; 313} 314