Skip to content

Commit f60b4aa

Browse files
authored
fix(schema-compiler): Correct join hints collection for transitive joins (#9671)
* attempt to make allJoinHints work * next attempt to make allJoinHints work * udpat traversing to change the ancestors to the original cube hinting * change how join hints are constructed * add tests * fix allJoinHints() for cases when there are no members * remove comment * code polish * implement smart join tree comparator and loop detector * add test for loop * add safeguard * some code polish + throw error for loop detected * fix tests and remove unneeded magic
1 parent 6308875 commit f60b4aa

File tree

6 files changed

+376
-20
lines changed

6 files changed

+376
-20
lines changed

packages/cubejs-schema-compiler/src/adapter/BaseQuery.js

Lines changed: 103 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -419,10 +419,81 @@ export class BaseQuery {
419419
*/
420420
get allJoinHints() {
421421
if (!this.collectedJoinHints) {
422-
this.collectedJoinHints = [
423-
...this.queryLevelJoinHints,
424-
...this.collectJoinHints(),
425-
];
422+
const [rootOfJoin, ...allMembersJoinHints] = this.collectJoinHintsFromMembers(this.allMembersConcat(false));
423+
const customSubQueryJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromCustomSubQuery());
424+
let joinMembersJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromJoin(this.join));
425+
426+
// One cube may join the other cube via transitive joined cubes,
427+
// members from which are referenced in the join `on` clauses.
428+
// We need to collect such join hints and push them upfront of the joining one
429+
// but only if they don't exist yet. Cause in other case we might affect what
430+
// join path will be constructed in join graph.
431+
// It is important to use queryLevelJoinHints during the calculation if it is set.
432+
433+
const constructJH = () => {
434+
const filteredJoinMembersJoinHints = joinMembersJoinHints.filter(m => !allMembersJoinHints.includes(m));
435+
return [
436+
...this.queryLevelJoinHints,
437+
...(rootOfJoin ? [rootOfJoin] : []),
438+
...filteredJoinMembersJoinHints,
439+
...allMembersJoinHints,
440+
...customSubQueryJoinHints,
441+
];
442+
};
443+
444+
let prevJoins = this.join;
445+
let prevJoinMembersJoinHints = joinMembersJoinHints;
446+
let newJoin = this.joinGraph.buildJoin(constructJH());
447+
448+
const isOrderPreserved = (base, updated) => {
449+
const common = base.filter(value => updated.includes(value));
450+
const bFiltered = updated.filter(value => common.includes(value));
451+
452+
return common.every((x, i) => x === bFiltered[i]);
453+
};
454+
455+
const isJoinTreesEqual = (a, b) => {
456+
if (!a || !b || a.root !== b.root || a.joins.length !== b.joins.length) {
457+
return false;
458+
}
459+
460+
// We don't care about the order of joins on the same level, so
461+
// we can compare them as sets.
462+
const aJoinsSet = new Set(a.joins.map(j => `${j.originalFrom}->${j.originalTo}`));
463+
const bJoinsSet = new Set(b.joins.map(j => `${j.originalFrom}->${j.originalTo}`));
464+
465+
if (aJoinsSet.size !== bJoinsSet.size) {
466+
return false;
467+
}
468+
469+
for (const val of aJoinsSet) {
470+
if (!bJoinsSet.has(val)) {
471+
return false;
472+
}
473+
}
474+
475+
return true;
476+
};
477+
478+
// Safeguard against infinite loop in case of cyclic joins somehow managed to slip through
479+
let cnt = 0;
480+
481+
while (newJoin?.joins.length > 0 && !isJoinTreesEqual(prevJoins, newJoin) && cnt < 10000) {
482+
prevJoins = newJoin;
483+
joinMembersJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromJoin(newJoin));
484+
if (!isOrderPreserved(prevJoinMembersJoinHints, joinMembersJoinHints)) {
485+
throw new UserError(`Can not construct joins for the query, potential loop detected: ${prevJoinMembersJoinHints.join('->')} vs ${joinMembersJoinHints.join('->')}`);
486+
}
487+
newJoin = this.joinGraph.buildJoin(constructJH());
488+
prevJoinMembersJoinHints = joinMembersJoinHints;
489+
cnt++;
490+
}
491+
492+
if (cnt >= 10000) {
493+
throw new UserError('Can not construct joins for the query, potential loop detected');
494+
}
495+
496+
this.collectedJoinHints = R.uniq(constructJH());
426497
}
427498
return this.collectedJoinHints;
428499
}
@@ -2429,7 +2500,17 @@ export class BaseQuery {
24292500
} else if (s.patchedMeasure?.patchedFrom) {
24302501
return [s.patchedMeasure.patchedFrom.cubeName].concat(this.evaluateSymbolSql(s.patchedMeasure.patchedFrom.cubeName, s.patchedMeasure.patchedFrom.name, s.definition()));
24312502
} else {
2432-
return this.evaluateSql(s.cube().name, s.definition().sql);
2503+
const res = this.evaluateSql(s.cube().name, s.definition().sql);
2504+
if (s.isJoinCondition) {
2505+
// In a join between Cube A and Cube B, sql() may reference members from other cubes.
2506+
// These referenced cubes must be added as join hints before Cube B to ensure correct SQL generation.
2507+
const targetCube = s.targetCubeName();
2508+
let { joinHints } = this.safeEvaluateSymbolContext();
2509+
joinHints = joinHints.filter(e => e !== targetCube);
2510+
joinHints.push(targetCube);
2511+
this.safeEvaluateSymbolContext().joinHints = joinHints;
2512+
}
2513+
return res;
24332514
}
24342515
}
24352516

@@ -2451,7 +2532,17 @@ export class BaseQuery {
24512532
* @returns {Array<Array<string>>}
24522533
*/
24532534
collectJoinHints(excludeTimeDimensions = false) {
2454-
const customSubQueryJoinMembers = this.customSubQueryJoins.map(j => {
2535+
const membersToCollectFrom = [
2536+
...this.allMembersConcat(excludeTimeDimensions),
2537+
...this.joinMembersFromJoin(this.join),
2538+
...this.joinMembersFromCustomSubQuery(),
2539+
];
2540+
2541+
return this.collectJoinHintsFromMembers(membersToCollectFrom);
2542+
}
2543+
2544+
joinMembersFromCustomSubQuery() {
2545+
return this.customSubQueryJoins.map(j => {
24552546
const res = {
24562547
path: () => null,
24572548
cube: () => this.cubeEvaluator.cubeFromPath(j.on.cubeName),
@@ -2465,22 +2556,18 @@ export class BaseQuery {
24652556
getMembers: () => [res],
24662557
};
24672558
});
2559+
}
24682560

2469-
const joinMembers = this.join ? this.join.joins.map(j => ({
2561+
joinMembersFromJoin(join) {
2562+
return join ? join.joins.map(j => ({
24702563
getMembers: () => [{
24712564
path: () => null,
24722565
cube: () => this.cubeEvaluator.cubeFromPath(j.originalFrom),
24732566
definition: () => j.join,
2567+
isJoinCondition: true,
2568+
targetCubeName: () => j.originalTo,
24742569
}]
24752570
})) : [];
2476-
2477-
const membersToCollectFrom = [
2478-
...this.allMembersConcat(excludeTimeDimensions),
2479-
...joinMembers,
2480-
...customSubQueryJoinMembers,
2481-
];
2482-
2483-
return this.collectJoinHintsFromMembers(membersToCollectFrom);
24842571
}
24852572

24862573
collectJoinHintsFromMembers(members) {
@@ -2885,7 +2972,7 @@ export class BaseQuery {
28852972

28862973
pushJoinHints(joinHints) {
28872974
if (this.safeEvaluateSymbolContext().joinHints && joinHints) {
2888-
if (joinHints.length === 1) {
2975+
if (Array.isArray(joinHints) && joinHints.length === 1) {
28892976
[joinHints] = joinHints;
28902977
}
28912978
this.safeEvaluateSymbolContext().joinHints.push(joinHints);

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,9 @@ export class CubeSymbols {
773773

774774
protected joinHints() {
775775
const { joinHints } = this.resolveSymbolsCallContext || {};
776+
if (Array.isArray(joinHints)) {
777+
return R.uniq(joinHints);
778+
}
776779
return joinHints;
777780
}
778781

@@ -879,7 +882,7 @@ export class CubeSymbols {
879882
} else if (this.symbols[cubeName]?.[name]) {
880883
cube = this.cubeReferenceProxy(
881884
cubeName,
882-
undefined,
885+
collectJoinHints ? [] : undefined,
883886
name
884887
);
885888
}

packages/cubejs-schema-compiler/test/integration/postgres/cube-views.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { dbRunner } from './PostgresDBRunner';
66
describe('Cube Views', () => {
77
jest.setTimeout(200000);
88

9+
// language=JavaScript
910
const { compiler, joinGraph, cubeEvaluator, metaTransformer } = prepareJsCompiler(`
1011
cube(\`Orders\`, {
1112
sql: \`

packages/cubejs-schema-compiler/test/integration/postgres/pre-aggregations.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('PreAggregations', () => {
2626
2727
cards: {
2828
relationship: 'hasMany',
29-
sql: \`\${visitors.id} = \${cards.visitorId}\`
29+
sql: \`\${CUBE.id} = \${cards.visitorId}\`
3030
}
3131
},
3232

0 commit comments

Comments
 (0)