blob: c6e2762dbb620b71a87b7097aaecebd9ad17811a [file] [log] [blame]
// Copyright (C) 2021 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {Actions} from '../common/actions';
import {Engine} from '../common/engine';
import {
AVAILABLE_AGGREGATIONS,
AVAILABLE_TABLES,
PivotTableQueryResponse,
RowAttrs,
WHERE_FILTERS
} from '../common/pivot_table_data';
import {
getAggregationAlias,
getPivotAlias,
PivotTableQueryGenerator
} from '../common/pivot_table_query_generator';
import {
QueryResponse,
runQuery,
} from '../common/queries';
import {Row} from '../common/query_result';
import {PivotTableHelper} from '../frontend/pivot_table_helper';
import {publishPivotTableHelper, publishQueryResult} from '../frontend/publish';
import {Controller} from './controller';
import {globals} from './globals';
export interface PivotTableControllerArgs {
pivotTableId: string;
engine: Engine;
}
function getExpandableColumn(pivotTableId: string, columns: string[]): string|
undefined {
const pivotTable = globals.state.pivotTable[pivotTableId];
const lastQueriedPivotIdx =
columns.length - pivotTable.selectedAggregations.length - 1;
if (lastQueriedPivotIdx < 0) {
return undefined;
}
const selectedPivots = pivotTable.selectedPivots;
const lastPivot = getPivotAlias(selectedPivots[selectedPivots.length - 1]);
if (columns[lastQueriedPivotIdx] !== lastPivot) {
return columns[lastQueriedPivotIdx];
}
return undefined;
}
function getPivotTableQueryResponseRows(
pivotTableId: string, rows: Row[], columns: string[]): RowAttrs[] {
const expandableColumn = getExpandableColumn(pivotTableId, columns);
const newRows: RowAttrs[] = [];
for (const row of rows) {
newRows.push({
row,
isExpanded: false,
expandableColumn,
rows: undefined,
isLoadingQuery: false
});
}
return newRows;
}
function getPivotTableQueryResponse(
pivotTableId: string, queryResp: QueryResponse): PivotTableQueryResponse {
const columns = [];
const pivotTable = globals.state.pivotTable[pivotTableId];
for (let i = 0; i < pivotTable.selectedPivots.length; ++i) {
const pivot = pivotTable.selectedPivots[i];
columns.push({
name: getPivotAlias(pivot),
index: i,
tableName: pivot.tableName,
columnName: pivot.columnName,
});
}
for (let i = 0; i < pivotTable.selectedAggregations.length; ++i) {
const aggregation = pivotTable.selectedAggregations[i];
columns.push({
name: getAggregationAlias(aggregation),
index: i,
tableName: aggregation.tableName,
columnName: aggregation.columnName,
aggregation: aggregation.aggregation,
order: aggregation.order,
});
}
return {
columns,
rows: getPivotTableQueryResponseRows(
pivotTableId, queryResp.rows, queryResp.columns),
error: queryResp.error,
durationMs: queryResp.durationMs
};
}
function getRowAndWhereFiltersInPivotTableQueryResponse(
queryResp: PivotTableQueryResponse, rowIndices: number[]) {
if (rowIndices.length === 0) {
throw new Error('Row indicies should have at least one index.');
}
let row = queryResp.rows[rowIndices[0]];
const whereFilters = [];
for (let i = 1; i < rowIndices.length; ++i) {
if (row.whereFilter !== undefined) {
whereFilters.push(row.whereFilter);
}
if (row.rows === undefined || row.rows.length <= rowIndices[i]) {
throw new Error(
`Expanded row index "${rowIndices[i]}" is out of bounds.`);
}
row = row.rows[rowIndices[i]];
}
return {row, whereFilters};
}
export class PivotTableController extends Controller<'main'> {
private pivotTableId: string;
private pivotTableQueryGenerator = new PivotTableQueryGenerator();
private engine: Engine;
private queryResp?: PivotTableQueryResponse;
constructor(args: PivotTableControllerArgs) {
super('main');
this.engine = args.engine;
this.pivotTableId = args.pivotTableId;
this.setup().then(() => {
this.run();
});
}
run() {
const {requestedAction} = globals.state.pivotTable[this.pivotTableId];
const pivotTable = globals.state.pivotTable[this.pivotTableId];
if (!requestedAction) return;
globals.dispatch(
Actions.resetPivotTableRequest({pivotTableId: this.pivotTableId}));
switch (requestedAction.action) {
case 'EXPAND':
const expandAttrs = requestedAction.attrs;
if (expandAttrs === undefined) {
throw Error('No attributes provided for expand query.');
}
if (this.queryResp === undefined) {
throw Error('Expand query requested without setting the main query.');
}
const {row: expandRow, whereFilters} =
getRowAndWhereFiltersInPivotTableQueryResponse(
this.queryResp, expandAttrs.rowIndices);
// No need to query if the row has been expanded before.
if (expandRow.rows !== undefined) {
expandRow.isExpanded = true;
publishQueryResult({id: this.pivotTableId, data: this.queryResp});
break;
}
const whereFilter = `CAST(${
pivotTable.selectedPivots[expandAttrs.columnIdx].tableName}.${
pivotTable.selectedPivots[expandAttrs.columnIdx]
.columnName} AS TEXT) = '${expandAttrs.value}'`;
whereFilters.push(whereFilter);
whereFilters.push(...WHERE_FILTERS);
// Slice returns an empty array if indexes are out of bounds.
const pivots = pivotTable.selectedPivots.slice(
expandAttrs.columnIdx + 1, expandAttrs.columnIdx + 2);
if (pivots.length === 0) {
throw Error(
`Expand operation at column index "${
expandAttrs.columnIdx}" should only be allowed if there are` +
`are more columns to query.`);
}
// Query the column after the expanded column.
const expandQuery = this.pivotTableQueryGenerator.generateQuery(
pivots, pivotTable.selectedAggregations, whereFilters);
expandRow.isLoadingQuery = true;
runQuery(this.pivotTableId, expandQuery, this.engine).then(resp => {
// Query resulting from query generator should always be valid.
if (resp.error) {
throw Error(`Pivot table expand query ${
expandQuery} resulted in SQL error: ${resp.error}`);
}
console.log(`Expand query ${expandQuery} took ${resp.durationMs} ms`);
expandRow.rows = getPivotTableQueryResponseRows(
this.pivotTableId, resp.rows, resp.columns);
expandRow.isExpanded = true;
expandRow.whereFilter = whereFilter;
expandRow.isLoadingQuery = false;
this.queryResp!.durationMs += resp.durationMs;
});
break;
case 'UNEXPAND':
const unexpandAttrs = requestedAction.attrs;
if (unexpandAttrs === undefined) {
throw Error('No attributes provided for unexpand query.');
}
if (this.queryResp === undefined) {
throw Error(
'Unexpand query requested without setting the main query.');
}
const {row: unexpandRow} =
getRowAndWhereFiltersInPivotTableQueryResponse(
this.queryResp, unexpandAttrs.rowIndices);
unexpandRow.isExpanded = false;
break;
case 'QUERY':
// Generates and executes new query based on selectedPivots and
// selectedAggregations.
// Query the first column.
const query = this.pivotTableQueryGenerator.generateQuery(
pivotTable.selectedPivots.slice(0, 1),
pivotTable.selectedAggregations,
WHERE_FILTERS);
if (query !== '') {
globals.dispatch(
Actions.toggleQueryLoading({pivotTableId: this.pivotTableId}));
runQuery(this.pivotTableId, query, this.engine).then(resp => {
// Query resulting from query generator should always be valid.
if (resp.error) {
throw Error(`Pivot table query ${query} resulted in SQL error: ${
resp.error}`);
}
console.log(`Query ${query} took ${resp.durationMs} ms`);
const data = getPivotTableQueryResponse(this.pivotTableId, resp);
publishQueryResult({id: this.pivotTableId, data});
this.queryResp = data;
globals.dispatch(
Actions.toggleQueryLoading({pivotTableId: this.pivotTableId}));
});
} else {
publishQueryResult({id: this.pivotTableId, data: undefined});
}
break;
default:
throw new Error(`Unexpected requested action ${requestedAction}`);
}
}
private async setup(): Promise<void> {
const pivotTable = globals.state.pivotTable[this.pivotTableId];
const selectedPivots = pivotTable.selectedPivots;
const selectedAggregations = pivotTable.selectedAggregations;
let availableColumns = globals.state.pivotTableConfig.availableColumns;
// No need to retrieve table columns if they are already stored.
// Only needed when first pivot table is created.
if (availableColumns === undefined) {
availableColumns = [];
for (const table of AVAILABLE_TABLES) {
const columns = await this.getColumnsForTable(table);
if (columns.length > 0) {
availableColumns.push({tableName: table, columns});
}
}
globals.dispatch(Actions.setAvailablePivotTableColumns(
{availableColumns, availableAggregations: AVAILABLE_AGGREGATIONS}));
}
publishPivotTableHelper({
id: this.pivotTableId,
data: new PivotTableHelper(
this.pivotTableId,
availableColumns,
AVAILABLE_AGGREGATIONS,
selectedPivots,
selectedAggregations)
});
}
private async getColumnsForTable(tableName: string): Promise<string[]> {
const query = `select * from ${tableName} limit 0;`;
const resp = await runQuery(this.pivotTableId, query, this.engine);
return resp.columns;
}
}