gooddata-js v13.5.0

File: src/execution/execute-afm.ts

                        // (C) 2007-2020 GoodData Corporation
                        import invariant from "invariant";
                        import qs from "qs";
                        import range from "lodash/range";
                        import get from "lodash/get";
                        import { Execution, AFM } from "@gooddata/typings";
                        
                        import { XhrModule, ApiResponseError } from "../xhr";
                        import { convertExecutionToJson } from "./execute-afm.convert";
                        
                        export const DEFAULT_LIMIT = 1000;
                        
                        /**
                         * This interface represents input for executeVisualization API endpoint.
                         *
                         * NOTE: all functionality related to executeVisualization is experimental and subject to possible breaking changes
                         * in the future; location and shape of this interface WILL change when the functionality is made GA.
                         *
                         * @private
                         * @internal
                         */
                        export interface IVisualizationExecution {
                            visualizationExecution: {
                                reference: string;
                                resultSpec?: AFM.IResultSpec;
                                filters?: AFM.CompatibilityFilter[];
                            };
                        }
                        
                        /**
                         * This interface represents error caused during second part of api execution (data fetching)
                         * and contains information about first execution part if that part was successful.
                         */
                        export class ApiExecutionResponseError extends ApiResponseError {
                            constructor(error: ApiResponseError, public executionResponse: any) {
                                super(error.message, error.response, error.responseBody);
                            }
                        }
                        
                        export class ExecuteAfmModule {
                            constructor(private xhr: XhrModule) {}
                        
                            /**
                             * Execute AFM and fetch all data results
                             *
                             * @method executeAfm
                             * @param {String} projectId - GD project identifier
                             * @param {AFM.IExecution} execution - See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/AFM.ts#L2
                             *
                             * @returns {Promise<Execution.IExecutionResponses>} Structure with `executionResponse` and `executionResult` -
                             *  See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/Execution.ts#L113
                             */
                            public executeAfm(projectId: string, execution: AFM.IExecution): Promise<Execution.IExecutionResponses> {
                                validateNumOfDimensions(get(execution, "execution.resultSpec.dimensions").length);
                                return this.getExecutionResponse(projectId, execution).then(
                                    (executionResponse: Execution.IExecutionResponse) => {
                                        return this.getExecutionResult(executionResponse.links.executionResult)
                                            .then((executionResult: Execution.IExecutionResult | null) => {
                                                return { executionResponse, executionResult };
                                            })
                                            .catch(error => {
                                                throw new ApiExecutionResponseError(error, executionResponse);
                                            });
                                    },
                                );
                            }
                        
                            /**
                             * Execute AFM and return execution's response; the response describes dimensionality of the results and
                             * includes link to poll for the results.
                             *
                             * @method getExecutionResponse
                             * @param {string} projectId - GD project identifier
                             * @param {AFM.IExecution} execution - See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/AFM.ts#L2
                             *
                             * @returns {Promise<Execution.IExecutionResponse>} Promise with `executionResponse`
                             *  See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/Execution.ts#L69
                             */
                            public getExecutionResponse(
                                projectId: string,
                                execution: AFM.IExecution,
                            ): Promise<Execution.IExecutionResponse> {
                                validateNumOfDimensions(get(execution, "execution.resultSpec.dimensions").length);
                                return this.xhr
                                    .post(`/gdc/app/projects/${projectId}/executeAfm`, { body: convertExecutionToJson(execution) })
                                    .then(apiResponse => apiResponse.getData())
                                    .then(unwrapExecutionResponse);
                            }
                        
                            /**
                             * Execute saved visualization and get all data.
                             *
                             * NOTE: all functionality related to executeVisualization is experimental and subject to possible breaking changes
                             * in the future; location and shape of this interface WILL change when the functionality is made GA.
                             *
                             * @param {string} projectId - GD project identifier
                             * @param {IVisualizationExecution} visExecution - execution payload
                             *
                             * @private
                             * @internal
                             */
                            public _executeVisualization(
                                projectId: string,
                                visExecution: IVisualizationExecution,
                            ): Promise<Execution.IExecutionResponses> {
                                // We have ONE-3961 as followup to take this out of experimental mode
                        
                                return this._getVisExecutionResponse(projectId, visExecution).then(
                                    (executionResponse: Execution.IExecutionResponse) => {
                                        return this.getExecutionResult(executionResponse.links.executionResult).then(
                                            (executionResult: Execution.IExecutionResult | null) => {
                                                return { executionResponse, executionResult };
                                            },
                                        );
                                    },
                                );
                            }
                        
                            /**
                             *
                             * Execute visualization and return the response; the response describes dimensionality of the results and
                             * includes link to poll for the results.
                             *
                             * NOTE: all functionality related to executeVisualization is experimental and subject to possible breaking changes
                             * in the future; location and shape of this interface WILL change when the functionality is made GA.
                             *
                             * @param {string} projectId - GD project identifier
                             * @param {IVisualizationExecution} visExecution - execution payload
                             *
                             * @private
                             * @internal
                             */
                            public _getVisExecutionResponse(
                                projectId: string,
                                visExecution: IVisualizationExecution,
                            ): Promise<Execution.IExecutionResponse> {
                                // We have ONE-3961 as followup to take this out of experimental mode
                        
                                const body = createExecuteVisualizationBody(visExecution);
                        
                                return this.xhr
                                    .post(`/gdc/app/projects/${projectId}/executeVisualization`, { body })
                                    .then(apiResponse => apiResponse.getData())
                                    .then(unwrapExecutionResponse);
                            }
                        
                            //
                            // working with results
                            //
                        
                            /**
                             * Get one page of Result from Execution (with requested limit and offset)
                             *
                             * @method getPartialExecutionResult
                             * @param {string} executionResultUri
                             * @param {number[]} limit - limit for each dimension
                             * @param {number[]} offset - offset for each dimension
                             *
                             * @returns {Promise<Execution.IExecutionResult | null>}
                             *  Promise with `executionResult` or `null` (null means empty response - HTTP 204)
                             *  See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/Execution.ts#L88
                             */
                            public getPartialExecutionResult(
                                executionResultUri: string,
                                limit: number[],
                                offset: number[],
                            ): Promise<Execution.IExecutionResult | null> {
                                const executionResultUriQueryPart = getExecutionResultUriQueryPart(executionResultUri);
                                const numOfDimensions = Number(qs.parse(executionResultUriQueryPart).dimensions);
                                validateNumOfDimensions(numOfDimensions);
                        
                                return this.getPage(executionResultUri, limit, offset);
                            }
                        
                            /**
                             * Get whole ExecutionResult
                             *
                             * @method getExecutionResult
                             * @param {string} executionResultUri
                             *
                             * @returns {Promise<Execution.IExecutionResult | null>}
                             *  Promise with `executionResult` or `null` (null means empty response - HTTP 204)
                             *  See https://github.com/gooddata/gooddata-typings/blob/v2.1.0/src/Execution.ts#L88
                             */
                            public getExecutionResult(executionResultUri: string): Promise<Execution.IExecutionResult | null> {
                                const executionResultUriQueryPart = getExecutionResultUriQueryPart(executionResultUri);
                                const numOfDimensions = Number(qs.parse(executionResultUriQueryPart).dimensions);
                                validateNumOfDimensions(numOfDimensions);
                        
                                const limit = Array(numOfDimensions).fill(DEFAULT_LIMIT);
                                const offset = Array(numOfDimensions).fill(0);
                        
                                return this.getAllPages(executionResultUri, limit, offset);
                            }
                        
                            private getPage(
                                executionResultUri: string,
                                limit: number[],
                                offset: number[],
                            ): Promise<Execution.IExecutionResult | null> {
                                return this.fetchExecutionResult(executionResultUri, limit, offset).then(
                                    (executionResultWrapper: Execution.IExecutionResultWrapper | null) => {
                                        return executionResultWrapper ? unwrapExecutionResult(executionResultWrapper) : null;
                                    },
                                );
                            }
                        
                            private getAllPages(
                                executionResultUri: string,
                                limit: number[],
                                offset: number[],
                                prevExecutionResult?: Execution.IExecutionResult,
                            ): Promise<Execution.IExecutionResult | null> {
                                return this.fetchExecutionResult(executionResultUri, limit, offset).then(
                                    (executionResultWrapper: Execution.IExecutionResultWrapper | null) => {
                                        if (!executionResultWrapper) {
                                            return null;
                                        }
                        
                                        const executionResult = unwrapExecutionResult(executionResultWrapper);
                        
                                        const newExecutionResult = prevExecutionResult
                                            ? mergePage(prevExecutionResult, executionResult)
                                            : executionResult;
                        
                                        const { offset, total } = executionResult.paging;
                                        const nextOffset = getNextOffset(limit, offset, total);
                                        const nextLimit = getNextLimit(limit, nextOffset, total);
                        
                                        return nextPageExists(nextOffset, total)
                                            ? this.getAllPages(executionResultUri, nextLimit, nextOffset, newExecutionResult)
                                            : newExecutionResult;
                                    },
                                );
                            }
                        
                            private fetchExecutionResult(
                                executionResultUri: string,
                                limit: number[],
                                offset: number[],
                            ): Promise<Execution.IExecutionResultWrapper | null> {
                                const uri = replaceLimitAndOffsetInUri(executionResultUri, limit, offset);
                        
                                return this.xhr
                                    .get(uri)
                                    .then(apiResponse => (apiResponse.response.status === 204 ? null : apiResponse.getData()));
                            }
                        }
                        
                        function getExecutionResultUriQueryPart(executionResultUri: string): string {
                            return executionResultUri.split(/\?(.+)/)[1];
                        }
                        
                        function unwrapExecutionResponse(
                            executionResponseWrapper: Execution.IExecutionResponseWrapper,
                        ): Execution.IExecutionResponse {
                            return executionResponseWrapper.executionResponse;
                        }
                        
                        function unwrapExecutionResult(
                            executionResultWrapper: Execution.IExecutionResultWrapper,
                        ): Execution.IExecutionResult {
                            return executionResultWrapper.executionResult;
                        }
                        
                        function validateNumOfDimensions(numOfDimensions: number): void {
                            invariant(
                                numOfDimensions === 1 || numOfDimensions === 2,
                                `${numOfDimensions} dimensions are not allowed. Only 1 or 2 dimensions are supported.`,
                            );
                        }
                        
                        function createExecuteVisualizationBody(visExecution: IVisualizationExecution): string {
                            const { reference, resultSpec, filters } = visExecution.visualizationExecution;
                            const resultSpecProp = resultSpec ? { resultSpec } : undefined;
                            const filtersProp = filters ? { filters } : undefined;
                        
                            return JSON.stringify({
                                visualizationExecution: {
                                    reference,
                                    ...resultSpecProp,
                                    ...filtersProp,
                                },
                            });
                        }
                        
                        export function replaceLimitAndOffsetInUri(oldUri: string, limit: number[], offset: number[]): string {
                            const [uriPart, queryPart] = oldUri.split(/\?(.+)/);
                            const query = {
                                ...qs.parse(queryPart),
                                limit: limit.join(","),
                                offset: offset.join(","),
                            };
                        
                            return uriPart + qs.stringify(query, { addQueryPrefix: true });
                        }
                        
                        export function getNextOffset(limit: number[], offset: number[], total: number[]): number[] {
                            const numOfDimensions = total.length;
                            const defaultNextRowsOffset = offset[0] + limit[0];
                        
                            if (numOfDimensions === 1) {
                                return [defaultNextRowsOffset];
                            }
                        
                            const defaultNextColumnsOffset = offset[1] + limit[1];
                            const nextColumnsExist = offset[1] + limit[1] < total[1];
                        
                            const nextRowsOffset = nextColumnsExist
                                ? offset[0] // stay in the same rows
                                : defaultNextRowsOffset; // go to the next rows
                        
                            const nextColumnsOffset = nextColumnsExist
                                ? defaultNextColumnsOffset // next columns for the same rows
                                : 0; // start in the beginning of the next rows
                        
                            return [nextRowsOffset, nextColumnsOffset];
                        }
                        
                        export function getNextLimit(limit: number[], nextOffset: number[], total: number[]): number[] {
                            const numOfDimensions = total.length;
                            validateNumOfDimensions(numOfDimensions);
                        
                            const getSingleNextLimit = (limit: number, nextOffset: number, total: number): number =>
                                nextOffset + limit > total ? total - nextOffset : limit;
                        
                            // prevent set up lower limit than possible for 2nd dimension in the beginning of the next rows
                            if (
                                numOfDimensions === 2 &&
                                nextOffset[1] === 0 && // beginning of the next rows
                                limit[0] < total[1] // limit from 1st dimension should be used in 2nd dimension
                            ) {
                                return [getSingleNextLimit(limit[0], nextOffset[0], total[0]), limit[0]];
                            }
                        
                            return range(numOfDimensions).map((i: number) => getSingleNextLimit(limit[i], nextOffset[i], total[i]));
                        }
                        
                        export function nextPageExists(nextOffset: number[], total: number[]): boolean {
                            // expression "return nextLimit[0] > 0" also returns correct result
                            return nextOffset[0] < total[0];
                        }
                        
                        function mergeHeaderItemsForEachAttribute(
                            dimension: number,
                            headerItems: Execution.IResultHeaderItem[][][] | undefined,
                            result: Execution.IExecutionResult,
                        ) {
                            if (headerItems && result.headerItems) {
                                for (let attrIdx = 0; attrIdx < headerItems[dimension].length; attrIdx += 1) {
                                    result.headerItems[dimension][attrIdx].push(...headerItems[dimension][attrIdx]);
                                }
                            }
                        }
                        
                        // works only for one or two dimensions
                        export function mergePage(
                            prevExecutionResult: Execution.IExecutionResult,
                            executionResult: Execution.IExecutionResult,
                        ): Execution.IExecutionResult {
                            const result = prevExecutionResult;
                            const { headerItems, data, paging } = executionResult;
                        
                            const mergeHeaderItems = (dimension: number) => {
                                // for 1 dimension we already have the headers from first page
                                const otherDimension = dimension === 0 ? 1 : 0;
                                const isEdge = paging.offset[otherDimension] === 0;
                                if (isEdge) {
                                    mergeHeaderItemsForEachAttribute(dimension, headerItems, result);
                                }
                            };
                        
                            // merge data
                            const rowOffset = paging.offset[0];
                            if (result.data[rowOffset]) {
                                // appending columns to existing rows
                                for (let i = 0; i < data.length; i += 1) {
                                    const columns = data[i] as Execution.DataValue[];
                                    const resultData = result.data[i + rowOffset] as Execution.DataValue[];
                                    resultData.push(...columns);
                                }
                            } else {
                                // appending new rows
                                const resultData = result.data as Execution.DataValue[];
                                const currentPageData = data as Execution.DataValue[];
                                resultData.push(...currentPageData);
                            }
                        
                            // merge headerItems
                            if (paging.offset.length > 1) {
                                mergeHeaderItems(0);
                                mergeHeaderItems(1);
                            } else {
                                mergeHeaderItemsForEachAttribute(0, headerItems, result);
                            }
                        
                            // update page count
                            if (paging.offset.length === 1) {
                                result.paging.count = [get(result, "headerItems[0][0]", []).length];
                            }
                            if (paging.offset.length === 2) {
                                result.paging.count = [
                                    get(result, "headerItems[0][0]", []).length,
                                    get(result, "headerItems[1][0]", []).length,
                                ];
                            }
                        
                            return result;
                        }