// (C) 2020-2021 GoodData Corporation import { ExecuteAFM as AFM, Execution } from "@gooddata/typings"; import { isEmpty, isEqual, chunk, flattenDeep, indexOf, lastIndexOf } from "lodash"; import { DimensionHeaders, Headers, RandomData } from "./dataResult"; import { CachedResponse, IErrorResponse } from "../executeAfm"; import { getSizeFromHeaders } from "./getDataSize"; import IResultHeaderItem = Execution.IResultHeaderItem; import IResultMeasureHeaderItem = Execution.IResultMeasureHeaderItem; import isMeasureHeaderItem = Execution.isMeasureHeaderItem; import IExecutionResultWrapper = Execution.IExecutionResultWrapper; import IExecution = AFM.IExecution; import isRankingFilter = AFM.isRankingFilter; import IRankingFilter = AFM.IRankingFilter; import ILocalIdentifierQualifier = AFM.ILocalIdentifierQualifier; import IMeasure = AFM.IMeasure; import CompatibilityFilter = AFM.CompatibilityFilter; const REMOVED_VALUE_PLACEHOLDER = "#REMOVED_VALUE#"; export interface IDataResult { headerItems: Headers; data: RandomData; } function findLocalRankingFilters(filters: CompatibilityFilter[]): IRankingFilter[] { if (isEmpty(filters)) { return []; } return filters.filter(filter => isRankingFilter(filter)) as IRankingFilter[]; } function findIndexOfMeasuresDimension(headerItems: Headers): number { return headerItems.findIndex((dimension: DimensionHeaders) => dimension.some((headers: IResultHeaderItem[]) => headers.some((header: IResultHeaderItem) => isMeasureHeaderItem(header)), ), ); } function findMeasureResultHeaders(measureDimension: DimensionHeaders): IResultMeasureHeaderItem[] { return measureDimension.find( (headers: IResultHeaderItem[]) => headers.length > 0 && isMeasureHeaderItem(headers[0]), ) as IResultMeasureHeaderItem[]; } function findReferencedMeasureAfmIndex(filter: IRankingFilter, measures: IMeasure[]) { const measureQualifier = filter.rankingFilter.measures[0] as ILocalIdentifierQualifier; return measures.findIndex(measure => measure.localIdentifier === measureQualifier.localIdentifier); } export function isErrorResponse(response: any): response is IErrorResponse { return (response as IErrorResponse).error !== undefined; } /** * Apply ranking filters from provided execution to provided dataResult generated for the execution. * The function filters the row or column measure data and removes attribute headers that no longer * have values. * * @param execution * @param dataResult * @return filtered execution result if matching filters were found in execution */ function filterDataResult( execution: IExecution | IErrorResponse, dataResult: IDataResult | IErrorResponse, ): IDataResult | IErrorResponse { if (isErrorResponse(execution) || isErrorResponse(dataResult)) { return dataResult; } const { execution: { afm }, } = execution; const { measures, filters } = afm; if (isEmpty(measures) || isEmpty(filters)) { return dataResult; } const rankingFilters = findLocalRankingFilters(filters); if (rankingFilters.length === 0) { return dataResult; } const { headerItems, data } = dataResult; const measureDimensionIndex = findIndexOfMeasuresDimension(headerItems); const measuresAreInRow = measureDimensionIndex === 0; const attributeDimensionIndex = measuresAreInRow ? 1 : 0; const measureHeaders = findMeasureResultHeaders(headerItems[measureDimensionIndex]); const getRemovedValuesIfSomeValueIsRemoved = (data: string[]) => data.includes(REMOVED_VALUE_PLACEHOLDER) ? new Array(data.length).fill(REMOVED_VALUE_PLACEHOLDER) : data; const filterDataRow = ( dataRow: string[], filter: IRankingFilter, filteredMeasureIndex: number, columnsData: string[][], ) => { // split values into groups per repeated measures ([m1, m2, m1, m2] => [[m1, m2], [m1, m2]]) const groupedColumnsData = chunk(columnsData, measures.length); const filteredMeasureGroups = chunk(dataRow, measures.length).map( (measureGroupValues: string[], groupIndex: number) => { const columnGroup = groupedColumnsData[groupIndex]; // replace values that does not match filter with removed value placeholder in every group const processedGroup = measureGroupValues.map((value: string, columnIndex: number) => { const column = columnGroup[columnIndex]; const currentMeasureIndex = measureHeaders[columnIndex].measureHeaderItem.order; return currentMeasureIndex === filteredMeasureIndex ? getMatchingValueOrRemovedValuePlaceholder(value, filter, column) : value; }); // replace all values in group with removed value placeholder if at least one value is removed value placeholder (was filtered out) return getRemovedValuesIfSomeValueIsRemoved(processedGroup); }, ); // put processed groups to the original shape ([[m1, m2], [m1, m2]] => [m1, m2, m1, m2]) return flattenDeep(filteredMeasureGroups); }; // rotate the data to have measures in columns and attributes in rows if it is not the case const columnData = measuresAreInRow ? rotateMatrix(data) : data; // replace values not matching the filters with removed value placeholder const validDataResultValues = rankingFilters.reduce((dataResult: string[][], filter: IRankingFilter) => { const measureIndex = findReferencedMeasureAfmIndex(filter, measures); const columnsData = rotateMatrix(dataResult); return dataResult.map((dataRow: string[]) => filterDataRow(dataRow, filter, measureIndex, columnsData), ); }, columnData); // remove row attribute headers that have every data value set as removed value placeholder in the row const keepRow = (_: any, rowIndex: number) => !validDataResultValues[rowIndex].every( (columnValue: string) => columnValue === REMOVED_VALUE_PLACEHOLDER, ); const filteredAttributesHeaders = headerItems[attributeDimensionIndex].map(rowAttributes => rowAttributes.filter(keepRow), ); // remove column attribute headers that have every data value set as removed value placeholder in the column const keepColumn = (_: any, columnIndex: number) => !validDataResultValues.every( (dataRow: string[]) => dataRow[columnIndex] === REMOVED_VALUE_PLACEHOLDER, ); const filteredMeasureHeaders = headerItems[measureDimensionIndex].map(columnAttributes => columnAttributes.filter(keepColumn), ); // remove data rows and columns that contain only removed value placeholder const filteredDataRows = validDataResultValues .filter(keepRow) .map((dataRow: string[]) => dataRow.filter(keepColumn)); const replaceRemovedValuesByNull = (value: string) => value === REMOVED_VALUE_PLACEHOLDER ? null : value; // replace removed value placeholder by null values const filteredDataRowsWithNullValues = filteredDataRows.map((dataRow: string[]) => dataRow.map(replaceRemovedValuesByNull), ); // rebuild header item dimensions const filteredHeaderItems = []; filteredHeaderItems[attributeDimensionIndex] = filteredAttributesHeaders; filteredHeaderItems[measureDimensionIndex] = filteredMeasureHeaders; return { headerItems: filteredHeaderItems, data: measuresAreInRow ? rotateMatrix(filteredDataRowsWithNullValues) // rotate data back to its original shape : filteredDataRowsWithNullValues, }; } function rotateMatrix(matrix: string[][]): string[][] { return matrix.length === 0 ? matrix : matrix[0].map((_, index) => matrix.map(row => row[index])); } function getMatchingValueOrRemovedValuePlaceholder( afmResultValue: string, filter: IRankingFilter, columnValues: string[], ): string { return isMatchingFilter(afmResultValue, filter, columnValues) ? afmResultValue : REMOVED_VALUE_PLACEHOLDER; } function isMatchingFilter(afmResultValue: string, filter: IRankingFilter, columnValues: string[]): boolean { const { operator, value } = filter.rankingFilter; const testedValue = afmResultValue === null ? null : Number(afmResultValue); const sortedColumnValues = columnValues .map(value => (value === null ? null : Number(value))) .sort((a, b) => (a !== null ? a : Infinity) - (b !== null ? b : Infinity)); // nulls at the end if (testedValue === null) { return false; } switch (operator) { case "TOP": return lastIndexOf(sortedColumnValues, testedValue) >= sortedColumnValues.length - value; case "BOTTOM": return indexOf(sortedColumnValues, testedValue) < value; default: return true; } } function isExecutionResult(result: CachedResponse): result is IExecutionResultWrapper { return !isEmpty(result) && (result as IExecutionResultWrapper).executionResult !== undefined; } function buildDataResult(result: IExecutionResultWrapper): IDataResult { const { executionResult: { headerItems, data }, } = result; return { headerItems, data, }; } function buildExecutionResult(originalResponse: IExecutionResultWrapper, dataResult: IDataResult) { const { executionResult } = originalResponse; const { headerItems, data } = dataResult; const total = getSizeFromHeaders(headerItems); return { executionResult: { ...executionResult, headerItems, data, paging: { ...executionResult.paging, count: total, total, }, }, }; } /** * Apply ranking filters from provided execution to provided execution result stored for execution. * The function filters the row or column measure data and removes attribute headers that no longer * have values. It also updates the paging information. * * @param execution * @param response * @return filtered execution result if matching filters were found in execution */ function filterExecutionResult( execution: IExecution, response: CachedResponse | IErrorResponse, ): CachedResponse | IErrorResponse { if (isErrorResponse(response) || !isExecutionResult(response)) { return response; } const dataResult = filterDataResult(execution, buildDataResult(response)); if (isErrorResponse(dataResult)) { return dataResult; } return isEqual(dataResult.data, response.executionResult.data) ? response : buildExecutionResult(response, dataResult); } export default { filterDataResult, filterExecutionResult, };