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