Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a705c8d
feat(api-gateway): granularities config and /v1/granularities endpoint
igorlukanin May 20, 2026
d24a272
feat(backend-native): add granularities to Cube.py Configuration
igorlukanin May 22, 2026
4b72d54
feat(granularities): expose effectiveGranularities, keep legacy granu…
igorlukanin Jul 16, 2026
2cad934
fix(granularities): keep overridden built-in type, scope camelize gua…
igorlukanin Jul 16, 2026
9a192e9
fix(granularities): avoid continue to satisfy no-continue lint rule
igorlukanin Jul 16, 2026
16614e0
fix(granularities): emit granularitiesBlock only when present, update…
igorlukanin Jul 16, 2026
0ee4a7b
refactor(granularities): resolve effective granularities in the compi…
igorlukanin Jul 16, 2026
c57346e
refactor(granularities): raise variant cache bound to 64 to cover com…
igorlukanin Jul 16, 2026
89c6dbd
feat(server-core): make granularity variant cache bound configurable …
igorlukanin Jul 16, 2026
46e8dc6
refactor(granularities): drop CUBEJS_MAX_GRANULARITY_VARIANTS env var…
igorlukanin Jul 16, 2026
ceb4ae2
refactor(granularities): fold granularityConfigHash into GlobalGranul…
igorlukanin Jul 16, 2026
2a3e2c6
fix(granularities): make global custom granularities queryable in SQL…
igorlukanin Jul 16, 2026
7adba32
fix(granularities): resolve config from securityContext on both meta …
igorlukanin Jul 17, 2026
d7a77d2
refactor(granularities): dedupe resolve→serialize via effectiveGranul…
igorlukanin Jul 17, 2026
773d9ee
perf(granularities): cache the SQL-path global-custom lookup per conf…
igorlukanin Jul 17, 2026
9aac80e
perf(granularities): skip per-dimension reconciliation for dims witho…
igorlukanin Jul 17, 2026
db42ad0
perf(granularities): store sparse effective sets in the variant cache…
igorlukanin Jul 17, 2026
33dec12
perf(granularities): key query cache by config hash not the shared de…
igorlukanin Jul 17, 2026
459aa2e
refactor(granularities): resolve global config once per appId at comp…
igorlukanin Jul 22, 2026
675da83
fix(granularities): use object destructuring in metaConfig (lint)
igorlukanin Jul 22, 2026
aebce83
refactor(granularities): move /v1/granularities catalog assembly into…
igorlukanin Jul 22, 2026
dba5209
refactor(granularities): resolve and apply global config in CubeSymbo…
igorlukanin Jul 29, 2026
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
13 changes: 13 additions & 0 deletions DEPRECATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ features:
| Removed | [Elasticsearch driver](#elasticsearch-driver) | v1.6.0 | v1.7.0 |
| Removed | [`context_to_roles`](#context-to-roles) | v1.6.4 | v1.7.0 |
| Deprecated | [Node.js 22](#nodejs-22) | v1.7.0 | |
| Deprecated | [`granularities` field on time dimensions in `/v1/meta`](#granularities-field-on-time-dimensions-in-v1meta) | v1.7.4 | |

### Node.js 8

Expand Down Expand Up @@ -454,3 +455,15 @@ The `context_to_roles` configuration option has been removed. Please use `contex

Node.js 22 is in maintenance mode from [October 21, 2025][link-nodejs-eol]. This means
no more new features, only security updates. Please upgrade to Node.js 24 or higher.

### `granularities` field on time dimensions in `/v1/meta`

**Deprecated in Release: v1.7.4**

The `granularities` field returned for each time dimension by the `/v1/meta` endpoint lists only
the custom granularities defined in the data model, without the enabled built-in granularities or a
`type` discriminator. It is deprecated in favor of the `effectiveGranularities` field, which returns
the full reconciled set (enabled built-ins, global custom granularities, and per-dimension custom
granularities), each tagged with `type` (`built-in` or `custom`) and carrying `title`, `format`, and
`interval`. The `granularities` field remains for backward compatibility and will be removed in a
future release; new integrations should read `effectiveGranularities`.
70 changes: 70 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`. Resolved once per application ID at data-model compile time and served from the compiled model."
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 @@ -109,6 +133,8 @@ components:
type: "object"
V1CubeMetaDimensionGranularity:
type: "object"
deprecated: true
description: "Deprecated. Legacy shape listing only model-defined custom granularities. Use V1CubeMetaDimensionEffectiveGranularity via `effectiveGranularities`."
required:
- name
- title
Expand All @@ -125,6 +151,44 @@ components:
type: "string"
origin:
type: "string"
V1CubeMetaDimensionEffectiveGranularity:
type: "object"
description: "Reconciled granularity: enabled built-ins plus global and per-dimension custom granularities."
required:
- name
- type
- title
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"
offset:
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/V1CubeMetaDimensionEffectiveGranularity"
V1CubeMetaDimension:
type: "object"
required:
Expand All @@ -145,9 +209,15 @@ components:
description: "When dimension is defined in View, it keeps the original path: Cube.dimension"
type: "string"
granularities:
deprecated: true
description: "Deprecated. Use `effectiveGranularities`."
type: array
items:
$ref: "#/components/schemas/V1CubeMetaDimensionGranularity"
effectiveGranularities:
type: array
items:
$ref: "#/components/schemas/V1CubeMetaDimensionEffectiveGranularity"
meta:
type: "object"
format:
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.7.23",
"@cubejs-backend/query-orchestrator": "1.7.23",
"@cubejs-backend/schema-compiler": "1.7.23",
"@cubejs-backend/shared": "1.7.23",
"@ungap/structured-clone": "^0.3.4",
"assert-never": "^1.4.0",
Expand Down
38 changes: 37 additions & 1 deletion packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
QueryAlias,
CacheMode,
LoggerFn,
isPredefinedGranularity,
} from '@cubejs-backend/shared';
import {
ResultArrayWrapper,
Expand Down Expand Up @@ -478,6 +479,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 @@ -750,6 +762,27 @@ class ApiGateway {
}
}

public async granularities({ context, res }: {
context: RequestContext,
res: ResponseResultFn,
}) {
const requestStarted = new Date();
try {
await this.assertApiScope('meta', context.securityContext);
const compilerApi = await this.getCompilerApi(context);
const granularities = await compilerApi.getGranularities({ requestId: context.requestId });
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 @@ -1176,7 +1209,10 @@ class ApiGateway {
} else {
const metaCacheKey = JSON.stringify(ctx);
if (!metaCache.has(metaCacheKey)) {
metaCache.set(metaCacheKey, await compiler.metaConfigExtended(context, ctx));
// `ctx` (outer context merged with the job's own context) is the request context;
// passing the outer `context` here would select visibility and granularities for
// the wrong tenant.
metaCache.set(metaCacheKey, await compiler.metaConfigExtended(ctx, { requestId: ctx.requestId }));
}

// checking and fetching result status
Expand Down
49 changes: 37 additions & 12 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 All @@ -37,6 +41,38 @@ type ConfigItem = {
granularities?: GranularityMeta[];
};

// Effective granularity meta for one queried time dimension, read from the enriched meta config.
// A queried granularity outside the effective set still annotates: disabled built-ins are
// synthesized from defaults; unknown customs yield undefined (never the deprecated legacy array).
function resolveGranularityMeta(
configMap: MetaConfigMap,
dimension: string,
granularity: string,
): GranularityMeta | undefined {
const cubeName = dimension.split('.')[0];
const dimConfig: any = configMap[cubeName]?.[MemberTypeEnum.DIMENSIONS]
?.find((m: any) => m.name === dimension);

const resolved = dimConfig?.effectiveGranularities
?.find((g: GranularityMeta) => g.name === granularity);
if (resolved) {
return resolved;
}

if (isPredefinedGranularity(granularity)) {
const defaults = BUILT_IN_GRANULARITIES[granularity];
return {
name: granularity,
type: 'built-in',
title: defaults?.title || granularity,
interval: `1 ${granularity}`,
...(defaults?.format ? { format: defaults.format } : {}),
};
}

return undefined;
}

type AnnotatedConfigItem = Omit<ConfigItem, 'granularities'> & {
granularity?: GranularityMeta;
};
Expand Down Expand Up @@ -113,18 +149,7 @@ function prepareAnnotation(metaConfig: MetaConfig[], query: any) {
let dimAnnotation: [string, AnnotatedConfigItem] | undefined;

if (an) {
let granularityMeta: GranularityMeta | undefined;
if (isPredefinedGranularity(td.granularity)) {
granularityMeta = {
name: td.granularity,
title: td.granularity,
interval: `1 ${td.granularity}`,
};
} else if (an[1].granularities) {
// No need to send all the granularities defined, only those make sense for this query
granularityMeta = an[1].granularities.find(g => g.name === td.granularity);
}

const granularityMeta = resolveGranularityMeta(configMap, td.dimension, td.granularity);
const { granularities: _, ...rest } = an[1];
dimAnnotation = [an[0], { ...rest, granularity: granularityMeta }];
}
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 Expand Up @@ -239,4 +243,70 @@ describe('prepareAnnotation helpers', () => {
}).timeDimensions
).toEqual({});
});

describe('granularity resolution from effectiveGranularities', () => {
const metaConfig = (effectiveGranularities?: any[]) => [{
config: ({
name: 'cube_name',
title: 'cube name',
dimensions: [{
name: 'cube_name.member',
type: 'time',
...(effectiveGranularities ? { effectiveGranularities } : {}),
}],
}) as { name: string; title: string; },
}];

const tdQuery = (granularity: string) => ({
dimensions: ['cube_name.member'],
timeDimensions: [{ dimension: 'cube_name.member', granularity }],
});

test('reads the queried granularity from the effective set (global override honored)', () => {
const result = prepareAnnotation(
metaConfig([
{ name: 'day', type: 'built-in', title: 'Tag', interval: '1 day', format: '%d.%m.%Y' },
{ name: 'fiscal_year', type: 'custom', title: 'Fiscal Year', interval: '1 year', origin: '2024-02-01' },
]),
tdQuery('day'),
);
expect((result.timeDimensions['cube_name.member.day'] as any).granularity).toEqual({
name: 'day', type: 'built-in', title: 'Tag', interval: '1 day', format: '%d.%m.%Y',
});
});

test('resolves a custom granularity from the effective set', () => {
const result = prepareAnnotation(
metaConfig([
{ name: 'fiscal_year', type: 'custom', title: 'Fiscal Year', interval: '1 year', origin: '2024-02-01' },
]),
tdQuery('fiscal_year'),
);
expect((result.timeDimensions['cube_name.member.fiscal_year'] as any).granularity).toEqual({
name: 'fiscal_year', type: 'custom', title: 'Fiscal Year', interval: '1 year', origin: '2024-02-01',
});
});

test('synthesizes a config-disabled built-in from defaults', () => {
const result = prepareAnnotation(
metaConfig([
{ name: 'year', type: 'built-in', title: 'Year', interval: '1 year', format: '%Y' },
]),
tdQuery('day'),
);
expect((result.timeDimensions['cube_name.member.day'] as any).granularity).toEqual({
name: 'day', type: 'built-in', title: 'Day', interval: '1 day', format: '%Y-%m-%d',
});
});

test('unknown custom granularity yields undefined, never the legacy array', () => {
const result = prepareAnnotation(
metaConfig([
{ name: 'day', type: 'built-in', title: 'Day', interval: '1 day', format: '%Y-%m-%d' },
]),
tdQuery('some_custom'),
);
expect((result.timeDimensions['cube_name.member.some_custom'] as any).granularity).toBeUndefined();
});
});
});
Loading
Loading