diff --git a/docs-mintlify/reference/configuration/environment-variables.mdx b/docs-mintlify/reference/configuration/environment-variables.mdx
index 8b14feca383ba..dbe98d287c758 100644
--- a/docs-mintlify/reference/configuration/environment-variables.mdx
+++ b/docs-mintlify/reference/configuration/environment-variables.mdx
@@ -31,6 +31,41 @@ different for multitenant setups.
| --------------- | ---------------------- | --------------------- |
| A valid string | `cubejs` | `cubejs` |
+## `CUBEJS_AUTO_DRILL_MEMBERS`
+
+If `true`, measures that declare no [`drill_members`][ref-drill-members] get a
+default set computed when the data model is compiled: the cube's own dimensions,
+primary key first, capped by
+[`CUBEJS_AUTO_DRILL_MEMBERS_LIMIT`](#cubejs_auto_drill_members_limit). Without
+this, a measure with no `drill_members` offers no [drill down][ref-drilldowns]
+at all.
+
+A measure that declares `drill_members` is never touched — including
+`drill_members: []`, which is how you suppress the default for one measure.
+Non-public dimensions are excluded, except the primary key; sub-query dimensions
+are excluded too, since each one costs a join per drill.
+
+For a view, the default names the view's own members, so a drill from a view
+measure resolves rather than dead-ending. A view has no primary key of its own,
+so there the set is simply the dimensions the view includes, in the order it
+includes them.
+
+| Possible Values | Default in Development | Default in Production |
+| --------------- | ---------------------- | --------------------- |
+| `true`, `false` | `false` | `false` |
+
+## `CUBEJS_AUTO_DRILL_MEMBERS_LIMIT`
+
+The most drill members
+[`CUBEJS_AUTO_DRILL_MEMBERS`](#cubejs_auto_drill_members) will add to a measure.
+Has no effect unless that variable is enabled. `0` disables the default set
+entirely. With `CUBEJS_AUTO_DRILL_MEMBERS` enabled, a value that isn't a number
+fails data model compilation.
+
+| Possible Values | Default in Development | Default in Production |
+| --------------- | ---------------------- | --------------------- |
+| A number | `10` | `10` |
+
## `CUBEJS_AUTO_RUN_MODE`
The deployment-wide default for whether [Explore](/docs/explore-analyze/explore)
@@ -2211,6 +2246,8 @@ The port for a Cube deployment to listen to API connections on.
[link-tesseract]: https://cube.dev/blog/introducing-next-generation-data-modeling-engine
[ref-multi-stage-calculations]: /docs/data-modeling/measures#multi-stage-measures
[ref-folders]: /reference/data-modeling/view#folders
+[ref-drill-members]: /reference/data-modeling/measures#drill_members
+[ref-drilldowns]: /recipes/core-data-api/drilldowns
[ref-dataviz-tools]: /admin/connect-to-data/visualization-tools
[ref-context-to-app-id]: /reference/configuration/config#context_to_app_id
[ref-environments]: /admin/deployment/environments
diff --git a/docs-mintlify/reference/data-modeling/measures.mdx b/docs-mintlify/reference/data-modeling/measures.mdx
index ed81bb6896dea..e94aed7e6087b 100644
--- a/docs-mintlify/reference/data-modeling/measures.mdx
+++ b/docs-mintlify/reference/data-modeling/measures.mdx
@@ -1489,6 +1489,16 @@ cube(`orders`, {
+
+
+A measure with no `drill_members` offers no drill down at all. Set
+[`CUBEJS_AUTO_DRILL_MEMBERS`][ref-auto-drill-members] to give such measures a
+default set — the cube's own dimensions, primary key first. Measures that
+declare `drill_members` keep exactly what they declare, and `drill_members: []`
+suppresses the default for a single measure.
+
+
+
[ref-ai-context]: /docs/data-modeling/ai-context
[ref-ref-cubes]: /reference/data-modeling/cube
@@ -1509,4 +1519,5 @@ cube(`orders`, {
[link-d3-format]: https://d3js.org/d3-format
[link-iso-4217]: https://en.wikipedia.org/wiki/ISO_4217
[ref-calculated-measures]: /docs/data-modeling/measures#calculated-measures
-[ref-schema-ref-preaggs-rollup]: /reference/data-modeling/pre-aggregations#rollup
\ No newline at end of file
+[ref-schema-ref-preaggs-rollup]: /reference/data-modeling/pre-aggregations#rollup
+[ref-auto-drill-members]: /reference/configuration/environment-variables#cubejs_auto_drill_members
\ No newline at end of file
diff --git a/packages/cubejs-backend-shared/src/env.ts b/packages/cubejs-backend-shared/src/env.ts
index ad63f10ba1cac..f5c2957663430 100644
--- a/packages/cubejs-backend-shared/src/env.ts
+++ b/packages/cubejs-backend-shared/src/env.ts
@@ -338,6 +338,14 @@ const variables: Record any> = {
nestedFoldersDelimiter: () => get('CUBEJS_NESTED_FOLDERS_DELIMITER')
.default('')
.asString(),
+ // Measures that declare no drill members get a default set computed at
+ // compilation time. Declared drill members always take precedence.
+ autoDrillMembers: () => get('CUBEJS_AUTO_DRILL_MEMBERS')
+ .default('false')
+ .asBoolStrict(),
+ autoDrillMembersLimit: () => get('CUBEJS_AUTO_DRILL_MEMBERS_LIMIT')
+ .default('10')
+ .asInt(),
defaultTimezone: () => get('CUBEJS_DEFAULT_TIMEZONE')
.default('UTC')
.asString(),
diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts
index 4b2fa35409886..a0f821626e954 100644
--- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts
+++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts
@@ -79,6 +79,7 @@ export type DimensionDefinition = {
type: string;
sql(): string;
primaryKey?: true;
+ subQuery?: boolean;
ownedByCube: boolean;
fieldType?: string;
multiStage?: boolean;
diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts
index da4eb611c221e..be42e27270f4e 100644
--- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts
+++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts
@@ -61,6 +61,7 @@ export interface ExtendedCubeSymbolDefinition extends CubeSymbolDefinition {
params?: Array<{ key: string; value: (...args: any[]) => string }>;
}>;
synthetic?: boolean;
+ subQuery?: boolean;
}
interface ExtendedCubeDefinition extends CubeDefinitionExtended {
@@ -236,6 +237,8 @@ export class CubeToMetaTransformer implements CompilerInterface {
const flatFolderSeparator = getEnv('nestedFoldersDelimiter');
const flatFolders: FlatFolder[] = [];
+ const autoDrillMembers = this.defaultDrillMembers(cubeName, extendedCube);
+
const processFolder = (folder: Folder, path: string[] = [], mergedMembers: string[] = []): NestedFolder => {
const flatMembers: string[] = [];
// After evaluation in CubeEvaluator, folder.includes contains resolved FolderMember items
@@ -290,7 +293,7 @@ export class CubeToMetaTransformer implements CompilerInterface {
const metricDef = nameToMetric[1] as ExtendedCubeSymbolDefinition;
const measureVisibility = isCubeVisible ? this.isVisible(metricDef, true) : false;
return {
- ...this.measureConfig(cubeName, cubeTitle, nameToMetric),
+ ...this.measureConfig(cubeName, cubeTitle, nameToMetric, autoDrillMembers),
isVisible: measureVisibility,
public: measureVisibility,
};
@@ -419,7 +422,78 @@ export class CubeToMetaTransformer implements CompilerInterface {
return dimensionType === 'switch' ? 'string' : dimensionType;
}
- private measureConfig(cubeName: string, cubeTitle: string, nameToMetric: [string, any]): Omit {
+ /**
+ * A view's included members don't carry `subQuery`, so a dimension reached
+ * through a view is resolved back to its source definition to classify it.
+ */
+ private isSubQuery(extendedDimDef: ExtendedCubeSymbolDefinition): boolean {
+ if (extendedDimDef.subQuery) {
+ return true;
+ }
+
+ if (!extendedDimDef.aliasMember) {
+ return false;
+ }
+
+ try {
+ return !!this.cubeEvaluator.dimensionByPath(extendedDimDef.aliasMember)?.subQuery;
+ } catch (e) {
+ // An alias that no longer resolves is not this method's problem to report.
+ return false;
+ }
+ }
+
+ /**
+ * Drill members for measures that declare none: the cube's own dimensions,
+ * primary key first, capped.
+ *
+ * The primary key is included whatever its visibility — it is hidden by
+ * default, yet it is the member that identifies a row, and hand-written
+ * `drillMembers` name it routinely. Visibility governs the member picker,
+ * not what a drill query may reference. Everything else must be public.
+ *
+ * Views have no primary key (it is not propagated onto included members) and
+ * their dimensions span every source cube, so there the set is simply the
+ * view's own dimensions in include order.
+ */
+ private defaultDrillMembers(cubeName: string, extendedCube: ExtendedCubeDefinition): string[] {
+ // The flag is checked before the limit is parsed: a malformed limit must not
+ // fail compilation for deployments that never enabled the feature.
+ if (!getEnv('autoDrillMembers')) {
+ return [];
+ }
+
+ const limit = getEnv('autoDrillMembersLimit');
+ if (limit <= 0) {
+ return [];
+ }
+
+ const primaryKeys: string[] = [];
+ const rest: string[] = [];
+
+ for (const [dimensionName, dimDef] of Object.entries(extendedCube.dimensions || {})) {
+ const extendedDimDef = dimDef as ExtendedCubeSymbolDefinition;
+
+ // Link helpers are generated, not authored — they would crowd out real
+ // attributes under the cap. Sub-query dimensions cost a join per drill.
+ const eligible = !extendedDimDef.synthetic && !this.isSubQuery(extendedDimDef);
+
+ if (eligible && extendedDimDef.primaryKey) {
+ primaryKeys.push(`${cubeName}.${dimensionName}`);
+ } else if (eligible && this.isVisible(extendedDimDef, true)) {
+ rest.push(`${cubeName}.${dimensionName}`);
+ }
+ }
+
+ return primaryKeys.concat(rest).slice(0, limit);
+ }
+
+ private measureConfig(
+ cubeName: string,
+ cubeTitle: string,
+ nameToMetric: [string, any],
+ autoDrillMembers: string[]
+ ): Omit {
const [metricName, metricDef] = nameToMetric;
const extendedMetricDef = metricDef as ExtendedCubeSymbolDefinition;
const name = `${cubeName}.${metricName}`;
@@ -427,9 +501,23 @@ export class CubeToMetaTransformer implements CompilerInterface {
// Support both old 'drillMemberReferences' and new 'drillMembers' keys
const drillMembers = extendedMetricDef.drillMembers || extendedMetricDef.drillMemberReferences;
- const drillMembersArray: string[] = (drillMembers && this.cubeEvaluator.evaluateReferences(
- cubeName, drillMembers, { originalSorting: true }
- )) || [];
+ // Keyed on whether drill members were declared at all, never on how many
+ // survive evaluation: `drill_members: []` is a deliberate opt-out, and a
+ // view including none of a declared set still evaluates to empty.
+ //
+ // The declared branch keeps its established shape verbatim, quirks included
+ // (a reference without an array literal evaluates to a bare string, which
+ // this field has always passed through). Normalizing it here would change
+ // emitted meta for models that never enabled the automatic set.
+ //
+ // The computed set is copied per measure: it is built once per cube and
+ // `metaConfig` is cached, so sharing the instance would let one consumer
+ // sorting in place reorder every other measure's list, for every request.
+ const drillMembersArray: string[] = drillMembers
+ ? ((this.cubeEvaluator.evaluateReferences(
+ cubeName, drillMembers, { originalSorting: true }
+ ) as string[]) || [])
+ : autoDrillMembers.slice();
const type = CubeSymbols.toMemberDataType(extendedMetricDef.type || 'number');
const isCumulative = extendedMetricDef.cumulative || BaseMeasure.isCumulative(extendedMetricDef);
diff --git a/packages/cubejs-schema-compiler/test/unit/auto-drill-members.test.ts b/packages/cubejs-schema-compiler/test/unit/auto-drill-members.test.ts
new file mode 100644
index 0000000000000..e0791da6c3f10
--- /dev/null
+++ b/packages/cubejs-schema-compiler/test/unit/auto-drill-members.test.ts
@@ -0,0 +1,464 @@
+import { prepareYamlCompiler, prepareJsCompiler } from './PrepareCompiler';
+
+const modelContent = `
+cubes:
+ - name: orders
+ sql: SELECT * FROM orders
+ measures:
+ - name: count
+ sql: id
+ type: count
+ - name: total
+ sql: amount
+ type: sum
+ - name: declared
+ sql: amount
+ type: sum
+ drill_members:
+ - status
+ - name: declared_empty
+ sql: amount
+ type: sum
+ drill_members: []
+ - name: big_orders
+ sql: amount
+ type: sum
+ filters:
+ - sql: "{CUBE}.amount > 10000"
+ dimensions:
+ - name: id
+ sql: id
+ type: number
+ primary_key: true
+ - name: status
+ sql: status
+ type: string
+ - name: city
+ sql: city
+ type: string
+ links:
+ - name: city_page
+ label: Open the city page
+ url: "{city}"
+ - name: secret
+ sql: secret
+ type: string
+ public: false
+ - name: created_at
+ sql: created_at
+ type: time
+ - name: line_item_count
+ sql: "{line_items.count}"
+ type: number
+ sub_query: true
+
+ - name: line_items
+ sql: SELECT * FROM line_items
+ measures:
+ - name: count
+ sql: id
+ type: count
+ dimensions:
+ - name: id
+ sql: id
+ type: number
+ primary_key: true
+ - name: sku
+ sql: sku
+ type: string
+
+views:
+ - name: orders_view
+ cubes:
+ - join_path: orders
+ includes:
+ - count
+ - total
+ - declared
+ - status
+ - line_item_count
+ - name: city
+ alias: renamed_city
+`;
+
+const legacyModelContent = `
+cube('orders', {
+ sql: 'SELECT * FROM orders',
+ measures: {
+ legacy: { type: 'count', sql: 'id', drillMemberReferences: [status] },
+ bare: { type: 'count', sql: 'id', drillMembers: status },
+ plain: { type: 'count', sql: 'id' }
+ },
+ dimensions: {
+ id: { sql: 'id', type: 'number', primaryKey: true },
+ status: { sql: 'status', type: 'string' }
+ }
+});
+`;
+
+const measure = (metaTransformer: any, cubeName: string, measureName: string) => metaTransformer.cubes
+ .find((it: any) => it.config.name === cubeName)
+ ?.config.measures.find((it: any) => it.name === `${cubeName}.${measureName}`);
+
+// Restoring the environment is what keeps one test's flags from leaking into
+// the next, so it lives in exactly one place. Only the model and the compiler
+// factory vary between callers.
+const withCompiled = async (
+ prepare: () => { compiler: any; metaTransformer: any },
+ env: Record,
+ fn: (metaTransformer: any) => void | Promise
+) => {
+ const originals: Record = {};
+ Object.keys(env).forEach((key) => {
+ originals[key] = process.env[key];
+ process.env[key] = env[key];
+ });
+
+ try {
+ // The compiler must be prepared *after* the env is set — meta is computed
+ // during compile() and cached on the instance.
+ const { compiler, metaTransformer } = prepare();
+ await compiler.compile();
+ await fn(metaTransformer);
+ } finally {
+ Object.keys(env).forEach((key) => {
+ if (originals[key] === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = originals[key];
+ }
+ });
+ }
+};
+
+const withEnv = (env: Record, fn: (metaTransformer: any) => void | Promise) => withCompiled(() => prepareYamlCompiler(modelContent), env, fn);
+
+describe('Auto drill members', () => {
+ describe('flag off (default)', () => {
+ let metaTransformer: any;
+
+ beforeAll(async () => {
+ delete process.env.CUBEJS_AUTO_DRILL_MEMBERS;
+ delete process.env.CUBEJS_AUTO_DRILL_MEMBERS_LIMIT;
+ const prepared = prepareYamlCompiler(modelContent);
+ metaTransformer = prepared.metaTransformer;
+ await prepared.compiler.compile();
+ });
+
+ it('leaves undeclared measures with no drill members', () => {
+ expect(measure(metaTransformer, 'orders', 'count').drillMembers).toEqual([]);
+ expect(measure(metaTransformer, 'orders', 'total').drillMembers).toEqual([]);
+ });
+
+ it('emits an empty grouped set, not a missing one', () => {
+ expect(measure(metaTransformer, 'orders', 'count').drillMembersGrouped).toEqual({
+ measures: [],
+ dimensions: [],
+ });
+ });
+
+ it('still resolves declared drill members', () => {
+ expect(measure(metaTransformer, 'orders', 'declared').drillMembers).toEqual(['orders.status']);
+ });
+
+ it('leaves view measures with no drill members', () => {
+ expect(measure(metaTransformer, 'orders_view', 'count').drillMembers).toEqual([]);
+ });
+ });
+
+ describe('flag on', () => {
+ it('gives an undeclared measure the cube dimensions, primary key first', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders', 'count').drillMembers).toEqual([
+ 'orders.id',
+ 'orders.status',
+ 'orders.city',
+ 'orders.created_at',
+ ]);
+ });
+ });
+
+ it('populates drillMembersGrouped from the computed set', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders', 'total').drillMembersGrouped).toEqual({
+ measures: [],
+ dimensions: ['orders.id', 'orders.status', 'orders.city', 'orders.created_at'],
+ });
+ });
+ });
+
+ it('excludes non-public dimensions but keeps the primary key', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ const { drillMembers } = measure(metaTransformer, 'orders', 'count');
+ expect(drillMembers).not.toContain('orders.secret');
+ // The primary key is hidden by default, yet it identifies the row.
+ expect(drillMembers[0]).toBe('orders.id');
+ });
+ });
+
+ it('excludes sub-query dimensions', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders', 'count').drillMembers)
+ .not.toContain('orders.line_item_count');
+ });
+ });
+
+ it('applies to every undeclared measure on the cube', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'line_items', 'count').drillMembers).toEqual([
+ 'line_items.id',
+ 'line_items.sku',
+ ]);
+ });
+ });
+
+ it('excludes generated link dimensions', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ // `city` declares a link, so a public `synthetic` dimension is minted
+ // alongside it. It is a URL helper, not an attribute worth a cap slot.
+ expect(measure(metaTransformer, 'orders', 'count').drillMembers)
+ .not.toContain('orders.city___link_city_page_url');
+ });
+ });
+
+ it('hands each measure its own copy of the computed set', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ const count = measure(metaTransformer, 'orders', 'count');
+ const total = measure(metaTransformer, 'orders', 'total');
+
+ // The set is computed once per cube and meta is cached, so sharing the
+ // instance would let one consumer's in-place sort reorder every other
+ // measure's list for every later request.
+ expect(count.drillMembers).not.toBe(total.drillMembers);
+ count.drillMembers.reverse();
+ expect(total.drillMembers[0]).toBe('orders.id');
+ });
+ });
+ });
+
+ describe('primary key shapes', () => {
+ const compoundPkModel = `
+cubes:
+ - name: shipments
+ sql: SELECT * FROM shipments
+ measures:
+ - name: count
+ sql: id
+ type: count
+ dimensions:
+ - name: order_id
+ sql: order_id
+ type: number
+ primary_key: true
+ - name: line_no
+ sql: line_no
+ type: number
+ primary_key: true
+ - name: carrier
+ sql: carrier
+ type: string
+
+ - name: events
+ sql: SELECT * FROM events
+ measures:
+ - name: count
+ sql: id
+ type: count
+ dimensions:
+ - name: name
+ sql: name
+ type: string
+ - name: happened_at
+ sql: happened_at
+ type: time
+`;
+
+ const withModel = (model: string, env: Record, fn: (m: any) => void) => withCompiled(() => prepareYamlCompiler(model), env, fn);
+
+ it('puts every part of a compound primary key ahead of the rest', async () => {
+ await withModel(compoundPkModel, { CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'shipments', 'count').drillMembers).toEqual([
+ 'shipments.order_id',
+ 'shipments.line_no',
+ 'shipments.carrier',
+ ]);
+ });
+ });
+
+ it('counts the compound key against the cap', async () => {
+ await withModel(
+ compoundPkModel,
+ { CUBEJS_AUTO_DRILL_MEMBERS: 'true', CUBEJS_AUTO_DRILL_MEMBERS_LIMIT: '2' },
+ (metaTransformer) => {
+ expect(measure(metaTransformer, 'shipments', 'count').drillMembers).toEqual([
+ 'shipments.order_id',
+ 'shipments.line_no',
+ ]);
+ }
+ );
+ });
+
+ it('falls back to the public dimensions when a cube has no primary key', async () => {
+ await withModel(compoundPkModel, { CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'events', 'count').drillMembers).toEqual([
+ 'events.name',
+ 'events.happened_at',
+ ]);
+ });
+ });
+ });
+
+ describe('declared always wins', () => {
+ it('does not touch a measure that declares drill members', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders', 'declared').drillMembers).toEqual(['orders.status']);
+ });
+ });
+
+ it('respects an empty declaration as an opt-out', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders', 'declared_empty').drillMembers).toEqual([]);
+ });
+ });
+ });
+
+ describe('the cap', () => {
+ it('truncates the set to the configured limit', async () => {
+ await withEnv(
+ { CUBEJS_AUTO_DRILL_MEMBERS: 'true', CUBEJS_AUTO_DRILL_MEMBERS_LIMIT: '2' },
+ (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders', 'count').drillMembers).toEqual([
+ 'orders.id',
+ 'orders.status',
+ ]);
+ }
+ );
+ });
+
+ it('yields nothing at all for a zero limit', async () => {
+ await withEnv(
+ { CUBEJS_AUTO_DRILL_MEMBERS: 'true', CUBEJS_AUTO_DRILL_MEMBERS_LIMIT: '0' },
+ (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders', 'count').drillMembers).toEqual([]);
+ }
+ );
+ });
+ });
+
+ describe('views', () => {
+ it('names the view\'s own members, using the view\'s alias', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ const { drillMembers } = measure(metaTransformer, 'orders_view', 'count');
+
+ // renamed_city, not city — proving these are the view's member keys
+ // rather than source names re-prefixed.
+ expect(drillMembers).toEqual(['orders_view.status', 'orders_view.renamed_city']);
+ });
+ });
+
+ it('names only members the view actually includes', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ const { drillMembers } = measure(metaTransformer, 'orders_view', 'total');
+
+ // created_at and secret are not included by the view, so a drill on it
+ // cannot dead-end on them.
+ expect(drillMembers).not.toContain('orders_view.created_at');
+ expect(drillMembers).not.toContain('orders_view.secret');
+ expect(drillMembers).not.toContain('orders_view.city');
+ });
+ });
+
+ it('excludes generated link dimensions reached through the view', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ // A view's included members do carry `synthetic`, and the link helper
+ // for an included dimension is auto-included alongside it — so an
+ // unfiltered view set would pick URL helpers up here too.
+ const { drillMembers } = measure(metaTransformer, 'orders_view', 'count');
+ expect(drillMembers.filter((m: string) => m.includes('___link_'))).toEqual([]);
+ });
+ });
+
+ it('excludes a sub-query dimension reached through the view', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ // A view's included members don't carry `sub_query`, so this only holds
+ // if the dimension is resolved back to its source definition.
+ expect(measure(metaTransformer, 'orders_view', 'count').drillMembers)
+ .not.toContain('orders_view.line_item_count');
+ });
+ });
+
+ it('carries a declared set through the view untouched', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders_view', 'declared').drillMembers).toEqual([
+ 'orders_view.status',
+ ]);
+ });
+ });
+ });
+
+ describe('legacy and irregular declaration shapes', () => {
+ const withLegacyEnv = (env: Record, fn: (m: any) => void) => withCompiled(() => prepareJsCompiler(legacyModelContent), env, fn);
+
+ it('treats the legacy drillMemberReferences key as a declaration', async () => {
+ await withLegacyEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders', 'legacy').drillMembers).toEqual(['orders.status']);
+ });
+ });
+
+ it('leaves a bare (non-array) declaration exactly as it has always been emitted', async () => {
+ await withLegacyEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ // Passed through unchanged rather than normalized: this field has always
+ // emitted the bare string for this shape, and the automatic set must not
+ // alter what a declaring model already produces.
+ expect(measure(metaTransformer, 'orders', 'bare').drillMembers).toBe('orders.status');
+ });
+ });
+
+ it('still fills in an undeclared measure alongside them', async () => {
+ await withLegacyEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ expect(measure(metaTransformer, 'orders', 'plain').drillMembers).toEqual([
+ 'orders.id',
+ 'orders.status',
+ ]);
+ });
+ });
+ });
+
+ describe('the limit is not read unless the feature is on', () => {
+ it('compiles with a malformed limit while the flag is off', async () => {
+ const original = process.env.CUBEJS_AUTO_DRILL_MEMBERS_LIMIT;
+ process.env.CUBEJS_AUTO_DRILL_MEMBERS_LIMIT = 'not-a-number';
+ delete process.env.CUBEJS_AUTO_DRILL_MEMBERS;
+
+ try {
+ const { compiler, metaTransformer } = prepareYamlCompiler(modelContent);
+ await compiler.compile();
+ expect(measure(metaTransformer, 'orders', 'count').drillMembers).toEqual([]);
+ } finally {
+ if (original === undefined) {
+ delete process.env.CUBEJS_AUTO_DRILL_MEMBERS_LIMIT;
+ } else {
+ process.env.CUBEJS_AUTO_DRILL_MEMBERS_LIMIT = original;
+ }
+ }
+ });
+ });
+
+ describe('measure filters', () => {
+ it('leaves a filtered measure\'s own filters in place alongside the computed set', async () => {
+ await withEnv({ CUBEJS_AUTO_DRILL_MEMBERS: 'true' }, (metaTransformer) => {
+ const bigOrders = measure(metaTransformer, 'orders', 'big_orders');
+
+ // The drill set is computed, and the measure's filters are untouched by
+ // it — they reach the drill query through the `measureFilter` operator,
+ // which keys on the measure rather than on how its members were derived.
+ expect(bigOrders.drillMembers).toEqual([
+ 'orders.id',
+ 'orders.status',
+ 'orders.city',
+ 'orders.created_at',
+ ]);
+ });
+ });
+ });
+});