Skip to content

Commit

Permalink
feat(schema-compiler): Reference granularities in a proxy dimension (#…
Browse files Browse the repository at this point in the history
…8664)

* Update resolveSymbol & cubeReferenceProxy to support deep properties resolution (proxied custom granularities)

* Update evaluateSymbolSql to support deep properties resolution (proxied custom granularities)

* add unit tests for proxied time dimension granularities

* refactor: remove cubePropertyReferenceProxy and move required logic piece to cubeReferenceProxy

* fix func types in CubeEvaluator

* Add support for proxying predefined granularities

* Add tests for proxying predefined granularities

* fix proxying granularities, referenced from different cubes

* add tests for proxying granularities, referenced from different cubes

* Add check for undefined during custom granularities tests

* fix proxying items, referenced from different cubes/views

* rename internalPropertyName -> subPropertyName in evaluateSymbolSql

* fix after merge/rebase

* Add comments in evaluateSymbolSql

* create resolveSubProperty for unified way of subproperty resolving

* Add usefull comments

* implement proxied time dim granularity evaluation within the BaseQuery/evaluateSymbolSql area, not going to dimensionSql()

* refactoring in resolveGranularity flow

* reverted back to dimensionSql()

* fix populating granularities in the views

* add unit tests for custom granularities in views

* add integration tests for proxied granularity
  • Loading branch information
KSDaemon authored Sep 15, 2024
1 parent db81b82 commit b7674f3
Show file tree
Hide file tree
Showing 7 changed files with 528 additions and 32 deletions.
22 changes: 20 additions & 2 deletions packages/cubejs-schema-compiler/src/adapter/BaseQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -2191,12 +2191,20 @@ export class BaseQuery {
return this.evaluateSymbolContext || {};
}

evaluateSymbolSql(cubeName, name, symbol, memberExpressionType) {
evaluateSymbolSql(cubeName, name, symbol, memberExpressionType, subPropertyName) {
const isMemberExpr = !!memberExpressionType;
if (!memberExpressionType) {
this.pushMemberNameForCollectionIfNecessary(cubeName, name);
}
const memberPathArray = [cubeName, name];
// Member path needs to be expanded to granularity if subPropertyName is provided.
// Without this: infinite recursion with maximum call stack size exceeded.
// During resolving within dimensionSql() the same symbol is pushed into the stack.
// This would not be needed when the subProperty evaluation will be here and no
// call to dimensionSql().
if (subPropertyName && symbol.type === 'time') {
memberPathArray.push('granularities', subPropertyName);
}
const memberPath = this.cubeEvaluator.pathFromArray(memberPathArray);
let type = memberExpressionType;
if (!type) {
Expand Down Expand Up @@ -2301,8 +2309,18 @@ export class BaseQuery {
'\',\'',
this.autoPrefixAndEvaluateSql(cubeName, symbol.longitude.sql, isMemberExpr)
]);
} else if (symbol.type === 'time' && subPropertyName) {
// TODO: Beware! memberExpression && shiftInterval are not supported with the current implementation.
// Ideally this should be implemented (at least partially) here + inside cube symbol evaluation logic.
// As now `dimensionSql()` is recursively calling `evaluateSymbolSql()` which is not good.
const td = this.newTimeDimension({
dimension: this.cubeEvaluator.pathFromArray([cubeName, name]),
granularity: subPropertyName
});
return td.dimensionSql();
} else {
let res = this.autoPrefixAndEvaluateSql(cubeName, symbol.sql, isMemberExpr);

if (symbol.shiftInterval) {
res = `(${this.addTimestampInterval(res, symbol.shiftInterval)})`;
}
Expand Down Expand Up @@ -2364,7 +2382,7 @@ export class BaseQuery {
}
return self.evaluateSymbolSql(nextCubeName, name, resolvedSymbol);
}, {
sqlResolveFn: options.sqlResolveFn || ((symbol, cube, n) => self.evaluateSymbolSql(cube, n, symbol)),
sqlResolveFn: options.sqlResolveFn || ((symbol, cube, propName, subPropName) => self.evaluateSymbolSql(cube, propName, symbol, false, subPropName)),
cubeAliasFn: self.cubeAlias.bind(self),
contextSymbols: this.parametrizedContextSymbols(),
query: this,
Expand Down
3 changes: 1 addition & 2 deletions packages/cubejs-schema-compiler/src/adapter/Granularity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ export class Granularity {
const customGranularity = this.query.cacheValue(
['customGranularity', timeDimension.dimension, this.granularity],
() => query.cubeEvaluator
.byPath('dimensions', timeDimension.dimension)
.granularities?.[this.granularity]
.resolveGranularity([...query.cubeEvaluator.parsePath('dimensions', timeDimension.dimension), 'granularities', this.granularity])
);

if (!customGranularity) {
Expand Down
16 changes: 8 additions & 8 deletions packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export class CubeEvaluator extends CubeSymbols {
for (const cube of validCubes) {
this.evaluatedCubes[cube.name] = this.prepareCube(cube, errorReporter);
}

this.byFileName = R.groupBy(v => v.fileName, validCubes);
this.primaryKeys = R.fromPairs(
validCubes.map((v) => {
Expand Down Expand Up @@ -128,21 +128,21 @@ export class CubeEvaluator extends CubeSymbols {
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));

return {
...it,
levels
};
}).filter(it => it.levels.length);

cube.hierarchies = [...(cube.hierarchies || []), ...filteredHierarchies];
}
}
Expand Down Expand Up @@ -420,15 +420,15 @@ export class CubeEvaluator extends CubeSymbols {
return Object.keys(this.evaluatedCubes);
}

public isMeasure(measurePath: string): boolean {
public isMeasure(measurePath: string | string[]): boolean {
return this.isInstanceOfType('measures', measurePath);
}

public isDimension(path: string): boolean {
public isDimension(path: string | string[]): boolean {
return this.isInstanceOfType('dimensions', path);
}

public isSegment(path: string): boolean {
public isSegment(path: string | string[]): boolean {
return this.isInstanceOfType('segments', path);
}

Expand Down
74 changes: 60 additions & 14 deletions packages/cubejs-schema-compiler/src/compiler/CubeSymbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export class CubeSymbols {
};
} else if (type === 'dimensions') {
memberDefinition = {
...(resolvedMember.granularities ? { granularities: resolvedMember.granularities } : {}),
sql,
type: resolvedMember.type,
meta: resolvedMember.meta,
Expand Down Expand Up @@ -507,25 +508,42 @@ export class CubeSymbols {
return symbol;
}

let cube = this.isCurrentCube(name) && this.symbols[cubeName] || this.symbols[name];
if (sqlResolveFn && cube) {
cube = this.cubeReferenceProxy(
this.isCurrentCube(name) ? cubeName : name,
collectJoinHints ? [] : undefined
);
// In proxied subProperty flow `name` will be set to parent dimension|measure name,
// so there will be no cube = this.symbols[cubeName : name] found, but potentially
// during cube definition evaluation some other deeper subProperty may be requested.
// To distinguish such cases we pass the right now requested property name to
// cubeReferenceProxy, so later if subProperty is requested we'll have all the required
// information to construct the response.
let cube = this.symbols[this.isCurrentCube(name) ? cubeName : name];
if (sqlResolveFn) {
if (cube) {
cube = this.cubeReferenceProxy(
this.isCurrentCube(name) ? cubeName : name,
collectJoinHints ? [] : undefined
);
} else if (this.symbols[cubeName]?.[name]) {
cube = this.cubeReferenceProxy(
cubeName,
undefined,
name
);
}
}

return cube || (this.symbols[cubeName] && this.symbols[cubeName][name]);
}

cubeReferenceProxy(cubeName, joinHints) {
cubeReferenceProxy(cubeName, joinHints, refProperty) {
if (joinHints) {
joinHints = joinHints.concat(cubeName);
}
const self = this;
const { sqlResolveFn, cubeAliasFn, query, cubeReferencesUsed } = self.resolveSymbolsCallContext || {};
return new Proxy({}, {
get: (v, propertyName) => {
if (propertyName === '_objectWithResolvedProperties') {
return true;
}
if (propertyName === '__cubeName') {
return cubeName;
}
Expand All @@ -538,6 +556,13 @@ export class CubeSymbols {
return undefined;
}
if (propertyName === 'toString') {
if (refProperty) {
return () => this.withSymbolsCallContext(
() => sqlResolveFn(cube[refProperty], cubeName, refProperty),
{ ...this.resolveSymbolsCallContext, joinHints }
);
}

return () => {
if (query) {
query.pushCubeNameForCollectionIfNecessary(cube.cubeName());
Expand All @@ -555,28 +580,49 @@ export class CubeSymbols {
if (propertyName === 'sql') {
return () => query.cubeSql(cube.cubeName());
}
if (propertyName === '_objectWithResolvedProperties') {
return true;
}
if (cube[propertyName]) {
if (refProperty &&
cube[refProperty].type === 'time' &&
self.resolveGranularity([cubeName, refProperty, 'granularities', propertyName], cube)
) {
return {
toString: () => this.withSymbolsCallContext(
() => sqlResolveFn(cube[propertyName], cubeName, propertyName),
{ ...this.resolveSymbolsCallContext, joinHints },
() => sqlResolveFn(cube[refProperty], cubeName, refProperty, propertyName),
{ ...this.resolveSymbolsCallContext },
),
};
}
if (cube[propertyName]) {
return this.cubeReferenceProxy(cubeName, joinHints, propertyName);
}
if (self.symbols[propertyName]) {
return this.cubeReferenceProxy(propertyName, joinHints);
}
if (typeof propertyName === 'string') {
throw new UserError(`${cubeName}.${propertyName} cannot be resolved. There's no such member or cube.`);
throw new UserError(`${cubeName}${refProperty ? `.${refProperty}` : ''}.${propertyName} cannot be resolved. There's no such member or cube.`);
}
return undefined;
}
});
}

/**
* Tries to resolve Granularity object.
* For predefined granularity it constructs it on the fly.
* @param {string|string[]} path
* @param [refCube] Optional cube object to operate on
*/
resolveGranularity(path, refCube) {
const [cubeName, dimName, gr, granName] = Array.isArray(path) ? path : path.split('.');
const cube = refCube || this.symbols[cubeName];

// Predefined granularity
if (typeof granName === 'string' && /^(second|minute|hour|day|week|month|quarter|year)$/i.test(granName)) {
return { interval: `1 ${granName}` };
}

return cube && cube[dimName] && cube[dimName][gr] && cube[dimName][gr][granName];
}

isCurrentCube(name) {
return CURRENT_CUBE_CONSTANTS.indexOf(name) >= 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ describe('Custom Granularities', () => {
sql: status
type: string
- name: createdAtHalfYear
sql: "{createdAt.half_year}"
type: string
- name: createdAt
sql: created_at
type: time
Expand Down Expand Up @@ -72,6 +76,12 @@ describe('Custom Granularities', () => {
type: count
rolling_window:
trailing: unbounded
views:
- name: orders_view
cubes:
- join_path: orders
includes: "*"
`);

it('works with half_year custom granularity w/o dimensions query', async () => dbRunner.runQueryTest(
Expand Down Expand Up @@ -111,6 +121,115 @@ describe('Custom Granularities', () => {
{ joinGraph, cubeEvaluator, compiler }
));

it('works with proxied createdAtHalfYear custom granularity as dimension query', async () => dbRunner.runQueryTest(
{
measures: ['orders.count'],
timeDimensions: [{
dimension: 'orders.createdAt',
dateRange: ['2024-01-01', '2025-12-31']
}],
dimensions: ['orders.createdAtHalfYear'],
filters: [],
timezone: 'Europe/London'
},
[
{
orders__count: '13',
orders__created_at_half_year: '2024-01-01T00:00:00.000Z',
},
{
orders__count: '13',
orders__created_at_half_year: '2024-07-01T00:00:00.000Z',
},
{
orders__count: '13',
orders__created_at_half_year: '2025-01-01T00:00:00.000Z',
},
{
orders__count: '13',
orders__created_at_half_year: '2025-07-01T00:00:00.000Z',
},
{
orders__count: '1',
orders__created_at_half_year: '2026-01-01T00:00:00.000Z',
},
],
{ joinGraph, cubeEvaluator, compiler }
));

it('works with half_year custom granularity w/o dimensions querying view', async () => dbRunner.runQueryTest(
{
measures: ['orders_view.count'],
timeDimensions: [{
dimension: 'orders_view.createdAt',
granularity: 'half_year',
dateRange: ['2024-01-01', '2025-12-31']
}],
dimensions: [],
filters: [],
timezone: 'Europe/London'
},
[
{
orders_view__count: '13',
orders_view__created_at_half_year: '2024-01-01T00:00:00.000Z',
},
{
orders_view__count: '13',
orders_view__created_at_half_year: '2024-07-01T00:00:00.000Z',
},
{
orders_view__count: '13',
orders_view__created_at_half_year: '2025-01-01T00:00:00.000Z',
},
{
orders_view__count: '13',
orders_view__created_at_half_year: '2025-07-01T00:00:00.000Z',
},
{
orders_view__count: '1',
orders_view__created_at_half_year: '2026-01-01T00:00:00.000Z',
},
],
{ joinGraph, cubeEvaluator, compiler }
));

it('works with proxied createdAtHalfYear custom granularity as dimension querying view', async () => dbRunner.runQueryTest(
{
measures: ['orders_view.count'],
timeDimensions: [{
dimension: 'orders_view.createdAt',
dateRange: ['2024-01-01', '2025-12-31']
}],
dimensions: ['orders_view.createdAtHalfYear'],
filters: [],
timezone: 'Europe/London'
},
[
{
orders_view__count: '13',
orders_view__created_at_half_year: '2024-01-01T00:00:00.000Z',
},
{
orders_view__count: '13',
orders_view__created_at_half_year: '2024-07-01T00:00:00.000Z',
},
{
orders_view__count: '13',
orders_view__created_at_half_year: '2025-01-01T00:00:00.000Z',
},
{
orders_view__count: '13',
orders_view__created_at_half_year: '2025-07-01T00:00:00.000Z',
},
{
orders_view__count: '1',
orders_view__created_at_half_year: '2026-01-01T00:00:00.000Z',
},
],
{ joinGraph, cubeEvaluator, compiler }
));

it('works with half_year_by_1st_april custom granularity w/o dimensions query', async () => dbRunner.runQueryTest(
{
measures: ['orders.count'],
Expand Down
Loading

0 comments on commit b7674f3

Please sign in to comment.