@@ -32,6 +32,13 @@ import type {
3232import { createProxyMiddleware } from 'http-proxy-middleware' ;
3333
3434import { 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' ;
3542import {
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
0 commit comments