diff --git a/docs-mintlify/reference/data-modeling/context-variables.mdx b/docs-mintlify/reference/data-modeling/context-variables.mdx index bd89de78309ce..2e6c6a56b8874 100644 --- a/docs-mintlify/reference/data-modeling/context-variables.mdx +++ b/docs-mintlify/reference/data-modeling/context-variables.mdx @@ -113,7 +113,7 @@ values from the Cube query during SQL generation. This is useful for hinting your database optimizer to use a specific index or filter out partitions or shards in your cloud data warehouse so you won't -be billed for scanning those. +be billed for scanning those. It can also be useful for constructing [links][ref-links]. @@ -816,4 +816,5 @@ cube(`orders`, { [ref-dynamic-data-models]: /docs/data-modeling/dynamic/jinja [ref-query-filter]: /reference/rest-api/query-format#query-properties [ref-dynamic-jinja]: /docs/data-modeling/dynamic/jinja -[ref-filter-boolean]: /reference/rest-api/query-format#boolean-logical-operators \ No newline at end of file +[ref-filter-boolean]: /reference/rest-api/query-format#boolean-logical-operators +[ref-links]: /reference/data-modeling/dimensions#links \ No newline at end of file diff --git a/docs-mintlify/reference/data-modeling/dimensions.mdx b/docs-mintlify/reference/data-modeling/dimensions.mdx index 5055845827ec3..ee444f406de68 100644 --- a/docs-mintlify/reference/data-modeling/dimensions.mdx +++ b/docs-mintlify/reference/data-modeling/dimensions.mdx @@ -426,9 +426,126 @@ Using it with other dimension types will result in a validation error. +### `links` + +The `links` parameter allows you to define links associated with a dimension. +They can be rendered as HTML links by supporting tools, e.g., [Workbooks][ref-workbooks]. + +Links are useful to let users navigate to related external resources (e.g., Google +search), internal tools (e.g., Salesforce), or other pages in a BI tool. + +Each link must have a `name`, a `label`, and a `url`. The `name` is used as an identifier +in the [synthetic dimension](#synthetic) name. The `url` is a SQL expression that constructs +the link URL. It can [reference][ref-references] column and dimension values, just like +the [`sql` parameter](#sql) or [`mask` parameter](#mask). + +Optionally, a link might use the `icon` parameter to reference an icon from a [supported +icon set][link-tabler] to be displayed alongside the link label. + +Optionally, a link might use the `target` parameter to specify [where to open it][link-target]: +`blank` (default) to open in a new tab/window or `self` to open in the same tab/window. + +```yaml +cubes: + - name: users + + dimensions: + # Definitions of dimensions such as `email`, etc. + + - name: full_name + sql: full_name + type: string + links: + - name: google_search + label: Search on Google + url: "CONCAT('https://www.google.com/search?q=', {CUBE}.full_name)" + icon: brand-google + target: blank + + - name: salesforce_search + label: Search in Salesforce + url: "CONCAT('https://your-company.salesforce.com/search/results/?q=', {email})" + target: blank + + - name: send_email + label: Write an email + url: "CONCAT('mailto:', {email})" + icon: send +``` + +#### `params` + +The optional `params` parameter can be used to add additional query parameters to the +URL. It accepts a map of key-value pairs, where keys are parameter names and values are +parameter values. + +Values in `params` can [reference][ref-references] columns and dimension values. +Additionally, values in `params` can reference filters applied to the current query using +the [`FILTER_PARAMS` context variable][ref-filter-params]. + +Conveniently, the `propagate_filters_to_params` parameter, `true` by default, can be used +to pass all filters from the current query as an additional parameter. Filters will use +the same format as the [`filters` query parameter][ref-rest-filters] in the REST API. +The `param_name_for_filters` parameter, `filters` by default, can be used to customize +the name of this additional parameter. + +All parameter keys and values will be [URL-encoded][link-encode-uri-component] +when the full URL is constructed. + +```yaml +cubes: + - name: users + + dimensions: + # Definitions of dimensions such as `id`, `country`, etc. + + - name: full_name + sql: full_name + type: string + links: + - name: performance + label: Check performance dashboard + url: "'https://my-account.cubecloud.dev/new/d/123/dashboards/KSqDYdUz6Ble'" + params: + # Pass dimension values as query parameters + filter_user_id: "{id}" + # Pass filters from the current query as query parameters + filter_country: "{FILTER_PARAMS.users.country}" + # Pass additional parameters, if needed + utm_source: cube + + - name: another_dashboard + label: Check another dashboard + url: "'https://my-account.cubecloud.dev/new/d/456/dashboards/iuZjHd8h5mKLe'" + # Don't pass any filters from the current query + propagate_filters_to_params: false + + - name: one_more_dashboard + label: Check one more dashboard + url: "'https://my-account.cubecloud.dev/new/d/456/dashboards/iuZjHd8h5mKLe'" + # Pass all filters from the current query as the `my_precious_filters` query parameter + param_name_for_filters: my_precious_filters +``` + +#### Dimensions + +Each link will be rendered as an additional [synthetic](#synthetic) dimension in the +result set, with the following naming convention: + +- `___link__url` + + + +All references in link URLs and parameters must resolve to a single value for a given +value of the dimension on which the link is defined. Otherwise, it will result in +duplicate rows in the result set. + + + ### `meta` -Custom metadata. Can be used to pass any information to the frontend. +The `meta` parameter allows you to attach arbitrary information to a dimension. +It can be consumed and interpreted by supporting tools. You can also use the `ai_context` key to provide context to the [AI agent][ref-ai-context] without exposing it in the user interface. @@ -902,6 +1019,13 @@ cube(`orders`, { +### `synthetic` + +The `synthetic` parameter can't be set by a user directly. It is used to mark dimensions +that are automatically created by Cube, e.g., for [links](#links). + +You can check if a dimension is synthetic via the [`/v1/meta` API endpoint][ref-meta-api]. + ### `granularities` By default, the following granularities are available for time dimensions: @@ -1222,4 +1346,11 @@ cube(`fiscal_calendar`, { [link-tesseract]: https://cube.dev/blog/introducing-next-generation-data-modeling-engine [ref-case-measures]: /reference/data-modeling/measures#case [ref-meta-api]: /reference/rest-api/reference#base_pathv1meta -[ref-string-time-dims]: /recipes/data-modeling/string-time-dimensions \ No newline at end of file +[ref-string-time-dims]: /recipes/data-modeling/string-time-dimensions +[ref-workbooks]: /docs/explore-analyze/workbooks +[link-target]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#target +[link-tabler]: https://tabler.io/icons +[ref-references]: /docs/data-modeling/syntax#references +[ref-filter-params]: /reference/data-modeling/context-variables#filter_params +[link-encode-uri-component]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent +[ref-rest-filters]: /reference/rest-api/query-format#query-properties \ No newline at end of file diff --git a/docs/content/product/data-modeling/reference/context-variables.mdx b/docs/content/product/data-modeling/reference/context-variables.mdx index 92fbea446b609..e7c6885ed4999 100644 --- a/docs/content/product/data-modeling/reference/context-variables.mdx +++ b/docs/content/product/data-modeling/reference/context-variables.mdx @@ -113,7 +113,7 @@ values from the Cube query during SQL generation. This is useful for hinting your database optimizer to use a specific index or filter out partitions or shards in your cloud data warehouse so you won't -be billed for scanning those. +be billed for scanning those. It can also be useful for constructing [links][ref-links]. @@ -816,4 +816,5 @@ cube(`orders`, { [ref-dynamic-data-models]: /product/data-modeling/dynamic/jinja [ref-query-filter]: /product/apis-integrations/rest-api/query-format#query-properties [ref-dynamic-jinja]: /product/data-modeling/dynamic/jinja -[ref-filter-boolean]: /product/apis-integrations/rest-api/query-format#boolean-logical-operators \ No newline at end of file +[ref-filter-boolean]: /product/apis-integrations/rest-api/query-format#boolean-logical-operators +[ref-links]: /product/data-modeling/reference/dimensions#links \ No newline at end of file diff --git a/docs/content/product/data-modeling/reference/dimensions.mdx b/docs/content/product/data-modeling/reference/dimensions.mdx index 9d3a286a5d9c2..262a19b3abdc1 100644 --- a/docs/content/product/data-modeling/reference/dimensions.mdx +++ b/docs/content/product/data-modeling/reference/dimensions.mdx @@ -293,9 +293,126 @@ cubes: +### `links` + +The `links` parameter allows you to define links associated with a dimension. +They can be rendered as HTML links by supporting tools, e.g., [Workbooks][ref-workbooks]. + +Links are useful to let users navigate to related external resources (e.g., Google +search), internal tools (e.g., Salesforce), or other pages in a BI tool. + +Each link must have a `name`, a `label`, and a `url`. The `name` is used as an identifier +in the [synthetic dimension](#synthetic) name. The `url` is a SQL expression that constructs +the link URL. It can [reference][ref-references] column and dimension values, just like +the [`sql` parameter](#sql) or [`mask` parameter](#mask). + +Optionally, a link might use the `icon` parameter to reference an icon from a [supported +icon set][link-tabler] to be displayed alongside the link label. + +Optionally, a link might use the `target` parameter to specify [where to open it][link-target]: +`blank` (default) to open in a new tab/window or `self` to open in the same tab/window. + +```yaml +cubes: + - name: users + + dimensions: + # Definitions of dimensions such as `email`, etc. + + - name: full_name + sql: full_name + type: string + links: + - name: google_search + label: Search on Google + url: "CONCAT('https://www.google.com/search?q=', {CUBE}.full_name)" + icon: brand-google + target: blank + + - name: salesforce_search + label: Search in Salesforce + url: "CONCAT('https://your-company.salesforce.com/search/results/?q=', {email})" + target: blank + + - name: send_email + label: Write an email + url: "CONCAT('mailto:', {email})" + icon: send +``` + +#### `params` + +The optional `params` parameter can be used to add additional query parameters to the +URL. It accepts a map of key-value pairs, where keys are parameter names and values are +parameter values. + +Values in `params` can [reference][ref-references] columns and dimension values. +Additionally, values in `params` can reference filters applied to the current query using +the [`FILTER_PARAMS` context variable][ref-filter-params]. + +Conveniently, the `propagate_filters_to_params` parameter, `true` by default, can be used +to pass all filters from the current query as an additional parameter. Filters will use +the same format as the [`filters` query parameter][ref-rest-filters] in the REST API. +The `param_name_for_filters` parameter, `filters` by default, can be used to customize +the name of this additional parameter. + +All parameter keys and values will be [URL-encoded][link-encode-uri-component] +when the full URL is constructed. + +```yaml +cubes: + - name: users + + dimensions: + # Definitions of dimensions such as `id`, `country`, etc. + + - name: full_name + sql: full_name + type: string + links: + - name: performance + label: Check performance dashboard + url: "'https://my-account.cubecloud.dev/new/d/123/dashboards/KSqDYdUz6Ble'" + params: + # Pass dimension values as query parameters + filter_user_id: "{id}" + # Pass filters from the current query as query parameters + filter_country: "{FILTER_PARAMS.users.country}" + # Pass additional parameters, if needed + utm_source: cube + + - name: another_dashboard + label: Check another dashboard + url: "'https://my-account.cubecloud.dev/new/d/456/dashboards/iuZjHd8h5mKLe'" + # Don't pass any filters from the current query + propagate_filters_to_params: false + + - name: one_more_dashboard + label: Check one more dashboard + url: "'https://my-account.cubecloud.dev/new/d/456/dashboards/iuZjHd8h5mKLe'" + # Pass all filters from the current query as the `my_precious_filters` query parameter + param_name_for_filters: my_precious_filters +``` + +#### Dimensions + +Each link will be rendered as an additional [synthetic](#synthetic) dimension in the +result set, with the following naming convention: + +- `___link__url` + + + +All references in link URLs and parameters must resolve to a single value for a given +value of the dimension on which the link is defined. Otherwise, it will result in +duplicate rows in the result set. + + + ### `meta` -Custom metadata. Can be used to pass any information to the frontend. +The `meta` parameter allows you to attach arbitrary information to a dimension. +It can be consumed and interpreted by supporting tools. @@ -701,6 +818,13 @@ cubes: +### `synthetic` + +The `synthetic` parameter can't be set by a user directly. It is used to mark dimensions +that are automatically created by Cube, e.g., for [links](#links). + +You can check if a dimension is synthetic via the [`/v1/meta` API endpoint][ref-meta]. + ### `granularities` By default, the following granularities are available for time dimensions: @@ -1015,3 +1139,11 @@ cube(`fiscal_calendar`, { [ref-cube-calendar]: /product/data-modeling/reference/cube#calendar [ref-measure-time-shift]: /product/data-modeling/reference/measures#time_shift [ref-data-masking]: /product/auth/data-access-policies#data-masking +[ref-workbooks]: /product/exploration/workbooks +[link-target]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#target +[link-tabler]: https://tabler.io/icons +[ref-references]: /product/data-modeling/syntax#references +[ref-filter-params]: /product/data-modeling/reference/context-variables#filter_params +[link-encode-uri-component]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent +[ref-rest-filters]: /product/apis-integrations/rest-api/query-format#query-properties +[ref-meta]: /product/apis-integrations/rest-api/reference#base_pathv1meta diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index e500f000f8c48..09729f15d05de 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -32,6 +32,17 @@ export type SegmentDefinition = { multiStage?: boolean; }; +export type LinkDefinition = { + name: string; + label: string; + url: (...args: any[]) => string; + icon?: string; + target?: 'blank' | 'self'; + params?: Record; + propagate_filters_to_params?: boolean; + param_name_for_filters?: string; +}; + export type DimensionDefinition = { type: string; sql(): string; @@ -43,6 +54,7 @@ export type DimensionDefinition = { order?: 'asc' | 'desc'; key?: (...args: any[]) => ToString; keyReference?: string; + links?: LinkDefinition[]; }; export type TimeShiftDefinition = { @@ -199,6 +211,7 @@ export class CubeEvaluator extends CubeSymbols { this.prepareJoins(cube, errorReporter); this.preparePreAggregations(cube, errorReporter); this.prepareMembers(cube.measures, cube, errorReporter); + this.prepareSyntheticLinkDimensions(cube); this.prepareMembers(cube.dimensions, cube, errorReporter); this.prepareMembers(cube.segments, cube, errorReporter); @@ -290,6 +303,26 @@ export class CubeEvaluator extends CubeSymbols { } } + protected prepareSyntheticLinkDimensions(cube: any) { + if (!cube.dimensions) return; + + for (const [dimName, dimDef] of Object.entries(cube.dimensions)) { + if (dimDef.links && Array.isArray(dimDef.links)) { + dimDef.links.forEach((link: any) => { + const linkName = typeof link.name === 'function' ? link.name() : link.name; + const syntheticName = `${dimName}___link_${linkName}_url`; + cube.dimensions[syntheticName] = { + sql: link.url, + type: 'string', + synthetic: true, + ownedByCube: true, + public: true, + }; + }); + } + } + } + private allMembersOrList(cube: any, specifier: string | string[]): string[] { const types = ['measures', 'dimensions', 'segments']; if (specifier === '*') { diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts index 6d12a55035418..cf20045e6be4a 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts @@ -1015,6 +1015,7 @@ export class CubeSymbols implements TranspilerSymbolResolver, CompilerInterface ...(resolvedMember.multiStage && { multiStage: resolvedMember.multiStage }), ...(resolvedMember.keyReference && this.processKeyReferenceForView(resolvedMember.keyReference, targetCube.name, viewAllMembers, memberRef.member)), ...(resolvedMember.mask !== undefined ? { mask: resolvedMember.mask } : {}), + ...(resolvedMember.links ? { links: resolvedMember.links } : {}), }; } else if (type === 'segments') { memberDefinition = { diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts index 0d8aa6551dc02..f3b66c3b966a5 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts @@ -51,6 +51,17 @@ export interface ExtendedCubeSymbolDefinition extends CubeSymbolDefinition { aggType?: string; keyReference?: string; currency?: string; + links?: Array<{ + name: string; + label: string; + url: (...args: any[]) => string; + icon?: string; + target?: 'blank' | 'self'; + params?: Record; + propagate_filters_to_params?: boolean; + param_name_for_filters?: string; + }>; + synthetic?: boolean; } interface ExtendedCubeDefinition extends CubeDefinitionExtended { @@ -97,6 +108,16 @@ export type MeasureConfig = { public: boolean; }; +export type LinkConfig = { + name: string; + label: string; + icon?: string; + target: 'blank' | 'self'; + params?: Record; + propagate_filters_to_params: boolean; + param_name_for_filters: string; +}; + export type DimensionConfig = { name: string; title: string; @@ -115,6 +136,8 @@ export type DimensionConfig = { granularities?: GranularityDefinition[]; order?: 'asc' | 'desc'; key?: string; + links?: LinkConfig[]; + synthetic?: boolean; }; export type SegmentConfig = { @@ -314,6 +337,16 @@ export class CubeToMetaTransformer implements CompilerInterface { : undefined, order: extendedDimDef.order, key: extendedDimDef.keyReference, + ...(extendedDimDef.links ? { links: extendedDimDef.links.map((link: any) => ({ + name: link.name, + label: link.label, + icon: link.icon, + target: link.target || 'blank', + params: link.params, + propagate_filters_to_params: link.propagate_filters_to_params !== false, + param_name_for_filters: link.param_name_for_filters || 'filters', + })) } : {}), + ...(extendedDimDef.synthetic ? { synthetic: true } : {}), }; }), segments: Object.entries(extendedCube.segments || {}).map((nameToSegment: [string, any]) => { diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 17760cfcc32ee..857c094c17cf3 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -311,6 +311,19 @@ const MaskSchema = Joi.alternatives([ Joi.string(), ]); +const LinkItemSchema = Joi.object().keys({ + name: identifier.required(), + label: Joi.string().required(), + url: Joi.func().required(), + icon: Joi.string(), + target: Joi.string().valid('blank', 'self'), + params: Joi.object().pattern(Joi.string(), Joi.string()), + propagate_filters_to_params: Joi.boolean().strict(), + param_name_for_filters: Joi.string(), +}); + +const LinksSchema = Joi.array().items(LinkItemSchema); + const BaseDimensionWithoutSubQuery = { aliases: Joi.array().items(Joi.string()), type: Joi.any().valid('string', 'number', 'boolean', 'time', 'geo').required(), @@ -323,6 +336,7 @@ const BaseDimensionWithoutSubQuery = { description: Joi.string(), suggestFilterValues: Joi.boolean().strict(), enableSuggestions: Joi.boolean().strict(), + links: LinksSchema, mask: MaskSchema, format: Joi.when('type', { switch: [ diff --git a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts index c1d9564d1c2af..2c13cf4ee7280 100644 --- a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts @@ -41,6 +41,7 @@ export const transpiledFieldsPatterns: Array = [ /^filters\.[0-9]+\.values$/, /^filters\.[0-9]+\.unless$/, /^(measures|dimensions)\.[_a-zA-Z][_a-zA-Z0-9]*\.mask\.sql$/, + /^dimensions\.[_a-zA-Z][_a-zA-Z0-9]*\.links\.[0-9]+\.url$/, ]; export const transpiledFields: Set = new Set(); diff --git a/packages/cubejs-schema-compiler/test/unit/links.test.ts b/packages/cubejs-schema-compiler/test/unit/links.test.ts new file mode 100644 index 0000000000000..7d5e9bc329d8c --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/links.test.ts @@ -0,0 +1,291 @@ +import { PostgresQuery } from '../../src'; +import { prepareYamlCompiler } from './PrepareCompiler'; + +describe('Links', () => { + const schemaWithLinks = ` +cubes: + - name: users + sql_table: users + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: full_name + sql: full_name + type: string + links: + - name: google_search + label: Search on Google + url: "{full_name}" + icon: brand-google + target: blank + - name: send_email + label: Write an email + url: "{email}" + icon: send + + - name: email + sql: email + type: string +`; + + it('should create synthetic link URL dimensions', async () => { + const compilers = prepareYamlCompiler(schemaWithLinks); + await compilers.compiler.compile(); + + const googleDef = compilers.cubeEvaluator.dimensionByPath('users.full_name___link_google_search_url'); + expect(googleDef).toBeDefined(); + expect(googleDef.type).toBe('string'); + expect((googleDef as any).synthetic).toBe(true); + + const emailDef = compilers.cubeEvaluator.dimensionByPath('users.full_name___link_send_email_url'); + expect(emailDef).toBeDefined(); + expect(emailDef.type).toBe('string'); + expect((emailDef as any).synthetic).toBe(true); + }); + + it('synthetic link dimension exists and can be referenced', async () => { + const compilers = prepareYamlCompiler(schemaWithLinks); + await compilers.compiler.compile(); + + const dimDef = compilers.cubeEvaluator.dimensionByPath('users.full_name___link_google_search_url'); + expect(dimDef).toBeDefined(); + expect(dimDef.type).toBe('string'); + expect(typeof dimDef.sql).toBe('function'); + }); + + it('should NOT include link URL columns unless explicitly queried', async () => { + const compilers = prepareYamlCompiler(schemaWithLinks); + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: [], + dimensions: ['users.full_name'], + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + expect(sql).not.toContain('___link_'); + }); + + it('should expose links metadata and synthetic flag in meta config', async () => { + const compilers = prepareYamlCompiler(schemaWithLinks); + await compilers.compiler.compile(); + + const { metaTransformer } = compilers; + const { cubes } = metaTransformer; + const usersCube = cubes.find((c: any) => c.config.name === 'users'); + expect(usersCube).toBeDefined(); + + const fullNameDim = usersCube!.config.dimensions.find( + (d: any) => d.name === 'users.full_name' + ); + expect(fullNameDim).toBeDefined(); + expect(fullNameDim!.links).toBeDefined(); + expect(fullNameDim!.links).toHaveLength(2); + expect(fullNameDim!.links![0].label).toBe('Search on Google'); + expect(fullNameDim!.links![0].icon).toBe('brand-google'); + expect(fullNameDim!.links![0].target).toBe('blank'); + + const syntheticDim = usersCube!.config.dimensions.find( + (d: any) => d.name === 'users.full_name___link_google_search_url' + ); + expect(syntheticDim).toBeDefined(); + expect(syntheticDim!.synthetic).toBe(true); + }); + + it('synthetic link dimensions should be public by default', async () => { + const compilers = prepareYamlCompiler(schemaWithLinks); + await compilers.compiler.compile(); + + const { metaTransformer } = compilers; + const { cubes } = metaTransformer; + const usersCube = cubes.find((c: any) => c.config.name === 'users'); + expect(usersCube).toBeDefined(); + + const syntheticDim = usersCube!.config.dimensions.find( + (d: any) => d.name === 'users.full_name___link_google_search_url' + ); + expect(syntheticDim).toBeDefined(); + expect(syntheticDim!.public).toBe(true); + }); + + it('should validate links schema - label is required', async () => { + const invalidSchema = ` +cubes: + - name: users + sql_table: users + + dimensions: + - name: full_name + sql: full_name + type: string + links: + - name: test + url: "{full_name}" +`; + const compilers = prepareYamlCompiler(invalidSchema); + + try { + await compilers.compiler.compile(); + fail('Should have thrown an error for missing label'); + } catch (e: any) { + expect(e.message || e.toString()).toMatch(/label/i); + } + }); + + describe('access policy on view with links', () => { + const schemaWithViewAndPolicy = ` +cubes: + - name: users + sql_table: users + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: full_name + sql: full_name + type: string + links: + - name: google_search + label: Search on Google + url: "{full_name}" + icon: brand-google + + - name: email + sql: email + type: string + +views: + - name: users_view + cubes: + - join_path: users + includes: + - full_name + - email + access_policy: + - role: "*" + member_level: + includes: + - full_name + - full_name___link_google_search_url +`; + + it('should include synthetic link dim when explicitly listed in access policy', async () => { + const compilers = prepareYamlCompiler(schemaWithViewAndPolicy); + await compilers.compiler.compile(); + + const viewCube = compilers.cubeEvaluator.cubeFromPath('users_view'); + expect(viewCube).toBeDefined(); + + const policy = viewCube.accessPolicy![0]; + expect(policy.memberLevel!.includesMembers).toContain('users_view.full_name'); + expect(policy.memberLevel!.includesMembers).toContain('users_view.full_name___link_google_search_url'); + }); + + const schemaWithViewPolicyExcludeLink = ` +cubes: + - name: users + sql_table: users + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: full_name + sql: full_name + type: string + links: + - name: google_search + label: Search on Google + url: "{full_name}" + icon: brand-google + + - name: email + sql: email + type: string + +views: + - name: users_view + cubes: + - join_path: users + includes: + - full_name + - email + access_policy: + - role: "*" + member_level: + includes: + - full_name + - email +`; + + it('should exclude synthetic link dim when not listed in access policy includes', async () => { + const compilers = prepareYamlCompiler(schemaWithViewPolicyExcludeLink); + await compilers.compiler.compile(); + + const viewCube = compilers.cubeEvaluator.cubeFromPath('users_view'); + expect(viewCube).toBeDefined(); + + const policy = viewCube.accessPolicy![0]; + expect(policy.memberLevel!.includesMembers).toContain('users_view.full_name'); + expect(policy.memberLevel!.includesMembers).toContain('users_view.email'); + expect(policy.memberLevel!.includesMembers).not.toContain('users_view.full_name___link_google_search_url'); + }); + + const schemaWithViewPolicyWildcard = ` +cubes: + - name: users + sql_table: users + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: full_name + sql: full_name + type: string + links: + - name: google_search + label: Search on Google + url: "{full_name}" + icon: brand-google + + - name: email + sql: email + type: string + +views: + - name: users_view + cubes: + - join_path: users + includes: "*" + access_policy: + - role: "*" + member_level: + includes: "*" +`; + + it('should include synthetic link dim when access policy uses wildcard includes', async () => { + const compilers = prepareYamlCompiler(schemaWithViewPolicyWildcard); + await compilers.compiler.compile(); + + const viewCube = compilers.cubeEvaluator.cubeFromPath('users_view'); + expect(viewCube).toBeDefined(); + + const policy = viewCube.accessPolicy![0]; + expect(policy.memberLevel!.includesMembers).toContain('users_view.full_name___link_google_search_url'); + }); + }); +});