• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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