Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { PostgresQuery } from '../../../src/adapter/PostgresQuery';
import { prepareYamlCompiler } from '../../unit/PrepareCompiler';
import { dbRunner } from './PostgresDBRunner';

// A cube's own primary key as a query dimension, next to measures from two
// cubes. The measures split into per-cube subqueries, and the one on the
// `one` side of the join is multiplied by the fan-out, so it is read through
// the keys subquery and re-joined to its own cube by that same primary key.
// The key plays two roles at once - query dimension and re-join key - and has
// to be projected once: two columns under one alias make the re-join's
// reference to it ambiguous.
describe('Primary key dimension on the multi-fact path', () => {
jest.setTimeout(200000);

const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler(`
cubes:
- name: cube_a
sql_alias: a
sql: >
SELECT 1 AS id, 100 AS value_a UNION ALL
SELECT 2 AS id, 200 AS value_a
dimensions:
- name: id
sql: id
type: number
primary_key: true
public: true
measures:
- name: measure_a
sql: value_a
type: sum

- name: cube_b
sql_alias: b
sql: >
SELECT 10 AS id, 1 AS a_id, '2026-07-05'::timestamp AS date, 5 AS value_b UNION ALL
SELECT 11 AS id, 1 AS a_id, '2026-07-10'::timestamp AS date, 7 AS value_b UNION ALL
SELECT 12 AS id, 2 AS a_id, '2026-07-15'::timestamp AS date, 9 AS value_b
joins:
- name: cube_a
relationship: many_to_one
sql: "{CUBE.a_id} = {cube_a.id}"
dimensions:
- name: id
sql: id
type: number
primary_key: true
- name: a_id
sql: a_id
type: number
- name: date
sql: date
type: time
measures:
- name: measure_b
sql: value_b
type: sum
`);

async function runQuery(q) {
await compiler.compile();
const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, q);
return dbRunner.testQuery(query.buildSqlAndParams());
}

it('primary key dimension next to measures from two cubes', async () => {
// measure_a must be counted once per cube_a row despite the two cube_b
// rows that share a_id = 1.
expect(await runQuery({
measures: ['cube_b.measure_b', 'cube_a.measure_a'],
dimensions: ['cube_a.id'],
timeDimensions: [{
dimension: 'cube_b.date',
granularity: 'month',
dateRange: ['2026-07-01', '2026-07-31'],
}],
order: [{ id: 'cube_a.id' }],
timezone: 'UTC',
})).toEqual([
{
a__id: 1,
b__date_month: '2026-07-01T00:00:00.000Z',
b__measure_b: '12',
a__measure_a: '100',
},
{
a__id: 2,
b__date_month: '2026-07-01T00:00:00.000Z',
b__measure_b: '9',
a__measure_a: '200',
},
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ impl<'a> LogicalNodeProcessor<'a, KeysSubQuery> for KeysSubQueryProcessor<'a> {

if !context.dimensions_query {
for member in keys_subquery.primary_keys_dimensions().iter() {
// A primary key that is also a query dimension is already
// projected above. Projecting it again would put two columns
// under one alias, making every reference to it from the
// enclosing re-join ambiguous. Symbols are matched the way
// `Schema::find_column_for_member` matches them, so that the
// re-join resolves to the surviving column.
let resolved = member.clone().resolve_reference_chain();
if keys_subquery
.schema()
.all_dimensions()
.any(|dim| dim.clone().resolve_reference_chain() == resolved)
{
continue;
}
Comment on lines +95 to +102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness looks right, but this re-implements Schema::find_column_for_member's matching rule (origin_member.resolve_reference_chain() == member.resolve_reference_chain()) against the logical schema instead of asking the physical schema that's actually being built. The comment acknowledges the coupling — the risk is that the two rules drift: if find_column_for_member ever changes (e.g. to also compare granularity or discriminant explicitly), this dedup silently stops matching it and the ambiguity comes back, with no test in default CI to catch it (see the other comment).

SelectBuilder already accumulates result_schema and add_projection_member records SchemaColumn::new(alias, Some(member)) for every projection above, so a small accessor would let this be expressed in terms of the single source of truth:

if select_builder.result_schema().find_column_for_member(member).is_some() {
    continue;
}

That also covers columns added through paths other than the all_dimensions() loop. Non-blocking — just a suggestion to keep one matching rule instead of two.

let alias = member.alias();
references_builder.resolve_references_for_member(
member.clone(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,35 @@ async fn test_non_multiplied_multi_join() {
}
}

#[tokio::test(flavor = "multi_thread")]
async fn test_multiplied_aggregate_grouped_by_own_primary_key() {
let ctx = create_context();

// customers.total_lifetime_value is multiplied by the customers→orders
// join, so it is read through the keys subquery and re-joined to customers
// by customers' primary key. That key is also a query dimension here, so it
// plays both roles at once and the keys subquery has to project it exactly
// once - two columns under one alias make every reference to it from the
// re-join ambiguous.
let query = indoc! {"
measures:
- customers.total_lifetime_value
- orders.count
dimensions:
- customers.id
- orders.status
order:
- id: customers.id
- id: orders.status
"};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test can't fail in CI. unit-core in .github/workflows/push.yml:233 runs cargo test --workspace --features cubesqlplanner/integration-cubestore — no integration-postgres — so try_execute_pg returns None and only build_sql(query).unwrap() runs, which succeeds with the bug present (the failure is Postgres-side). The PR description notes this, and it does match the surrounding suite, so it's not a blocker; the schema-compiler test does run in CI (integration:postgres, and for postgres the matrix covers both CUBEJS_TESSERACT_SQL_PLANNER true and false), so the fix isn't unguarded.

Still, the defect here is purely a SQL-shape one and is cheap to pin without a database — tests/common_sql_generation.rs has the pattern (insta::assert_snapshot! over build_sql). A snapshot showing the keys subquery projecting "a__id" once would make unit-core catch a regression directly on the changed line, and would also catch drift between this dedup and find_column_for_member.

ctx.build_sql(query).unwrap();

if let Some(result) = ctx.try_execute_pg(query, SEED).await {
insta::assert_snapshot!(result);
}
}

#[tokio::test(flavor = "multi_thread")]
async fn test_multi_fact_view_two_facts_with_measure_filter() {
let schema = MockSchema::from_yaml_file("common/integration_multi_fact_view.yaml");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
source: cubesqlplanner/cubesqlplanner/src/tests/integration/multi_fact.rs
expression: result
---
customers__id | orders__status | customers__total_lifetime_value | orders__count
--------------+----------------+---------------------------------+--------------
1 | completed | 1000.00 | 2
1 | pending | 1000.00 | 2
2 | completed | 2000.00 | 2
2 | pending | 2000.00 | 1
3 | NULL | 500.00 | 0
4 | completed | 1500.00 | 1
Loading