diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js index f1137a4d607f0..c44479802d581 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js +++ b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js @@ -1780,7 +1780,25 @@ export class BaseQuery { // TODO not working yet const membersToSelect = measures?.concat(multiStageDimensions).concat(multiStageTimeDimensions); const select = fromSubQuery && fromSubQuery.outerMeasuresJoinFullKeyQueryAggregate(membersToSelect, membersToSelect, withQuery.memberFrom.map(f => f.alias)); - const fromSql = select && this.wrapInParenthesis(select); + // Leaf multi_stage measure: sql references a raw column with no measure dependencies, + // so fromMeasures is null, fromSubQuery was never built, and select is null. + // Fall back to querying the cube's own table directly instead of producing an empty FROM. + // See: https://github.com/cube-js/cube/issues/9241 + let fromSql = select && this.wrapInParenthesis(select); + if (!fromSql && !fromSubQuery && withQuery.measures?.length) { + const leafFromQuery = this.newSubQuery({ + measures: withQuery.measures, + dimensions: withQuery.dimensions, + timeDimensions: withQuery.timeDimensions, + multiStageDimensions: withQuery.multiStageDimensions, + multiStageTimeDimensions: withQuery.multiStageTimeDimensions, + filters: withQuery.filters, + segments: withQuery.segments, + multiStageQuery: true, + disableExternalPreAggregations: true, + }); + fromSql = this.wrapInParenthesis(leafFromQuery.buildParamAnnotatedSql()); + } const subQueryOptions = { measures: withQuery.measures, diff --git a/packages/cubejs-schema-compiler/test/unit/base-query.test.ts b/packages/cubejs-schema-compiler/test/unit/base-query.test.ts index 5aacf5344dcff..d93b1c19b40fe 100644 --- a/packages/cubejs-schema-compiler/test/unit/base-query.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/base-query.test.ts @@ -2683,4 +2683,110 @@ describe('Class unit tests', () => { const re = new RegExp('(b__aid).*(b__bval_sum).*(b__count).*'); expect(re.test(sql[0])).toBeTruthy(); }); + + describe('multi_stage leaf measure (raw column sql, no measure dependencies)', () => { + // Regression tests for https://github.com/cube-js/cube/issues/9241 + // + // The bug: a multi_stage measure whose sql references a raw column (no {} measure dependency) + // has no children in the member dependency graph. When a higher-level multi_stage measure + // references it via {leaf_measure}, renderWithQuery is called for the leaf withQuery with + // memberFrom=null. This left fromSubQuery=null and fromSql=null, causing the error check + // at the end of renderWithQuery to throw "lacks FROM clause". + // + // The two-level schema is required to trigger the path: the outer measure forces + // renderWithQuery to be called for the leaf, which is where the fix applies. + const compilers = prepareYamlCompiler(` +cubes: + - name: orders + sql_table: orders + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: status + sql: status + type: string + + measures: + - name: raw_sum + sql: amount + type: sum + multi_stage: true + + - name: raw_avg + sql: amount + type: avg + multi_stage: true + + - name: computed_from_raw_sum + sql: "{raw_sum}" + type: number + multi_stage: true + + - name: computed_from_raw_avg + sql: "{raw_avg}" + type: number + multi_stage: true +`); + + it('does not throw "lacks FROM clause" (sum leaf)', async () => { + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: ['orders.computed_from_raw_sum'], + dimensions: ['orders.status'], + timeDimensions: [], + filters: [], + }); + + expect(() => query.buildSqlAndParams()).not.toThrow(); + }); + + it('generates a CTE query (multi_stage path is taken)', async () => { + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: ['orders.computed_from_raw_sum'], + dimensions: ['orders.status'], + timeDimensions: [], + filters: [], + }); + + const [sql] = query.buildSqlAndParams(); + expect(sql).toMatch(/WITH\s+cte_\d+\s+AS/i); + expect(sql).toContain('orders'); + }); + + it('propagates filters through the leaf fallback subquery', async () => { + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: ['orders.computed_from_raw_sum'], + dimensions: ['orders.status'], + timeDimensions: [], + filters: [{ member: 'orders.status', operator: 'equals', values: ['completed'] }], + }); + + const [sql] = query.buildSqlAndParams(); + expect(sql).toMatch(/WHERE/i); + }); + + it('works for avg type as well as sum', async () => { + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: ['orders.computed_from_raw_avg'], + dimensions: ['orders.status'], + timeDimensions: [], + filters: [], + }); + + expect(() => query.buildSqlAndParams()).not.toThrow(); + const [sql] = query.buildSqlAndParams(); + expect(sql).toMatch(/WITH\s+cte_\d+\s+AS/i); + }); + }); });