diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/primary-key-multi-fact.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/primary-key-multi-fact.test.ts new file mode 100644 index 0000000000000..3e14b599e7198 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/integration/postgres/primary-key-multi-fact.test.ts @@ -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', + }, + ]); + }); +}); diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/keys_sub_query.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/keys_sub_query.rs index 86a28dbea19f8..28d2365924f86 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/keys_sub_query.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/keys_sub_query.rs @@ -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; + } let alias = member.alias(); references_builder.resolve_references_for_member( member.clone(), diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_fact.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_fact.rs index f7381b4e99a6e..794148bbba6fd 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_fact.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_fact.rs @@ -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 + "}; + + 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"); diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__multi_fact__multiplied_aggregate_grouped_by_own_primary_key.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__multi_fact__multiplied_aggregate_grouped_by_own_primary_key.snap new file mode 100644 index 0000000000000..91ae41cf6c1ac --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__multi_fact__multiplied_aggregate_grouped_by_own_primary_key.snap @@ -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