Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
caf266f
add tests
KSDaemon Sep 24, 2025
9fd3732
enable view join tests for tesseract
KSDaemon Sep 24, 2025
d914219
fix test
KSDaemon Sep 24, 2025
ac493ab
temp comment out tests
KSDaemon Sep 26, 2025
4b2178f
correct additional hints
KSDaemon Sep 25, 2025
2062bd6
trying to adopt loop test
KSDaemon Oct 6, 2025
56c42c7
linter fix
KSDaemon Oct 6, 2025
e1c0035
small fix in error handling
KSDaemon Oct 8, 2025
7b3f37e
add test case for join maps test
KSDaemon Oct 8, 2025
b7507db
implement join maps
KSDaemon Oct 8, 2025
d472f3e
update snapshot
KSDaemon Oct 8, 2025
b6b7d97
refactor tests
KSDaemon Oct 8, 2025
09fdc21
add more tests
KSDaemon Oct 8, 2025
8922094
return back loop for join resolution
KSDaemon Oct 9, 2025
0475712
fix typo
KSDaemon Oct 9, 2025
143c9c8
join map in tesseract
KSDaemon Oct 9, 2025
5a160d5
enable view join tests for tesseract
KSDaemon Oct 9, 2025
813b54f
cargo fmt
KSDaemon Oct 9, 2025
5f0758d
uncomment test
KSDaemon Oct 10, 2025
2c2b0ba
remove println!
KSDaemon Oct 10, 2025
e5bb80b
remove unneeded rootOfJoin
KSDaemon Oct 14, 2025
4b6e165
refactor tests
KSDaemon Oct 15, 2025
c28e0e9
fix linter warning
KSDaemon Oct 15, 2025
e44126d
refactor allJoinHints()
KSDaemon Oct 15, 2025
9bccba2
refactor: extract inlined isJoinTreesEqual()
KSDaemon Oct 15, 2025
e75e3db
remove unused
KSDaemon Oct 15, 2025
f6f5731
add support for transitive joins in tesseract
KSDaemon Oct 15, 2025
b903ee2
uncomment transitive joins tests for tesseract
KSDaemon Oct 15, 2025
01e2a97
cargo fmt
KSDaemon Oct 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 169 additions & 81 deletions packages/cubejs-schema-compiler/src/adapter/BaseQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
localTimestampToUtc,
timeSeries as timeSeriesBase,
timeSeriesFromCustomInterval,
parseSqlInterval,
findMinGranularityDimension
} from '@cubejs-backend/shared';

Expand Down Expand Up @@ -387,12 +386,59 @@ export class BaseQuery {
}
}

/**
* Is used by native
* This function follows the same logic as in this.collectJoinHints()
* @private
* @param {Array<(Array<string> | string)>} hints
* @return {import('../compiler/JoinGraph').FinishedJoinTree}
*/
joinTreeForHints(hints) {
const explicitJoinHintMembers = new Set(hints.filter(j => Array.isArray(j)).flat());
const queryJoinMaps = this.queryJoinMap();
const newCollectedHints = [];

const constructJH = () => R.uniq(this.enrichHintsWithJoinMap([
...newCollectedHints,
...hints,
],
queryJoinMaps));

let prevJoin = null;
let newJoin = null;

// Safeguard against infinite loop in case of cyclic joins somehow managed to slip through
let cnt = 0;
let newJoinHintsCollectedCnt;

do {
const allJoinHints = constructJH();
prevJoin = newJoin;
newJoin = this.joinGraph.buildJoin(allJoinHints);
const allJoinHintsFlatten = new Set(allJoinHints.flat());
const joinMembersJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromJoin(newJoin));

const iterationCollectedHints = joinMembersJoinHints.filter(j => !allJoinHintsFlatten.has(j));
newJoinHintsCollectedCnt = iterationCollectedHints.length;
cnt++;
if (newJoin) {
newCollectedHints.push(...joinMembersJoinHints.filter(j => !explicitJoinHintMembers.has(j)));
}
} while (newJoin?.joins.length > 0 && !this.isJoinTreesEqual(prevJoin, newJoin) && cnt < 10000 && newJoinHintsCollectedCnt > 0);

if (cnt >= 10000) {
throw new UserError('Can not construct joins for the query, potential loop detected');
}

return newJoin;
}

cacheValue(key, fn, { contextPropNames, inputProps, cache } = {}) {
const currentContext = this.safeEvaluateSymbolContext();
if (contextPropNames) {
const contextKey = {};
for (let i = 0; i < contextPropNames.length; i++) {
contextKey[contextPropNames[i]] = currentContext[contextPropNames[i]];
for (const element of contextPropNames) {
contextKey[element] = currentContext[element];
}
key = key.concat([JSON.stringify(contextKey)]);
}
Expand Down Expand Up @@ -436,83 +482,54 @@ export class BaseQuery {
*/
get allJoinHints() {
if (!this.collectedJoinHints) {
const [rootOfJoin, ...allMembersJoinHints] = this.collectJoinHintsFromMembers(this.allMembersConcat(false));
const customSubQueryJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromCustomSubQuery());
let joinMembersJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromJoin(this.join));

// One cube may join the other cube via transitive joined cubes,
// members from which are referenced in the join `on` clauses.
// We need to collect such join hints and push them upfront of the joining one
// but only if they don't exist yet. Cause in other case we might affect what
// join path will be constructed in join graph.
// It is important to use queryLevelJoinHints during the calculation if it is set.

const constructJH = () => {
const filteredJoinMembersJoinHints = joinMembersJoinHints.filter(m => !allMembersJoinHints.includes(m));
return [
...this.queryLevelJoinHints,
...(rootOfJoin ? [rootOfJoin] : []),
...filteredJoinMembersJoinHints,
...allMembersJoinHints,
...customSubQueryJoinHints,
];
};

let prevJoins = this.join;
let prevJoinMembersJoinHints = joinMembersJoinHints;
let newJoin = this.joinGraph.buildJoin(constructJH());

const isOrderPreserved = (base, updated) => {
const common = base.filter(value => updated.includes(value));
const bFiltered = updated.filter(value => common.includes(value));

return common.every((x, i) => x === bFiltered[i]);
};

const isJoinTreesEqual = (a, b) => {
if (!a || !b || a.root !== b.root || a.joins.length !== b.joins.length) {
return false;
}

// We don't care about the order of joins on the same level, so
// we can compare them as sets.
const aJoinsSet = new Set(a.joins.map(j => `${j.originalFrom}->${j.originalTo}`));
const bJoinsSet = new Set(b.joins.map(j => `${j.originalFrom}->${j.originalTo}`));

if (aJoinsSet.size !== bJoinsSet.size) {
return false;
}

for (const val of aJoinsSet) {
if (!bJoinsSet.has(val)) {
return false;
}
}
this.collectedJoinHints = this.collectJoinHints();
}
return this.collectedJoinHints;
}

return true;
};
/**
* @private
* @return { Record<string, string[][]>}
*/
queryJoinMap() {
const queryMembers = this.allMembersConcat(false);
const joinMaps = {};

for (const member of queryMembers) {
const memberCube = member.cube?.();
if (memberCube?.isView && !joinMaps[memberCube.name] && memberCube.joinMap) {
joinMaps[memberCube.name] = memberCube.joinMap;
}
}

// Safeguard against infinite loop in case of cyclic joins somehow managed to slip through
let cnt = 0;
return joinMaps;
}

while (newJoin?.joins.length > 0 && !isJoinTreesEqual(prevJoins, newJoin) && cnt < 10000) {
prevJoins = newJoin;
joinMembersJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromJoin(newJoin));
if (!isOrderPreserved(prevJoinMembersJoinHints, joinMembersJoinHints)) {
throw new UserError(`Can not construct joins for the query, potential loop detected: ${prevJoinMembersJoinHints.join('->')} vs ${joinMembersJoinHints.join('->')}`);
}
newJoin = this.joinGraph.buildJoin(constructJH());
prevJoinMembersJoinHints = joinMembersJoinHints;
cnt++;
/**
* @private
* @param { (string|string[])[] } hints
* @param { Record<string, string[][]>} joinMap
* @return {(string|string[])[]}
*/
enrichHintsWithJoinMap(hints, joinMap) {
// Potentially, if joins between views would take place, we need to distinguish
// join maps on per view basis.
const allPaths = Object.values(joinMap).flat();

return hints.map(hint => {
if (Array.isArray(hint)) {
return hint;
}

if (cnt >= 10000) {
throw new UserError('Can not construct joins for the query, potential loop detected');
for (const path of allPaths) {
const hintIndex = path.indexOf(hint);
if (hintIndex !== -1) {
return path.slice(0, hintIndex + 1);
}
}

this.collectedJoinHints = R.uniq(constructJH());
}
return this.collectedJoinHints;
return hint;
});
}

get dataSource() {
Expand Down Expand Up @@ -2613,18 +2630,89 @@ export class BaseQuery {
}

/**
*
* Just a helper to avoid copy/paste
* @private
* @param {import('../compiler/JoinGraph').FinishedJoinTree} a
* @param {import('../compiler/JoinGraph').FinishedJoinTree} b
* @return {boolean}
*/
isJoinTreesEqual(a, b) {
if (!a || !b || a.root !== b.root || a.joins.length !== b.joins.length) {
return false;
}

// We don't care about the order of joins on the same level, so
// we can compare them as sets.
const aJoinsSet = new Set(a.joins.map(j => `${j.originalFrom}->${j.originalTo}`));
const bJoinsSet = new Set(b.joins.map(j => `${j.originalFrom}->${j.originalTo}`));

if (aJoinsSet.size !== bJoinsSet.size) {
return false;
}

for (const val of aJoinsSet) {
if (!bJoinsSet.has(val)) {
return false;
}
}

return true;
}

/**
* @private
* @param {boolean} [excludeTimeDimensions=false]
* @returns {Array<Array<string>>}
* @returns {Array<(Array<string> | string)>}
*/
collectJoinHints(excludeTimeDimensions = false) {
const membersToCollectFrom = [
...this.allMembersConcat(excludeTimeDimensions),
...this.joinMembersFromJoin(this.join),
...this.joinMembersFromCustomSubQuery(),
];
const allMembersJoinHints = this.collectJoinHintsFromMembers(this.allMembersConcat(excludeTimeDimensions));
const explicitJoinHintMembers = new Set(allMembersJoinHints.filter(j => Array.isArray(j)).flat());
const queryJoinMaps = this.queryJoinMap();
const customSubQueryJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromCustomSubQuery());
const newCollectedHints = [];

// One cube may join the other cube via transitive joined cubes,
// members from which are referenced in the join `on` clauses.
// We need to collect such join hints and push them upfront of the joining one
// but only if they don't exist yet. Cause in other case we might affect what
// join path will be constructed in join graph.
// It is important to use queryLevelJoinHints during the calculation if it is set.

const constructJH = () => R.uniq(this.enrichHintsWithJoinMap([
...this.queryLevelJoinHints,
...newCollectedHints,
...allMembersJoinHints,
...customSubQueryJoinHints,
],
queryJoinMaps));

let prevJoin = null;
let newJoin = null;

// Safeguard against infinite loop in case of cyclic joins somehow managed to slip through
let cnt = 0;
let newJoinHintsCollectedCnt;

do {
const allJoinHints = constructJH();
prevJoin = newJoin;
newJoin = this.joinGraph.buildJoin(allJoinHints);
const allJoinHintsFlatten = new Set(allJoinHints.flat());
const joinMembersJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromJoin(newJoin));

const iterationCollectedHints = joinMembersJoinHints.filter(j => !allJoinHintsFlatten.has(j));
newJoinHintsCollectedCnt = iterationCollectedHints.length;
cnt++;
if (newJoin) {
newCollectedHints.push(...joinMembersJoinHints.filter(j => !explicitJoinHintMembers.has(j)));
}
} while (newJoin?.joins.length > 0 && !this.isJoinTreesEqual(prevJoin, newJoin) && cnt < 10000 && newJoinHintsCollectedCnt > 0);

if (cnt >= 10000) {
throw new UserError('Can not construct joins for the query, potential loop detected');
}

return this.collectJoinHintsFromMembers(membersToCollectFrom);
return constructJH();
}

joinMembersFromCustomSubQuery() {
Expand Down
24 changes: 18 additions & 6 deletions packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export type AccessPolicyDefinition = {
};
};

export type ViewIncludedMember = {
type: string;
memberPath: string;
name: string;
};

export interface CubeDefinition {
name: string;
extends?: (...args: Array<unknown>) => { __cubeName: string };
Expand All @@ -159,7 +165,8 @@ export interface CubeDefinition {
isView?: boolean;
calendar?: boolean;
isSplitView?: boolean;
includedMembers?: any[];
includedMembers?: ViewIncludedMember[];
joinMap?: string[][];
fileName?: string;
}

Expand Down Expand Up @@ -562,6 +569,8 @@ export class CubeSymbols implements TranspilerSymbolResolver {
// `hierarchies` must be processed first
const types = ['hierarchies', 'measures', 'dimensions', 'segments'];

const joinMap: string[][] = [];

for (const type of types) {
let cubeIncludes: any[] = [];

Expand All @@ -573,6 +582,11 @@ export class CubeSymbols implements TranspilerSymbolResolver {
const split = fullPath.split('.');
const cubeRef = split[split.length - 1];

// No need to keep a simple direct cube joins in join map
if (split.length > 1) {
joinMap.push(split);
}

if (it.includes === '*') {
return it;
}
Expand Down Expand Up @@ -614,11 +628,7 @@ export class CubeSymbols implements TranspilerSymbolResolver {
existing.map(({ type: t, memberPath, name }) => `${t}|${memberPath}|${name}`)
);

const additions: {
type: string;
memberPath: string;
name: string;
}[] = [];
const additions: ViewIncludedMember[] = [];

for (const { member, name } of cubeIncludes) {
const parts = member.split('.');
Expand All @@ -636,6 +646,8 @@ export class CubeSymbols implements TranspilerSymbolResolver {
}
}

cube.joinMap = joinMap;

[...memberSets.allMembers].filter(it => !memberSets.resolvedMembers.has(it)).forEach(it => {
errorReporter.error(`Member '${it}' is included in '${cube.name}' but not defined in any cube`);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ export class DataSchemaCompiler {
if (e.toString().indexOf('SyntaxError') !== -1) {
const err = e as SyntaxErrorInterface;
const line = file.content.split('\n')[(err.loc?.start?.line || 1) - 1];
const spaces = Array(err.loc?.start.column).fill(' ').join('');
const spaces = Array(err.loc?.start?.column).fill(' ').join('') || '';
errorsReport.error(`Syntax error during parsing: ${err.message}:\n${line}\n${spaces}^`, file.fileName);
} else {
errorsReport.error(e);
Expand Down
Loading
Loading