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); + }); +});