Skip to content

Commit 898dec8

Browse files
committed
feat(api-gateway): granularities config and /v1/granularities endpoint
1 parent d54d02b commit 898dec8

24 files changed

Lines changed: 987 additions & 67 deletions

File tree

packages/cubejs-api-gateway/openspec.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ info:
44
version: "1.0.0"
55
title: "Cube.js"
66
paths:
7+
"/v1/granularities":
8+
get:
9+
summary: "List the granularities available for this deployment"
10+
description: "Returns the granularities enabled in this deployment — built-ins plus any custom granularities defined via `CUBEJS_GRANULARITIES` or `config.granularities`. Evaluated per request context."
11+
operationId: "granularitiesV1"
12+
responses:
13+
"200":
14+
description: "successful operation"
15+
content:
16+
application/json:
17+
schema:
18+
$ref: "#/components/schemas/V1GranularitiesResponse"
19+
"4XX":
20+
description: "Request could not be completed"
21+
content:
22+
application/json:
23+
schema:
24+
$ref: "#/components/schemas/V1Error"
25+
"5XX":
26+
description: "Internal Server Error"
27+
content:
28+
application/json:
29+
schema:
30+
$ref: "#/components/schemas/V1Error"
731
"/v1/meta":
832
get:
933
summary: "Load Metadata"
@@ -115,8 +139,17 @@ components:
115139
properties:
116140
name:
117141
type: "string"
142+
type:
143+
type: "string"
144+
description: "Built-in (year/quarter/month/...) or user-defined custom granularity."
145+
enum:
146+
- built-in
147+
- custom
118148
title:
119149
type: "string"
150+
format:
151+
type: "string"
152+
description: "d3-time-format string used by clients to display bucketed timestamps."
120153
interval:
121154
type: "string"
122155
sql:
@@ -125,6 +158,17 @@ components:
125158
type: "string"
126159
origin:
127160
type: "string"
161+
V1GranularitiesResponse:
162+
type: "object"
163+
description: "Response shape of GET /v1/granularities."
164+
properties:
165+
data:
166+
type: "object"
167+
properties:
168+
granularities:
169+
type: array
170+
items:
171+
$ref: "#/components/schemas/V1CubeMetaDimensionGranularity"
128172
V1CubeMetaDimension:
129173
type: "object"
130174
required:

packages/cubejs-api-gateway/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dependencies": {
3030
"@cubejs-backend/native": "1.6.46",
3131
"@cubejs-backend/query-orchestrator": "1.6.46",
32+
"@cubejs-backend/schema-compiler": "1.6.46",
3233
"@cubejs-backend/shared": "1.6.46",
3334
"@ungap/structured-clone": "^0.3.4",
3435
"assert-never": "^1.4.0",

packages/cubejs-api-gateway/src/gateway.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ import type {
3232
import { createProxyMiddleware } from 'http-proxy-middleware';
3333

3434
import { QueryBody } from '@cubejs-backend/query-orchestrator';
35+
import {
36+
resolveGlobalGranularities,
37+
resolveDimensionGranularities,
38+
normalizeGranularitiesBlock,
39+
buildBuiltInsCatalog,
40+
BUILT_IN_GRANULARITIES,
41+
} from '@cubejs-backend/schema-compiler';
3542
import {
3643
QueryType,
3744
ApiScopes,
@@ -154,6 +161,8 @@ class ApiGateway {
154161

155162
protected readonly extendContext?: ExtendContextFn;
156163

164+
protected readonly granularitiesOption?: ApiGatewayOptions['granularities'];
165+
157166
protected readonly dataSourceStorage: any;
158167

159168
public readonly checkAuthFn: PreparedCheckAuthFn;
@@ -207,6 +216,7 @@ class ApiGateway {
207216
this.subscriptionStore = options.subscriptionStore || new LocalSubscriptionStore();
208217
this.enforceSecurityChecks = options.enforceSecurityChecks || (process.env.NODE_ENV === 'production');
209218
this.extendContext = options.extendContext;
219+
this.granularitiesOption = options.granularities;
210220

211221
this.checkAuthFn = this.createCheckAuthFn(options);
212222
this.checkAuthSystemFn = this.createCheckAuthSystemFn();
@@ -453,6 +463,17 @@ class ApiGateway {
453463
})
454464
);
455465

466+
app.get(
467+
`${this.basePath}/v1/granularities`,
468+
userMiddlewares,
469+
userAsyncHandler(async (req, res) => {
470+
await this.granularities({
471+
context: req.context,
472+
res: this.resToResultFn(res),
473+
});
474+
})
475+
);
476+
456477
app.post(
457478
`${this.basePath}/v1/cubesql`,
458479
userMiddlewares,
@@ -668,7 +689,9 @@ class ApiGateway {
668689
const cubesConfig = onlyViews
669690
? metaConfig.cubes.filter((c: any) => c.config?.type === 'view')
670691
: metaConfig.cubes;
671-
const cubes = this.filterVisibleItemsInMeta(context, cubesConfig).map(cube => cube.config);
692+
const filteredCubes = this.filterVisibleItemsInMeta(context, cubesConfig).map(cube => cube.config);
693+
// Apply after the visibility filter so we only enrich what the client will actually receive.
694+
const cubes = await this.applyGlobalGranularitiesToMetaCubes(context, filteredCubes);
672695
const visibleCubeNames = new Set(cubes.map(c => c.name));
673696
const viewGroups = (metaConfig.viewGroups || [])
674697
.map(group => ({
@@ -695,6 +718,47 @@ class ApiGateway {
695718
}
696719
}
697720

721+
public async granularities({ context, res }: {
722+
context: RequestContext,
723+
res: ResponseResultFn,
724+
}) {
725+
const requestStarted = new Date();
726+
try {
727+
await this.assertApiScope('meta', context.securityContext);
728+
const globalConfig = await this.resolveGlobalGranularitiesForRequest(context);
729+
const builtInsCatalog = buildBuiltInsCatalog(globalConfig);
730+
731+
const granularities: any[] = [];
732+
for (const [name, entry] of Object.entries(builtInsCatalog)) {
733+
granularities.push({ type: 'built-in', name, ...entry });
734+
}
735+
for (const [name, def] of Object.entries<any>(globalConfig.customGranularities)) {
736+
// Skip names already emitted by `buildBuiltInsCatalog` (their inline overrides are folded in there).
737+
if (!(name in BUILT_IN_GRANULARITIES)) {
738+
const entry: any = {
739+
type: 'custom',
740+
name,
741+
title: def.title || name,
742+
};
743+
if (def.interval !== undefined) entry.interval = def.interval;
744+
if (def.origin !== undefined) entry.origin = def.origin;
745+
if (def.offset !== undefined) entry.offset = def.offset;
746+
if (def.format !== undefined) entry.format = def.format;
747+
granularities.push(entry);
748+
}
749+
}
750+
res({ data: { granularities } });
751+
} catch (e: any) {
752+
this.handleError({
753+
e,
754+
context,
755+
// @ts-ignore
756+
res,
757+
requestStarted,
758+
});
759+
}
760+
}
761+
698762
public async metaExtended({ context, res, onlyViews }: {
699763
context: ExtendedRequestContext,
700764
res: ResponseResultFn,
@@ -1998,6 +2062,13 @@ class ApiGateway {
19982062
});
19992063

20002064
metaConfigResult = this.filterVisibleItemsInMeta(context, metaConfigResult);
2065+
// Annotation reads from this meta. Without enrichment, /v1/load and /v1/cubesql would
2066+
// omit the type/title/format/interval fields that /v1/meta exposes.
2067+
const enrichedCubes = await this.applyGlobalGranularitiesToMetaCubes(
2068+
context,
2069+
metaConfigResult.map((m: any) => m.config),
2070+
);
2071+
metaConfigResult = metaConfigResult.map((m: any, i: number) => ({ ...m, config: enrichedCubes[i] }));
20012072

20022073
const sqlQueries = await this.getSqlQueriesInternal(context, normalizedQueries);
20032074

@@ -2296,6 +2367,57 @@ class ApiGateway {
22962367
return this.adapterApi(context);
22972368
}
22982369

2370+
protected async resolveGlobalGranularitiesForRequest(context: RequestContext) {
2371+
return resolveGlobalGranularities(this.granularitiesOption, context);
2372+
}
2373+
2374+
// Reconcile each time dimension's `granularitiesBlock` against the request's global config
2375+
// and emit the effective granularity array. Built-ins get tagged `built-in`, locals/globals
2376+
// become `custom`. Replaces the per-dim `granularities` array on the returned cube.
2377+
protected async applyGlobalGranularitiesToMetaCubes(context: RequestContext, cubes: any[]): Promise<any[]> {
2378+
const globalConfig = await this.resolveGlobalGranularitiesForRequest(context);
2379+
const builtInsCatalog = buildBuiltInsCatalog(globalConfig);
2380+
2381+
return cubes.map(cube => ({
2382+
...cube,
2383+
dimensions: cube.dimensions?.map((dim: any) => {
2384+
if (dim.type !== 'time') return dim;
2385+
// Re-key the local-custom array CubeToMetaTransformer produced so the resolver can
2386+
// merge it back into `granularitiesBlock.custom` cleanly.
2387+
const localCustom: Record<string, any> = {};
2388+
for (const g of dim.granularities || []) {
2389+
localCustom[g.name] = {
2390+
title: g.title,
2391+
interval: g.interval,
2392+
offset: g.offset,
2393+
origin: g.origin,
2394+
...(g.format !== undefined ? { format: g.format } : {}),
2395+
};
2396+
}
2397+
const block = dim.granularitiesBlock || normalizeGranularitiesBlock(undefined);
2398+
const blockWithLocal = { ...block, custom: { ...block.custom, ...localCustom } };
2399+
const resolved = resolveDimensionGranularities(
2400+
blockWithLocal,
2401+
globalConfig.enabledBuiltIns,
2402+
globalConfig.customGranularities,
2403+
builtInsCatalog,
2404+
);
2405+
const resolvedArray = Object.entries(resolved).map(([name, def]: [string, any]) => ({
2406+
name,
2407+
type: def.type,
2408+
title: def.title,
2409+
...(def.interval !== undefined ? { interval: def.interval } : {}),
2410+
...(def.offset !== undefined ? { offset: def.offset } : {}),
2411+
...(def.origin !== undefined ? { origin: def.origin } : {}),
2412+
...(def.format !== undefined ? { format: def.format } : {}),
2413+
}));
2414+
// Strip the transport-only block; clients only see the resolved `granularities` array.
2415+
const { granularitiesBlock, ...rest } = dim;
2416+
return { ...rest, granularities: resolvedArray };
2417+
}),
2418+
}));
2419+
}
2420+
22992421
public async contextByReq(req: Request, securityContext, requestId: string): Promise<ExtendedRequestContext> {
23002422
req.securityContext = securityContext;
23012423

packages/cubejs-api-gateway/src/helpers/prepare-annotation.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@
77

88
import R from 'ramda';
99
import { isPredefinedGranularity } from '@cubejs-backend/shared';
10+
import { BUILT_IN_GRANULARITIES } from '@cubejs-backend/schema-compiler';
1011
import { MetaConfig, MetaConfigMap, toConfigMap } from './to-config-map';
1112
import { MemberType } from '../types/strings';
1213
import { MemberType as MemberTypeEnum } from '../types/enums';
1314
import { MemberExpression } from '../types/query';
1415

1516
type GranularityMeta = {
1617
name: string;
18+
type?: 'built-in' | 'custom';
1719
title: string;
20+
/** d3-time-format string for displaying bucketed timestamps. */
21+
format?: string;
1822
interval: string;
1923
offset?: string;
2024
origin?: string;
@@ -115,14 +119,25 @@ function prepareAnnotation(metaConfig: MetaConfig[], query: any) {
115119
if (an) {
116120
let granularityMeta: GranularityMeta | undefined;
117121
if (isPredefinedGranularity(td.granularity)) {
122+
// Prefer values the meta endpoint already attached (these honor any global
123+
// title/format override). Fall back to BUILT_IN_GRANULARITIES, then to the bare name.
124+
const fromMeta = an[1].granularities?.find(g => g.name === td.granularity);
125+
const builtInDefaults = BUILT_IN_GRANULARITIES[td.granularity] || {};
118126
granularityMeta = {
119127
name: td.granularity,
120-
title: td.granularity,
121-
interval: `1 ${td.granularity}`,
128+
type: 'built-in',
129+
title: fromMeta?.title || builtInDefaults.title || td.granularity,
130+
interval: fromMeta?.interval || `1 ${td.granularity}`,
131+
...(fromMeta?.format || builtInDefaults.format
132+
? { format: fromMeta?.format || builtInDefaults.format }
133+
: {}),
122134
};
123135
} else if (an[1].granularities) {
124-
// No need to send all the granularities defined, only those make sense for this query
136+
// Forward only the granularity in play for this query; siblings stay in /v1/meta.
125137
granularityMeta = an[1].granularities.find(g => g.name === td.granularity);
138+
if (granularityMeta && !granularityMeta.type) {
139+
granularityMeta = { ...granularityMeta, type: 'custom' };
140+
}
126141
}
127142

128143
const { granularities: _, ...rest } = an[1];

packages/cubejs-api-gateway/src/types/gateway.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ type ScheduledRefreshContextsFn =
5252

5353
type ScheduledRefreshTimeZonesFn = (context: RequestContext) => string[] | Promise<string[]>;
5454

55+
type GranularityListItem = string | {
56+
name: string;
57+
title?: string;
58+
format?: string;
59+
interval?: string;
60+
origin?: string;
61+
offset?: string;
62+
};
63+
type GranularityList = GranularityListItem[];
64+
type GranularitiesOption =
65+
| GranularityList
66+
| ((context: RequestContext) => GranularityList | Promise<GranularityList>);
67+
5568
/**
5669
* Gateway configuration options interface.
5770
*/
@@ -64,6 +77,13 @@ interface ApiGatewayOptions {
6477
scheduledRefreshTimeZones?: ScheduledRefreshTimeZonesFn;
6578
basePath: string;
6679
extendContext?: ExtendContextFn;
80+
/**
81+
* Enabled granularities (built-in names and/or custom definitions), or a function called per
82+
* request to produce the same. Drives /v1/granularities and the /v1/meta enrichment.
83+
* Shape mirrors `GranularityList` in @cubejs-backend/schema-compiler; redeclared locally to
84+
* avoid a dependency on schema-compiler from this types module.
85+
*/
86+
granularities?: GranularitiesOption;
6787
jwt?: JWTOptions;
6888
requestLoggerMiddleware?: RequestLoggerMiddlewareFn;
6989
queryRewrite?: QueryRewriteFn;

packages/cubejs-api-gateway/test/helpers/prepare-annotation.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ describe('prepareAnnotation helpers', () => {
182182
}).timeDimensions
183183
).toEqual({
184184
'cube_name.member': {
185+
currency: undefined,
185186
description: undefined,
186187
format: undefined,
187188
meta: undefined,
@@ -190,6 +191,7 @@ describe('prepareAnnotation helpers', () => {
190191
type: undefined,
191192
},
192193
'cube_name.member.day': {
194+
currency: undefined,
193195
description: undefined,
194196
format: undefined,
195197
meta: undefined,
@@ -198,8 +200,10 @@ describe('prepareAnnotation helpers', () => {
198200
type: undefined,
199201
granularity: {
200202
name: 'day',
201-
title: 'day',
203+
type: 'built-in',
204+
title: 'Day',
202205
interval: '1 day',
206+
format: '%Y-%m-%d',
203207
}
204208
},
205209
});

packages/cubejs-backend-shared/src/env.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,18 @@ const variables: Record<string, (...args: any) => any> = {
20662066
.asString(),
20672067
accessPolicyMaskNumber: () => get('CUBEJS_ACCESS_POLICY_MASK_NUMBER')
20682068
.asString(),
2069+
// Comma-separated names (built-in or custom). Empty/unset = all 8 built-ins enabled.
2070+
granularities: () => get('CUBEJS_GRANULARITIES')
2071+
.asArray(','),
2072+
// `getEnv` forwards `opts` positionally, so callers pass `{ name }` (matches dbType: { dataSource }).
2073+
granularityCustomInterval: ({ name }: { name: string }) => get(`CUBEJS_GRANULARITIES_${name.toUpperCase()}_INTERVAL`)
2074+
.asString(),
2075+
granularityCustomTitle: ({ name }: { name: string }) => get(`CUBEJS_GRANULARITIES_${name.toUpperCase()}_TITLE`)
2076+
.asString(),
2077+
granularityCustomOffset: ({ name }: { name: string }) => get(`CUBEJS_GRANULARITIES_${name.toUpperCase()}_OFFSET`)
2078+
.asString(),
2079+
granularityCustomOrigin: ({ name }: { name: string }) => get(`CUBEJS_GRANULARITIES_${name.toUpperCase()}_ORIGIN`)
2080+
.asString(),
20692081
};
20702082

20712083
type Vars = typeof variables;

packages/cubejs-client-core/src/time.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ export type SqlInterval = string;
2222
// TODO: Define a better type as unitOfTime.DurationConstructor in moment.js
2323
export type ParsedInterval = Record<string, number>;
2424

25+
// Runtime shape for custom-granularity time-series math. For built-ins, see
26+
// TimeDimensionPredefinedGranularity (query value) and GranularityAnnotation (response field).
2527
export type Granularity = {
28+
type?: 'built-in' | 'custom';
29+
title?: string;
30+
format?: string;
2631
interval: SqlInterval;
2732
origin?: string;
2833
offset?: SqlInterval;

0 commit comments

Comments
 (0)