diff --git a/.dockerignore b/.dockerignore index bfc7f4fbc857e..ad3e938a1db19 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ ** !package.json !tsconfig.base.json +!tsconfig.json !rollup.config.js !yarn.lock !lerna.json diff --git a/jest.base-ts.config.js b/jest.base-ts.config.js new file mode 100644 index 0000000000000..337e832baa952 --- /dev/null +++ b/jest.base-ts.config.js @@ -0,0 +1,16 @@ +const base = require('./jest.base.config'); + +/** @type {import('jest').Config} */ +module.exports = { + ...base, + preset: 'ts-jest', + testMatch: ['/test/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: '../../tsconfig.jest.json' }], + }, + collectCoverageFrom: [ + '/src/**/*.{ts,tsx}', + '!/src/**/*.d.ts', + ] +}; diff --git a/jest.base.config.js b/jest.base.config.js index 915c54622bb45..7c0fafae64cf6 100644 --- a/jest.base.config.js +++ b/jest.base.config.js @@ -16,6 +16,7 @@ module.exports = { '^uuid$': require.resolve('uuid'), '^yaml$': require.resolve('yaml'), }, + setupFiles: ['../../jest.setup.js'], snapshotFormat: { escapeString: true, // To keep existing variant of snapshots printBasicPrototype: true diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000000000..e9d1eac214228 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,5 @@ +const { webcrypto } = require('node:crypto'); + +if (!globalThis.crypto) { + globalThis.crypto = webcrypto; +} diff --git a/package.json b/package.json index 7cc44276cfddb..9b9ac3a3580ea 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ] }, "scripts": { - "build": "rollup -c", + "build": "yarn lerna run build:client-core && rollup -c", "watch": "rollup -c -w", "watch-local": "CUBEJS_API_URL=http://localhost:6020/cubejs-api/v1 rollup -c -w", "lint:npm": "yarn npmPkgJsonLint packages/*/package.json rust/package.json", @@ -26,7 +26,6 @@ }, "author": "Cube Dev, Inc.", "dependencies": { - "@typescript-eslint/eslint-plugin": "^4.17.0", "core-js": "^3.34.0", "lerna": "^8.2.1" }, @@ -44,7 +43,9 @@ "@rollup/plugin-alias": "^3.1.2", "@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-commonjs": "^17.1.0", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^11.2.0", + "@typescript-eslint/eslint-plugin": "^6.12.0", "@types/fs-extra": "^9.0.1", "@types/jest": "^27", "flush-promises": "^1.0.2", @@ -57,6 +58,7 @@ "rimraf": "^3.0.2", "rollup": "2.53.1", "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-tsconfig-paths": "^1.5.2", "typescript": "~5.2.2" }, "repository": { diff --git a/packages/cubejs-api-gateway/test/helpers/prepareAnnotation.test.ts b/packages/cubejs-api-gateway/test/helpers/prepareAnnotation.test.ts index 83156c52c4933..2977fdce0c74c 100644 --- a/packages/cubejs-api-gateway/test/helpers/prepareAnnotation.test.ts +++ b/packages/cubejs-api-gateway/test/helpers/prepareAnnotation.test.ts @@ -6,7 +6,6 @@ /* globals describe,test,expect */ /* eslint-disable import/no-duplicates */ -/* eslint-disable @typescript-eslint/no-duplicate-imports */ import { MemberType } from '../../src/types/enums'; import prepareAnnotationDef diff --git a/packages/cubejs-base-driver/src/BaseDriver.ts b/packages/cubejs-base-driver/src/BaseDriver.ts index d9b5123604c8d..3b7fc4cd83784 100644 --- a/packages/cubejs-base-driver/src/BaseDriver.ts +++ b/packages/cubejs-base-driver/src/BaseDriver.ts @@ -375,9 +375,9 @@ export abstract class BaseDriver implements DriverInterface { return undefined; } - abstract testConnection(): Promise; + public abstract testConnection(): Promise; - abstract query(_query: string, _values?: unknown[], _options?: QueryOptions): Promise; + public abstract query(_query: string, _values?: unknown[], _options?: QueryOptions): Promise; // eslint-disable-next-line @typescript-eslint/no-unused-vars public async streamQuery(sql: string, values: string[]): Promise { diff --git a/packages/cubejs-client-core/.eslintrc.js b/packages/cubejs-client-core/.eslintrc.js deleted file mode 100644 index 341ab49edc8e6..0000000000000 --- a/packages/cubejs-client-core/.eslintrc.js +++ /dev/null @@ -1,39 +0,0 @@ -module.exports = { - extends: 'airbnb-base', - plugins: [ - 'import' - ], - parser: 'babel-eslint', - rules: { - 'max-classes-per-file': 0, - 'prefer-object-spread': 0, - 'import/no-unresolved': 0, - 'comma-dangle': 0, - 'no-console': 0, - 'arrow-parens': 0, - 'import/extensions': 0, - quotes: ['warn', 'single'], - 'no-prototype-builtins': 0, - 'class-methods-use-this': 0, - 'no-param-reassign': 0, - 'no-mixed-operators': 0, - 'no-else-return': 0, - 'prefer-promise-reject-errors': 0, - 'no-plusplus': 0, - 'no-await-in-loop': 0, - 'operator-linebreak': 0, - 'max-len': ['error', 120, 2, { - ignoreUrls: true, - ignoreComments: false, - ignoreRegExpLiterals: true, - ignoreStrings: true, - ignoreTemplateLiterals: true, - }], - 'no-trailing-spaces': ['warn', { skipBlankLines: true }], - 'no-unused-vars': ['warn'], - 'object-curly-newline': 0 - }, - // env: { - // 'jest/globals': true - // } -}; diff --git a/packages/cubejs-client-core/babel.config.js b/packages/cubejs-client-core/babel.config.js deleted file mode 100644 index a494575cb580c..0000000000000 --- a/packages/cubejs-client-core/babel.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - presets: [['@babel/preset-env', {}]], - plugins: ['@babel/plugin-transform-runtime'] -}; diff --git a/packages/cubejs-client-core/index.d.ts b/packages/cubejs-client-core/index.d.ts index cd176f21f7837..e69de29bb2d1d 100644 --- a/packages/cubejs-client-core/index.d.ts +++ b/packages/cubejs-client-core/index.d.ts @@ -1,1354 +0,0 @@ -/** - * @title @cubejs-client/core - * @permalink /@cubejs-client-core - * @menuCategory Reference - * @subcategory Frontend - * @menuOrder 2 - * @description Vanilla JavaScript Cube.js client. - */ - -declare module '@cubejs-client/core' { - export type TransportOptions = { - /** - * [jwt auth token](security) - */ - authorization: string; - /** - * path to `/cubejs-api/v1` - */ - apiUrl: string; - /** - * custom headers - */ - headers?: Record; - credentials?: 'omit' | 'same-origin' | 'include'; - method?: 'GET' | 'PUT' | 'POST' | 'PATCH'; - /** - * Fetch timeout in milliseconds. Would be passed as AbortSignal.timeout() - */ - fetchTimeout?: number; - /** - * AbortSignal to cancel requests - */ - signal?: AbortSignal; - }; - - export interface ITransportResponse { - subscribe: (cb: (result: R, resubscribe: () => Promise) => CBResult) => Promise; - // Optional, supported in WebSocketTransport - unsubscribe?: () => Promise; - } - - export interface ITransport { - request(method: string, params: Record): ITransportResponse; - } - - /** - * Default transport implementation. - * @order 3 - */ - export class HttpTransport implements ITransport { - /** - * @hidden - */ - protected authorization: TransportOptions['authorization']; - /** - * @hidden - */ - protected apiUrl: TransportOptions['apiUrl']; - /** - * @hidden - */ - protected method: TransportOptions['method']; - /** - * @hidden - */ - protected headers: TransportOptions['headers']; - /** - * @hidden - */ - protected credentials: TransportOptions['credentials']; - /** - * @hidden - */ - protected signal?: TransportOptions['signal']; - - constructor(options: TransportOptions); - - public request(method: string, params: any): ITransportResponse; - } - - export type CubeApiOptions = { - /** - * URL of your Cube.js Backend. By default, in the development environment it is `http://localhost:4000/cubejs-api/v1` - */ - apiUrl: string; - /** - * Transport implementation to use. [HttpTransport](#http-transport) will be used by default. - */ - transport?: ITransport; - headers?: Record; - pollInterval?: number; - credentials?: 'omit' | 'same-origin' | 'include'; - parseDateMeasures?: boolean; - resType?: 'default' | 'compact'; - castNumerics?: boolean; - /** - * How many network errors would be retried before returning to users. Default to 0. - */ - networkErrorRetries?: number; - /** - * AbortSignal to cancel requests - */ - signal?: AbortSignal; - }; - - export type LoadMethodOptions = { - /** - * Key to store the current request's MUTEX inside the `mutexObj`. MUTEX object is used to reject orphaned queries results when new queries are sent. For example: if two queries are sent with the same `mutexKey` only the last one will return results. - */ - mutexKey?: string; - /** - * Object to store MUTEX - */ - mutexObj?: Object; - /** - * Pass `true` to use continuous fetch behavior. - */ - subscribe?: boolean; - /** - * A Cube API instance. If not provided will be taken from `CubeProvider` - */ - cubeApi?: CubeApi; - /** - * If enabled, all members of the 'number' type will be automatically converted to numerical values on the client side - */ - castNumerics?: boolean; - /** - * Function that receives `ProgressResult` on each `Continue wait` message. - */ - progressCallback?(result: ProgressResult): void; - /** - * AbortSignal to cancel the request - */ - signal?: AbortSignal; - }; - - export type LoadMethodCallback = (error: Error | null, resultSet: T) => void; - - export type QueryOrder = 'asc' | 'desc'; - - export type TQueryOrderObject = { [key: string]: QueryOrder }; - export type TQueryOrderArray = Array<[string, QueryOrder]>; - - export type Annotation = { - title: string; - shortTitle: string; - type: string; - format?: 'currency' | 'percent' | 'number'; - }; - - export type QueryAnnotations = { - dimensions: Record; - measures: Record; - timeDimensions: Record; - }; - - type PivotQuery = Query & { - queryType: QueryType; - }; - - type QueryType = 'regularQuery' | 'compareDateRangeQuery' | 'blendingQuery'; - - type LeafMeasure = { - measure: string; - additive: boolean; - type: 'count' | 'countDistinct' | 'sum' | 'min' | 'max' | 'number' | 'countDistinctApprox' - }; - - export type TransformedQuery = { - allFiltersWithinSelectedDimensions: boolean; - granularityHierarchies: Record; - hasMultipliedMeasures: boolean; - hasNoTimeDimensionsWithoutGranularity: boolean; - isAdditive: boolean; - leafMeasureAdditive: boolean; - leafMeasures: string[]; - measures: string[]; - sortedDimensions: string[]; - sortedTimeDimensions: [[string, string]]; - measureToLeafMeasures?: Record; - ownedDimensions: string[]; - ownedTimeDimensionsAsIs: [[string, string | null]]; - ownedTimeDimensionsWithRollupGranularity: [[string, string]]; - }; - - export type PreAggregationType = 'rollup' | 'rollupJoin' | 'rollupLambda' | 'originalSql'; - - type UsedPreAggregation = { - targetTableName: string; - type: PreAggregationType; - }; - - type LoadResponseResult = { - annotation: QueryAnnotations; - lastRefreshTime: string; - query: Query; - data: T[]; - external: boolean | null; - dbType: string; - extDbType: string; - requestId?: string; - usedPreAggregations?: Record; - transformedQuery?: TransformedQuery; - total?: number - }; - - export type LoadResponse = { - queryType: QueryType; - results: LoadResponseResult[]; - pivotQuery: PivotQuery; - [key: string]: any; - }; - - /** - * Configuration object that contains information about pivot axes and other options. - * - * Let's apply `pivotConfig` and see how it affects the axes - * ```js - * // Example query - * { - * measures: ['Orders.count'], - * dimensions: ['Users.country', 'Users.gender'] - * } - * ``` - * If we put the `Users.gender` dimension on **y** axis - * ```js - * resultSet.tablePivot({ - * x: ['Users.country'], - * y: ['Users.gender', 'measures'] - * }) - * ``` - * - * The resulting table will look the following way - * - * | Users Country | male, Orders.count | female, Orders.count | - * | ------------- | ------------------ | -------------------- | - * | Australia | 3 | 27 | - * | Germany | 10 | 12 | - * | US | 5 | 7 | - * - * Now let's put the `Users.country` dimension on **y** axis instead - * ```js - * resultSet.tablePivot({ - * x: ['Users.gender'], - * y: ['Users.country', 'measures'], - * }); - * ``` - * - * in this case the `Users.country` values will be laid out on **y** or **columns** axis - * - * | Users Gender | Australia, Orders.count | Germany, Orders.count | US, Orders.count | - * | ------------ | ----------------------- | --------------------- | ---------------- | - * | male | 3 | 10 | 5 | - * | female | 27 | 12 | 7 | - * - * It's also possible to put the `measures` on **x** axis. But in either case it should always be the last item of the array. - * ```js - * resultSet.tablePivot({ - * x: ['Users.gender', 'measures'], - * y: ['Users.country'], - * }); - * ``` - * - * | Users Gender | measures | Australia | Germany | US | - * | ------------ | ------------ | --------- | ------- | --- | - * | male | Orders.count | 3 | 10 | 5 | - * | female | Orders.count | 27 | 12 | 7 | - */ - export type PivotConfig = { - /** - * Dimensions to put on **x** or **rows** axis. - */ - x?: string[]; - /** - * Dimensions to put on **y** or **columns** axis. - */ - y?: string[]; - /** - * If `true` missing dates on the time dimensions will be filled with fillWithValue or `0` by default for all measures.Note: the `fillMissingDates` option set to `true` will override any **order** applied to the query - */ - fillMissingDates?: boolean | null; - /** - * Value to autofill all the missing date's measure. - */ - fillWithValue?: string | number | null; - /** - * Give each series a prefix alias. Should have one entry for each query:measure. See [chartPivot](#result-set-chart-pivot) - */ - aliasSeries?: string[]; - }; - - export type DrillDownLocator = { - xValues: string[]; - yValues?: string[]; - }; - - export type Series = { - key: string; - title: string; - shortTitle: string; - series: T[]; - }; - - export type Column = { - key: string; - title: string; - series: []; - }; - - export type SeriesNamesColumn = { - key: string; - title: string; - shortTitle: string; - yValues: string[]; - }; - - export type ChartPivotRow = { - x: string; - xValues: string[]; - [key: string]: any; - }; - - export type TableColumn = { - key: string; - dataIndex: string; - meta: any; - type: string | number; - title: string; - shortTitle: string; - format?: any; - children?: TableColumn[]; - }; - - export type PivotRow = { - xValues: Array; - yValuesArray: Array<[string[], number]>; - }; - - export type SerializedResult = { - loadResponse: LoadResponse; - }; - - /** - * Provides a convenient interface for data manipulation. - */ - export class ResultSet { - /** - * @hidden - */ - static measureFromAxis(axisValues: string[]): string; - static getNormalizedPivotConfig(query: PivotQuery, pivotConfig?: Partial): PivotConfig; - /** - * ```js - * import { ResultSet } from '@cubejs-client/core'; - * - * const resultSet = await cubeApi.load(query); - * // You can store the result somewhere - * const tmp = resultSet.serialize(); - * - * // and restore it later - * const resultSet = ResultSet.deserialize(tmp); - * ``` - * @param data the result of [serialize](#result-set-serialize) - */ - static deserialize(data: Object, options?: Object): ResultSet; - - /** - * Can be used to stash the `ResultSet` in a storage and restored later with [deserialize](#result-set-deserialize) - */ - serialize(): SerializedResult; - - /** - * Can be used when you need access to the methods that can't be used with some query types (eg `compareDateRangeQuery` or `blendingQuery`) - * ```js - * resultSet.decompose().forEach((currentResultSet) => { - * console.log(currentResultSet.rawData()); - * }); - * ``` - */ - decompose(): ResultSet[]; - - /** - * @hidden - */ - normalizePivotConfig(pivotConfig?: PivotConfig): PivotConfig; - - /** - * Returns a measure drill down query. - * - * Provided you have a measure with the defined `drillMemebers` on the `Orders` cube - * ```js - * measures: { - * count: { - * type: `count`, - * drillMembers: [Orders.status, Users.city, count], - * }, - * // ... - * } - * ``` - * - * Then you can use the `drillDown` method to see the rows that contribute to that metric - * ```js - * resultSet.drillDown( - * { - * xValues, - * yValues, - * }, - * // you should pass the `pivotConfig` if you have used it for axes manipulation - * pivotConfig - * ) - * ``` - * - * the result will be a query with the required filters applied and the dimensions/measures filled out - * ```js - * { - * measures: ['Orders.count'], - * dimensions: ['Orders.status', 'Users.city'], - * filters: [ - * // dimension and measure filters - * ], - * timeDimensions: [ - * //... - * ] - * } - * ``` - * - * In case when you want to add `order` or `limit` to the query, you can simply spread it - * - * ```js - * // An example for React - * const drillDownResponse = useCubeQuery( - * { - * ...drillDownQuery, - * limit: 30, - * order: { - * 'Orders.ts': 'desc' - * } - * }, - * { - * skip: !drillDownQuery - * } - * ); - * ``` - * @returns Drill down query - */ - drillDown(drillDownLocator: DrillDownLocator, pivotConfig?: PivotConfig): Query | null; - - /** - * Returns an array of series with key, title and series data. - * ```js - * // For the query - * { - * measures: ['Stories.count'], - * timeDimensions: [{ - * dimension: 'Stories.time', - * dateRange: ['2015-01-01', '2015-12-31'], - * granularity: 'month' - * }] - * } - * - * // ResultSet.series() will return - * [ - * { - * key: 'Stories.count', - * title: 'Stories Count', - * shortTitle: 'Count', - * series: [ - * { x: '2015-01-01T00:00:00', value: 27120 }, - * { x: '2015-02-01T00:00:00', value: 25861 }, - * { x: '2015-03-01T00:00:00', value: 29661 }, - * //... - * ], - * }, - * ] - * ``` - */ - series(pivotConfig?: PivotConfig): Series[]; - - /** - * Returns an array of series objects, containing `key` and `title` parameters. - * ```js - * // For query - * { - * measures: ['Stories.count'], - * timeDimensions: [{ - * dimension: 'Stories.time', - * dateRange: ['2015-01-01', '2015-12-31'], - * granularity: 'month' - * }] - * } - * - * // ResultSet.seriesNames() will return - * [ - * { - * key: 'Stories.count', - * title: 'Stories Count', - * shortTitle: 'Count', - * yValues: ['Stories.count'], - * }, - * ] - * ``` - * @returns An array of series names - */ - seriesNames(pivotConfig?: PivotConfig): SeriesNamesColumn[]; - - /** - * Base method for pivoting [ResultSet](#result-set) data. - * Most of the times shouldn't be used directly and [chartPivot](#result-set-chart-pivot) - * or [tablePivot](#table-pivot) should be used instead. - * - * You can find the examples of using the `pivotConfig` [here](#types-pivot-config) - * ```js - * // For query - * { - * measures: ['Stories.count'], - * timeDimensions: [{ - * dimension: 'Stories.time', - * dateRange: ['2015-01-01', '2015-03-31'], - * granularity: 'month' - * }] - * } - * - * // ResultSet.pivot({ x: ['Stories.time'], y: ['measures'] }) will return - * [ - * { - * xValues: ["2015-01-01T00:00:00"], - * yValuesArray: [ - * [['Stories.count'], 27120] - * ] - * }, - * { - * xValues: ["2015-02-01T00:00:00"], - * yValuesArray: [ - * [['Stories.count'], 25861] - * ] - * }, - * { - * xValues: ["2015-03-01T00:00:00"], - * yValuesArray: [ - * [['Stories.count'], 29661] - * ] - * } - * ] - * ``` - * @returns An array of pivoted rows. - */ - pivot(pivotConfig?: PivotConfig): PivotRow[]; - - /** - * Returns normalized query result data in the following format. - * - * You can find the examples of using the `pivotConfig` [here](#types-pivot-config) - * ```js - * // For the query - * { - * measures: ['Stories.count'], - * timeDimensions: [{ - * dimension: 'Stories.time', - * dateRange: ['2015-01-01', '2015-12-31'], - * granularity: 'month' - * }] - * } - * - * // ResultSet.chartPivot() will return - * [ - * { "x":"2015-01-01T00:00:00", "Stories.count": 27120, "xValues": ["2015-01-01T00:00:00"] }, - * { "x":"2015-02-01T00:00:00", "Stories.count": 25861, "xValues": ["2015-02-01T00:00:00"] }, - * { "x":"2015-03-01T00:00:00", "Stories.count": 29661, "xValues": ["2015-03-01T00:00:00"] }, - * //... - * ] - * - * ``` - * When using `chartPivot()` or `seriesNames()`, you can pass `aliasSeries` in the [pivotConfig](#types-pivot-config) - * to give each series a unique prefix. This is useful for `blending queries` which use the same measure multiple times. - * - * ```js - * // For the queries - * { - * measures: ['Stories.count'], - * timeDimensions: [ - * { - * dimension: 'Stories.time', - * dateRange: ['2015-01-01', '2015-12-31'], - * granularity: 'month', - * }, - * ], - * }, - * { - * measures: ['Stories.count'], - * timeDimensions: [ - * { - * dimension: 'Stories.time', - * dateRange: ['2015-01-01', '2015-12-31'], - * granularity: 'month', - * }, - * ], - * filters: [ - * { - * member: 'Stores.read', - * operator: 'equals', - * value: ['true'], - * }, - * ], - * }, - * - * // ResultSet.chartPivot({ aliasSeries: ['one', 'two'] }) will return - * [ - * { - * x: '2015-01-01T00:00:00', - * 'one,Stories.count': 27120, - * 'two,Stories.count': 8933, - * xValues: ['2015-01-01T00:00:00'], - * }, - * { - * x: '2015-02-01T00:00:00', - * 'one,Stories.count': 25861, - * 'two,Stories.count': 8344, - * xValues: ['2015-02-01T00:00:00'], - * }, - * { - * x: '2015-03-01T00:00:00', - * 'one,Stories.count': 29661, - * 'two,Stories.count': 9023, - * xValues: ['2015-03-01T00:00:00'], - * }, - * //... - * ]; - * ``` - */ - chartPivot(pivotConfig?: PivotConfig): ChartPivotRow[]; - - /** - * Returns normalized query result data prepared for visualization in the table format. - * - * You can find the examples of using the `pivotConfig` [here](#types-pivot-config) - * - * For example: - * ```js - * // For the query - * { - * measures: ['Stories.count'], - * timeDimensions: [{ - * dimension: 'Stories.time', - * dateRange: ['2015-01-01', '2015-12-31'], - * granularity: 'month' - * }] - * } - * - * // ResultSet.tablePivot() will return - * [ - * { "Stories.time": "2015-01-01T00:00:00", "Stories.count": 27120 }, - * { "Stories.time": "2015-02-01T00:00:00", "Stories.count": 25861 }, - * { "Stories.time": "2015-03-01T00:00:00", "Stories.count": 29661 }, - * //... - * ] - * ``` - * @returns An array of pivoted rows - */ - tablePivot(pivotConfig?: PivotConfig): Array<{ [key: string]: string | number | boolean }>; - - /** - * Returns an array of column definitions for `tablePivot`. - * - * For example: - * ```js - * // For the query - * { - * measures: ['Stories.count'], - * timeDimensions: [{ - * dimension: 'Stories.time', - * dateRange: ['2015-01-01', '2015-12-31'], - * granularity: 'month' - * }] - * } - * - * // ResultSet.tableColumns() will return - * [ - * { - * key: 'Stories.time', - * dataIndex: 'Stories.time', - * title: 'Stories Time', - * shortTitle: 'Time', - * type: 'time', - * format: undefined, - * }, - * { - * key: 'Stories.count', - * dataIndex: 'Stories.count', - * title: 'Stories Count', - * shortTitle: 'Count', - * type: 'count', - * format: undefined, - * }, - * //... - * ] - * ``` - * - * In case we want to pivot the table axes - * ```js - * // Let's take this query as an example - * { - * measures: ['Orders.count'], - * dimensions: ['Users.country', 'Users.gender'] - * } - * - * // and put the dimensions on `y` axis - * resultSet.tableColumns({ - * x: [], - * y: ['Users.country', 'Users.gender', 'measures'] - * }) - * ``` - * - * then `tableColumns` will group the table head and return - * ```js - * { - * key: 'Germany', - * type: 'string', - * title: 'Users Country Germany', - * shortTitle: 'Germany', - * meta: undefined, - * format: undefined, - * children: [ - * { - * key: 'male', - * type: 'string', - * title: 'Users Gender male', - * shortTitle: 'male', - * meta: undefined, - * format: undefined, - * children: [ - * { - * // ... - * dataIndex: 'Germany.male.Orders.count', - * shortTitle: 'Count', - * }, - * ], - * }, - * { - * // ... - * shortTitle: 'female', - * children: [ - * { - * // ... - * dataIndex: 'Germany.female.Orders.count', - * shortTitle: 'Count', - * }, - * ], - * }, - * ], - * }, - * // ... - * ``` - * @returns An array of columns - */ - tableColumns(pivotConfig?: PivotConfig): TableColumn[]; - totalRow(pivotConfig?: PivotConfig): ChartPivotRow; - categories(pivotConfig?: PivotConfig): ChartPivotRow[]; - - tableRow(): ChartPivotRow; - query(): Query; - rawData(): T[]; - annotation(): QueryAnnotations; - - /** - * @return the total number of rows if the `total` option was set, when sending the query - */ - totalRows(): number | null; - } - - export type Filter = BinaryFilter | UnaryFilter | LogicalOrFilter | LogicalAndFilter; - export type LogicalAndFilter = { - and: Filter[]; - }; - - export type LogicalOrFilter = { - or: Filter[]; - }; - - export interface BinaryFilter { - /** - * @deprecated Use `member` instead. - */ - dimension?: string; - member?: string; - operator: BinaryOperator; - values: string[]; - } - export interface UnaryFilter { - /** - * @deprecated Use `member` instead. - */ - dimension?: string; - member?: string; - operator: UnaryOperator; - values?: never; - } - export type UnaryOperator = 'set' | 'notSet'; - export type BinaryOperator = - | 'equals' - | 'notEquals' - | 'contains' - | 'notContains' - | 'startsWith' - | 'notStartsWith' - | 'endsWith' - | 'notEndsWith' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'inDateRange' - | 'notInDateRange' - | 'beforeDate' - | 'beforeOrOnDate' - | 'afterDate' - | 'afterOrOnDate'; - - export type TimeDimensionPredefinedGranularity = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; - - export type TimeDimensionGranularity = TimeDimensionPredefinedGranularity | string; - - export type DateRange = string | [string, string]; - - export interface TimeDimensionBase { - dimension: string; - granularity?: TimeDimensionGranularity; - } - - type TimeDimensionComparisonFields = { - compareDateRange: Array; - dateRange?: never; - }; - export type TimeDimensionComparison = TimeDimensionBase & TimeDimensionComparisonFields; - - type TimeDimensionRangedFields = { - dateRange?: DateRange; - }; - export type TimeDimensionRanged = TimeDimensionBase & TimeDimensionRangedFields; - - export type TimeDimension = TimeDimensionComparison | TimeDimensionRanged; - - type DeeplyReadonly = { - readonly [K in keyof T]: DeeplyReadonly; - }; - - export interface Query { - measures?: string[]; - dimensions?: string[]; - filters?: Filter[]; - timeDimensions?: TimeDimension[]; - segments?: string[]; - limit?: null | number; - offset?: number; - order?: TQueryOrderObject | TQueryOrderArray; - timezone?: string; - renewQuery?: boolean; - ungrouped?: boolean; - responseFormat?: 'compact' | 'default'; - total?: boolean; - } - - export type QueryRecordType> = - T extends DeeplyReadonly ? QueryArrayRecordType : - T extends DeeplyReadonly ? SingleQueryRecordType : - never; - - type QueryArrayRecordType> = - T extends readonly [infer First, ...infer Rest] - ? SingleQueryRecordType> | QueryArrayRecordType> - : never; - - // If we can't infer any members at all, then return any. - type SingleQueryRecordType> = ExtractMembers extends never - ? any - : { [K in string & ExtractMembers]: string | number | boolean | null }; - - type ExtractMembers> = - | (T extends { dimensions: readonly (infer Names)[]; } ? Names : never) - | (T extends { measures: readonly (infer Names)[]; } ? Names : never) - | (T extends { timeDimensions: (infer U); } ? ExtractTimeMembers : never); - - type ExtractTimeMembers = - T extends readonly [infer First, ...infer Rest] - ? ExtractTimeMember | ExtractTimeMembers - : never; - - type ExtractTimeMember = - T extends { dimension: infer Dimension, granularity: infer Granularity } - ? Dimension | `${Dimension & string}.${Granularity & string}` - : never; - - export class ProgressResult { - stage(): string; - timeElapsed(): string; - } - - export interface UnsubscribeObj { - /** - * Allows to stop requests in-flight in long polling or web socket subscribe loops. - * It doesn't cancel any submitted requests to the underlying databases. - */ - unsubscribe(): Promise; - } - - export type SqlQueryTuple = [string, any[], any]; - - export type SqlData = { - aliasNameToMember: Record; - cacheKeyQueries: SqlQueryTuple[]; - dataSource: boolean; - external: boolean; - sql: SqlQueryTuple; - preAggregations: any[]; - rollupMatchResults: any[]; - }; - - export class SqlQuery { - rawQuery(): SqlData; - sql(): string; - } - - export type MemberType = 'measures' | 'dimensions' | 'segments'; - - type TOrderMember = { - id: string; - title: string; - order: QueryOrder | 'none'; - }; - - type TCubeMemberType = 'time' | 'number' | 'string' | 'boolean'; - - // @see BaseCubeMember - // @deprecated - export type TCubeMember = { - type: TCubeMemberType; - name: string; - title: string; - shortTitle: string; - description?: string; - /** - * @deprecated use `public` instead - */ - isVisible?: boolean; - public?: boolean; - meta?: any; - }; - - export type BaseCubeMember = { - type: TCubeMemberType; - name: string; - title: string; - shortTitle: string; - description?: string; - /** - * @deprecated use `public` instead - */ - isVisible?: boolean; - public?: boolean; - meta?: any; - }; - - export type TCubeMeasure = BaseCubeMember & { - aggType: 'count' | 'number'; - cumulative: boolean; - cumulativeTotal: boolean; - drillMembers: string[]; - drillMembersGrouped: { - measures: string[]; - dimensions: string[]; - }; - format?: 'currency' | 'percent'; - }; - - export type CubeTimeDimensionGranularity = { - name: string; - title: string; - } - - export type BaseCubeDimension = BaseCubeMember & { - primaryKey?: boolean; - suggestFilterValues: boolean; - } - - export type CubeTimeDimension = BaseCubeDimension & - { type: 'time'; granularities?: CubeTimeDimensionGranularity[] }; - - export type TCubeDimension = - (BaseCubeDimension & { type: Exclude }) | - CubeTimeDimension; - - export type TCubeSegment = Omit; - - type TCubeMemberByType = T extends 'measures' - ? TCubeMeasure - : T extends 'dimensions' - ? TCubeDimension - : T extends 'segments' - ? TCubeSegment - : never; - - export type CubeMember = TCubeMeasure | TCubeDimension | TCubeSegment; - - export type TCubeFolder = { - name: string; - members: string[]; - }; - - export type TCubeHierarchy = { - name: string; - title?: string; - levels: string[]; - public?: boolean; - }; - - /** - * @deprecated use DryRunResponse - */ - type TDryRunResponse = { - queryType: QueryType; - normalizedQueries: Query[]; - pivotQuery: PivotQuery; - queryOrder: Array<{ [k: string]: QueryOrder }>; - transformedQueries: TransformedQuery[]; - }; - - export type DryRunResponse = { - queryType: QueryType; - normalizedQueries: Query[]; - pivotQuery: PivotQuery; - queryOrder: Array<{ [k: string]: QueryOrder }>; - transformedQueries: TransformedQuery[]; - }; - - export type Cube = { - name: string; - title: string; - description?: string; - measures: TCubeMeasure[]; - dimensions: TCubeDimension[]; - segments: TCubeSegment[]; - folders: TCubeFolder[]; - hierarchies: TCubeHierarchy[]; - connectedComponent?: number; - type?: 'view' | 'cube'; - /** - * @deprecated use `public` instead - */ - isVisible?: boolean; - public?: boolean; - meta?: any; - }; - - - export type CubeMap = { - measures: Record; - dimensions: Record; - segments: Record; - }; - - export type CubesMap = Record< - string, - CubeMap - >; - - export type MetaResponse = { - cubes: Cube[]; - }; - - type FilterOperator = { - name: string; - title: string; - }; - - /** - * Contains information about available cubes and it's members. - * @order 4 - */ - export class Meta { - - constructor(metaResponse: MetaResponse); - - /** - * Raw meta response - */ - meta: MetaResponse; - - /** - * An array of all available cubes with their members - */ - cubes: Cube[]; - - /** - * A map of all cubes where the key is a cube name - */ - cubesMap: CubesMap; - - /** - * Get all members of a specific type for a given query. - * If empty query is provided no filtering is done based on query context and all available members are retrieved. - * @param query - context query to provide filtering of members available to add to this query - */ - membersForQuery(query: DeeplyReadonly | null, memberType: MemberType): TCubeMeasure[] | TCubeDimension[] | TCubeMember[]; - - /** - * Get meta information for a cube member - * Member meta information contains: - * ```javascript - * { - * name, - * title, - * shortTitle, - * type, - * description, - * format - * } - * ``` - * @param memberName - Fully qualified member name in a form `Cube.memberName` - * @return An object containing meta information about member - */ - resolveMember( - memberName: string, - memberType: T | T[] - ): { title: string; error: string } | TCubeMemberByType; - defaultTimeDimensionNameFor(memberName: string): string; - filterOperatorsForMember(memberName: string, memberType: MemberType | MemberType[]): FilterOperator[]; - - // todo: types - membersGroupedByCube(): any; - } - - /** - * Main class for accessing Cube API - * - * @order 2 - */ - export class CubeApi { - load>( - query: QueryType, - options?: LoadMethodOptions, - ): Promise>>; - /** - * Fetch data for the passed `query`. - * - * ```js - * import cube from '@cubejs-client/core'; - * import Chart from 'chart.js'; - * import chartjsConfig from './toChartjsData'; - * - * const cubeApi = cube('CUBEJS_TOKEN'); - * - * const resultSet = await cubeApi.load({ - * measures: ['Stories.count'], - * timeDimensions: [{ - * dimension: 'Stories.time', - * dateRange: ['2015-01-01', '2015-12-31'], - * granularity: 'month' - * }] - * }); - * - * const context = document.getElementById('myChart'); - * new Chart(context, chartjsConfig(resultSet)); - * ``` - * @param query - [Query object](/product/apis-integrations/rest-api/query-format) - */ - load>( - query: QueryType, - options?: LoadMethodOptions, - callback?: LoadMethodCallback>>, - ): UnsubscribeObj; - - load>( - query: QueryType, - options?: LoadMethodOptions, - callback?: LoadMethodCallback, - responseFormat?: string - ): Promise>>; - - /** - * Allows you to fetch data and receive updates over time. See [Real-Time Data Fetch](/product/apis-integrations/rest-api/real-time-data-fetch) - * - * ```js - * // Subscribe to a query's updates - * const subscription = await cubeApi.subscribe( - * { - * measures: ['Logs.count'], - * timeDimensions: [ - * { - * dimension: 'Logs.time', - * granularity: 'hour', - * dateRange: 'last 1440 minutes', - * }, - * ], - * }, - * options, - * (error, resultSet) => { - * if (!error) { - * // handle the update - * } - * } - * ); - * - * // Unsubscribe from a query's updates - * subscription.unsubscribe(); - * ``` - */ - subscribe>( - query: QueryType, - options: LoadMethodOptions | null, - callback: LoadMethodCallback>>, - ): UnsubscribeObj; - - sql(query: DeeplyReadonly, options?: LoadMethodOptions): Promise; - /** - * Get generated SQL string for the given `query`. - * @param query - [Query object](query-format) - */ - sql(query: DeeplyReadonly, options?: LoadMethodOptions, callback?: LoadMethodCallback): UnsubscribeObj; - - meta(options?: LoadMethodOptions): Promise; - /** - * Get meta description of cubes available for querying. - */ - meta(options?: LoadMethodOptions, callback?: LoadMethodCallback): UnsubscribeObj; - - dryRun(query: DeeplyReadonly, options?: LoadMethodOptions): Promise; - /** - * Get query related meta without query execution - */ - dryRun(query: DeeplyReadonly, options: LoadMethodOptions, callback?: LoadMethodCallback): UnsubscribeObj; - } - - /** - * Creates an instance of the `CubeApi`. The API entry point. - * - * ```js - * import cube from '@cubejs-client/core'; - * const cubeApi = cube( - * 'CUBE-API-TOKEN', - * { apiUrl: 'http://localhost:4000/cubejs-api/v1' } - * ); - * ``` - * - * You can also pass an async function or a promise that will resolve to the API token - * - * ```js - * import cube from '@cubejs-client/core'; - * const cubeApi = cube( - * async () => await Auth.getJwtToken(), - * { apiUrl: 'http://localhost:4000/cubejs-api/v1' } - * ); - * ``` - * - * @param apiToken - [API token](/product/auth) is used to authorize requests and determine SQL database you're accessing. In the development mode, Cube.js Backend will print the API token to the console on startup. In case of async function `authorization` is updated for `options.transport` on each request. - * @order 1 - */ - export default function cube(apiToken: string | (() => Promise), options: CubeApiOptions): CubeApi; - export default function cube(options: CubeApiOptions): CubeApi; - - /** - * @hidden - */ - export type TSourceAxis = 'x' | 'y'; - - export type ChartType = 'line' | 'bar' | 'table' | 'area' | 'number' | 'pie'; - - export type TDefaultHeuristicsOptions = { - meta: Meta; - sessionGranularity?: TimeDimensionGranularity; - }; - - export type TDefaultHeuristicsResponse = { - shouldApplyHeuristicOrder: boolean; - pivotConfig: PivotConfig | null; - query: Query; - chartType?: ChartType; - }; - - export type TDefaultHeuristicsState = { - query: Query; - chartType?: ChartType; - }; - - export function defaultHeuristics( - newState: TDefaultHeuristicsState, - oldQuery: Query, - options: TDefaultHeuristicsOptions - ): TDefaultHeuristicsResponse; - /** - * @hidden - */ - export function isQueryPresent(query: DeeplyReadonly | null | undefined): boolean; - export function movePivotItem( - pivotConfig: PivotConfig, - sourceIndex: number, - destinationIndex: number, - sourceAxis: TSourceAxis, - destinationAxis: TSourceAxis - ): PivotConfig; - /** - * @hidden - */ - export function moveItemInArray(list: T[], sourceIndex: number, destinationIndex: number): T[]; - - export function defaultOrder(query: DeeplyReadonly): { [key: string]: QueryOrder }; - - export interface TFlatFilter { - /** - * @deprecated Use `member` instead. - */ - dimension?: string; - member?: string; - operator: BinaryOperator; - values: string[]; - } - - /** - * @hidden - */ - export function flattenFilters(filters: Filter[]): TFlatFilter[]; - - type TGranularityMap = { - name: TimeDimensionGranularity | undefined; - title: string; - }; - - /** - * @hidden - */ - export function getOrderMembersFromOrder( - orderMembers: any, - order: TQueryOrderObject | TQueryOrderArray - ): TOrderMember[]; - - export const GRANULARITIES: TGranularityMap[]; - /** - * @hidden - */ - export function getQueryMembers(query: DeeplyReadonly): string[]; - - export function areQueriesEqual(query1: DeeplyReadonly | null, query2: DeeplyReadonly | null): boolean; - - export function validateQuery(query: DeeplyReadonly | null | undefined): Query; - - export type ProgressResponse = { - stage: string; - timeElapsed: number; - }; - - export function granularityFor(dateStr: string): string; - - export function minGranularityForIntervals(i1: string, i2: string): string; - - export function isPredefinedGranularity(granularity: TimeDimensionGranularity): boolean; -} diff --git a/packages/cubejs-client-core/jest.config.js b/packages/cubejs-client-core/jest.config.js index e8878181f33d5..d2dfbb31e44eb 100644 --- a/packages/cubejs-client-core/jest.config.js +++ b/packages/cubejs-client-core/jest.config.js @@ -1,10 +1,11 @@ -const base = require('../../jest.base.config'); +const base = require('../../jest.base-ts.config'); /** @type {import('jest').Config} */ module.exports = { ...base, rootDir: '.', - transform: { - '^.+\\.js$': 'babel-jest', - }, + collectCoverageFrom: [ + ...base.collectCoverageFrom, + '!/src/index.umd.ts', + ], }; diff --git a/packages/cubejs-client-core/package.json b/packages/cubejs-client-core/package.json index d605066473c08..e9267419aa519 100644 --- a/packages/cubejs-client-core/package.json +++ b/packages/cubejs-client-core/package.json @@ -7,42 +7,47 @@ "url": "https://github.com/cube-js/cube.git", "directory": "packages/cubejs-client-core" }, - "description": "cube.js client", - "main": "dist/cubejs-client-core.js", - "module": "dist/cubejs-client-core.esm.js", - "types": "index.d.ts", + "description": "Cube client", + "main": "dist/cubejs-client-core.cjs.js", + "browser": "dist/cubejs-client-core.umd.js", + "typings": "dist/src/index.d.ts", "author": "Cube Dev, Inc.", + "exports": { + "import": "./dist/src/index.js", + "require": "./dist/cubejs-client-core.cjs.js" + }, "dependencies": { - "@babel/runtime": "^7.1.2", "core-js": "^3.6.5", "cross-fetch": "^3.0.2", "dayjs": "^1.10.4", "ramda": "^0.27.2", "url-search-params-polyfill": "^7.0.0", - "uuid": "^8.3.2" + "uuid": "^11.1.0" }, "scripts": { + "build:client-core": "rm -rf dist && npm run tsc", + "tsc": "tsc", + "watch": "tsc -w", "test": "npm run unit", - "unit": "jest", - "lint": "eslint src/*.js", - "lint:fix": "eslint --fix src/*.js" + "unit": "jest --coverage", + "lint": "eslint src/* test/ --ext .ts,.js", + "lint:fix": "eslint --fix src/* test/ --ext .ts,js" }, "files": [ - "src", - "dist", - "index.d.ts" + "dist" ], "license": "MIT", "devDependencies": { - "@babel/core": "^7.3.3", - "@babel/preset-env": "^7.3.1", + "@cubejs-backend/linter": "1.3.15", "@types/jest": "^29", "@types/moment-range": "^4.0.0", + "@types/ramda": "^0.27.34", "babel-jest": "^29", - "eslint": "^7.21.0", - "eslint-config-airbnb-base": "^13.1.0", - "eslint-plugin-import": "^2.16.0", - "eslint-plugin-node": "^10.0.0", - "jest": "^29" + "jest": "^29", + "ts-jest": "^29", + "typescript": "~5.2.2" + }, + "eslintConfig": { + "extends": "../cubejs-linter" } } diff --git a/packages/cubejs-client-core/src/HttpTransport.js b/packages/cubejs-client-core/src/HttpTransport.js deleted file mode 100644 index 91dd8b9a1e1f4..0000000000000 --- a/packages/cubejs-client-core/src/HttpTransport.js +++ /dev/null @@ -1,61 +0,0 @@ -import fetch from 'cross-fetch'; -import 'url-search-params-polyfill'; - -class HttpTransport { - constructor({ authorization, apiUrl, method, headers = {}, credentials, fetchTimeout, signal }) { - this.authorization = authorization; - this.apiUrl = apiUrl; - this.method = method; - this.headers = headers; - this.credentials = credentials; - this.fetchTimeout = fetchTimeout; - this.signal = signal; - } - - request(method, { baseRequestId, signal, ...params }) { - let spanCounter = 1; - const searchParams = new URLSearchParams( - params && Object.keys(params) - .map(k => ({ [k]: typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k] })) - .reduce((a, b) => ({ ...a, ...b }), {}) - ); - - let url = `${this.apiUrl}/${method}${searchParams.toString().length ? `?${searchParams}` : ''}`; - - const requestMethod = this.method || (url.length < 2000 ? 'GET' : 'POST'); - if (requestMethod === 'POST') { - url = `${this.apiUrl}/${method}`; - this.headers['Content-Type'] = 'application/json'; - } - - // Currently, all methods make GET requests. If a method makes a request with a body payload, - // remember to add {'Content-Type': 'application/json'} to the header. - const runRequest = () => fetch(url, { - method: requestMethod, - headers: { - Authorization: this.authorization, - 'x-request-id': baseRequestId && `${baseRequestId}-span-${spanCounter++}`, - ...this.headers - }, - credentials: this.credentials, - body: requestMethod === 'POST' ? JSON.stringify(params) : null, - signal: signal || this.signal || (this.fetchTimeout ? AbortSignal.timeout(this.fetchTimeout) : undefined), - }); - - return { - /* eslint no-unsafe-finally: off */ - async subscribe(callback) { - let result = { - error: 'network Error' // add default error message - }; - try { - result = await runRequest(); - } finally { - return callback(result, () => this.subscribe(callback)); - } - } - }; - } -} - -export default HttpTransport; diff --git a/packages/cubejs-client-core/src/HttpTransport.ts b/packages/cubejs-client-core/src/HttpTransport.ts new file mode 100644 index 0000000000000..decb7bdfd7731 --- /dev/null +++ b/packages/cubejs-client-core/src/HttpTransport.ts @@ -0,0 +1,117 @@ +import fetch from 'cross-fetch'; +import 'url-search-params-polyfill'; + +export interface ErrorResponse { + error: string; +} + +export type TransportOptions = { + /** + * [jwt auth token](security) + */ + authorization?: string; + /** + * path to `/cubejs-api/v1` + */ + apiUrl: string; + /** + * custom headers + */ + headers: Record; + credentials?: 'omit' | 'same-origin' | 'include'; + method?: 'GET' | 'PUT' | 'POST' | 'PATCH'; + /** + * Fetch timeout in milliseconds. Would be passed as AbortSignal.timeout() + */ + fetchTimeout?: number; + /** + * AbortSignal to cancel requests + */ + signal?: AbortSignal; +}; + +export interface ITransportResponse { + subscribe: (cb: (result: R | ErrorResponse, resubscribe: () => Promise) => CBResult) => Promise; + // Optional, supported in WebSocketTransport + unsubscribe?: () => Promise; +} + +export interface ITransport { + request(method: string, params: Record): ITransportResponse; + authorization: TransportOptions['authorization']; +} + +/** + * Default transport implementation. + */ +export class HttpTransport implements ITransport { + public authorization: TransportOptions['authorization']; + + protected apiUrl: TransportOptions['apiUrl']; + + protected method: TransportOptions['method']; + + protected headers: TransportOptions['headers']; + + protected credentials: TransportOptions['credentials']; + + protected fetchTimeout: number | undefined; + + private readonly signal: AbortSignal | undefined; + + public constructor({ authorization, apiUrl, method, headers = {}, credentials, fetchTimeout, signal }: Omit & { headers?: TransportOptions['headers'] }) { + this.authorization = authorization; + this.apiUrl = apiUrl; + this.method = method; + this.headers = headers; + this.credentials = credentials; + this.fetchTimeout = fetchTimeout; + this.signal = signal; + } + + public request(method: string, { baseRequestId, signal, ...params }: any): ITransportResponse { + let spanCounter = 1; + const searchParams = new URLSearchParams( + params && Object.keys(params) + .map(k => ({ [k]: typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k] })) + .reduce((a, b) => ({ ...a, ...b }), {}) + ); + + let url = `${this.apiUrl}/${method}${searchParams.toString().length ? `?${searchParams}` : ''}`; + + const requestMethod = this.method || (url.length < 2000 ? 'GET' : 'POST'); + if (requestMethod === 'POST') { + url = `${this.apiUrl}/${method}`; + this.headers['Content-Type'] = 'application/json'; + } + + // Currently, all methods make GET requests. If a method makes a request with a body payload, + // remember to add {'Content-Type': 'application/json'} to the header. + const runRequest = () => fetch(url, { + method: requestMethod, + headers: { + Authorization: this.authorization, + 'x-request-id': baseRequestId && `${baseRequestId}-span-${spanCounter++}`, + ...this.headers + } as HeadersInit, + credentials: this.credentials, + body: requestMethod === 'POST' ? JSON.stringify(params) : null, + signal: signal || this.signal || (this.fetchTimeout ? AbortSignal.timeout(this.fetchTimeout) : undefined), + }); + + return { + /* eslint no-unsafe-finally: off */ + async subscribe(callback) { + try { + const result = await runRequest(); + return callback(result, () => this.subscribe(callback)); + } catch (e) { + const result: ErrorResponse = { error: 'network Error' }; + return callback(result, () => this.subscribe(callback)); + } + } + }; + } +} + +export default HttpTransport; diff --git a/packages/cubejs-client-core/src/Meta.js b/packages/cubejs-client-core/src/Meta.js deleted file mode 100644 index 38a7408877e03..0000000000000 --- a/packages/cubejs-client-core/src/Meta.js +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @module @cubejs-client/core - */ - -import { unnest, fromPairs } from 'ramda'; - -const memberMap = (memberArray) => fromPairs(memberArray.map((m) => [m.name, m])); - -const operators = { - string: [ - { name: 'contains', title: 'contains' }, - { name: 'notContains', title: 'does not contain' }, - { name: 'equals', title: 'equals' }, - { name: 'notEquals', title: 'does not equal' }, - { name: 'set', title: 'is set' }, - { name: 'notSet', title: 'is not set' }, - { name: 'startsWith', title: 'starts with' }, - { name: 'notStartsWith', title: 'does not start with' }, - { name: 'endsWith', title: 'ends with' }, - { name: 'notEndsWith', title: 'does not end with' }, - ], - number: [ - { name: 'equals', title: 'equals' }, - { name: 'notEquals', title: 'does not equal' }, - { name: 'set', title: 'is set' }, - { name: 'notSet', title: 'is not set' }, - { name: 'gt', title: '>' }, - { name: 'gte', title: '>=' }, - { name: 'lt', title: '<' }, - { name: 'lte', title: '<=' }, - ], - time: [ - { name: 'equals', title: 'equals' }, - { name: 'notEquals', title: 'does not equal' }, - { name: 'inDateRange', title: 'in date range' }, - { name: 'notInDateRange', title: 'not in date range' }, - { name: 'afterDate', title: 'after date' }, - { name: 'afterOrOnDate', title: 'after or on date' }, - { name: 'beforeDate', title: 'before date' }, - { name: 'beforeOrOnDate', title: 'before or on date' }, - ], -}; - -/** - * Contains information about available cubes and it's members. - */ -class Meta { - constructor(metaResponse) { - this.meta = metaResponse; - const { cubes } = this.meta; - this.cubes = cubes; - this.cubesMap = fromPairs( - cubes.map((c) => [ - c.name, - { - measures: memberMap(c.measures), - dimensions: memberMap(c.dimensions), - segments: memberMap(c.segments), - }, - ]) - ); - } - - membersForQuery(query, memberType) { - return unnest(this.cubes.map((c) => c[memberType])).sort((a, b) => (a.title > b.title ? 1 : -1)); - } - - membersGroupedByCube() { - const memberKeys = ['measures', 'dimensions', 'segments', 'timeDimensions']; - - return this.cubes.reduce( - (memo, cube) => { - memberKeys.forEach((key) => { - let members = cube[key]; - - if (key === 'timeDimensions') { - members = cube.dimensions.filter((m) => m.type === 'time'); - } - - memo[key] = [ - ...memo[key], - { - cubeName: cube.name, - cubeTitle: cube.title, - type: cube.type, - public: cube.public, - members - }, - ]; - }); - - return memo; - }, - { - measures: [], - dimensions: [], - segments: [], - timeDimensions: [], - } - ); - } - - resolveMember(memberName, memberType) { - const [cube] = memberName.split('.'); - - if (!this.cubesMap[cube]) { - return { title: memberName, error: `Cube not found ${cube} for path '${memberName}'` }; - } - - const memberTypes = Array.isArray(memberType) ? memberType : [memberType]; - const member = memberTypes - .map((type) => this.cubesMap[cube][type] && this.cubesMap[cube][type][memberName]) - .find((m) => m); - - if (!member) { - return { - title: memberName, - error: `Path not found '${memberName}'`, - }; - } - - return member; - } - - defaultTimeDimensionNameFor(memberName) { - const [cube] = memberName.split('.'); - if (!this.cubesMap[cube]) { - return null; - } - return Object.keys(this.cubesMap[cube].dimensions || {}).find( - (d) => this.cubesMap[cube].dimensions[d].type === 'time' - ); - } - - filterOperatorsForMember(memberName, memberType) { - const member = this.resolveMember(memberName, memberType); - - return operators[member.type] || operators.string; - } -} - -export default Meta; diff --git a/packages/cubejs-client-core/src/Meta.ts b/packages/cubejs-client-core/src/Meta.ts new file mode 100644 index 0000000000000..f440e4afc4aae --- /dev/null +++ b/packages/cubejs-client-core/src/Meta.ts @@ -0,0 +1,225 @@ +import { unnest, fromPairs } from 'ramda'; +import { + Cube, + CubesMap, + MemberType, + MetaResponse, + TCubeMeasure, + TCubeDimension, + TCubeMember, + TCubeMemberByType, + Query, + FilterOperator, + TCubeSegment, + NotFoundMember, +} from './types'; +import { DeeplyReadonly } from './index'; + +export interface CubeMemberWrapper { + cubeName: string; + cubeTitle: string; + type: 'view' | 'cube'; + public: boolean; + members: T[]; +} + +export type AggregatedMembers = { + measures: CubeMemberWrapper[]; + dimensions: CubeMemberWrapper[]; + segments: CubeMemberWrapper[]; + timeDimensions: CubeMemberWrapper[]; +}; + +const memberMap = (memberArray: any[]) => fromPairs( + memberArray.map((m) => [m.name, m]) +); + +const operators = { + string: [ + { name: 'contains', title: 'contains' }, + { name: 'notContains', title: 'does not contain' }, + { name: 'equals', title: 'equals' }, + { name: 'notEquals', title: 'does not equal' }, + { name: 'set', title: 'is set' }, + { name: 'notSet', title: 'is not set' }, + { name: 'startsWith', title: 'starts with' }, + { name: 'notStartsWith', title: 'does not start with' }, + { name: 'endsWith', title: 'ends with' }, + { name: 'notEndsWith', title: 'does not end with' }, + ], + number: [ + { name: 'equals', title: 'equals' }, + { name: 'notEquals', title: 'does not equal' }, + { name: 'set', title: 'is set' }, + { name: 'notSet', title: 'is not set' }, + { name: 'gt', title: '>' }, + { name: 'gte', title: '>=' }, + { name: 'lt', title: '<' }, + { name: 'lte', title: '<=' }, + ], + time: [ + { name: 'equals', title: 'equals' }, + { name: 'notEquals', title: 'does not equal' }, + { name: 'inDateRange', title: 'in date range' }, + { name: 'notInDateRange', title: 'not in date range' }, + { name: 'afterDate', title: 'after date' }, + { name: 'afterOrOnDate', title: 'after or on date' }, + { name: 'beforeDate', title: 'before date' }, + { name: 'beforeOrOnDate', title: 'before or on date' }, + ], +}; + +/** + * Contains information about available cubes and it's members. + */ +export default class Meta { + /** + * Raw meta response + */ + public readonly meta: MetaResponse; + + /** + * An array of all available cubes with their members + */ + public readonly cubes: Cube[]; + + /** + * A map of all cubes where the key is a cube name + */ + public readonly cubesMap: CubesMap; + + public constructor(metaResponse: MetaResponse) { + this.meta = metaResponse; + const { cubes } = this.meta; + this.cubes = cubes; + this.cubesMap = fromPairs( + cubes.map((c) => [ + c.name, + { + measures: memberMap(c.measures), + dimensions: memberMap(c.dimensions), + segments: memberMap(c.segments), + }, + ]) + ); + } + + /** + * Get all members of a specific type for a given query. + * If empty query is provided no filtering is done based on query context and all available members are retrieved. + * @param _query - context query to provide filtering of members available to add to this query + * @param memberType + */ + public membersForQuery(_query: DeeplyReadonly | null, memberType: MemberType): (TCubeMeasure | TCubeDimension | TCubeMember | TCubeSegment)[] { + return unnest(this.cubes.map((c) => c[memberType])) + .sort((a, b) => (a.title > b.title ? 1 : -1)); + } + + public membersGroupedByCube() { + const memberKeys = ['measures', 'dimensions', 'segments', 'timeDimensions']; + + return this.cubes.reduce( + (memo, cube) => { + memberKeys.forEach((key) => { + let members: TCubeMeasure[] | TCubeDimension[] | TCubeSegment[] = []; + + // eslint-disable-next-line default-case + switch (key) { + case 'measures': + members = cube.measures || []; + break; + case 'dimensions': + members = cube.dimensions || []; + break; + case 'segments': + members = cube.segments || []; + break; + case 'timeDimensions': + members = cube.dimensions.filter((m) => m.type === 'time') || []; + break; + } + + // TODO: Convince TS this is working + // @ts-ignore + memo[key].push({ + cubeName: cube.name, + cubeTitle: cube.title, + type: cube.type, + public: cube.public, + members, + }); + }); + + return memo; + }, + { + measures: [], + dimensions: [], + segments: [], + timeDimensions: [], + } as AggregatedMembers + ); + } + + /** + * Get meta information for a cube member + * meta information contains: + * ```javascript + * { + * name, + * title, + * shortTitle, + * type, + * description, + * format + * } + * ``` + * @param memberName - Fully qualified member name in a form `Cube.memberName` + * @param memberType + * @return An object containing meta information about member + */ + public resolveMember( + memberName: string, + memberType: T | T[] + ): NotFoundMember | TCubeMemberByType { + const [cube] = memberName.split('.'); + + if (!this.cubesMap[cube]) { + return { title: memberName, error: `Cube not found ${cube} for path '${memberName}'` }; + } + + const memberTypes = Array.isArray(memberType) ? memberType : [memberType]; + const member = memberTypes + .map((type) => this.cubesMap[cube][type] && this.cubesMap[cube][type][memberName]) + .find((m) => m); + + if (!member) { + return { + title: memberName, + error: `Path not found '${memberName}'`, + }; + } + + return member as TCubeMemberByType; + } + + public defaultTimeDimensionNameFor(memberName: string): string | null | undefined { + const [cube] = memberName.split('.'); + if (!this.cubesMap[cube]) { + return null; + } + return Object.keys(this.cubesMap[cube].dimensions || {}).find( + (d) => this.cubesMap[cube].dimensions[d].type === 'time' + ); + } + + public filterOperatorsForMember(memberName: string, memberType: MemberType | MemberType[]): FilterOperator[] { + const member = this.resolveMember(memberName, memberType); + + if ('error' in member || !('type' in member) || member.type === 'boolean') { + return operators.string; + } + + return operators[member.type] || operators.string; + } +} diff --git a/packages/cubejs-client-core/src/ProgressResult.js b/packages/cubejs-client-core/src/ProgressResult.js deleted file mode 100644 index fd02aec278540..0000000000000 --- a/packages/cubejs-client-core/src/ProgressResult.js +++ /dev/null @@ -1,13 +0,0 @@ -export default class ProgressResult { - constructor(progressResponse) { - this.progressResponse = progressResponse; - } - - stage() { - return this.progressResponse.stage; - } - - timeElapsed() { - return this.progressResponse.timeElapsed; - } -} diff --git a/packages/cubejs-client-core/src/ProgressResult.ts b/packages/cubejs-client-core/src/ProgressResult.ts new file mode 100644 index 0000000000000..ee05976d8278d --- /dev/null +++ b/packages/cubejs-client-core/src/ProgressResult.ts @@ -0,0 +1,17 @@ +import { ProgressResponse } from './types'; + +export default class ProgressResult { + private progressResponse: ProgressResponse; + + public constructor(progressResponse: ProgressResponse) { + this.progressResponse = progressResponse; + } + + public stage(): string { + return this.progressResponse.stage; + } + + public timeElapsed(): number { + return this.progressResponse.timeElapsed; + } +} diff --git a/packages/cubejs-client-core/src/RequestError.js b/packages/cubejs-client-core/src/RequestError.ts similarity index 51% rename from packages/cubejs-client-core/src/RequestError.js rename to packages/cubejs-client-core/src/RequestError.ts index 74364fe2b1f36..acd57c515af70 100644 --- a/packages/cubejs-client-core/src/RequestError.js +++ b/packages/cubejs-client-core/src/RequestError.ts @@ -1,5 +1,9 @@ export default class RequestError extends Error { - constructor(message, response, status) { + public response: any; + + public status: number; + + public constructor(message: string, response: any, status: number) { super(message); this.response = response; this.status = status; diff --git a/packages/cubejs-client-core/src/ResultSet.js b/packages/cubejs-client-core/src/ResultSet.js deleted file mode 100644 index 02777216fa126..0000000000000 --- a/packages/cubejs-client-core/src/ResultSet.js +++ /dev/null @@ -1,746 +0,0 @@ -import dayjs from 'dayjs'; -import { - groupBy, pipe, fromPairs, uniq, filter, map, dropLast, equals, reduce, minBy, maxBy, clone, mergeDeepLeft, - pluck, mergeAll, flatten, -} from 'ramda'; - -import { aliasSeries } from './utils'; -import { - DateRegex, - dayRange, - internalDayjs, - isPredefinedGranularity, - LocalDateRegex, - TIME_SERIES, - timeSeriesFromCustomInterval -} from './time'; - -const groupByToPairs = (keyFn) => { - const acc = new Map(); - - return (data) => { - data.forEach((row) => { - const key = keyFn(row); - - if (!acc.has(key)) { - acc.set(key, []); - } - - acc.get(key).push(row); - }); - - return Array.from(acc.entries()); - }; -}; - -const unnest = (arr) => { - const res = []; - arr.forEach((subArr) => { - subArr.forEach(element => res.push(element)); - }); - - return res; -}; - -export const QUERY_TYPE = { - REGULAR_QUERY: 'regularQuery', - COMPARE_DATE_RANGE_QUERY: 'compareDateRangeQuery', - BLENDING_QUERY: 'blendingQuery', -}; - -class ResultSet { - static measureFromAxis(axisValues) { - return axisValues[axisValues.length - 1]; - } - - static timeDimensionMember(td) { - return `${td.dimension}.${td.granularity}`; - } - - static deserialize(data, options = {}) { - return new ResultSet(data.loadResponse, options); - } - - constructor(loadResponse, options = {}) { - this.loadResponse = loadResponse; - - if (this.loadResponse.queryType != null) { - this.queryType = loadResponse.queryType; - this.loadResponses = loadResponse.results; - } else { - this.queryType = QUERY_TYPE.REGULAR_QUERY; - this.loadResponse.pivotQuery = { - ...loadResponse.query, - queryType: this.queryType - }; - this.loadResponses = [loadResponse]; - } - - if (!Object.values(QUERY_TYPE).includes(this.queryType)) { - throw new Error('Unknown query type'); - } - - this.parseDateMeasures = options.parseDateMeasures; - this.options = options; - - this.backwardCompatibleData = []; - } - - drillDown(drillDownLocator, pivotConfig) { - if (this.queryType === QUERY_TYPE.COMPARE_DATE_RANGE_QUERY) { - throw new Error('compareDateRange drillDown query is not currently supported'); - } - if (this.queryType === QUERY_TYPE.BLENDING_QUERY) { - throw new Error('Data blending drillDown query is not currently supported'); - } - - const { query } = this.loadResponses[0]; - const { xValues = [], yValues = [] } = drillDownLocator; - const normalizedPivotConfig = this.normalizePivotConfig(pivotConfig); - - const values = []; - normalizedPivotConfig.x.forEach((member, currentIndex) => values.push([member, xValues[currentIndex]])); - normalizedPivotConfig.y.forEach((member, currentIndex) => values.push([member, yValues[currentIndex]])); - - const { filters: parentFilters = [], segments = [] } = this.query(); - const { measures } = this.loadResponses[0].annotation; - let [, measureName] = values.find(([member]) => member === 'measures') || []; - - if (measureName === undefined) { - [measureName] = Object.keys(measures); - } - - if (!(measures[measureName] && measures[measureName].drillMembers || []).length) { - return null; - } - - const filters = [ - { - member: measureName, - operator: 'measureFilter', - }, - ...parentFilters - ]; - const timeDimensions = []; - - values.filter(([member]) => member !== 'measures') - .forEach(([member, value]) => { - const [cubeName, dimension, granularity] = member.split('.'); - - if (granularity !== undefined) { - const range = dayRange(value, value).snapTo(granularity); - const originalTimeDimension = query.timeDimensions.find((td) => td.dimension); - - let dateRange = [ - range.start, - range.end - ]; - - if (originalTimeDimension?.dateRange) { - const [originalStart, originalEnd] = originalTimeDimension.dateRange; - - dateRange = [ - dayjs(originalStart) > range.start ? dayjs(originalStart) : range.start, - dayjs(originalEnd) < range.end ? dayjs(originalEnd) : range.end, - ]; - } - - timeDimensions.push({ - dimension: [cubeName, dimension].join('.'), - dateRange: dateRange.map((dt) => dt.format('YYYY-MM-DDTHH:mm:ss.SSS')), - }); - } else if (value == null) { - filters.push({ - member, - operator: 'notSet', - }); - } else { - filters.push({ - member, - operator: 'equals', - values: [value.toString()], - }); - } - }); - - if ( - timeDimensions.length === 0 && - query.timeDimensions.length > 0 && - query.timeDimensions[0].granularity == null - ) { - timeDimensions.push(query.timeDimensions[0]); - } - - return { - ...measures[measureName].drillMembersGrouped, - filters, - ...(segments.length > 0 ? { segments } : {}), - timeDimensions, - segments, - timezone: query.timezone - }; - } - - series(pivotConfig) { - return this.seriesNames(pivotConfig).map(({ title, shortTitle, key }) => ({ - title, - shortTitle, - key, - series: this.chartPivot(pivotConfig).map(({ x, ...obj }) => ({ value: obj[key], x })) - })); - } - - axisValues(axis, resultIndex = 0) { - const { query } = this.loadResponses[resultIndex]; - - return row => { - const value = (measure) => axis.filter(d => d !== 'measures') - .map(d => (row[d] != null ? row[d] : null)).concat(measure ? [measure] : []); - - if (axis.find(d => d === 'measures') && (query.measures || []).length) { - return query.measures.map(value); - } - - return [value()]; - }; - } - - axisValuesString(axisValues, delimiter) { - const formatValue = (v) => { - if (v == null) { - return '∅'; - } else if (v === '') { - return '[Empty string]'; - } else { - return v; - } - }; - return axisValues.map(formatValue).join(delimiter || ', '); - } - - static getNormalizedPivotConfig(query = {}, pivotConfig = null) { - const defaultPivotConfig = { - x: [], - y: [], - fillMissingDates: true, - joinDateRange: false - }; - - const { - measures = [], - dimensions = [] - } = query; - - const timeDimensions = (query.timeDimensions || []).filter(td => !!td.granularity); - - pivotConfig = pivotConfig || (timeDimensions.length ? { - x: timeDimensions.map(td => ResultSet.timeDimensionMember(td)), - y: dimensions - } : { - x: dimensions, - y: [] - }); - - pivotConfig = mergeDeepLeft(pivotConfig, defaultPivotConfig); - - const substituteTimeDimensionMembers = axis => axis.map( - subDim => ( - ( - timeDimensions.find(td => td.dimension === subDim) && - !dimensions.find(d => d === subDim) - ) ? - ResultSet.timeDimensionMember(query.timeDimensions.find(td => td.dimension === subDim)) : - subDim - ) - ); - - pivotConfig.x = substituteTimeDimensionMembers(pivotConfig.x); - pivotConfig.y = substituteTimeDimensionMembers(pivotConfig.y); - - const allIncludedDimensions = pivotConfig.x.concat(pivotConfig.y); - const allDimensions = timeDimensions.map(td => ResultSet.timeDimensionMember(td)).concat(dimensions); - - const dimensionFilter = (key) => allDimensions.includes(key) || key === 'measures'; - - pivotConfig.x = pivotConfig.x.concat( - allDimensions.filter(d => !allIncludedDimensions.includes(d) && d !== 'compareDateRange') - ) - .filter(dimensionFilter); - pivotConfig.y = pivotConfig.y.filter(dimensionFilter); - - if (!pivotConfig.x.concat(pivotConfig.y).find(d => d === 'measures')) { - pivotConfig.y.push('measures'); - } - - if (dimensions.includes('compareDateRange') && !pivotConfig.y.concat(pivotConfig.x).includes('compareDateRange')) { - pivotConfig.y.unshift('compareDateRange'); - } - - if (!measures.length) { - pivotConfig.x = pivotConfig.x.filter(d => d !== 'measures'); - pivotConfig.y = pivotConfig.y.filter(d => d !== 'measures'); - } - - return pivotConfig; - } - - normalizePivotConfig(pivotConfig) { - return ResultSet.getNormalizedPivotConfig(this.loadResponse.pivotQuery, pivotConfig); - } - - timeSeries(timeDimension, resultIndex, annotations) { - if (!timeDimension.granularity) { - return null; - } - - let { dateRange } = timeDimension; - - if (!dateRange) { - const member = ResultSet.timeDimensionMember(timeDimension); - const dates = pipe( - map(row => row[member] && internalDayjs(row[member])), - filter(Boolean) - )(this.timeDimensionBackwardCompatibleData(resultIndex)); - - dateRange = dates.length && [ - reduce(minBy(d => d.toDate()), dates[0], dates), - reduce(maxBy(d => d.toDate()), dates[0], dates) - ] || null; - } - - if (!dateRange) { - return null; - } - - const padToDay = timeDimension.dateRange ? - timeDimension.dateRange.find(d => d.match(DateRegex)) : - !['hour', 'minute', 'second'].includes(timeDimension.granularity); - - const [start, end] = dateRange; - const range = dayRange(start, end); - - if (isPredefinedGranularity(timeDimension.granularity)) { - return TIME_SERIES[timeDimension.granularity]( - padToDay ? range.snapTo('d') : range - ); - } - - if (!annotations[`${timeDimension.dimension}.${timeDimension.granularity}`]) { - throw new Error(`Granularity "${timeDimension.granularity}" not found in time dimension "${timeDimension.dimension}"`); - } - - return timeSeriesFromCustomInterval( - start, end, annotations[`${timeDimension.dimension}.${timeDimension.granularity}`].granularity - ); - } - - pivot(pivotConfig) { - pivotConfig = this.normalizePivotConfig(pivotConfig); - const { pivotQuery: query } = this.loadResponse; - - const pivotImpl = (resultIndex = 0) => { - let groupByXAxis = groupByToPairs(({ xValues }) => this.axisValuesString(xValues)); - - const measureValue = (row, measure) => row[measure] || pivotConfig.fillWithValue || 0; - - if ( - pivotConfig.fillMissingDates && - pivotConfig.x.length === 1 && - (equals( - pivotConfig.x, - (query.timeDimensions || []) - .filter(td => Boolean(td.granularity)) - .map(td => ResultSet.timeDimensionMember(td)) - )) - ) { - const series = this.loadResponses.map( - (loadResponse) => this.timeSeries( - loadResponse.query.timeDimensions[0], - resultIndex, loadResponse.annotation.timeDimensions - ) - ); - - if (series[0]) { - groupByXAxis = (rows) => { - const byXValues = groupBy( - ({ xValues }) => xValues[0], - rows - ); - return series[resultIndex].map(d => [d, byXValues[d] || [{ xValues: [d], row: {} }]]); - }; - } - } - - const xGrouped = pipe( - map(row => this.axisValues(pivotConfig.x, resultIndex)(row).map(xValues => ({ xValues, row }))), - unnest, - groupByXAxis - )(this.timeDimensionBackwardCompatibleData(resultIndex)); - - const yValuesMap = {}; - xGrouped.forEach(([, rows]) => { - rows.forEach(({ row }) => { - this.axisValues(pivotConfig.y, resultIndex)(row).forEach((values) => { - if (Object.keys(row).length > 0) { - yValuesMap[values.join()] = values; - } - }); - }); - }); - const allYValues = Object.values(yValuesMap); - - const measureOnX = Boolean(pivotConfig.x.find(d => d === 'measures')); - - return xGrouped.map(([, rows]) => { - const { xValues } = rows[0]; - const yGrouped = {}; - - rows.forEach(({ row }) => { - const arr = this.axisValues(pivotConfig.y, resultIndex)(row).map(yValues => ({ yValues, row })); - arr.forEach((res) => { - yGrouped[this.axisValuesString(res.yValues)] = res; - }); - }); - - return { - xValues, - yValuesArray: unnest(allYValues.map(yValues => { - const measure = measureOnX ? - ResultSet.measureFromAxis(xValues) : - ResultSet.measureFromAxis(yValues); - - return [[yValues, measureValue((yGrouped[this.axisValuesString(yValues)] || - ({ row: {} })).row, measure)]]; - })) - }; - }); - }; - - const pivots = this.loadResponses.length > 1 - ? this.loadResponses.map((_, index) => pivotImpl(index)) - : []; - - return pivots.length - ? this.mergePivots(pivots, pivotConfig.joinDateRange) - : pivotImpl(); - } - - mergePivots(pivots, joinDateRange) { - const minLengthPivot = pivots.reduce( - (memo, current) => (memo != null && current.length >= memo.length ? memo : current), null - ); - - return minLengthPivot.map((_, index) => { - const xValues = joinDateRange - ? [pivots.map((pivot) => pivot[index] && pivot[index].xValues || []).join(', ')] - : minLengthPivot[index].xValues; - - return { - xValues, - yValuesArray: unnest(pivots.map((pivot) => pivot[index].yValuesArray)) - }; - }); - } - - pivotedRows(pivotConfig) { // TODO - return this.chartPivot(pivotConfig); - } - - chartPivot(pivotConfig) { - const validate = (value) => { - if (this.parseDateMeasures && LocalDateRegex.test(value)) { - return new Date(value); - } else if (!Number.isNaN(Number.parseFloat(value))) { - return Number.parseFloat(value); - } - - return value; - }; - - const duplicateMeasures = new Set(); - if (this.queryType === QUERY_TYPE.BLENDING_QUERY) { - const allMeasures = flatten(this.loadResponses.map(({ query }) => query.measures)); - allMeasures.filter((e, i, a) => a.indexOf(e) !== i).forEach(m => duplicateMeasures.add(m)); - } - - return this.pivot(pivotConfig).map(({ xValues, yValuesArray }) => { - const yValuesMap = {}; - - yValuesArray - .forEach(([yValues, m], i) => { - yValuesMap[this.axisValuesString(aliasSeries(yValues, i, pivotConfig, duplicateMeasures), ',')] = m && validate(m); - }); - - return ({ - x: this.axisValuesString(xValues, ','), - xValues, - ...yValuesMap - }); - }); - } - - tablePivot(pivotConfig) { - const normalizedPivotConfig = this.normalizePivotConfig(pivotConfig || {}); - const isMeasuresPresent = normalizedPivotConfig.x.concat(normalizedPivotConfig.y).includes('measures'); - - return this.pivot(normalizedPivotConfig).map(({ xValues, yValuesArray }) => fromPairs( - normalizedPivotConfig.x - .map((key, index) => [key, xValues[index]]) - .concat( - isMeasuresPresent ? yValuesArray.map(([yValues, measure]) => [ - yValues.length ? yValues.join() : 'value', - measure - ]) : [] - ) - )); - } - - tableColumns(pivotConfig) { - const normalizedPivotConfig = this.normalizePivotConfig(pivotConfig || {}); - const annotations = pipe( - pluck('annotation'), - reduce(mergeDeepLeft(), {}) - )(this.loadResponses); - const flatMeta = Object.values(annotations).reduce((a, b) => ({ ...a, ...b }), {}); - const schema = {}; - - const extractFields = (key) => { - const { title, shortTitle, type, format, meta } = flatMeta[key] || {}; - - return { - key, - title, - shortTitle, - type, - format, - meta - }; - }; - - const pivot = this.pivot(normalizedPivotConfig); - - (pivot[0] && pivot[0].yValuesArray || []).forEach(([yValues]) => { - if (yValues.length > 0) { - let currentItem = schema; - - yValues.forEach((value, index) => { - currentItem[`_${value}`] = { - key: value, - memberId: normalizedPivotConfig.y[index] === 'measures' - ? value - : normalizedPivotConfig.y[index], - children: (currentItem[`_${value}`] && currentItem[`_${value}`].children) || {} - }; - - currentItem = currentItem[`_${value}`].children; - }); - } - }); - - const toColumns = (item = {}, path = []) => { - if (Object.keys(item).length === 0) { - return []; - } - - return Object.values(item).map(({ key, ...currentItem }) => { - const children = toColumns(currentItem.children, [ - ...path, - key - ]); - - const { title, shortTitle, ...fields } = extractFields(currentItem.memberId); - - const dimensionValue = key !== currentItem.memberId || title == null ? key : ''; - - if (!children.length) { - return { - ...fields, - key, - dataIndex: [...path, key].join(), - title: [title, dimensionValue].join(' ').trim(), - shortTitle: dimensionValue || shortTitle, - }; - } - - return { - ...fields, - key, - title: [title, dimensionValue].join(' ').trim(), - shortTitle: dimensionValue || shortTitle, - children, - }; - }); - }; - - let otherColumns = []; - - if (!pivot.length && normalizedPivotConfig.y.includes('measures')) { - otherColumns = (this.loadResponses[0].query.measures || []).map( - (key) => ({ ...extractFields(key), dataIndex: key }) - ); - } - - // Syntatic column to display the measure value - if (!normalizedPivotConfig.y.length && normalizedPivotConfig.x.includes('measures')) { - otherColumns.push({ - key: 'value', - dataIndex: 'value', - title: 'Value', - shortTitle: 'Value', - type: 'string', - }); - } - - return normalizedPivotConfig.x - .map((key) => { - if (key === 'measures') { - return { - key: 'measures', - dataIndex: 'measures', - title: 'Measures', - shortTitle: 'Measures', - type: 'string', - }; - } - - return ({ ...extractFields(key), dataIndex: key }); - }) - .concat(toColumns(schema)) - .concat(otherColumns); - } - - totalRow(pivotConfig) { - return this.chartPivot(pivotConfig)[0]; - } - - categories(pivotConfig) { // TODO - return this.chartPivot(pivotConfig); - } - - seriesNames(pivotConfig) { - pivotConfig = this.normalizePivotConfig(pivotConfig); - const measures = pipe( - pluck('annotation'), - pluck('measures'), - mergeAll - )(this.loadResponses); - - const seriesNames = unnest(this.loadResponses.map((_, index) => pipe( - map(this.axisValues(pivotConfig.y, index)), - unnest, - uniq - )( - this.timeDimensionBackwardCompatibleData(index) - ))); - const duplicateMeasures = new Set(); - if (this.queryType === QUERY_TYPE.BLENDING_QUERY) { - const allMeasures = flatten(this.loadResponses.map(({ query }) => query.measures)); - allMeasures.filter((e, i, a) => a.indexOf(e) !== i).forEach(m => duplicateMeasures.add(m)); - } - - return seriesNames.map((axisValues, i) => { - const aliasedAxis = aliasSeries(axisValues, i, pivotConfig, duplicateMeasures); - return { - title: this.axisValuesString( - pivotConfig.y.find(d => d === 'measures') ? - dropLast(1, aliasedAxis).concat( - measures[ - ResultSet.measureFromAxis(axisValues) - ].title - ) : - aliasedAxis, ', ' - ), - shortTitle: this.axisValuesString( - pivotConfig.y.find(d => d === 'measures') ? - dropLast(1, aliasedAxis).concat( - measures[ - ResultSet.measureFromAxis(axisValues) - ].shortTitle - ) : - aliasedAxis, ', ' - ), - key: this.axisValuesString(aliasedAxis, ','), - yValues: axisValues - }; - }); - } - - query() { - if (this.queryType !== QUERY_TYPE.REGULAR_QUERY) { - throw new Error(`Method is not supported for a '${this.queryType}' query type. Please use decompose`); - } - - return this.loadResponses[0].query; - } - - pivotQuery() { - return this.loadResponse.pivotQuery || null; - } - - totalRows() { - return this.loadResponses[0].total; - } - - rawData() { - if (this.queryType !== QUERY_TYPE.REGULAR_QUERY) { - throw new Error(`Method is not supported for a '${this.queryType}' query type. Please use decompose`); - } - - return this.loadResponses[0].data; - } - - annotation() { - if (this.queryType !== QUERY_TYPE.REGULAR_QUERY) { - throw new Error(`Method is not supported for a '${this.queryType}' query type. Please use decompose`); - } - - return this.loadResponses[0].annotation; - } - - timeDimensionBackwardCompatibleData(resultIndex) { - if (resultIndex === undefined) { - throw new Error('resultIndex is required'); - } - - if (!this.backwardCompatibleData[resultIndex]) { - const { data, query } = this.loadResponses[resultIndex]; - const timeDimensions = (query.timeDimensions || []).filter(td => Boolean(td.granularity)); - - this.backwardCompatibleData[resultIndex] = data.map(row => ( - { - ...row, - ...( - fromPairs(Object.keys(row) - .filter( - field => timeDimensions.find(d => d.dimension === field) && - !row[ResultSet.timeDimensionMember(timeDimensions.find(d => d.dimension === field))] - ).map(field => ( - [ResultSet.timeDimensionMember(timeDimensions.find(d => d.dimension === field)), row[field]] - ))) - ) - } - )); - } - - return this.backwardCompatibleData[resultIndex]; - } - - decompose() { - return this.loadResponses.map((result) => new ResultSet({ - queryType: QUERY_TYPE.REGULAR_QUERY, - pivotQuery: { - ...result.query, - queryType: QUERY_TYPE.REGULAR_QUERY, - }, - results: [result] - }, this.options)); - } - - serialize() { - return { - loadResponse: clone(this.loadResponse) - }; - } -} - -export default ResultSet; diff --git a/packages/cubejs-client-core/src/ResultSet.ts b/packages/cubejs-client-core/src/ResultSet.ts new file mode 100644 index 0000000000000..a6586b06c1a66 --- /dev/null +++ b/packages/cubejs-client-core/src/ResultSet.ts @@ -0,0 +1,1185 @@ +import dayjs from 'dayjs'; +import { + groupBy, pipe, fromPairs, uniq, map, dropLast, equals, reduce, minBy, maxBy, clone, mergeDeepLeft, + flatten, +} from 'ramda'; + +import { aliasSeries } from './utils'; +import { + DateRegex, + dayRange, + internalDayjs, + isPredefinedGranularity, + LocalDateRegex, + TIME_SERIES, + timeSeriesFromCustomInterval +} from './time'; +import { + Annotation, + ChartPivotRow, DateRange, + DrillDownLocator, + LoadResponse, + LoadResponseResult, Pivot, + PivotConfig, PivotConfigFull, + PivotQuery, + PivotRow, + Query, + QueryAnnotations, QueryType, + SerializedResult, + Series, + SeriesNamesColumn, + TableColumn, + TimeDimension +} from './types'; + +const groupByToPairs = function groupByToPairsImpl(keyFn: (item: T) => K): (data: T[]) => [K, T[]][] { + const acc = new Map(); + + return (data) => { + data.forEach((row) => { + const key = keyFn(row); + + if (!acc.has(key)) { + acc.set(key, []); + } + + acc.get(key).push(row); + }); + + return Array.from(acc.entries()); + }; +}; + +const unnest = (arr: any[][]): any[] => { + const res: any[] = []; + arr.forEach((subArr) => { + subArr.forEach(element => res.push(element)); + }); + + return res; +}; + +export const QUERY_TYPE: Record = { + REGULAR_QUERY: 'regularQuery', + COMPARE_DATE_RANGE_QUERY: 'compareDateRangeQuery', + BLENDING_QUERY: 'blendingQuery', +}; + +export type ResultSetOptions = { + parseDateMeasures?: boolean; +}; + +/** + * Provides a convenient interface for data manipulation. + */ +export default class ResultSet = any> { + private readonly loadResponse: LoadResponse; + + private readonly loadResponses: LoadResponseResult[]; + + private readonly queryType: QueryType; + + private readonly parseDateMeasures: boolean | undefined; + + private readonly options: {}; + + private readonly backwardCompatibleData: Record[][]; + + public static measureFromAxis(axisValues: string[]): string { + return axisValues[axisValues.length - 1]; + } + + public static timeDimensionMember(td: TimeDimension) { + return `${td.dimension}.${td.granularity}`; + } + + /** + * ```js + * import { ResultSet } from '@cubejs-client/core'; + * + * const resultSet = await cubeApi.load(query); + * // You can store the result somewhere + * const tmp = resultSet.serialize(); + * + * // and restore it later + * const resultSet = ResultSet.deserialize(tmp); + * ``` + * @param data the result of [serialize](#result-set-serialize) + * @param options + */ + public static deserialize = any>(data: SerializedResult, options?: Object): ResultSet { + return new ResultSet(data.loadResponse, options); + } + + public constructor(loadResponse: LoadResponse | LoadResponseResult, options: ResultSetOptions = {}) { + if ('queryType' in loadResponse && loadResponse.queryType != null) { + this.loadResponse = loadResponse; + this.queryType = loadResponse.queryType; + this.loadResponses = loadResponse.results; + } else { + this.queryType = QUERY_TYPE.REGULAR_QUERY; + this.loadResponse = { + ...loadResponse, + pivotQuery: { + ...loadResponse.query, + queryType: this.queryType + } + } as LoadResponse; + this.loadResponses = [loadResponse as LoadResponseResult]; + } + + if (!Object.values(QUERY_TYPE).includes(this.queryType)) { + throw new Error('Unknown query type'); + } + + this.parseDateMeasures = options.parseDateMeasures; + this.options = options; + + this.backwardCompatibleData = []; + } + + /** + * Returns a measure drill down query. + * + * Provided you have a measure with the defined `drillMembers` on the `Orders` cube + * ```js + * measures: { + * count: { + * type: `count`, + * drillMembers: [Orders.status, Users.city, count], + * }, + * // ... + * } + * ``` + * + * Then you can use the `drillDown` method to see the rows that contribute to that metric + * ```js + * resultSet.drillDown( + * { + * xValues, + * yValues, + * }, + * // you should pass the `pivotConfig` if you have used it for axes manipulation + * pivotConfig + * ) + * ``` + * + * the result will be a query with the required filters applied and the dimensions/measures filled out + * ```js + * { + * measures: ['Orders.count'], + * dimensions: ['Orders.status', 'Users.city'], + * filters: [ + * // dimension and measure filters + * ], + * timeDimensions: [ + * //... + * ] + * } + * ``` + * + * In case when you want to add `order` or `limit` to the query, you can simply spread it + * + * ```js + * // An example for React + * const drillDownResponse = useCubeQuery( + * { + * ...drillDownQuery, + * limit: 30, + * order: { + * 'Orders.ts': 'desc' + * } + * }, + * { + * skip: !drillDownQuery + * } + * ); + * ``` + * @returns Drill down query + */ + public drillDown(drillDownLocator: DrillDownLocator, pivotConfig?: PivotConfig): Query | null { + if (this.queryType === QUERY_TYPE.COMPARE_DATE_RANGE_QUERY) { + throw new Error('compareDateRange drillDown query is not currently supported'); + } + if (this.queryType === QUERY_TYPE.BLENDING_QUERY) { + throw new Error('Data blending drillDown query is not currently supported'); + } + + const { query } = this.loadResponses[0]; + const xValues = drillDownLocator?.xValues ?? []; + const yValues = drillDownLocator?.yValues ?? []; + const normalizedPivotConfig = this.normalizePivotConfig(pivotConfig); + + const values: string[][] = []; + normalizedPivotConfig?.x.forEach((member, currentIndex) => values.push([member, xValues[currentIndex]])); + normalizedPivotConfig?.y.forEach((member, currentIndex) => values.push([member, yValues[currentIndex]])); + + const { filters: parentFilters = [], segments = [] } = this.query(); + const { measures } = this.loadResponses[0].annotation; + let [, measureName] = values.find(([member]) => member === 'measures') || []; + + if (measureName === undefined) { + [measureName] = Object.keys(measures); + } + + if (!(measures[measureName]?.drillMembers?.length ?? 0)) { + return null; + } + + const filters = [ + { + member: measureName, + operator: 'measureFilter', + }, + ...parentFilters + ]; + const timeDimensions = []; + + values.filter(([member]) => member !== 'measures') + .forEach(([member, value]) => { + const [cubeName, dimension, granularity] = member.split('.'); + + if (granularity !== undefined) { + const range = dayRange(value, value).snapTo(granularity); + const originalTimeDimension = query.timeDimensions?.find((td) => td.dimension); + + let dateRange = [ + range.start, + range.end + ]; + + if (originalTimeDimension?.dateRange) { + const [originalStart, originalEnd] = originalTimeDimension.dateRange; + + dateRange = [ + dayjs(originalStart) > range.start ? dayjs(originalStart) : range.start, + dayjs(originalEnd) < range.end ? dayjs(originalEnd) : range.end, + ]; + } + + timeDimensions.push({ + dimension: [cubeName, dimension].join('.'), + dateRange: dateRange.map((dt) => dt.format('YYYY-MM-DDTHH:mm:ss.SSS')), + }); + } else if (value == null) { + filters.push({ + member, + operator: 'notSet', + }); + } else { + filters.push({ + member, + operator: 'equals', + values: [value.toString()], + }); + } + }); + + if ( + timeDimensions.length === 0 && + Array.isArray(query.timeDimensions) && + query.timeDimensions.length > 0 && + query.timeDimensions[0].granularity == null + ) { + timeDimensions.push(query.timeDimensions[0]); + } + + return { + ...measures[measureName].drillMembersGrouped, + filters, + ...(segments.length > 0 ? { segments } : {}), + timeDimensions, + segments, + timezone: query.timezone + }; + } + + /** + * Returns an array of series with key, title and series data. + * ```js + * // For the query + * { + * measures: ['Stories.count'], + * timeDimensions: [{ + * dimension: 'Stories.time', + * dateRange: ['2015-01-01', '2015-12-31'], + * granularity: 'month' + * }] + * } + * + * // ResultSet.series() will return + * [ + * { + * key: 'Stories.count', + * title: 'Stories Count', + * shortTitle: 'Count', + * series: [ + * { x: '2015-01-01T00:00:00', value: 27120 }, + * { x: '2015-02-01T00:00:00', value: 25861 }, + * { x: '2015-03-01T00:00:00', value: 29661 }, + * //... + * ], + * }, + * ] + * ``` + */ + public series(pivotConfig?: PivotConfig): Series[] { + return this.seriesNames(pivotConfig).map(({ title, shortTitle, key }) => ({ + title, + shortTitle, + key, + series: this.chartPivot(pivotConfig).map(({ x, ...obj }) => ({ value: obj[key], x })) + } as Series)); + } + + private axisValues(axis: string[], resultIndex = 0) { + const { query } = this.loadResponses[resultIndex]; + + return (row: Record) => { + const value = (measure?: string) => axis + .filter(d => d !== 'measures') + .map((d: string) => { + const val = row[d]; + return val != null ? val : null; + }) + .concat(measure ? [measure] : []); + + if (axis.find(d => d === 'measures') && (query.measures || []).length) { + return (query.measures || []).map(value); + } + + return [value()]; + }; + } + + private axisValuesString(axisValues: (string | number)[], delimiter: string = ', '): string { + const formatValue = (v: string | number) => { + if (v == null) { + return '∅'; + } else if (v === '') { + return '[Empty string]'; + } else { + return v; + } + }; + return axisValues.map(formatValue).join(delimiter); + } + + public static getNormalizedPivotConfig(query?: PivotQuery, pivotConfig?: PivotConfig): PivotConfigFull { + const defaultPivotConfig: PivotConfig = { + x: [], + y: [], + fillMissingDates: true, + joinDateRange: false + }; + + const { + measures = [], + dimensions = [] + } = query || {}; + + const timeDimensions = (query?.timeDimensions || []).filter(td => !!td.granularity); + + pivotConfig = pivotConfig || (timeDimensions.length ? { + x: timeDimensions.map(td => ResultSet.timeDimensionMember(td)), + y: dimensions + } : { + x: dimensions, + y: [] + }); + + const normalizedPivotConfig = mergeDeepLeft(pivotConfig, defaultPivotConfig) as PivotConfigFull; + + const substituteTimeDimensionMembers = (axis: string[]) => axis.map( + subDim => ( + ( + timeDimensions.find(td => td.dimension === subDim) && + !dimensions.find(d => d === subDim) + ) ? + ResultSet.timeDimensionMember((query?.timeDimensions || []).find(td => td.dimension === subDim)!) : + subDim + ) + ); + + normalizedPivotConfig.x = substituteTimeDimensionMembers(normalizedPivotConfig.x); + normalizedPivotConfig.y = substituteTimeDimensionMembers(normalizedPivotConfig.y); + + const allIncludedDimensions = normalizedPivotConfig.x.concat(normalizedPivotConfig.y); + const allDimensions = timeDimensions.map(td => ResultSet.timeDimensionMember(td)).concat(dimensions); + + const dimensionFilter = (key: string) => allDimensions.includes(key) || key === 'measures'; + + normalizedPivotConfig.x = normalizedPivotConfig.x.concat( + allDimensions.filter(d => !allIncludedDimensions.includes(d) && d !== 'compareDateRange') + ) + .filter(dimensionFilter); + normalizedPivotConfig.y = normalizedPivotConfig.y.filter(dimensionFilter); + + if (!normalizedPivotConfig.x.concat(normalizedPivotConfig.y).find(d => d === 'measures')) { + normalizedPivotConfig.y.push('measures'); + } + + if (dimensions.includes('compareDateRange') && !normalizedPivotConfig.y.concat(normalizedPivotConfig.x).includes('compareDateRange')) { + normalizedPivotConfig.y.unshift('compareDateRange'); + } + + if (!measures.length) { + normalizedPivotConfig.x = normalizedPivotConfig.x.filter(d => d !== 'measures'); + normalizedPivotConfig.y = normalizedPivotConfig.y.filter(d => d !== 'measures'); + } + + return normalizedPivotConfig; + } + + public normalizePivotConfig(pivotConfig?: PivotConfig): PivotConfigFull { + return ResultSet.getNormalizedPivotConfig(this.loadResponse.pivotQuery, pivotConfig); + } + + public timeSeries(timeDimension: TimeDimension, resultIndex?: number, annotations?: Record) { + if (!timeDimension.granularity) { + return null; + } + + let dateRange: DateRange | null | undefined; + dateRange = timeDimension.dateRange; + + if (!dateRange) { + const member = ResultSet.timeDimensionMember(timeDimension); + const rawRows: Record[] = this.timeDimensionBackwardCompatibleData(resultIndex || 0); + + const dates = rawRows + .map(row => { + const value = row[member]; + return value ? internalDayjs(value) : null; + }) + .filter((d): d is dayjs.Dayjs => Boolean(d)); + + dateRange = dates.length && [ + (reduce(minBy((d: dayjs.Dayjs): Date => d.toDate()), dates[0], dates)).toString(), + (reduce(maxBy((d: dayjs.Dayjs): Date => d.toDate()), dates[0], dates)).toString(), + ] || null; + } + + if (!dateRange) { + return null; + } + + const padToDay = timeDimension.dateRange ? + (timeDimension.dateRange as string[]).find(d => d.match(DateRegex)) : + !['hour', 'minute', 'second'].includes(timeDimension.granularity); + + const [start, end] = dateRange; + const range = dayRange(start, end); + + if (isPredefinedGranularity(timeDimension.granularity)) { + return TIME_SERIES[timeDimension.granularity]( + padToDay ? range.snapTo('d') : range + ); + } + + if (!annotations?.[`${timeDimension.dimension}.${timeDimension.granularity}`]) { + throw new Error(`Granularity "${timeDimension.granularity}" not found in time dimension "${timeDimension.dimension}"`); + } + + return timeSeriesFromCustomInterval( + start, end, annotations[`${timeDimension.dimension}.${timeDimension.granularity}`].granularity! + ); + } + + /** + * Base method for pivoting [ResultSet](#result-set) data. + * Most of the time shouldn't be used directly and [chartPivot](#result-set-chart-pivot) + * or [tablePivot](#table-pivot) should be used instead. + * + * You can find the examples of using the `pivotConfig` [here](#types-pivot-config) + * ```js + * // For query + * { + * measures: ['Stories.count'], + * timeDimensions: [{ + * dimension: 'Stories.time', + * dateRange: ['2015-01-01', '2015-03-31'], + * granularity: 'month' + * }] + * } + * + * // ResultSet.pivot({ x: ['Stories.time'], y: ['measures'] }) will return + * [ + * { + * xValues: ["2015-01-01T00:00:00"], + * yValuesArray: [ + * [['Stories.count'], 27120] + * ] + * }, + * { + * xValues: ["2015-02-01T00:00:00"], + * yValuesArray: [ + * [['Stories.count'], 25861] + * ] + * }, + * { + * xValues: ["2015-03-01T00:00:00"], + * yValuesArray: [ + * [['Stories.count'], 29661] + * ] + * } + * ] + * ``` + * @returns An array of pivoted rows. + */ + public pivot(pivotConfig?: PivotConfig): PivotRow[] { + const normalizedPivotConfig = this.normalizePivotConfig(pivotConfig); + const { pivotQuery: query } = this.loadResponse; + + const pivotImpl = (resultIndex = 0) => { + let groupByXAxis = groupByToPairs<{ xValues: string[], row: Record }, string>(({ xValues }) => this.axisValuesString(xValues)); + + const measureValue = (row: Record, measure: string) => row[measure] || normalizedPivotConfig.fillWithValue || 0; + + if ( + normalizedPivotConfig.fillMissingDates && + normalizedPivotConfig.x.length === 1 && + (equals( + normalizedPivotConfig.x, + (query.timeDimensions || []) + .filter(td => Boolean(td.granularity)) + .map(td => ResultSet.timeDimensionMember(td)) + )) + ) { + const series = this.loadResponses.map( + (loadResponse) => this.timeSeries( + loadResponse.query.timeDimensions![0], + resultIndex, loadResponse.annotation.timeDimensions + ) + ); + + if (series[0]) { + groupByXAxis = (rows) => { + const byXValues = groupBy( + ({ xValues }) => xValues[0], + rows + ); + return series[resultIndex]?.map(d => [d, byXValues[d] || [{ xValues: [d], row: {} }]]) ?? []; + }; + } + } + + const xGrouped: [string, { xValues: string[], row: Record }[]][] = pipe( + map((row: Record) => this.axisValues(normalizedPivotConfig.x, resultIndex)(row).map(xValues => ({ xValues, row }))), + unnest, + groupByXAxis + )(this.timeDimensionBackwardCompatibleData(resultIndex)); + + const yValuesMap: Record = {}; + xGrouped.forEach(([, rows]) => { + rows.forEach(({ row }) => { + this.axisValues(normalizedPivotConfig.y, resultIndex)(row).forEach((values) => { + if (Object.keys(row).length > 0) { + yValuesMap[values.join()] = values; + } + }); + }); + }); + const allYValues = Object.values(yValuesMap); + + const measureOnX = Boolean((normalizedPivotConfig.x).find(d => d === 'measures')); + + return xGrouped.map(([, rows]) => { + const { xValues } = rows[0]; + const yGrouped: Record = {}; + + rows.forEach(({ row }) => { + const arr = this.axisValues(normalizedPivotConfig.y, resultIndex)(row).map(yValues => ({ yValues, row })); + arr.forEach((res) => { + yGrouped[this.axisValuesString(res.yValues)] = res; + }); + }); + + return { + xValues, + yValuesArray: unnest(allYValues.map(yValues => { + const measure = measureOnX ? + ResultSet.measureFromAxis(xValues) : + ResultSet.measureFromAxis(yValues); + + return [[yValues, measureValue((yGrouped[this.axisValuesString(yValues)] || + ({ row: {} })).row, measure)]]; + })) + }; + }); + }; + + const pivots = this.loadResponses.length > 1 + ? this.loadResponses.map((_, index) => pivotImpl(index)) + : []; + + return pivots.length + ? this.mergePivots(pivots, normalizedPivotConfig.joinDateRange || false) + : pivotImpl(); + } + + private mergePivots(pivots: Pivot[][], joinDateRange: ((pivots: Pivot, joinDateRange: any) => PivotRow[]) | false): PivotRow[] { + const minLengthPivot: Pivot[] = pivots.reduce( + (memo, current) => (memo != null && current.length >= memo.length ? memo : current), null + ) || []; + + return minLengthPivot.map((_: any, index: number) => { + const xValues = joinDateRange + ? [pivots.map((pivot) => pivot[index]?.xValues || []).join(', ')] + : minLengthPivot[index].xValues; + + return { + xValues, + yValuesArray: unnest(pivots.map((pivot) => pivot[index].yValuesArray)) + }; + }); + } + + /** + * Returns normalized query result data in the following format. + * + * You can find the examples of using the `pivotConfig` [here](#types-pivot-config) + * ```js + * // For the query + * { + * measures: ['Stories.count'], + * timeDimensions: [{ + * dimension: 'Stories.time', + * dateRange: ['2015-01-01', '2015-12-31'], + * granularity: 'month' + * }] + * } + * + * // ResultSet.chartPivot() will return + * [ + * { "x":"2015-01-01T00:00:00", "Stories.count": 27120, "xValues": ["2015-01-01T00:00:00"] }, + * { "x":"2015-02-01T00:00:00", "Stories.count": 25861, "xValues": ["2015-02-01T00:00:00"] }, + * { "x":"2015-03-01T00:00:00", "Stories.count": 29661, "xValues": ["2015-03-01T00:00:00"] }, + * //... + * ] + * + * ``` + * When using `chartPivot()` or `seriesNames()`, you can pass `aliasSeries` in the [pivotConfig](#types-pivot-config) + * to give each series a unique prefix. This is useful for `blending queries` which use the same measure multiple times. + * + * ```js + * // For the queries + * { + * measures: ['Stories.count'], + * timeDimensions: [ + * { + * dimension: 'Stories.time', + * dateRange: ['2015-01-01', '2015-12-31'], + * granularity: 'month', + * }, + * ], + * }, + * { + * measures: ['Stories.count'], + * timeDimensions: [ + * { + * dimension: 'Stories.time', + * dateRange: ['2015-01-01', '2015-12-31'], + * granularity: 'month', + * }, + * ], + * filters: [ + * { + * member: 'Stores.read', + * operator: 'equals', + * value: ['true'], + * }, + * ], + * }, + * + * // ResultSet.chartPivot({ aliasSeries: ['one', 'two'] }) will return + * [ + * { + * x: '2015-01-01T00:00:00', + * 'one,Stories.count': 27120, + * 'two,Stories.count': 8933, + * xValues: ['2015-01-01T00:00:00'], + * }, + * { + * x: '2015-02-01T00:00:00', + * 'one,Stories.count': 25861, + * 'two,Stories.count': 8344, + * xValues: ['2015-02-01T00:00:00'], + * }, + * { + * x: '2015-03-01T00:00:00', + * 'one,Stories.count': 29661, + * 'two,Stories.count': 9023, + * xValues: ['2015-03-01T00:00:00'], + * }, + * //... + * ] + * ``` + */ + public chartPivot(pivotConfig?: PivotConfig): ChartPivotRow[] { + const validate = (value: string) => { + if (this.parseDateMeasures && LocalDateRegex.test(value)) { + return new Date(value); + } else if (!Number.isNaN(Number.parseFloat(value))) { + return Number.parseFloat(value); + } + + return value; + }; + + const duplicateMeasures = new Set(); + if (this.queryType === QUERY_TYPE.BLENDING_QUERY) { + const allMeasures = flatten(this.loadResponses.map(({ query }) => query.measures ?? [])); + allMeasures.filter((e, i, a) => a.indexOf(e) !== i).forEach(m => duplicateMeasures.add(m)); + } + + return this.pivot(pivotConfig).map(({ xValues, yValuesArray }) => { + const yValuesMap: Record = {}; + + yValuesArray + .forEach(([yValues, m]: [string[], string], i: number) => { + yValuesMap[this.axisValuesString(aliasSeries(yValues, i, pivotConfig, duplicateMeasures), ',')] = m && validate(m); + }); + + return ({ + x: this.axisValuesString(xValues, ','), + xValues, + ...yValuesMap + } as ChartPivotRow); + }); + } + + /** + * Returns normalized query result data prepared for visualization in the table format. + * + * You can find the examples of using the `pivotConfig` [here](#types-pivot-config) + * + * For example: + * ```js + * // For the query + * { + * measures: ['Stories.count'], + * timeDimensions: [{ + * dimension: 'Stories.time', + * dateRange: ['2015-01-01', '2015-12-31'], + * granularity: 'month' + * }] + * } + * + * // ResultSet.tablePivot() will return + * [ + * { "Stories.time": "2015-01-01T00:00:00", "Stories.count": 27120 }, + * { "Stories.time": "2015-02-01T00:00:00", "Stories.count": 25861 }, + * { "Stories.time": "2015-03-01T00:00:00", "Stories.count": 29661 }, + * //... + * ] + * ``` + * @returns An array of pivoted rows + */ + public tablePivot(pivotConfig?: PivotConfig): Array<{ [key: string]: string | number | boolean }> { + const normalizedPivotConfig = this.normalizePivotConfig(pivotConfig || {}); + const isMeasuresPresent = normalizedPivotConfig.x.concat(normalizedPivotConfig.y).includes('measures'); + + return this.pivot(normalizedPivotConfig).map(({ xValues, yValuesArray }) => fromPairs( + [ + ...(normalizedPivotConfig.x).map((key, index): [string, string | number] => [ + key, + xValues[index] ?? '' + ]), + ...(isMeasuresPresent + ? yValuesArray.map(([yValues, measure]): [string, string | number] => [ + yValues.length ? yValues.join() : 'value', + measure + ]) + : []) + ] + )); + } + + /** + * Returns an array of column definitions for `tablePivot`. + * + * For example: + * ```js + * // For the query + * { + * measures: ['Stories.count'], + * timeDimensions: [{ + * dimension: 'Stories.time', + * dateRange: ['2015-01-01', '2015-12-31'], + * granularity: 'month' + * }] + * } + * + * // ResultSet.tableColumns() will return + * [ + * { + * key: 'Stories.time', + * dataIndex: 'Stories.time', + * title: 'Stories Time', + * shortTitle: 'Time', + * type: 'time', + * format: undefined, + * }, + * { + * key: 'Stories.count', + * dataIndex: 'Stories.count', + * title: 'Stories Count', + * shortTitle: 'Count', + * type: 'count', + * format: undefined, + * }, + * //... + * ] + * ``` + * + * In case we want to pivot the table axes + * ```js + * // Let's take this query as an example + * { + * measures: ['Orders.count'], + * dimensions: ['Users.country', 'Users.gender'] + * } + * + * // and put the dimensions on `y` axis + * resultSet.tableColumns({ + * x: [], + * y: ['Users.country', 'Users.gender', 'measures'] + * }) + * ``` + * + * then `tableColumns` will group the table head and return + * ```js + * { + * key: 'Germany', + * type: 'string', + * title: 'Users Country Germany', + * shortTitle: 'Germany', + * meta: undefined, + * format: undefined, + * children: [ + * { + * key: 'male', + * type: 'string', + * title: 'Users Gender male', + * shortTitle: 'male', + * meta: undefined, + * format: undefined, + * children: [ + * { + * // ... + * dataIndex: 'Germany.male.Orders.count', + * shortTitle: 'Count', + * }, + * ], + * }, + * { + * // ... + * shortTitle: 'female', + * children: [ + * { + * // ... + * dataIndex: 'Germany.female.Orders.count', + * shortTitle: 'Count', + * }, + * ], + * }, + * ], + * }, + * // ... + * ``` + * @returns An array of columns + */ + public tableColumns(pivotConfig?: PivotConfig): TableColumn[] { + const normalizedPivotConfig = this.normalizePivotConfig(pivotConfig || {}); + + const annotations: QueryAnnotations = this.loadResponses + .map((r) => r.annotation) + .reduce((acc, annotation) => mergeDeepLeft(acc, annotation) as QueryAnnotations, + { + dimensions: {}, + measures: {}, + timeDimensions: {}, + segments: {}, + }); + + const flatMeta = Object.values(annotations).reduce((a, b) => ({ ...a, ...b }), {}); + const schema: Record = {}; + + const extractFields = (key: string) => { + const { title, shortTitle, type, format, meta } = flatMeta[key] || {}; + + return { + key, + title, + shortTitle, + type, + format, + meta + }; + }; + + const pivot = this.pivot(normalizedPivotConfig); + + (pivot[0]?.yValuesArray || []).forEach(([yValues]) => { + if (yValues.length > 0) { + let currentItem = schema; + + yValues.forEach((value, index) => { + currentItem[`_${value}`] = { + key: value, + memberId: normalizedPivotConfig.y[index] === 'measures' + ? value + : normalizedPivotConfig.y[index], + children: currentItem[`_${value}`]?.children || {} + }; + + currentItem = currentItem[`_${value}`].children; + }); + } + }); + + const toColumns = (item: Record = {}, path: string[] = []): TableColumn[] => { + if (Object.keys(item).length === 0) { + return []; + } + + return Object.values(item).map(({ key, ...currentItem }) => { + const children = toColumns(currentItem.children, [ + ...path, + key + ]); + + const { title, shortTitle, ...fields } = extractFields(currentItem.memberId); + + const dimensionValue = key !== currentItem.memberId || title == null ? key : ''; + + if (!children.length) { + return { + ...fields, + key, + dataIndex: [...path, key].join(), + title: [title, dimensionValue].join(' ').trim(), + shortTitle: dimensionValue || shortTitle, + } as TableColumn; + } + + return { + ...fields, + key, + title: [title, dimensionValue].join(' ').trim(), + shortTitle: dimensionValue || shortTitle, + children, + } as TableColumn; + }); + }; + + let otherColumns: TableColumn[] = []; + + if (!pivot.length && normalizedPivotConfig.y.includes('measures')) { + otherColumns = (this.loadResponses[0].query.measures || []).map( + (key) => ({ ...extractFields(key), dataIndex: key }) + ); + } + + // Synthetic column to display the measure value + if (!normalizedPivotConfig.y.length && normalizedPivotConfig.x.includes('measures')) { + otherColumns.push({ + key: 'value', + dataIndex: 'value', + title: 'Value', + shortTitle: 'Value', + type: 'string', + }); + } + + return (normalizedPivotConfig.x).map((key) => { + if (key === 'measures') { + return { + key: 'measures', + dataIndex: 'measures', + title: 'Measures', + shortTitle: 'Measures', + type: 'string', + } as TableColumn; + } + + return ({ ...extractFields(key), dataIndex: key }); + }) + .concat(toColumns(schema)) + .concat(otherColumns); + } + + public totalRow(pivotConfig?: PivotConfig): ChartPivotRow { + return this.chartPivot(pivotConfig)[0]; + } + + public categories(pivotConfig?: PivotConfig): ChartPivotRow[] { + return this.chartPivot(pivotConfig); + } + + /** + * Returns an array of series objects, containing `key` and `title` parameters. + * ```js + * // For query + * { + * measures: ['Stories.count'], + * timeDimensions: [{ + * dimension: 'Stories.time', + * dateRange: ['2015-01-01', '2015-12-31'], + * granularity: 'month' + * }] + * } + * + * // ResultSet.seriesNames() will return + * [ + * { + * key: 'Stories.count', + * title: 'Stories Count', + * shortTitle: 'Count', + * yValues: ['Stories.count'], + * }, + * ] + * ``` + * @returns An array of series names + */ + public seriesNames(pivotConfig?: PivotConfig): SeriesNamesColumn[] { + const normalizedPivotConfig = this.normalizePivotConfig(pivotConfig); + const measures = this.loadResponses + .map(r => r.annotation.measures) + .reduce((acc, m) => ({ ...acc, ...m }), {}); + + const seriesNames = unnest(this.loadResponses.map((_, index) => pipe( + map(this.axisValues(normalizedPivotConfig.y, index)), + unnest, + uniq + )( + this.timeDimensionBackwardCompatibleData(index) + ))); + + const duplicateMeasures = new Set(); + if (this.queryType === QUERY_TYPE.BLENDING_QUERY) { + const allMeasures = flatten(this.loadResponses.map(({ query }) => query.measures ?? [])); + allMeasures.filter((e, i, a) => a.indexOf(e) !== i).forEach(m => duplicateMeasures.add(m)); + } + + return seriesNames.map((axisValues, i) => { + const aliasedAxis = aliasSeries(axisValues, i, normalizedPivotConfig, duplicateMeasures); + return { + title: this.axisValuesString( + normalizedPivotConfig.y.find(d => d === 'measures') ? + dropLast(1, aliasedAxis).concat( + measures[ + ResultSet.measureFromAxis(axisValues) + ].title + ) : + aliasedAxis, ', ' + ), + shortTitle: this.axisValuesString( + normalizedPivotConfig.y.find(d => d === 'measures') ? + dropLast(1, aliasedAxis).concat( + measures[ + ResultSet.measureFromAxis(axisValues) + ].shortTitle + ) : + aliasedAxis, ', ' + ), + key: this.axisValuesString(aliasedAxis, ','), + yValues: axisValues + }; + }); + } + + public query(): Query { + if (this.queryType !== QUERY_TYPE.REGULAR_QUERY) { + throw new Error(`Method is not supported for a '${this.queryType}' query type. Please use decompose`); + } + + return this.loadResponses[0].query; + } + + public pivotQuery(): PivotQuery { + return this.loadResponse.pivotQuery || null; + } + + /** + * @return the total number of rows if the `total` option was set, when sending the query + */ + public totalRows(): number | null | undefined { + return this.loadResponses[0].total; + } + + public rawData(): T[] { + if (this.queryType !== QUERY_TYPE.REGULAR_QUERY) { + throw new Error(`Method is not supported for a '${this.queryType}' query type. Please use decompose`); + } + + return this.loadResponses[0].data; + } + + public annotation(): QueryAnnotations { + if (this.queryType !== QUERY_TYPE.REGULAR_QUERY) { + throw new Error(`Method is not supported for a '${this.queryType}' query type. Please use decompose`); + } + + return this.loadResponses[0].annotation; + } + + private timeDimensionBackwardCompatibleData(resultIndex: number) { + if (resultIndex === undefined) { + throw new Error('resultIndex is required'); + } + + if (!this.backwardCompatibleData[resultIndex]) { + const { data, query } = this.loadResponses[resultIndex]; + const timeDimensions = (query.timeDimensions || []).filter(td => Boolean(td.granularity)); + + this.backwardCompatibleData[resultIndex] = data.map(row => ( + { + ...row, + ...( + fromPairs(Object.keys(row) + .filter( + field => { + const foundTd = timeDimensions.find(d => d.dimension === field); + return foundTd && !row[ResultSet.timeDimensionMember(foundTd)]; + } + ).map(field => ( + [ResultSet.timeDimensionMember(timeDimensions.find(d => d.dimension === field)!), row[field]] + ))) + ) + } + )); + } + + return this.backwardCompatibleData[resultIndex]; + } + + /** + * Can be used when you need access to the methods that can't be used with some query types (eg `compareDateRangeQuery` or `blendingQuery`) + * ```js + * resultSet.decompose().forEach((currentResultSet) => { + * console.log(currentResultSet.rawData()); + * }); + * ``` + */ + public decompose(): ResultSet[] { + return this.loadResponses.map((result) => new ResultSet({ + queryType: QUERY_TYPE.REGULAR_QUERY, + pivotQuery: { + ...result.query, + queryType: QUERY_TYPE.REGULAR_QUERY, + }, + results: [result] + }, this.options)); + } + + /** + * Can be used to stash the `ResultSet` in a storage and restored later with [deserialize](#result-set-deserialize) + */ + public serialize(): SerializedResult { + return { + loadResponse: clone(this.loadResponse) + }; + } +} diff --git a/packages/cubejs-client-core/src/SqlQuery.js b/packages/cubejs-client-core/src/SqlQuery.js deleted file mode 100644 index 9a9a2b6ea84ba..0000000000000 --- a/packages/cubejs-client-core/src/SqlQuery.js +++ /dev/null @@ -1,13 +0,0 @@ -export default class SqlQuery { - constructor(sqlQuery) { - this.sqlQuery = sqlQuery; - } - - rawQuery() { - return this.sqlQuery.sql; - } - - sql() { - return this.rawQuery().sql[0]; - } -} diff --git a/packages/cubejs-client-core/src/SqlQuery.ts b/packages/cubejs-client-core/src/SqlQuery.ts new file mode 100644 index 0000000000000..2c450d311ca8c --- /dev/null +++ b/packages/cubejs-client-core/src/SqlQuery.ts @@ -0,0 +1,27 @@ +export type SqlQueryTuple = [string, any[], any]; + +export type SqlData = { + aliasNameToMember: Record; + cacheKeyQueries: SqlQueryTuple[]; + dataSource: boolean; + external: boolean; + sql: SqlQueryTuple; + preAggregations: any[]; + rollupMatchResults: any[]; +}; + +export default class SqlQuery { + private readonly sqlQuery: SqlData; + + public constructor(sqlQuery: SqlData) { + this.sqlQuery = sqlQuery; + } + + public rawQuery(): SqlData { + return this.sqlQuery; + } + + public sql(): string { + return this.rawQuery().sql[0]; + } +} diff --git a/packages/cubejs-client-core/src/index.js b/packages/cubejs-client-core/src/index.js deleted file mode 100644 index 80820c3efb686..0000000000000 --- a/packages/cubejs-client-core/src/index.js +++ /dev/null @@ -1,410 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; -import ResultSet from './ResultSet'; -import SqlQuery from './SqlQuery'; -import Meta from './Meta'; -import ProgressResult from './ProgressResult'; -import HttpTransport from './HttpTransport'; -import RequestError from './RequestError'; - -let mutexCounter = 0; - -const MUTEX_ERROR = 'Mutex has been changed'; - -/** - * Query result dataset formats enum. - */ -const ResultType = { - DEFAULT: 'default', - COMPACT: 'compact' -}; - -function mutexPromise(promise) { - return new Promise(async (resolve, reject) => { - try { - resolve(await promise); - } catch (error) { - if (error !== MUTEX_ERROR) { - reject(error); - } - } - }); -} - -class CubeApi { - constructor(apiToken, options) { - if (apiToken !== null && !Array.isArray(apiToken) && typeof apiToken === 'object') { - options = apiToken; - apiToken = undefined; - } - options = options || {}; - - if (!options.transport && !options.apiUrl) { - throw new Error('The `apiUrl` option is required'); - } - - this.apiToken = apiToken; - this.apiUrl = options.apiUrl; - this.method = options.method; - this.headers = options.headers || {}; - this.credentials = options.credentials; - this.transport = options.transport || new HttpTransport({ - authorization: typeof apiToken === 'function' ? undefined : apiToken, - apiUrl: this.apiUrl, - method: this.method, - headers: this.headers, - credentials: this.credentials, - fetchTimeout: options.fetchTimeout, - signal: options.signal - }); - this.pollInterval = options.pollInterval || 5; - this.parseDateMeasures = options.parseDateMeasures; - this.castNumerics = typeof options.castNumerics === 'boolean' ? options.castNumerics : false; - this.networkErrorRetries = options.networkErrorRetries || 0; - - this.updateAuthorizationPromise = null; - } - - request(method, params) { - return this.transport.request(method, { - baseRequestId: uuidv4(), - ...params - }); - } - - loadMethod(request, toResult, options, callback) { - const mutexValue = ++mutexCounter; - if (typeof options === 'function' && !callback) { - callback = options; - options = undefined; - } - - options = options || {}; - - const mutexKey = options.mutexKey || 'default'; - if (options.mutexObj) { - options.mutexObj[mutexKey] = mutexValue; - } - - const requestPromise = this - .updateTransportAuthorization() - .then(() => request()); - - let skipAuthorizationUpdate = true; - let unsubscribed = false; - - const checkMutex = async () => { - const requestInstance = await requestPromise; - - if ( - options.mutexObj && - options.mutexObj[mutexKey] !== mutexValue - ) { - unsubscribed = true; - if (requestInstance.unsubscribe) { - await requestInstance.unsubscribe(); - } - throw MUTEX_ERROR; - } - }; - - let networkRetries = this.networkErrorRetries; - - const loadImpl = async (response, next) => { - const requestInstance = await requestPromise; - - const subscribeNext = async () => { - if (options.subscribe && !unsubscribed) { - if (requestInstance.unsubscribe) { - return next(); - } else { - await new Promise(resolve => setTimeout(() => resolve(), this.pollInterval * 1000)); - return next(); - } - } - return null; - }; - - const continueWait = async (wait) => { - if (!unsubscribed) { - if (wait) { - await new Promise(resolve => setTimeout(() => resolve(), this.pollInterval * 1000)); - } - return next(); - } - return null; - }; - - if (options.subscribe && !skipAuthorizationUpdate) { - await this.updateTransportAuthorization(); - } - - skipAuthorizationUpdate = false; - - if (response.status === 502 || - response.error && - response.error.toLowerCase() === 'network error' && - --networkRetries >= 0 - ) { - await checkMutex(); - return continueWait(true); - } - - let body = {}; - let text = ''; - try { - text = await response.text(); - body = JSON.parse(text); - } catch (_) { - body.error = text; - } - - if (body.error === 'Continue wait') { - await checkMutex(); - if (options.progressCallback) { - options.progressCallback(new ProgressResult(body)); - } - return continueWait(); - } - - if (response.status !== 200) { - await checkMutex(); - if (!options.subscribe && requestInstance.unsubscribe) { - await requestInstance.unsubscribe(); - } - - const error = new RequestError(body.error, body, response.status); // TODO error class - if (callback) { - callback(error); - } else { - throw error; - } - - return subscribeNext(); - } - await checkMutex(); - if (!options.subscribe && requestInstance.unsubscribe) { - await requestInstance.unsubscribe(); - } - const result = toResult(body); - if (callback) { - callback(null, result); - } else { - return result; - } - - return subscribeNext(); - }; - - const promise = requestPromise.then(requestInstance => mutexPromise(requestInstance.subscribe(loadImpl))); - - if (callback) { - return { - unsubscribe: async () => { - const requestInstance = await requestPromise; - - unsubscribed = true; - if (requestInstance.unsubscribe) { - return requestInstance.unsubscribe(); - } - return null; - } - }; - } else { - return promise; - } - } - - async updateTransportAuthorization() { - if (this.updateAuthorizationPromise) { - await this.updateAuthorizationPromise; - return; - } - - if (typeof this.apiToken === 'function') { - this.updateAuthorizationPromise = new Promise(async (resolve, reject) => { - try { - const token = await this.apiToken(); - if (this.transport.authorization !== token) { - this.transport.authorization = token; - } - resolve(); - } catch (error) { - reject(error); - } finally { - this.updateAuthorizationPromise = null; - } - }); - - await this.updateAuthorizationPromise; - } - } - - /** - * Add system properties to a query object. - * @param {Query} query - * @param {string} responseFormat - * @returns {void} - * @private - */ - patchQueryInternal(query, responseFormat) { - if ( - responseFormat === ResultType.COMPACT && - query.responseFormat !== ResultType.COMPACT - ) { - return { - ...query, - responseFormat: ResultType.COMPACT, - }; - } else { - return query; - } - } - - /** - * Process result fetched from the gateway#load method according - * to the network protocol. - * @param {*} response - * @returns ResultSet - * @private - */ - loadResponseInternal(response, options = {}) { - if ( - response.results.length - ) { - if (options.castNumerics) { - response.results.forEach((result) => { - const numericMembers = Object.entries({ - ...result.annotation.measures, - ...result.annotation.dimensions, - }).map(([k, v]) => { - if (v.type === 'number') { - return k; - } - - return undefined; - }).filter(Boolean); - - result.data = result.data.map((row) => { - numericMembers.forEach((key) => { - if (row[key] != null) { - row[key] = Number(row[key]); - } - }); - - return row; - }); - }); - } - - if (response.results[0].query.responseFormat && - response.results[0].query.responseFormat === ResultType.COMPACT) { - response.results.forEach((result, j) => { - const data = []; - result.data.dataset.forEach((r) => { - const row = {}; - result.data.members.forEach((m, i) => { - row[m] = r[i]; - }); - data.push(row); - }); - response.results[j].data = data; - }); - } - } - - return new ResultSet(response, { - parseDateMeasures: this.parseDateMeasures - }); - } - - load(query, options, callback, responseFormat = ResultType.DEFAULT) { - options = { - castNumerics: this.castNumerics, - ...options - }; - - if (responseFormat === ResultType.COMPACT) { - if (Array.isArray(query)) { - query = query.map((q) => this.patchQueryInternal(q, ResultType.COMPACT)); - } else { - query = this.patchQueryInternal(query, ResultType.COMPACT); - } - } - return this.loadMethod( - () => this.request('load', { - query, - queryType: 'multi', - signal: options.signal - }), - (response) => this.loadResponseInternal(response, options), - options, - callback - ); - } - - subscribe(query, options, callback, responseFormat = ResultType.DEFAULT) { - options = { - castNumerics: this.castNumerics, - ...options - }; - - if (responseFormat === ResultType.COMPACT) { - if (Array.isArray(query)) { - query = query.map((q) => this.patchQueryInternal(q, ResultType.COMPACT)); - } else { - query = this.patchQueryInternal(query, ResultType.COMPACT); - } - } - return this.loadMethod( - () => this.request('subscribe', { - query, - queryType: 'multi', - signal: options.signal - }), - (response) => this.loadResponseInternal(response, options), - { ...options, subscribe: true }, - callback - ); - } - - sql(query, options, callback) { - return this.loadMethod( - () => this.request('sql', { - query, - signal: options?.signal - }), - (response) => (Array.isArray(response) ? response.map((body) => new SqlQuery(body)) : new SqlQuery(response)), - options, - callback - ); - } - - meta(options, callback) { - return this.loadMethod( - () => this.request('meta', { - signal: options?.signal - }), - (body) => new Meta(body), - options, - callback - ); - } - - dryRun(query, options, callback) { - return this.loadMethod( - () => this.request('dry-run', { - query, - signal: options?.signal - }), - (response) => response, - options, - callback - ); - } -} - -export default (apiToken, options) => new CubeApi(apiToken, options); - -export { CubeApi, HttpTransport, ResultSet, RequestError, Meta }; -export * from './utils'; -export * from './time'; diff --git a/packages/cubejs-client-core/src/index.ts b/packages/cubejs-client-core/src/index.ts new file mode 100644 index 0000000000000..c2f32bb9cd063 --- /dev/null +++ b/packages/cubejs-client-core/src/index.ts @@ -0,0 +1,685 @@ +import { v4 as uuidv4 } from 'uuid'; +import ResultSet from './ResultSet'; +import SqlQuery from './SqlQuery'; +import Meta from './Meta'; +import ProgressResult from './ProgressResult'; +import HttpTransport, { ErrorResponse, ITransport, TransportOptions } from './HttpTransport'; +import RequestError from './RequestError'; +import { + ExtractTimeMembers, + LoadResponse, + MetaResponse, + PivotQuery, + ProgressResponse, + Query, + QueryOrder, + QueryType, + TransformedQuery +} from './types'; + +export type LoadMethodCallback = (error: Error | null, resultSet: T) => void; + +export type LoadMethodOptions = { + /** + * Key to store the current request's MUTEX inside the `mutexObj`. MUTEX object is used to reject orphaned queries results when new queries are sent. For example: if two queries are sent with the same `mutexKey` only the last one will return results. + */ + mutexKey?: string; + /** + * Object to store MUTEX + */ + mutexObj?: { [key: string]: any }; + /** + * Pass `true` to use continuous fetch behavior. + */ + subscribe?: boolean; + /** + * A Cube API instance. If not provided will be taken from `CubeProvider` + */ + // eslint-disable-next-line no-use-before-define + cubeApi?: CubeApi; + /** + * If enabled, all members of the 'number' type will be automatically converted to numerical values on the client side + */ + castNumerics?: boolean; + /** + * Function that receives `ProgressResult` on each `Continue wait` message. + */ + progressCallback?(result: ProgressResult): void; + /** + * AbortSignal to cancel requests + */ + signal?: AbortSignal; +}; + +export type DeeplyReadonly = { + readonly [K in keyof T]: DeeplyReadonly; +}; + +export type ExtractMembers> = + | (T extends { dimensions: readonly (infer Names)[]; } ? Names : never) + | (T extends { measures: readonly (infer Names)[]; } ? Names : never) + | (T extends { timeDimensions: (infer U); } ? ExtractTimeMembers : never); + +// If we can't infer any members at all, then return any. +export type SingleQueryRecordType> = ExtractMembers extends never + ? any + : { [K in string & ExtractMembers]: string | number | boolean | null }; + +export type QueryArrayRecordType> = + T extends readonly [infer First, ...infer Rest] + ? SingleQueryRecordType> | QueryArrayRecordType> + : never; + +export type QueryRecordType> = + T extends DeeplyReadonly ? QueryArrayRecordType : + T extends DeeplyReadonly ? SingleQueryRecordType : + never; + +export interface UnsubscribeObj { + /** + * Allows to stop requests in-flight in long polling or web socket subscribe loops. + * It doesn't cancel any submitted requests to the underlying databases. + */ + unsubscribe(): Promise; +} + +/** + * @deprecated use DryRunResponse + */ +export type TDryRunResponse = { + queryType: QueryType; + normalizedQueries: Query[]; + pivotQuery: PivotQuery; + queryOrder: Array<{ [k: string]: QueryOrder }>; + transformedQueries: TransformedQuery[]; +}; + +export type DryRunResponse = { + queryType: QueryType; + normalizedQueries: Query[]; + pivotQuery: PivotQuery; + queryOrder: Array<{ [k: string]: QueryOrder }>; + transformedQueries: TransformedQuery[]; +}; + +interface BodyResponse { + error?: string; + [key: string]: any; +} + +let mutexCounter = 0; + +const MUTEX_ERROR = 'Mutex has been changed'; + +function mutexPromise(promise: Promise) { + return promise + .then((result) => result) + .catch((error) => { + if (error !== MUTEX_ERROR) { + throw error; + } + }); +} + +export type ResponseFormat = 'compact' | 'default' | undefined; + +export type CubeApiOptions = { + /** + * URL of your Cube.js Backend. By default, in the development environment it is `http://localhost:4000/cubejs-api/v1` + */ + apiUrl: string; + /** + * Transport implementation to use. [HttpTransport](#http-transport) will be used by default. + */ + transport?: ITransport; + method?: TransportOptions['method']; + headers?: TransportOptions['headers']; + pollInterval?: number; + credentials?: TransportOptions['credentials']; + parseDateMeasures?: boolean; + resType?: 'default' | 'compact'; + castNumerics?: boolean; + /** + * How many network errors would be retried before returning to users. Default to 0. + */ + networkErrorRetries?: number; + /** + * AbortSignal to cancel requests + */ + signal?: AbortSignal; + /** + * Fetch timeout in milliseconds. Would be passed as AbortSignal.timeout() + */ + fetchTimeout?: number; +}; + +/** + * Main class for accessing Cube API + */ +class CubeApi { + private readonly apiToken: string | (() => Promise) | (CubeApiOptions & any[]) | undefined; + + private readonly apiUrl: string; + + private readonly method: TransportOptions['method']; + + private readonly headers: TransportOptions['headers']; + + private readonly credentials: TransportOptions['credentials']; + + protected readonly transport: ITransport; + + private readonly pollInterval: number; + + private readonly parseDateMeasures: boolean | undefined; + + private readonly castNumerics: boolean; + + private readonly networkErrorRetries: number; + + private updateAuthorizationPromise: Promise | null; + + public constructor(apiToken: string | (() => Promise) | undefined, options: CubeApiOptions); + + public constructor(options: CubeApiOptions); + + /** + * Creates an instance of the `CubeApi`. The API entry point. + * + * ```js + * import cube from '@cubejs-client/core'; + * const cubeApi = cube( + * 'CUBE-API-TOKEN', + * { apiUrl: 'http://localhost:4000/cubejs-api/v1' } + * ); + * ``` + * + * You can also pass an async function or a promise that will resolve to the API token + * + * ```js + * import cube from '@cubejs-client/core'; + * const cubeApi = cube( + * async () => await Auth.getJwtToken(), + * { apiUrl: 'http://localhost:4000/cubejs-api/v1' } + * ); + * ``` + */ + public constructor( + apiToken: string | (() => Promise) | undefined | CubeApiOptions, + options?: CubeApiOptions + ) { + if (apiToken && !Array.isArray(apiToken) && typeof apiToken === 'object') { + options = apiToken; + apiToken = undefined; + } + + if (!options || (!options.transport && !options.apiUrl)) { + throw new Error('The `apiUrl` option is required'); + } + + this.apiToken = apiToken; + this.apiUrl = options.apiUrl; + this.method = options.method; + this.headers = options.headers || {}; + this.credentials = options.credentials; + + this.transport = options.transport || new HttpTransport({ + authorization: typeof apiToken === 'string' ? apiToken : undefined, + apiUrl: this.apiUrl, + method: this.method, + headers: this.headers, + credentials: this.credentials, + fetchTimeout: options.fetchTimeout, + signal: options.signal + }); + + this.pollInterval = options.pollInterval || 5; + this.parseDateMeasures = options.parseDateMeasures; + this.castNumerics = typeof options.castNumerics === 'boolean' ? options.castNumerics : false; + this.networkErrorRetries = options.networkErrorRetries || 0; + + this.updateAuthorizationPromise = null; + } + + protected request(method: string, params?: any) { + return this.transport.request(method, { + baseRequestId: uuidv4(), + ...params + }); + } + + private loadMethod(request: CallableFunction, toResult: CallableFunction, options?: LoadMethodOptions, callback?: CallableFunction) { + const mutexValue = ++mutexCounter; + if (typeof options === 'function' && !callback) { + callback = options; + options = undefined; + } + + options = options || {}; + + const mutexKey = options.mutexKey || 'default'; + if (options.mutexObj) { + options.mutexObj[mutexKey] = mutexValue; + } + + const requestPromise = this + .updateTransportAuthorization() + .then(() => request()); + + let skipAuthorizationUpdate = true; + let unsubscribed = false; + + const checkMutex = async () => { + const requestInstance = await requestPromise; + + if (options && + options.mutexObj && + options.mutexObj[mutexKey] !== mutexValue + ) { + unsubscribed = true; + if (requestInstance.unsubscribe) { + await requestInstance.unsubscribe(); + } + throw MUTEX_ERROR; + } + }; + + let networkRetries = this.networkErrorRetries; + + const loadImpl = async (response: Response | ErrorResponse, next: CallableFunction) => { + const requestInstance = await requestPromise; + + const subscribeNext = async () => { + if (options?.subscribe && !unsubscribed) { + if (requestInstance.unsubscribe) { + return next(); + } else { + await new Promise(resolve => setTimeout(() => resolve(), this.pollInterval * 1000)); + return next(); + } + } + return null; + }; + + const continueWait = async (wait: boolean = false) => { + if (!unsubscribed) { + if (wait) { + await new Promise(resolve => setTimeout(() => resolve(), this.pollInterval * 1000)); + } + return next(); + } + return null; + }; + + if (options?.subscribe && !skipAuthorizationUpdate) { + await this.updateTransportAuthorization(); + } + + skipAuthorizationUpdate = false; + + if (('status' in response && response.status === 502) || + ('error' in response && response.error?.toLowerCase() === 'network error') && + --networkRetries >= 0 + ) { + await checkMutex(); + return continueWait(true); + } + + // From here we're sure that response is only fetch Response + response = (response as Response); + let body: BodyResponse = {}; + let text = ''; + try { + text = await response.text(); + body = JSON.parse(text); + } catch (_) { + body.error = text; + } + + if (body.error === 'Continue wait') { + await checkMutex(); + if (options?.progressCallback) { + options.progressCallback(new ProgressResult(body as ProgressResponse)); + } + return continueWait(); + } + + if (response.status !== 200) { + await checkMutex(); + if (!options?.subscribe && requestInstance.unsubscribe) { + await requestInstance.unsubscribe(); + } + + const error = new RequestError(body.error || '', body, response.status); + if (callback) { + callback(error); + } else { + throw error; + } + + return subscribeNext(); + } + await checkMutex(); + if (!options?.subscribe && requestInstance.unsubscribe) { + await requestInstance.unsubscribe(); + } + const result = toResult(body); + if (callback) { + callback(null, result); + } else { + return result; + } + + return subscribeNext(); + }; + + const promise = requestPromise.then(requestInstance => mutexPromise(requestInstance.subscribe(loadImpl))); + + if (callback) { + return { + unsubscribe: async () => { + const requestInstance = await requestPromise; + + unsubscribed = true; + if (requestInstance.unsubscribe) { + return requestInstance.unsubscribe(); + } + return null; + } + }; + } else { + return promise; + } + } + + private async updateTransportAuthorization() { + if (this.updateAuthorizationPromise) { + await this.updateAuthorizationPromise; + return; + } + + const tokenFetcher = this.apiToken; + + if (typeof tokenFetcher === 'function') { + const promise = (async () => { + try { + const token = await tokenFetcher(); + + if (this.transport.authorization !== token) { + this.transport.authorization = token; + } + } finally { + this.updateAuthorizationPromise = null; + } + })(); + + this.updateAuthorizationPromise = promise; + await promise; + } + } + + /** + * Add system properties to a query object. + */ + private patchQueryInternal(query: DeeplyReadonly, responseFormat: ResponseFormat): DeeplyReadonly { + if ( + responseFormat === 'compact' && + query.responseFormat !== 'compact' + ) { + return { + ...query, + responseFormat: 'compact', + }; + } else { + return query; + } + } + + /** + * Process result fetched from the gateway#load method according + * to the network protocol. + */ + protected loadResponseInternal(response: LoadResponse, options: LoadMethodOptions | null = {}): ResultSet { + if ( + response.results.length + ) { + if (options?.castNumerics) { + response.results.forEach((result) => { + const numericMembers = Object.entries({ + ...result.annotation.measures, + ...result.annotation.dimensions, + }).reduce((acc, [k, v]) => { + if (v.type === 'number') { + acc.push(k); + } + return acc; + }, []); + + result.data = result.data.map((row) => { + numericMembers.forEach((key) => { + if (row[key] != null) { + row[key] = Number(row[key]); + } + }); + + return row; + }); + }); + } + + if (response.results[0].query.responseFormat && + response.results[0].query.responseFormat === 'compact') { + response.results.forEach((result, j) => { + const data: Record[] = []; + const { dataset, members } = result.data as unknown as { dataset: any[]; members: string[] }; + dataset.forEach((r) => { + const row: Record = {}; + members.forEach((m, i) => { + row[m] = r[i]; + }); + data.push(row); + }); + response.results[j].data = data; + }); + } + } + + return new ResultSet(response, { + parseDateMeasures: this.parseDateMeasures + }); + } + + public load>( + query: QueryType, + options?: LoadMethodOptions, + ): Promise>>; + + public load>( + query: QueryType, + options?: LoadMethodOptions, + callback?: LoadMethodCallback>>, + ): UnsubscribeObj; + + public load>( + query: QueryType, + options?: LoadMethodOptions, + callback?: LoadMethodCallback>, + responseFormat?: string + ): Promise>>; + + /** + * Fetch data for the passed `query`. + * + * ```js + * import cube from '@cubejs-client/core'; + * import Chart from 'chart.js'; + * import chartjsConfig from './toChartjsData'; + * + * const cubeApi = cube('CUBEJS_TOKEN'); + * + * const resultSet = await cubeApi.load({ + * measures: ['Stories.count'], + * timeDimensions: [{ + * dimension: 'Stories.time', + * dateRange: ['2015-01-01', '2015-12-31'], + * granularity: 'month' + * }] + * }); + * + * const context = document.getElementById('myChart'); + * new Chart(context, chartjsConfig(resultSet)); + * ``` + * @param query - [Query object](/product/apis-integrations/rest-api/query-format) + * @param options + * @param callback + * @param responseFormat + */ + public load>(query: QueryType, options?: LoadMethodOptions, callback?: CallableFunction, responseFormat: ResponseFormat = 'default') { + [query, options] = this.prepareQueryOptions(query, options, responseFormat); + return this.loadMethod( + () => this.request('load', { + query, + queryType: 'multi', + signal: options?.signal + }), + (response: any) => this.loadResponseInternal(response, options), + options, + callback + ); + } + + private prepareQueryOptions>(query: QueryType, options?: LoadMethodOptions | null, responseFormat: ResponseFormat = 'default'): [query: QueryType, options: LoadMethodOptions] { + options = { + castNumerics: this.castNumerics, + ...options + }; + + if (responseFormat === 'compact') { + if (Array.isArray(query)) { + const patched = query.map((q) => this.patchQueryInternal(q, 'compact')); + return [patched as unknown as QueryType, options]; + } else { + const patched = this.patchQueryInternal(query as DeeplyReadonly, 'compact'); + return [patched as QueryType, options]; + } + } + + return [query, options]; + } + + /** + * Allows you to fetch data and receive updates over time. See [Real-Time Data Fetch](/product/apis-integrations/rest-api/real-time-data-fetch) + * + * ```js + * // Subscribe to a query's updates + * const subscription = await cubeApi.subscribe( + * { + * measures: ['Logs.count'], + * timeDimensions: [ + * { + * dimension: 'Logs.time', + * granularity: 'hour', + * dateRange: 'last 1440 minutes', + * }, + * ], + * }, + * options, + * (error, resultSet) => { + * if (!error) { + * // handle the update + * } + * } + * ); + * + * // Unsubscribe from a query's updates + * subscription.unsubscribe(); + * ``` + */ + public subscribe>( + query: QueryType, + options: LoadMethodOptions | null, + callback: LoadMethodCallback>>, + responseFormat: ResponseFormat = 'default' + ): UnsubscribeObj { + [query, options] = this.prepareQueryOptions(query, options, responseFormat); + return this.loadMethod( + () => this.request('subscribe', { + query, + queryType: 'multi', + signal: options?.signal + }), + (response: any) => this.loadResponseInternal(response, options), + { ...options, subscribe: true }, + callback + ) as UnsubscribeObj; + } + + public sql(query: DeeplyReadonly, options?: LoadMethodOptions): Promise; + + public sql(query: DeeplyReadonly, options?: LoadMethodOptions, callback?: LoadMethodCallback): UnsubscribeObj; + + /** + * Get generated SQL string for the given `query`. + */ + public sql(query: DeeplyReadonly, options?: LoadMethodOptions, callback?: LoadMethodCallback): Promise | UnsubscribeObj { + return this.loadMethod( + () => this.request('sql', { + query, + signal: options?.signal + }), + (response: any) => (Array.isArray(response) ? response.map((body) => new SqlQuery(body)) : new SqlQuery(response)), + options, + callback + ); + } + + public meta(options?: LoadMethodOptions): Promise; + + public meta(options?: LoadMethodOptions, callback?: LoadMethodCallback): UnsubscribeObj; + + /** + * Get meta description of cubes available for querying. + */ + public meta(options?: LoadMethodOptions, callback?: LoadMethodCallback): Promise | UnsubscribeObj { + return this.loadMethod( + () => this.request('meta', { + signal: options?.signal + }), + (body: MetaResponse) => new Meta(body), + options, + callback + ); + } + + public dryRun(query: DeeplyReadonly, options?: LoadMethodOptions): Promise; + + public dryRun(query: DeeplyReadonly, options: LoadMethodOptions, callback?: LoadMethodCallback): UnsubscribeObj; + + /** + * Get query related meta without query execution + */ + public dryRun(query: DeeplyReadonly, options?: LoadMethodOptions, callback?: LoadMethodCallback): Promise | UnsubscribeObj { + return this.loadMethod( + () => this.request('dry-run', { + query, + signal: options?.signal + }), + (response: DryRunResponse) => response, + options, + callback + ); + } +} + +export default (apiToken: string | (() => Promise), options: CubeApiOptions) => new CubeApi(apiToken, options); + +export { CubeApi }; +export { default as Meta } from './Meta'; +export { default as SqlQuery } from './SqlQuery'; +export { default as RequestError } from './RequestError'; +export { default as ProgressResult } from './ProgressResult'; +export { default as ResultSet } from './ResultSet'; +export * from './HttpTransport'; +export * from './utils'; +export * from './time'; +export * from './types'; diff --git a/packages/cubejs-client-core/src/index.umd.js b/packages/cubejs-client-core/src/index.umd.js deleted file mode 100644 index 635c3171426e4..0000000000000 --- a/packages/cubejs-client-core/src/index.umd.js +++ /dev/null @@ -1,8 +0,0 @@ -import cube from './index'; -import * as clientCoreExports from './index'; - -Object.keys(clientCoreExports).forEach((key) => { - cube[key] = clientCoreExports[key]; -}); - -export default cube; diff --git a/packages/cubejs-client-core/src/index.umd.ts b/packages/cubejs-client-core/src/index.umd.ts new file mode 100644 index 0000000000000..c9906e017b67f --- /dev/null +++ b/packages/cubejs-client-core/src/index.umd.ts @@ -0,0 +1,9 @@ +import cube, * as clientCoreExports from './index'; + +const cubeAll: any = cube; + +Object.keys(clientCoreExports).forEach((key) => { + cubeAll[key] = (clientCoreExports as Record)[key]; +}); + +export default cubeAll; diff --git a/packages/cubejs-client-core/src/tests/CubeApi.test.js b/packages/cubejs-client-core/src/tests/CubeApi.test.js deleted file mode 100644 index 9940999e4252c..0000000000000 --- a/packages/cubejs-client-core/src/tests/CubeApi.test.js +++ /dev/null @@ -1,219 +0,0 @@ -/** - * @license MIT License - * @copyright Cube Dev, Inc. - * @fileoverview Test signal parameter in CubeApi - */ - -/* globals describe,test,expect,jest,beforeEach */ - -import 'jest'; -import { CubeApi } from '../index'; -import HttpTransport from '../HttpTransport'; - -describe('CubeApi with Signal', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - test('should pass signal from constructor to request', async () => { - const controller = new AbortController(); - const { signal } = controller; - - // Create a spy on the request method - const requestSpy = jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ - subscribe: (cb) => Promise.resolve(cb({ - status: 200, - text: () => Promise.resolve('{"results":[]}'), - json: () => Promise.resolve({ results: [] }) - })) - })); - - const cubeApi = new CubeApi('token', { - apiUrl: 'http://localhost:4000/cubejs-api/v1', - signal - }); - - // Create a second spy on the load method to verify signal is passed to HttpTransport - jest.spyOn(cubeApi, 'load'); - await cubeApi.load({ - measures: ['Orders.count'] - }); - - // Check if the signal was passed to request method through load - expect(requestSpy).toHaveBeenCalled(); - - // The request method should receive the signal in the call - // Create a request in the same way as CubeApi.load does - cubeApi.request('load', { - query: { measures: ['Orders.count'] }, - queryType: 'multi' - }); - - // Verify the transport is using the signal - expect(cubeApi.transport.signal).toBe(signal); - }); - - test('should pass signal from options to request', async () => { - const controller = new AbortController(); - const { signal } = controller; - - // Mock for this specific test - jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ - subscribe: (cb) => Promise.resolve(cb({ - status: 200, - text: () => Promise.resolve('{"results":[]}'), - json: () => Promise.resolve({ results: [] }) - })) - })); - - const cubeApi = new CubeApi('token', { - apiUrl: 'http://localhost:4000/cubejs-api/v1' - }); - - await cubeApi.load( - { measures: ['Orders.count'] }, - { signal } - ); - - expect(HttpTransport.prototype.request).toHaveBeenCalled(); - expect(HttpTransport.prototype.request.mock.calls[0][1].signal).toBe(signal); - }); - - test('options signal should override constructor signal', async () => { - const constructorController = new AbortController(); - const optionsController = new AbortController(); - - // Mock for this specific test - jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ - subscribe: (cb) => Promise.resolve(cb({ - status: 200, - text: () => Promise.resolve('{"results":[]}'), - json: () => Promise.resolve({ results: [] }) - })) - })); - - const cubeApi = new CubeApi('token', { - apiUrl: 'http://localhost:4000/cubejs-api/v1', - signal: constructorController.signal - }); - - await cubeApi.load( - { measures: ['Orders.count'] }, - { signal: optionsController.signal } - ); - - expect(HttpTransport.prototype.request).toHaveBeenCalled(); - expect(HttpTransport.prototype.request.mock.calls[0][1].signal).toBe(optionsController.signal); - expect(HttpTransport.prototype.request.mock.calls[0][1].signal).not.toBe(constructorController.signal); - }); - - test('should pass signal to meta request', async () => { - const controller = new AbortController(); - const { signal } = controller; - - // Mock for meta with proper format - include dimensions, segments, and measures with required properties - jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ - subscribe: (cb) => Promise.resolve(cb({ - status: 200, - text: () => Promise.resolve(JSON.stringify({ - cubes: [{ - name: 'Orders', - title: 'Orders', - measures: [{ - name: 'count', - title: 'Count', - shortTitle: 'Count', - type: 'number' - }], - dimensions: [{ - name: 'status', - title: 'Status', - type: 'string' - }], - segments: [] - }] - })), - json: () => Promise.resolve({ - cubes: [{ - name: 'Orders', - title: 'Orders', - measures: [{ - name: 'count', - title: 'Count', - shortTitle: 'Count', - type: 'number' - }], - dimensions: [{ - name: 'status', - title: 'Status', - type: 'string' - }], - segments: [] - }] - }) - })) - })); - - const cubeApi = new CubeApi('token', { - apiUrl: 'http://localhost:4000/cubejs-api/v1' - }); - - await cubeApi.meta({ signal }); - - expect(HttpTransport.prototype.request).toHaveBeenCalled(); - expect(HttpTransport.prototype.request.mock.calls[0][1].signal).toBe(signal); - }); - - test('should pass signal to sql request', async () => { - const controller = new AbortController(); - const { signal } = controller; - - // Mock for SQL response - jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ - subscribe: (cb) => Promise.resolve(cb({ - status: 200, - text: () => Promise.resolve('{"sql":{"sql":"SELECT * FROM orders"}}'), - json: () => Promise.resolve({ sql: { sql: 'SELECT * FROM orders' } }) - })) - })); - - const cubeApi = new CubeApi('token', { - apiUrl: 'http://localhost:4000/cubejs-api/v1' - }); - - await cubeApi.sql( - { measures: ['Orders.count'] }, - { signal } - ); - - expect(HttpTransport.prototype.request).toHaveBeenCalled(); - expect(HttpTransport.prototype.request.mock.calls[0][1].signal).toBe(signal); - }); - - test('should pass signal to dryRun request', async () => { - const controller = new AbortController(); - const { signal } = controller; - - // Mock for dryRun response - jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ - subscribe: (cb) => Promise.resolve(cb({ - status: 200, - text: () => Promise.resolve('{"queryType":"regular"}'), - json: () => Promise.resolve({ queryType: 'regular' }) - })) - })); - - const cubeApi = new CubeApi('token', { - apiUrl: 'http://localhost:4000/cubejs-api/v1' - }); - - await cubeApi.dryRun( - { measures: ['Orders.count'] }, - { signal } - ); - - expect(HttpTransport.prototype.request).toHaveBeenCalled(); - expect(HttpTransport.prototype.request.mock.calls[0][1].signal).toBe(signal); - }); -}); diff --git a/packages/cubejs-client-core/src/time.js b/packages/cubejs-client-core/src/time.ts similarity index 78% rename from packages/cubejs-client-core/src/time.js rename to packages/cubejs-client-core/src/time.ts index 3ac4e8e01762c..94b5144d134e7 100644 --- a/packages/cubejs-client-core/src/time.js +++ b/packages/cubejs-client-core/src/time.ts @@ -8,7 +8,42 @@ dayjs.extend(quarterOfYear); dayjs.extend(duration); dayjs.extend(isoWeek); -export const GRANULARITIES = [ +export type SqlInterval = string; + +// TODO: Define a better type as unitOfTime.DurationConstructor in moment.js +export type ParsedInterval = Record; + +export type Granularity = { + interval: SqlInterval; + origin?: string; + offset?: SqlInterval; +}; + +export type DayRange = { + by: (value: any) => dayjs.Dayjs[]; + snapTo: (value: any) => DayRange; + start: dayjs.Dayjs; + end: dayjs.Dayjs; +}; + +export type TimeDimensionPredefinedGranularity = + 'second' + | 'minute' + | 'hour' + | 'day' + | 'week' + | 'month' + | 'quarter' + | 'year'; + +export type TimeDimensionGranularity = TimeDimensionPredefinedGranularity | string; + +export type TGranularityMap = { + name: TimeDimensionGranularity | undefined; + title: string; +}; + +export const GRANULARITIES: TGranularityMap[] = [ { name: undefined, title: 'w/o grouping' }, { name: 'second', title: 'Second' }, { name: 'minute', title: 'Minute' }, @@ -24,9 +59,9 @@ export const DEFAULT_GRANULARITY = 'day'; // When granularity is week, weekStart Value must be 1. However, since the client can change it globally // (https://day.js.org/docs/en/i18n/changing-locale) So the function below has been added. -export const internalDayjs = (...args) => dayjs(...args).locale({ ...en, weekStart: 1 }); +export const internalDayjs = (...args: any[]): dayjs.Dayjs => dayjs(...args).locale({ ...en, weekStart: 1 }); -export const TIME_SERIES = { +export const TIME_SERIES: Record string[]> = { day: (range) => range.by('d').map(d => d.format('YYYY-MM-DDT00:00:00.000')), month: (range) => range.snapTo('month').by('M').map(d => d.format('YYYY-MM-01T00:00:00.000')), year: (range) => range.snapTo('year').by('y').map(d => d.format('YYYY-01-01T00:00:00.000')), @@ -39,13 +74,13 @@ export const TIME_SERIES = { )), }; -export const isPredefinedGranularity = (granularity) => !!TIME_SERIES[granularity]; +export const isPredefinedGranularity = (granularity: TimeDimensionGranularity): boolean => !!TIME_SERIES[granularity]; export const DateRegex = /^\d\d\d\d-\d\d-\d\d$/; export const LocalDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z?$/; -export const dayRange = (from, to) => ({ - by: (value) => { +export const dayRange = (from: any, to: any): DayRange => ({ + by: (value: any) => { const results = []; let start = internalDayjs(from); @@ -58,7 +93,7 @@ export const dayRange = (from, to) => ({ return results; }, - snapTo: (value) => dayRange(internalDayjs(from).startOf(value), internalDayjs(to).endOf(value)), + snapTo: (value: any): DayRange => dayRange(internalDayjs(from).startOf(value), internalDayjs(to).endOf(value)), start: internalDayjs(from), end: internalDayjs(to), }); @@ -73,8 +108,8 @@ export const dayRange = (from, to) => ({ * It's not referenced to omit imports of moment.js staff. * Probably one day we should choose one implementation and reuse it in other places. */ -export function parseSqlInterval(intervalStr) { - const interval = {}; +export function parseSqlInterval(intervalStr: SqlInterval): ParsedInterval { + const interval: ParsedInterval = {}; const parts = intervalStr.split(/\s+/); for (let i = 0; i < parts.length; i += 2) { @@ -97,7 +132,7 @@ export function parseSqlInterval(intervalStr) { * @param interval * @returns {dayjs} */ -export function addInterval(date, interval) { +export function addInterval(date: dayjs.Dayjs, interval: ParsedInterval): dayjs.Dayjs { let res = date.clone(); Object.entries(interval).forEach(([key, value]) => { @@ -115,7 +150,7 @@ export function addInterval(date, interval) { * @param interval * @returns {dayjs} */ -export function subtractInterval(date, interval) { +export function subtractInterval(date: dayjs.Dayjs, interval: ParsedInterval): dayjs.Dayjs { let res = date.clone(); Object.entries(interval).forEach(([key, value]) => { @@ -130,7 +165,7 @@ export function subtractInterval(date, interval) { * TODO: It's copy/paste of alignToOrigin from @cubejs-backend/shared [time.ts] * but operates with dayjs instead of moment.js */ -function alignToOrigin(startDate, interval, origin) { +function alignToOrigin(startDate: dayjs.Dayjs, interval: ParsedInterval, origin: dayjs.Dayjs): dayjs.Dayjs { let alignedDate = startDate.clone(); let intervalOp; let isIntervalNegative = false; @@ -172,7 +207,7 @@ function alignToOrigin(startDate, interval, origin) { * TODO: It's almost a copy/paste of timeSeriesFromCustomInterval from * @cubejs-backend/shared [time.ts] but operates with dayjs instead of moment.js */ -export const timeSeriesFromCustomInterval = (from, to, granularity) => { +export const timeSeriesFromCustomInterval = (from: string, to: string, granularity: Granularity): string[] => { const intervalParsed = parseSqlInterval(granularity.interval); const start = internalDayjs(from); const end = internalDayjs(to); @@ -194,11 +229,8 @@ export const timeSeriesFromCustomInterval = (from, to, granularity) => { /** * Returns the lowest time unit for the interval - * @protected - * @param {string} interval - * @returns {string} */ -export const diffTimeUnitForInterval = (interval) => { +export const diffTimeUnitForInterval = (interval: string): string => { if (/second/i.test(interval)) { return 'second'; } else if (/minute/i.test(interval)) { @@ -220,7 +252,7 @@ export const diffTimeUnitForInterval = (interval) => { const granularityOrder = ['year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second']; -export const minGranularityForIntervals = (i1, i2) => { +export const minGranularityForIntervals = (i1: string, i2: string): string => { const g1 = diffTimeUnitForInterval(i1); const g2 = diffTimeUnitForInterval(i2); const g1pos = granularityOrder.indexOf(g1); @@ -233,7 +265,7 @@ export const minGranularityForIntervals = (i1, i2) => { return g2; }; -export const granularityFor = (dateStr) => { +export const granularityFor = (dateStr: string): string => { const dayjsDate = internalDayjs(dateStr); const month = dayjsDate.month(); const date = dayjsDate.date(); diff --git a/packages/cubejs-client-core/src/types.ts b/packages/cubejs-client-core/src/types.ts new file mode 100644 index 0000000000000..1d408fd4aabb9 --- /dev/null +++ b/packages/cubejs-client-core/src/types.ts @@ -0,0 +1,520 @@ +import Meta from './Meta'; +import { TimeDimensionGranularity } from './time'; +import { TransportOptions } from './HttpTransport'; + +export type QueryOrder = 'asc' | 'desc' | 'none'; + +export type TQueryOrderObject = { [key: string]: QueryOrder }; +export type TQueryOrderArray = Array<[string, QueryOrder]>; + +export type GranularityAnnotation = { + name: string; + title: string; + interval: string; + offset?: string; + origin?: string; +}; + +export type Annotation = { + title: string; + shortTitle: string; + type: string; + meta?: any; + format?: 'currency' | 'percent' | 'number'; + drillMembers?: any[]; + drillMembersGrouped?: any; + granularity?: GranularityAnnotation; +}; + +export type QueryAnnotations = { + dimensions: Record; + measures: Record; + timeDimensions: Record; + segments: Record; +}; + +export type QueryType = 'regularQuery' | 'compareDateRangeQuery' | 'blendingQuery'; + +export type DateRange = string | [string, string]; + +export interface TimeDimensionBase { + dimension: string; + granularity?: TimeDimensionGranularity; + dateRange?: DateRange; +} + +export interface TimeDimensionComparison extends TimeDimensionBase { + compareDateRange: Array; +} + +export type TimeDimension = TimeDimensionBase | TimeDimensionComparison; + +// eslint-disable-next-line no-use-before-define +export type Filter = BinaryFilter | UnaryFilter | LogicalOrFilter | LogicalAndFilter; + +export type LogicalAndFilter = { + and: Filter[]; +}; + +export type LogicalOrFilter = { + or: Filter[]; +}; + +export type UnaryOperator = 'set' | 'notSet'; + +export type BinaryOperator = + | 'equals' + | 'notEquals' + | 'contains' + | 'notContains' + | 'startsWith' + | 'notStartsWith' + | 'endsWith' + | 'notEndsWith' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'inDateRange' + | 'notInDateRange' + | 'beforeDate' + | 'beforeOrOnDate' + | 'afterDate' + | 'afterOrOnDate'; + +export interface BinaryFilter { + /** + * @deprecated Use `member` instead. + */ + dimension?: string; + member?: string; + operator: BinaryOperator; + values: string[]; +} + +export interface UnaryFilter { + /** + * @deprecated Use `member` instead. + */ + dimension?: string; + member?: string; + operator: UnaryOperator; + values?: never; +} + +export interface Query { + measures?: string[]; + dimensions?: string[]; + filters?: Filter[]; + timeDimensions?: TimeDimension[]; + segments?: string[]; + limit?: null | number; + rowLimit?: null | number; + offset?: number; + order?: TQueryOrderObject | TQueryOrderArray; + timezone?: string; + renewQuery?: boolean; + ungrouped?: boolean; + responseFormat?: 'compact' | 'default'; + total?: boolean; +} + +export type PivotQuery = Query & { + queryType: QueryType; +}; + +type LeafMeasure = { + measure: string; + additive: boolean; + type: 'count' | 'countDistinct' | 'sum' | 'min' | 'max' | 'number' | 'countDistinctApprox' +}; + +export type TransformedQuery = { + allFiltersWithinSelectedDimensions: boolean; + granularityHierarchies: Record; + hasMultipliedMeasures: boolean; + hasNoTimeDimensionsWithoutGranularity: boolean; + isAdditive: boolean; + leafMeasureAdditive: boolean; + leafMeasures: string[]; + measures: string[]; + sortedDimensions: string[]; + sortedTimeDimensions: [[string, string]]; + measureToLeafMeasures?: Record; + ownedDimensions: string[]; + ownedTimeDimensionsAsIs: [[string, string | null]]; + ownedTimeDimensionsWithRollupGranularity: [[string, string]]; +}; + +export type PreAggregationType = 'rollup' | 'rollupJoin' | 'rollupLambda' | 'originalSql'; + +export type UsedPreAggregation = { + targetTableName: string; + type: PreAggregationType; +}; + +export type LoadResponseResult = { + annotation: QueryAnnotations; + lastRefreshTime: string; + query: Query; + data: T[]; + external: boolean | null; + dbType: string; + extDbType: string; + requestId?: string; + usedPreAggregations?: Record; + transformedQuery?: TransformedQuery; + total?: number; +}; + +export type LoadResponse = { + queryType: QueryType; + results: LoadResponseResult[]; + pivotQuery: PivotQuery; + slowQuery?: boolean; + [key: string]: any; +}; + +export type PivotRow = { + xValues: Array; + yValuesArray: Array<[string[], string]>; +}; + +export type Pivot = any; +// { +// xValues: any; +// yValuesArray: any[]; +// }; + +/** + * Configuration object that contains information about pivot axes and other options. + * + * Let's apply `pivotConfig` and see how it affects the axes + * ```js + * // Example query + * { + * measures: ['Orders.count'], + * dimensions: ['Users.country', 'Users.gender'] + * } + * ``` + * If we put the `Users.gender` dimension on **y** axis + * ```js + * resultSet.tablePivot({ + * x: ['Users.country'], + * y: ['Users.gender', 'measures'] + * }) + * ``` + * + * The resulting table will look the following way + * + * | Users Country | male, Orders.count | female, Orders.count | + * | ------------- | ------------------ | -------------------- | + * | Australia | 3 | 27 | + * | Germany | 10 | 12 | + * | US | 5 | 7 | + * + * Now let's put the `Users.country` dimension on **y** axis instead + * ```js + * resultSet.tablePivot({ + * x: ['Users.gender'], + * y: ['Users.country', 'measures'], + * }); + * ``` + * + * in this case the `Users.country` values will be laid out on **y** or **columns** axis + * + * | Users Gender | Australia, Orders.count | Germany, Orders.count | US, Orders.count | + * | ------------ | ----------------------- | --------------------- | ---------------- | + * | male | 3 | 10 | 5 | + * | female | 27 | 12 | 7 | + * + * It's also possible to put the `measures` on **x** axis. But in either case it should always be the last item of the array. + * ```js + * resultSet.tablePivot({ + * x: ['Users.gender', 'measures'], + * y: ['Users.country'], + * }); + * ``` + * + * | Users Gender | measures | Australia | Germany | US | + * | ------------ | ------------ | --------- | ------- | --- | + * | male | Orders.count | 3 | 10 | 5 | + * | female | Orders.count | 27 | 12 | 7 | + */ +export type PivotConfig = { + joinDateRange?: ((pivots: Pivot[], joinDateRange: any) => PivotRow[]) | false; + /** + * Dimensions to put on **x** or **rows** axis. + */ + x?: string[]; + /** + * Dimensions to put on **y** or **columns** axis. + */ + y?: string[]; + /** + * If `true` missing dates on the time dimensions will be filled with fillWithValue or `0` by default for all measures.Note: the `fillMissingDates` option set to `true` will override any **order** applied to the query + */ + fillMissingDates?: boolean | null; + /** + * Value to autofill all the missing date's measure. + */ + fillWithValue?: string | number | null; + /** + * Give each series a prefix alias. Should have one entry for each query:measure. See [chartPivot](#result-set-chart-pivot) + */ + aliasSeries?: string[]; +}; + +export type PivotConfigFull = Omit & { + x: string[]; + y: string[]; +}; + +export type DrillDownLocator = { + xValues: string[]; + yValues?: string[]; +}; + +export type Series = { + key: string; + title: string; + shortTitle: string; + series: T[]; +}; + +export type Column = { + key: string; + title: string; + series: []; +}; + +export type SeriesNamesColumn = { + key: string; + title: string; + shortTitle: string; + yValues: string[]; +}; + +export type ChartPivotRow = { + x: string; + xValues: string[]; + [key: string]: any; +}; + +export type TableColumn = { + key: string; + dataIndex: string; + meta?: any; + type: string | number; + title: string; + shortTitle: string; + format?: any; + children?: TableColumn[]; +}; + +export type SerializedResult = { + loadResponse: LoadResponse; +}; + +export type ExtractTimeMember = + T extends { dimension: infer Dimension, granularity: infer Granularity } + ? Dimension | `${Dimension & string}.${Granularity & string}` + : never; + +export type ExtractTimeMembers = + T extends readonly [infer First, ...infer Rest] + ? ExtractTimeMember | ExtractTimeMembers + : never; + +export type MemberType = 'measures' | 'dimensions' | 'segments'; + +export type TOrderMember = { + id: string; + title: string; + order: QueryOrder | 'none'; +}; + +export type TCubeMemberType = 'time' | 'number' | 'string' | 'boolean'; + +// @see BaseCubeMember +// @deprecated +export type TCubeMember = { + type: TCubeMemberType; + name: string; + title: string; + shortTitle: string; + description?: string; + /** + * @deprecated use `public` instead + */ + isVisible?: boolean; + public?: boolean; + meta?: any; +}; + +export type BaseCubeMember = { + type: TCubeMemberType; + name: string; + title: string; + shortTitle: string; + description?: string; + /** + * @deprecated use `public` instead + */ + isVisible?: boolean; + public?: boolean; + meta?: any; +}; + +export type TCubeMeasure = BaseCubeMember & { + aggType: 'count' | 'number'; + cumulative: boolean; + cumulativeTotal: boolean; + drillMembers: string[]; + drillMembersGrouped: { + measures: string[]; + dimensions: string[]; + }; + format?: 'currency' | 'percent'; +}; + +export type CubeTimeDimensionGranularity = { + name: string; + title: string; +}; + +export type BaseCubeDimension = BaseCubeMember & { + primaryKey?: boolean; + suggestFilterValues: boolean; +}; + +export type CubeTimeDimension = BaseCubeDimension & + { type: 'time'; granularities?: CubeTimeDimensionGranularity[] }; + +export type TCubeDimension = + (BaseCubeDimension & { type: Exclude }) | + CubeTimeDimension; + +export type TCubeSegment = Omit; + +export type NotFoundMember = { + title: string; + error: string; +}; + +export type TCubeMemberByType = T extends 'measures' + ? TCubeMeasure + : T extends 'dimensions' + ? TCubeDimension + : T extends 'segments' + ? TCubeSegment + : never; + +export type CubeMember = TCubeMeasure | TCubeDimension | TCubeSegment; + +export type TCubeFolder = { + name: string; + members: string[]; +}; + +export type TCubeHierarchy = { + name: string; + title?: string; + levels: string[]; + public?: boolean; +}; + +/** + * @deprecated use DryRunResponse + */ +export type TDryRunResponse = { + queryType: QueryType; + normalizedQueries: Query[]; + pivotQuery: PivotQuery; + queryOrder: Array<{ [k: string]: QueryOrder }>; + transformedQueries: TransformedQuery[]; +}; + +export type DryRunResponse = { + queryType: QueryType; + normalizedQueries: Query[]; + pivotQuery: PivotQuery; + queryOrder: Array<{ [k: string]: QueryOrder }>; + transformedQueries: TransformedQuery[]; +}; + +export type Cube = { + name: string; + title: string; + description?: string; + measures: TCubeMeasure[]; + dimensions: TCubeDimension[]; + segments: TCubeSegment[]; + folders: TCubeFolder[]; + hierarchies: TCubeHierarchy[]; + connectedComponent?: number; + type?: 'view' | 'cube'; + /** + * @deprecated use `public` instead + */ + isVisible?: boolean; + public?: boolean; + meta?: any; +}; + +export type CubeMap = { + measures: Record; + dimensions: Record; + segments: Record; +}; + +export type CubesMap = Record< + string, + CubeMap +>; + +export type MetaResponse = { + cubes: Cube[]; +}; + +export type FilterOperator = { + name: string; + title: string; +}; + +export type TSourceAxis = 'x' | 'y'; + +export type ChartType = 'line' | 'bar' | 'table' | 'area' | 'number' | 'pie'; + +export type TDefaultHeuristicsOptions = { + meta: Meta; + sessionGranularity?: TimeDimensionGranularity; +}; + +export type TDefaultHeuristicsResponse = { + shouldApplyHeuristicOrder: boolean; + pivotConfig: PivotConfig | null; + query: Query; + chartType?: ChartType; + sessionGranularity?: TimeDimensionGranularity | null; +}; + +export type TDefaultHeuristicsState = { + query: Query; + chartType?: ChartType; +}; + +export interface TFlatFilter { + /** + * @deprecated Use `member` instead. + */ + dimension?: string; + member?: string; + operator: BinaryOperator | UnaryOperator; + values?: string[]; +} + +export type ProgressResponse = { + stage: string; + timeElapsed: number; +}; diff --git a/packages/cubejs-client-core/src/utils.js b/packages/cubejs-client-core/src/utils.ts similarity index 61% rename from packages/cubejs-client-core/src/utils.js rename to packages/cubejs-client-core/src/utils.ts index 48ef2e274a056..790bfe6f0a6ed 100644 --- a/packages/cubejs-client-core/src/utils.js +++ b/packages/cubejs-client-core/src/utils.ts @@ -1,56 +1,69 @@ import { clone, equals, fromPairs, indexBy, prop, toPairs } from 'ramda'; +import { DeeplyReadonly } from './index'; import { DEFAULT_GRANULARITY } from './time'; - -export function removeEmptyQueryFields(_query) { +import { + Filter, + PivotConfig, + Query, + QueryOrder, + TDefaultHeuristicsOptions, + TDefaultHeuristicsResponse, + TDefaultHeuristicsState, + TFlatFilter, + TOrderMember, + TQueryOrderArray, + TQueryOrderObject, + TSourceAxis +} from './types'; + +export function removeEmptyQueryFields(_query: DeeplyReadonly) { const query = _query || {}; return fromPairs( - toPairs(query) - .map(([key, value]) => { - if ( - ['measures', 'dimensions', 'segments', 'timeDimensions', 'filters'].includes(key) - ) { - if (Array.isArray(value) && value.length === 0) { - return null; - } + toPairs(query).flatMap(([key, value]) => { + if ( + ['measures', 'dimensions', 'segments', 'timeDimensions', 'filters'].includes(key) + ) { + if (Array.isArray(value) && value.length === 0) { + return []; } + } - if (key === 'order' && value) { - if (Array.isArray(value) && !value.length) { - return null; - } else if (!Object.keys(value).length) { - return null; - } + if (key === 'order' && value) { + if (Array.isArray(value) && value.length === 0) { + return []; + } else if (!Object.keys(value).length) { + return []; } + } - return [key, value]; - }) - .filter(Boolean) + return [[key, value]]; + }) ); } -export function validateQuery(_query) { +export function validateQuery(_query: DeeplyReadonly | null | undefined): Query { const query = _query || {}; return removeEmptyQueryFields({ ...query, - filters: (query.filters || []).filter((f) => f.operator), + filters: (query.filters || []).filter((f) => 'operator' in f), timeDimensions: (query.timeDimensions || []).filter( (td) => !(!td.dateRange && !td.granularity) ), }); } -export function areQueriesEqual(query1 = {}, query2 = {}) { +export function areQueriesEqual(query1: DeeplyReadonly | null, query2: DeeplyReadonly | null): boolean { return ( equals( - Object.entries((query1 && query1.order) || {}), - Object.entries((query2 && query2.order) || {}) + Object.entries(query1?.order || {}), + Object.entries(query2?.order || {}) ) && equals(query1, query2) ); } -export function defaultOrder(query) { +export function defaultOrder(query: DeeplyReadonly): { [key: string]: QueryOrder } { const granularity = (query.timeDimensions || []).find((d) => d.granularity); if (granularity) { @@ -62,23 +75,29 @@ export function defaultOrder(query) { (query.dimensions || []).length > 0 ) { return { - [query.measures[0]]: 'desc', + [query.measures![0]]: 'desc', }; } else if ((query.dimensions || []).length > 0) { return { - [query.dimensions[0]]: 'asc', + [query.dimensions![0]]: 'asc', }; } return {}; } -export function defaultHeuristics(newState, oldQuery = {}, options) { +export function defaultHeuristics( + newState: TDefaultHeuristicsState, + oldQuery: Query, + options: TDefaultHeuristicsOptions +): TDefaultHeuristicsResponse { const { query, ...props } = clone(newState); const { meta, sessionGranularity } = options; const granularity = sessionGranularity || DEFAULT_GRANULARITY; - let state = { + let state: TDefaultHeuristicsResponse = { + shouldApplyHeuristicOrder: false, + pivotConfig: null, query, ...props, }; @@ -89,20 +108,24 @@ export function defaultHeuristics(newState, oldQuery = {}, options) { } if (Array.isArray(newQuery) || Array.isArray(oldQuery)) { - return newState; + return { + shouldApplyHeuristicOrder: false, + pivotConfig: null, + ...newState, + }; } if (newQuery) { if ( (oldQuery.timeDimensions || []).length === 1 && (newQuery.timeDimensions || []).length === 1 && - newQuery.timeDimensions[0].granularity && - oldQuery.timeDimensions[0].granularity !== - newQuery.timeDimensions[0].granularity + newQuery.timeDimensions![0].granularity && + oldQuery.timeDimensions![0].granularity !== + newQuery.timeDimensions![0].granularity ) { state = { ...state, - sessionGranularity: newQuery.timeDimensions[0].granularity, + sessionGranularity: newQuery.timeDimensions![0].granularity, }; } @@ -111,11 +134,11 @@ export function defaultHeuristics(newState, oldQuery = {}, options) { (newQuery.measures || []).length > 0) || ((oldQuery.measures || []).length === 1 && (newQuery.measures || []).length === 1 && - oldQuery.measures[0] !== newQuery.measures[0]) + oldQuery.measures![0] !== newQuery.measures![0]) ) { const [td] = newQuery.timeDimensions || []; const defaultTimeDimension = meta.defaultTimeDimensionNameFor( - newQuery.measures[0] + newQuery.measures![0] ); newQuery = { ...newQuery, @@ -123,8 +146,8 @@ export function defaultHeuristics(newState, oldQuery = {}, options) { ? [ { dimension: defaultTimeDimension, - granularity: (td && td.granularity) || granularity, - dateRange: td && td.dateRange, + granularity: td?.granularity || granularity, + dateRange: td?.dateRange, }, ] : [], @@ -209,9 +232,9 @@ export function defaultHeuristics(newState, oldQuery = {}, options) { if ( (newChartType === 'line' || newChartType === 'area') && (oldQuery.timeDimensions || []).length === 1 && - !oldQuery.timeDimensions[0].granularity + !oldQuery.timeDimensions![0].granularity ) { - const [td] = oldQuery.timeDimensions; + const [td] = oldQuery.timeDimensions!; return { ...state, pivotConfig: null, @@ -227,9 +250,9 @@ export function defaultHeuristics(newState, oldQuery = {}, options) { newChartType === 'table' || newChartType === 'number') && (oldQuery.timeDimensions || []).length === 1 && - oldQuery.timeDimensions[0].granularity + oldQuery.timeDimensions![0].granularity ) { - const [td] = oldQuery.timeDimensions; + const [td] = oldQuery.timeDimensions!; return { ...state, pivotConfig: null, @@ -245,31 +268,29 @@ export function defaultHeuristics(newState, oldQuery = {}, options) { return state; } -export function isQueryPresent(query) { +export function isQueryPresent(query: DeeplyReadonly | null | undefined): boolean { if (!query) { return false; } return (Array.isArray(query) ? query : [query]).every( - (q) => (q.measures && q.measures.length) || - (q.dimensions && q.dimensions.length) || - (q.timeDimensions && q.timeDimensions.length) + (q) => q.measures?.length || q.dimensions?.length || q.timeDimensions?.length ); } export function movePivotItem( - pivotConfig, - sourceIndex, - destinationIndex, - sourceAxis, - destinationAxis -) { + pivotConfig: PivotConfig, + sourceIndex: number, + destinationIndex: number, + sourceAxis: TSourceAxis, + destinationAxis: TSourceAxis +): PivotConfig { const nextPivotConfig = { ...pivotConfig, - x: [...pivotConfig.x], - y: [...pivotConfig.y], + x: [...(pivotConfig.x || [])], + y: [...(pivotConfig.y || [])], }; - const id = pivotConfig[sourceAxis][sourceIndex]; + const id = pivotConfig[sourceAxis]![sourceIndex]; const lastIndex = nextPivotConfig[destinationAxis].length - 1; if (id === 'measures') { @@ -294,7 +315,7 @@ export function movePivotItem( return nextPivotConfig; } -export function moveItemInArray(list, sourceIndex, destinationIndex) { +export function moveItemInArray(list: T[], sourceIndex: number, destinationIndex: number): T[] { const result = [...list]; const [removed] = result.splice(sourceIndex, 1); result.splice(destinationIndex, 0, removed); @@ -302,33 +323,43 @@ export function moveItemInArray(list, sourceIndex, destinationIndex) { return result; } -export function flattenFilters(filters = []) { - return filters.reduce((memo, filter) => { - if (filter.or || filter.and) { - return [...memo, ...flattenFilters(filter.or || filter.and)]; +export function flattenFilters(filters: Filter[] = []): TFlatFilter[] { + return filters.reduce((memo, filter) => { + if ('or' in filter) { + return [...memo, ...flattenFilters(filter.or)]; + } + + if ('and' in filter) { + return [...memo, ...flattenFilters(filter.and)]; } return [...memo, filter]; }, []); } -export function getQueryMembers(query = {}) { - const keys = ['measures', 'dimensions', 'segments']; - const members = new Set(); +export function getQueryMembers(query: DeeplyReadonly = {}): string[] { + const keys = ['measures', 'dimensions', 'segments'] as const; + const members = new Set(); keys.forEach((key) => (query[key] || []).forEach((member) => members.add(member))); (query.timeDimensions || []).forEach((td) => members.add(td.dimension)); - flattenFilters(query.filters).forEach((filter) => members.add(filter.dimension || filter.member)); + const filters = flattenFilters(query.filters as Filter[]); + filters.forEach((filter) => { + const member = filter.dimension || filter.member; + if (typeof member === 'string') { + members.add(member); + } + }); return [...members]; } -export function getOrderMembersFromOrder(orderMembers, order) { - const ids = new Set(); +export function getOrderMembersFromOrder(orderMembers: any[], order: TQueryOrderObject | TQueryOrderArray): TOrderMember[] { + const ids = new Set(); const indexedOrderMembers = indexBy(prop('id'), orderMembers); const entries = Array.isArray(order) ? order : Object.entries(order || {}); - const nextOrderMembers = []; + const nextOrderMembers: TOrderMember[] = []; entries.forEach(([memberId, currentOrder]) => { if (currentOrder !== 'none' && indexedOrderMembers[memberId]) { @@ -351,14 +382,10 @@ export function getOrderMembersFromOrder(orderMembers, order) { return nextOrderMembers; } -export function aliasSeries(values, index, pivotConfig, duplicateMeasures) { - const nonNullValues = values.filter((value) => value != null); +export function aliasSeries(values: string[], index: number, pivotConfig?: Partial, duplicateMeasures: Set = new Set()) { + const nonNullValues = values.filter((value: any) => value != null); - if ( - pivotConfig && - pivotConfig.aliasSeries && - pivotConfig.aliasSeries[index] - ) { + if (pivotConfig?.aliasSeries?.[index]) { return [pivotConfig.aliasSeries[index], ...nonNullValues]; } else if (duplicateMeasures.has(nonNullValues[0])) { return [index, ...nonNullValues]; diff --git a/packages/cubejs-client-core/test/CubeApi.test.ts b/packages/cubejs-client-core/test/CubeApi.test.ts new file mode 100644 index 0000000000000..d1097fe206259 --- /dev/null +++ b/packages/cubejs-client-core/test/CubeApi.test.ts @@ -0,0 +1,360 @@ +/** + * @license MIT License + * @copyright Cube Dev, Inc. + * @fileoverview Test signal parameter in CubeApi + */ + +/* globals describe,test,expect,jest,beforeEach */ +/* eslint-disable import/first */ + +import { CubeApi as CubeApiOriginal, Query } from '../src'; +import HttpTransport from '../src/HttpTransport'; +import { + DescriptiveQueryRequest, + DescriptiveQueryRequestCompact, + DescriptiveQueryResponse, + NumericCastedData +} from './helpers'; +import ResultSet from '../src/ResultSet'; + +class CubeApi extends CubeApiOriginal { + public getTransport(): any { + return this.transport; + } + + public makeRequest(method: string, params?: any): any { + return this.request(method, params); + } +} + +describe('CubeApi Constructor', () => { + test('throw error if no api url', async () => { + try { + const _cubeApi = new CubeApi('token', {} as any); + throw new Error('Should not get here'); + } catch (e: any) { + expect(e.message).toBe('The `apiUrl` option is required'); + } + }); +}); + +describe('CubeApi Load', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('simple query, no options', async () => { + // Create a spy on the request method + jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(JSON.stringify(DescriptiveQueryResponse)), + json: () => Promise.resolve(DescriptiveQueryResponse) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + const res = await cubeApi.load(DescriptiveQueryRequest as Query); + expect(res).toBeInstanceOf(ResultSet); + expect(res.rawData()).toEqual(DescriptiveQueryResponse.results[0].data); + }); + + test('simple query + { mutexKey, castNumerics }', async () => { + // Create a spy on the request method + jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(JSON.stringify(DescriptiveQueryResponse)), + json: () => Promise.resolve(DescriptiveQueryResponse) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi({ + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + const res = await cubeApi.load(DescriptiveQueryRequest as Query, { mutexKey: 'mutexKey', castNumerics: true }); + expect(res).toBeInstanceOf(ResultSet); + expect(res.rawData()).toEqual(NumericCastedData); + }); + + test('simple query + compact response format', async () => { + // Create a spy on the request method + jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(JSON.stringify(DescriptiveQueryResponse)), + json: () => Promise.resolve(DescriptiveQueryResponse) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + const res = await cubeApi.load(DescriptiveQueryRequestCompact as Query, undefined, undefined, 'compact'); + expect(res).toBeInstanceOf(ResultSet); + expect(res.rawData()).toEqual(DescriptiveQueryResponse.results[0].data); + }); + + test('2 queries', async () => { + // Create a spy on the request method + jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(JSON.stringify(DescriptiveQueryResponse)), + json: () => Promise.resolve(DescriptiveQueryResponse) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + const res = await cubeApi.load([DescriptiveQueryRequest as Query, DescriptiveQueryRequest as Query]); + expect(res).toBeInstanceOf(ResultSet); + expect(res.rawData()).toEqual(DescriptiveQueryResponse.results[0].data); + }); + + test('2 queries + compact response format', async () => { + // Create a spy on the request method + jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(JSON.stringify(DescriptiveQueryResponse)), + json: () => Promise.resolve(DescriptiveQueryResponse) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + const res = await cubeApi.load([DescriptiveQueryRequestCompact as Query, DescriptiveQueryRequestCompact as Query], undefined, undefined, 'compact'); + expect(res).toBeInstanceOf(ResultSet); + expect(res.rawData()).toEqual(DescriptiveQueryResponse.results[0].data); + }); +}); + +describe('CubeApi with Abort Signal', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('should pass signal from constructor to request', async () => { + const controller = new AbortController(); + const { signal } = controller; + + // Create a spy on the request method + const requestSpy = jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve('{"results":[]}'), + json: () => Promise.resolve({ results: [] }) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + signal + }); + + // Create a second spy on the load method to verify signal is passed to HttpTransport + jest.spyOn(cubeApi, 'load'); + await cubeApi.load({ + measures: ['Orders.count'] + }); + + // Check if the signal was passed to request method through load + expect(requestSpy).toHaveBeenCalled(); + + // The request method should receive the signal in the call + // Create a request in the same way as CubeApi.load does + cubeApi.makeRequest('load', { + query: { measures: ['Orders.count'] }, + queryType: 'multi' + }); + + // Verify the transport is using the signal + expect(cubeApi.getTransport().signal).toBe(signal); + }); + + test('should pass signal from options to request', async () => { + const controller = new AbortController(); + const { signal } = controller; + + // Mock for this specific test + const requestSpy = jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve('{"results":[]}'), + json: () => Promise.resolve({ results: [] }) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1' + }); + + await cubeApi.load( + { measures: ['Orders.count'] }, + { signal } + ); + + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy.mock.calls[0]?.[1]?.signal).toBe(signal); + }); + + test('options signal should override constructor signal', async () => { + const constructorController = new AbortController(); + const optionsController = new AbortController(); + + // Mock for this specific test + const requestSpy = jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve('{"results":[]}'), + json: () => Promise.resolve({ results: [] }) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + signal: constructorController.signal + }); + + await cubeApi.load( + { measures: ['Orders.count'] }, + { signal: optionsController.signal } + ); + + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy.mock.calls[0]?.[1]?.signal).toBe(optionsController.signal); + expect(requestSpy.mock.calls[0]?.[1]?.signal).not.toBe(constructorController.signal); + }); + + test('should pass signal to meta request', async () => { + const controller = new AbortController(); + const { signal } = controller; + + // Mock for meta with proper format - include dimensions, segments, and measures with required properties + const requestSpy = jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(JSON.stringify({ + cubes: [{ + name: 'Orders', + title: 'Orders', + measures: [{ + name: 'count', + title: 'Count', + shortTitle: 'Count', + type: 'number' + }], + dimensions: [{ + name: 'status', + title: 'Status', + type: 'string' + }], + segments: [] + }] + })), + json: () => Promise.resolve({ + cubes: [{ + name: 'Orders', + title: 'Orders', + measures: [{ + name: 'count', + title: 'Count', + shortTitle: 'Count', + type: 'number' + }], + dimensions: [{ + name: 'status', + title: 'Status', + type: 'string' + }], + segments: [] + }] + }) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1' + }); + + await cubeApi.meta({ signal }); + + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy.mock.calls[0]?.[1]?.signal).toBe(signal); + }); + + test('should pass signal to sql request', async () => { + const controller = new AbortController(); + const { signal } = controller; + + // Mock for SQL response + const requestSpy = jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve('{"sql":{"sql":"SELECT * FROM orders"}}'), + json: () => Promise.resolve({ sql: { sql: 'SELECT * FROM orders' } }) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1' + }); + + await cubeApi.sql( + { measures: ['Orders.count'] }, + { signal } + ); + + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy.mock.calls[0]?.[1]?.signal).toBe(signal); + }); + + test('should pass signal to dryRun request', async () => { + const controller = new AbortController(); + const { signal } = controller; + + // Mock for dryRun response + const requestSpy = jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve('{"queryType":"regular"}'), + json: () => Promise.resolve({ queryType: 'regular' }) + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1' + }); + + await cubeApi.dryRun( + { measures: ['Orders.count'] }, + { signal } + ); + + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy.mock.calls[0]?.[1]?.signal).toBe(signal); + }); +}); diff --git a/packages/cubejs-client-core/src/HttpTransport.test.js b/packages/cubejs-client-core/test/HttpTransport.test.ts similarity index 84% rename from packages/cubejs-client-core/src/HttpTransport.test.js rename to packages/cubejs-client-core/test/HttpTransport.test.ts index 92d53bc79111b..ec72c0c0704bf 100644 --- a/packages/cubejs-client-core/src/HttpTransport.test.js +++ b/packages/cubejs-client-core/test/HttpTransport.test.ts @@ -1,10 +1,12 @@ /* eslint-disable import/first */ -/* eslint-disable import/newline-after-import */ /* globals describe,test,expect,jest,afterEach,beforeAll,beforeEach */ -import '@babel/runtime/regenerator'; -jest.mock('cross-fetch'); import fetch from 'cross-fetch'; -import HttpTransport from './HttpTransport'; + +jest.mock('cross-fetch'); + +import HttpTransport from '../src/HttpTransport'; + +const mockedFetch = fetch as jest.MockedFunction; describe('HttpTransport', () => { const apiUrl = 'http://localhost:3000/cubejs-api/v1'; @@ -31,11 +33,11 @@ describe('HttpTransport', () => { const largeQueryJson = `{"query":{"measures":["Orders.count"],"dimensions":["Users.country"],"filters":[{"member":"Users.id","operator":"equals","values":${JSON.stringify(ids)}}]}}`; beforeAll(() => { - fetch.mockReturnValue(Promise.resolve({ ok: true })); + mockedFetch.mockReturnValue(Promise.resolve({ ok: true } as Response)); }); afterEach(() => { - fetch.mockClear(); + mockedFetch.mockClear(); }); test('it serializes the query object and sends it in the query string', async () => { @@ -44,7 +46,7 @@ describe('HttpTransport', () => { apiUrl, }); const req = transport.request('load', { query }); - await req.subscribe(() => { }); + await req.subscribe(() => { console.log('subscribe cb'); }); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith(`${apiUrl}/load?query=${queryUrlEncoded}`, { method: 'GET', @@ -66,7 +68,7 @@ describe('HttpTransport', () => { } }); const req = transport.request('meta', { extraParams }); - await req.subscribe(() => { }); + await req.subscribe(() => { console.log('subscribe cb'); }); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith(`${apiUrl}/meta?extraParams=${serializedExtraParams}`, { method: 'GET', @@ -85,7 +87,7 @@ describe('HttpTransport', () => { method: 'POST' }); const req = transport.request('load', { query }); - await req.subscribe(() => { }); + await req.subscribe(() => { console.log('subscribe cb'); }); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith(`${apiUrl}/load`, { method: 'POST', @@ -103,7 +105,7 @@ describe('HttpTransport', () => { apiUrl }); const req = transport.request('load', { query: LargeQuery }); - await req.subscribe(() => { }); + await req.subscribe(() => { console.log('subscribe cb'); }); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith(`${apiUrl}/load`, { method: 'POST', @@ -118,13 +120,16 @@ describe('HttpTransport', () => { // Signal tests from src/tests/HttpTransport.test.js describe('Signal functionality', () => { beforeEach(() => { - fetch.mockClear(); // Default mock implementation for signal tests - fetch.mockImplementation(() => Promise.resolve({ + mockedFetch.mockImplementation(() => Promise.resolve({ json: () => Promise.resolve({ data: 'test data' }), ok: true, status: 200 - })); + }) as any); + }); + + afterEach(() => { + mockedFetch.mockClear(); }); test('should pass the signal to fetch when provided in constructor', async () => { @@ -146,8 +151,8 @@ describe('HttpTransport', () => { await Promise.resolve(); // Ensure fetch was called with the signal - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch.mock.calls[0][1].signal).toBe(signal); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch.mock.calls[0]?.[1]?.signal).toBe(signal); await promise; }); @@ -173,8 +178,8 @@ describe('HttpTransport', () => { await Promise.resolve(); // Ensure fetch was called with the signal - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch.mock.calls[0][1].signal).toBe(signal); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch.mock.calls[0]?.[1]?.signal).toBe(signal); await promise; }); @@ -201,9 +206,9 @@ describe('HttpTransport', () => { await Promise.resolve(); // Ensure fetch was called with the request signal, not the constructor signal - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch.mock.calls[0][1].signal).toBe(controller2.signal); - expect(fetch.mock.calls[0][1].signal).not.toBe(controller1.signal); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch.mock.calls[0]?.[1]?.signal).toBe(controller2.signal); + expect(mockedFetch.mock.calls[0]?.[1]?.signal).not.toBe(controller1.signal); await promise; }); @@ -231,8 +236,8 @@ describe('HttpTransport', () => { await Promise.resolve(); // Ensure fetch was called with the timeout signal - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch.mock.calls[0][1].signal).toBe(mockTimeoutSignal); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch.mock.calls[0]?.[1]?.signal).toBe(mockTimeoutSignal); expect(AbortSignal.timeout).toHaveBeenCalledWith(5000); // Restore original implementation @@ -244,12 +249,12 @@ describe('HttpTransport', () => { test('should handle request abortion', async () => { // Create a mock Promise and resolver function to control Promise completion let resolveFetch; - const fetchPromise = new Promise(resolve => { + const fetchPromise = new Promise(resolve => { resolveFetch = resolve; }); // Mock fetch to return our controlled Promise - fetch.mockImplementationOnce(() => fetchPromise); + mockedFetch.mockImplementationOnce(() => fetchPromise); const controller = new AbortController(); const { signal } = controller; @@ -271,14 +276,14 @@ describe('HttpTransport', () => { await Promise.resolve(); // Ensure fetch was called with the signal - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch.mock.calls[0][1].signal).toBe(signal); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch.mock.calls[0]?.[1]?.signal).toBe(signal); // Abort the request controller.abort(); // Resolve the fetch Promise, simulating request completion - resolveFetch({ + resolveFetch!({ json: () => Promise.resolve({ data: 'aborted data' }), ok: true, status: 200 diff --git a/packages/cubejs-client-core/src/tests/ResultSet.test.js b/packages/cubejs-client-core/test/ResultSet.test.ts similarity index 83% rename from packages/cubejs-client-core/src/tests/ResultSet.test.js rename to packages/cubejs-client-core/test/ResultSet.test.ts index f1c997eb68119..b090df65a667f 100644 --- a/packages/cubejs-client-core/src/tests/ResultSet.test.js +++ b/packages/cubejs-client-core/test/ResultSet.test.ts @@ -7,16 +7,18 @@ /* globals describe,test,expect */ import 'jest'; -import ResultSet from '../ResultSet'; +import ResultSet from '../src/ResultSet'; +import { TimeDimension } from '../src'; +import { DescriptiveQueryResponse } from './helpers'; describe('ResultSet', () => { describe('timeSeries', () => { test('it generates array of dates - granularity month', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2015-01-01', '2015-12-31'], granularity: 'month', - timeDimension: 'Events.time' + dimension: 'Events.time' }; const output = [ '2015-01-01T00:00:00.000', @@ -36,11 +38,11 @@ describe('ResultSet', () => { }); test('it generates array of dates - granularity quarter', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2015-01-01', '2015-12-31'], granularity: 'quarter', - timeDimension: 'Events.time' + dimension: 'Events.time' }; const output = [ '2015-01-01T00:00:00.000', @@ -52,11 +54,11 @@ describe('ResultSet', () => { }); test('it generates array of dates - granularity hour', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2015-01-01', '2015-01-01'], granularity: 'hour', - timeDimension: 'Events.time' + dimension: 'Events.time' }; const output = [ '2015-01-01T00:00:00.000', @@ -88,11 +90,11 @@ describe('ResultSet', () => { }); test('it generates array of dates - granularity hour - not full day', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2015-01-01T10:30:00.000', '2015-01-01T13:59:00.000'], granularity: 'hour', - timeDimension: 'Events.time' + dimension: 'Events.time' }; const output = [ '2015-01-01T10:00:00.000', @@ -104,8 +106,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 1 year, origin - 2020-01-01', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-01-01', '2023-12-31'], granularity: 'one_year', dimension: 'Events.time' @@ -131,8 +133,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 1 year, origin - 2025-03-01', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-01-01', '2022-12-31'], granularity: 'one_year', dimension: 'Events.time' @@ -158,8 +160,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 1 year, offset - 2 months', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-01-01', '2022-12-31'], granularity: 'one_year', dimension: 'Events.time' @@ -185,8 +187,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 2 months, origin - 2019-01-01', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-01-01', '2021-12-31'], granularity: 'two_months', dimension: 'Events.time' @@ -215,8 +217,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 2 months, no offset', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-01-01', '2021-12-31'], granularity: 'two_months', dimension: 'Events.time' @@ -244,8 +246,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 2 months, origin - 2019-03-15', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-01-01', '2021-12-31'], granularity: 'two_months', dimension: 'Events.time' @@ -275,8 +277,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 1 months 2 weeks 3 days, origin - 2021-01-25', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-01-01', '2021-12-31'], granularity: 'one_mo_two_we_three_d', dimension: 'Events.time' @@ -308,8 +310,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 3 weeks, origin - 2020-12-15', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-01-01', '2021-03-01'], granularity: 'three_weeks', dimension: 'Events.time' @@ -336,8 +338,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 2 months 3 weeks 4 days 5 hours 6 minutes 7 seconds, origin - 2021-01-01', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-01-01', '2021-12-31'], granularity: 'two_mo_3w_4d_5h_6m_7s', dimension: 'Events.time' @@ -365,8 +367,8 @@ describe('ResultSet', () => { }); test('it generates array of dates - custom interval - 10 minutes 15 seconds, origin - 2021-02-01 09:59:45', () => { - const resultSet = new ResultSet({}); - const timeDimension = { + const resultSet = new ResultSet({} as any); + const timeDimension: TimeDimension = { dateRange: ['2021-02-01 10:00:00', '2021-02-01 12:00:00'], granularity: 'ten_min_fifteen_sec', dimension: 'Events.time' @@ -437,7 +439,7 @@ describe('ResultSet', () => { segments: {}, timeDimensions: {} } - }); + } as any); expect(resultSet.chartPivot()).toEqual([ { @@ -486,7 +488,7 @@ describe('ResultSet', () => { segments: {}, timeDimensions: {} } - }); + } as any); expect(resultSet.chartPivot()).toEqual([ { @@ -535,7 +537,7 @@ describe('ResultSet', () => { segments: {}, timeDimensions: {} } - }); + } as any); expect(resultSet.chartPivot()).toEqual([ { @@ -583,7 +585,7 @@ describe('ResultSet', () => { segments: {}, timeDimensions: {} } - }); + } as any); expect(resultSet.chartPivot()).toEqual([ { @@ -633,7 +635,7 @@ describe('ResultSet', () => { segments: {}, timeDimensions: {} } - }, + } as any, { parseDateMeasures: true } ); @@ -650,6 +652,160 @@ describe('ResultSet', () => { }); }); + test('tableColumns', () => { + const resultSet = new ResultSet(DescriptiveQueryResponse as any); + + expect(resultSet.tableColumns()).toEqual([ + { + dataIndex: 'base_orders.created_at.month', + format: undefined, + key: 'base_orders.created_at.month', + meta: undefined, + shortTitle: 'Created at', + title: 'Base Orders Created at', + type: 'time', + }, + { + dataIndex: 'base_orders.status', + format: undefined, + key: 'base_orders.status', + meta: { + addDesc: 'The status of order', + moreNum: 42, + }, + shortTitle: 'Status', + title: 'Base Orders Status', + type: 'string', + }, + { + dataIndex: 'base_orders.count', + format: undefined, + key: 'base_orders.count', + meta: undefined, + shortTitle: 'Count', + title: 'Base Orders Count', + type: 'number', + }, + ]); + }); + + test('totalRow', () => { + const resultSet = new ResultSet(DescriptiveQueryResponse as any); + + expect(resultSet.totalRow()).toEqual({ + 'completed,base_orders.count': 2, + 'processing,base_orders.count': 0, + 'shipped,base_orders.count': 0, + x: '2023-04-01T00:00:00.000', + xValues: [ + '2023-04-01T00:00:00.000', + ], + }); + }); + + test('pivotQuery', () => { + const resultSet = new ResultSet(DescriptiveQueryResponse as any); + + expect(resultSet.pivotQuery()).toEqual(DescriptiveQueryResponse.pivotQuery); + }); + + test('totalRows', () => { + const resultSet = new ResultSet(DescriptiveQueryResponse as any); + + expect(resultSet.totalRows()).toEqual(19); + }); + + test('rawData', () => { + const resultSet = new ResultSet(DescriptiveQueryResponse as any); + + expect(resultSet.rawData()).toEqual(DescriptiveQueryResponse.results[0].data); + }); + + test('annotation', () => { + const resultSet = new ResultSet(DescriptiveQueryResponse as any); + + expect(resultSet.annotation()).toEqual(DescriptiveQueryResponse.results[0].annotation); + }); + + test('categories', () => { + const resultSet = new ResultSet(DescriptiveQueryResponse as any); + + expect(resultSet.categories()).toEqual([ + { + 'completed,base_orders.count': 2, + 'processing,base_orders.count': 0, + 'shipped,base_orders.count': 0, + x: '2023-04-01T00:00:00.000', + xValues: [ + '2023-04-01T00:00:00.000', + ], + }, + { + 'completed,base_orders.count': 6, + 'processing,base_orders.count': 6, + 'shipped,base_orders.count': 9, + x: '2023-05-01T00:00:00.000', + xValues: [ + '2023-05-01T00:00:00.000', + ], + }, + { + 'completed,base_orders.count': 5, + 'processing,base_orders.count': 5, + 'shipped,base_orders.count': 13, + x: '2023-06-01T00:00:00.000', + xValues: [ + '2023-06-01T00:00:00.000', + ], + }, + { + 'completed,base_orders.count': 5, + 'processing,base_orders.count': 7, + 'shipped,base_orders.count': 5, + x: '2023-07-01T00:00:00.000', + xValues: [ + '2023-07-01T00:00:00.000', + ], + }, + { + 'completed,base_orders.count': 11, + 'processing,base_orders.count': 3, + 'shipped,base_orders.count': 4, + x: '2023-08-01T00:00:00.000', + xValues: [ + '2023-08-01T00:00:00.000', + ], + }, + { + 'completed,base_orders.count': 5, + 'processing,base_orders.count': 10, + 'shipped,base_orders.count': 9, + x: '2023-09-01T00:00:00.000', + xValues: [ + '2023-09-01T00:00:00.000', + ], + }, + { + 'completed,base_orders.count': 4, + 'processing,base_orders.count': 5, + 'shipped,base_orders.count': 9, + x: '2023-10-01T00:00:00.000', + xValues: [ + '2023-10-01T00:00:00.000', + ], + }, + ]); + }); + + test('serialize/deserialize', () => { + const resultSet = new ResultSet(DescriptiveQueryResponse as any); + + const serialized = resultSet.serialize(); + const restoredResultSet = ResultSet.deserialize(serialized); + + expect(restoredResultSet).toEqual(resultSet); + }); + describe('seriesNames', () => { test('Multiple series with custom alias', () => { const resultSet = new ResultSet({ @@ -758,7 +914,7 @@ describe('ResultSet', () => { ], dimensions: [], }, - }); + } as any); expect(resultSet.seriesNames({ aliasSeries: ['one', 'two'] })).toEqual([ { @@ -882,7 +1038,7 @@ describe('ResultSet', () => { ], dimensions: [], }, - }); + } as any); expect(resultSet.seriesNames()).toEqual([ { @@ -913,7 +1069,7 @@ describe('ResultSet', () => { } ] } - }); + } as any); expect(resultSet.normalizePivotConfig({ y: ['Foo.bar'] })).toEqual({ x: ['Foo.createdAt.day'], @@ -934,7 +1090,7 @@ describe('ResultSet', () => { } ] } - }); + } as any); expect( resultSet.normalizePivotConfig({ x: ['Foo.createdAt'], y: ['Foo.bar'] }) @@ -960,7 +1116,7 @@ describe('ResultSet', () => { filters: [], timezone: 'UTC' } - }); + } as any); expect( resultSet.normalizePivotConfig(resultSet.normalizePivotConfig({})) @@ -986,7 +1142,7 @@ describe('ResultSet', () => { filters: [], timezone: 'UTC' } - }); + } as any); expect( resultSet.normalizePivotConfig(resultSet.normalizePivotConfig()) @@ -1013,7 +1169,7 @@ describe('ResultSet', () => { filters: [], timezone: 'UTC' } - }); + } as any); expect( resultSet.normalizePivotConfig(resultSet.normalizePivotConfig({})) @@ -1214,7 +1370,7 @@ describe('ResultSet', () => { } } } - }); + } as any); expect(resultSet.tablePivot()).toEqual([ { @@ -1406,7 +1562,7 @@ describe('ResultSet', () => { } } } - }); + } as any); expect(resultSet.tablePivot()).toEqual([ { @@ -1419,124 +1575,124 @@ describe('ResultSet', () => { }); test('fill missing dates with custom value', () => { - const resultSet = new ResultSet({ - query: { - measures: ['Orders.total'], - timeDimensions: [ - { - dimension: 'Orders.createdAt', - granularity: 'day', - dateRange: ['2020-01-08T00:00:00.000', '2020-01-11T23:59:59.999'] - } - ], - filters: [], - timezone: 'UTC' - }, - data: [ - { - 'Orders.createdAt': '2020-01-08T00:00:00.000', - 'Orders.total': 1 - }, - { - 'Orders.createdAt': '2020-01-10T00:00:00.000', - 'Orders.total': 10 - } - ], - annotation: { - measures: {}, - dimensions: {}, - segments: {}, - timeDimensions: { - 'Orders.createdAt': { - title: 'Orders Created at', - shortTitle: 'Created at', - type: 'time' - } - } + const resultSet = new ResultSet({ + query: { + measures: ['Orders.total'], + timeDimensions: [ + { + dimension: 'Orders.createdAt', + granularity: 'day', + dateRange: ['2020-01-08T00:00:00.000', '2020-01-11T23:59:59.999'] + } + ], + filters: [], + timezone: 'UTC' + }, + data: [ + { + 'Orders.createdAt': '2020-01-08T00:00:00.000', + 'Orders.total': 1 + }, + { + 'Orders.createdAt': '2020-01-10T00:00:00.000', + 'Orders.total': 10 + } + ], + annotation: { + measures: {}, + dimensions: {}, + segments: {}, + timeDimensions: { + 'Orders.createdAt': { + title: 'Orders Created at', + shortTitle: 'Created at', + type: 'time' } - }); + } + } + } as any); - expect(resultSet.tablePivot({ - 'fillWithValue': 5 - })).toEqual([ - { - 'Orders.createdAt.day': '2020-01-08T00:00:00.000', - 'Orders.total': 1 - }, - { - 'Orders.createdAt.day': '2020-01-09T00:00:00.000', - 'Orders.total': 5 - }, - { - 'Orders.createdAt.day': '2020-01-10T00:00:00.000', - 'Orders.total': 10 - }, + expect(resultSet.tablePivot({ + fillWithValue: 5 + })).toEqual([ + { + 'Orders.createdAt.day': '2020-01-08T00:00:00.000', + 'Orders.total': 1 + }, + { + 'Orders.createdAt.day': '2020-01-09T00:00:00.000', + 'Orders.total': 5 + }, + { + 'Orders.createdAt.day': '2020-01-10T00:00:00.000', + 'Orders.total': 10 + }, + { + 'Orders.createdAt.day': '2020-01-11T00:00:00.000', + 'Orders.total': 5 + } + ]); + }); + + test('fill missing dates with custom string', () => { + const resultSet = new ResultSet({ + query: { + measures: ['Orders.total'], + timeDimensions: [ { - 'Orders.createdAt.day': '2020-01-11T00:00:00.000', - 'Orders.total': 5 + dimension: 'Orders.createdAt', + granularity: 'day', + dateRange: ['2020-01-08T00:00:00.000', '2020-01-11T23:59:59.999'] + } + ], + filters: [], + timezone: 'UTC' + }, + data: [ + { + 'Orders.createdAt': '2020-01-08T00:00:00.000', + 'Orders.total': 1 + }, + { + 'Orders.createdAt': '2020-01-10T00:00:00.000', + 'Orders.total': 10 + } + ], + annotation: { + measures: {}, + dimensions: {}, + segments: {}, + timeDimensions: { + 'Orders.createdAt': { + title: 'Orders Created at', + shortTitle: 'Created at', + type: 'time' } - ]); - }); + } + } + } as any); - test('fill missing dates with custom string', () => { - const resultSet = new ResultSet({ - query: { - measures: ['Orders.total'], - timeDimensions: [ - { - dimension: 'Orders.createdAt', - granularity: 'day', - dateRange: ['2020-01-08T00:00:00.000', '2020-01-11T23:59:59.999'] - } - ], - filters: [], - timezone: 'UTC' - }, - data: [ - { - 'Orders.createdAt': '2020-01-08T00:00:00.000', - 'Orders.total': 1 - }, - { - 'Orders.createdAt': '2020-01-10T00:00:00.000', - 'Orders.total': 10 - } - ], - annotation: { - measures: {}, - dimensions: {}, - segments: {}, - timeDimensions: { - 'Orders.createdAt': { - title: 'Orders Created at', - shortTitle: 'Created at', - type: 'time' - } - } - } - }); - - expect(resultSet.tablePivot({ - 'fillWithValue': 'N/A' - })).toEqual([ - { - 'Orders.createdAt.day': '2020-01-08T00:00:00.000', - 'Orders.total': 1 - }, - { - 'Orders.createdAt.day': '2020-01-09T00:00:00.000', - 'Orders.total': "N/A" - }, - { - 'Orders.createdAt.day': '2020-01-10T00:00:00.000', - 'Orders.total': 10 - }, - { - 'Orders.createdAt.day': '2020-01-11T00:00:00.000', - 'Orders.total': "N/A" - } - ]); - }); + expect(resultSet.tablePivot({ + fillWithValue: 'N/A' + })).toEqual([ + { + 'Orders.createdAt.day': '2020-01-08T00:00:00.000', + 'Orders.total': 1 + }, + { + 'Orders.createdAt.day': '2020-01-09T00:00:00.000', + 'Orders.total': 'N/A' + }, + { + 'Orders.createdAt.day': '2020-01-10T00:00:00.000', + 'Orders.total': 10 + }, + { + 'Orders.createdAt.day': '2020-01-11T00:00:00.000', + 'Orders.total': 'N/A' + } + ]); + }); test('same dimension and time dimension without granularity', () => { const resultSet = new ResultSet({ @@ -1580,7 +1736,7 @@ describe('ResultSet', () => { segments: {}, timeDimensions: {} } - }); + } as any); expect(resultSet.tablePivot()).toEqual([ { 'Orders.createdAt': '2020-01-08T17:04:43.000' }, @@ -1640,7 +1796,7 @@ describe('ResultSet', () => { segments: {}, timeDimensions: {} } - }); + } as any); expect(resultSet.pivot()).toEqual( [ diff --git a/packages/cubejs-client-core/src/tests/compare-date-range.test.js b/packages/cubejs-client-core/test/compare-date-range.test.ts similarity index 99% rename from packages/cubejs-client-core/src/tests/compare-date-range.test.js rename to packages/cubejs-client-core/test/compare-date-range.test.ts index 0ab77f1ebadeb..c00178269f597 100644 --- a/packages/cubejs-client-core/src/tests/compare-date-range.test.js +++ b/packages/cubejs-client-core/test/compare-date-range.test.ts @@ -1,5 +1,7 @@ +/* globals describe, test, expect */ + import 'jest'; -import ResultSet from '../ResultSet'; +import ResultSet from '../src/ResultSet'; const loadResponses = [ { @@ -289,8 +291,8 @@ const loadResponses = [ ]; describe('compare date range', () => { - const resultSet1 = new ResultSet(loadResponses[0]); - const resultSet2 = new ResultSet(loadResponses[1]); + const resultSet1 = new ResultSet(loadResponses[0] as any); + const resultSet2 = new ResultSet(loadResponses[1] as any); describe('series and seriesNames', () => { test('with a single time dimension', () => { diff --git a/packages/cubejs-client-core/src/tests/data-blending.test.js b/packages/cubejs-client-core/test/data-blending.test.ts similarity index 98% rename from packages/cubejs-client-core/src/tests/data-blending.test.js rename to packages/cubejs-client-core/test/data-blending.test.ts index e3eb42c96d161..19b163e6556cb 100644 --- a/packages/cubejs-client-core/src/tests/data-blending.test.js +++ b/packages/cubejs-client-core/test/data-blending.test.ts @@ -1,10 +1,12 @@ +/* globals describe, test, expect */ + import 'jest'; -import ResultSet from '../ResultSet'; +import ResultSet from '../src/ResultSet'; import { loadResponse, loadResponseWithoutDateRange } from './fixtures/datablending/load-responses.json'; describe('data blending', () => { - const resultSet1 = new ResultSet(loadResponse); + const resultSet1 = new ResultSet(loadResponse as any); describe('with different dimensions', () => { test('normalized pivotConfig', () => { @@ -210,7 +212,7 @@ describe('data blending', () => { ], dimensions: [], }, - }); + } as any); expect(resultSet.chartPivot()).toEqual([ { @@ -247,7 +249,7 @@ describe('data blending', () => { }); test('query without date range', () => { - const resultSet = new ResultSet(loadResponseWithoutDateRange); + const resultSet = new ResultSet(loadResponseWithoutDateRange as any); expect(resultSet.chartPivot()).toEqual([ { @@ -394,7 +396,7 @@ describe('data blending', () => { ], dimensions: [], }, - }); + } as any); expect(resultSet.chartPivot({ aliasSeries: ['one', 'two'] })).toEqual([ { diff --git a/packages/cubejs-client-core/src/tests/default-heuristics.test.js b/packages/cubejs-client-core/test/default-heuristics.test.ts similarity index 89% rename from packages/cubejs-client-core/src/tests/default-heuristics.test.js rename to packages/cubejs-client-core/test/default-heuristics.test.ts index 2c2ca063d4fe7..63c50eae85797 100644 --- a/packages/cubejs-client-core/src/tests/default-heuristics.test.js +++ b/packages/cubejs-client-core/test/default-heuristics.test.ts @@ -1,5 +1,7 @@ +/* globals jest, describe, expect, it */ + import 'jest'; -import { defaultHeuristics } from '../utils'; +import { defaultHeuristics } from '../src/utils'; jest.mock('moment-range', () => { const Moment = jest.requireActual('moment'); @@ -40,7 +42,7 @@ describe('default heuristics', () => { return 'Orders.ts'; }, }, - }) + } as any) ).toStrictEqual({ pivotConfig: null, query: { @@ -73,7 +75,7 @@ describe('default heuristics', () => { const oldQuery = {}; - expect(defaultHeuristics(newState, oldQuery, { meta })).toMatchObject({ + expect(defaultHeuristics(newState, oldQuery, { meta } as any)).toMatchObject({ query: { timeDimensions: [ { @@ -104,7 +106,7 @@ describe('default heuristics', () => { }, }; - expect(defaultHeuristics(newState, {}, { meta })).toMatchObject({ + expect(defaultHeuristics(newState, {}, { meta } as any)).toMatchObject({ query: { timeDimensions: [ { diff --git a/packages/cubejs-client-core/src/tests/drill-down.test.js b/packages/cubejs-client-core/test/drill-down.test.ts similarity index 96% rename from packages/cubejs-client-core/src/tests/drill-down.test.js rename to packages/cubejs-client-core/test/drill-down.test.ts index 1c6196787c488..672512c0a7003 100644 --- a/packages/cubejs-client-core/src/tests/drill-down.test.js +++ b/packages/cubejs-client-core/test/drill-down.test.ts @@ -1,5 +1,7 @@ +/* globals jest, describe, expect, it */ + import 'jest'; -import ResultSet from '../ResultSet'; +import ResultSet from '../src/ResultSet'; jest.mock('moment-range', () => { const Moment = jest.requireActual('moment'); @@ -218,11 +220,11 @@ const loadResponse2 = { }; describe('drill down query', () => { - const resultSet1 = new ResultSet(loadResponse()); + const resultSet1 = new ResultSet(loadResponse() as any); const resultSet2 = new ResultSet( loadResponse({ timezone: 'America/Los_Angeles', - }) + }) as any ); const resultSet3 = new ResultSet( loadResponse({ @@ -233,13 +235,13 @@ describe('drill down query', () => { values: ['Los Angeles'], }, ], - }) + }) as any ); const resultSet4 = new ResultSet( loadResponse({ dimensions: ['Statuses.potential'], timeDimensions: [], - }) + }) as any ); const resultSet5 = new ResultSet( loadResponse({ @@ -249,7 +251,7 @@ describe('drill down query', () => { granularity: 'week', } ] - }) + }) as any ); it('handles a query with a time dimension', () => { @@ -333,7 +335,7 @@ describe('drill down query', () => { }); it('handles null values', () => { - expect(resultSet4.drillDown({ xvalues: [null] })).toEqual({ + expect(resultSet4.drillDown({ xValues: [] })).toEqual({ measures: [], segments: [], dimensions: ['Orders.id', 'Orders.title'], @@ -353,7 +355,7 @@ describe('drill down query', () => { }); it('respects the parent time dimension date range', () => { - const resultSet = new ResultSet(loadResponse2); + const resultSet = new ResultSet(loadResponse2 as any); expect( resultSet.drillDown({ xValues: ['2023-05-08T00:00:00.000'] }) diff --git a/packages/cubejs-client-core/src/tests/fixtures/datablending/load-responses.json b/packages/cubejs-client-core/test/fixtures/datablending/load-responses.json similarity index 100% rename from packages/cubejs-client-core/src/tests/fixtures/datablending/load-responses.json rename to packages/cubejs-client-core/test/fixtures/datablending/load-responses.json diff --git a/packages/cubejs-client-core/src/tests/granularity.test.js b/packages/cubejs-client-core/test/granularity.test.ts similarity index 97% rename from packages/cubejs-client-core/src/tests/granularity.test.js rename to packages/cubejs-client-core/test/granularity.test.ts index 0e975f99134f7..cdc3529947b4c 100644 --- a/packages/cubejs-client-core/src/tests/granularity.test.js +++ b/packages/cubejs-client-core/test/granularity.test.ts @@ -3,7 +3,7 @@ import 'jest'; import dayjs from 'dayjs'; import ko from 'dayjs/locale/ko'; -import ResultSet from '../ResultSet'; +import ResultSet from '../src/ResultSet'; describe('ResultSet Granularity', () => { describe('chartPivot', () => { @@ -33,7 +33,6 @@ describe('ResultSet Granularity', () => { timezone: 'UTC', order: [], dimensions: [], - queryType: 'regularQuery', }, data: [ { @@ -98,7 +97,7 @@ describe('ResultSet Granularity', () => { queryType: 'regularQuery', }, slowQuery: false, - }); + } as any); expect(result.chartPivot()).toStrictEqual([ { @@ -141,7 +140,6 @@ describe('ResultSet Granularity', () => { timezone: 'UTC', order: [], dimensions: [], - queryType: 'regularQuery', }, data: [ { @@ -206,7 +204,7 @@ describe('ResultSet Granularity', () => { queryType: 'regularQuery', }, slowQuery: false, - }); + } as any); expect(result.chartPivot()).toStrictEqual([ { diff --git a/packages/cubejs-client-core/test/helpers.ts b/packages/cubejs-client-core/test/helpers.ts new file mode 100644 index 0000000000000..cdb2868496f6f --- /dev/null +++ b/packages/cubejs-client-core/test/helpers.ts @@ -0,0 +1,977 @@ +export const DescriptiveQueryRequest = { + timeDimensions: [ + { + dimension: 'base_orders.created_at', + granularity: 'month' + }, + { + dimension: 'base_orders.completed_at', + dateRange: [ + '2023-05-16', + '2025-05-16' + ] + } + ], + filters: [ + { + member: 'base_orders.fiscal_event_date_label', + operator: 'set' + } + ], + dimensions: [ + 'base_orders.status' + ], + measures: [ + 'base_orders.count' + ], + segments: [ + 'users.sf_users' + ] +}; + +export const DescriptiveQueryRequestCompact = { + timeDimensions: [ + { + dimension: 'base_orders.created_at', + granularity: 'month' + }, + { + dimension: 'base_orders.completed_at', + dateRange: [ + '2023-05-16', + '2025-05-16' + ] + } + ], + filters: [ + { + member: 'base_orders.fiscal_event_date_label', + operator: 'set' + } + ], + dimensions: [ + 'base_orders.status' + ], + measures: [ + 'base_orders.count' + ], + segments: [ + 'users.sf_users' + ], + responseFormat: 'compact', +}; + +export const DescriptiveQueryResponse = { + queryType: 'regularQuery', + results: [ + { + query: { + measures: [ + 'base_orders.count' + ], + dimensions: [ + 'base_orders.status' + ], + timeDimensions: [ + { + dimension: 'base_orders.created_at', + granularity: 'month' + }, + { + dimension: 'base_orders.completed_at', + dateRange: [ + '2023-05-16T00:00:00.000', + '2025-05-16T23:59:59.999' + ] + } + ], + segments: [ + 'users.sf_users' + ], + limit: 10000, + total: true, + timezone: 'UTC', + filters: [ + { + member: 'base_orders.fiscal_event_date_label', + operator: 'set' + } + ], + rowLimit: 10000 + }, + lastRefreshTime: '2025-05-16T13:34:38.144Z', + refreshKeyValues: [ + [ + { + refresh_key: '174740245' + } + ], + [ + { + refresh_key: '174740245' + } + ] + ], + usedPreAggregations: {}, + transformedQuery: { + sortedDimensions: [ + 'base_orders.fiscal_event_date_label', + 'base_orders.status', + 'users.sf_users' + ], + sortedTimeDimensions: [ + [ + 'base_orders.completed_at', + 'day' + ], + [ + 'base_orders.created_at', + 'month' + ] + ], + timeDimensions: [ + [ + 'base_orders.completed_at', + null + ], + [ + 'base_orders.created_at', + 'month' + ] + ], + measures: [ + 'base_orders.count' + ], + leafMeasureAdditive: true, + leafMeasures: [ + 'base_orders.count' + ], + measureToLeafMeasures: { + 'base_orders.count': [ + { + measure: 'base_orders.count', + additive: true, + type: 'count' + } + ] + }, + hasNoTimeDimensionsWithoutGranularity: false, + allFiltersWithinSelectedDimensions: false, + isAdditive: true, + granularityHierarchies: { + 'line_items_to_orders.created_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'line_items_to_orders.created_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'line_items_to_orders.created_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'line_items_to_orders.created_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'line_items_to_orders.created_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'line_items_to_orders.created_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'line_items_to_orders.created_at.minute': [ + 'minute', + 'second' + ], + 'line_items_to_orders.created_at.second': [ + 'second' + ], + 'orders_to_line_items.created_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.created_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.created_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.created_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.created_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.created_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.created_at.minute': [ + 'minute', + 'second' + ], + 'orders_to_line_items.created_at.second': [ + 'second' + ], + 'orders_to_line_items.completed_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.completed_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.completed_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.completed_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.completed_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.completed_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'orders_to_line_items.completed_at.minute': [ + 'minute', + 'second' + ], + 'orders_to_line_items.completed_at.second': [ + 'second' + ], + 'products.created_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'products.created_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'products.created_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'products.created_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'products.created_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'products.created_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'products.created_at.minute': [ + 'minute', + 'second' + ], + 'products.created_at.second': [ + 'second' + ], + 'simple_orders.created_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders.created_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders.created_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders.created_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders.created_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders.created_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'simple_orders.created_at.minute': [ + 'minute', + 'second' + ], + 'simple_orders.created_at.second': [ + 'second' + ], + 'simple_orders_sql_ext.created_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders_sql_ext.created_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders_sql_ext.created_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders_sql_ext.created_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders_sql_ext.created_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'simple_orders_sql_ext.created_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'simple_orders_sql_ext.created_at.minute': [ + 'minute', + 'second' + ], + 'simple_orders_sql_ext.created_at.second': [ + 'second' + ], + 'users.created_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'users.created_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'users.created_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'users.created_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'users.created_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'users.created_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'users.created_at.minute': [ + 'minute', + 'second' + ], + 'users.created_at.second': [ + 'second' + ], + 'base_orders.created_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.created_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.created_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.created_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.created_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.created_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'base_orders.created_at.minute': [ + 'minute', + 'second' + ], + 'base_orders.created_at.second': [ + 'second' + ], + 'base_orders.completed_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.completed_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.completed_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.completed_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.completed_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.completed_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'base_orders.completed_at.minute': [ + 'minute', + 'second' + ], + 'base_orders.completed_at.second': [ + 'second' + ], + 'base_orders.event_date.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.event_date.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.event_date.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.event_date.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.event_date.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.event_date.hour': [ + 'hour', + 'minute', + 'second' + ], + 'base_orders.event_date.minute': [ + 'minute', + 'second' + ], + 'base_orders.event_date.second': [ + 'second' + ], + 'base_orders.event_date.fiscal_year': [ + 'fiscal_year', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'base_orders.event_date.fiscal_quarter': [ + 'fiscal_quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'check_dup_names.created_at.year': [ + 'year', + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'check_dup_names.created_at.quarter': [ + 'quarter', + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'check_dup_names.created_at.month': [ + 'month', + 'day', + 'hour', + 'minute', + 'second' + ], + 'check_dup_names.created_at.week': [ + 'week', + 'day', + 'hour', + 'minute', + 'second' + ], + 'check_dup_names.created_at.day': [ + 'day', + 'hour', + 'minute', + 'second' + ], + 'check_dup_names.created_at.hour': [ + 'hour', + 'minute', + 'second' + ], + 'check_dup_names.created_at.minute': [ + 'minute', + 'second' + ], + 'check_dup_names.created_at.second': [ + 'second' + ] + }, + hasMultipliedMeasures: false, + hasCumulativeMeasures: false, + windowGranularity: null, + filterDimensionsSingleValueEqual: null, + ownedDimensions: [ + 'base_orders.event_date', + 'base_orders.status', + 'users.sf_users' + ], + ownedTimeDimensionsWithRollupGranularity: [ + [ + 'base_orders.completed_at', + 'day' + ], + [ + 'base_orders.created_at', + 'month' + ] + ], + ownedTimeDimensionsAsIs: [ + [ + 'base_orders.completed_at', + null + ], + [ + 'base_orders.created_at', + 'month' + ] + ], + allBackAliasMembers: {}, + ungrouped: null, + sortedUsedCubePrimaryKeys: null, + sortedAllCubeNames: null, + hasMultiStage: false + }, + requestId: '2ac2a7b1-008b-41ec-be93-691f79a55348-span-1', + annotation: { + measures: { + 'base_orders.count': { + title: 'Base Orders Count', + shortTitle: 'Count', + type: 'number', + drillMembers: [ + 'base_orders.id', + 'base_orders.status', + 'users.city', + 'users.gender' + ], + drillMembersGrouped: { + measures: [], + dimensions: [ + 'base_orders.id', + 'base_orders.status', + 'users.city', + 'users.gender' + ] + } + } + }, + dimensions: { + 'base_orders.status': { + title: 'Base Orders Status', + shortTitle: 'Status', + type: 'string', + meta: { + addDesc: 'The status of order', + moreNum: 42 + } + } + }, + timeDimensions: { + 'base_orders.created_at': { + title: 'Base Orders Created at', + shortTitle: 'Created at', + type: 'time' + }, + 'base_orders.created_at.month': { + title: 'Base Orders Created at', + shortTitle: 'Created at', + type: 'time', + granularity: { + name: 'month', + title: 'month', + interval: '1 month' + } + } + }, + segments: { + 'users.sf_users': { + title: 'Users Sf Users', + shortTitle: 'Sf Users' + } + } + }, + dataSource: 'default', + dbType: 'postgres', + extDbType: 'cubestore', + external: false, + slowQuery: false, + total: 19, + data: [ + { + 'base_orders.created_at.month': '2023-04-01T00:00:00.000', + 'base_orders.created_at': '2023-04-01T00:00:00.000', + 'base_orders.count': '2', + 'base_orders.status': 'completed' + }, + { + 'base_orders.count': '6', + 'base_orders.created_at': '2023-05-01T00:00:00.000', + 'base_orders.created_at.month': '2023-05-01T00:00:00.000', + 'base_orders.status': 'completed' + }, + { + 'base_orders.count': '6', + 'base_orders.status': 'processing', + 'base_orders.created_at': '2023-05-01T00:00:00.000', + 'base_orders.created_at.month': '2023-05-01T00:00:00.000' + }, + { + 'base_orders.count': '9', + 'base_orders.created_at.month': '2023-05-01T00:00:00.000', + 'base_orders.status': 'shipped', + 'base_orders.created_at': '2023-05-01T00:00:00.000' + }, + { + 'base_orders.created_at': '2023-06-01T00:00:00.000', + 'base_orders.status': 'completed', + 'base_orders.created_at.month': '2023-06-01T00:00:00.000', + 'base_orders.count': '5' + }, + { + 'base_orders.count': '5', + 'base_orders.status': 'processing', + 'base_orders.created_at': '2023-06-01T00:00:00.000', + 'base_orders.created_at.month': '2023-06-01T00:00:00.000' + }, + { + 'base_orders.count': '13', + 'base_orders.created_at': '2023-06-01T00:00:00.000', + 'base_orders.status': 'shipped', + 'base_orders.created_at.month': '2023-06-01T00:00:00.000' + }, + { + 'base_orders.status': 'completed', + 'base_orders.created_at.month': '2023-07-01T00:00:00.000', + 'base_orders.created_at': '2023-07-01T00:00:00.000', + 'base_orders.count': '5' + }, + { + 'base_orders.created_at.month': '2023-07-01T00:00:00.000', + 'base_orders.status': 'processing', + 'base_orders.created_at': '2023-07-01T00:00:00.000', + 'base_orders.count': '7' + }, + { + 'base_orders.count': '5', + 'base_orders.status': 'shipped', + 'base_orders.created_at': '2023-07-01T00:00:00.000', + 'base_orders.created_at.month': '2023-07-01T00:00:00.000' + }, + { + 'base_orders.created_at': '2023-08-01T00:00:00.000', + 'base_orders.status': 'completed', + 'base_orders.count': '11', + 'base_orders.created_at.month': '2023-08-01T00:00:00.000' + }, + { + 'base_orders.count': '3', + 'base_orders.created_at.month': '2023-08-01T00:00:00.000', + 'base_orders.created_at': '2023-08-01T00:00:00.000', + 'base_orders.status': 'processing' + }, + { + 'base_orders.status': 'shipped', + 'base_orders.count': '4', + 'base_orders.created_at.month': '2023-08-01T00:00:00.000', + 'base_orders.created_at': '2023-08-01T00:00:00.000' + }, + { + 'base_orders.created_at.month': '2023-09-01T00:00:00.000', + 'base_orders.status': 'completed', + 'base_orders.count': '5', + 'base_orders.created_at': '2023-09-01T00:00:00.000' + }, + { + 'base_orders.count': '10', + 'base_orders.created_at.month': '2023-09-01T00:00:00.000', + 'base_orders.status': 'processing', + 'base_orders.created_at': '2023-09-01T00:00:00.000' + }, + { + 'base_orders.created_at': '2023-09-01T00:00:00.000', + 'base_orders.count': '9', + 'base_orders.created_at.month': '2023-09-01T00:00:00.000', + 'base_orders.status': 'shipped' + }, + { + 'base_orders.count': '4', + 'base_orders.created_at.month': '2023-10-01T00:00:00.000', + 'base_orders.created_at': '2023-10-01T00:00:00.000', + 'base_orders.status': 'completed' + }, + { + 'base_orders.count': '5', + 'base_orders.created_at': '2023-10-01T00:00:00.000', + 'base_orders.status': 'processing', + 'base_orders.created_at.month': '2023-10-01T00:00:00.000' + }, + { + 'base_orders.status': 'shipped', + 'base_orders.created_at.month': '2023-10-01T00:00:00.000', + 'base_orders.count': '9', + 'base_orders.created_at': '2023-10-01T00:00:00.000' + } + ] + } + ], + pivotQuery: { + measures: [ + 'base_orders.count' + ], + dimensions: [ + 'base_orders.status' + ], + timeDimensions: [ + { + dimension: 'base_orders.created_at', + granularity: 'month' + }, + { + dimension: 'base_orders.completed_at', + dateRange: [ + '2023-05-16T00:00:00.000', + '2025-05-16T23:59:59.999' + ] + } + ], + segments: [ + 'users.sf_users' + ], + limit: 10000, + total: true, + timezone: 'UTC', + filters: [ + { + member: 'base_orders.fiscal_event_date_label', + operator: 'set' + } + ], + rowLimit: 10000, + queryType: 'regularQuery' + }, + slowQuery: false +}; + +export const NumericCastedData = DescriptiveQueryResponse.results[0].data.map(r => ({ + ...r, + 'base_orders.count': Number(r['base_orders.count']) +})); diff --git a/packages/cubejs-client-core/src/index.test.js b/packages/cubejs-client-core/test/index.test.ts similarity index 91% rename from packages/cubejs-client-core/src/index.test.js rename to packages/cubejs-client-core/test/index.test.ts index 5dbb3330e6412..ac65e4ab2c9f1 100644 --- a/packages/cubejs-client-core/src/index.test.js +++ b/packages/cubejs-client-core/test/index.test.ts @@ -6,12 +6,21 @@ /* globals describe,test,expect,beforeEach,jest */ -import ResultSet from './ResultSet'; -import { CubeApi } from './index'; +import { CubeApi, LoadMethodOptions, LoadResponse } from '../src/index'; +import ResultSet from '../src/ResultSet'; + +jest.mock('../src/ResultSet'); + +const MockedResultSet = ResultSet as jest.MockedClass; + +class CubeApiTest extends CubeApi { + public loadResponseInternal(response: LoadResponse, options: LoadMethodOptions | null = {}): ResultSet { + return super.loadResponseInternal(response, options); + } +} -jest.mock('./ResultSet'); beforeEach(() => { - ResultSet.mockClear(); + MockedResultSet.mockClear(); }); const mockData = { @@ -205,7 +214,7 @@ const mockData = { describe('CubeApi', () => { test('CubeApi#loadResponseInternal should work with the "default" resType for regular query', () => { - const api = new CubeApi(undefined, { + const api = new CubeApiTest(undefined, { apiUrl: 'http://localhost:4000/cubejs-api/v1', }); const income = { @@ -228,7 +237,7 @@ describe('CubeApi', () => { ) }], }; - api.loadResponseInternal(income); + api.loadResponseInternal(income as LoadResponse); expect(ResultSet).toHaveBeenCalled(); expect(ResultSet).toHaveBeenCalledTimes(1); expect(ResultSet).toHaveBeenCalledWith(outcome, { @@ -237,7 +246,7 @@ describe('CubeApi', () => { }); test('CubeApi#loadResponseInternal should work with the "default" resType for compare date range query', () => { - const api = new CubeApi(undefined, { + const api = new CubeApiTest(undefined, { apiUrl: 'http://localhost:4000/cubejs-api/v1', }); const income = { @@ -274,7 +283,7 @@ describe('CubeApi', () => { ) }], }; - api.loadResponseInternal(income); + api.loadResponseInternal(income as LoadResponse); expect(ResultSet).toHaveBeenCalled(); expect(ResultSet).toHaveBeenCalledTimes(1); expect(ResultSet).toHaveBeenCalledWith(outcome, { @@ -283,7 +292,7 @@ describe('CubeApi', () => { }); test('CubeApi#loadResponseInternal should work with the "default" resType for blending query', () => { - const api = new CubeApi(undefined, { + const api = new CubeApiTest(undefined, { apiUrl: 'http://localhost:4000/cubejs-api/v1', }); const income = { @@ -320,7 +329,7 @@ describe('CubeApi', () => { ) }], }; - api.loadResponseInternal(income); + api.loadResponseInternal(income as LoadResponse); expect(ResultSet).toHaveBeenCalled(); expect(ResultSet).toHaveBeenCalledTimes(1); expect(ResultSet).toHaveBeenCalledWith(outcome, { @@ -329,7 +338,7 @@ describe('CubeApi', () => { }); test('CubeApi#loadResponseInternal should work with the "compact" resType for regular query', () => { - const api = new CubeApi(undefined, { + const api = new CubeApiTest(undefined, { apiUrl: 'http://localhost:4000/cubejs-api/v1', }); const income = { @@ -352,7 +361,7 @@ describe('CubeApi', () => { ) }], }; - api.loadResponseInternal(income); + api.loadResponseInternal(income as LoadResponse); expect(ResultSet).toHaveBeenCalled(); expect(ResultSet).toHaveBeenCalledTimes(1); expect(ResultSet).toHaveBeenCalledWith(outcome, { @@ -361,7 +370,7 @@ describe('CubeApi', () => { }); test('CubeApi#loadResponseInternal should work with the "compact" resType for compare date range query', () => { - const api = new CubeApi(undefined, { + const api = new CubeApiTest(undefined, { apiUrl: 'http://localhost:4000/cubejs-api/v1', }); const income = { @@ -398,7 +407,7 @@ describe('CubeApi', () => { ) }], }; - api.loadResponseInternal(income); + api.loadResponseInternal(income as LoadResponse); expect(ResultSet).toHaveBeenCalled(); expect(ResultSet).toHaveBeenCalledTimes(1); expect(ResultSet).toHaveBeenCalledWith(outcome, { @@ -407,7 +416,7 @@ describe('CubeApi', () => { }); test('CubeApi#loadResponseInternal should work with the "compact" resType for blending query', () => { - const api = new CubeApi(undefined, { + const api = new CubeApiTest(undefined, { apiUrl: 'http://localhost:4000/cubejs-api/v1', }); const income = { @@ -444,7 +453,7 @@ describe('CubeApi', () => { ) }], }; - api.loadResponseInternal(income); + api.loadResponseInternal(income as LoadResponse); expect(ResultSet).toHaveBeenCalled(); expect(ResultSet).toHaveBeenCalledTimes(1); expect(ResultSet).toHaveBeenCalledWith(outcome, { diff --git a/packages/cubejs-client-core/src/tests/table.test.js b/packages/cubejs-client-core/test/table.test.ts similarity index 93% rename from packages/cubejs-client-core/src/tests/table.test.js rename to packages/cubejs-client-core/test/table.test.ts index 14240546c158b..41bf623e1a6ab 100644 --- a/packages/cubejs-client-core/src/tests/table.test.js +++ b/packages/cubejs-client-core/test/table.test.ts @@ -1,5 +1,8 @@ +/* globals describe, expect, test */ + import 'jest'; -import ResultSet from '../ResultSet'; +import ResultSet from '../src/ResultSet'; +import { PivotConfig } from '../src/types'; describe('resultSet tablePivot and tableColumns', () => { describe('it works with one measure', () => { @@ -62,10 +65,10 @@ describe('resultSet tablePivot and tableColumns', () => { segments: {}, timeDimensions: {}, }, - }); + } as any); describe('all dimensions on `x` axis', () => { - const pivotConfig = { + const pivotConfig: PivotConfig = { x: ['Users.country', 'Users.gender'], y: ['measures'], }; @@ -129,7 +132,7 @@ describe('resultSet tablePivot and tableColumns', () => { }); describe('one dimension on `x` and one one `y` axis', () => { - const pivotConfig = { + const pivotConfig: PivotConfig = { x: ['Users.country'], y: ['Users.gender', 'measures'], }; @@ -477,7 +480,7 @@ describe('resultSet tablePivot and tableColumns', () => { segments: {}, timeDimensions: {}, }, - }); + } as any); test('all dimensions on `x` axis', () => { const pivotConfig = { @@ -622,7 +625,7 @@ describe('resultSet tablePivot and tableColumns', () => { segments: {}, timeDimensions: {}, }, - }); + } as any); test('all dimensions on `x` axis', () => { const pivotConfig = { @@ -697,16 +700,16 @@ describe('resultSet tablePivot and tableColumns', () => { test('order of values is preserved', () => { const resultSet = new ResultSet({ query: { - measures: [ + measures: [ 'Branch.count' ], dimensions: [ 'Tenant.number' ], - 'order': [ + order: [ { - 'id': 'Tenant.number', - 'desc': true + id: 'Tenant.number', + desc: true } ], filters: [], @@ -738,54 +741,54 @@ describe('resultSet tablePivot and tableColumns', () => { segments: {}, timeDimensions: {} } - }); + } as any); expect(resultSet.tableColumns({ - 'x': [], - 'y': [ + x: [], + y: [ 'Tenant.number' ] })).toEqual( - [ - { - 'key': '6', - 'type': 'string', - 'title': 'Tenant Number 6', - 'shortTitle': '6', - 'format': undefined, - 'meta': undefined, - 'children': [ - { - 'key': 'Branch.count', - 'type': 'number', - 'dataIndex': '6,Branch.count', - 'title': 'Branch.count', - 'shortTitle': 'Branch.count', - 'format': undefined, - 'meta': undefined, - } - ] - }, - { - 'key': '1', - 'type': 'string', - 'title': 'Tenant Number 1', - 'shortTitle': '1', - 'format': undefined, - 'meta': undefined, - 'children': [ - { - 'key': 'Branch.count', - 'type': 'number', - 'dataIndex': '1,Branch.count', - 'title': 'Branch.count', - 'shortTitle': 'Branch.count', - 'format': undefined, - "meta": undefined, - } - ] - } - ] + [ + { + key: '6', + type: 'string', + title: 'Tenant Number 6', + shortTitle: '6', + format: undefined, + meta: undefined, + children: [ + { + key: 'Branch.count', + type: 'number', + dataIndex: '6,Branch.count', + title: 'Branch.count', + shortTitle: 'Branch.count', + format: undefined, + meta: undefined, + } + ] + }, + { + key: '1', + type: 'string', + title: 'Tenant Number 1', + shortTitle: '1', + format: undefined, + meta: undefined, + children: [ + { + key: 'Branch.count', + type: 'number', + dataIndex: '1,Branch.count', + title: 'Branch.count', + shortTitle: 'Branch.count', + format: undefined, + meta: undefined, + } + ] + } + ] ); }); }); diff --git a/packages/cubejs-client-core/src/tests/utils.test.js b/packages/cubejs-client-core/test/utils.test.ts similarity index 84% rename from packages/cubejs-client-core/src/tests/utils.test.js rename to packages/cubejs-client-core/test/utils.test.ts index 59c7bf0fb4acb..f56d0ec2a0840 100644 --- a/packages/cubejs-client-core/src/tests/utils.test.js +++ b/packages/cubejs-client-core/test/utils.test.ts @@ -1,7 +1,9 @@ +/* globals describe, expect, test */ + import 'jest'; -import { defaultOrder } from '../utils'; -import { dayRange, TIME_SERIES } from '../time'; +import { defaultOrder } from '../src/utils'; +import { dayRange, TIME_SERIES } from '../src/time'; describe('utils', () => { test('default order', () => { diff --git a/packages/cubejs-client-core/tsconfig.json b/packages/cubejs-client-core/tsconfig.json index d7a96505a301b..6637475d03a1e 100644 --- a/packages/cubejs-client-core/tsconfig.json +++ b/packages/cubejs-client-core/tsconfig.json @@ -1,13 +1,18 @@ { + "include": [ + "src/**/*", + "test/**/*", + "test/fixtures/datablending/load-responses.json" + ], "compilerOptions": { - "lib": ["es2017"], - "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ - "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "target": "ES2020", + "lib": ["dom", "dom.iterable", "ES2022"], + "module": "ES2020", + "moduleResolution": "node", "declaration": true, "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ "declarationDir": "./dist", /* Generates a sourcemap for each corresponding '.d.ts' file. */ "sourceMap": false, /* Generates corresponding '.map' file. */ - "outDir": "./temp", /* Redirect output structure to the directory. */ "strict": true, /* Enable all strict type-checking options. */ "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ "strictNullChecks": true, /* Enable strict null checks. */ @@ -19,6 +24,10 @@ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ "allowSyntheticDefaultImports": true, "skipLibCheck": true, /* Skip type checking of declaration files. */ - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ + "outDir": "dist", + "rootDir": ".", + "baseUrl": ".", + "resolveJsonModule": true, } } diff --git a/packages/cubejs-client-ngx/src/client.ts b/packages/cubejs-client-ngx/src/client.ts index 11a8d83b2f29c..0c86ea53bd411 100644 --- a/packages/cubejs-client-ngx/src/client.ts +++ b/packages/cubejs-client-ngx/src/client.ts @@ -55,8 +55,8 @@ export class CubeClient { public load( query: Query | Query[], options?: LoadMethodOptions - ): Observable { - return from(>this.apiInstance().load(query, options)); + ): Observable> { + return from(>>this.apiInstance().load(query, options)); } public sql( @@ -77,7 +77,7 @@ export class CubeClient { return from(this.apiInstance().meta(options)); } - public watch(query, params = {}): Observable { + public watch(query, params = {}): Observable> { return new Observable((observer) => query.subscribe({ next: async (query) => { diff --git a/packages/cubejs-client-ngx/src/query-builder/builder-meta.ts b/packages/cubejs-client-ngx/src/query-builder/builder-meta.ts index 139bcbb2067da..b57dba6bd4f96 100644 --- a/packages/cubejs-client-ngx/src/query-builder/builder-meta.ts +++ b/packages/cubejs-client-ngx/src/query-builder/builder-meta.ts @@ -2,13 +2,13 @@ import { Meta, TCubeDimension, TCubeMeasure, - TCubeMember, + TCubeSegment, } from '@cubejs-client/core'; export class BuilderMeta { measures: TCubeMeasure[]; dimensions: TCubeDimension[]; - segments: TCubeMember[]; + segments: TCubeSegment[]; timeDimensions: TCubeDimension[]; filters: Array; diff --git a/packages/cubejs-client-vue/src/QueryBuilder.js b/packages/cubejs-client-vue/src/QueryBuilder.js index 3f3a998073439..5fd15fdb3abed 100644 --- a/packages/cubejs-client-vue/src/QueryBuilder.js +++ b/packages/cubejs-client-vue/src/QueryBuilder.js @@ -658,8 +658,8 @@ export default { .dryRun(query, { mutexObj: this.mutex, }) - .then(({ pivotQuery }) => { - const pivotConfig = ResultSet.getNormalizedPivotConfig(pivotQuery, this.pivotConfig); + .then((result) => { + const pivotConfig = ResultSet.getNormalizedPivotConfig(result?.pivotQuery, this.pivotConfig); if (!equals(pivotConfig, this.pivotConfig)) { this.pivotConfig = pivotConfig; diff --git a/packages/cubejs-client-vue3/src/QueryBuilder.js b/packages/cubejs-client-vue3/src/QueryBuilder.js index 3877e75ed9819..d0efd07884bc1 100644 --- a/packages/cubejs-client-vue3/src/QueryBuilder.js +++ b/packages/cubejs-client-vue3/src/QueryBuilder.js @@ -648,8 +648,8 @@ export default { .dryRun(query, { mutexObj: this.mutex, }) - .then(({ pivotQuery }) => { - const pivotConfig = ResultSet.getNormalizedPivotConfig(pivotQuery, this.pivotConfig); + .then((result) => { + const pivotConfig = ResultSet.getNormalizedPivotConfig(result?.pivotQuery, this.pivotConfig); if (!equals(pivotConfig, this.pivotConfig)) { this.pivotConfig = pivotConfig; diff --git a/packages/cubejs-client-ws-transport/package.json b/packages/cubejs-client-ws-transport/package.json index 2a06d70fa88f7..d0b14ece87108 100644 --- a/packages/cubejs-client-ws-transport/package.json +++ b/packages/cubejs-client-ws-transport/package.json @@ -8,7 +8,7 @@ "directory": "packages/cubejs-client-ws-transport" }, "description": "Cube.js WebSocket transport", - "main": "dist/cubejs-client-ws-transport.js", + "main": "dist/cubejs-client-ws-transport.cjs.js", "typings": "dist/index.d.ts", "author": "Cube Dev, Inc.", "scripts": { diff --git a/packages/cubejs-linter/index.js b/packages/cubejs-linter/index.js index 159d6f99aceb7..9d2c470987ab1 100644 --- a/packages/cubejs-linter/index.js +++ b/packages/cubejs-linter/index.js @@ -105,9 +105,7 @@ module.exports = { '@typescript-eslint/explicit-member-accessibility': 'error', 'no-shadow': 'off', '@typescript-eslint/no-shadow': ['error', { ignoreTypeValueShadow: true }], - // 'no-duplicate-imports': 'off', - '@typescript-eslint/no-duplicate-imports': 'error', semi: 'off', '@typescript-eslint/semi': 'error', }, diff --git a/packages/cubejs-query-orchestrator/src/orchestrator/BaseQueueDriver.ts b/packages/cubejs-query-orchestrator/src/orchestrator/BaseQueueDriver.ts index 9d838553c0337..dea283349bd43 100644 --- a/packages/cubejs-query-orchestrator/src/orchestrator/BaseQueueDriver.ts +++ b/packages/cubejs-query-orchestrator/src/orchestrator/BaseQueueDriver.ts @@ -14,7 +14,7 @@ export abstract class BaseQueueDriver implements QueueDriverInterface { return getCacheHash(queryKey, this.processUid); } - abstract createConnection(): Promise; + public abstract createConnection(): Promise; - abstract release(connection: QueueDriverConnectionInterface): void; + public abstract release(connection: QueueDriverConnectionInterface): void; } diff --git a/packages/cubejs-schema-compiler/package.json b/packages/cubejs-schema-compiler/package.json index 222223fa23bd4..88e50f69389ea 100644 --- a/packages/cubejs-schema-compiler/package.json +++ b/packages/cubejs-schema-compiler/package.json @@ -81,8 +81,7 @@ "source-map-support": "^0.5.19", "sqlstring": "^2.3.1", "testcontainers": "^10.13.0", - "typescript": "~5.2.2", - "uuid": "^8.3.2" + "typescript": "~5.2.2" }, "license": "Apache-2.0", "eslintConfig": { diff --git a/packages/cubejs-testing-shared/src/query-test.abstract.ts b/packages/cubejs-testing-shared/src/query-test.abstract.ts index bd9a929e6872f..cb6cf9a624991 100644 --- a/packages/cubejs-testing-shared/src/query-test.abstract.ts +++ b/packages/cubejs-testing-shared/src/query-test.abstract.ts @@ -13,7 +13,7 @@ export const prepareCompiler = (content: any, options?: any) => originalPrepareC }, { adapter: 'postgres', ...options }); export abstract class QueryTestAbstract { - abstract getQueryClass(): any; + public abstract getQueryClass(): any; protected getQuery(compilers: any, options: any): BaseQuery { const QueryClass = this.getQueryClass(); diff --git a/rollup.config.js b/rollup.config.js index 8300ea079c49f..d976825c4901e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,9 +2,17 @@ import babel from '@rollup/plugin-babel'; import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import alias from '@rollup/plugin-alias'; +import tsconfigPaths from 'rollup-plugin-tsconfig-paths'; import peerDepsExternal from 'rollup-plugin-peer-deps-external'; +import json from '@rollup/plugin-json'; +import { builtinModules } from 'module'; -const bundle = (name, globalName, { globals = {}, ...baseConfig }, umdConfig) => { +const bundle = ( + name, + globalName, + { globals = {}, ...baseConfig }, + umdConfig +) => { const baseUmdConfig = { ...(umdConfig || baseConfig), plugins: [ @@ -14,6 +22,7 @@ const bundle = (name, globalName, { globals = {}, ...baseConfig }, umdConfig) => resolve({ extensions: ['.ts', '.js', '.json'], mainFields: ['browser', 'module', 'main'], + resolveOnly: [/^\.\.?/], }), babel({ extensions: ['.js', '.jsx', '.ts', '.tsx'], @@ -45,13 +54,16 @@ const bundle = (name, globalName, { globals = {}, ...baseConfig }, umdConfig) => }), alias({ entries: { - '@cubejs-client/core': '../cubejs-client-core/src/index.js', + '@cubejs-client/core': '../cubejs-client-core/src/index.ts', }, }), ], }; - return [ + // Will be built with typescript + const skipEsModule = name === 'cubejs-client-core'; + + const config = [ // browser-friendly UMD build { ...baseUmdConfig, @@ -69,6 +81,13 @@ const bundle = (name, globalName, { globals = {}, ...baseConfig }, umdConfig) => { ...baseConfig, plugins: [ + json(), + tsconfigPaths(), + resolve({ + extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx', '.json'], + resolveOnly: [/^\.\.?/], + }), + commonjs(), peerDepsExternal(), babel({ extensions: ['.js', '.jsx', '.ts', '.tsx'], @@ -101,24 +120,30 @@ const bundle = (name, globalName, { globals = {}, ...baseConfig }, umdConfig) => ], output: [ { - file: `packages/${name}/dist/${name}.js`, + file: `packages/${name}/dist/${name}.cjs.js`, format: 'cjs', sourcemap: true, - } + }, ], }, + ]; + + if (!skipEsModule) { // ES module (for bundlers) build. - { + config.push({ ...baseConfig, plugins: [ + tsconfigPaths(), + resolve({ + extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx', '.json'], + resolveOnly: [/^\.\.?/], + }), + commonjs(), peerDepsExternal(), babel({ extensions: ['.js', '.jsx', '.ts', '.tsx'], exclude: 'node_modules/**', - presets: [ - '@babel/preset-react', - '@babel/preset-typescript', - ], + presets: ['@babel/preset-react', '@babel/preset-typescript'], }), ], output: [ @@ -129,18 +154,20 @@ const bundle = (name, globalName, { globals = {}, ...baseConfig }, umdConfig) => globals, }, ], - }, - ]; + }); + } + + return config; }; export default bundle( 'cubejs-client-core', 'cubejs', { - input: 'packages/cubejs-client-core/src/index.js', + input: 'packages/cubejs-client-core/src/index.ts', }, { - input: 'packages/cubejs-client-core/src/index.umd.js', + input: 'packages/cubejs-client-core/src/index.umd.ts', } ) .concat( diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 0000000000000..2cd2e6642cdae --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "sourceMap": true, + "inlineSourceMap": true, + "inlineSources": true, + "resolveJsonModule": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 083de832790a3..aa6ec37cefcb6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,9 @@ { "extends": "./tsconfig.base.json", "references": [ + { + "path": "packages/cubejs-client-core" + }, { "path": "packages/cubejs-client-ws-transport" }, diff --git a/yarn.lock b/yarn.lock index 55a6ad94fd477..70ae277646c5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7837,7 +7837,7 @@ resolved "https://registry.yarnpkg.com/@types/json-bigint/-/json-bigint-1.0.1.tgz#201062a6990119a8cc18023cfe1fed12fc2fc8a7" integrity sha512-zpchZLNsNuzJHi6v64UBoFWAvQlPhch7XAi36FkH6tL1bbbmimIF+cS7vwkzY4u5RaSWMoflQfu+TshMPPw8uw== -"@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.7", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -8306,20 +8306,6 @@ dependencies: "@types/node" "*" -"@typescript-eslint/eslint-plugin@^4.17.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.33.0.tgz#c24dc7c8069c7706bc40d99f6fa87edcb2005276" - integrity sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg== - dependencies: - "@typescript-eslint/experimental-utils" "4.33.0" - "@typescript-eslint/scope-manager" "4.33.0" - debug "^4.3.1" - functional-red-black-tree "^1.0.1" - ignore "^5.1.8" - regexpp "^3.1.0" - semver "^7.3.5" - tsutils "^3.21.0" - "@typescript-eslint/eslint-plugin@^6.12.0": version "6.12.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.12.0.tgz#2a647d278bb48bf397fef07ba0507612ff9dd812" @@ -8337,18 +8323,6 @@ semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/experimental-utils@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.33.0.tgz#6f2a786a4209fa2222989e9380b5331b2810f7fd" - integrity sha512-zeQjOoES5JFjTnAhI5QY7ZviczMzDptls15GFsI6jyUOq0kOf9+WonkhtlIhh0RgHRnqj5gdNxW5j1EvAyYg6Q== - dependencies: - "@types/json-schema" "^7.0.7" - "@typescript-eslint/scope-manager" "4.33.0" - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/typescript-estree" "4.33.0" - eslint-scope "^5.1.1" - eslint-utils "^3.0.0" - "@typescript-eslint/parser@^6.12.0": version "6.12.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.12.0.tgz#9fb21ed7d88065a4a2ee21eb80b8578debb8217c" @@ -8360,14 +8334,6 @@ "@typescript-eslint/visitor-keys" "6.12.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.33.0.tgz#d38e49280d983e8772e29121cf8c6e9221f280a3" - integrity sha512-5IfJHpgTsTZuONKbODctL4kKuQje/bzBRkwHE8UOZ4f89Zeddg+EGZs8PD8NcN4LdM3ygHWYB3ukPAYjvl/qbQ== - dependencies: - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/visitor-keys" "4.33.0" - "@typescript-eslint/scope-manager@6.12.0": version "6.12.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.12.0.tgz#5833a16dbe19cfbad639d4d33bcca5e755c7044b" @@ -8386,29 +8352,11 @@ debug "^4.3.4" ts-api-utils "^1.0.1" -"@typescript-eslint/types@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.33.0.tgz#a1e59036a3b53ae8430ceebf2a919dc7f9af6d72" - integrity sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ== - "@typescript-eslint/types@6.12.0": version "6.12.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.12.0.tgz#ffc5297bcfe77003c8b7b545b51c2505748314ac" integrity sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q== -"@typescript-eslint/typescript-estree@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.33.0.tgz#0dfb51c2908f68c5c08d82aefeaf166a17c24609" - integrity sha512-rkWRY1MPFzjwnEVHsxGemDzqqddw2QbTJlICPD9p9I9LfsO8fdmfQPOX3uKfUaGRDFJbfrtm/sXhVXN4E+bzCA== - dependencies: - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/visitor-keys" "4.33.0" - debug "^4.3.1" - globby "^11.0.3" - is-glob "^4.0.1" - semver "^7.3.5" - tsutils "^3.21.0" - "@typescript-eslint/typescript-estree@6.12.0": version "6.12.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.12.0.tgz#764ccc32598549e5b48ec99e3b85f89b1385310c" @@ -8435,14 +8383,6 @@ "@typescript-eslint/typescript-estree" "6.12.0" semver "^7.5.4" -"@typescript-eslint/visitor-keys@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.33.0.tgz#2a22f77a41604289b7a186586e9ec48ca92ef1dd" - integrity sha512-uqi/2aSz9g2ftcHWf8uLPJA70rUv6yuMW5Bohw+bwcuzaxQIHaKFZCKGoGXIrc9vkTJ3+0txM73K0Hq3d5wgIg== - dependencies: - "@typescript-eslint/types" "4.33.0" - eslint-visitor-keys "^2.0.0" - "@typescript-eslint/visitor-keys@6.12.0": version "6.12.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.12.0.tgz#5877950de42a0f3344261b7a1eee15417306d7e9" @@ -10430,6 +10370,13 @@ browserslist@^4.0.0, browserslist@^4.16.0, browserslist@^4.16.3, browserslist@^4 node-releases "^2.0.19" update-browserslist-db "^1.1.1" +bs-logger@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + bser@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" @@ -12953,7 +12900,7 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -ejs@^3.1.7: +ejs@^3.1.10, ejs@^3.1.7: version "3.1.10" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== @@ -13622,13 +13569,6 @@ eslint-utils@^2.1.0: dependencies: eslint-visitor-keys "^1.1.0" -eslint-utils@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672" - integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== - dependencies: - eslint-visitor-keys "^2.0.0" - eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" @@ -14150,7 +14090,7 @@ fast-glob@^3.3.1, fast-glob@^3.3.2, fast-glob@^3.3.3: merge2 "^1.3.0" micromatch "^4.0.8" -fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -15687,7 +15627,7 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.0.4, ignore@^5.1.1, ignore@^5.1.4, ignore@^5.1.8, ignore@^5.1.9, ignore@^5.2.0, ignore@^5.2.4: +ignore@^5.0.4, ignore@^5.1.1, ignore@^5.1.4, ignore@^5.1.9, ignore@^5.2.0, ignore@^5.2.4: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== @@ -17239,7 +17179,7 @@ jest-util@^28.1.3: graceful-fs "^4.2.9" picomatch "^2.2.3" -jest-util@^29.7.0: +jest-util@^29.0.0, jest-util@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== @@ -18539,6 +18479,11 @@ make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: dependencies: semver "^6.0.0" +make-error@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + make-fetch-happen@^10.0.3: version "10.2.1" resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz#f5e3835c5e9817b617f2770870d9492d28678164" @@ -22924,6 +22869,13 @@ rollup-plugin-peer-deps-external@^2.2.4: resolved "https://registry.yarnpkg.com/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.4.tgz#8a420bbfd6dccc30aeb68c9bf57011f2f109570d" integrity sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g== +rollup-plugin-tsconfig-paths@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/rollup-plugin-tsconfig-paths/-/rollup-plugin-tsconfig-paths-1.5.2.tgz#753bf970b14594b9ea1e93f3237eda380635f5b5" + integrity sha512-tyS7u2Md0eXKwbDfTuDDa1izciwqhOZsHzX7zYc5gKC1L7q5ozdSt+q1jjtD1dDqWyjrt8lZoiLtOQGhMHh1OQ== + dependencies: + typescript-paths "^1.5.1" + rollup@2.53.1: version "2.53.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.53.1.tgz#b60439efd1eb41bdb56630509bd99aae78b575d3" @@ -23271,6 +23223,11 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@^7.7.2: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + send@0.19.0: version "0.19.0" resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" @@ -25021,6 +24978,22 @@ ts-invariant@^0.10.3: dependencies: tslib "^2.1.0" +ts-jest@^29: + version "29.3.3" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.3.3.tgz#c24c31a9d12268f88899e3eeb05912cab42c574c" + integrity sha512-y6jLm19SL4GroiBmHwFK4dSHUfDNmOrJbRfp6QmDIlI9p5tT5Q8ItccB4pTIslCIqOZuQnBwpTR0bQ5eUMYwkw== + dependencies: + bs-logger "^0.2.6" + ejs "^3.1.10" + fast-json-stable-stringify "^2.1.0" + jest-util "^29.0.0" + json5 "^2.2.3" + lodash.memoize "^4.1.2" + make-error "^1.3.6" + semver "^7.7.2" + type-fest "^4.41.0" + yargs-parser "^21.1.1" + ts-toolbelt@^6.15.1: version "6.15.5" resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz#cb3b43ed725cb63644782c64fbcad7d8f28c0a83" @@ -25065,7 +25038,7 @@ tslib@2.6.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== -tslib@^1, tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: +tslib@^1, tslib@^1.10.0, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -25080,13 +25053,6 @@ tslib@~2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== -tsutils@^3.21.0: - version "3.21.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" - integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== - dependencies: - tslib "^1.8.1" - tuf-js@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-2.2.1.tgz#fdd8794b644af1a75c7aaa2b197ddffeb2911b56" @@ -25167,6 +25133,11 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +type-fest@^4.41.0: + version "4.41.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" + integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== + type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -25241,6 +25212,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +typescript-paths@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/typescript-paths/-/typescript-paths-1.5.1.tgz#975cf5883915b24f9287315a8fa70b3b3451e32e" + integrity sha512-lYErSLCON2MSplVV5V/LBgD4UNjMgY3guATdFCZY2q1Nr6OZEu4q6zX/rYMsG1TaWqqQSszg6C9EU7AGWMDrIw== + "typescript@>=3 < 6": version "5.7.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e"