diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js index 576a2eadfe9cc..868942c2ce26a 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js +++ b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js @@ -1287,10 +1287,22 @@ export class BaseQuery { if (memberDef.addGroupByReferences) { queryContext = { ...queryContext, dimensions: R.uniq(queryContext.dimensions.concat(memberDef.addGroupByReferences)) }; } - if (memberDef.timeShiftReferences) { - queryContext = { - ...queryContext, - timeDimensions: queryContext.timeDimensions.map(td => { + if (memberDef.timeShiftReferences?.length) { + let mapFn; + + if (memberDef.timeShiftReferences.length === 1 && !memberDef.timeShiftReferences[0].timeDimension) { + const timeShift = memberDef.timeShiftReferences[0]; + mapFn = (td) => { + if (td.shiftInterval) { + throw new UserError(`Hierarchical time shift is not supported but was provided for '${td.dimension}'. Parent time shift is '${td.shiftInterval}' and current is '${timeShift.interval}'`); + } + return { + ...td, + shiftInterval: timeShift.type === 'next' ? this.negateInterval(timeShift.interval) : timeShift.interval + }; + }; + } else { + mapFn = (td) => { const timeShift = memberDef.timeShiftReferences.find(r => r.timeDimension === td.dimension); if (timeShift) { if (td.shiftInterval) { @@ -1302,7 +1314,12 @@ export class BaseQuery { }; } return td; - }) + }; + } + + queryContext = { + ...queryContext, + timeDimensions: queryContext.timeDimensions.map(mapFn) }; } queryContext = { diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index 880e80bf85eb4..ac71717d034ea 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -28,13 +28,13 @@ export type DimensionDefinition = { }; export type TimeShiftDefinition = { - timeDimension: (...args: Array) => ToString, + timeDimension?: (...args: Array) => ToString, interval: string, type: 'next' | 'prior', }; export type TimeShiftDefinitionReference = { - timeDimension: string, + timeDimension?: string, interval: string, type: 'next' | 'prior', }; @@ -353,8 +353,13 @@ export class CubeEvaluator extends CubeSymbols { member.addGroupByReferences = this.evaluateReferences(cubeName, member.addGroupBy); } if (member.timeShift) { - member.timeShiftReferences = member.timeShift - .map(s => ({ ...s, timeDimension: this.evaluateReferences(cubeName, s.timeDimension) })); + member.timeShiftReferences = member.timeShift.map((s): TimeShiftDefinitionReference => ({ + interval: s.interval, + type: s.type, + ...(typeof s.timeDimension === 'function' + ? { timeDimension: this.evaluateReferences(cubeName, s.timeDimension) } + : {}), + })); } } } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 97508876eab70..14c7e460c3983 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -564,6 +564,18 @@ const multiStageMeasureType = Joi.string().valid( 'rank' ); +const timeShiftItemRequired = Joi.object({ + timeDimension: Joi.func().required(), + interval: regexTimeInterval.required(), + type: Joi.string().valid('next', 'prior').required(), +}); + +const timeShiftItemOptional = Joi.object({ + timeDimension: Joi.func(), // не required + interval: regexTimeInterval.required(), + type: Joi.string().valid('next', 'prior').required(), +}); + const MeasuresSchema = Joi.object().pattern(identifierRegex, Joi.alternatives().conditional(Joi.ref('.multiStage'), [ { is: true, @@ -574,11 +586,10 @@ const MeasuresSchema = Joi.object().pattern(identifierRegex, Joi.alternatives(). groupBy: Joi.func(), reduceBy: Joi.func(), addGroupBy: Joi.func(), - timeShift: Joi.array().items(Joi.object().keys({ - timeDimension: Joi.func().required(), - interval: regexTimeInterval.required(), - type: Joi.string().valid('next', 'prior').required(), - })), + timeShift: Joi.alternatives().conditional(Joi.array().length(1), { + then: Joi.array().items(timeShiftItemOptional), + otherwise: Joi.array().items(timeShiftItemRequired) + }), // TODO validate for order window functions orderBy: Joi.array().items(Joi.object().keys({ sql: Joi.func().required(), diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/sql-generation.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/sql-generation.test.ts index 76f159e5b8856..0f5b525c17191 100644 --- a/packages/cubejs-schema-compiler/test/integration/postgres/sql-generation.test.ts +++ b/packages/cubejs-schema-compiler/test/integration/postgres/sql-generation.test.ts @@ -129,6 +129,15 @@ describe('SQL Generation', () => { type: 'prior', }] }, + revenue_day_ago_no_td: { + multi_stage: true, + type: 'sum', + sql: \`\${revenue}\`, + time_shift: [{ + interval: '1 day', + type: 'prior', + }] + }, cagr_day: { multi_stage: true, sql: \`ROUND(100 * \${revenue} / NULLIF(\${revenue_day_ago}, 0))\`, @@ -1347,6 +1356,26 @@ SELECT 1 AS revenue, cast('2024-01-01' AS timestamp) as time UNION ALL { visitors__created_at_day: '2017-01-06T00:00:00.000Z', visitors__cagr_day: '300', visitors__revenue: '900', visitors__revenue_day_ago: '300' } ])); + it('CAGR (no td in time_shift)', async () => runQueryTest({ + measures: [ + 'visitors.revenue', + 'visitors.revenue_day_ago_no_td', + 'visitors.cagr_day' + ], + timeDimensions: [{ + dimension: 'visitors.created_at', + granularity: 'day', + dateRange: ['2016-12-01', '2017-01-31'] + }], + order: [{ + id: 'visitors.created_at' + }], + timezone: 'America/Los_Angeles' + }, [ + { visitors__created_at_day: '2017-01-05T00:00:00.000Z', visitors__cagr_day: '150', visitors__revenue: '300', visitors__revenue_day_ago_no_td: '200' }, + { visitors__created_at_day: '2017-01-06T00:00:00.000Z', visitors__cagr_day: '300', visitors__revenue: '900', visitors__revenue_day_ago_no_td: '300' } + ])); + it('sql utils', async () => runQueryTest({ measures: [ 'visitors.visitor_count' diff --git a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts index 2c39328166b15..19b8d84ef2c9f 100644 --- a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts @@ -251,27 +251,136 @@ describe('Cube Validation', () => { expect(validationResult.error).toBeTruthy(); }); - it('measures alternatives', async () => { - const cubeValidator = new CubeValidator(new CubeSymbols()); - const cube = { - name: 'name', - sql: () => '', - fileName: 'fileName', - measures: { - number: { - type: 'suma' + describe('Measures', () => { + it('measures alternatives', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'name', + sql: () => '', + fileName: 'fileName', + measures: { + number: { + type: 'suma' + } } - } - }; + }; - const validationResult = cubeValidator.validate(cube, { - error: (message: any, _e: any) => { - console.log(message); - expect(message).toContain('must be one of [count, number,'); - } - } as any); + const validationResult = cubeValidator.validate(cube, { + error: (message: any, _e: any) => { + console.log(message); + expect(message).toContain('must be one of [count, number,'); + } + } as any); - expect(validationResult.error).toBeTruthy(); + expect(validationResult.error).toBeTruthy(); + }); + + it('2 timeShifts, 1 without timeDimension', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'name', + sql: () => '', + fileName: 'fileName', + measures: { + measure_with_time_shift: { + multiStage: true, + type: 'sum', + sql: () => '', + timeShift: [ + { + timeDimension: () => '', + interval: '1 day', + type: 'prior', + }, + { + interval: '1 day', + type: 'prior', + } + ] + } + } + }; + + const validationResult = cubeValidator.validate(cube, { + error: (message: any, _e: any) => { + console.log(message); + expect(message).toContain('(measures.measure_with_time_shift.timeShift[1].timeDimension) is required'); + } + } as any); + + expect(validationResult.error).toBeTruthy(); + }); + + it('3 timeShifts', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'name', + sql: () => '', + fileName: 'fileName', + measures: { + measure_with_time_shift: { + multiStage: true, + type: 'sum', + sql: () => '', + timeShift: [ + { + timeDimension: () => '', + interval: '1 day', + type: 'prior', + }, + { + timeDimension: () => '', + interval: '1 year', + type: 'next', + }, + { + timeDimension: () => '', + interval: '1 week', + type: 'prior', + } + ] + } + } + }; + + const validationResult = cubeValidator.validate(cube, { + error: (message: any, _e: any) => { + console.log(message); + } + } as any); + + expect(validationResult.error).toBeFalsy(); + }); + + it('1 timeShift without timeDimension', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'name', + sql: () => '', + fileName: 'fileName', + measures: { + measure_with_time_shift: { + multiStage: true, + type: 'sum', + sql: () => '', + timeShift: [ + { + interval: '1 day', + type: 'prior', + } + ] + } + } + }; + + const validationResult = cubeValidator.validate(cube, { + error: (message: any, _e: any) => { + console.log(message); + } + } as any); + + expect(validationResult.error).toBeFalsy(); + }); }); it('OriginalSqlSchema', async () => {