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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions docs-mintlify/docs/data-modeling/view-groups.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<CodeGroup>
Expand All @@ -50,23 +50,62 @@ 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
```

```javascript title="JavaScript"
view_group(`sales`, {
title: `Sales`,
views: [`orders_overview`, `revenue`]
includes: [`orders_overview`, `revenue`]
})
```

</CodeGroup>

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`.

<CodeGroup>

```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`]
}
]
})
```

</CodeGroup>

## Where view groups live in the model

By [convention][ref-syntax], view groups are typically defined alongside
Expand All @@ -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
70 changes: 58 additions & 12 deletions docs-mintlify/reference/data-modeling/view-group.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,25 +79,67 @@ view_group(`sales`, {

</CodeGroup>

### `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).

<CodeGroup>

```yaml title="YAML"
view_groups:
- name: sales
title: Sales
views:
includes:
- orders_overview
- revenue
```

```javascript title="JavaScript"
view_group(`sales`, {
title: `Sales`,
views: [`orders_overview`, `revenue`]
includes: [`orders_overview`, `revenue`]
})
```

</CodeGroup>

#### 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`.

<CodeGroup>

```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`]
}
]
})
```

Expand All @@ -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`.

<CodeGroup>
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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`]
})
```

Expand All @@ -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
31 changes: 26 additions & 5 deletions packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): 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;
}

Comment on lines +654 to +671
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] { ...group, views, includes } copies every property from the compiler-side group object into the meta response — including anything future code adds to CompiledViewGroup (e.g. internal fields). This is mostly fine today but is an easy place to leak future internal state. Consider an explicit projection: { name, title, description, views, includes }.

return { ...group, views, includes };
}

public async meta({ context, res, includeCompilerId, onlyCompilerId, onlyViews }: {
context: RequestContext,
res: MetaResponseResultFn,
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/cubejs-api-gateway/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -750,6 +753,7 @@ describe('API Gateway', () => {
title: 'Analytics',
description: 'Analytics related views',
views: ['FooView'],
includes: ['FooView'],
},
]);
});
Expand Down
13 changes: 13 additions & 0 deletions packages/cubejs-api-gateway/test/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions packages/cubejs-client-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
41 changes: 41 additions & 0 deletions packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment on lines +1230 to +1253
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] Because nested entries are validated through the same #viewGroupSchema link, a nested entry inherits name: required plus optional views | includes plus fileName — but a nested entry with neither views nor includes (e.g. { name: 'empty' }) passes validation and produces a zero-view compiled group that still gets a viewToGroups entry for no one. Consider adding .or('views', 'includes') so a nested entry must include something, or — better — defining a separate schema for nested entries that omits the legacy views path entirely.

Side note: fileName is also accepted for nested entries from this same link, which is meaningless. A separate nested schema would tidy that up too.


const ViewDefaultFilterSchema = Joi.object().keys({
member: Joi.func().required(),
operator: Joi.any().valid(
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading