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', () => {