diff --git a/.github/actions/smoke.sh b/.github/actions/smoke.sh index b75ee3527de1d..e58b66a27e1b1 100755 --- a/.github/actions/smoke.sh +++ b/.github/actions/smoke.sh @@ -75,3 +75,7 @@ echo "::endgroup::" echo "::group::RBAC GraphQL" yarn lerna run --concurrency 1 --stream --no-prefix smoke:rbac-graphql echo "::endgroup::" + +echo "::group::Links" +yarn lerna run --concurrency 1 --stream --no-prefix smoke:links +echo "::endgroup::" 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..4741863932287 100644 --- a/docs-mintlify/reference/data-modeling/dimensions.mdx +++ b/docs-mintlify/reference/data-modeling/dimensions.mdx @@ -426,9 +426,107 @@ 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` and a `label`. The `name` is used as an identifier +in the [synthetic dimension](#synthetic) name. + +A link must specify either a `url` or a `dashboard`: +- `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). +- `dashboard` is a dashboard identifier. When set, the link URL is generated as + `/dashboard/`. The `params` object is still appended as a query string. + +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 +SQL expressions (just like `url`). + +Values in `params` can [reference][ref-references] columns and dimension values. +All parameter values will be [URL-encoded][link-encode-uri-component] in the generated SQL +using a database-specific encoding function. + +```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 + dashboard: KSqDYdUz6Ble + params: + - key: filter_user_id + value: "{id}" + - key: filter_country + value: "{country}" +``` + +#### 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 +1000,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 +1327,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..9fbc94c6353a7 100644 --- a/docs/content/product/data-modeling/reference/dimensions.mdx +++ b/docs/content/product/data-modeling/reference/dimensions.mdx @@ -293,9 +293,107 @@ 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` and a `label`. The `name` is used as an identifier +in the [synthetic dimension](#synthetic) name. + +A link must specify either a `url` or a `dashboard`: +- `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). +- `dashboard` is a dashboard identifier. When set, the link URL is generated as + `/dashboard/`. The `params` object is still appended as a query string. + +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 +SQL expressions (just like `url`). + +Values in `params` can [reference][ref-references] columns and dimension values. +All parameter values will be [URL-encoded][link-encode-uri-component] in the generated SQL +using a database-specific encoding function. + +```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 + dashboard: KSqDYdUz6Ble + params: + - key: filter_user_id + value: "{id}" + - key: filter_country + value: "{country}" +``` + +#### 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 +799,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 +1120,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-databricks-jdbc-driver/src/DatabricksQuery.ts b/packages/cubejs-databricks-jdbc-driver/src/DatabricksQuery.ts index ac08b4328d099..a01396914e7f5 100644 --- a/packages/cubejs-databricks-jdbc-driver/src/DatabricksQuery.ts +++ b/packages/cubejs-databricks-jdbc-driver/src/DatabricksQuery.ts @@ -216,4 +216,8 @@ export class DatabricksQuery extends BaseQuery { delete templates.types.interval; return templates; } + + public urlEncode(sql: string): string { + return `url_encode(CAST(${sql} as STRING))`; + } } diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js index 34c2ef0a7e3a1..264fadd505e92 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js +++ b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js @@ -3989,6 +3989,17 @@ export class BaseQuery { throw new Error('Not implemented'); } + /** + * URL-encode a SQL expression. Override in dialect-specific query classes + * for native URL encoding support. Default implementation uses REPLACE + * chains for the most common characters. + * @param {string} sql + * @return {string} + */ + urlEncode(sql) { + return `REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(CAST(${sql} as TEXT), '%', '%25'), '&', '%26'), '=', '%3D'), '+', '%2B'), ' ', '%20')`; + } + /** * @param {string} granularity * @param {string} dimension @@ -5007,7 +5018,8 @@ export class BaseQuery { filterParams: this.filtersProxy(), filterGroup: this.filterGroupFunction(), sqlUtils: { - convertTz: this.convertTz.bind(this) + convertTz: this.convertTz.bind(this), + urlEncode: this.urlEncode.bind(this) } }, R.map( (symbols) => this.contextSymbolsProxy(symbols), @@ -5023,6 +5035,7 @@ export class BaseQuery { filterGroup: () => '1 = 1', sqlUtils: { convertTz: (field) => field, + urlEncode: (sql) => sql, }, securityContext: CubeSymbols.contextSymbolsProxyFrom({}, allocateParam), }; @@ -5034,7 +5047,8 @@ export class BaseQuery { sqlUtilsForRust() { return { - convertTz: this.convertTz.bind(this) + convertTz: this.convertTz.bind(this), + urlEncode: this.urlEncode.bind(this) }; } diff --git a/packages/cubejs-schema-compiler/src/adapter/PrestodbQuery.ts b/packages/cubejs-schema-compiler/src/adapter/PrestodbQuery.ts index 193f95aee08d6..06c67597e9c9c 100644 --- a/packages/cubejs-schema-compiler/src/adapter/PrestodbQuery.ts +++ b/packages/cubejs-schema-compiler/src/adapter/PrestodbQuery.ts @@ -196,4 +196,8 @@ export class PrestodbQuery extends BaseQuery { public castToString(sql: any): string { return `CAST(${sql} as VARCHAR)`; } + + public urlEncode(sql: string): string { + return `url_encode(CAST(${sql} as VARCHAR))`; + } } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index e500f000f8c48..c69d1305579f5 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -32,6 +32,16 @@ export type SegmentDefinition = { multiStage?: boolean; }; +export type LinkDefinition = { + name: string; + label: string; + url?: (...args: any[]) => string; + dashboard?: string; + icon?: string; + target?: 'blank' | 'self'; + params?: Array<{ key: string; value: (...args: any[]) => string }>; +}; + export type DimensionDefinition = { type: string; sql(): string; @@ -43,6 +53,7 @@ export type DimensionDefinition = { order?: 'asc' | 'desc'; key?: (...args: any[]) => ToString; keyReference?: string; + links?: LinkDefinition[]; }; export type TimeShiftDefinition = { @@ -199,6 +210,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 +302,68 @@ export class CubeEvaluator extends CubeSymbols { } } + protected prepareSyntheticLinkDimensions(cube: any) { + if (!cube.dimensions) return; + if (cube.isView) 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`; + + if (cube.dimensions[syntheticName] && !(link.params && Array.isArray(link.params) && link.params.length > 0)) return; + + let baseSql; + if (link.url) { + baseSql = link.url; + } else if (link.dashboard) { + const dashboardId = typeof link.dashboard === 'function' ? link.dashboard() : link.dashboard; + // eslint-disable-next-line no-new-func + baseSql = new Function(cube.name, `return \`'/dashboard/${dashboardId}'\``); + } + + if (baseSql) { + let sql; + if (link.params && Array.isArray(link.params) && link.params.length > 0) { + sql = this.buildLinkSqlWithParams(cube.name, baseSql, link.params); + } else { + sql = baseSql; + } + cube.dimensions[syntheticName] = { + sql, + type: 'string', + synthetic: true, + ownedByCube: true, + public: true, + }; + } + }); + } + } + } + + private buildLinkSqlWithParams(cubeName: string, baseSql: any, params: Array) { + if (params.length === 0) { + return baseSql; + } + + // eslint-disable-next-line no-new-func + const fn = new Function(cubeName, 'SQL_UTILS', ` + var baseResult = (${baseSql.toString()})(${cubeName}); + var result = baseResult; + ${params.map((param, idx) => { + const separator = idx === 0 ? '?' : '&'; + const key = typeof param.key === 'function' ? param.key() : param.key; + const valueFnStr = typeof param.value === 'function' ? param.value.toString() : `function() { return '${param.value}'; }`; + return `result = result + " || '${separator}${key}=' || " + SQL_UTILS.urlEncode((${valueFnStr})(${cubeName}));`; + }).join('\n ')} + return result; + `); + Object.defineProperty(fn, 'length', { value: baseSql.length }); + return fn; + } + private allMembersOrList(cube: any, specifier: string | string[]): string[] { const types = ['measures', 'dimensions', 'segments']; if (specifier === '*') { @@ -739,7 +813,7 @@ export class CubeEvaluator extends CubeSymbols { } } - if (ownedByCube && cube.isView) { + if (ownedByCube && cube.isView && !members[memberName].synthetic) { errorReporter.error(`View '${cube.name}' defines own member '${cube.name}.${memberName}'. Please move this member definition to one of the cubes.`); } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts index 6d12a55035418..120a2ebe39b3d 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts @@ -565,6 +565,10 @@ export class CubeSymbols implements TranspilerSymbolResolver, CompilerInterface this.transformPreAggregations(cube.preAggregations); } + if (this.evaluateViews && !cube.isView) { + this.generateSyntheticLinkDimensions(cube); + } + if (this.evaluateViews) { this.prepareIncludes(cube, errorReporter, splitViews); } @@ -635,6 +639,40 @@ export class CubeSymbols implements TranspilerSymbolResolver, CompilerInterface } } + protected generateSyntheticLinkDimensions(cube: any) { + if (!cube.dimensions) return; + + const dims = cube.dimensions; + for (const dimName of Object.keys(dims)) { + const dimDef = dims[dimName]; + if (dimDef.links && Array.isArray(dimDef.links)) { + for (const link of dimDef.links) { + const linkName = typeof link.name === 'function' ? link.name() : link.name; + if (!linkName) return; + const syntheticName = `${dimName}___link_${linkName}_url`; + if (!dims[syntheticName]) { + let sql; + if (link.url) { + sql = link.url; + } else if (link.dashboard) { + const dashboardId = typeof link.dashboard === 'function' ? link.dashboard() : link.dashboard; + // eslint-disable-next-line no-new-func + sql = new Function(cube.name, `return \`'/dashboard/${dashboardId}'\``); + } + if (sql) { + dims[syntheticName] = { + sql, + type: 'string', + synthetic: true, + public: true, + }; + } + } + } + } + } + } + protected transformPreAggregations(preAggregations: Object) { // eslint-disable-next-line no-restricted-syntax for (const preAggregation of Object.values(preAggregations)) { @@ -723,9 +761,25 @@ export class CubeSymbols implements TranspilerSymbolResolver, CompilerInterface .map((path) => path.split('.')[1]) .filter(memberName => !(it.includes as (string | ViewCubeIncludeMember)[]).find((include) => ((typeof include === 'object' ? include.name : include)) === memberName)); + // Auto-include synthetic link dimensions for included dimensions that have links + const syntheticLinkMembers: string[] = []; + const membersObj = this.symbols[cubeRef]?.cubeObj()?.dimensions || {}; + for (const include of (it.includes as (string | ViewCubeIncludeMember)[])) { + const memberName = typeof include === 'object' ? include.name : include; + if (membersObj[memberName] && (membersObj[memberName] as any).links) { + for (const key of Object.keys(membersObj)) { + if (key.startsWith(`${memberName}___link_`) && key.endsWith('_url')) { + if (!(it.includes as (string | ViewCubeIncludeMember)[]).find((inc) => ((typeof inc === 'object' ? inc.name : inc)) === key)) { + syntheticLinkMembers.push(key); + } + } + } + } + } + return { ...it, - includes: (it.includes as (string | ViewCubeIncludeMember)[]).concat(currentCubeAutoIncludeMembers), + includes: (it.includes as (string | ViewCubeIncludeMember)[]).concat(currentCubeAutoIncludeMembers).concat(syntheticLinkMembers), }; }) : includedCubes; @@ -1015,6 +1069,8 @@ 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 } : {}), + ...(resolvedMember.synthetic ? { synthetic: true } : {}), }; } 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..8e8cb29e8b064 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts @@ -51,6 +51,16 @@ export interface ExtendedCubeSymbolDefinition extends CubeSymbolDefinition { aggType?: string; keyReference?: string; currency?: string; + links?: Array<{ + name: string; + label: string; + url?: (...args: any[]) => string; + dashboard?: string; + icon?: string; + target?: 'blank' | 'self'; + params?: Array<{ key: string; value: (...args: any[]) => string }>; + }>; + synthetic?: boolean; } interface ExtendedCubeDefinition extends CubeDefinitionExtended { @@ -97,6 +107,14 @@ export type MeasureConfig = { public: boolean; }; +export type LinkConfig = { + name: string; + label: string; + dashboard?: string; + icon?: string; + target: 'blank' | 'self'; +}; + export type DimensionConfig = { name: string; title: string; @@ -115,6 +133,8 @@ export type DimensionConfig = { granularities?: GranularityDefinition[]; order?: 'asc' | 'desc'; key?: string; + links?: LinkConfig[]; + synthetic?: boolean; }; export type SegmentConfig = { @@ -314,6 +334,14 @@ 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, + ...(link.dashboard ? { dashboard: typeof link.dashboard === 'function' ? link.dashboard() : link.dashboard } : {}), + icon: link.icon, + target: link.target || 'blank', + })) } : {}), + ...(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..dccd493177484 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -311,6 +311,21 @@ const MaskSchema = Joi.alternatives([ Joi.string(), ]); +const LinkItemSchema = Joi.object().keys({ + name: identifier.required(), + label: Joi.string().required(), + url: Joi.func(), + dashboard: Joi.string(), + icon: Joi.string(), + target: Joi.string().valid('blank', 'self'), + params: Joi.array().items(Joi.object().keys({ + key: Joi.string().required(), + value: Joi.func().required(), + })), +}).oxor('url', 'dashboard'); + +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 +338,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..c9a09d28c6dfd 100644 --- a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts @@ -41,6 +41,8 @@ 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$/, + /^dimensions\.[_a-zA-Z][_a-zA-Z0-9]*\.links\.[0-9]+\.params\.[0-9]+\.value$/, ]; 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..d95784cd74d59 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/links.test.ts @@ -0,0 +1,426 @@ +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('dashboard links', () => { + const schemaWithDashboardLink = ` +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: overview + label: View dashboard + dashboard: abc123 + icon: dashboard +`; + + it('should create synthetic dimension for dashboard link', async () => { + const compilers = prepareYamlCompiler(schemaWithDashboardLink); + await compilers.compiler.compile(); + + const dimDef = compilers.cubeEvaluator.dimensionByPath('users.full_name___link_overview_url'); + expect(dimDef).toBeDefined(); + expect(dimDef.type).toBe('string'); + expect((dimDef as any).synthetic).toBe(true); + }); + + it('should expose dashboard in meta config', async () => { + const compilers = prepareYamlCompiler(schemaWithDashboardLink); + 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![0].dashboard).toBe('abc123'); + }); + + it('should not allow both url and dashboard on same link', async () => { + const invalidSchema = ` +cubes: + - name: users + sql_table: users + + dimensions: + - name: full_name + sql: full_name + type: string + links: + - name: both + label: Invalid + url: "{full_name}" + dashboard: abc123 +`; + const compilers = prepareYamlCompiler(invalidSchema); + + try { + await compilers.compiler.compile(); + fail('Should have thrown a validation error'); + } catch (e: any) { + expect(e.message || e.toString()).toMatch(/url.*dashboard|dashboard.*url/i); + } + }); + }); + + describe('params', () => { + const schemaWithParams = ` +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: profile + label: View profile + dashboard: dash123 + params: + - key: user_id + value: "{id}" + - key: user_name + value: "{full_name}" + + - name: country + sql: country + type: string +`; + + it('should create synthetic dimension with params', async () => { + const compilers = prepareYamlCompiler(schemaWithParams); + await compilers.compiler.compile(); + + const dimDef = compilers.cubeEvaluator.dimensionByPath('users.full_name___link_profile_url'); + expect(dimDef).toBeDefined(); + expect(dimDef.type).toBe('string'); + expect((dimDef as any).synthetic).toBe(true); + expect(typeof dimDef.sql).toBe('function'); + }); + + it('should generate SQL with urlEncode for params', async () => { + const compilers = prepareYamlCompiler(schemaWithParams); + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: [], + dimensions: ['users.full_name___link_profile_url'], + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + expect(sql).toContain('/dashboard/dash123'); + expect(sql).toContain('user_id='); + expect(sql).toContain('name='); + expect(sql).toContain('REPLACE'); + }); + }); + + 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'); + }); + }); +}); diff --git a/packages/cubejs-testing/birdbox-fixtures/links/cube.js b/packages/cubejs-testing/birdbox-fixtures/links/cube.js new file mode 100644 index 0000000000000..7be35b6b6e7de --- /dev/null +++ b/packages/cubejs-testing/birdbox-fixtures/links/cube.js @@ -0,0 +1,2 @@ +module.exports = { +}; diff --git a/packages/cubejs-testing/birdbox-fixtures/links/model/cubes/users.yaml b/packages/cubejs-testing/birdbox-fixtures/links/model/cubes/users.yaml new file mode 100644 index 0000000000000..fc13a8714c52d --- /dev/null +++ b/packages/cubejs-testing/birdbox-fixtures/links/model/cubes/users.yaml @@ -0,0 +1,32 @@ +cubes: + - name: users + sql: > + SELECT 1 as id, 'John' as first_name, 'Doe' as last_name, 'New York' as city + UNION ALL + SELECT 2, 'Jane', 'Smith', 'London' + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: full_name + sql: "first_name || ' ' || last_name" + type: string + links: + - name: google_search + label: Search on Google + url: "{full_name}" + icon: brand-google + - name: profile + label: View profile + dashboard: user_profile_123 + + - name: city + sql: city + type: string + + measures: + - name: count + type: count diff --git a/packages/cubejs-testing/birdbox-fixtures/links/model/views/users_view.yaml b/packages/cubejs-testing/birdbox-fixtures/links/model/views/users_view.yaml new file mode 100644 index 0000000000000..b7a2b193cc8be --- /dev/null +++ b/packages/cubejs-testing/birdbox-fixtures/links/model/views/users_view.yaml @@ -0,0 +1,12 @@ +views: + - name: users_with_links + cubes: + - join_path: users + includes: + - full_name + - city + + - name: users_all + cubes: + - join_path: users + includes: "*" diff --git a/packages/cubejs-testing/package.json b/packages/cubejs-testing/package.json index ec739a0d54a1c..e5604cbb0d3b8 100644 --- a/packages/cubejs-testing/package.json +++ b/packages/cubejs-testing/package.json @@ -84,6 +84,7 @@ "smoke:vertica:snapshot": "jest --verbose --updateSnapshot -i dist/test/smoke-vertica.test.js", "smoke:rbac": "TZ=UTC jest --verbose -i dist/test/smoke-rbac.test.js", "smoke:rbac-graphql": "TZ=UTC jest --verbose -i dist/test/smoke-rbac-graphql.test.js", + "smoke:links": "jest --verbose -i dist/test/smoke-links.test.js", "smoke:cubesql": "TZ=UTC jest --verbose --forceExit -i dist/test/smoke-cubesql.test.js", "smoke:cubesql:snapshot": "TZ=UTC jest --verbose --forceExit --updateSnapshot -i dist/test/smoke-cubesql.test.js", "smoke:prestodb": "jest --verbose -i dist/test/smoke-prestodb.test.js", diff --git a/packages/cubejs-testing/test/smoke-links.test.ts b/packages/cubejs-testing/test/smoke-links.test.ts new file mode 100644 index 0000000000000..22f2489ed25b4 --- /dev/null +++ b/packages/cubejs-testing/test/smoke-links.test.ts @@ -0,0 +1,128 @@ +import fetch from 'node-fetch'; +import { StartedTestContainer } from 'testcontainers'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { afterAll, beforeAll, expect, jest } from '@jest/globals'; +import cubejs, { CubeApi } from '@cubejs-client/core'; +import { PostgresDBRunner } from '@cubejs-backend/testing-shared'; +import { BirdBox, getBirdbox } from '../src'; +import { + DEFAULT_API_TOKEN, + DEFAULT_CONFIG, + JEST_AFTER_ALL_DEFAULT_TIMEOUT, + JEST_BEFORE_ALL_DEFAULT_TIMEOUT, +} from './smoke-tests'; + +describe('links through views', () => { + jest.setTimeout(60 * 5 * 1000); + let db: StartedTestContainer; + let birdbox: BirdBox; + let client: CubeApi; + + beforeAll(async () => { + db = await PostgresDBRunner.startContainer({}); + birdbox = await getBirdbox( + 'postgres', + { + ...DEFAULT_CONFIG, + CUBEJS_DB_HOST: db.getHost(), + CUBEJS_DB_PORT: `${db.getMappedPort(5432)}`, + CUBEJS_DB_NAME: 'test', + CUBEJS_DB_USER: 'test', + CUBEJS_DB_PASS: 'test', + }, + { + schemaDir: 'links/model', + cubejsConfig: 'links/cube.js', + }, + ); + client = cubejs(async () => DEFAULT_API_TOKEN, { + apiUrl: birdbox.configuration.apiUrl, + }); + }, JEST_BEFORE_ALL_DEFAULT_TIMEOUT); + + afterAll(async () => { + await birdbox.stop(); + await db.stop(); + }, JEST_AFTER_ALL_DEFAULT_TIMEOUT); + + test('meta exposes link synthetic dimensions on view with explicit includes', async () => { + const meta = await fetch( + `${birdbox.configuration.apiUrl}/meta`, + { headers: { Authorization: DEFAULT_API_TOKEN } } + ); + const metaJson = await meta.json() as any; + + const view = metaJson.cubes.find((c: any) => c.name === 'users_with_links'); + expect(view).toBeDefined(); + + const dimNames = view.dimensions.map((d: any) => d.name); + expect(dimNames).toContain('users_with_links.full_name'); + expect(dimNames).toContain('users_with_links.full_name___link_google_search_url'); + expect(dimNames).toContain('users_with_links.full_name___link_profile_url'); + }); + + test('meta exposes links metadata on parent dimension', async () => { + const meta = await fetch( + `${birdbox.configuration.apiUrl}/meta`, + { headers: { Authorization: DEFAULT_API_TOKEN } } + ); + const metaJson = await meta.json() as any; + + const view = metaJson.cubes.find((c: any) => c.name === 'users_with_links'); + const fullNameDim = view.dimensions.find((d: any) => d.name === 'users_with_links.full_name'); + + expect(fullNameDim.links).toBeDefined(); + expect(fullNameDim.links).toHaveLength(2); + expect(fullNameDim.links[0].name).toBe('google_search'); + expect(fullNameDim.links[0].label).toBe('Search on Google'); + expect(fullNameDim.links[0].icon).toBe('brand-google'); + expect(fullNameDim.links[1].name).toBe('profile'); + expect(fullNameDim.links[1].dashboard).toBe('user_profile_123'); + }); + + test('synthetic link dimensions are marked as synthetic in meta', async () => { + const meta = await fetch( + `${birdbox.configuration.apiUrl}/meta`, + { headers: { Authorization: DEFAULT_API_TOKEN } } + ); + const metaJson = await meta.json() as any; + + const view = metaJson.cubes.find((c: any) => c.name === 'users_with_links'); + const syntheticDim = view.dimensions.find( + (d: any) => d.name === 'users_with_links.full_name___link_google_search_url' + ); + + expect(syntheticDim).toBeDefined(); + expect(syntheticDim.synthetic).toBe(true); + expect(syntheticDim.type).toBe('string'); + }); + + test('wildcard view includes all link synthetic dimensions', async () => { + const meta = await fetch( + `${birdbox.configuration.apiUrl}/meta`, + { headers: { Authorization: DEFAULT_API_TOKEN } } + ); + const metaJson = await meta.json() as any; + + const view = metaJson.cubes.find((c: any) => c.name === 'users_all'); + expect(view).toBeDefined(); + + const dimNames = view.dimensions.map((d: any) => d.name); + expect(dimNames).toContain('users_all.full_name___link_google_search_url'); + expect(dimNames).toContain('users_all.full_name___link_profile_url'); + }); + + test('can query dashboard link synthetic dimension through view', async () => { + const response = await client.load({ + dimensions: [ + 'users_with_links.full_name', + 'users_with_links.full_name___link_profile_url', + ], + limit: 1, + }); + const data = response.rawData(); + expect(data.length).toBeGreaterThanOrEqual(1); + const url = data[0]['users_with_links.full_name___link_profile_url']; + expect(url).toContain('/dashboard/user_profile_123'); + }); +});