From 281c01c1e5347a722ef2eb315d17f261bb4240ef Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 4 Jun 2025 20:43:23 +0300 Subject: [PATCH 01/15] extend validation schema with nested folders --- .../src/compiler/CubeValidator.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index bf604b83cceaa..fe22356346aa8 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -795,6 +795,19 @@ const cubeSchema = inherit(baseSchema, { 'object.xor': 'You must use either sql or sqlTable within a model, but not both' }); +const folderSchema = Joi.object().keys({ + name: Joi.string().required(), + includes: Joi.alternatives([ + Joi.string().valid('*'), + Joi.array().items( + Joi.alternatives([ + Joi.string().required(), + Joi.link('#folderSchema'), // Can contain nested folders + ]), + ), + ]).required(), +}).id('folderSchema'); + const viewSchema = inherit(baseSchema, { isView: Joi.boolean().strict(), cubes: Joi.array().items( @@ -822,13 +835,7 @@ const viewSchema = inherit(baseSchema, { 'object.oxor': 'Using split together with prefix is not supported' }) ), - folders: Joi.array().items(Joi.object().keys({ - name: Joi.string().required(), - includes: Joi.alternatives([ - Joi.string().valid('*'), - Joi.array().items(Joi.string().required()) - ]).required(), - })), + folders: Joi.array().items(folderSchema), }); function formatErrorMessageFromDetails(explain, d) { From 40ff1b4deeed7448d1d42d7b3a84fd0ef6930a33 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Thu, 5 Jun 2025 15:35:57 +0300 Subject: [PATCH 02/15] support nested folders in CubeEvaluator --- .../src/compiler/CubeEvaluator.ts | 65 ++++++++++++------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index d8ee7b6bd19b0..7a688eb3ccbd0 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -259,34 +259,51 @@ export class CubeEvaluator extends CubeSymbols { private prepareFolders(cube: any, errorReporter: ErrorReporter) { const folders = cube.rawFolders(); - if (folders.length) { - cube.folders = folders.map(it => { - const includedMembers = this.allMembersOrList(cube, it.includes); - const includes = includedMembers.map(memberName => { - if (memberName.includes('.')) { - errorReporter.error( - `Paths aren't allowed in the 'folders' but '${memberName}' has been provided for ${cube.name}` - ); - } + if (!folders.length) return; - const member = cube.includedMembers.find(m => m.name === memberName); - if (!member) { - errorReporter.error( - `Member '${memberName}' included in folder '${it.name}' not found` - ); - return null; - } + const checkMember = (memberName: string, folderName: string) => { + if (memberName.includes('.')) { + errorReporter.error( + `Paths aren't allowed in the 'folders' but '${memberName}' has been provided for ${cube.name}` + ); + } + + const member = cube.includedMembers.find(m => m.name === memberName); + if (!member) { + errorReporter.error( + `Member '${memberName}' included in folder '${folderName}' not found` + ); + return null; + } - return member; - }) - .filter(Boolean); + return member; + }; + + const processFolder = (folder: any): any => { + let includedMembers: string[]; + let includes: any[] = []; + + if (folder.includes === '*') { + includedMembers = this.allMembersOrList(cube, folder.includes); + includes = includedMembers.map(m => checkMember(m, folder.name)).filter(Boolean); + } else if (Array.isArray(folder.includes)) { + includes = folder.includes.map(item => { + if (typeof item === 'object' && item !== null) { + return processFolder(item); + } - return ({ - ...it, - includes + return checkMember(item, folder.name); }); - }); - } + } + + return { + ...folder, + type: 'folder', + includes: includes.filter(Boolean) + }; + }; + + cube.folders = folders.map(processFolder); } private prepareHierarchies(cube: any, errorReporter: ErrorReporter): void { From 4c4c0486815f5d3e7ea54a5182b2b03af7d0e8b6 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Thu, 5 Jun 2025 16:08:33 +0300 Subject: [PATCH 03/15] support nested folders in meta --- .../src/compiler/CubeToMetaTransformer.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js index fe79d0e62162c..4915c435843f6 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js @@ -43,6 +43,17 @@ export class CubeToMetaTransformer { const isCubeVisible = this.isVisible(cube, true); + const processFolderMember = (member) => { + if (member.type === 'folder') { + return { + name: member.name, + members: member.includes.map(processFolderMember), + }; + } + + return `${cube.name}.${member.name}`; + }; + return { config: { name: cube.name, @@ -115,7 +126,7 @@ export class CubeToMetaTransformer { })), folders: (cube.folders || []).map((it) => ({ name: it.name, - members: it.includes.map(member => `${cube.name}.${member.name}`), + members: it.includes.map(processFolderMember), })), }, }; From c6132884ee4d468327f3b6ab225cebf9d3897d74 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Thu, 5 Jun 2025 16:14:53 +0300 Subject: [PATCH 04/15] add tests --- .../unit/__snapshots__/schema.test.ts.snap | 2 + .../test/unit/fixtures/folders.yml | 11 +++++ .../unit/fixtures/folders_invalid_path.yml | 3 ++ .../test/unit/folders.test.ts | 45 +++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap b/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap index 5857397186fc4..d4867c4c7d141 100644 --- a/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap +++ b/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap @@ -1990,6 +1990,7 @@ Array [ }, ], "name": "folder1", + "type": "folder", }, Object { "includes": Array [ @@ -2005,6 +2006,7 @@ Array [ }, ], "name": "folder2", + "type": "folder", }, ] `; diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/folders.yml b/packages/cubejs-schema-compiler/test/unit/fixtures/folders.yml index ddb8983f280c2..8e2428aa45868 100644 --- a/packages/cubejs-schema-compiler/test/unit/fixtures/folders.yml +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/folders.yml @@ -105,6 +105,17 @@ views: includes: - users_city - users_renamed_in_view3_gender + - name: test_view4 + extends: test_view3 + folders: + - name: folder3 + includes: + - users_city + - name: inner folder 4 + includes: + - renamed_orders_status + - name: inner folder 5 + includes: "*" # - name: empty_view # cubes: diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/folders_invalid_path.yml b/packages/cubejs-schema-compiler/test/unit/fixtures/folders_invalid_path.yml index b9df376e84aa5..5325cda75af95 100644 --- a/packages/cubejs-schema-compiler/test/unit/fixtures/folders_invalid_path.yml +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/folders_invalid_path.yml @@ -72,6 +72,9 @@ views: - age - renamed_gender - users.age + - name: inner folder + includes: + - users.renamed_gender - name: folder2 includes: '*' - name: test_view2 diff --git a/packages/cubejs-schema-compiler/test/unit/folders.test.ts b/packages/cubejs-schema-compiler/test/unit/folders.test.ts index adf699f8059fc..567a23955ca87 100644 --- a/packages/cubejs-schema-compiler/test/unit/folders.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/folders.test.ts @@ -43,6 +43,50 @@ describe('Cube Folders', () => { ); }); + it('a nested folders with some * and named members', async () => { + const testView = metaTransformer.cubes.find( + (it) => it.config.name === 'test_view4' + ); + + expect(testView.config.folders.length).toBe(3); + + const folder1 = testView.config.folders.find( + (it) => it.name === 'folder1' + ); + expect(folder1.members).toEqual([ + 'test_view4.users_age', + 'test_view4.users_state', + 'test_view4.renamed_orders_status', + ]); + + const folder2 = testView.config.folders.find( + (it) => it.name === 'folder2' + ); + expect(folder2.members).toEqual( + expect.arrayContaining(['test_view4.users_city', 'test_view4.users_renamed_in_view3_gender']) + ); + + const folder3 = testView.config.folders.find( + (it) => it.name === 'folder3' + ); + expect(folder3.members.length).toBe(3); + expect(folder3.members[1]).toEqual( + { name: 'inner folder 4', members: ['test_view4.renamed_orders_status'] } + ); + expect(folder3.members[2].name).toEqual('inner folder 5'); + expect(folder3.members[2].members).toEqual([ + 'test_view4.renamed_orders_count', + 'test_view4.renamed_orders_id', + 'test_view4.renamed_orders_number', + 'test_view4.renamed_orders_status', + 'test_view4.users_age', + 'test_view4.users_state', + 'test_view4.users_gender', + 'test_view4.users_city', + 'test_view4.users_renamed_in_view3_gender', + ]); + }); + it('folders from view extending other view', async () => { const view2 = metaTransformer.cubes.find( (it) => it.config.name === 'test_view2' @@ -93,6 +137,7 @@ describe('Cube Folders', () => { throw new Error('should throw earlier'); } catch (e: any) { expect(e.toString()).toMatch(/Paths aren't allowed in the 'folders' but 'users.age' has been provided for test_view/); + expect(e.toString()).toMatch(/Paths aren't allowed in the 'folders' but 'users.renamed_gender' has been provided for test_view/); expect(e.toString()).toMatch(/Member 'users.age' included in folder 'folder1' not found/); } }); From 774f91ac2296361bee3575e0ab34ec02062f478a Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Thu, 5 Jun 2025 16:20:40 +0300 Subject: [PATCH 05/15] fix open api spec --- packages/cubejs-api-gateway/openspec.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/cubejs-api-gateway/openspec.yml b/packages/cubejs-api-gateway/openspec.yml index d97a857d9d0bd..912d650ad0379 100644 --- a/packages/cubejs-api-gateway/openspec.yml +++ b/packages/cubejs-api-gateway/openspec.yml @@ -175,6 +175,21 @@ components: type: array items: type: "string" + V1CubeMetaNestedFolder: + type: "object" + required: + - name + - members + properties: + name: + type: "string" + members: + type: array + items: + type: "string" + oneOf: + - type: string + - $ref: "#/components/schemas/V1CubeMetaNestedFolder" V1CubeMetaHierarchy: type: "object" required: @@ -231,6 +246,10 @@ components: type: "array" items: $ref: "#/components/schemas/V1CubeMetaFolder" + nestedFolders: + type: "array" + items: + $ref: "#/components/schemas/V1CubeMetaNestedFolder" hierarchies: type: "array" items: From b02407662d4808230a7d63bbd17198c9c14ee6e4 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Fri, 20 Jun 2025 17:23:20 +0300 Subject: [PATCH 06/15] Fix TCubeFolder type fix/add TCubeNestedFolder type in client --- packages/cubejs-client-core/src/types.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cubejs-client-core/src/types.ts b/packages/cubejs-client-core/src/types.ts index 1d408fd4aabb9..3ecc43befa139 100644 --- a/packages/cubejs-client-core/src/types.ts +++ b/packages/cubejs-client-core/src/types.ts @@ -417,6 +417,11 @@ export type TCubeFolder = { members: string[]; }; +export type TCubeNestedFolder = { + name: string; + members: (string | TCubeNestedFolder)[]; +}; + export type TCubeHierarchy = { name: string; title?: string; @@ -451,6 +456,7 @@ export type Cube = { dimensions: TCubeDimension[]; segments: TCubeSegment[]; folders: TCubeFolder[]; + nestedFolders: TCubeNestedFolder[]; hierarchies: TCubeHierarchy[]; connectedComponent?: number; type?: 'view' | 'cube'; From 704d8806ced8fb5cbf1d0dc4f38ae17cb9d18f70 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Thu, 26 Jun 2025 20:53:26 +0300 Subject: [PATCH 07/15] Temporary marked folder members as strings --- .../components/SidePanelCubeItem.tsx | 4 ++- .../src/QueryBuilderV2/hooks/query-builder.ts | 28 ++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/SidePanelCubeItem.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/SidePanelCubeItem.tsx index a30faba2e4227..5debf31ad0342 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/SidePanelCubeItem.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/SidePanelCubeItem.tsx @@ -147,7 +147,9 @@ export function SidePanelCubeItem(props: CubeListItemProps) { const [openHierarchies, setOpenHierarchies] = useState([]); const folderMembers = folders.reduce((acc, folder) => { - return acc.concat(folder.members); + // FIXME: Temporary marked folder members as strings + // It should be aware of recursive folders structure + return acc.concat(folder.members as string[]); }, [] as string[]); const hierarchyMembers = hierarchies.reduce((acc, hierarchy) => { return acc.concat(hierarchy.levels); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/hooks/query-builder.ts b/packages/cubejs-playground/src/QueryBuilderV2/hooks/query-builder.ts index 68e925de37f86..af4e91a9082bc 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/hooks/query-builder.ts +++ b/packages/cubejs-playground/src/QueryBuilderV2/hooks/query-builder.ts @@ -1286,30 +1286,32 @@ export function useQueryBuilder(props: UseQueryBuilderProps) { const folderStats = stats.folders[folderName]; + // FIXME: Temporary marked folder members as strings + // It should be aware of recursive folders structure folder.members.forEach((memberName) => { - if (stats.dimensions.includes(memberName)) { - if (!folderStats.dimensions.includes(memberName)) { - folderStats.dimensions.push(memberName); + if (stats.dimensions.includes(memberName as string)) { + if (!folderStats.dimensions.includes(memberName as string)) { + folderStats.dimensions.push(memberName as string); } - } else if (stats.measures.includes(memberName)) { - if (!folderStats.measures.includes(memberName)) { - folderStats.measures.push(memberName); + } else if (stats.measures.includes(memberName as string)) { + if (!folderStats.measures.includes(memberName as string)) { + folderStats.measures.push(memberName as string); } - } else if (stats.segments.includes(memberName)) { - if (!folderStats.segments.includes(memberName)) { - folderStats.segments.push(memberName); + } else if (stats.segments.includes(memberName as string)) { + if (!folderStats.segments.includes(memberName as string)) { + folderStats.segments.push(memberName as string); } - } else if (stats.hierarchies[memberName]) { + } else if (stats.hierarchies[memberName as string]) { // add all selected dimensions from the hierarchy - stats.hierarchies[memberName].forEach((levelMemberName) => { + stats.hierarchies[memberName as string].forEach((levelMemberName) => { if (!folderStats.dimensions.includes(levelMemberName)) { folderStats.dimensions.push(levelMemberName); } }); } - if (grouping.includes(memberName)) { - folderStats.grouping.push(memberName); + if (grouping.includes(memberName as string)) { + folderStats.grouping.push(memberName as string); } }); }); From cf0af32cab3378254dab6ad9c027768f36f9955f Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 2 Jul 2025 13:18:06 +0300 Subject: [PATCH 08/15] Revert "Temporary marked folder members as strings" This reverts commit f7101f38b5ffc5821ee44999afe4bb7f25c2c925. --- .../components/SidePanelCubeItem.tsx | 4 +-- .../src/QueryBuilderV2/hooks/query-builder.ts | 28 +++++++++---------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/SidePanelCubeItem.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/SidePanelCubeItem.tsx index 5debf31ad0342..a30faba2e4227 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/SidePanelCubeItem.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/SidePanelCubeItem.tsx @@ -147,9 +147,7 @@ export function SidePanelCubeItem(props: CubeListItemProps) { const [openHierarchies, setOpenHierarchies] = useState([]); const folderMembers = folders.reduce((acc, folder) => { - // FIXME: Temporary marked folder members as strings - // It should be aware of recursive folders structure - return acc.concat(folder.members as string[]); + return acc.concat(folder.members); }, [] as string[]); const hierarchyMembers = hierarchies.reduce((acc, hierarchy) => { return acc.concat(hierarchy.levels); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/hooks/query-builder.ts b/packages/cubejs-playground/src/QueryBuilderV2/hooks/query-builder.ts index af4e91a9082bc..68e925de37f86 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/hooks/query-builder.ts +++ b/packages/cubejs-playground/src/QueryBuilderV2/hooks/query-builder.ts @@ -1286,32 +1286,30 @@ export function useQueryBuilder(props: UseQueryBuilderProps) { const folderStats = stats.folders[folderName]; - // FIXME: Temporary marked folder members as strings - // It should be aware of recursive folders structure folder.members.forEach((memberName) => { - if (stats.dimensions.includes(memberName as string)) { - if (!folderStats.dimensions.includes(memberName as string)) { - folderStats.dimensions.push(memberName as string); + if (stats.dimensions.includes(memberName)) { + if (!folderStats.dimensions.includes(memberName)) { + folderStats.dimensions.push(memberName); } - } else if (stats.measures.includes(memberName as string)) { - if (!folderStats.measures.includes(memberName as string)) { - folderStats.measures.push(memberName as string); + } else if (stats.measures.includes(memberName)) { + if (!folderStats.measures.includes(memberName)) { + folderStats.measures.push(memberName); } - } else if (stats.segments.includes(memberName as string)) { - if (!folderStats.segments.includes(memberName as string)) { - folderStats.segments.push(memberName as string); + } else if (stats.segments.includes(memberName)) { + if (!folderStats.segments.includes(memberName)) { + folderStats.segments.push(memberName); } - } else if (stats.hierarchies[memberName as string]) { + } else if (stats.hierarchies[memberName]) { // add all selected dimensions from the hierarchy - stats.hierarchies[memberName as string].forEach((levelMemberName) => { + stats.hierarchies[memberName].forEach((levelMemberName) => { if (!folderStats.dimensions.includes(levelMemberName)) { folderStats.dimensions.push(levelMemberName); } }); } - if (grouping.includes(memberName as string)) { - folderStats.grouping.push(memberName as string); + if (grouping.includes(memberName)) { + folderStats.grouping.push(memberName); } }); }); From 2620666dc6703ebb76654c3ec61729c930cbe2d9 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 2 Jul 2025 15:15:13 +0300 Subject: [PATCH 09/15] update meta transformer with flat and nested folders --- .../src/compiler/CubeToMetaTransformer.js | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js index 4915c435843f6..5008f3117422c 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js @@ -43,17 +43,34 @@ export class CubeToMetaTransformer { const isCubeVisible = this.isVisible(cube, true); - const processFolderMember = (member) => { - if (member.type === 'folder') { - return { - name: member.name, - members: member.includes.map(processFolderMember), - }; - } - - return `${cube.name}.${member.name}`; + const flatFolderSeparator = '/'; + const flatFolders = []; + + const processFolder = (folder, path = []) => { + const flatMembers = []; + const nestedMembers = folder.includes.map(member => { + if (member.type === 'folder') { + return processFolder(member, [...path, folder.name]); + } + const memberName = `${cube.name}.${member.name}`; + flatMembers.push(memberName); + + return `${cube.name}.${member.name}`; + }); + + flatFolders.push({ + name: [...path, folder.name].join(flatFolderSeparator), + members: flatMembers, + }); + + return { + name: folder.name, + members: nestedMembers, + }; }; + const nestedFolders = (cube.folders || []).map(f => processFolder(f)); + return { config: { name: cube.name, @@ -124,10 +141,8 @@ export class CubeToMetaTransformer { public: it.public ?? true, name: `${cube.name}.${it.name}`, })), - folders: (cube.folders || []).map((it) => ({ - name: it.name, - members: it.includes.map(processFolderMember), - })), + folders: flatFolders, + nestedFolders, }, }; } From 46b6ac5cca6cf4496e1a127a05b465b9f22de81e Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 2 Jul 2025 15:42:43 +0300 Subject: [PATCH 10/15] add CUBEJS_NESTED_FOLDERS_DELIMITER env and related flow --- packages/cubejs-backend-shared/src/env.ts | 3 +++ .../src/compiler/CubeToMetaTransformer.js | 26 +++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/cubejs-backend-shared/src/env.ts b/packages/cubejs-backend-shared/src/env.ts index f3f37fd0c7d39..647f9b1eb17db 100644 --- a/packages/cubejs-backend-shared/src/env.ts +++ b/packages/cubejs-backend-shared/src/env.ts @@ -240,6 +240,9 @@ const variables: Record any> = { transpilationNative: () => get('CUBEJS_TRANSPILATION_NATIVE') .default('false') .asBoolStrict(), + nestedFoldersDelimiter: () => get('CUBEJS_NESTED_FOLDERS_DELIMITER') + .default('') + .asString(), /** **************************************************************** * Common db options * diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js index 5008f3117422c..d9cba5b8e1103 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js @@ -2,6 +2,7 @@ import inflection from 'inflection'; import R from 'ramda'; import camelCase from 'camelcase'; +import { getEnv } from '@cubejs-backend/shared'; import { CubeSymbols } from './CubeSymbols'; import { UserError } from './UserError'; import { BaseMeasure } from '../adapter'; @@ -43,25 +44,34 @@ export class CubeToMetaTransformer { const isCubeVisible = this.isVisible(cube, true); - const flatFolderSeparator = '/'; + const flatFolderSeparator = getEnv('nestedFoldersDelimiter'); const flatFolders = []; - const processFolder = (folder, path = []) => { + const processFolder = (folder, path = [], mergedMembers = []) => { const flatMembers = []; const nestedMembers = folder.includes.map(member => { if (member.type === 'folder') { - return processFolder(member, [...path, folder.name]); + return processFolder(member, [...path, folder.name], flatMembers); } const memberName = `${cube.name}.${member.name}`; flatMembers.push(memberName); - return `${cube.name}.${member.name}`; + return memberName; }); - flatFolders.push({ - name: [...path, folder.name].join(flatFolderSeparator), - members: flatMembers, - }); + if (flatFolderSeparator !== '') { + flatFolders.push({ + name: [...path, folder.name].join(flatFolderSeparator), + members: flatMembers, + }); + } else if (path.length > 0) { + mergedMembers.push(...flatMembers); + } else { // We're at the root level + flatFolders.push({ + name: folder.name, + members: flatMembers, + }); + } return { name: folder.name, From 81dbb49247fb481c78a96b5f82da3d3812f46571 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 2 Jul 2025 15:44:02 +0300 Subject: [PATCH 11/15] regenerate cube rust openapi client --- .../cubeclient/.openapi-generator/FILES | 1 + rust/cubesql/cubeclient/src/models/mod.rs | 2 ++ .../cubeclient/src/models/v1_cube_meta.rs | 3 +++ .../src/models/v1_cube_meta_nested_folder.rs | 26 +++++++++++++++++++ rust/cubesql/cubesql/benches/large_model.rs | 1 + rust/cubesql/cubesql/src/compile/test/mod.rs | 7 +++++ rust/cubesql/cubesql/src/transport/ctx.rs | 2 ++ 7 files changed, 42 insertions(+) create mode 100644 rust/cubesql/cubeclient/src/models/v1_cube_meta_nested_folder.rs diff --git a/rust/cubesql/cubeclient/.openapi-generator/FILES b/rust/cubesql/cubeclient/.openapi-generator/FILES index a499650e840fd..eb8863fbc4d4b 100644 --- a/rust/cubesql/cubeclient/.openapi-generator/FILES +++ b/rust/cubesql/cubeclient/.openapi-generator/FILES @@ -8,6 +8,7 @@ src/models/v1_cube_meta_folder.rs src/models/v1_cube_meta_hierarchy.rs src/models/v1_cube_meta_join.rs src/models/v1_cube_meta_measure.rs +src/models/v1_cube_meta_nested_folder.rs src/models/v1_cube_meta_segment.rs src/models/v1_cube_meta_type.rs src/models/v1_error.rs diff --git a/rust/cubesql/cubeclient/src/models/mod.rs b/rust/cubesql/cubeclient/src/models/mod.rs index cb678c35165a0..8b83cf4497450 100644 --- a/rust/cubesql/cubeclient/src/models/mod.rs +++ b/rust/cubesql/cubeclient/src/models/mod.rs @@ -12,6 +12,8 @@ pub mod v1_cube_meta_join; pub use self::v1_cube_meta_join::V1CubeMetaJoin; pub mod v1_cube_meta_measure; pub use self::v1_cube_meta_measure::V1CubeMetaMeasure; +pub mod v1_cube_meta_nested_folder; +pub use self::v1_cube_meta_nested_folder::V1CubeMetaNestedFolder; pub mod v1_cube_meta_segment; pub use self::v1_cube_meta_segment::V1CubeMetaSegment; pub mod v1_cube_meta_type; diff --git a/rust/cubesql/cubeclient/src/models/v1_cube_meta.rs b/rust/cubesql/cubeclient/src/models/v1_cube_meta.rs index ddd19ed03bccd..24557b0eb2613 100644 --- a/rust/cubesql/cubeclient/src/models/v1_cube_meta.rs +++ b/rust/cubesql/cubeclient/src/models/v1_cube_meta.rs @@ -33,6 +33,8 @@ pub struct V1CubeMeta { pub joins: Option>, #[serde(rename = "folders", skip_serializing_if = "Option::is_none")] pub folders: Option>, + #[serde(rename = "nestedFolders", skip_serializing_if = "Option::is_none")] + pub nested_folders: Option>, #[serde(rename = "hierarchies", skip_serializing_if = "Option::is_none")] pub hierarchies: Option>, } @@ -56,6 +58,7 @@ impl V1CubeMeta { segments, joins: None, folders: None, + nested_folders: None, hierarchies: None, } } diff --git a/rust/cubesql/cubeclient/src/models/v1_cube_meta_nested_folder.rs b/rust/cubesql/cubeclient/src/models/v1_cube_meta_nested_folder.rs new file mode 100644 index 0000000000000..0ce0fb480b6a7 --- /dev/null +++ b/rust/cubesql/cubeclient/src/models/v1_cube_meta_nested_folder.rs @@ -0,0 +1,26 @@ +/* + * Cube.js + * + * Cube.js Swagger Schema + * + * The version of the OpenAPI document: 1.0.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct V1CubeMetaNestedFolder { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "members")] + pub members: Vec, +} + +impl V1CubeMetaNestedFolder { + pub fn new(name: String, members: Vec) -> V1CubeMetaNestedFolder { + V1CubeMetaNestedFolder { name, members } + } +} diff --git a/rust/cubesql/cubesql/benches/large_model.rs b/rust/cubesql/cubesql/benches/large_model.rs index be7d41f25bc6e..b4e46a2def264 100644 --- a/rust/cubesql/cubesql/benches/large_model.rs +++ b/rust/cubesql/cubesql/benches/large_model.rs @@ -93,6 +93,7 @@ pub fn get_large_model_test_meta(dims: usize) -> Vec { segments: vec![], joins: None, folders: None, + nested_folders: None, hierarchies: None, meta: None, }] diff --git a/rust/cubesql/cubesql/src/compile/test/mod.rs b/rust/cubesql/cubesql/src/compile/test/mod.rs index 9ded03081ede3..f6b3c6d83deb9 100644 --- a/rust/cubesql/cubesql/src/compile/test/mod.rs +++ b/rust/cubesql/cubesql/src/compile/test/mod.rs @@ -169,6 +169,7 @@ pub fn get_test_meta() -> Vec { relationship: "belongsTo".to_string(), }]), folders: None, + nested_folders: None, hierarchies: None, meta: None, }, @@ -220,6 +221,7 @@ pub fn get_test_meta() -> Vec { relationship: "belongsTo".to_string(), }]), folders: None, + nested_folders: None, hierarchies: None, meta: None, }, @@ -241,6 +243,7 @@ pub fn get_test_meta() -> Vec { segments: vec![], joins: None, folders: None, + nested_folders: None, hierarchies: None, meta: None, }, @@ -320,6 +323,7 @@ pub fn get_test_meta() -> Vec { segments: Vec::new(), joins: Some(Vec::new()), folders: None, + nested_folders: None, hierarchies: None, meta: None, }, @@ -438,6 +442,7 @@ pub fn get_test_meta() -> Vec { segments: Vec::new(), joins: Some(Vec::new()), folders: None, + nested_folders: None, hierarchies: None, meta: None, }, @@ -463,6 +468,7 @@ pub fn get_string_cube_meta() -> Vec { segments: vec![], joins: None, folders: None, + nested_folders: None, hierarchies: None, meta: None, }] @@ -507,6 +513,7 @@ pub fn get_sixteen_char_member_cube() -> Vec { segments: vec![], joins: None, folders: None, + nested_folders: None, hierarchies: None, meta: None, }] diff --git a/rust/cubesql/cubesql/src/transport/ctx.rs b/rust/cubesql/cubesql/src/transport/ctx.rs index 3aa40b97d2d1f..b2645034f204c 100644 --- a/rust/cubesql/cubesql/src/transport/ctx.rs +++ b/rust/cubesql/cubesql/src/transport/ctx.rs @@ -273,6 +273,7 @@ mod tests { segments: vec![], joins: None, folders: None, + nested_folders: None, hierarchies: None, meta: None, }, @@ -286,6 +287,7 @@ mod tests { segments: vec![], joins: None, folders: None, + nested_folders: None, hierarchies: None, meta: None, }, From 5a259052c32171e7420d182b3f5c9fb55e0991d6 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 2 Jul 2025 16:34:19 +0300 Subject: [PATCH 12/15] filter uniq folder members --- .../src/compiler/CubeToMetaTransformer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js index d9cba5b8e1103..b6a706feddaa3 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js @@ -69,7 +69,7 @@ export class CubeToMetaTransformer { } else { // We're at the root level flatFolders.push({ name: folder.name, - members: flatMembers, + members: [...new Set(flatMembers)], }); } From 934a645dc191405e0316b5e229b74f2fbf908ee1 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 2 Jul 2025 16:34:33 +0300 Subject: [PATCH 13/15] update tests --- .../test/unit/folders.test.ts | 107 +++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/cubejs-schema-compiler/test/unit/folders.test.ts b/packages/cubejs-schema-compiler/test/unit/folders.test.ts index 567a23955ca87..22b0bdb89257f 100644 --- a/packages/cubejs-schema-compiler/test/unit/folders.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/folders.test.ts @@ -43,7 +43,7 @@ describe('Cube Folders', () => { ); }); - it('a nested folders with some * and named members', async () => { + it('a nested folders with some * and named members (merged)', async () => { const testView = metaTransformer.cubes.find( (it) => it.config.name === 'test_view4' ); @@ -69,6 +69,111 @@ describe('Cube Folders', () => { const folder3 = testView.config.folders.find( (it) => it.name === 'folder3' ); + expect(folder3.members.length).toBe(9); + expect(folder3.members).toEqual([ + 'test_view4.users_city', + 'test_view4.renamed_orders_status', + 'test_view4.renamed_orders_count', + 'test_view4.renamed_orders_id', + 'test_view4.renamed_orders_number', + 'test_view4.users_age', + 'test_view4.users_state', + 'test_view4.users_gender', + 'test_view4.users_renamed_in_view3_gender', + ]); + }); + + it('a nested folders with some * and named members (flattened)', async () => { + process.env.CUBEJS_NESTED_FOLDERS_DELIMITER = '/'; + const modelContent = fs.readFileSync( + path.join(process.cwd(), '/test/unit/fixtures/folders.yml'), + 'utf8' + ); + const prepared = prepareYamlCompiler(modelContent); + const compilerL = prepared.compiler; + const metaTransformerL = prepared.metaTransformer; + + await compilerL.compile(); + + const testView = metaTransformerL.cubes.find( + (it) => it.config.name === 'test_view4' + ); + + expect(testView.config.folders.length).toBe(5); + + const folder1 = testView.config.folders.find( + (it) => it.name === 'folder1' + ); + expect(folder1.members).toEqual([ + 'test_view4.users_age', + 'test_view4.users_state', + 'test_view4.renamed_orders_status', + ]); + + const folder2 = testView.config.folders.find( + (it) => it.name === 'folder2' + ); + expect(folder2.members).toEqual( + expect.arrayContaining(['test_view4.users_city', 'test_view4.users_renamed_in_view3_gender']) + ); + + const folder3 = testView.config.folders.find( + (it) => it.name === 'folder3' + ); + expect(folder3.members.length).toBe(1); + expect(folder3.members).toEqual([ + 'test_view4.users_city', + ]); + + const folder4 = testView.config.folders.find( + (it) => it.name === 'folder3/inner folder 4' + ); + expect(folder4.members.length).toBe(1); + expect(folder4.members).toEqual(['test_view4.renamed_orders_status']); + + const folder5 = testView.config.folders.find( + (it) => it.name === 'folder3/inner folder 5' + ); + expect(folder5.members.length).toBe(9); + expect(folder5.members).toEqual([ + 'test_view4.renamed_orders_count', + 'test_view4.renamed_orders_id', + 'test_view4.renamed_orders_number', + 'test_view4.renamed_orders_status', + 'test_view4.users_age', + 'test_view4.users_state', + 'test_view4.users_gender', + 'test_view4.users_city', + 'test_view4.users_renamed_in_view3_gender', + ]); + }); + + it('a nested folders with some * and named members (nested)', async () => { + const testView = metaTransformer.cubes.find( + (it) => it.config.name === 'test_view4' + ); + + expect(testView.config.nestedFolders.length).toBe(3); + + const folder1 = testView.config.nestedFolders.find( + (it) => it.name === 'folder1' + ); + expect(folder1.members).toEqual([ + 'test_view4.users_age', + 'test_view4.users_state', + 'test_view4.renamed_orders_status', + ]); + + const folder2 = testView.config.nestedFolders.find( + (it) => it.name === 'folder2' + ); + expect(folder2.members).toEqual( + expect.arrayContaining(['test_view4.users_city', 'test_view4.users_renamed_in_view3_gender']) + ); + + const folder3 = testView.config.nestedFolders.find( + (it) => it.name === 'folder3' + ); expect(folder3.members.length).toBe(3); expect(folder3.members[1]).toEqual( { name: 'inner folder 4', members: ['test_view4.renamed_orders_status'] } From 634c358cbf8fc3807a98634d2880d4a848c34fe3 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 2 Jul 2025 17:01:02 +0300 Subject: [PATCH 14/15] update snapshots --- .../test/unit/__snapshots__/views.test.ts.snap | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cubejs-schema-compiler/test/unit/__snapshots__/views.test.ts.snap b/packages/cubejs-schema-compiler/test/unit/__snapshots__/views.test.ts.snap index 9811ebd942891..62cb9f9ea8335 100644 --- a/packages/cubejs-schema-compiler/test/unit/__snapshots__/views.test.ts.snap +++ b/packages/cubejs-schema-compiler/test/unit/__snapshots__/views.test.ts.snap @@ -89,6 +89,7 @@ Object { ], "meta": undefined, "name": "simple_view", + "nestedFolders": Array [], "public": true, "segments": Array [], "title": "Simple View", @@ -168,6 +169,7 @@ Object { ], "meta": undefined, "name": "simple_view", + "nestedFolders": Array [], "public": true, "segments": Array [], "title": "Simple View", @@ -281,6 +283,7 @@ Object { ], "meta": undefined, "name": "simple_view", + "nestedFolders": Array [], "public": true, "segments": Array [], "title": "Simple View", @@ -360,6 +363,7 @@ Object { ], "meta": undefined, "name": "simple_view", + "nestedFolders": Array [], "public": true, "segments": Array [], "title": "Simple View", From 61d2a011a92f026c62508fdd34e20d139f1bd271 Mon Sep 17 00:00:00 2001 From: Igor Lukanin Date: Mon, 7 Jul 2025 18:20:08 +0200 Subject: [PATCH 15/15] docs: Nested folders --- docs/pages/product/apis-integrations.mdx | 10 +- .../apis-integrations/rest-api/reference.mdx | 5 +- .../reference/environment-variables.mdx | 17 +++ docs/pages/product/data-modeling/concepts.mdx | 17 ++- .../product/data-modeling/reference/view.mdx | 114 ++++++++++++++++++ 5 files changed, 154 insertions(+), 9 deletions(-) diff --git a/docs/pages/product/apis-integrations.mdx b/docs/pages/product/apis-integrations.mdx index 9edd6c61261cd..274b9457f4fff 100644 --- a/docs/pages/product/apis-integrations.mdx +++ b/docs/pages/product/apis-integrations.mdx @@ -45,10 +45,11 @@ for an unofficial, community-maintained [client library for Python](https://gith Support for data modeling features differ across APIs, integrations, and [visualization tools][ref-viz-tools]. Some of the features with partial support are listed below: -| Feature | ✅ Supported in | ❌ Not supported in | -| --- | --- | --- | -| [Hierarchies][ref-hierarchies] | [Microsoft Power BI][ref-powerbi] via the [DAX API][ref-dax-api]
[Cube Cloud for Excel][ref-cube-cloud-for-excel]
[Cube Cloud for Sheets][ref-cube-cloud-for-sheets]
[Tableau][ref-tableau] via [Semantic Layer Sync][ref-sls] | All other tools | -| [Folders][ref-folders] | [Microsoft Power BI][ref-powerbi] via the [DAX API][ref-dax-api]
[Cube Cloud for Excel][ref-cube-cloud-for-excel]
[Cube Cloud for Sheets][ref-cube-cloud-for-sheets]
[Tableau][ref-tableau] via [Semantic Layer Sync][ref-sls]
[Apache Superset][ref-superset] via [Semantic Layer Sync][ref-sls]
[Preset][ref-preset] via [Semantic Layer Sync][ref-sls] | All other tools | +| Feature | ✅ Supported in | +| --- | --- | +| [Hierarchies][ref-hierarchies] | [Microsoft Power BI][ref-powerbi] via the [DAX API][ref-dax-api]
[Cube Cloud for Excel][ref-cube-cloud-for-excel]
[Cube Cloud for Sheets][ref-cube-cloud-for-sheets]
[Tableau][ref-tableau] via [Semantic Layer Sync][ref-sls]

Also, supported in [Playground][ref-playground] | +| Flat [folders][ref-folders] | [Microsoft Power BI][ref-powerbi] via the [DAX API][ref-dax-api]
[Cube Cloud for Excel][ref-cube-cloud-for-excel]
[Cube Cloud for Sheets][ref-cube-cloud-for-sheets]
[Tableau][ref-tableau] via [Semantic Layer Sync][ref-sls]
[Apache Superset][ref-superset] via [Semantic Layer Sync][ref-sls]
[Preset][ref-preset] via [Semantic Layer Sync][ref-sls]

Also, supported in [Playground][ref-playground] | +| Nested [folders][ref-folders] | Currently, not supported in any tool | ### Authentication methods @@ -96,3 +97,4 @@ API][ref-orchestration-api]. [ref-auth-ntlm]: /product/auth/methods/ntlm [ref-superset]: /product/configuration/visualization-tools/superset [ref-preset]: /product/configuration/visualization-tools/superset +[ref-playground]: /product/workspace/playground \ No newline at end of file diff --git a/docs/pages/product/apis-integrations/rest-api/reference.mdx b/docs/pages/product/apis-integrations/rest-api/reference.mdx index 8715241bdd8bb..ab775c43b7ca9 100644 --- a/docs/pages/product/apis-integrations/rest-api/reference.mdx +++ b/docs/pages/product/apis-integrations/rest-api/reference.mdx @@ -254,7 +254,7 @@ Response - `dimensions` - Array of dimensions in this cube/view - `hierarchies` - Array of hierarchies in this cube - `segments` - Array of segments in this cube/view - - `folders` - Array of folders in this view + - `folders` and `nestedFolders` - Arrays of flat and nested [folder][ref-folders] structures in this view, respectively - `connectedComponent` - An integer representing a join relationship. If the same value is returned for two cubes, then there is at least one join path between them. @@ -638,4 +638,5 @@ Keep-Alive: timeout=5 [ref-query-wpp]: /product/apis-integrations/queries#query-with-post-processing [ref-query-wpd]: /product/apis-integrations/queries#query-with-pushdown [ref-sql-api]: /product/apis-integrations/sql-api -[ref-orchestration-api]: /product/apis-integrations/orchestration-api \ No newline at end of file +[ref-orchestration-api]: /product/apis-integrations/orchestration-api +[ref-folders]: /product/data-modeling/reference/view#folders \ No newline at end of file diff --git a/docs/pages/product/configuration/reference/environment-variables.mdx b/docs/pages/product/configuration/reference/environment-variables.mdx index 229faa92433fa..33c134364dba2 100644 --- a/docs/pages/product/configuration/reference/environment-variables.mdx +++ b/docs/pages/product/configuration/reference/environment-variables.mdx @@ -1297,6 +1297,21 @@ If `true`, the DAX API will expose time dimensions as calendar hierarchies. | --------------- | ---------------------- | --------------------- | | `true`, `false` | `true` | `true` | +## `CUBEJS_NESTED_FOLDERS_DELIMITER` + +Specifies the delimiter used to flatten the names of nested [folder][ref-folders] in +views when [visualization tools][ref-dataviz-tools] do not support nested folder +structures. When set, nested folders will be presented at the root level with path-like +names using the specified delimiter. + +| Possible Values | Default in Development | Default in Production | +| --------------- | ---------------------- | --------------------- | +| A valid string delimiter (e.g., ` / `) | N/A | N/A | + +For example, if set to `/`, a nested folder structure like the `Customer Information` +folder with the `Personal Details` subfolder will be flattened to `Customer +Information / Personal Details` at the root level. + ## `CUBEJS_TELEMETRY` If `true`, then send telemetry to Cube. @@ -1781,3 +1796,5 @@ The port for a Cube deployment to listen to API connections on. [ref-schema-ref-preagg-allownonstrict]: /product/data-modeling/reference/pre-aggregations#allow_non_strict_date_range_match [link-tesseract]: https://cube.dev/blog/introducing-next-generation-data-modeling-engine [ref-multi-stage-calculations]: /product/data-modeling/concepts/multi-stage-calculations +[ref-folders]: /product/data-modeling/reference/view#folders +[ref-dataviz-tools]: /product/configuration/visualization-tools \ No newline at end of file diff --git a/docs/pages/product/data-modeling/concepts.mdx b/docs/pages/product/data-modeling/concepts.mdx index ae3ea87e8e44d..698f4f2e040a1 100644 --- a/docs/pages/product/data-modeling/concepts.mdx +++ b/docs/pages/product/data-modeling/concepts.mdx @@ -131,8 +131,7 @@ paths. Views do **not** define their own members. Instead, they reference cubes by -specific join paths and include their members. Optionally, you can also group -members of a view into [folders][ref-ref-folders]. +specific join paths and include their members. In the example below, we create the `orders` view which includes select members from `base_orders`, `products`, and `users` cubes: @@ -212,6 +211,16 @@ See the reference documentaton for the full list of view [parameters][ref-views] +### Folders + +Optionally, members of a view can be organized into [folders][ref-ref-folders]. +Each folder would contain a subset of members of the view. + +Cube supports both flat and nested folder structures, which can be used with various +[visualization tools][ref-viz-tools]. If a specific tool does not support nested folders, +they will be exposed to such a tool as an equivalent flat structure. Check [APIs & +Integrations][ref-apis-support] for details on the nested folders support. + ## Dimensions _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 [ref-avg-and-percentile-recipe]: /product/data-modeling/recipes/percentiles [ref-period-over-period-recipe]: /product/data-modeling/recipes/period-over-period [ref-custom-calendar-recipe]: /product/data-modeling/recipes/custom-calendar -[ref-cube-with-dbt]: /product/data-modeling/recipes/dbt \ No newline at end of file +[ref-cube-with-dbt]: /product/data-modeling/recipes/dbt +[ref-apis-support]: /product/apis-integrations#data-modeling +[ref-viz-tools]: /product/configuration/visualization-tools \ No newline at end of file diff --git a/docs/pages/product/data-modeling/reference/view.mdx b/docs/pages/product/data-modeling/reference/view.mdx index 41d0f566fc017..28ba3eb2b5fe1 100644 --- a/docs/pages/product/data-modeling/reference/view.mdx +++ b/docs/pages/product/data-modeling/reference/view.mdx @@ -441,6 +441,120 @@ views: +Nested folders are also supported. The `includes` parameter can contain not only +references to view members but also other folders: + + + +```javascript +view(`customers`, { + cubes: [ + { + join_path: `users`, + includes: `*` + }, + { + join_path: `users.orders`, + prefix: true, + includes: [ + `status`, + `price`, + `count` + ] + } + ], + + folders: [ + { + name: `Customer Information`, + includes: [ + { + name: `Personal Details`, + includes: [ + `name`, + `gender` + ] + }, + { + name: `Location`, + includes: [ + `address`, + `postal_code`, + `city` + ] + } + ] + }, + { + name: `Order Analytics`, + includes: [ + `orders_status`, + `orders_price`, + { + name: `Metrics`, + includes: [ + `orders_count`, + `orders_average_value` + ] + } + ] + } + ] +}) +``` + +```yaml +views: + - name: customers + + cubes: + - join_path: users + includes: "*" + + - join_path: users.orders + prefix: true + includes: + - status + - price + - count + + folders: + - name: Customer Information + includes: + - name: Personal Details + includes: + - name + - gender + + - name: Location + includes: + - address + - postal_code + - city + + - name: Order Analytics + includes: + - orders_status + - orders_price + + - name: Metrics + includes: + - orders_count + - orders_average_value +``` + + + +You can still define nested folders in the data model even if some of your [visualization +tools][ref-viz-tools] do not support them. Check [APIs & Integrations][ref-apis-support] +for details on the nested folders support. + +For tools that do not support nested folders, the nested structure will be flattened: +by default, the members of nested folders are merged into folders at the root level. +You can also set the `CUBEJS_NESTED_FOLDERS_DELIMITER` environment variable to preserve +nested folders and give them path-like names, e.g., `Customer Information / Personal +Details`. + ### `access_policy` The `access_policy` parameter is used to configure [data access policies][ref-ref-dap].