Skip to content

Commit ead4ac0

Browse files
authored
feat: Cube based includes and meta exposure (#6380)
* feat: Cube based includes and meta exposure * chore: Fix mssql test * Join path support in includes
1 parent 654bbbc commit ead4ac0

File tree

10 files changed

+155
-28
lines changed

10 files changed

+155
-28
lines changed

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

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,16 @@ export class CubeEvaluator extends CubeSymbols {
123123
}
124124

125125
addIncludes(cube, errorReporter) {
126-
if (!cube.includes) {
126+
if (!cube.includes && !cube.cubes) {
127127
return;
128128
}
129129
const types = ['measures', 'dimensions', 'segments'];
130130
for (const type of types) {
131+
const cubeIncludes = cube.cubes && this.membersFromCubes(cube.cubes, type, errorReporter) || [];
131132
const includes = cube.includes && this.membersFromIncludeExclude(cube.includes, cube.name, type) || [];
132133
const excludes = cube.excludes && this.membersFromIncludeExclude(cube.excludes, cube.name, type) || [];
133-
const finalIncludes = R.difference(includes, excludes);
134+
// cube includes will take precedence in case of member clash
135+
const finalIncludes = this.diffByMember(this.diffByMember(includes, cubeIncludes).concat(cubeIncludes), excludes);
134136
const includeMembers = this.generateIncludeMembers(finalIncludes, cube.name, type);
135137
for (const [memberName, memberDefinition] of includeMembers) {
136138
if (cube[type]?.[memberName]) {
@@ -142,16 +144,71 @@ export class CubeEvaluator extends CubeSymbols {
142144
}
143145
}
144146

147+
membersFromCubes(cubes, type, errorReporter) {
148+
return R.unnest(cubes.map(cubeInclude => {
149+
const fullPath = this.evaluateReferences(null, cubeInclude.cube, { collectJoinHints: true });
150+
const split = fullPath.split('.');
151+
const cubeReference = split[split.length - 1];
152+
const cubeName = cubeInclude.name || cubeReference;
153+
let includes;
154+
if (cubeInclude.includes === '*') {
155+
const membersObj = this.symbols[cubeReference]?.cubeObj()?.[type] || {};
156+
includes = Object.keys(membersObj).map(memberName => ({ member: `${fullPath}.${memberName}` }));
157+
} else {
158+
includes = cubeInclude.includes.map(include => {
159+
const member = include.member || include;
160+
if (member.indexOf('.') !== -1) {
161+
errorReporter.error(`Paths aren't allowed in cube includes but '${member}' provided as include member`);
162+
}
163+
let name = include.name || member;
164+
name = cubeInclude.prefix ? `${cubeName}_${name}` : name;
165+
if (include.member) {
166+
const resolvedMember = this.symbols[cubeReference]?.cubeObj()?.[type]?.[include.member];
167+
return resolvedMember ? {
168+
member: `${fullPath}.${include.member}`,
169+
name,
170+
} : undefined;
171+
} else {
172+
const resolvedMember = this.symbols[cubeReference]?.cubeObj()?.[type]?.[include];
173+
return resolvedMember ? {
174+
member: `${fullPath}.${include}`,
175+
name
176+
} : undefined;
177+
}
178+
});
179+
}
180+
181+
const excludes = (cubeInclude.excludes || []).map(exclude => {
182+
if (exclude.indexOf('.') !== -1) {
183+
errorReporter.error(`Paths aren't allowed in cube excludes but '${exclude}' provided as exclude member`);
184+
}
185+
const resolvedMember = this.symbols[cubeReference]?.cubeObj()?.[type]?.[exclude];
186+
return resolvedMember ? {
187+
member: `${cubeReference}.${exclude}`
188+
} : undefined;
189+
});
190+
return this.diffByMember(includes.filter(Boolean), excludes.filter(Boolean));
191+
}));
192+
}
193+
194+
diffByMember(includes, excludes) {
195+
const excludesMap = new Map();
196+
for (const exclude of excludes) {
197+
excludesMap.set(exclude.member, true);
198+
}
199+
return includes.filter(include => !excludesMap.get(include.member));
200+
}
201+
145202
membersFromIncludeExclude(referencesFn, cubeName, type) {
146203
const references = this.evaluateReferences(cubeName, referencesFn);
147204
return R.unnest(references.map(ref => {
148205
const path = ref.split('.');
149206
if (path.length === 1) {
150207
const membersObj = this.symbols[path[0]]?.cubeObj()?.[type] || {};
151-
return Object.keys(membersObj).map(memberName => `${ref}.${memberName}`);
208+
return Object.keys(membersObj).map(memberName => ({ member: `${ref}.${memberName}` }));
152209
} else if (path.length === 2) {
153210
const resolvedMember = this.symbols[path[0]]?.cubeObj()?.[type]?.[path[1]];
154-
return resolvedMember ? [ref] : undefined;
211+
return resolvedMember ? [{ member: ref }] : undefined;
155212
} else {
156213
throw new Error(`Unexpected path length ${path.length} for ${ref}`);
157214
}
@@ -160,33 +217,40 @@ export class CubeEvaluator extends CubeSymbols {
160217

161218
generateIncludeMembers(members, cubeName, type) {
162219
return members.map(memberRef => {
163-
const path = memberRef.split('.');
164-
const resolvedMember = this.symbols[path[0]]?.cubeObj()?.[type]?.[path[1]];
220+
const path = memberRef.member.split('.');
221+
const resolvedMember = this.symbols[path[path.length - 2]]?.cubeObj()?.[type]?.[path[path.length - 1]];
165222
if (!resolvedMember) {
166-
throw new Error(`Can't resolve '${memberRef}' while generating include members`);
223+
throw new Error(`Can't resolve '${memberRef.member}' while generating include members`);
167224
}
168225

169226
// eslint-disable-next-line no-new-func
170-
const sql = new Function(path[0], `return \`\${${path[0]}.${path[1]}}\`;`);
227+
const sql = new Function(path[0], `return \`\${${memberRef.member}}\`;`);
171228
let memberDefinition;
172229
if (type === 'measures') {
173230
memberDefinition = {
174231
sql,
175-
type: 'number'
232+
type: 'number',
233+
aggType: resolvedMember.type,
234+
meta: resolvedMember.meta,
235+
description: resolvedMember.description,
176236
};
177237
} else if (type === 'dimensions') {
178238
memberDefinition = {
179239
sql,
180-
type: resolvedMember.type
240+
type: resolvedMember.type,
241+
meta: resolvedMember.meta,
242+
description: resolvedMember.description,
181243
};
182244
} else if (type === 'segments') {
183245
memberDefinition = {
184-
sql
246+
sql,
247+
meta: resolvedMember.meta,
248+
description: resolvedMember.description,
185249
};
186250
} else {
187251
throw new Error(`Unexpected member type: ${type}`);
188252
}
189-
return [path[1], memberDefinition];
253+
return [memberRef.name || path[path.length - 1], memberDefinition];
190254
});
191255
}
192256

@@ -408,6 +472,14 @@ export class CubeEvaluator extends CubeSymbols {
408472
evaluateReferences(cube, referencesFn, options = {}) {
409473
const cubeEvaluator = this;
410474

475+
const fullPath = (joinHints, path) => {
476+
if (joinHints?.length > 0) {
477+
return R.uniq(joinHints.concat(path));
478+
} else {
479+
return path;
480+
}
481+
};
482+
411483
const arrayOrSingle = cubeEvaluator.resolveSymbolsCall(referencesFn, (name) => {
412484
const referencedCube = cubeEvaluator.symbols[name] && name || cube;
413485
const resolvedSymbol =
@@ -419,10 +491,13 @@ export class CubeEvaluator extends CubeSymbols {
419491
if (resolvedSymbol._objectWithResolvedProperties) {
420492
return resolvedSymbol;
421493
}
422-
return cubeEvaluator.pathFromArray([referencedCube, name]);
494+
return cubeEvaluator.pathFromArray(fullPath(cubeEvaluator.joinHints(), [referencedCube, name]));
423495
}, {
424496
// eslint-disable-next-line no-shadow
425-
sqlResolveFn: (symbol, cube, n) => cubeEvaluator.pathFromArray([cube, n])
497+
sqlResolveFn: (symbol, cube, n) => cubeEvaluator.pathFromArray(fullPath(cubeEvaluator.joinHints(), [cube, n])),
498+
// eslint-disable-next-line no-shadow
499+
cubeAliasFn: (cube) => cubeEvaluator.pathFromArray(fullPath(cubeEvaluator.joinHints(), [cube])),
500+
collectJoinHints: options.collectJoinHints,
426501
});
427502
if (!Array.isArray(arrayOrSingle)) {
428503
return arrayOrSingle.toString();

packages/cubejs-schema-compiler/src/compiler/CubeSymbols.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export class CubeSymbols {
260260
joinHints = joinHints.concat(cubeName);
261261
}
262262
const self = this;
263+
const { sqlResolveFn, cubeAliasFn, query, cubeReferencesUsed } = self.resolveSymbolsCallContext || {};
263264
return new Proxy({}, {
264265
get: (v, propertyName) => {
265266
if (propertyName === '__cubeName') {
@@ -273,7 +274,6 @@ export class CubeSymbols {
273274
}
274275
return undefined;
275276
}
276-
const { sqlResolveFn, cubeAliasFn, query, cubeReferencesUsed } = self.resolveSymbolsCallContext || {};
277277
if (propertyName === 'toString') {
278278
return () => {
279279
if (query) {
@@ -283,7 +283,10 @@ export class CubeSymbols {
283283
if (cubeReferencesUsed) {
284284
cubeReferencesUsed.push(cube.cubeName());
285285
}
286-
return cubeAliasFn && cubeAliasFn(cube.cubeName()) || cube.cubeName();
286+
return cubeAliasFn && this.withSymbolsCallContext(
287+
() => cubeAliasFn(cube.cubeName()),
288+
{ ...this.resolveSymbolsCallContext, joinHints }
289+
) || cube.cubeName();
287290
};
288291
}
289292
if (propertyName === 'sql') {

packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export class CubeToMetaTransformer {
138138
cumulativeTotal: nameToMetric[1].cumulative || BaseMeasure.isCumulative(nameToMetric[1]),
139139
cumulative: nameToMetric[1].cumulative || BaseMeasure.isCumulative(nameToMetric[1]),
140140
type,
141-
aggType: nameToMetric[1].type,
141+
aggType: nameToMetric[1].aggType || nameToMetric[1].type,
142142
drillMembers: drillMembersArray,
143143
drillMembersGrouped: {
144144
measures: drillMembersArray.filter((member) => this.cubeEvaluator.isMeasure(member)),

packages/cubejs-schema-compiler/src/compiler/CubeValidator.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ export const nonStringFields = new Set([
2121
'incremental',
2222
'external',
2323
'useOriginalSqlPreAggregations',
24-
'readOnly'
24+
'readOnly',
25+
'prefix'
2526
]);
2627

2728
const identifierRegex = /^[_a-zA-Z][_a-zA-Z0-9]*$/;
@@ -528,6 +529,24 @@ const viewSchema = inherit(baseSchema, {
528529
isView: Joi.boolean().strict(),
529530
includes: Joi.func(),
530531
excludes: Joi.func(),
532+
cubes: Joi.array().items(
533+
Joi.object().keys({
534+
cube: Joi.func().required(),
535+
prefix: Joi.boolean(),
536+
name: Joi.string(),
537+
includes: Joi.alternatives([
538+
Joi.string().valid('*'),
539+
Joi.array().items(Joi.alternatives([
540+
Joi.string().required(),
541+
Joi.object().keys({
542+
member: Joi.string().required(),
543+
name: Joi.string()
544+
})
545+
]))
546+
]).required(),
547+
excludes: Joi.array().items(Joi.string().required()),
548+
})
549+
),
531550
});
532551

533552
function formatErrorMessageFromDetails(explain, d) {

packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const transpiledFieldsPatterns: Array<RegExp> = [
1717
/^contextMembers$/,
1818
/^includes$/,
1919
/^excludes$/,
20+
/^cubes\.[0-9]+\.cube$/,
2021
];
2122

2223
export const transpiledFields: Set<String> = new Set<String>();

packages/cubejs-schema-compiler/test/integration/postgres/yaml-compiler.test.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -346,18 +346,24 @@ cubes:
346346
347347
views:
348348
- name: orders_view
349-
includes:
350-
- orders.count
351-
- orders.time
352-
- customers.name
349+
cubes:
350+
- cube: orders
351+
prefix: true
352+
includes:
353+
- count
354+
- member: time
355+
name: date
356+
- cube: orders.customers
357+
includes:
358+
- name
353359
`);
354360
await compiler.compile();
355361

356362
const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, {
357-
measures: ['orders_view.count'],
363+
measures: ['orders_view.orders_count'],
358364
dimensions: ['orders_view.name'],
359365
timeDimensions: [{
360-
dimension: 'orders_view.time',
366+
dimension: 'orders_view.orders_date',
361367
granularity: 'day',
362368
dateRange: ['2022-01-01', '2022-01-03']
363369
}],
@@ -372,9 +378,9 @@ views:
372378

373379
expect(res).toEqual(
374380
[{
375-
orders_view__count: '1',
381+
orders_view__orders_count: '1',
376382
orders_view__name: 'Foo',
377-
orders_view__time_day: '2022-01-01T00:00:00.000Z',
383+
orders_view__orders_date_day: '2022-01-01T00:00:00.000Z',
378384
}]
379385
);
380386
});

packages/cubejs-testing-shared/src/db/mssql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ type MssqlStartOptions = DBRunnerContainerOptions & {
88

99
export class MssqlDbRunner extends DbRunnerAbstract {
1010
public static startContainer(options: MssqlStartOptions) {
11-
const version = process.env.TEST_MSSQL_VERSION || options.version || '2022-latest';
11+
const version = process.env.TEST_MSSQL_VERSION || options.version || '2017-latest';
1212

1313
const container = new GenericContainer(`mcr.microsoft.com/mssql/server:${version}`)
1414
.withEnv('ACCEPT_EULA', 'Y')

packages/cubejs-testing/birdbox-fixtures/driver-test-data/ECommerce.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ cube(`ECommerce`, {
4646
measures: {
4747
count: {
4848
type: `count`,
49+
meta: {
50+
foo: `bar`
51+
}
4952
},
5053
totalQuantity: {
5154
sql: 'quantity',
@@ -114,3 +117,11 @@ cube(`ECommerce`, {
114117
},
115118
},
116119
});
120+
121+
view(`ECommerceView`, {
122+
cubes: [{
123+
cube: ECommerce,
124+
includes: `*`,
125+
excludes: [`orderDate`]
126+
}]
127+
});

packages/cubejs-testing/test/driverTests/testSets.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import {
5454
filteringECommerceEndsWithDimensionsFirst,
5555
filteringECommerceEndsWithDimensionsSecond,
5656
filteringECommerceEndsWithDimensionsThird,
57-
queryingEcommerceTotalQuantifyAvgDiscountTotal, hiddenMember, hiddenCube, preAggsCustomersRunningTotal,
57+
queryingEcommerceTotalQuantifyAvgDiscountTotal, hiddenMember, hiddenCube, viewMetaExposed, preAggsCustomersRunningTotal,
5858
} from './tests';
5959
import { testSet } from './driverTest';
6060

@@ -133,6 +133,7 @@ export const mainTestSet = testSet([
133133
...withoutOrderingTestSet,
134134
hiddenMember,
135135
hiddenCube,
136+
viewMetaExposed
136137
]);
137138

138139
export const preAggsTestSet = testSet([

packages/cubejs-testing/test/driverTests/tests.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,3 +1291,14 @@ export const hiddenCube = driverTestFn({
12911291
expect(meta.cubes.find(cube => cube.name === 'HiddenECommerce')).toBe(undefined);
12921292
}
12931293
});
1294+
1295+
export const viewMetaExposed = driverTestFn({
1296+
name: 'view meta exposed',
1297+
schemas: commonSchemas,
1298+
testFn: async (client) => {
1299+
const meta = await client.meta();
1300+
const view = meta.cubes.find(cube => cube.name === 'ECommerceView');
1301+
expect(view?.measures?.find(m => m.name === 'ECommerceView.count')?.aggType).toBe('count');
1302+
expect(view?.measures?.find(m => m.name === 'ECommerceView.count')?.meta?.foo).toBe('bar');
1303+
}
1304+
});

0 commit comments

Comments
 (0)