Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/cubejs-api-gateway/openspec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ info:
version: "1.0.0"
title: "Cube.js"
paths:
"/v1/granularities":
get:
summary: "List the granularities available for this deployment"
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."
operationId: "granularitiesV1"
responses:
"200":
description: "successful operation"
content:
application/json:
schema:
$ref: "#/components/schemas/V1GranularitiesResponse"
"4XX":
description: "Request could not be completed"
content:
application/json:
schema:
$ref: "#/components/schemas/V1Error"
"5XX":
description: "Internal Server Error"
content:
application/json:
schema:
$ref: "#/components/schemas/V1Error"
"/v1/meta":
get:
summary: "Load Metadata"
Expand Down Expand Up @@ -115,8 +139,17 @@ components:
properties:
name:
type: "string"
type:
type: "string"
description: "Built-in (year/quarter/month/...) or user-defined custom granularity."
enum:
- built-in
- custom
title:
type: "string"
format:
type: "string"
description: "d3-time-format string used by clients to display bucketed timestamps."
interval:
type: "string"
sql:
Expand All @@ -125,6 +158,17 @@ components:
type: "string"
origin:
type: "string"
V1GranularitiesResponse:
type: "object"
description: "Response shape of GET /v1/granularities."
properties:
data:
type: "object"
properties:
granularities:
type: array
items:
$ref: "#/components/schemas/V1CubeMetaDimensionGranularity"
V1CubeMetaDimension:
type: "object"
required:
Expand Down
1 change: 1 addition & 0 deletions packages/cubejs-api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"dependencies": {
"@cubejs-backend/native": "1.6.49",
"@cubejs-backend/query-orchestrator": "1.6.49",
"@cubejs-backend/schema-compiler": "1.6.49",
"@cubejs-backend/shared": "1.6.49",
"@ungap/structured-clone": "^0.3.4",
"assert-never": "^1.4.0",
Expand Down
124 changes: 123 additions & 1 deletion packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ import type {
import { createProxyMiddleware } from 'http-proxy-middleware';

import { QueryBody } from '@cubejs-backend/query-orchestrator';
import {
resolveGlobalGranularities,
resolveDimensionGranularities,
normalizeGranularitiesBlock,
buildBuiltInsCatalog,
BUILT_IN_GRANULARITIES,
} from '@cubejs-backend/schema-compiler';
import {
QueryType,
ApiScopes,
Expand Down Expand Up @@ -154,6 +161,8 @@ class ApiGateway {

protected readonly extendContext?: ExtendContextFn;

protected readonly granularitiesOption?: ApiGatewayOptions['granularities'];

protected readonly dataSourceStorage: any;

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

this.checkAuthFn = this.createCheckAuthFn(options);
this.checkAuthSystemFn = this.createCheckAuthSystemFn();
Expand Down Expand Up @@ -453,6 +463,17 @@ class ApiGateway {
})
);

app.get(
`${this.basePath}/v1/granularities`,
userMiddlewares,
Comment thread
igorlukanin marked this conversation as resolved.
Dismissed
userAsyncHandler(async (req, res) => {
await this.granularities({
context: req.context,
res: this.resToResultFn(res),
});
})
);

app.post(
`${this.basePath}/v1/cubesql`,
userMiddlewares,
Expand Down Expand Up @@ -668,7 +689,9 @@ class ApiGateway {
const cubesConfig = onlyViews
? metaConfig.cubes.filter((c: any) => c.config?.type === 'view')
: metaConfig.cubes;
const cubes = this.filterVisibleItemsInMeta(context, cubesConfig).map(cube => cube.config);
const filteredCubes = this.filterVisibleItemsInMeta(context, cubesConfig).map(cube => cube.config);
// Apply after the visibility filter so we only enrich what the client will actually receive.
const cubes = await this.applyGlobalGranularitiesToMetaCubes(context, filteredCubes);
const visibleCubeNames = new Set(cubes.map(c => c.name));
const viewGroups = (metaConfig.viewGroups || [])
.map(group => ({
Expand All @@ -695,6 +718,47 @@ class ApiGateway {
}
}

public async granularities({ context, res }: {
context: RequestContext,
res: ResponseResultFn,
}) {
const requestStarted = new Date();
try {
await this.assertApiScope('meta', context.securityContext);
const globalConfig = await this.resolveGlobalGranularitiesForRequest(context);
const builtInsCatalog = buildBuiltInsCatalog(globalConfig);

const granularities: any[] = [];
for (const [name, entry] of Object.entries(builtInsCatalog)) {
granularities.push({ type: 'built-in', name, ...entry });
}
for (const [name, def] of Object.entries<any>(globalConfig.customGranularities)) {
// Skip names already emitted by `buildBuiltInsCatalog` (their inline overrides are folded in there).
if (!(name in BUILT_IN_GRANULARITIES)) {
const entry: any = {
type: 'custom',
name,
title: def.title || name,
};
if (def.interval !== undefined) entry.interval = def.interval;
if (def.origin !== undefined) entry.origin = def.origin;
if (def.offset !== undefined) entry.offset = def.offset;
if (def.format !== undefined) entry.format = def.format;
granularities.push(entry);
}
}
res({ data: { granularities } });
} catch (e: any) {
this.handleError({
e,
context,
// @ts-ignore
res,
requestStarted,
});
}
}

public async metaExtended({ context, res, onlyViews }: {
context: ExtendedRequestContext,
res: ResponseResultFn,
Expand Down Expand Up @@ -1998,6 +2062,13 @@ class ApiGateway {
});

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

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

Expand Down Expand Up @@ -2296,6 +2367,57 @@ class ApiGateway {
return this.adapterApi(context);
}

protected async resolveGlobalGranularitiesForRequest(context: RequestContext) {
return resolveGlobalGranularities(this.granularitiesOption, context);
}

// Reconcile each time dimension's `granularitiesBlock` against the request's global config
// and emit the effective granularity array. Built-ins get tagged `built-in`, locals/globals
// become `custom`. Replaces the per-dim `granularities` array on the returned cube.
protected async applyGlobalGranularitiesToMetaCubes(context: RequestContext, cubes: any[]): Promise<any[]> {
const globalConfig = await this.resolveGlobalGranularitiesForRequest(context);
const builtInsCatalog = buildBuiltInsCatalog(globalConfig);

return cubes.map(cube => ({
...cube,
dimensions: cube.dimensions?.map((dim: any) => {
if (dim.type !== 'time') return dim;
// Re-key the local-custom array CubeToMetaTransformer produced so the resolver can
// merge it back into `granularitiesBlock.custom` cleanly.
const localCustom: Record<string, any> = {};
for (const g of dim.granularities || []) {
localCustom[g.name] = {
title: g.title,
interval: g.interval,
offset: g.offset,
origin: g.origin,
...(g.format !== undefined ? { format: g.format } : {}),
};
}
const block = dim.granularitiesBlock || normalizeGranularitiesBlock(undefined);
const blockWithLocal = { ...block, custom: { ...block.custom, ...localCustom } };
const resolved = resolveDimensionGranularities(
blockWithLocal,
globalConfig.enabledBuiltIns,
globalConfig.customGranularities,
builtInsCatalog,
);
const resolvedArray = Object.entries(resolved).map(([name, def]: [string, any]) => ({
name,
type: def.type,
title: def.title,
...(def.interval !== undefined ? { interval: def.interval } : {}),
...(def.offset !== undefined ? { offset: def.offset } : {}),
...(def.origin !== undefined ? { origin: def.origin } : {}),
...(def.format !== undefined ? { format: def.format } : {}),
}));
// Strip the transport-only block; clients only see the resolved `granularities` array.
const { granularitiesBlock, ...rest } = dim;
return { ...rest, granularities: resolvedArray };
}),
}));
}

public async contextByReq(req: Request, securityContext, requestId: string): Promise<ExtendedRequestContext> {
req.securityContext = securityContext;

Expand Down
21 changes: 18 additions & 3 deletions packages/cubejs-api-gateway/src/helpers/prepare-annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@

import R from 'ramda';
import { isPredefinedGranularity } from '@cubejs-backend/shared';
import { BUILT_IN_GRANULARITIES } from '@cubejs-backend/schema-compiler';
import { MetaConfig, MetaConfigMap, toConfigMap } from './to-config-map';
import { MemberType } from '../types/strings';
import { MemberType as MemberTypeEnum } from '../types/enums';
import { MemberExpression } from '../types/query';

type GranularityMeta = {
name: string;
type?: 'built-in' | 'custom';
title: string;
/** d3-time-format string for displaying bucketed timestamps. */
format?: string;
interval: string;
offset?: string;
origin?: string;
Expand Down Expand Up @@ -115,14 +119,25 @@ function prepareAnnotation(metaConfig: MetaConfig[], query: any) {
if (an) {
let granularityMeta: GranularityMeta | undefined;
if (isPredefinedGranularity(td.granularity)) {
// Prefer values the meta endpoint already attached (these honor any global
// title/format override). Fall back to BUILT_IN_GRANULARITIES, then to the bare name.
const fromMeta = an[1].granularities?.find(g => g.name === td.granularity);
const builtInDefaults = BUILT_IN_GRANULARITIES[td.granularity] || {};
granularityMeta = {
name: td.granularity,
title: td.granularity,
interval: `1 ${td.granularity}`,
type: 'built-in',
title: fromMeta?.title || builtInDefaults.title || td.granularity,
interval: fromMeta?.interval || `1 ${td.granularity}`,
...(fromMeta?.format || builtInDefaults.format
? { format: fromMeta?.format || builtInDefaults.format }
: {}),
};
} else if (an[1].granularities) {
// No need to send all the granularities defined, only those make sense for this query
// Forward only the granularity in play for this query; siblings stay in /v1/meta.
granularityMeta = an[1].granularities.find(g => g.name === td.granularity);
if (granularityMeta && !granularityMeta.type) {
granularityMeta = { ...granularityMeta, type: 'custom' };
}
}

const { granularities: _, ...rest } = an[1];
Expand Down
20 changes: 20 additions & 0 deletions packages/cubejs-api-gateway/src/types/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ type ScheduledRefreshContextsFn =

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

type GranularityListItem = string | {
name: string;
title?: string;
format?: string;
interval?: string;
origin?: string;
offset?: string;
};
type GranularityList = GranularityListItem[];
type GranularitiesOption =
| GranularityList
| ((context: RequestContext) => GranularityList | Promise<GranularityList>);

/**
* Gateway configuration options interface.
*/
Expand All @@ -64,6 +77,13 @@ interface ApiGatewayOptions {
scheduledRefreshTimeZones?: ScheduledRefreshTimeZonesFn;
basePath: string;
extendContext?: ExtendContextFn;
/**
* Enabled granularities (built-in names and/or custom definitions), or a function called per
* request to produce the same. Drives /v1/granularities and the /v1/meta enrichment.
* Shape mirrors `GranularityList` in @cubejs-backend/schema-compiler; redeclared locally to
* avoid a dependency on schema-compiler from this types module.
*/
granularities?: GranularitiesOption;
jwt?: JWTOptions;
requestLoggerMiddleware?: RequestLoggerMiddlewareFn;
queryRewrite?: QueryRewriteFn;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ describe('prepareAnnotation helpers', () => {
}).timeDimensions
).toEqual({
'cube_name.member': {
currency: undefined,
description: undefined,
format: undefined,
meta: undefined,
Expand All @@ -190,6 +191,7 @@ describe('prepareAnnotation helpers', () => {
type: undefined,
},
'cube_name.member.day': {
currency: undefined,
description: undefined,
format: undefined,
meta: undefined,
Expand All @@ -198,8 +200,10 @@ describe('prepareAnnotation helpers', () => {
type: undefined,
granularity: {
name: 'day',
title: 'day',
type: 'built-in',
title: 'Day',
interval: '1 day',
format: '%Y-%m-%d',
}
},
});
Expand Down
4 changes: 4 additions & 0 deletions packages/cubejs-backend-native/python/cube/src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ class Configuration:
check_sql_auth: Callable
can_switch_sql_user: Callable
extend_context: Callable
# Mirrors `granularities` in the JS config: a list of granularity names and/or custom-granularity
# definitions, or a function returning the same. Drives /v1/granularities and /v1/meta enrichment.
granularities: Union[list, Callable[[RequestContext], list]]
scheduled_refresh_contexts: Callable
context_to_api_scopes: Callable
repository_factory: Callable
Expand Down Expand Up @@ -120,6 +123,7 @@ def __init__(self):
self.can_switch_sql_user = None
self.query_rewrite = None
self.extend_context = None
self.granularities = None
self.scheduled_refresh_contexts = None
self.scheduled_refresh_time_zones = None
self.context_to_api_scopes = None
Expand Down
Loading
Loading