diff --git a/docs/content/product/apis-integrations/javascript-sdk/reference/cubejs-client-core.mdx b/docs/content/product/apis-integrations/javascript-sdk/reference/cubejs-client-core.mdx index 6e17ad00c51ce..181af13319896 100644 --- a/docs/content/product/apis-integrations/javascript-sdk/reference/cubejs-client-core.mdx +++ b/docs/content/product/apis-integrations/javascript-sdk/reference/cubejs-client-core.mdx @@ -153,12 +153,14 @@ callback? | [LoadMethodCallback](#types-load-method-callback)‹[ResultSet](#res ### `meta` -> **meta**(**options?**: [LoadMethodOptions](#types-load-method-options)): *Promise‹[Meta](#meta)›* +> **meta**(**options?**: [MetaMethodOptions](#types-meta-method-options)): *Promise‹[Meta](#meta)›* -> **meta**(**options?**: [LoadMethodOptions](#types-load-method-options), **callback?**: [LoadMethodCallback](#types-load-method-callback)‹[Meta](#meta)›): *void* +> **meta**(**options?**: [MetaMethodOptions](#types-meta-method-options), **callback?**: [LoadMethodCallback](#types-load-method-callback)‹[Meta](#meta)›): *void* Get meta description of cubes available for querying. +Pass **`extended: true`** to request [extended `/v1/meta`](/product/apis-integrations/rest-api/reference#base_pathv1meta) (joins, SQL snippets, pre-aggregations, and related fields). The client only adds the `extended` query parameter when this option is `true`; omit it otherwise. Do not rely on `extended: false` on the wire—the API treats the presence of any `extended` parameter as extended mode. + ### `sql` > **sql**(**query**: [Query](#types-query) | [Query](#types-query)[], **options?**: [LoadMethodOptions](#types-load-method-options)): *Promise‹[SqlQuery](#sql-query)›* @@ -817,6 +819,14 @@ Name | Type | Optional? | Description | `signal` | `AbortSignal` | ✅ Yes | AbortSignal to cancel the request. This allows you to manually abort requests using AbortController. | `baseRequestId` | `string` | ✅ Yes | Base request ID ([`spanId`](https://cube.dev/docs/product/apis-integrations/core-data-apis/rest-api#request-span-annotation)) to be used for the request. If not provided a random request ID will be generated. | +### `MetaMethodOptions` + +Extends [LoadMethodOptions](#types-load-method-options). + +Name | Type | Optional? | Description | +------ | ------ | ------ | --- | +`extended` | `boolean` | ✅ Yes | Pass **`true`** to load extended meta. Omit when not needed (the client does not send `extended=false`). | + ### `LoadResponse` Name | Type | diff --git a/packages/cubejs-client-core/src/Meta.ts b/packages/cubejs-client-core/src/Meta.ts index f440e4afc4aae..ec9399a1ea7e8 100644 --- a/packages/cubejs-client-core/src/Meta.ts +++ b/packages/cubejs-client-core/src/Meta.ts @@ -1,13 +1,15 @@ import { unnest, fromPairs } from 'ramda'; import { - Cube, - CubesMap, + CubeExtended, + CubePlain, + CubesMapFromMeta, MemberType, + MetaCubeOf, MetaResponse, + MetaResponseExtended, TCubeMeasure, TCubeDimension, - TCubeMember, - TCubeMemberByType, + TCubeMemberByTypeForMeta, Query, FilterOperator, TCubeSegment, @@ -23,13 +25,16 @@ export interface CubeMemberWrapper { members: T[]; } -export type AggregatedMembers = { - measures: CubeMemberWrapper[]; - dimensions: CubeMemberWrapper[]; - segments: CubeMemberWrapper[]; - timeDimensions: CubeMemberWrapper[]; +export type AggregatedMembersFor = { + measures: CubeMemberWrapper[]; + dimensions: CubeMemberWrapper[]; + segments: CubeMemberWrapper[]; + timeDimensions: CubeMemberWrapper[]; }; +/** Plain-meta variant of {@link AggregatedMembersFor} (alias for `AggregatedMembersFor`). */ +export type AggregatedMembers = AggregatedMembersFor; + const memberMap = (memberArray: any[]) => fromPairs( memberArray.map((m) => [m.name, m]) ); @@ -72,23 +77,23 @@ const operators = { /** * Contains information about available cubes and it's members. */ -export default class Meta { +export default class Meta { /** * Raw meta response */ - public readonly meta: MetaResponse; + public readonly meta: T; /** * An array of all available cubes with their members */ - public readonly cubes: Cube[]; + public readonly cubes: T['cubes']; /** * A map of all cubes where the key is a cube name */ - public readonly cubesMap: CubesMap; + public readonly cubesMap: CubesMapFromMeta; - public constructor(metaResponse: MetaResponse) { + public constructor(metaResponse: T) { this.meta = metaResponse; const { cubes } = this.meta; this.cubes = cubes; @@ -110,15 +115,19 @@ export default class Meta { * @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)[] { + public membersForQuery(_query: DeeplyReadonly | null, memberType: MemberType): ( + TCubeMemberByTypeForMeta, 'measures'> + | TCubeMemberByTypeForMeta, 'dimensions'> + | TCubeMemberByTypeForMeta, 'segments'> + )[] { return unnest(this.cubes.map((c) => c[memberType])) .sort((a, b) => (a.title > b.title ? 1 : -1)); } - public membersGroupedByCube() { + public membersGroupedByCube(): AggregatedMembersFor> { const memberKeys = ['measures', 'dimensions', 'segments', 'timeDimensions']; - return this.cubes.reduce( + return this.cubes.reduce>>( (memo, cube) => { memberKeys.forEach((key) => { let members: TCubeMeasure[] | TCubeDimension[] | TCubeSegment[] = []; @@ -157,7 +166,7 @@ export default class Meta { dimensions: [], segments: [], timeDimensions: [], - } as AggregatedMembers + } as AggregatedMembersFor> ); } @@ -178,10 +187,10 @@ export default class Meta { * @param memberType * @return An object containing meta information about member */ - public resolveMember( + public resolveMember( memberName: string, - memberType: T | T[] - ): NotFoundMember | TCubeMemberByType { + memberType: M | M[] + ): NotFoundMember | TCubeMemberByTypeForMeta, M> { const [cube] = memberName.split('.'); if (!this.cubesMap[cube]) { @@ -200,7 +209,7 @@ export default class Meta { }; } - return member as TCubeMemberByType; + return member as TCubeMemberByTypeForMeta, M>; } public defaultTimeDimensionNameFor(memberName: string): string | null | undefined { diff --git a/packages/cubejs-client-core/src/index.ts b/packages/cubejs-client-core/src/index.ts index 821deabe68fa4..86818915db7b6 100644 --- a/packages/cubejs-client-core/src/index.ts +++ b/packages/cubejs-client-core/src/index.ts @@ -12,6 +12,7 @@ import { LoadResponse, MeasureFormat, MetaResponse, + MetaResponseExtended, PivotQuery, ProgressResponse, Query, @@ -63,6 +64,16 @@ export type LoadMethodOptions = { baseRequestId?: string; }; +export type MetaMethodOptions = LoadMethodOptions & { + /** + * When `true`, requests extended meta from the API (joins, pre-aggregations, SQL snippets, etc.). + * Only pass this property when the value is `true`. Omit it when extended meta is not needed — + * the gateway treats the presence of any `extended` query parameter as extended mode, so + * sending `extended=false` would still select extended meta. + */ + extended?: boolean; +}; + export type DeeplyReadonly = { readonly [K in keyof T]: DeeplyReadonly; }; @@ -690,20 +701,32 @@ class CubeApi { ); } - public meta(options?: LoadMethodOptions): Promise; + public meta(options?: MetaMethodOptions & { extended?: false }): Promise>; + + public meta(options: MetaMethodOptions & { extended: true }): Promise>; + + public meta(options?: MetaMethodOptions & { extended?: false }, callback?: LoadMethodCallback>): UnsubscribeObj; - public meta(options?: LoadMethodOptions, callback?: LoadMethodCallback): UnsubscribeObj; + public meta(options: MetaMethodOptions & { extended: true }, callback?: LoadMethodCallback>): UnsubscribeObj; /** * Get meta description of cubes available for querying. */ - public meta(options?: LoadMethodOptions, callback?: LoadMethodCallback): Promise | UnsubscribeObj { + public meta( + options?: MetaMethodOptions, + callback?: LoadMethodCallback> | LoadMethodCallback> + ): Promise> | Promise> | UnsubscribeObj { return this.loadMethod( () => this.request('meta', { signal: options?.signal, baseRequestId: options?.baseRequestId, + ...(options?.extended === true ? { extended: true } : {}), }), - (body: MetaResponse) => new Meta(body), + (body: MetaResponse | MetaResponseExtended) => ( + options?.extended === true + ? new Meta(body as MetaResponseExtended) + : new Meta(body as MetaResponse) + ), options, callback ); diff --git a/packages/cubejs-client-core/src/types.ts b/packages/cubejs-client-core/src/types.ts index f4e8c61f9c526..9ed74b9782a68 100644 --- a/packages/cubejs-client-core/src/types.ts +++ b/packages/cubejs-client-core/src/types.ts @@ -386,6 +386,10 @@ export type BaseCubeMember = { aliasMember?: string; }; +export type TCubeMeasureFilterMeta = { + sql?: string; +}; + export type TCubeMeasure = BaseCubeMember & { aggType: 'count' | 'number'; cumulative: boolean; @@ -398,13 +402,30 @@ export type TCubeMeasure = BaseCubeMember & { format?: 'currency' | 'percent'; /** ISO 4217 currency code in uppercase (e.g. USD, EUR) */ currency?: string; + sql?: string; + filters?: TCubeMeasureFilterMeta[]; }; +export type TCubeMeasurePlain = Omit; + export type CubeTimeDimensionGranularity = { name: string; title: string; }; +export type TCubeDimensionCaseWhen = { + sql?: string; + label?: string | { sql?: string } | unknown; +}; + +export type TCubeDimensionCase = { + when?: TCubeDimensionCaseWhen[]; + else?: { + label?: string; + [key: string]: unknown; + }; +}; + export type BaseCubeDimension = BaseCubeMember & { primaryKey?: boolean; suggestFilterValues: boolean; @@ -412,6 +433,8 @@ export type BaseCubeDimension = BaseCubeMember & { /** ISO 4217 currency code in uppercase (e.g. USD, EUR) */ currency?: string; key?: string; + sql?: string; + case?: TCubeDimensionCase; }; export type CubeTimeDimension = BaseCubeDimension & @@ -421,7 +444,15 @@ export type TCubeDimension = (BaseCubeDimension & { type: Exclude }) | CubeTimeDimension; -export type TCubeSegment = Omit; +export type TCubeDimensionPlain = + | (Omit & { type: Exclude }) + | Omit; + +export type TCubeSegment = Omit & { + sql?: string; +}; + +export type TCubeSegmentPlain = Omit; export type NotFoundMember = { title: string; @@ -455,6 +486,19 @@ export type TCubeHierarchy = { public?: boolean; }; +export type TCubeJoin = { + name: string; + sql?: string; + relationship?: string; +} & Record; + +export type TCubePreAggregationMeta = { + name: string; + timeDimensionReference?: string; + dimensionReferences?: string; + measureReferences?: string; +} & Record; + /** * @deprecated use DryRunResponse */ @@ -474,13 +518,10 @@ export type DryRunResponse = { transformedQueries: TransformedQuery[]; }; -export type Cube = { +type CubeBaseFields = { name: string; title: string; description?: string; - measures: TCubeMeasure[]; - dimensions: TCubeDimension[]; - segments: TCubeSegment[]; folders: TCubeFolder[]; nestedFolders: TCubeNestedFolder[]; hierarchies: TCubeHierarchy[]; @@ -494,10 +535,43 @@ export type Cube = { meta?: any; }; +/** + * Cube description from plain `/v1/meta` (no `extended` query param). + * Extended schema fields (joins, SQL snippets, pre-aggregations, etc.) are not present on this type. + */ +export type CubePlain = CubeBaseFields & { + measures: TCubeMeasurePlain[]; + dimensions: TCubeDimensionPlain[]; + segments: TCubeSegmentPlain[]; +}; + +/** Cube-level fields only returned when `extended` is requested on `/v1/meta`. */ +export type CubeExtendedFields = { + joins?: TCubeJoin[]; + sql?: string; + extends?: string; + fileName?: string; + refreshKey?: unknown; + preAggregations?: TCubePreAggregationMeta[]; +}; + +/** + * Cube description from `/v1/meta?extended=true` (extended meta). + * Includes optional cube-level and member-level fields from the extended meta transform. + */ +export type CubeExtended = CubeBaseFields & CubeExtendedFields & { + measures: TCubeMeasure[]; + dimensions: TCubeDimension[]; + segments: TCubeSegment[]; +}; + +/** Alias for {@link CubePlain} (default `/v1/meta` response cube shape). */ +export type Cube = CubePlain; + export type CubeMap = { - measures: Record; - dimensions: Record; - segments: Record; + measures: Record; + dimensions: Record; + segments: Record; }; export type CubesMap = Record< @@ -505,10 +579,41 @@ export type CubesMap = Record< CubeMap >; +export type CubeMapExtended = { + measures: Record; + dimensions: Record; + segments: Record; +}; + +export type CubesMapExtended = Record; + +/** Response body for `GET /v1/meta` without extended meta. */ export type MetaResponse = { - cubes: Cube[]; + cubes: CubePlain[]; +}; + +/** Response body for `GET /v1/meta?extended=true`. */ +export type MetaResponseExtended = { + cubes: CubeExtended[]; }; +export type MetaCubeOf = T['cubes'][number]; + +export type CubesMapFromMeta = Record< + string, + { + measures: Record['measures'][number]>; + dimensions: Record['dimensions'][number]>; + segments: Record['segments'][number]>; + } +>; + +export type TCubeMemberByTypeForMeta = + T extends 'measures' ? C['measures'][number] + : T extends 'dimensions' ? C['dimensions'][number] + : T extends 'segments' ? C['segments'][number] + : never; + export type FilterOperator = { name: string; title: string; @@ -519,7 +624,7 @@ export type TSourceAxis = 'x' | 'y'; export type ChartType = 'line' | 'bar' | 'table' | 'area' | 'number' | 'pie'; export type TDefaultHeuristicsOptions = { - meta: Meta; + meta: Meta; sessionGranularity?: TimeDimensionGranularity; }; diff --git a/packages/cubejs-client-core/test/CubeApi.test.ts b/packages/cubejs-client-core/test/CubeApi.test.ts index b11c0ce9125b5..4eb9553b54778 100644 --- a/packages/cubejs-client-core/test/CubeApi.test.ts +++ b/packages/cubejs-client-core/test/CubeApi.test.ts @@ -774,3 +774,119 @@ describe('CubeApi Mutex Cancellation', () => { ).rejects.toThrow(RequestError); }); }); + +describe('CubeApi meta', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + const minimalMetaResponseText = JSON.stringify({ + cubes: [{ + name: 'Orders', + title: 'Orders', + measures: [{ + name: 'count', + title: 'Count', + shortTitle: 'Count', + type: 'number' + }], + dimensions: [{ + name: 'status', + title: 'Status', + type: 'string' + }], + segments: [] + }] + }); + + function mockMetaRequest(): jest.SpyInstance { + return jest.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(minimalMetaResponseText), + json: () => Promise.resolve(JSON.parse(minimalMetaResponseText)) + } as any, + async () => undefined as any)) + })); + } + + describe('when no extended option is provided', () => { + test('omits `extended` from the meta request params', async () => { + const requestSpy = mockMetaRequest(); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1' + }); + + await cubeApi.meta(); + + expect(requestSpy).toHaveBeenCalledWith('meta', expect.any(Object)); + expect(requestSpy.mock.calls[0]?.[1]?.extended).toBeUndefined(); + }); + + test('omits `extended` when options is an empty object', async () => { + const requestSpy = mockMetaRequest(); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1' + }); + + await cubeApi.meta({}); + + expect(requestSpy.mock.calls[0]?.[1]?.extended).toBeUndefined(); + }); + + test('omits `extended` when `extended` is false (presence on the wire would still enable extended meta)', async () => { + const requestSpy = mockMetaRequest(); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1' + }); + + await cubeApi.meta({ extended: false }); + + expect(requestSpy.mock.calls[0]?.[1]?.extended).toBeUndefined(); + }); + }); + + describe('when extended option is provided', () => { + test('passes `extended: true` in the meta request params', async () => { + const requestSpy = mockMetaRequest(); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1' + }); + + await cubeApi.meta({ extended: true }); + + expect(requestSpy).toHaveBeenCalledWith( + 'meta', + expect.objectContaining({ extended: true }) + ); + expect(requestSpy.mock.calls[0]?.[1]?.extended).toBe(true); + }); + + test('still passes signal and baseRequestId together with `extended: true`', async () => { + const controller = new AbortController(); + const { signal } = controller; + const baseRequestId = 'meta-extended-req'; + + const requestSpy = mockMetaRequest(); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1' + }); + + await cubeApi.meta({ extended: true, signal, baseRequestId }); + + expect(requestSpy.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + extended: true, + signal, + baseRequestId + }) + ); + }); + }); +});