diff --git a/packages/cubejs-backend-shared/src/env.ts b/packages/cubejs-backend-shared/src/env.ts index eaad63fc5d03f..7ce6c9eb57595 100644 --- a/packages/cubejs-backend-shared/src/env.ts +++ b/packages/cubejs-backend-shared/src/env.ts @@ -233,6 +233,9 @@ const variables: Record any> = { transpilationNative: () => get('CUBEJS_TRANSPILATION_NATIVE') .default('false') .asBoolStrict(), + caseInsensitiveDuplicatesCheck: () => get('CUBEJS_CASE_INSENSITIVE_DUPLICATES_CHECK') + .default('false') + .asBoolStrict(), /** **************************************************************** * Common db options * @@ -1981,7 +1984,13 @@ const variables: Record any> = { .default(200000) .asInt(), convertTzForRawTimeDimension: () => get('CUBESQL_SQL_PUSH_DOWN').default('true').asBoolStrict(), + fastReload: () => get('CUBEJS_FAST_RELOAD_ENABLED') + .default('false') + .asBoolStrict(), + + // *************************************************** // Deprecated section + // *************************************************** // Support for Redis as queue & cache driver was removed in 0.36 // This code is used to detect Redis and throw an error @@ -2005,9 +2014,6 @@ const variables: Record any> = { return undefined; }, - fastReload: () => get('CUBEJS_FAST_RELOAD_ENABLED') - .default('false') - .asBoolStrict(), }; type Vars = typeof variables; diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts index 5c00edacdf94d..ff51b2cd098a0 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts @@ -4,12 +4,12 @@ import { camelize } from 'inflection'; import { UserError } from './UserError'; import { DynamicReference } from './DynamicReference'; -import { camelizeCube } from './utils'; +import { camelizeCube, findCyclesInGraph, topologicalSort } from './utils'; import { BaseQuery } from '../adapter'; import type { ErrorReporter } from './ErrorReporter'; -interface CubeDefinition { +export interface CubeDefinition { name: string; extends?: string; measures?: Record; @@ -34,7 +34,7 @@ interface SplitViews { const FunctionRegex = /function\s+\w+\(([A-Za-z0-9_,]*)|\(([\s\S]*?)\)\s*=>|\(?(\w+)\)?\s*=>/; export const CONTEXT_SYMBOLS = { SECURITY_CONTEXT: 'securityContext', - // SECURITY_CONTEXT has been deprecated, however security_context (lowecase) + // SECURITY_CONTEXT has been deprecated, however security_context (lowercase) // is allowed in RBAC policies for query-time attribute matching security_context: 'securityContext', securityContext: 'securityContext', @@ -45,21 +45,33 @@ export const CONTEXT_SYMBOLS = { export const CURRENT_CUBE_CONSTANTS = ['CUBE', 'TABLE']; +export type CubeDef = any; + +/** + * Tuple of 2 node names + * Treat it as first node depends on the last + */ +export type GraphEdge = [string, string]; + export class CubeSymbols { public symbols: Record; - private builtCubes: Record; + private readonly builtCubes: Record; private cubeDefinitions: Record; - private funcArgumentsValues: Record; + private readonly funcArgumentsValues: Record; public cubeList: any[]; - private evaluateViews: boolean; + private readonly evaluateViews: boolean; private resolveSymbolsCallContext: any; + private readonly viewDuplicateCheckerFn: (cube: any, memberType: string, memberName: string) => boolean; + + private readonly cubeDuplicateNamesCheckerFn: (cube: any) => string[]; + public constructor(evaluateViews = false) { this.symbols = {}; this.builtCubes = {}; @@ -67,21 +79,23 @@ export class CubeSymbols { this.funcArgumentsValues = {}; this.cubeList = []; this.evaluateViews = evaluateViews; + + if (getEnv('caseInsensitiveDuplicatesCheck')) { + this.cubeDuplicateNamesCheckerFn = this.cubeDuplicateNamesCheckerCaseInsensitive; + this.viewDuplicateCheckerFn = this.viewDuplicateCheckerCaseInsensitive; + } else { + this.cubeDuplicateNamesCheckerFn = this.cubeDuplicateNamesCheckerCaseSensitive; + this.viewDuplicateCheckerFn = this.viewDuplicateCheckerCaseSensitive; + } } public compile(cubes: CubeDefinition[], errorReporter: ErrorReporter) { - // @ts-ignore - this.cubeDefinitions = R.pipe( - // @ts-ignore - R.map((c: CubeDefinition) => [c.name, c]), - R.fromPairs - // @ts-ignore - )(cubes); - this.cubeList = cubes.map(c => (c.name ? this.getCubeDefinition(c.name) : this.createCube(c))); - // TODO support actual dependency sorting to allow using views inside views - const sortedByDependency = R.pipe( - R.sortBy((c: CubeDefinition) => !!c.isView), - )(cubes); + this.cubeDefinitions = Object.fromEntries(cubes.map((c) => [c.name, c])); + this.cubeList = cubes.map(c => this.getCubeDefinition(c.name)); + + // Sorting matters only for views evaluation + const sortedByDependency = this.evaluateViews ? topologicalSort(this.prepareDepsGraph(cubes)) : cubes; + for (const cube of sortedByDependency) { const splitViews: SplitViews = {}; this.symbols[cube.name] = this.transform(cube.name, errorReporter.inContext(`${cube.name} cube`), splitViews); @@ -93,6 +107,102 @@ export class CubeSymbols { } } + private prepareDepsGraph(cubes: CubeDefinition[]): [Map, GraphEdge[]] { + const graphNodes = new Map(); + const adjacencyList = new Map>(); // To search for cycles + + const addEdge = (from: string, to: string) => { + if (!adjacencyList.has(from)) { + adjacencyList.set(from, new Set()); + } + adjacencyList.get(from)!.add(to); + }; + + for (const cube of cubes) { + graphNodes.set(cube.name, cube); + + if (cube.isView) { + cube.cubes?.forEach(c => { + const jp = c.joinPath || c.join_path; // View is not camelized yet + if (jp) { + // It's enough to ref the very first level, as everything else will be evaluated on its own + let cubeJoinPath; + const fa = this.funcArguments(jp); + if (fa?.length > 0) { + [cubeJoinPath] = fa; + } else { // It's a function without params so it's safe to call it without further processing + const res = jp.apply(null); + if (typeof res === 'string') { + [cubeJoinPath] = res.split('.'); + } + } + addEdge(cube.name, cubeJoinPath); + } + }); + + // Legacy-style includes + if (typeof cube.includes === 'function') { + const refs = this.funcArguments(cube.includes); + refs.forEach(ref => { + addEdge(cube.name, ref); + }); + } + } else if (cube.joins && Object.keys(cube.joins).length > 0) { + Object.keys(cube.joins).forEach(j => { + addEdge(cube.name, j); + }); + } else { + adjacencyList.set(cube.name, new Set()); + } + } + + const cycles = findCyclesInGraph(adjacencyList); + + for (const cycle of cycles) { + const cycleSet = new Set(cycle); + + // Validate that cycle doesn't have views + if (cycle.some(node => graphNodes.get(node)?.isView)) { + throw new UserError(`A view cannot be part of a dependency loop. Please review your cube definitions ${cycle.join(', ')} and ensure that no views are included in loops.`); + } + + // Let's find external dependencies (who refers to the loop) + const externalNodes = new Set(); + for (const [from, toSet] of adjacencyList.entries()) { + if (!cycleSet.has(from)) { + for (const to of toSet) { + if (cycleSet.has(to)) { + externalNodes.add(from); + } + } + } + } + + // Remove all edges inside the loop + for (const node of cycle) { + adjacencyList.set(node, new Set([...adjacencyList.get(node)!].filter(n => !cycleSet.has(n)))); + } + + // If there are external dependencies, point them to every node in the loop + if (externalNodes.size > 0) { + for (const external of externalNodes) { + for (const cube of cycle) { + addEdge(external, cube); + } + } + } + } + + const graphEdges: GraphEdge[] = []; + for (const [from, toSet] of adjacencyList) { + for (const to of toSet) { + graphEdges.push([from, to]); + } + } + + return [graphNodes, graphEdges]; + } + public getCubeDefinition(cubeName: string) { if (!this.builtCubes[cubeName]) { const cubeDefinition = this.cubeDefinitions[cubeName]; @@ -175,23 +285,6 @@ export class CubeSymbols { protected transform(cubeName: string, errorReporter: ErrorReporter, splitViews: SplitViews) { const cube = this.getCubeDefinition(cubeName); - const duplicateNames = R.compose( - R.map((nameToDefinitions: any) => nameToDefinitions[0]), - R.toPairs, - R.filter((definitionsByName: any) => definitionsByName.length > 1), - R.groupBy((nameToDefinition: any) => nameToDefinition[0]), - R.unnest, - R.map(R.toPairs), - // @ts-ignore - R.filter((v: any) => !!v) - // @ts-ignore - )([cube.measures, cube.dimensions, cube.segments, cube.preAggregations, cube.hierarchies]); - - // @ts-ignore - if (duplicateNames.length > 0) { - // @ts-ignore - errorReporter.error(`${duplicateNames.join(', ')} defined more than once`); - } camelizeCube(cube); @@ -210,13 +303,50 @@ export class CubeSymbols { this.prepareIncludes(cube, errorReporter, splitViews); } - return Object.assign( - { cubeName: () => cube.name, cubeObj: () => cube }, - cube.measures || {}, - cube.dimensions || {}, - cube.segments || {}, - cube.preAggregations || {} - ); + const duplicateNames = this.cubeDuplicateNamesCheckerFn(cube); + + if (duplicateNames.length > 0) { + errorReporter.error(`${duplicateNames.join(', ')} defined more than once`); + } + + return { + cubeName: () => cube.name, + cubeObj: () => cube, + ...cube.measures || {}, + ...cube.dimensions || {}, + ...cube.segments || {}, + ...cube.preAggregations || {} + }; + } + + private cubeDuplicateNamesCheckerCaseSensitive(cube: any): string[] { + // @ts-ignore + return R.compose( + R.map(([name]) => name), + R.toPairs, + R.filter((definitionsByName: any) => definitionsByName.length > 1), + R.groupBy(([name]: any) => name), + R.unnest, + R.map(R.toPairs), + // @ts-ignore + R.filter((v: any) => !!v) + // @ts-ignore + )([cube.measures, cube.dimensions, cube.segments, cube.preAggregations, cube.hierarchies]); + } + + private cubeDuplicateNamesCheckerCaseInsensitive(cube: any): string[] { + // @ts-ignore + return R.compose( + R.map(([name]) => name), + R.toPairs, + R.filter((definitionsByName: any) => definitionsByName.length > 1), + R.groupBy(([name]: any) => name.toLowerCase()), + R.unnest, + R.map(R.toPairs), + // @ts-ignore + R.filter((v: any) => !!v) + // @ts-ignore + )([cube.measures, cube.dimensions, cube.segments, cube.preAggregations, cube.hierarchies]); } private camelCaseTypes(obj: Object) { @@ -360,7 +490,7 @@ export class CubeSymbols { protected applyIncludeMembers(includeMembers: any[], cube: CubeDefinition, type: string, errorReporter: ErrorReporter) { for (const [memberName, memberDefinition] of includeMembers) { - if (cube[type]?.[memberName]) { + if (this.viewDuplicateCheckerFn(cube, type, memberName)) { errorReporter.error(`Included member '${memberName}' conflicts with existing member of '${cube.name}'. Please consider excluding this member or assigning it an alias.`); } else if (type !== 'hierarchies') { cube[type][memberName] = memberDefinition; @@ -368,6 +498,14 @@ export class CubeSymbols { } } + private viewDuplicateCheckerCaseSensitive(cube: any, memberType: string, memberName: string): boolean { + return cube[memberType][memberName]; + } + + private viewDuplicateCheckerCaseInsensitive(cube: any, memberType: string, memberName: string): boolean { + return Object.keys(cube[memberType]).map(v => v.toLowerCase()).includes(memberName.toLowerCase()); + } + protected membersFromCubes(parentCube: CubeDefinition, cubes: any[], type: string, errorReporter: ErrorReporter, splitViews: SplitViews, memberSets: any) { return R.unnest(cubes.map(cubeInclude => { const fullPath = this.evaluateReferences(null, cubeInclude.joinPath, { collectJoinHints: true }); @@ -524,7 +662,7 @@ export class CubeSymbols { /** * This method is mainly used for evaluating RLS conditions and filters. - * It allows referencing security_context (lowecase) in dynamic conditions or filter values. + * It allows referencing security_context (lowercase) in dynamic conditions or filter values. * * It currently does not support async calls because inner resolveSymbol and * resolveSymbolsCall are sync. Async support may be added later with deeper diff --git a/packages/cubejs-schema-compiler/src/compiler/utils.ts b/packages/cubejs-schema-compiler/src/compiler/utils.ts index aea98cd017b62..c340e73b59534 100644 --- a/packages/cubejs-schema-compiler/src/compiler/utils.ts +++ b/packages/cubejs-schema-compiler/src/compiler/utils.ts @@ -1,4 +1,5 @@ import { camelize } from 'inflection'; +import { CubeDef, GraphEdge } from './CubeSymbols'; // It's a map where key - is a level and value - is a map of properties on this level to ignore camelization const IGNORE_CAMELIZE = { @@ -52,3 +53,84 @@ export function camelizeCube(cube: any): unknown { return cube; } + +/** + * This is a simple cube-views topological sorting based on Kahn's algorythm. + */ +export function topologicalSort([nodes, edges]: [Map, GraphEdge[]]): CubeDef[] { + const inDegree = new Map>(); + const outDegree = new Map(); + + nodes.forEach(node => { + outDegree.set(node.name, 0); + inDegree.set(node.name, new Set()); + }); + + for (const [from, to] of edges) { + const n = inDegree.get(to) || new Set(); + n.add(from); + outDegree.set(from, (outDegree.get(from) ?? 0) + 1); + } + + const queue: string[] = [...outDegree.entries()].filter(([_, deg]) => deg === 0).map(([name]) => name); + + const sorted: CubeDef[] = []; + + while (queue.length) { + const nodeName = queue.shift(); + if (nodeName === undefined) { + break; + } + + const from = inDegree.get(nodeName) || new Set(); + + sorted.push(nodes.get(nodeName)); + + for (const neighbor of from) { + outDegree.set(neighbor, (outDegree.get(neighbor) || 1) - 1); + if (outDegree.get(neighbor) === 0) { + queue.push(neighbor); + } + } + } + + if (sorted.length !== nodes.size) { + const remainingNodes = [...nodes.keys()].filter(node => !sorted.includes(node)); + throw new Error(`Cyclical dependence detected! Potential problems with ${remainingNodes.join(', ')}.`); + } + + return sorted; +} + +export function findCyclesInGraph(adjacencyList: Map>): string[][] { + const visited = new Set(); + const stack = new Set(); + const cycles: string[][] = []; + + const dfs = (node: string, path: string[]) => { + if (stack.has(node)) { + const cycleStart = path.indexOf(node); + cycles.push(path.slice(cycleStart)); + return; + } + if (visited.has(node)) return; + + visited.add(node); + stack.add(node); + path.push(node); + + for (const neighbor of adjacencyList.get(node) ?? []) { + dfs(neighbor, [...path]); + } + + stack.delete(node); + }; + + for (const node of adjacencyList.keys()) { + if (!visited.has(node)) { + dfs(node, []); + } + } + + return cycles; +} diff --git a/packages/cubejs-schema-compiler/test/unit/cube-symbols.test.ts b/packages/cubejs-schema-compiler/test/unit/cube-symbols.test.ts new file mode 100644 index 0000000000000..7763c19e31e41 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/cube-symbols.test.ts @@ -0,0 +1,341 @@ +import * as process from 'node:process'; +import { CubeSymbols, CubeDefinition } from '../../src/compiler/CubeSymbols'; +import { ErrorReporter } from '../../src/compiler/ErrorReporter'; + +class ConsoleErrorReporter extends ErrorReporter { + public error(message: any, _e: any) { + console.log(message); + } +} + +/** + * Topological sort in CubeSymbols.compile() should correctly + * order cubes and views in a way that views depend on cubes will be processed after dependencies + */ +const cubeDefs: CubeDefinition[] = [ + { + name: 'users_view', + isView: true, + cubes: [ + { join_path: (users) => 'users', includes: '*' }, + { joinPath: () => 'users.clients', includes: '*' }, + ] + }, + { + name: 'clients', + measures: { + Count: { type: 'count', sql: () => 'sql' }, + Sum: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + UserId: { type: 'number', sql: () => 'user_id' }, + Name: { type: 'string', sql: () => 'user_name' }, + CreatedAt: { type: 'time', sql: () => 'created_at' }, + }, + }, + { + name: 'users', + measures: { + count: { type: 'count', sql: () => 'sql' }, + sum: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + userId: { type: 'number', sql: () => 'user_id' }, + name: { type: 'string', sql: () => 'user_name' }, + createdAt: { type: 'time', sql: () => 'created_at' }, + }, + joins: { + checkins: { relationship: 'hasMany', sql: (CUBE) => `${CUBE}.id = checkins.id` }, + clients: { relationship: 'hasMany', sql: (CUBE) => `${CUBE}.id = clients.id` } + }, + preAggregations: { + main: {} + } + }, + { + name: 'view_with_view_as_cube', + isView: true, + cubes: [ + { join_path: () => 'emails', includes: '*' }, + { joinPath: () => 'users_view', includes: ['UserId'] }, + ] + }, + { + name: 'emails', + measures: { + CountMail: { type: 'count', sql: () => 'sql' }, + SumMail: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + mailId: { type: 'number', sql: () => 'user_id' }, + Address: { type: 'string', sql: () => 'email' }, + MailCreatedAt: { type: 'time', sql: () => 'created_at' }, + }, + }, + { + name: 'checkins', + measures: { + CheckinsCount: { type: 'count', sql: () => 'sql' }, + SumCheckins: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + checkinId: { type: 'number', sql: () => 'user_id' }, + CheckinCreatedAt: { type: 'time', sql: () => 'created_at' }, + }, + }, + + // Separate graph configuration with loops + { + name: 'view', + isView: true, + cubes: [ + { join_path: () => 'A', includes: ['aid'] }, + ] + }, + { + name: 'A', + dimensions: { aid: { type: 'number', sql: () => 'aid' } }, + joins: { + B: { relationship: 'hasMany', sql: (CUBE) => 'join' }, + D: { relationship: 'hasMany', sql: (CUBE) => 'join' } + }, + }, + { + name: 'B', + dimensions: { bid: { type: 'number', sql: () => 'bid' } }, + joins: { + A: { relationship: 'hasMany', sql: (CUBE) => 'join' }, + E: { relationship: 'hasMany', sql: (CUBE) => 'join' } + }, + }, + { + name: 'D', + dimensions: { did: { type: 'number', sql: () => 'did' } }, + joins: { + A: { relationship: 'hasMany', sql: (CUBE) => 'join' }, + B: { relationship: 'hasMany', sql: (CUBE) => 'join' }, + E: { relationship: 'hasMany', sql: (CUBE) => 'join' } + }, + }, + { + name: 'E', + dimensions: { eid: { type: 'number', sql: () => 'eid' } }, + }, +]; + +describe('Cube Symbols Compiler', () => { + it('disallows members of different types with the same name (case sensitive)', () => { + process.env.CUBEJS_CASE_INSENSITIVE_DUPLICATES_CHECK = 'false'; + + const reporter = new ConsoleErrorReporter(); + let compiler = new CubeSymbols(); + + let cubeDefsTest: CubeDefinition[] = [ + { + name: 'users', + measures: { + count: { type: 'count', sql: () => 'sql' }, + sum: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + userId: { type: 'number', sql: () => 'user_id' }, + Sum: { type: 'string', sql: () => 'user_name' }, + createdAt: { type: 'time', sql: () => 'created_at' }, + } + } + ]; + + compiler.compile(cubeDefsTest, reporter); + reporter.throwIfAny(); // should not throw in this case + + compiler = new CubeSymbols(); + cubeDefsTest = [ + { + name: 'users', + measures: { + count: { type: 'count', sql: () => 'sql' }, + sum: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + userId: { type: 'number', sql: () => 'user_id' }, + sum: { type: 'string', sql: () => 'user_name' }, + createdAt: { type: 'time', sql: () => 'created_at' }, + } + } + ]; + + compiler.compile(cubeDefsTest, reporter); + expect(() => reporter.throwIfAny()).toThrow(/sum defined more than once/); + }); + + it('disallows members of different types with the same name (case insensitive)', () => { + process.env.CUBEJS_CASE_INSENSITIVE_DUPLICATES_CHECK = 'true'; + + const reporter = new ConsoleErrorReporter(); + const compiler = new CubeSymbols(); + + const cubeDefsTest: CubeDefinition[] = [ + { + name: 'users', + measures: { + count: { type: 'count', sql: () => 'sql' }, + sum: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + userId: { type: 'number', sql: () => 'user_id' }, + Sum: { type: 'string', sql: () => 'user_name' }, + createdAt: { type: 'time', sql: () => 'created_at' }, + } + } + ]; + + compiler.compile(cubeDefsTest, reporter); + expect(() => reporter.throwIfAny()).toThrow(/sum defined more than once/); + }); + + it('throws error if dependency loop involving view is detected', () => { + process.env.CUBEJS_CASE_INSENSITIVE_DUPLICATES_CHECK = 'true'; + + const reporter = new ConsoleErrorReporter(); + const compiler = new CubeSymbols(true); + + const cubeDefsTest: CubeDefinition[] = [...cubeDefs]; + // Change the A cube to be a view + cubeDefsTest[7] = { + name: 'A', + isView: true, + cubes: [ + { join_path: () => 'B', includes: ['bid'] }, + { join_path: () => 'D', includes: ['did'] }, + ] + }; + + expect(() => compiler.compile(cubeDefsTest, reporter)).toThrow(/A view cannot be part of a dependency loop/); + }); + + it('compiles correct cubes and views (case sensitive)', () => { + process.env.CUBEJS_CASE_INSENSITIVE_DUPLICATES_CHECK = 'false'; + + const reporter = new ConsoleErrorReporter(); + let compiler = new CubeSymbols(); + + compiler.compile(cubeDefs, reporter); + reporter.throwIfAny(); + + // and with compileViews + compiler = new CubeSymbols(true); + compiler.compile(cubeDefs, reporter); + reporter.throwIfAny(); + }); + + it('throws error for duplicates with case insensitive flag', () => { + process.env.CUBEJS_CASE_INSENSITIVE_DUPLICATES_CHECK = 'true'; + + const reporter = new ConsoleErrorReporter(); + let compiler = new CubeSymbols(); + + compiler.compile(cubeDefs, reporter); + reporter.throwIfAny(); // should not throw at this stage + + // and with compileViews + compiler = new CubeSymbols(true); + compiler.compile(cubeDefs, reporter); + expect(() => reporter.throwIfAny()).toThrow(/users_view cube.*conflicts with existing member/); + }); + + it('throws error for including non-existing member in view\'s cube', () => { + process.env.CUBEJS_CASE_INSENSITIVE_DUPLICATES_CHECK = 'false'; + + const reporter = new ConsoleErrorReporter(); + const compiler = new CubeSymbols(true); + + const cubeDefsTest: CubeDefinition[] = [ + { + name: 'users', + measures: { + count: { type: 'count', sql: () => 'sql' }, + sum: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + userId: { type: 'number', sql: () => 'user_id' }, + Sum: { type: 'string', sql: () => 'user_name' }, + createdAt: { type: 'time', sql: () => 'created_at' }, + } + }, + { + name: 'users_view', + isView: true, + cubes: [ + { join_path: (users) => 'users', includes: ['sum', 'non-existent'] }, + ] + }, + ]; + + compiler.compile(cubeDefsTest, reporter); + expect(() => reporter.throwIfAny()).toThrow(/Member 'non-existent' is included in 'users_view' but not defined in any cube/); + }); + + it('throws error for using paths in view\'s cube includes members', () => { + process.env.CUBEJS_CASE_INSENSITIVE_DUPLICATES_CHECK = 'false'; + + const reporter = new ConsoleErrorReporter(); + const compiler = new CubeSymbols(true); + + const cubeDefsTest: CubeDefinition[] = [ + { + name: 'users', + measures: { + count: { type: 'count', sql: () => 'sql' }, + sum: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + userId: { type: 'number', sql: () => 'user_id' }, + Sum: { type: 'string', sql: () => 'user_name' }, + createdAt: { type: 'time', sql: () => 'created_at' }, + } + }, + { + name: 'users_view', + isView: true, + cubes: [ + { join_path: (users) => 'users', includes: ['sum', 'some.other.non-existent'] }, + ] + }, + ]; + + compiler.compile(cubeDefsTest, reporter); + expect(() => reporter.throwIfAny()).toThrow(/Paths aren't allowed in cube includes but 'some.other.non-existent' provided as include member/); + }); + + it('throws error for using paths in view\'s cube includes members', () => { + process.env.CUBEJS_CASE_INSENSITIVE_DUPLICATES_CHECK = 'false'; + + const reporter = new ConsoleErrorReporter(); + const compiler = new CubeSymbols(true); + + const cubeDefsTest: CubeDefinition[] = [ + { + name: 'users', + measures: { + count: { type: 'count', sql: () => 'sql' }, + sum: { type: 'sum', sql: () => 'sql' }, + }, + dimensions: { + userId: { type: 'number', sql: () => 'user_id' }, + Sum: { type: 'string', sql: () => 'user_name' }, + createdAt: { type: 'time', sql: () => 'created_at' }, + } + }, + { + name: 'users_view', + isView: true, + cubes: [ + { join_path: (users) => 'users', includes: '*', excludes: ['some.other.non-existent'] }, + ] + }, + ]; + + compiler.compile(cubeDefsTest, reporter); + expect(() => reporter.throwIfAny()).toThrow(/Paths aren't allowed in cube excludes but 'some.other.non-existent' provided as exclude member/); + }); +});