Skip to content

Commit 2000373

Browse files
authored
feat(schema-compiler): Add duplicate folder name and time shift validation (cube-js#10423)
1 parent c90b656 commit 2000373

File tree

3 files changed

+115
-33
lines changed

3 files changed

+115
-33
lines changed

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,20 @@ export class CubeEvaluator extends CubeSymbols {
318318
const folders = cube.rawFolders();
319319
if (!folders.length) return;
320320

321+
const seen = new Set<string>();
322+
323+
for (const folder of folders) {
324+
if (folder.name && seen.has(folder.name)) {
325+
errorReporter.error(
326+
`Folder names must be unique within a view. Found duplicate folder '${folder.name}' in view '${cube.name}'.`
327+
);
328+
}
329+
330+
if (folder.name) {
331+
seen.add(folder.name);
332+
}
333+
}
334+
321335
const checkMember = (memberName: string, folderName: string): ViewIncludedMember | null => {
322336
if (memberName.includes('.')) {
323337
errorReporter.error(

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

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,14 @@ export class YamlCompiler {
9696

9797
for (const key of Object.keys(yamlObj)) {
9898
if (key === 'cubes') {
99-
this.checkDuplicateNames(yamlObj.cubes || [], 'cube', errorsReport);
99+
this.checkDuplicateNames(yamlObj.cubes || [], errorsReport, (name) => `Found duplicate cube name '${name}'.`);
100100

101101
(yamlObj.cubes || []).forEach(({ name, ...cube }) => {
102102
const transpiledCube = this.transpileAndPrepareJsFile('cube', { name, ...cube }, errorsReport);
103103
transpiledFilesContent.push(transpiledCube);
104104
});
105105
} else if (key === 'views') {
106-
this.checkDuplicateNames(yamlObj.views || [], 'view', errorsReport);
106+
this.checkDuplicateNames(yamlObj.views || [], errorsReport, (name) => `Found duplicate view name '${name}'.`);
107107

108108
(yamlObj.views || []).forEach(({ name, ...cube }) => {
109109
const transpiledView = this.transpileAndPrepareJsFile('view', { name, ...cube }, errorsReport);
@@ -140,6 +140,7 @@ export class YamlCompiler {
140140
cubeObj.hierarchies = this.yamlArrayToObj(cubeObj.hierarchies || [], 'hierarchies', errorsReport, ctx);
141141

142142
cubeObj.joins = cubeObj.joins || []; // For edge cases where joins are not defined/null
143+
143144
if (!Array.isArray(cubeObj.joins)) {
144145
errorsReport.error('joins must be defined as array');
145146
cubeObj.joins = [];
@@ -320,19 +321,17 @@ export class YamlCompiler {
320321
return body?.expression;
321322
}
322323

323-
private checkDuplicateNames(items: any[], type: 'cube' | 'view', errorsReport: ErrorReporter) {
324-
const seen = new Set<string>();
324+
private checkDuplicateNames(items: any[], errorsReport: ErrorReporter, message: (name: string) => string) {
325+
const names = items
326+
.map(item => item?.name)
327+
.filter((name): name is string => name != null);
325328

326-
for (const item of items) {
327-
if (item?.name) {
328-
if (seen.has(item.name)) {
329-
errorsReport.error(
330-
`Found duplicate ${type} name '${item.name}'.`
331-
);
332-
} else {
333-
seen.add(item.name);
334-
}
329+
const seen = new Set<string>();
330+
for (const name of names) {
331+
if (seen.has(name)) {
332+
errorsReport.error(message(name));
335333
}
334+
seen.add(name);
336335
}
337336
}
338337

@@ -348,29 +347,15 @@ export class YamlCompiler {
348347
}
349348

350349
// Check for duplicate names
351-
const names = yamlArray
352-
.map(item => item?.name)
353-
.filter(name => name != null);
354-
355-
const seen = new Set<string>();
356-
357-
for (const name of names) {
358-
if (seen.has(name)) {
359-
if (ctx.parent) {
360-
errorsReport.error(
361-
`Found duplicate ${memberType} '${name}' in ${ctx.parent.type} '${ctx.parent.name}' in cube '${ctx.cubeName}'.`
362-
);
363-
} else {
364-
errorsReport.error(
365-
`Member names must be unique within a cube. Found duplicate ${memberType} '${name}' in cube '${ctx.cubeName}'.`
366-
);
367-
}
350+
this.checkDuplicateNames(yamlArray, errorsReport, (name) => {
351+
if (ctx.parent) {
352+
return `Found duplicate ${memberType} '${name}' in ${ctx.parent.type} '${ctx.parent.name}' in cube '${ctx.cubeName}'.`;
368353
}
369354

370-
seen.add(name);
371-
}
355+
return `Member names must be unique within a cube. Found duplicate ${memberType} '${name}' in cube '${ctx.cubeName}'.`;
356+
});
372357

373-
const remapped = yamlArray.map(({ name, indexes, granularities, ...rest }) => {
358+
const remapped = yamlArray.map(({ name, indexes, granularities, timeShift, ...rest }) => {
374359
if (!name) {
375360
errorsReport.error(`name isn't defined for ${memberType}: ${JSON.stringify(rest)}`);
376361
return {};
@@ -393,7 +378,18 @@ export class YamlCompiler {
393378
res[name] = { granularities, ...res[name] };
394379
}
395380

381+
if (timeShift) {
382+
this.checkDuplicateNames(
383+
timeShift,
384+
errorsReport,
385+
(shiftName) => `Time shift names must be unique within a ${memberType}. Found duplicate time shift '${shiftName}' in ${memberType} '${name}' in cube '${ctx.cubeName}'.`
386+
);
387+
388+
res[name] = { timeShift, ...res[name] };
389+
}
390+
396391
res[name] = { ...res[name], ...rest };
392+
397393
return res;
398394
});
399395

packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,78 @@ cubes:
241241
expect(e.message).toContain("Found duplicate dimension.granularity 'fiscal_year' in time dimension 'created_at' in cube 'orders'");
242242
}
243243
});
244+
245+
it('detects duplicate folder names in views', async () => {
246+
const { compiler } = prepareYamlCompiler(`
247+
cubes:
248+
- name: orders
249+
sql_table: orders
250+
dimensions:
251+
- name: id
252+
sql: id
253+
type: number
254+
primary_key: true
255+
- name: status
256+
sql: status
257+
type: string
258+
- name: category
259+
sql: category
260+
type: string
261+
views:
262+
- name: orders_view
263+
cubes:
264+
- join_path: orders
265+
includes: "*"
266+
folders:
267+
- name: Details
268+
includes:
269+
- id
270+
- status
271+
- name: Details
272+
includes:
273+
- category
274+
`);
275+
276+
try {
277+
await compiler.compile();
278+
throw new Error('compile must return an error');
279+
} catch (e: any) {
280+
expect(e.message).toContain(
281+
"Folder names must be unique within a view. Found duplicate folder 'Details' in view 'orders_view'."
282+
);
283+
}
284+
});
285+
286+
it('detects duplicate dimension time shifts', async () => {
287+
const { compiler } = prepareYamlCompiler(`
288+
cubes:
289+
- name: fiscal_calendar
290+
sql: "SELECT 1"
291+
dimensions:
292+
- name: date_key
293+
sql: date_key
294+
type: time
295+
primary_key: true
296+
- name: date
297+
sql: calendar_date
298+
type: time
299+
time_shift:
300+
- name: prior_year
301+
type: prior
302+
interval: 1 year
303+
- name: prior_year
304+
sql: "{CUBE}.prior_year_date"
305+
`);
306+
307+
try {
308+
await compiler.compile();
309+
throw new Error('compile must return an error');
310+
} catch (e: any) {
311+
expect(e.message).toContain(
312+
"Time shift names must be unique within a dimension. Found duplicate time shift 'prior_year' in dimension 'date' in cube 'fiscal_calendar'."
313+
);
314+
}
315+
});
244316
});
245317

246318
it('members must be defined as arrays', async () => {

0 commit comments

Comments
 (0)