-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(tesseract): project a primary key once in the keys subquery #11527
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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 | ||
| "}; | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test can't fail in CI. Still, the defect here is purely a SQL-shape one and is cheap to pin without a database — |
||
| 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"); | ||
|
|
||
| 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 |
There was a problem hiding this comment.
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: iffind_column_for_memberever 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).SelectBuilderalready accumulatesresult_schemaandadd_projection_memberrecordsSchemaColumn::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: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.