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