From 95b9e3b33877df1f0a814a830779c3fe0d645080 Mon Sep 17 00:00:00 2001 From: Igor Lukanin Date: Tue, 2 Jun 2026 22:43:25 +0200 Subject: [PATCH] feat(schema-compiler): support nested view groups via includes --- .../docs/data-modeling/view-groups.mdx | 50 ++- .../reference/data-modeling/view-group.mdx | 70 +++- packages/cubejs-api-gateway/src/gateway.ts | 31 +- .../cubejs-api-gateway/test/index.test.ts | 4 + packages/cubejs-api-gateway/test/mocks.ts | 13 + packages/cubejs-client-core/src/types.ts | 9 + .../src/compiler/CubeValidator.ts | 41 ++ .../src/compiler/PrepareCompiler.ts | 2 +- .../src/compiler/ViewGroupEvaluator.ts | 268 +++++++++++-- .../src/compiler/YamlCompiler.ts | 46 ++- .../transpilers/CubePropContextTranspiler.ts | 60 ++- .../postgres/dataschema-compiler.test.ts | 376 ++++++++++++++++++ .../test/unit/yaml-schema.test.ts | 97 +++++ 13 files changed, 993 insertions(+), 74 deletions(-) diff --git a/docs-mintlify/docs/data-modeling/view-groups.mdx b/docs-mintlify/docs/data-modeling/view-groups.mdx index 8e7aaa46a5b2d..1a96e6c4f7d06 100644 --- a/docs-mintlify/docs/data-modeling/view-groups.mdx +++ b/docs-mintlify/docs/data-modeling/view-groups.mdx @@ -41,7 +41,7 @@ view_group(`sales`, { ## Assigning views to a group To assign a view to a group, list its name on the group via the -[`views`][ref-view-group-views] parameter. This keeps the full +[`includes`][ref-view-group-includes] parameter. This keeps the full membership in one place, which makes it easy to review a group at a glance. @@ -50,7 +50,7 @@ membership in one place, which makes it easy to review a group at a glance. view_groups: - name: sales title: Sales - views: + includes: - orders_overview - revenue ``` @@ -58,15 +58,54 @@ view_groups: ```javascript title="JavaScript" view_group(`sales`, { title: `Sales`, - views: [`orders_overview`, `revenue`] + includes: [`orders_overview`, `revenue`] }) ``` -A view can belong to more than one group — list it under the `views` +A view can belong to more than one group — list it under the `includes` parameter of every group it should appear in. +## Nesting + +View groups can be nested, similar to [nested folders][ref-view-nesting]. Add a +nested view group — with its own `name`, `title`, `description`, and +`includes` — directly inside a parent group's `includes`. + + + +```yaml title="YAML" +view_groups: + - name: sales + title: Sales + includes: + - orders_overview + - revenue + + - name: enterprise_sales + title: Enterprise Sales + includes: + - enterprise_deals +``` + +```javascript title="JavaScript" +view_group(`sales`, { + title: `Sales`, + includes: [ + `orders_overview`, + `revenue`, + { + name: `enterprise_sales`, + title: `Enterprise Sales`, + includes: [`enterprise_deals`] + } + ] +}) +``` + + + ## Where view groups live in the model By [convention][ref-syntax], view groups are typically defined alongside @@ -83,8 +122,9 @@ entity and can be split across multiple files as your model grows. - Explore [AI context][ref-ai-context] to improve AI query accuracy [ref-views]: /docs/data-modeling/views +[ref-view-nesting]: /reference/data-modeling/view#nesting [ref-syntax]: /docs/data-modeling/concepts/syntax [ref-ai-context]: /docs/data-modeling/ai-context [ref-view-group-ref]: /reference/data-modeling/view-group -[ref-view-group-views]: /reference/data-modeling/view-group#views +[ref-view-group-includes]: /reference/data-modeling/view-group#includes [ref-meta-endpoint]: /reference/core-data-apis/rest-api/reference diff --git a/docs-mintlify/reference/data-modeling/view-group.mdx b/docs-mintlify/reference/data-modeling/view-group.mdx index 91ed9adf1ff0e..fa6822e157ab1 100644 --- a/docs-mintlify/reference/data-modeling/view-group.mdx +++ b/docs-mintlify/reference/data-modeling/view-group.mdx @@ -79,9 +79,10 @@ view_group(`sales`, { -### `views` +### `includes` -A list of view names that belong to this group. +A list of [views][ref-views] that belong to this group. It can also contain +nested view groups (see [Nesting](#nesting) below). @@ -89,7 +90,7 @@ A list of view names that belong to this group. view_groups: - name: sales title: Sales - views: + includes: - orders_overview - revenue ``` @@ -97,7 +98,48 @@ view_groups: ```javascript title="JavaScript" view_group(`sales`, { title: `Sales`, - views: [`orders_overview`, `revenue`] + includes: [`orders_overview`, `revenue`] +}) +``` + + + +#### Nesting + +View groups can be nested, similar to [nested folders][ref-view-nesting]. The +`includes` parameter can contain not only references to views but also other +view groups, each with its own `title`, `description`, and `includes`. + + + +```yaml title="YAML" +view_groups: + - name: sales + title: Sales + includes: + - orders_overview + - revenue + + - name: enterprise_sales + title: Enterprise Sales + description: Views for the enterprise sales team + includes: + - enterprise_deals +``` + +```javascript title="JavaScript" +view_group(`sales`, { + title: `Sales`, + includes: [ + `orders_overview`, + `revenue`, + { + name: `enterprise_sales`, + title: `Enterprise Sales`, + description: `Views for the enterprise sales team`, + includes: [`enterprise_deals`] + } + ] }) ``` @@ -106,12 +148,12 @@ view_group(`sales`, { ## Assigning views to groups To associate a view with a view group, list its name in the -[`views`](#views) parameter on the view group. +[`includes`](#includes) parameter on the view group. ### Example The following model defines two view groups. The `sales` group lists -`orders_overview` and `revenue` in its `views` parameter, while the +`orders_overview` and `revenue` in its `includes` parameter, while the `people` group lists `customers_view`. @@ -186,14 +228,14 @@ view_groups: - name: sales title: Sales description: Revenue and order views for the sales team - views: + includes: - orders_overview - revenue - name: people title: People description: Customer and user views - views: + includes: - customers_view ``` @@ -292,13 +334,13 @@ view(`customers_view`, { view_group(`sales`, { title: `Sales`, description: `Revenue and order views for the sales team`, - views: [`orders_overview`, `revenue`] + includes: [`orders_overview`, `revenue`] }) view_group(`people`, { title: `People`, description: `Customer and user views`, - views: [`customers_view`] + includes: [`customers_view`] }) ``` @@ -313,18 +355,22 @@ With this model, the `/v1/meta` response includes a `viewGroups` array: "name": "sales", "title": "Sales", "description": "Revenue and order views for the sales team", - "views": ["orders_overview", "revenue"] + "includes": ["orders_overview", "revenue"] }, { "name": "people", "title": "People", "description": "Customer and user views", - "views": ["customers_view"] + "includes": ["customers_view"] } ] } ``` +Each view group carries an `includes` array that preserves the authoring order +and contains the group's views as well as any nested view groups. + [ref-views]: /docs/data-modeling/views +[ref-view-nesting]: /reference/data-modeling/view#nesting [ref-naming]: /docs/data-modeling/concepts/syntax#naming [ref-meta-endpoint]: /reference/core-data-apis/rest-api/reference diff --git a/packages/cubejs-api-gateway/src/gateway.ts b/packages/cubejs-api-gateway/src/gateway.ts index 6dbd852f4ed32..fb4245b38fb50 100644 --- a/packages/cubejs-api-gateway/src/gateway.ts +++ b/packages/cubejs-api-gateway/src/gateway.ts @@ -648,6 +648,30 @@ class ApiGateway { })).filter(cube => cube.config.measures?.length || cube.config.dimensions?.length || cube.config.segments?.length); } + /** + * Recursively filters a (possibly nested) view group so that only visible + * views are exposed. A view group is dropped (returns null) when neither it + * nor any of its nested groups contains a visible view, preventing leaks of + * restricted view names. + */ + private filterVisibleViewGroup(group: any, visibleCubeNames: Set): any | null { + const views = (group.views || []).filter((v: string) => visibleCubeNames.has(v)); + const includes = (group.includes || []) + .map((include: any) => { + if (typeof include === 'string') { + return visibleCubeNames.has(include) ? include : null; + } + return this.filterVisibleViewGroup(include, visibleCubeNames); + }) + .filter((include: any) => include !== null); + + if (views.length === 0 && includes.length === 0) { + return null; + } + + return { ...group, views, includes }; + } + public async meta({ context, res, includeCompilerId, onlyCompilerId, onlyViews }: { context: RequestContext, res: MetaResponseResultFn, @@ -679,11 +703,8 @@ class ApiGateway { const cubes = this.filterVisibleItemsInMeta(context, cubesConfig).map(cube => cube.config); const visibleCubeNames = new Set(cubes.map(c => c.name)); const viewGroups = (metaConfig.viewGroups || []) - .map(group => ({ - ...group, - views: group.views.filter((v: string) => visibleCubeNames.has(v)), - })) - .filter(group => group.views.length > 0); + .map(group => this.filterVisibleViewGroup(group, visibleCubeNames)) + .filter(group => group !== null); const response: { cubes: any[], viewGroups?: any[], compilerId?: string } = { cubes }; if (viewGroups.length > 0) { response.viewGroups = viewGroups; diff --git a/packages/cubejs-api-gateway/test/index.test.ts b/packages/cubejs-api-gateway/test/index.test.ts index edc41a1992d0b..6c2f9c27311d7 100644 --- a/packages/cubejs-api-gateway/test/index.test.ts +++ b/packages/cubejs-api-gateway/test/index.test.ts @@ -698,11 +698,14 @@ describe('API Gateway', () => { expect(res.body).toHaveProperty('viewGroups'); expect(res.body.viewGroups).toHaveLength(1); + // The nested `restricted` group references only a hidden view, so it is + // pruned from the response. expect(res.body.viewGroups[0]).toEqual({ name: 'analytics', title: 'Analytics', description: 'Analytics related views', views: ['FooView'], + includes: ['FooView'], }); const fooView = res.body.cubes.find(c => c.name === 'FooView'); @@ -750,6 +753,7 @@ describe('API Gateway', () => { title: 'Analytics', description: 'Analytics related views', views: ['FooView'], + includes: ['FooView'], }, ]); }); diff --git a/packages/cubejs-api-gateway/test/mocks.ts b/packages/cubejs-api-gateway/test/mocks.ts index 65ff08a0c9609..5ea9898f8487a 100644 --- a/packages/cubejs-api-gateway/test/mocks.ts +++ b/packages/cubejs-api-gateway/test/mocks.ts @@ -161,9 +161,22 @@ export const compilerApi = jest.fn().mockImplementation(async () => ({ title: 'Analytics', description: 'Analytics related views', views: ['FooView'], + includes: [ + 'FooView', + { + name: 'restricted', + title: 'Restricted', + // Only references a hidden view, so it must be pruned from meta. + views: ['HiddenView'], + includes: ['HiddenView'], + }, + ], }, ]; } + // NOTE: `views`/`includes` here represent the already-compiled meta shape + // returned by the compiler (post-resolution), where both fields are + // always present — not the authored view group definition. return result; } diff --git a/packages/cubejs-client-core/src/types.ts b/packages/cubejs-client-core/src/types.ts index 876533d97d471..c71cd05c93fe2 100644 --- a/packages/cubejs-client-core/src/types.ts +++ b/packages/cubejs-client-core/src/types.ts @@ -545,7 +545,16 @@ export type ViewGroup = { name: string; title?: string; description?: string; + /** + * The group's own direct view references at this level. + */ views: string[]; + /** + * Recursive representation: view names interleaved with nested view groups, + * preserving authoring order. Present when the group is defined via + * `includes` (including nested view groups). + */ + includes?: (string | ViewGroup)[]; }; export type MetaResponse = { diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 9003936fc40d5..8f2e087edce38 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -1227,6 +1227,31 @@ const folderSchema = Joi.object().keys({ ]).required(), }).id('folderSchema'); +const viewGroupSchema = Joi.object().keys({ + name: Joi.string().required(), + title: Joi.string(), + description: Joi.string(), + // Legacy way of including views into a group, kept for backward compatibility. + views: Joi.alternatives([Joi.array().items(Joi.string().required()), Joi.func()]), + // Preferred way of including views (and nested view groups) into a group. + includes: Joi.alternatives([ + Joi.func(), + Joi.array().items( + Joi.alternatives([ + Joi.string().required(), + Joi.func(), + Joi.link('#viewGroupSchema'), // Can contain nested view groups + ]), + ), + ]), + fileName: Joi.string(), +}) + .oxor('views', 'includes') + .messages({ + 'object.oxor': 'View group must use either "views" or "includes", but not both' + }) + .id('viewGroupSchema'); + const ViewDefaultFilterSchema = Joi.object().keys({ member: Joi.func().required(), operator: Joi.any().valid( @@ -1389,6 +1414,22 @@ export class CubeValidator implements CompilerInterface { return result; } + public validateViewGroup(viewGroup, errorReporter: ErrorReporter) { + const options = { + nonEnumerables: true, + abortEarly: false, // This will allow all errors to be reported, not just the first one + }; + const result = viewGroupSchema.validate(viewGroup, options); + + if (result.error != null) { + errorReporter + .inContext(`${viewGroup?.name} view group`) + .error(formatErrorMessage(result.error)); + } + + return result; + } + /** * Validates that each cube appears only once within each root path in a view. * This ensures that there is only one path to reach each cube within each root's join graph. diff --git a/packages/cubejs-schema-compiler/src/compiler/PrepareCompiler.ts b/packages/cubejs-schema-compiler/src/compiler/PrepareCompiler.ts index cd0e0c52fc7e8..e36e6cb02589b 100644 --- a/packages/cubejs-schema-compiler/src/compiler/PrepareCompiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/PrepareCompiler.ts @@ -67,7 +67,7 @@ export const prepareCompiler = (repo: SchemaFileRepository, options: PrepareComp const cubeValidator = new CubeValidator(cubeSymbols); const cubeEvaluator = new CubeEvaluator(cubeValidator); const contextEvaluator = new ContextEvaluator(cubeEvaluator); - const viewGroupEvaluator = new ViewGroupEvaluator(cubeEvaluator); + const viewGroupEvaluator = new ViewGroupEvaluator(cubeEvaluator, cubeValidator); const joinGraph = new JoinGraph(cubeValidator, cubeEvaluator); const metaTransformer = new CubeToMetaTransformer(cubeValidator, cubeEvaluator, contextEvaluator, viewGroupEvaluator, joinGraph); const { maxQueryCacheSize, maxQueryCacheAge } = options; diff --git a/packages/cubejs-schema-compiler/src/compiler/ViewGroupEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/ViewGroupEvaluator.ts index 89734fa39e03d..ad422d7d96ea7 100644 --- a/packages/cubejs-schema-compiler/src/compiler/ViewGroupEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/ViewGroupEvaluator.ts @@ -1,12 +1,40 @@ import type { CubeEvaluator } from './CubeEvaluator'; +import type { CubeValidator } from './CubeValidator'; import type { ErrorReporter } from './ErrorReporter'; import { CompilerInterface } from './PrepareCompiler'; +/** + * A nested view group definition, as authored inside the `includes` array of + * another view group. Mirrors the nested folder shape. + */ +export interface ViewGroupIncludeNested { + name: string; + title?: string; + description?: string; + // eslint-disable-next-line no-use-before-define + includes: ViewGroupInclude[] | (() => any[]); +} + +/** + * An entry of a view group's `includes`: either a view reference (a string, or + * a transpiled bare-identifier reference) or a nested view group definition. + */ +export type ViewGroupInclude = string | (() => any) | ViewGroupIncludeNested; + export interface ViewGroupInput { name: string; title?: string; description?: string; + /** + * Legacy way of including views into a group. Kept for backward + * compatibility. When `includes` is present, `views` is ignored. + */ views?: string[] | (() => string[]); + /** + * Preferred way of including views into a group. Supports both view + * references and nested view group definitions (full hierarchy). + */ + includes?: ViewGroupInclude[] | (() => any[]); fileName?: string; } @@ -14,20 +42,31 @@ export interface CompiledViewGroup { name: string; title?: string; description?: string; + /** + * The group's own direct view references at this level (not a deep flatten). + */ views: string[]; + /** + * Recursive representation: view name strings interleaved with nested + * compiled view groups, preserving authoring order. + */ + includes: (string | CompiledViewGroup)[]; } export class ViewGroupEvaluator implements CompilerInterface { private readonly cubeEvaluator: CubeEvaluator; + private readonly cubeValidator: CubeValidator; + private viewGroupDefinitions: Map; private resolvedViewGroups: CompiledViewGroup[]; private viewToGroups: Map; - public constructor(cubeEvaluator: CubeEvaluator) { + public constructor(cubeEvaluator: CubeEvaluator, cubeValidator: CubeValidator) { this.cubeEvaluator = cubeEvaluator; + this.cubeValidator = cubeValidator; this.viewGroupDefinitions = new Map(); this.resolvedViewGroups = []; this.viewToGroups = new Map(); @@ -37,17 +76,37 @@ export class ViewGroupEvaluator implements CompilerInterface { this.viewGroupDefinitions = new Map(); for (const viewGroup of viewGroups) { + if (errorReporter) { + this.cubeValidator.validateViewGroup(viewGroup, errorReporter); + } + if (errorReporter && this.viewGroupDefinitions.has(viewGroup.name)) { errorReporter.error(`View group "${viewGroup.name}" already exists!`); } else { - this.viewGroupDefinitions.set(viewGroup.name, this.compileViewGroup(viewGroup)); + this.viewGroupDefinitions.set(viewGroup.name, this.compileViewGroup(viewGroup, errorReporter)); } } this.resolve(errorReporter); } - private compileViewGroup(viewGroup: ViewGroupInput): CompiledViewGroup { + private compileViewGroup(viewGroup: ViewGroupInput, errorReporter?: ErrorReporter): CompiledViewGroup { + // `views` and `includes` are mutually exclusive on a view group definition; + // this is enforced by the Joi `viewGroupSchema` (oxor). `includes` is the + // preferred form, so it takes precedence here if both somehow slip through. + if (viewGroup.includes !== undefined) { + const seenNames = new Set([viewGroup.name]); + const { views, includes } = this.compileIncludes(viewGroup.includes, seenNames, errorReporter); + return { + name: viewGroup.name, + title: viewGroup.title, + description: viewGroup.description, + views, + includes, + }; + } + + // Legacy `views` parameter. let views: string[] = []; if (viewGroup.views) { if (typeof viewGroup.views === 'function') { @@ -63,62 +122,98 @@ export class ViewGroupEvaluator implements CompilerInterface { title: viewGroup.title, description: viewGroup.description, views, + includes: views.slice(), }; } + /** + * Recursively compiles a view group's `includes` into the group's own direct + * view references plus a recursive `includes` representation (strings for + * views, nested CompiledViewGroup objects for nested groups). + */ + private compileIncludes( + rawIncludes: ViewGroupInclude[] | (() => any[]), + seenNames: Set, + errorReporter?: ErrorReporter, + ): { views: string[]; includes: (string | CompiledViewGroup)[] } { + let items: any[] = []; + if (typeof rawIncludes === 'function') { + const evaluated = rawIncludes(); + items = Array.isArray(evaluated) ? evaluated : [evaluated]; + } else if (Array.isArray(rawIncludes)) { + items = rawIncludes; + } + + const views: string[] = []; + const includes: (string | CompiledViewGroup)[] = []; + + for (const item of items) { + if (this.isNestedGroup(item)) { + if (errorReporter && seenNames.has(item.name)) { + errorReporter.error(`View group "${item.name}" already exists!`); + // eslint-disable-next-line no-continue + continue; + } + seenNames.add(item.name); + + const child = this.compileIncludes(item.includes, seenNames, errorReporter); + includes.push({ + name: item.name, + title: item.title, + description: item.description, + views: child.views, + includes: child.includes, + }); + } else { + // A view reference: either a plain string or a transpiled + // single-reference arrow function. + for (const name of this.resolveViewReference(item)) { + views.push(name); + includes.push(name); + } + } + } + + return { views, includes }; + } + + private isNestedGroup(item: any): item is ViewGroupIncludeNested { + return typeof item === 'object' && item !== null && typeof item.name === 'string' && 'includes' in item; + } + + private resolveViewReference(item: any): string[] { + if (typeof item === 'function') { + const evaluated = this.cubeEvaluator.evaluateReferences(null, item, { originalSorting: true }); + return Array.isArray(evaluated) ? evaluated : [evaluated]; + } + if (typeof item === 'string') { + return [item]; + } + return []; + } + private resolve(errorReporter?: ErrorReporter): void { - const viewGroupMap = new Map(); const validViewNames = new Set(); - for (const cube of this.cubeEvaluator.cubeList) { if (cube.isView) { validViewNames.add(cube.name); } } + const viewGroupMap = new Map(); for (const [name, def] of this.viewGroupDefinitions) { - viewGroupMap.set(name, { - name: def.name, - title: def.title, - description: def.description, - views: def.views.filter(v => validViewNames.has(v)), - }); + viewGroupMap.set(name, this.resolveGroup(def, validViewNames, errorReporter)); } + // Auto-attach views that reference a top-level group via their own + // `viewGroup` / `viewGroups` properties. for (const cube of this.cubeEvaluator.cubeList) { if (!cube.isView) { // eslint-disable-next-line no-continue continue; } - const groupNames: string[] = []; - if (cube.viewGroup) { - const resolved = typeof cube.viewGroup === 'function' - ? this.cubeEvaluator.evaluateReferences(null, cube.viewGroup) - : cube.viewGroup; - const names = Array.isArray(resolved) ? resolved : [resolved]; - for (const n of names) { - if (!groupNames.includes(n)) { - groupNames.push(n); - } - } - } - if (cube.viewGroups) { - let resolved: string[]; - if (typeof cube.viewGroups === 'function') { - const evaluated = this.cubeEvaluator.evaluateReferences(null, cube.viewGroups, { originalSorting: true }); - resolved = Array.isArray(evaluated) ? evaluated : [evaluated]; - } else { - resolved = cube.viewGroups; - } - for (const n of resolved) { - if (!groupNames.includes(n)) { - groupNames.push(n); - } - } - } - - for (const groupName of groupNames) { + for (const groupName of this.viewGroupNamesForCube(cube)) { const group = viewGroupMap.get(groupName); if (!group) { if (errorReporter) { @@ -126,23 +221,108 @@ export class ViewGroupEvaluator implements CompilerInterface { } } else if (!group.views.includes(cube.name)) { group.views.push(cube.name); + group.includes.push(cube.name); } } } this.resolvedViewGroups = Array.from(viewGroupMap.values()); + // Map each view to the most-specific group(s) it directly belongs to. this.viewToGroups = new Map(); for (const group of this.resolvedViewGroups) { - for (const viewName of group.views) { - let groups = this.viewToGroups.get(viewName); - if (!groups) { - groups = []; - this.viewToGroups.set(viewName, groups); + this.collectViewToGroups(group); + } + } + + /** + * Validates a compiled group's view references against the set of real view + * names and returns a filtered copy. References to views that do not exist + * produce a compile error. + */ + private resolveGroup( + group: CompiledViewGroup, + validViewNames: Set, + errorReporter?: ErrorReporter, + ): CompiledViewGroup { + const views: string[] = []; + const includes: (string | CompiledViewGroup)[] = []; + + for (const include of group.includes) { + if (typeof include !== 'string') { + includes.push(this.resolveGroup(include, validViewNames, errorReporter)); + } else if (validViewNames.has(include)) { + views.push(include); + includes.push(include); + } else if (errorReporter) { + errorReporter.error(`View group "${group.name}" includes "${include}" which is not a defined view.`); + } + } + + return { + name: group.name, + title: group.title, + description: group.description, + views, + includes, + }; + } + + private viewGroupNamesForCube(cube: any): string[] { + const groupNames: string[] = []; + + if (cube.viewGroup) { + const resolved = typeof cube.viewGroup === 'function' + ? this.cubeEvaluator.evaluateReferences(null, cube.viewGroup) + : cube.viewGroup; + const names = Array.isArray(resolved) ? resolved : [resolved]; + for (const n of names) { + if (!groupNames.includes(n)) { + groupNames.push(n); } + } + } + + if (cube.viewGroups) { + let resolved: string[]; + if (typeof cube.viewGroups === 'function') { + const evaluated = this.cubeEvaluator.evaluateReferences(null, cube.viewGroups, { originalSorting: true }); + resolved = Array.isArray(evaluated) ? evaluated : [evaluated]; + } else { + resolved = cube.viewGroups; + } + for (const n of resolved) { + if (!groupNames.includes(n)) { + groupNames.push(n); + } + } + } + + return groupNames; + } + + /** + * Recursively maps each view to the most-specific group it directly belongs + * to. A view that is a direct member of a nested group maps to that nested + * group's name, not the ancestor chain. + */ + private collectViewToGroups(group: CompiledViewGroup): void { + for (const viewName of group.views) { + let groups = this.viewToGroups.get(viewName); + if (!groups) { + groups = []; + this.viewToGroups.set(viewName, groups); + } + if (!groups.includes(group.name)) { groups.push(group.name); } } + + for (const include of group.includes) { + if (typeof include !== 'string') { + this.collectViewToGroups(include); + } + } } public get viewGroupList(): string[] { diff --git a/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts b/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts index a13a13a9bf12d..42e20a9f4cb63 100644 --- a/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts @@ -129,15 +129,45 @@ export class YamlCompiler { } private transpileViewGroup(viewGroupObj): string { + const viewGroupCall = t.callExpression( + t.identifier('view_group'), + [t.stringLiteral(viewGroupObj.name), this.viewGroupBodyAst(viewGroupObj)] + ); + + return babelGenerator(viewGroupCall, {}, '').code; + } + + /** + * Builds the AST for a (possibly nested) view group definition body. View + * references inside `includes` are emitted as plain string literals and + * nested view groups as object literals, so no reference resolution is needed + * at evaluation time (YAML uses string view names, not bare identifiers). + */ + private viewGroupBodyAst(viewGroupObj, nested = false): t.ObjectExpression { const properties: t.ObjectProperty[] = []; + if (nested && viewGroupObj.name) { + properties.push(t.objectProperty(t.stringLiteral('name'), t.stringLiteral(viewGroupObj.name))); + } if (viewGroupObj.title) { properties.push(t.objectProperty(t.stringLiteral('title'), t.stringLiteral(viewGroupObj.title))); } if (viewGroupObj.description) { properties.push(t.objectProperty(t.stringLiteral('description'), t.stringLiteral(viewGroupObj.description))); } - if (viewGroupObj.views && Array.isArray(viewGroupObj.views)) { + // Emit `views` and `includes` independently (not else-if) so that a YAML + // definition that wrongly specifies both still reaches the `viewGroupSchema` + // mutual-exclusion (oxor) validation instead of being silently coerced. + if (Array.isArray(viewGroupObj.includes)) { + properties.push( + t.objectProperty( + t.stringLiteral('includes'), + t.arrayExpression(viewGroupObj.includes.map((item) => this.viewGroupIncludeAst(item))) + ) + ); + } + if (Array.isArray(viewGroupObj.views)) { + // Legacy `views` parameter, kept for backward compatibility. properties.push( t.objectProperty( t.stringLiteral('views'), @@ -146,12 +176,16 @@ export class YamlCompiler { ); } - const viewGroupCall = t.callExpression( - t.identifier('view_group'), - [t.stringLiteral(viewGroupObj.name), t.objectExpression(properties)] - ); + return t.objectExpression(properties); + } - return babelGenerator(viewGroupCall, {}, '').code; + private viewGroupIncludeAst(item): t.Expression { + if (item && typeof item === 'object') { + // A nested view group definition: keep its `name` inside the body so the + // evaluator can recognise it as a nested group. + return this.viewGroupBodyAst(item, true); + } + return t.stringLiteral(item); } private transpileAndPrepareJsFile(methodFn: ('cube' | 'view'), cubeObj, errorsReport: ErrorReporter): string { diff --git a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts index b703780f07971..f7bbaa3e45366 100644 --- a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts @@ -78,8 +78,10 @@ export class CubePropContextTranspiler implements TranspilerInterface { this.knownIdentifiersInjectVisitor('extends', name => this.cubeDictionary.resolveCube(name)) ); } - } else if (path.node.callee.name === 'context' || path.node.callee.name === 'view_group') { + } else if (path.node.callee.name === 'context') { args[args.length - 1].traverse(this.sqlAndReferencesFieldVisitor(null)); + } else if (path.node.callee.name === 'view_group') { + args[args.length - 1].traverse(this.viewGroupReferencesVisitor()); } } } @@ -123,6 +125,62 @@ export class CubePropContextTranspiler implements TranspilerInterface { } } + /** + * Visitor for the `view_group(...)` definition object. The legacy `views` + * array is wrapped as a whole (flat references), while `includes` is wrapped + * per-leaf so that nested view group definitions stay structurally intact and + * only their view references become resolvable arrow functions. This mirrors + * how nested folders preserve their structure. + */ + protected viewGroupReferencesVisitor(): TraverseObject { + const resolveSymbol = n => this.viewCompiler.resolveSymbol(null, n) || + this.cubeSymbols.resolveSymbol(null, n) || + this.cubeSymbols.isCurrentCube(n); + + const keyName = (node: t.ObjectProperty): string | undefined => { + if (node.key.type === 'Identifier') return node.key.name; + if (node.key.type === 'StringLiteral') return node.key.value; + return undefined; + }; + + const wrapIncludes = (valuePath: NodePath) => { + if (!valuePath.isArrayExpression()) { + return; + } + for (const element of valuePath.get('elements')) { + if (element.isObjectExpression()) { + // Nested view group: recurse into its own `includes` only. + for (const prop of element.get('properties')) { + if (prop.isObjectProperty() && keyName(prop.node) === 'includes') { + wrapIncludes(prop.get('value') as NodePath); + } + } + } else if (element.isIdentifier() || element.isStringLiteral()) { + CubePropContextTranspiler.replaceValueWithArrowFunction(resolveSymbol, element); + } + } + }; + + return { + ObjectProperty: (path) => { + const key = keyName(path.node); + if (key !== 'includes' && key !== 'views') { + return; + } + // Only handle the top-level definition properties here; nested + // `includes` arrays are reached via manual recursion above. + if (CubePropContextTranspiler.fullPath(path) !== key) { + return; + } + if (key === 'views') { + this.transformObjectProperty(path, resolveSymbol); + } else { + wrapIncludes(path.get('value') as NodePath); + } + } + }; + } + protected sqlAndReferencesFieldVisitor(cubeName: string | null | undefined): TraverseObject { const resolveSymbol = n => this.viewCompiler.resolveSymbol(cubeName, n) || this.cubeSymbols.resolveSymbol(cubeName, n) || diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/dataschema-compiler.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/dataschema-compiler.test.ts index bc115a1289233..6b96a298185ed 100644 --- a/packages/cubejs-schema-compiler/test/integration/postgres/dataschema-compiler.test.ts +++ b/packages/cubejs-schema-compiler/test/integration/postgres/dataschema-compiler.test.ts @@ -527,6 +527,7 @@ describe('DataSchemaCompiler', () => { title: 'Sales', description: 'Sales related views', views: ['revenue', 'customers_view'], + includes: ['revenue', 'customers_view'], }]); expect(metaTransformer.viewGroups).toEqual([{ @@ -534,6 +535,7 @@ describe('DataSchemaCompiler', () => { title: 'Sales', description: 'Sales related views', views: ['revenue', 'customers_view'], + includes: ['revenue', 'customers_view'], }]); const revenueView = metaTransformer.cubes.find(c => c.config.name === 'revenue'); @@ -573,6 +575,7 @@ describe('DataSchemaCompiler', () => { name: 'sales', title: 'Sales', views: ['revenue'], + includes: ['revenue'], }]); const revenueView = metaTransformer.cubes.find(c => c.config.name === 'revenue'); @@ -606,6 +609,7 @@ describe('DataSchemaCompiler', () => { expect(metaTransformer.viewGroups).toEqual([{ name: 'sales', views: ['revenue'], + includes: ['revenue'], }]); const revenueView = metaTransformer.cubes.find(c => c.config.name === 'revenue'); @@ -837,6 +841,7 @@ view_groups: title: 'Sales', description: 'Sales related views', views: ['revenue'], + includes: ['revenue'], }]); const revenueView = metaTransformer.cubes.find(c => c.config.name === 'revenue'); @@ -872,6 +877,7 @@ view_groups: expect(metaTransformer.viewGroups).toEqual([{ name: 'sales', views: ['revenue'], + includes: ['revenue'], }]); const revenueView = metaTransformer.cubes.find(c => c.config.name === 'revenue'); @@ -906,4 +912,374 @@ view_groups: expect(e.message).toContain('View "revenue" references view group "nonexistent" which is not defined'); } }); + + it('view_group with includes (string references)', async () => { + const { compiler, metaTransformer } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + cube('Customers', { + sql: \`select * from customers\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('revenue', { cubes: [{ joinPath: Orders, includes: '*' }] }) + view('customers_view', { cubes: [{ joinPath: Customers, includes: '*' }] }) + + view_group('sales', { + title: 'Sales', + includes: ['revenue', 'customers_view'] + }); + `); + await compiler.compile(); + + expect(metaTransformer.viewGroups).toEqual([{ + name: 'sales', + title: 'Sales', + views: ['revenue', 'customers_view'], + includes: ['revenue', 'customers_view'], + }]); + + expect(metaTransformer.cubes.find(c => c.config.name === 'revenue')?.config.viewGroups).toEqual(['sales']); + expect(metaTransformer.cubes.find(c => c.config.name === 'customers_view')?.config.viewGroups).toEqual(['sales']); + }); + + it('view_group with includes (bare and mixed references)', async () => { + const { compiler, metaTransformer } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + cube('Customers', { + sql: \`select * from customers\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('revenue', { cubes: [{ joinPath: Orders, includes: '*' }] }) + view('customers_view', { cubes: [{ joinPath: Customers, includes: '*' }] }) + + view_group('sales', { + title: 'Sales', + includes: [revenue, 'customers_view'] + }); + `); + await compiler.compile(); + + const salesGroup = metaTransformer.viewGroups.find(g => g.name === 'sales'); + expect(salesGroup?.views).toEqual(['revenue', 'customers_view']); + expect(salesGroup?.includes).toEqual(['revenue', 'customers_view']); + }); + + it('view_group with nested view groups (includes)', async () => { + const { compiler, metaTransformer } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('revenue', { cubes: [{ joinPath: Orders, includes: '*' }] }) + view('enterprise_deals', { cubes: [{ joinPath: Orders, includes: '*' }] }) + + view_group('sales', { + title: 'Sales', + description: 'Sales related views', + includes: [ + revenue, + { + name: 'ent_sales', + title: 'Enterprise Sales', + description: 'Enterprise deals', + includes: [enterprise_deals] + } + ] + }); + `); + await compiler.compile(); + + expect(metaTransformer.viewGroups).toEqual([{ + name: 'sales', + title: 'Sales', + description: 'Sales related views', + views: ['revenue'], + includes: [ + 'revenue', + { + name: 'ent_sales', + title: 'Enterprise Sales', + description: 'Enterprise deals', + views: ['enterprise_deals'], + includes: ['enterprise_deals'], + }, + ], + }]); + + // Most-specific membership: a view in a nested group maps to the nested group only. + expect(metaTransformer.cubes.find(c => c.config.name === 'revenue')?.config.viewGroups).toEqual(['sales']); + expect(metaTransformer.cubes.find(c => c.config.name === 'enterprise_deals')?.config.viewGroups).toEqual(['ent_sales']); + }); + + it('view_group with deeply nested view groups', async () => { + const { compiler, metaTransformer } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('a', { cubes: [{ joinPath: Orders, includes: '*' }] }) + view('b', { cubes: [{ joinPath: Orders, includes: '*' }] }) + view('c', { cubes: [{ joinPath: Orders, includes: '*' }] }) + + view_group('root', { + includes: [ + a, + { + name: 'mid', + includes: [ + b, + { name: 'leaf', includes: [c] } + ] + } + ] + }); + `); + await compiler.compile(); + + const root = metaTransformer.viewGroups.find(g => g.name === 'root'); + expect(root?.views).toEqual(['a']); + const mid = root?.includes.find((i: any) => typeof i !== 'string' && i.name === 'mid') as any; + expect(mid.views).toEqual(['b']); + const leaf = mid.includes.find((i: any) => typeof i !== 'string' && i.name === 'leaf') as any; + expect(leaf.views).toEqual(['c']); + expect(leaf.includes).toEqual(['c']); + + expect(metaTransformer.cubes.find(c => c.config.name === 'a')?.config.viewGroups).toEqual(['root']); + expect(metaTransformer.cubes.find(c => c.config.name === 'b')?.config.viewGroups).toEqual(['mid']); + expect(metaTransformer.cubes.find(c => c.config.name === 'c')?.config.viewGroups).toEqual(['leaf']); + }); + + it('view can belong to two sibling top-level view groups via includes', async () => { + const { compiler, metaTransformer } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('revenue', { cubes: [{ joinPath: Orders, includes: '*' }] }) + + view_group('sales', { title: 'Sales', includes: [revenue] }); + view_group('finance', { title: 'Finance', includes: [revenue] }); + `); + await compiler.compile(); + + const salesGroup = metaTransformer.viewGroups.find(g => g.name === 'sales'); + expect(salesGroup?.views).toEqual(['revenue']); + expect(salesGroup?.includes).toEqual(['revenue']); + + const financeGroup = metaTransformer.viewGroups.find(g => g.name === 'finance'); + expect(financeGroup?.views).toEqual(['revenue']); + expect(financeGroup?.includes).toEqual(['revenue']); + + expect(metaTransformer.cubes.find(c => c.config.name === 'revenue')?.config.viewGroups).toEqual(['sales', 'finance']); + }); + + it('view can belong to both a view group and its nested child group', async () => { + const { compiler, metaTransformer } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('revenue', { cubes: [{ joinPath: Orders, includes: '*' }] }) + + view_group('sales', { + title: 'Sales', + includes: [ + revenue, + { name: 'ent_sales', title: 'Enterprise Sales', includes: [revenue] } + ] + }); + `); + await compiler.compile(); + + const salesGroup = metaTransformer.viewGroups.find(g => g.name === 'sales'); + expect(salesGroup?.views).toEqual(['revenue']); + expect(salesGroup?.includes).toEqual([ + 'revenue', + { + name: 'ent_sales', + title: 'Enterprise Sales', + views: ['revenue'], + includes: ['revenue'], + }, + ]); + + // The view is a direct member of both the parent and the nested child. + expect(metaTransformer.cubes.find(c => c.config.name === 'revenue')?.config.viewGroups).toEqual(['sales', 'ent_sales']); + }); + + it('fails when view group uses both views and includes', async () => { + const { compiler } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('revenue', { cubes: [{ joinPath: Orders, includes: '*' }] }) + view('orders_overview', { cubes: [{ joinPath: Orders, includes: '*' }] }) + + view_group('sales', { + views: ['revenue'], + includes: ['orders_overview'] + }); + `); + + try { + await compiler.compile(); + throw new Error('compile must return an error'); + } catch (e: any) { + expect(e.message).toContain('View group must use either "views" or "includes", but not both'); + } + }); + + it('view_group with nested view groups in YAML', async () => { + const { compiler, metaTransformer } = prepareYamlCompiler(` +cubes: + - name: Orders + sql_table: orders + measures: + - name: count + type: count + dimensions: + - name: id + type: number + sql: id + primary_key: true + +views: + - name: revenue + cubes: + - join_path: Orders + includes: '*' + - name: enterprise_deals + cubes: + - join_path: Orders + includes: '*' + +view_groups: + - name: sales + title: Sales + includes: + - revenue + - name: ent_sales + title: Enterprise Sales + description: Enterprise deals + includes: + - enterprise_deals + `); + await compiler.compile(); + + expect(metaTransformer.viewGroups).toEqual([{ + name: 'sales', + title: 'Sales', + views: ['revenue'], + includes: [ + 'revenue', + { + name: 'ent_sales', + title: 'Enterprise Sales', + description: 'Enterprise deals', + views: ['enterprise_deals'], + includes: ['enterprise_deals'], + }, + ], + }]); + + expect(metaTransformer.cubes.find(c => c.config.name === 'enterprise_deals')?.config.viewGroups).toEqual(['ent_sales']); + }); + + it('fails when view group includes a non-existent view', async () => { + const { compiler } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('revenue', { cubes: [{ joinPath: Orders, includes: '*' }] }) + + view_group('sales', { + includes: ['revenue', 'ghost'] + }); + `); + + try { + await compiler.compile(); + throw new Error('compile must return an error'); + } catch (e: any) { + expect(e.message).toContain('View group "sales" includes "ghost" which is not a defined view'); + } + }); + + it('fails on duplicate nested view group name', async () => { + const { compiler } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('revenue', { cubes: [{ joinPath: Orders, includes: '*' }] }) + + view_group('sales', { + includes: [ + { name: 'dup', includes: [revenue] }, + { name: 'dup', includes: [revenue] } + ] + }); + `); + + try { + await compiler.compile(); + throw new Error('compile must return an error'); + } catch (e: any) { + expect(e.message).toContain('View group "dup" already exists'); + } + }); + + it('fails on malformed nested view group (missing name)', async () => { + const { compiler } = prepareJsCompiler(` + cube('Orders', { + sql: \`select * from orders\`, + measures: { count: { type: 'count' } }, + dimensions: { id: { type: 'number', sql: 'id', primaryKey: true } } + }) + + view('revenue', { cubes: [{ joinPath: Orders, includes: '*' }] }) + + view_group('sales', { + includes: [ + { title: 'No name', includes: [revenue] } + ] + }); + `); + + try { + await compiler.compile(); + throw new Error('compile must return an error'); + } catch (e: any) { + expect(e.message).toContain('view group'); + } + }); }); diff --git a/packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts b/packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts index 1be4291636477..ab110b2cd4073 100644 --- a/packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts @@ -1237,6 +1237,7 @@ view_groups: title: 'Sales', description: 'Sales related views', views: ['revenue'], + includes: ['revenue'], }]); const revenueView = metaTransformer.cubes.find(c => c.config.name === 'revenue'); @@ -1273,6 +1274,7 @@ view_groups: expect(metaTransformer.viewGroups).toEqual([{ name: 'sales', views: ['revenue'], + includes: ['revenue'], }]); const revenueView = metaTransformer.cubes.find(c => c.config.name === 'revenue'); @@ -1474,6 +1476,7 @@ view_groups: expect(metaTransformer.viewGroups).toEqual([{ name: 'sales', views: ['revenue'], + includes: ['revenue'], }]); }); @@ -1623,6 +1626,100 @@ view_groups: expect(metaTransformer.viewGroups.find(g => g.name === 'sales')?.views).toContain('revenue'); expect(metaTransformer.viewGroups.find(g => g.name === 'finance')?.views).toContain('revenue'); }); + + it('nested view groups via includes in YAML', async () => { + const { compiler, metaTransformer } = prepareYamlCompiler(` +cubes: + - name: orders + sql_table: orders + measures: + - name: count + type: count + dimensions: + - name: id + sql: id + type: number + primary_key: true + +views: + - name: revenue + cubes: + - join_path: orders + includes: '*' + - name: enterprise_deals + cubes: + - join_path: orders + includes: '*' + +view_groups: + - name: sales + title: Sales + includes: + - revenue + - name: ent_sales + title: Enterprise Sales + description: Enterprise deals + includes: + - enterprise_deals + `); + + await compiler.compile(); + + expect(metaTransformer.viewGroups).toEqual([{ + name: 'sales', + title: 'Sales', + views: ['revenue'], + includes: [ + 'revenue', + { + name: 'ent_sales', + title: 'Enterprise Sales', + description: 'Enterprise deals', + views: ['enterprise_deals'], + includes: ['enterprise_deals'], + }, + ], + }]); + + const entView = metaTransformer.cubes.find(c => c.config.name === 'enterprise_deals'); + expect(entView?.config.viewGroups).toEqual(['ent_sales']); + }); + + it('fails when a view group uses both views and includes in YAML', async () => { + const { compiler } = prepareYamlCompiler(` +cubes: + - name: orders + sql_table: orders + measures: + - name: count + type: count + dimensions: + - name: id + sql: id + type: number + primary_key: true + +views: + - name: revenue + cubes: + - join_path: orders + includes: '*' + +view_groups: + - name: sales + views: + - revenue + includes: + - revenue + `); + + try { + await compiler.compile(); + throw new Error('compile must return an error'); + } catch (e: any) { + expect(e.message).toContain('View group must use either "views" or "includes", but not both'); + } + }); }); describe('Mask SQL with shorthand', () => {