Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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
106 changes: 73 additions & 33 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 @@ -436,9 +435,11 @@ export class BaseQuery {
*/
get allJoinHints() {
if (!this.collectedJoinHints) {
const [rootOfJoin, ...allMembersJoinHints] = this.collectJoinHintsFromMembers(this.allMembersConcat(false));
const allMembersJoinHints = this.collectJoinHintsFromMembers(this.allMembersConcat(false));
const explicitJoinHintMembers = new Set(allMembersJoinHints.filter(j => Array.isArray(j)).flat());
const queryJoinMaps = this.queryJoinMap();
const customSubQueryJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromCustomSubQuery());
let joinMembersJoinHints = this.collectJoinHintsFromMembers(this.joinMembersFromJoin(this.join));
const newCollectedHints = [];

// One cube may join the other cube via transitive joined cubes,
// members from which are referenced in the join `on` clauses.
Expand All @@ -447,27 +448,16 @@ export class BaseQuery {
// 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 constructJH = () => R.uniq(this.enrichHintsWithJoinMap([
...this.queryLevelJoinHints,
...newCollectedHints,
...allMembersJoinHints,
...customSubQueryJoinHints,
],
queryJoinMaps));

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]);
};
let prevJoin = null;
let newJoin = null;

const isJoinTreesEqual = (a, b) => {
if (!a || !b || a.root !== b.root || a.joins.length !== b.joins.length) {
Expand All @@ -494,27 +484,77 @@ export class BaseQuery {

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

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;
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 && !isJoinTreesEqual(prevJoin, newJoin) && cnt < 10000 && newJoinHintsCollectedCnt > 0);

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

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

/**
* @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]) {
joinMaps[memberCube.name] = memberCube.joinMap;
}
}

return joinMaps;
}

/**
* @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;
}

for (const path of allPaths) {
const hintIndex = path.indexOf(hint);
if (hintIndex !== -1) {
return path.slice(0, hintIndex + 1);
}
}

return hint;
});
}

get dataSource() {
const dataSources = R.uniq(this.allCubeNames.map(c => this.cubeDataSource(c)));
if (dataSources.length > 1 && !this.externalPreAggregationQuery()) {
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
Original file line number Diff line number Diff line change
Expand Up @@ -5198,7 +5198,7 @@ cubes:
sql: amount
type: sum

# Join loop for testing transitive joins
# Model for testing multiple joins to the same cube via transitive joins
- name: alpha_facts
sql: |
(
Expand All @@ -5215,7 +5215,7 @@ cubes:
sql: "{CUBE}.b_id = {gamma_dims.b_id}"
- name: delta_bridge
relationship: many_to_one
sql: "{beta_dims.a_name} = {delta_bridge.a_name} AND {gamma_dims.b_name} = {delta_bridge.b_name}"
sql: "{beta_dims.a_name} = {delta_bridge.a_name} AND {gamma_dims.b_name} = {delta_bridge.c_name}"
dimensions:
- name: reporting_date
sql: reporting_date
Expand Down Expand Up @@ -5256,9 +5256,9 @@ cubes:
- name: gamma_dims
sql: |
(
SELECT 10 AS b_id, 'Beta1' AS b_name
SELECT 10 AS b_id, 'Beta1' AS b_name, 'Gamma1' AS c_name
UNION ALL
SELECT 20 AS b_id, 'Beta2' AS b_name
SELECT 20 AS b_id, 'Beta2' AS b_name, 'Gamma2' AS c_name
)
dimensions:
- name: b_id
Expand All @@ -5272,16 +5272,16 @@ cubes:
- name: delta_bridge
sql: |
(
SELECT 'Alpha1' AS a_name, 'Beta1' AS b_name, 'Organic' AS channel
SELECT 'Alpha1' AS a_name, 'Beta1' AS b_name, 'Beta1' AS c_name, 'Organic' AS channel
UNION ALL
SELECT 'Alpha1' AS a_name, 'Beta2' AS b_name, 'Paid' AS channel
SELECT 'Alpha2' AS a_name, 'Beta2' AS b_name, 'Beta2' AS c_name, 'Paid' AS channel
UNION ALL
SELECT 'Alpha2' AS a_name, 'Beta1' AS b_name, 'Referral' AS channel
SELECT 'Alpha1' AS a_name, 'Beta1' AS b_name, 'Beta3' AS c_name, 'Referral' AS channel
)
joins:
- name: gamma_dims
relationship: many_to_one
sql: "{CUBE}.b_name = {gamma_dims.b_name}"
sql: "{CUBE}.c_name = {gamma_dims.c_name}"
dimensions:
- name: a_name
sql: a_name
Expand All @@ -5290,7 +5290,9 @@ cubes:
- name: b_name
sql: "{gamma_dims.b_name}"
type: string
primary_key: true
- name: c_name
sql: c_name
type: string
- name: channel
sql: channel
type: string
Expand Down Expand Up @@ -5344,31 +5346,40 @@ cubes:
}

if (!getEnv('nativeSqlPlanner')) {
it('querying cube with transitive joins with loop', async () => {
it('querying cube with transitive joins with a few joins to the same cube', async () => {
await compiler.compile();

try {
const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, {
measures: [],
dimensions: [
'alpha_facts.reporting_date',
'delta_bridge.b_name',
'alpha_facts.channel'
],
order: [{
id: 'alpha_facts.reporting_date'
}],
timezone: 'America/Los_Angeles'
});

await dbRunner.testQuery(query.buildSqlAndParams());
throw new Error('Should have thrown an error');
} catch (err: any) {
expect(err.message).toContain('Can not construct joins for the query, potential loop detected');
}
const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, {
measures: [],
dimensions: [
'alpha_facts.reporting_date',
'delta_bridge.b_name',
'alpha_facts.channel'
],
order: [{
id: 'alpha_facts.reporting_date'
}],
timezone: 'America/Los_Angeles'
});

const res = await dbRunner.testQuery(query.buildSqlAndParams());
console.log(JSON.stringify(res));

expect(res).toEqual([
{
alpha_facts__channel: 'Organic',
alpha_facts__reporting_date: '2023-01-01T00:00:00.000Z',
delta_bridge__b_name: 'Beta1',
},
{
alpha_facts__channel: 'Paid',
alpha_facts__reporting_date: '2023-01-02T00:00:00.000Z',
delta_bridge__b_name: 'Beta2',
},
]);
});
} else {
it.skip('FIXME(tesseract): querying cube dimension that require transitive joins', async () => {
it.skip('FIXME(tesseract): querying cube with transitive joins with a few joins to the same cube', async () => {
// FIXME should be implemented in Tesseract
});
}
Expand Down
Loading
Loading