diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index ca2eefe4ca7df..f1bfe0e562677 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -112,7 +112,8 @@ export class CubeEvaluator extends CubeSymbols { this.evaluateMultiStageReferences(cube.name, cube.measures); this.evaluateMultiStageReferences(cube.name, cube.dimensions); - this.prepareHierarchies(cube); + this.prepareHierarchies(cube, errorReporter); + this.prepareFolders(cube, errorReporter); this.prepareAccessPolicy(cube, errorReporter); @@ -179,36 +180,91 @@ export class CubeEvaluator extends CubeSymbols { } } - private prepareHierarchies(cube: any) { + private prepareFolders(cube: any, errorReporter: ErrorReporter) { + if (Array.isArray(cube.folders)) { + cube.folders = cube.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}` + ); + } + + const member = cube.includedMembers.find(m => m.name === memberName); + if (!member) { + errorReporter.error( + `Member '${memberName}' included in folder '${it.name}' not found` + ); + return null; + } + + return member; + }) + .filter(Boolean); + + return ({ + ...it, + includes + }); + }); + } + + return []; + } + + private prepareHierarchies(cube: any, errorReporter: ErrorReporter) { + const uniqueHierarchyNames = new Set(); if (Array.isArray(cube.hierarchies)) { - cube.hierarchies = cube.hierarchies.map(hierarchy => ({ - ...hierarchy, - levels: this.evaluateReferences( - cube.name, hierarchy.levels, { originalSorting: true } - ) - })); + cube.hierarchies = cube.hierarchies.map(hierarchy => { + if (uniqueHierarchyNames.has(hierarchy.name)) { + errorReporter.error(`Duplicate hierarchy name '${hierarchy.name}' in cube '${cube.name}'`); + } + uniqueHierarchyNames.add(hierarchy.name); + + return ({ + ...hierarchy, + levels: this.evaluateReferences( + cube.name, + hierarchy.levels, + { originalSorting: true } + ) + }); + }); } if (cube.isView && (cube.includedMembers || []).length) { - const includedCubeNames: string[] = R.uniq(cube.includedMembers.map(it => it.memberPath.split('.')[0])); const includedMemberPaths: string[] = R.uniq(cube.includedMembers.map(it => it.memberPath)); - - if (!cube.hierarchies) { - for (const cubeName of includedCubeNames) { - const { hierarchies } = this.evaluatedCubes[cubeName] || {}; - - if (Array.isArray(hierarchies) && hierarchies.length) { - const filteredHierarchies = hierarchies.map(it => { - const levels = it.levels.filter(level => includedMemberPaths.includes(level)); + const includedCubeNames: string[] = R.uniq(includedMemberPaths.map(it => it.split('.')[0])); + const includedHierarchyNames = cube.includedMembers.filter(it => it.type === 'hierarchies').map(it => it.memberPath.split('.')[1]); + + for (const cubeName of includedCubeNames) { + const { hierarchies } = this.evaluatedCubes[cubeName] || {}; + + if (Array.isArray(hierarchies) && hierarchies.length) { + const filteredHierarchies = hierarchies + .filter(it => includedHierarchyNames.includes(it.name)) + .map(it => { + const levels = it.levels.filter(level => { + const member = cube.includedMembers.find(m => m.memberPath === level); + if (member && member.type !== 'dimensions') { + const memberName = level.split('.')[1] || level; + errorReporter.error(`Only dimensions can be part of a hierarchy. Please remove the '${memberName}' member from the '${it.name}' hierarchy.`); + } else if (member) { + return includedMemberPaths.includes(level); + } + + return null; + }).filter(Boolean); return { ...it, levels }; - }).filter(it => it.levels.length); + }) + .filter(it => it.levels.length); - cube.hierarchies = [...(cube.hierarchies || []), ...filteredHierarchies]; - } + cube.hierarchies = [...(cube.hierarchies || []), ...filteredHierarchies]; } } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.js b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.js index ee8b0ab6e333f..28c99383ae1fc 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.js +++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.js @@ -226,9 +226,15 @@ export class CubeSymbols { return; } - const types = ['measures', 'dimensions', 'segments']; + const memberSets = { + resolvedMembers: new Set(), + allMembers: new Set(), + }; + + const types = ['measures', 'dimensions', 'segments', 'hierarchies']; for (const type of types) { - const cubeIncludes = cube.cubes && this.membersFromCubes(cube, cube.cubes, type, errorReporter, splitViews) || []; + const cubeIncludes = cube.cubes && this.membersFromCubes(cube, cube.cubes, type, errorReporter, splitViews, memberSets) || []; + const includes = cube.includes && this.membersFromIncludeExclude(cube.includes, cube.name, type) || []; const excludes = cube.excludes && this.membersFromIncludeExclude(cube.excludes, cube.name, type) || []; @@ -245,18 +251,23 @@ export class CubeSymbols { const split = it.member.split('.'); const memberPath = this.pathFromArray([split[split.length - 2], split[split.length - 1]]); return { + type, memberPath, name: it.name }; })))]; } + + [...memberSets.allMembers].filter(it => !memberSets.resolvedMembers.has(it)).forEach(it => { + errorReporter.error(`Member '${it}' is included in '${cube.name}' but not defined in any cube`); + }); } applyIncludeMembers(includeMembers, cube, type, errorReporter) { for (const [memberName, memberDefinition] of includeMembers) { if (cube[type]?.[memberName]) { errorReporter.error(`Included member '${memberName}' conflicts with existing member of '${cube.name}'. Please consider excluding this member.`); - } else { + } else if (type !== 'hierarchies') { cube[type][memberName] = memberDefinition; } } @@ -265,7 +276,7 @@ export class CubeSymbols { /** * @protected */ - membersFromCubes(parentCube, cubes, type, errorReporter, splitViews) { + membersFromCubes(parentCube, cubes, type, errorReporter, splitViews, memberSets) { return R.unnest(cubes.map(cubeInclude => { const fullPath = this.evaluateReferences(null, cubeInclude.joinPath, { collectJoinHints: true }); const split = fullPath.split('.'); @@ -277,37 +288,43 @@ export class CubeSymbols { if (cubeInclude.includes === '*') { const membersObj = this.symbols[cubeReference]?.cubeObj()?.[type] || {}; - includes = Object.keys(membersObj).map(memberName => ({ member: `${fullPath}.${memberName}`, name: fullMemberName(memberName) })); + if (Array.isArray(membersObj)) { + includes = membersObj.map(it => ({ member: `${fullPath}.${it.name}`, name: fullMemberName(it.name) })); + } else { + includes = Object.keys(membersObj).map(memberName => ({ member: `${fullPath}.${memberName}`, name: fullMemberName(memberName) })); + } } else { includes = cubeInclude.includes.map(include => { const member = include.alias || include; - if (member.indexOf('.') !== -1) { + + if (member.includes('.')) { errorReporter.error(`Paths aren't allowed in cube includes but '${member}' provided as include member`); } const name = fullMemberName(include.alias || member); - if (include.name) { - const resolvedMember = this.symbols[cubeReference]?.cubeObj()?.[type]?.[include.name]; - return resolvedMember ? { - member: `${fullPath}.${include.name}`, - name, - } : undefined; - } else { - const resolvedMember = this.symbols[cubeReference]?.cubeObj()?.[type]?.[include]; - return resolvedMember ? { - member: `${fullPath}.${include}`, - name - } : undefined; + memberSets.allMembers.add(name); + + const includedMemberName = include.name || include; + + const resolvedMember = this.getResolvedMember(type, cubeReference, includedMemberName) ? { + member: `${fullPath}.${includedMemberName}`, + name, + } : undefined; + + if (resolvedMember) { + memberSets.resolvedMembers.add(name); } + + return resolvedMember; }); } const excludes = (cubeInclude.excludes || []).map(exclude => { - if (exclude.indexOf('.') !== -1) { + if (exclude.includes('.')) { errorReporter.error(`Paths aren't allowed in cube excludes but '${exclude}' provided as exclude member`); } - const resolvedMember = this.symbols[cubeReference]?.cubeObj()?.[type]?.[exclude]; + const resolvedMember = this.getResolvedMember(type, cubeReference, exclude); return resolvedMember ? { member: `${fullPath}.${exclude}` } : undefined; @@ -356,7 +373,7 @@ export class CubeSymbols { const membersObj = this.symbols[path[0]]?.cubeObj()?.[type] || {}; return Object.keys(membersObj).map(memberName => ({ member: `${ref}.${memberName}` })); } else if (path.length === 2) { - const resolvedMember = this.symbols[path[0]]?.cubeObj()?.[type]?.[path[1]]; + const resolvedMember = this.getResolvedMember(type, path[0], path[1]); return resolvedMember ? [{ member: ref }] : undefined; } else { throw new Error(`Unexpected path length ${path.length} for ${ref}`); @@ -364,13 +381,24 @@ export class CubeSymbols { })).filter(Boolean); } + /** + * @protected + */ + getResolvedMember(type, cubeName, memberName) { + if (Array.isArray(this.symbols[cubeName]?.cubeObj()?.[type])) { + return this.symbols[cubeName]?.cubeObj()?.[type]?.find((it) => it.name === memberName); + } + + return this.symbols[cubeName]?.cubeObj()?.[type]?.[memberName]; + } + /** * @protected */ generateIncludeMembers(members, cubeName, type) { return members.map(memberRef => { const path = memberRef.member.split('.'); - const resolvedMember = this.symbols[path[path.length - 2]]?.cubeObj()?.[type]?.[path[path.length - 1]]; + const resolvedMember = this.getResolvedMember(type, path[path.length - 2], path[path.length - 1]); if (!resolvedMember) { throw new Error(`Can't resolve '${memberRef.member}' while generating include members`); } @@ -404,6 +432,11 @@ export class CubeSymbols { meta: resolvedMember.meta, description: resolvedMember.description, }; + } else if (type === 'hierarchies') { + memberDefinition = { + title: resolvedMember.title, + levels: resolvedMember.levels, + }; } else { throw new Error(`Unexpected member type: ${type}`); } @@ -458,6 +491,7 @@ export class CubeSymbols { name ); // eslint-disable-next-line no-underscore-dangle + // if (resolvedSymbol && resolvedSymbol._objectWithResolvedProperties) { if (resolvedSymbol._objectWithResolvedProperties) { return resolvedSymbol; } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js index 10edba0b87430..2e362ab085c38 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js @@ -106,7 +106,15 @@ export class CubeToMetaTransformer { })), R.toPairs )(cube.segments || {}), - hierarchies: cube.hierarchies || [] + hierarchies: (cube.hierarchies || []).map((it) => ({ + ...it, + public: it.public ?? true, + name: `${cube.name}.${it.name}`, + })), + folders: (cube.folders || []).map((it) => ({ + name: it.name, + members: it.includes.map(member => `${cube.name}.${member.name}`), + })), }, }; } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 1dd4bc256f82a..397aaaac7881e 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -745,10 +745,12 @@ const baseSchema = { )), segments: SegmentsSchema, preAggregations: PreAggregationsAlternatives, - hierarchies: Joi.array().items(Joi.object().keys({ + folders: Joi.array().items(Joi.object().keys({ name: Joi.string().required(), - title: Joi.string(), - levels: Joi.func() + includes: Joi.alternatives([ + Joi.string().valid('*'), + Joi.array().items(Joi.string().required()) + ]).required(), })), accessPolicy: Joi.array().items(RolePolicySchema.required()), }; @@ -756,6 +758,12 @@ const baseSchema = { const cubeSchema = inherit(baseSchema, { sql: Joi.func(), sqlTable: Joi.func(), + hierarchies: Joi.array().items(Joi.object().keys({ + name: identifier, + title: Joi.string(), + public: Joi.boolean().strict(), + levels: Joi.func() + })), }).xor('sql', 'sqlTable').messages({ 'object.xor': 'You must use either sql or sqlTable within a model, but not both' }); diff --git a/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts b/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts index d75087ddabbdf..f5e7320243d03 100644 --- a/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts @@ -131,6 +131,7 @@ export class YamlCompiler { cubeObj.segments = this.yamlArrayToObj(cubeObj.segments || [], 'segment', errorsReport); cubeObj.preAggregations = this.yamlArrayToObj(cubeObj.preAggregations || [], 'preAggregation', errorsReport); cubeObj.joins = this.yamlArrayToObj(cubeObj.joins || [], 'join', errorsReport); + // cubeObj.hierarchies = this.yamlArrayToObj(cubeObj.hierarchies || [], 'hierarchies', errorsReport); return this.transpileYaml(cubeObj, [], cubeObj.name, errorsReport); } 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 5f9f19f47aee3..9811ebd942891 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 @@ -40,6 +40,7 @@ Object { "type": "number", }, ], + "folders": Array [], "hierarchies": Array [], "isVisible": true, "measures": Array [ @@ -118,6 +119,7 @@ Object { "type": "number", }, ], + "folders": Array [], "hierarchies": Array [], "isVisible": true, "measures": Array [ @@ -230,6 +232,7 @@ Object { "type": "number", }, ], + "folders": Array [], "hierarchies": Array [], "isVisible": true, "measures": Array [ @@ -308,6 +311,7 @@ Object { "type": "number", }, ], + "folders": Array [], "hierarchies": Array [], "isVisible": true, "measures": Array [ diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/folders.yml b/packages/cubejs-schema-compiler/test/unit/fixtures/folders.yml new file mode 100644 index 0000000000000..c03356586533e --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/folders.yml @@ -0,0 +1,105 @@ +cubes: + - name: orders + sql: SELECT * FROM orders + joins: + - name: users + sql: "{CUBE}.order_id = {orders}.id" + relationship: many_to_one + measures: + - name: count + sql: id + type: count + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: number + sql: number + type: number + + - name: status + sql: status + type: string + + - name: city + sql: city + type: string + hierarchies: + - name: orders_hierarchy + levels: + - "{CUBE}.status" + - number + - users.city + - name: some_other_hierarchy + title: Some other hierarchy + levels: + - users.state + - users.city + # + - name: users + sql: SELECT * FROM users + hierarchies: + - name: users_hierarchy + levels: + - users.age + - city + dimensions: + - name: age + sql: age + type: number + - name: state + sql: state + type: string + - name: city + sql: city + type: string + - name: gender + sql: gender + type: string + +views: + - name: test_view + cubes: + - join_path: orders + includes: "*" + - join_path: users + includes: + - age + - state + - name: gender + alias: renamed_gender + folders: + - name: folder1 + includes: + - age + - renamed_gender + - name: folder2 + includes: '*' + - name: test_view2 + cubes: + - join_path: orders + alias: renamed_orders + prefix: true + includes: "*" + - join_path: users + prefix: true + includes: + - age + - state + folders: + - name: folder1 + includes: + - users_age + - users_state + - renamed_orders_status + # - name: empty_view + # cubes: + # - join_path: orders + # includes: + # - count + # - status + + + diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/hierarchies.yml b/packages/cubejs-schema-compiler/test/unit/fixtures/hierarchies.yml new file mode 100644 index 0000000000000..c7a4d7d49de22 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/hierarchies.yml @@ -0,0 +1,89 @@ +cubes: + - name: orders + sql: SELECT * FROM orders + joins: + - name: users + sql: "{CUBE}.order_id = {orders}.id" + relationship: many_to_one + measures: + - name: count + sql: id + type: count + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: number + sql: number + type: number + + - name: status + sql: status + type: string + + - name: city + sql: city + type: string + hierarchies: + - name: orders_hierarchy + title: Hello Hierarchy + levels: + - "{CUBE}.status" + - number + - users.city + - name: some_other_hierarchy + title: Some other hierarchy + levels: + - users.state + - users.city + # + - name: users + sql: SELECT * FROM users + hierarchies: + - name: users_hierarchy + levels: + - users.age + - city + dimensions: + - name: age + sql: age + type: number + - name: state + sql: state + type: string + - name: city + sql: city + type: string + +views: + - name: orders_users_view + cubes: + - join_path: orders + includes: "*" + - join_path: users + includes: + - age + - state + - name: orders_includes_excludes_view + cubes: + - join_path: orders + includes: "*" + excludes: + - some_other_hierarchy + - name: empty_view + cubes: + - join_path: orders + includes: + - count + - status + - name: all_hierarchy_view + cubes: + - join_path: orders + includes: "*" + - join_path: users + prefix: true + includes: "*" + + diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/hierarchy-with-measure.yml b/packages/cubejs-schema-compiler/test/unit/fixtures/hierarchy-with-measure.yml new file mode 100644 index 0000000000000..6693b8447a0c7 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/hierarchy-with-measure.yml @@ -0,0 +1,35 @@ +cubes: + - name: orders + sql: SELECT * FROM orders + measures: + - name: count + sql: id + type: count + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: status + sql: status + type: string + + - name: city + sql: city + type: string + hierarchies: + - name: correct_hierarchy + levels: + - status + - city + - name: orders_hierarchy + levels: + - "{CUBE}.status" + - count + +views: + - name: test_view + cubes: + - join_path: orders + includes: "*" diff --git a/packages/cubejs-schema-compiler/test/unit/folders.test.ts b/packages/cubejs-schema-compiler/test/unit/folders.test.ts new file mode 100644 index 0000000000000..00e93426e83f6 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/folders.test.ts @@ -0,0 +1,60 @@ +import fs from 'fs'; +import path from 'path'; + +import { CubeToMetaTransformer } from 'src/compiler/CubeToMetaTransformer'; +import { prepareYamlCompiler } from './PrepareCompiler'; + +describe('Cube Folders', () => { + let metaTransformer: CubeToMetaTransformer; + let compiler; + + beforeAll(async () => { + const modelContent = fs.readFileSync( + path.join(process.cwd(), '/test/unit/fixtures/folders.yml'), + 'utf8' + ); + const prepared = prepareYamlCompiler(modelContent); + compiler = prepared.compiler; + metaTransformer = prepared.metaTransformer; + + await compiler.compile(); + }); + + it('a folder with includes all and named members', async () => { + const emptyView = metaTransformer.cubes.find( + (it) => it.config.name === 'test_view' + ); + + expect(emptyView.config.folders.length).toBe(2); + + const folder1 = emptyView.config.folders.find( + (it) => it.name === 'folder1' + ); + expect(folder1.members).toEqual([ + 'test_view.age', + 'test_view.renamed_gender', + ]); + + const folder2 = emptyView.config.folders.find( + (it) => it.name === 'folder2' + ); + expect(folder2.members).toEqual( + expect.arrayContaining(['test_view.age', 'test_view.renamed_gender']) + ); + }); + + it('a folder with aliased and prefixed cubes', async () => { + const view = metaTransformer.cubes.find( + (it) => it.config.name === 'test_view2' + ); + + expect(view.config.folders.length).toBe(1); + + const folder1 = view.config.folders.find((it) => it.name === 'folder1'); + expect(folder1.members).toEqual([ + 'test_view2.users_age', + 'test_view2.users_state', + 'test_view2.renamed_orders_status', + ]); + }); +}); diff --git a/packages/cubejs-schema-compiler/test/unit/hierarchies.test.ts b/packages/cubejs-schema-compiler/test/unit/hierarchies.test.ts index af23edd22493c..2ed7c93dbd08d 100644 --- a/packages/cubejs-schema-compiler/test/unit/hierarchies.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/hierarchies.test.ts @@ -1,263 +1,105 @@ +import fs from 'fs'; +import path from 'path'; + import { prepareYamlCompiler } from './PrepareCompiler'; describe('Cube hierarchies', () => { - it('includes cube hierarchies', async () => { - const { compiler, metaTransformer } = prepareYamlCompiler(` -cubes: - - name: orders - sql: SELECT * FROM orders - joins: - - name: users - sql: "{CUBE}.order_id = {orders}.id" - relationship: many_to_one - measures: - - name: xxx - sql: xxx - type: number - dimensions: - - name: id - sql: id - type: number - primary_key: true - - - name: number - sql: number - type: number - - - name: status - sql: status - type: string - - - name: city - sql: city - type: string - hierarchies: - - name: orders_hierarchy - levels: - - orders.status - - users.state - - city - - name: Some other hierarchy - levels: - - users.state - - users.city - - name: users - sql: SELECT * FROM users - hierarchies: - - name: Users hierarchy - levels: - - users.age - - city - dimensions: - - name: age - sql: age - type: number - - name: state - sql: state - type: string - - name: city - sql: city - type: string - -views: - - name: orders_view - cubes: - - join_path: orders - includes: "*" - - join_path: users - includes: - - age - - state - - name: test_view - hierarchies: [] - cubes: - - join_path: orders - includes: "*" - - join_path: users - includes: - - age - - state - - name: empty_view - hierarchies: [] - cubes: - - join_path: users.orders - includes: - - number - `); + it('base cases', async () => { + const modelContent = fs.readFileSync( + path.join(process.cwd(), '/test/unit/fixtures/hierarchies.yml'), + 'utf8' + ); + const { compiler, metaTransformer } = prepareYamlCompiler(modelContent); await compiler.compile(); - const orders = metaTransformer.cubes.find(it => it.config.name === 'orders'); - expect(orders.config.hierarchies).toEqual([ - { - name: 'orders_hierarchy', - levels: [ - 'orders.status', - 'users.state', - 'orders.city' - ] - }, - { - name: 'Some other hierarchy', - levels: [ - 'users.state', - 'users.city' - ] - } - ]); + const ordersView = metaTransformer.cubes.find( + (it) => it.config.name === 'orders_users_view' + ); - const ordersView = metaTransformer.cubes.find(it => it.config.name === 'orders_view'); + expect(ordersView.config.hierarchies.length).toBe(2); expect(ordersView.config.hierarchies).toEqual([ { - name: 'orders_hierarchy', - levels: [ - 'orders_view.status', - 'orders_view.state', - 'orders_view.city', - ] - }, - { - name: 'Some other hierarchy', + name: 'orders_users_view.orders_hierarchy', + title: 'Hello Hierarchy', + public: true, levels: [ - 'orders_view.state' - ] + 'orders_users_view.status', + 'orders_users_view.number' + ], }, { - name: 'Users hierarchy', - levels: [ - 'orders_view.age' - ] + name: 'orders_users_view.some_other_hierarchy', + public: true, + title: 'Some other hierarchy', + levels: ['orders_users_view.state'] } ]); - const testView = metaTransformer.cubes.find(it => it.config.name === 'test_view'); - expect(testView.config.hierarchies.length).toBe(0); + const ordersIncludesExcludesView = metaTransformer.cubes.find( + (it) => it.config.name === 'orders_includes_excludes_view' + ); + expect(ordersIncludesExcludesView.config.hierarchies.length).toBe(1); - const emptyView = metaTransformer.cubes.find(it => it.config.name === 'empty_view'); + const emptyView = metaTransformer.cubes.find( + (it) => it.config.name === 'empty_view' + ); expect(emptyView.config.hierarchies.length).toBe(0); - }); - it('hierarchies defined on a view only', async () => { - const { compiler, metaTransformer } = prepareYamlCompiler(` -views: - - name: orders_view - cubes: - - join_path: orders - includes: "*" - hierarchies: - - name: hello - levels: - - orders.status -cubes: - - name: orders - sql: SELECT * FROM orders - measures: - - name: count - type: count - dimensions: - - name: id - sql: id - type: number - primary_key: true - - - name: status - sql: status - type: string - - - name: city - sql: city - type: string - `); + const allHierarchyView = metaTransformer.cubes.find( + (it) => it.config.name === 'all_hierarchy_view' + ); + expect(allHierarchyView.config.hierarchies.length).toBe(3); + }); - await compiler.compile(); + it(('hierarchy with measure'), async () => { + const modelContent = fs.readFileSync( + path.join(process.cwd(), '/test/unit/fixtures/hierarchy-with-measure.yml'), + 'utf8' + ); + const { compiler } = prepareYamlCompiler(modelContent); - const ordersView = metaTransformer.cubes.find(it => it.config.name === 'orders_view'); - - expect(ordersView.config.hierarchies).toEqual([ - { - name: 'hello', - levels: [ - 'orders_view.status', - ] - }, - ]); + await expect(compiler.compile()).rejects.toThrow('Only dimensions can be part of a hierarchy. Please remove the \'count\' member from the \'orders_hierarchy\' hierarchy.'); }); - it('views with prefix and aliased members', async () => { - const { compiler, metaTransformer } = prepareYamlCompiler(` -views: - - name: orders_view - cubes: - - join_path: orders - prefix: true - includes: "*" - - join_path: users - prefix: false - includes: - - count - - name: gender - alias: hello_world - hierarchies: - - name: hello - levels: - - users.count - - users.gender - - orders.count - - orders.status -cubes: + it(('does not accept wrong name'), async () => { + const { compiler } = prepareYamlCompiler(`cubes: - name: orders - sql: SELECT * FROM orders - measures: - - name: count - type: count + sql_table: orders dimensions: - name: id sql: id type: number primary_key: true - - name: status - sql: status - type: string - - - name: users - sql: SELECT * FROM users - measures: - - name: count - type: count - dimensions: - - name: id - sql: id - type: number - primary_key: true - - - name: gender - sql: gender - type: string - - - name: city - sql: city - type: string - - - name: status - sql: status - type: string - `); + hierarchies: + - name: hello wrong name + levels: + - id +`); - await compiler.compile(); + await expect(compiler.compile()).rejects.toThrow('with value "hello wrong name" fails to match the identifier pattern'); + }); - const ordersView = metaTransformer.cubes.find(it => it.config.name === 'orders_view'); - - expect(ordersView.config.hierarchies).toEqual([ - { - name: 'hello', - levels: [ - 'orders_view.count', - 'orders_view.hello_world', - 'orders_view.orders_count', - 'orders_view.orders_status' - ] - }, - ]); + it(('duplicated hierarchy'), async () => { + const { compiler } = prepareYamlCompiler(`cubes: + - name: orders + sql_table: orders + dimensions: + - name: id + sql: id + type: number + primary_key: true + + hierarchies: + - name: test_hierarchy + levels: + - id + - name: test_hierarchy + levels: + - id + `); + + await expect(compiler.compile()).rejects.toThrow('Duplicate hierarchy name \'test_hierarchy\' in cube \'orders\''); }); }); diff --git a/packages/cubejs-schema-compiler/test/unit/views.test.ts b/packages/cubejs-schema-compiler/test/unit/views.test.ts index eaea59650f8b8..3b412ee8f4841 100644 --- a/packages/cubejs-schema-compiler/test/unit/views.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/views.test.ts @@ -426,4 +426,34 @@ describe('Views YAML', () => { other_id: dimensionFixtureForCube('CubeB.other_id'), }); }); + + it('throws error for unresolved members', async () => { + const { compiler } = prepareYamlCompiler(` + cubes: + - name: orders + sql: SELECT * FROM orders + measures: + - name: count + type: count + dimensions: + - name: id + sql: id + type: number + primary_key: true + - name: status + sql: status + type: string + views: + - name: test_view + cubes: + - join_path: orders + includes: + - name: count + alias: renamed_count + - status + - unknown +`); + + await expect(compiler.compile()).rejects.toThrow('test_view cube: Member \'unknown\''); + }); }); diff --git a/packages/cubejs-server-core/src/core/CompilerApi.js b/packages/cubejs-server-core/src/core/CompilerApi.js index 96f7a99cb6d71..6e3689491a314 100644 --- a/packages/cubejs-server-core/src/core/CompilerApi.js +++ b/packages/cubejs-server-core/src/core/CompilerApi.js @@ -462,6 +462,10 @@ export class CompilerApi { for (const segment of cube.config.segments) { isMemberVisibleInContext[segment.name] = computeMemberVisibility(segment); } + + for (const hierarchy of cube.config.hierarchies) { + isMemberVisibleInContext[hierarchy.name] = computeMemberVisibility(hierarchy); + } } } @@ -484,6 +488,7 @@ export class CompilerApi { measures: cube.config.measures?.map(visibilityPatcherForCube(cube)), dimensions: cube.config.dimensions?.map(visibilityPatcherForCube(cube)), segments: cube.config.segments?.map(visibilityPatcherForCube(cube)), + hierarchies: cube.config.hierarchies?.map(visibilityPatcherForCube(cube)), }, })); }