Skip to content

Commit 720f048

Browse files
feat(schema-compiler,api-gateway): Nested folders support (#9659)
* extend validation schema with nested folders * support nested folders in CubeEvaluator * support nested folders in meta * add tests * fix open api spec * Fix TCubeFolder type fix/add TCubeNestedFolder type in client * Temporary marked folder members as strings * Revert "Temporary marked folder members as strings" This reverts commit f7101f3. * update meta transformer with flat and nested folders * add CUBEJS_NESTED_FOLDERS_DELIMITER env and related flow * regenerate cube rust openapi client * filter uniq folder members * update tests * update snapshots * docs: Nested folders --------- Co-authored-by: Igor Lukanin <igor@cube.dev>
1 parent a610157 commit 720f048

File tree

23 files changed

+489
-44
lines changed

23 files changed

+489
-44
lines changed

docs/pages/product/apis-integrations.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,11 @@ for an unofficial, community-maintained [client library for Python](https://gith
4545
Support for data modeling features differ across APIs, integrations, and [visualization
4646
tools][ref-viz-tools]. Some of the features with partial support are listed below:
4747

48-
| Feature | ✅ Supported in | ❌ Not supported in |
49-
| --- | --- | --- |
50-
| [Hierarchies][ref-hierarchies] | [Microsoft Power BI][ref-powerbi] via the [DAX API][ref-dax-api]<br/>[Cube Cloud for Excel][ref-cube-cloud-for-excel]<br/>[Cube Cloud for Sheets][ref-cube-cloud-for-sheets]<br/>[Tableau][ref-tableau] via [Semantic Layer Sync][ref-sls] | All other tools |
51-
| [Folders][ref-folders] | [Microsoft Power BI][ref-powerbi] via the [DAX API][ref-dax-api]<br/>[Cube Cloud for Excel][ref-cube-cloud-for-excel]<br/>[Cube Cloud for Sheets][ref-cube-cloud-for-sheets]<br/>[Tableau][ref-tableau] via [Semantic Layer Sync][ref-sls]<br/>[Apache Superset][ref-superset] via [Semantic Layer Sync][ref-sls]<br/>[Preset][ref-preset] via [Semantic Layer Sync][ref-sls] | All other tools |
48+
| Feature | ✅ Supported in |
49+
| --- | --- |
50+
| [Hierarchies][ref-hierarchies] | [Microsoft Power BI][ref-powerbi] via the [DAX API][ref-dax-api]<br/>[Cube Cloud for Excel][ref-cube-cloud-for-excel]<br/>[Cube Cloud for Sheets][ref-cube-cloud-for-sheets]<br/>[Tableau][ref-tableau] via [Semantic Layer Sync][ref-sls]<br/><br/>Also, supported in [Playground][ref-playground] |
51+
| Flat [folders][ref-folders] | [Microsoft Power BI][ref-powerbi] via the [DAX API][ref-dax-api]<br/>[Cube Cloud for Excel][ref-cube-cloud-for-excel]<br/>[Cube Cloud for Sheets][ref-cube-cloud-for-sheets]<br/>[Tableau][ref-tableau] via [Semantic Layer Sync][ref-sls]<br/>[Apache Superset][ref-superset] via [Semantic Layer Sync][ref-sls]<br/>[Preset][ref-preset] via [Semantic Layer Sync][ref-sls]<br/><br/>Also, supported in [Playground][ref-playground] |
52+
| Nested [folders][ref-folders] | Currently, not supported in any tool |
5253

5354
### Authentication methods
5455

@@ -96,3 +97,4 @@ API][ref-orchestration-api].
9697
[ref-auth-ntlm]: /product/auth/methods/ntlm
9798
[ref-superset]: /product/configuration/visualization-tools/superset
9899
[ref-preset]: /product/configuration/visualization-tools/superset
100+
[ref-playground]: /product/workspace/playground

docs/pages/product/apis-integrations/rest-api/reference.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ Response
254254
- `dimensions` - Array of dimensions in this cube/view
255255
- `hierarchies` - Array of hierarchies in this cube
256256
- `segments` - Array of segments in this cube/view
257-
- `folders` - Array of folders in this view
257+
- `folders` and `nestedFolders` - Arrays of flat and nested [folder][ref-folders] structures in this view, respectively
258258
- `connectedComponent` - An integer representing a join relationship. If the same value is returned for two cubes, then there is
259259
at least one join path between them.
260260

@@ -638,4 +638,5 @@ Keep-Alive: timeout=5
638638
[ref-query-wpp]: /product/apis-integrations/queries#query-with-post-processing
639639
[ref-query-wpd]: /product/apis-integrations/queries#query-with-pushdown
640640
[ref-sql-api]: /product/apis-integrations/sql-api
641-
[ref-orchestration-api]: /product/apis-integrations/orchestration-api
641+
[ref-orchestration-api]: /product/apis-integrations/orchestration-api
642+
[ref-folders]: /product/data-modeling/reference/view#folders

docs/pages/product/configuration/reference/environment-variables.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,21 @@ If `true`, the DAX API will expose time dimensions as calendar hierarchies.
12971297
| --------------- | ---------------------- | --------------------- |
12981298
| `true`, `false` | `true` | `true` |
12991299

1300+
## `CUBEJS_NESTED_FOLDERS_DELIMITER`
1301+
1302+
Specifies the delimiter used to flatten the names of nested [folder][ref-folders] in
1303+
views when [visualization tools][ref-dataviz-tools] do not support nested folder
1304+
structures. When set, nested folders will be presented at the root level with path-like
1305+
names using the specified delimiter.
1306+
1307+
| Possible Values | Default in Development | Default in Production |
1308+
| --------------- | ---------------------- | --------------------- |
1309+
| A valid string delimiter (e.g., ` / `) | N/A | N/A |
1310+
1311+
For example, if set to `/`, a nested folder structure like the `Customer Information`
1312+
folder with the `Personal Details` subfolder will be flattened to `Customer
1313+
Information / Personal Details` at the root level.
1314+
13001315
## `CUBEJS_TELEMETRY`
13011316

13021317
If `true`, then send telemetry to Cube.
@@ -1781,3 +1796,5 @@ The port for a Cube deployment to listen to API connections on.
17811796
[ref-schema-ref-preagg-allownonstrict]: /product/data-modeling/reference/pre-aggregations#allow_non_strict_date_range_match
17821797
[link-tesseract]: https://cube.dev/blog/introducing-next-generation-data-modeling-engine
17831798
[ref-multi-stage-calculations]: /product/data-modeling/concepts/multi-stage-calculations
1799+
[ref-folders]: /product/data-modeling/reference/view#folders
1800+
[ref-dataviz-tools]: /product/configuration/visualization-tools

docs/pages/product/data-modeling/concepts.mdx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,7 @@ paths.
131131
<Diagram src="https://ucarecdn.com/bfc3e04a-b690-40bc-a6f8-14a9175fb4fd/" />
132132
133133
Views do **not** define their own members. Instead, they reference cubes by
134-
specific join paths and include their members. Optionally, you can also group
135-
members of a view into [folders][ref-ref-folders].
134+
specific join paths and include their members.
136135
137136
In the example below, we create the `orders` view which includes select members
138137
from `base_orders`, `products`, and `users` cubes:
@@ -212,6 +211,16 @@ See the reference documentaton for the full list of view [parameters][ref-views]
212211
213212
</ReferenceBox>
214213
214+
### Folders
215+
216+
Optionally, members of a view can be organized into [folders][ref-ref-folders].
217+
Each folder would contain a subset of members of the view.
218+
219+
Cube supports both flat and nested folder structures, which can be used with various
220+
[visualization tools][ref-viz-tools]. If a specific tool does not support nested folders,
221+
they will be exposed to such a tool as an equivalent flat structure. Check [APIs &
222+
Integrations][ref-apis-support] for details on the nested folders support.
223+
215224
## Dimensions
216225
217226
_Dimensions_ represent the properties of a **single** data point in the cube.
@@ -841,4 +850,6 @@ See the reference documentaton for the full list of pre-aggregation
841850
[ref-avg-and-percentile-recipe]: /product/data-modeling/recipes/percentiles
842851
[ref-period-over-period-recipe]: /product/data-modeling/recipes/period-over-period
843852
[ref-custom-calendar-recipe]: /product/data-modeling/recipes/custom-calendar
844-
[ref-cube-with-dbt]: /product/data-modeling/recipes/dbt
853+
[ref-cube-with-dbt]: /product/data-modeling/recipes/dbt
854+
[ref-apis-support]: /product/apis-integrations#data-modeling
855+
[ref-viz-tools]: /product/configuration/visualization-tools

docs/pages/product/data-modeling/reference/view.mdx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,120 @@ views:
441441
442442
</CodeTabs>
443443
444+
Nested folders are also supported. The `includes` parameter can contain not only
445+
references to view members but also other folders:
446+
447+
<CodeTabs>
448+
449+
```javascript
450+
view(`customers`, {
451+
cubes: [
452+
{
453+
join_path: `users`,
454+
includes: `*`
455+
},
456+
{
457+
join_path: `users.orders`,
458+
prefix: true,
459+
includes: [
460+
`status`,
461+
`price`,
462+
`count`
463+
]
464+
}
465+
],
466+
467+
folders: [
468+
{
469+
name: `Customer Information`,
470+
includes: [
471+
{
472+
name: `Personal Details`,
473+
includes: [
474+
`name`,
475+
`gender`
476+
]
477+
},
478+
{
479+
name: `Location`,
480+
includes: [
481+
`address`,
482+
`postal_code`,
483+
`city`
484+
]
485+
}
486+
]
487+
},
488+
{
489+
name: `Order Analytics`,
490+
includes: [
491+
`orders_status`,
492+
`orders_price`,
493+
{
494+
name: `Metrics`,
495+
includes: [
496+
`orders_count`,
497+
`orders_average_value`
498+
]
499+
}
500+
]
501+
}
502+
]
503+
})
504+
```
505+
506+
```yaml
507+
views:
508+
- name: customers
509+
510+
cubes:
511+
- join_path: users
512+
includes: "*"
513+
514+
- join_path: users.orders
515+
prefix: true
516+
includes:
517+
- status
518+
- price
519+
- count
520+
521+
folders:
522+
- name: Customer Information
523+
includes:
524+
- name: Personal Details
525+
includes:
526+
- name
527+
- gender
528+
529+
- name: Location
530+
includes:
531+
- address
532+
- postal_code
533+
- city
534+
535+
- name: Order Analytics
536+
includes:
537+
- orders_status
538+
- orders_price
539+
540+
- name: Metrics
541+
includes:
542+
- orders_count
543+
- orders_average_value
544+
```
545+
546+
</CodeTabs>
547+
548+
You can still define nested folders in the data model even if some of your [visualization
549+
tools][ref-viz-tools] do not support them. Check [APIs & Integrations][ref-apis-support]
550+
for details on the nested folders support.
551+
552+
For tools that do not support nested folders, the nested structure will be flattened:
553+
by default, the members of nested folders are merged into folders at the root level.
554+
You can also set the `CUBEJS_NESTED_FOLDERS_DELIMITER` environment variable to preserve
555+
nested folders and give them path-like names, e.g., `Customer Information / Personal
556+
Details`.
557+
444558
### `access_policy`
445559

446560
The `access_policy` parameter is used to configure [data access policies][ref-ref-dap].

packages/cubejs-api-gateway/openspec.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,21 @@ components:
175175
type: array
176176
items:
177177
type: "string"
178+
V1CubeMetaNestedFolder:
179+
type: "object"
180+
required:
181+
- name
182+
- members
183+
properties:
184+
name:
185+
type: "string"
186+
members:
187+
type: array
188+
items:
189+
type: "string"
190+
oneOf:
191+
- type: string
192+
- $ref: "#/components/schemas/V1CubeMetaNestedFolder"
178193
V1CubeMetaHierarchy:
179194
type: "object"
180195
required:
@@ -231,6 +246,10 @@ components:
231246
type: "array"
232247
items:
233248
$ref: "#/components/schemas/V1CubeMetaFolder"
249+
nestedFolders:
250+
type: "array"
251+
items:
252+
$ref: "#/components/schemas/V1CubeMetaNestedFolder"
234253
hierarchies:
235254
type: "array"
236255
items:

packages/cubejs-backend-shared/src/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ const variables: Record<string, (...args: any) => any> = {
240240
transpilationNative: () => get('CUBEJS_TRANSPILATION_NATIVE')
241241
.default('false')
242242
.asBoolStrict(),
243+
nestedFoldersDelimiter: () => get('CUBEJS_NESTED_FOLDERS_DELIMITER')
244+
.default('')
245+
.asString(),
243246

244247
/** ****************************************************************
245248
* Common db options *

packages/cubejs-client-core/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,11 @@ export type TCubeFolder = {
417417
members: string[];
418418
};
419419

420+
export type TCubeNestedFolder = {
421+
name: string;
422+
members: (string | TCubeNestedFolder)[];
423+
};
424+
420425
export type TCubeHierarchy = {
421426
name: string;
422427
title?: string;
@@ -451,6 +456,7 @@ export type Cube = {
451456
dimensions: TCubeDimension[];
452457
segments: TCubeSegment[];
453458
folders: TCubeFolder[];
459+
nestedFolders: TCubeNestedFolder[];
454460
hierarchies: TCubeHierarchy[];
455461
connectedComponent?: number;
456462
type?: 'view' | 'cube';

packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -261,34 +261,51 @@ export class CubeEvaluator extends CubeSymbols {
261261

262262
private prepareFolders(cube: any, errorReporter: ErrorReporter) {
263263
const folders = cube.rawFolders();
264-
if (folders.length) {
265-
cube.folders = folders.map(it => {
266-
const includedMembers = this.allMembersOrList(cube, it.includes);
267-
const includes = includedMembers.map(memberName => {
268-
if (memberName.includes('.')) {
269-
errorReporter.error(
270-
`Paths aren't allowed in the 'folders' but '${memberName}' has been provided for ${cube.name}`
271-
);
272-
}
264+
if (!folders.length) return;
273265

274-
const member = cube.includedMembers.find(m => m.name === memberName);
275-
if (!member) {
276-
errorReporter.error(
277-
`Member '${memberName}' included in folder '${it.name}' not found`
278-
);
279-
return null;
280-
}
266+
const checkMember = (memberName: string, folderName: string) => {
267+
if (memberName.includes('.')) {
268+
errorReporter.error(
269+
`Paths aren't allowed in the 'folders' but '${memberName}' has been provided for ${cube.name}`
270+
);
271+
}
272+
273+
const member = cube.includedMembers.find(m => m.name === memberName);
274+
if (!member) {
275+
errorReporter.error(
276+
`Member '${memberName}' included in folder '${folderName}' not found`
277+
);
278+
return null;
279+
}
281280

282-
return member;
283-
})
284-
.filter(Boolean);
281+
return member;
282+
};
283+
284+
const processFolder = (folder: any): any => {
285+
let includedMembers: string[];
286+
let includes: any[] = [];
287+
288+
if (folder.includes === '*') {
289+
includedMembers = this.allMembersOrList(cube, folder.includes);
290+
includes = includedMembers.map(m => checkMember(m, folder.name)).filter(Boolean);
291+
} else if (Array.isArray(folder.includes)) {
292+
includes = folder.includes.map(item => {
293+
if (typeof item === 'object' && item !== null) {
294+
return processFolder(item);
295+
}
285296

286-
return ({
287-
...it,
288-
includes
297+
return checkMember(item, folder.name);
289298
});
290-
});
291-
}
299+
}
300+
301+
return {
302+
...folder,
303+
type: 'folder',
304+
includes: includes.filter(Boolean)
305+
};
306+
};
307+
308+
cube.folders = folders.map(processFolder);
292309
}
293310

294311
private prepareHierarchies(cube: any, errorReporter: ErrorReporter): void {

0 commit comments

Comments
 (0)