UNPKG

@gooddata/gooddata-js

Version:
410 lines (357 loc) • 15.7 kB
// (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; }