From d0adfe83bc9d02ef9475c2a52b3b9885e7610d69 Mon Sep 17 00:00:00 2001 From: Thomas Langton <155970791+tlangton3@users.noreply.github.com> Date: Mon, 11 May 2026 17:25:53 +0100 Subject: [PATCH] fix(schema-compiler): resolve multi_stage order_by template in owning cube context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a multi_stage measure (e.g. type: rank) is exposed through a view, its order_by template — which references other cube members by bare name — was being evaluated against the view's symbol table rather than the underlying cube's. If the view didn't expose the referenced member under its bare name (either not at all, or only under an alias), the resolver returned undefined and BaseQuery.evaluateSql crashed at the unguarded _objectWithResolvedProperties access with: TypeError: Cannot read properties of undefined (reading '_objectWithResolvedProperties') The referenced member is a cube-internal implementation detail of the rank — it doesn't need to be (and shouldn't be) exposed on every view that includes the rank. Fix: - CubeSymbols.generateIncludeMembers now tags view-generated measure definitions that carry an order_by template with orderByCubeName (the underlying cube's name), so the consumer of the symbol knows where the template's references were authored. - BaseQuery.evaluateSymbolSql uses symbol.orderByCubeName (falling back to cubeName for direct cube access) when evaluating the order_by template, matching the Rust planner's semantic of compiling measure templates in the owning cube's context. - BaseQuery.evaluateSql now throws a clear UserError when cubeEvaluator.resolveSymbol returns undefined, instead of leaving an unguarded _objectWithResolvedProperties access to surface as a generic TypeError. Adds a regression test exercising the production shape: a view that exposes only the rank + a time dim, with the rank's order_by referencing a cube measure not exposed on the view. Fixes #10856. --- .../src/adapter/BaseQuery.js | 17 ++++- .../src/compiler/CubeSymbols.ts | 10 ++- .../multi-stage-orderby-view-alias.test.ts | 73 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 packages/cubejs-schema-compiler/test/unit/multi-stage-orderby-view-alias.test.ts diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js index 24bc94423b54b..ea9390c2adccc 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js +++ b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js @@ -3346,7 +3346,16 @@ export class BaseQuery { } } const primaryKeys = this.cubeEvaluator.primaryKeys[cubeName]; - const orderBySql = (symbol.orderBy || []).map(o => ({ sql: this.evaluateSql(cubeName, o.sql), dir: o.dir })); + // Evaluate the measure's order_by template in the cube context where + // it was authored, not the consumer's queried cube/view context. When + // a multi_stage measure (e.g. type: rank) is exposed on a view, its + // order_by template may reference other cube members by bare name + // (e.g. `{num_parcels}`) which only exist on the underlying cube. + // CubeSymbols.generateIncludeMembers tags view-generated measure + // definitions with `orderByCubeName` for this purpose; falls back to + // `cubeName` for direct cube access. See cube-js/cube#10856. + const orderByCubeName = symbol.orderByCubeName || cubeName; + const orderBySql = (symbol.orderBy || []).map(o => ({ sql: this.evaluateSql(orderByCubeName, o.sql), dir: o.dir })); let sql; let patchedSymbol = symbol; if (symbol.type !== 'rank') { @@ -3589,6 +3598,12 @@ export class BaseQuery { cubeName, name ); + if (!resolvedSymbol) { + throw new UserError( + `Symbol '${name}' could not be resolved on cube '${cubeName}'. ` + + 'Check that the referenced member exists and is accessible from this context.' + ); + } // eslint-disable-next-line no-underscore-dangle if (resolvedSymbol._objectWithResolvedProperties) { return resolvedSymbol; diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts index e34ac15082c42..9b9b6acf05747 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts @@ -986,7 +986,15 @@ export class CubeSymbols implements TranspilerSymbolResolver, CompilerInterface ...(propagatedCurrency ? { currency: propagatedCurrency } : {}), ...(resolvedMember.multiStage && { multiStage: resolvedMember.multiStage }), ...(resolvedMember.timeShift && { timeShift: resolvedMember.timeShift }), - ...(resolvedMember.orderBy && { orderBy: resolvedMember.orderBy }), + // Carry the owning cube's name through to the view-generated + // measure definition so that internal templates (e.g. order_by, + // which references other cube members by bare name) can be + // resolved against the cube context where they were authored, + // not the consumer view's context. See cube-js/cube#10856. + ...(resolvedMember.orderBy && { + orderBy: resolvedMember.orderBy, + orderByCubeName: path[path.length - 2], + }), ...(processedDrillMembers && { drillMembers: processedDrillMembers }), ...(resolvedMember.drillMembersGrouped && { drillMembersGrouped: resolvedMember.drillMembersGrouped }), ...(resolvedMember.mask !== undefined ? { mask: resolvedMember.mask } : {}), diff --git a/packages/cubejs-schema-compiler/test/unit/multi-stage-orderby-view-alias.test.ts b/packages/cubejs-schema-compiler/test/unit/multi-stage-orderby-view-alias.test.ts new file mode 100644 index 0000000000000..5de4f9a2ece69 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/multi-stage-orderby-view-alias.test.ts @@ -0,0 +1,73 @@ +/* eslint-disable no-restricted-syntax */ +import { PostgresQuery } from '../../src/adapter/PostgresQuery'; +import { prepareJsCompiler } from './PrepareCompiler'; + +describe('Multi-stage rank order_by template resolution through a view (issue #10856)', () => { + const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT 1 as id, 5 as parcels, '2024-01-01'::timestamp as created_at\`, + + dimensions: { + id: { + sql: \`id\`, + type: \`number\`, + primary_key: true, + public: true + }, + + created_at: { + sql: \`created_at\`, + type: \`time\` + } + }, + + measures: { + num_parcels: { + type: \`sum\`, + sql: \`parcels\` + }, + + volume_by_day_rank: { + multi_stage: true, + type: \`rank\`, + order_by: [{ + sql: \`\${num_parcels}\`, + dir: 'desc' + }], + reduce_by: [created_at] + } + } + }); + + view(\`orders_view\`, { + cubes: [{ + join_path: orders, + includes: [\`created_at\`, \`volume_by_day_rank\`] + }] + }); + `); + + it('resolves rank order_by against the owning cube when queried through a view that does not expose the referenced measure', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders_view.volume_by_day_rank'], + timeDimensions: [{ + dimension: 'orders_view.created_at', + granularity: 'day', + }], + timezone: 'UTC', + }); + + // Before the fix, buildSqlAndParams crashes with: + // TypeError: Cannot read properties of undefined (reading '_objectWithResolvedProperties') + // because the rank's order_by template `${num_parcels}` was being resolved + // against the view's symbol table (which doesn't expose num_parcels) rather + // than the cube's. + const [sql] = query.buildSqlAndParams(); + + // The cube column `parcels` (which num_parcels aggregates) must appear in + // the rank's ORDER BY clause. + expect(sql).toMatch(/ORDER BY[^)]*parcels/i); + }); +});