Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1d2e325
DEV-1608: Cube → SLayer ingestion (Stage 1)
ZmeiGorynych Jun 29, 2026
c1990c4
DEV-1608: address PR review feedback (Sonar gate + Codex)
ZmeiGorynych Jun 30, 2026
3ac7590
DEV-1608: final review nits (Sonar complexity + spec markdown)
ZmeiGorynych Jun 30, 2026
2f55c37
DEV-1608: clarify facade joined-measure doc example (CodeRabbit)
ZmeiGorynych Jul 1, 2026
3959d5f
DEV-1608: report path honors --models-dir (CodeRabbit)
ZmeiGorynych Jul 1, 2026
488f5d0
DEV-1608: address CodeRabbit review batch
ZmeiGorynych Jul 1, 2026
0d9d030
Merge remote-tracking branch 'origin/egor/dev-1625-variable-substitut…
ZmeiGorynych Aug 3, 2026
b31318c
Merge remote-tracking branch 'origin/main' into egor/dev-1608-cube-to…
ZmeiGorynych Aug 3, 2026
75d1aa7
feat(DEV-1730): list-valued {variable} substitution for IN pushdown
ZmeiGorynych Aug 3, 2026
30173ab
fix(DEV-1608/DEV-1730): address CodeRabbit + Sonar + Codex review
ZmeiGorynych Aug 3, 2026
00ba92b
feat(DEV-1730): Cube JS ingestion + FILTER_PARAMS representability
ZmeiGorynych Aug 4, 2026
2bb0428
Merge remote-tracking branch 'origin/egor/dev-1608-cube-to-slayer-ing…
ZmeiGorynych Aug 4, 2026
9648d6b
feat(DEV-1727): dialect-aware / complete escaping for Mode-A {var} su…
ZmeiGorynych Aug 4, 2026
ad365f9
Merge origin/egor/dev-1608-cube-to-slayer-ingestion into DEV-1727
ZmeiGorynych Aug 4, 2026
a944208
fix(DEV-1730): address CodeRabbit + Codex + Sonar review
ZmeiGorynych Aug 4, 2026
c8485d0
refactor(DEV-1730): reduce cognitive complexity (Sonar S3776)
ZmeiGorynych Aug 4, 2026
4016f4a
test(DEV-1727): isolate the throwing call in two raises-blocks (Sonar…
ZmeiGorynych Aug 4, 2026
6dec2dc
docs(DEV-1730): demonstrate optional blocks in the variable-substitut…
ZmeiGorynych Aug 4, 2026
8970bc7
Merge pull request #278 from MotleyAI/egor/dev-1727-dialect-aware-com…
ZmeiGorynych Aug 4, 2026
f4a7c71
docs(DEV-1730): clarify optional-block + list contract (CodeRabbit)
ZmeiGorynych Aug 4, 2026
0d8be1c
Merge remote-tracking branch 'origin/egor/dev-1608-cube-to-slayer-ing…
ZmeiGorynych Aug 4, 2026
6cb9e7b
refactor(DEV-1730): keep substitute_variables under S3776 after DEV-1…
ZmeiGorynych Aug 4, 2026
17bf5ce
Merge pull request #275 from MotleyAI/egor/dev-1730-cube-js-config-in…
ZmeiGorynych Aug 4, 2026
9bb846e
fix(DEV-1730): coerce scalars for declared list-valued {variable}s
ZmeiGorynych Aug 4, 2026
ef5a738
fix(DEV-1730): make the declared-variable bag self-identifying
ZmeiGorynych Aug 4, 2026
fb16a22
fix(DEV-1730): require a non-empty member in a variable declaration
ZmeiGorynych Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
540 changes: 540 additions & 0 deletions CUBE_IMPORT_SPEC.md

Large diffs are not rendered by default.

136 changes: 136 additions & 0 deletions docs/cube/cube_import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Importing Cube definitions

SLayer can import [Cube](https://cube.dev) (Cube.js / Cube.dev) YAML data models —
cubes and views — and convert them to SLayer models. The conversion is **fully
offline**: data types come from Cube's declared dimension / measure types, so no
database connection is required. Everything that can't be mapped cleanly is
captured in a structured JSON report rather than silently dropped.

## Quick start

```bash
slayer import-cube ./cube_project --datasource my_postgres --storage ./slayer_data
```

This recursively reads every `.yml`/`.yaml` file under the path, extracts
`cubes:` and `views:`, writes SLayer model files to the storage directory, and
writes `cube_import_report.json` next to it.

`--datasource` is just the SLayer datasource name to file the models under — it
does not need to exist or be reachable. After importing, run `slayer ingest`
against a live connection to profile sample values and refine numeric types.

## What gets converted

### Cubes → models

Each cube becomes one `SlayerModel` anchored on its `sql_table` (or `sql`).

| Cube | SLayer |
|------|--------|
| `name` | `name` |
| `sql_table` / `sql` | `sql_table` / `sql` (with `{CUBE}`/`{member}` refs translated) |
| `description` | `description` |
| `public: false` | `hidden: true` |
| `meta` (incl. `ai_context`) | `meta` |
| `title` | `meta.cube_title` |

### Measures → columns + measures

Cube bakes the aggregation into each measure; SLayer separates the row-level
expression (a `Column`) from the named aggregation (a `ModelMeasure`).

```yaml
# Cube
measures:
- { name: total_revenue, type: sum, sql: "{CUBE}.amount" }
# SLayer
columns:
- { name: amount, type: DOUBLE }
measures:
- { name: total_revenue, formula: "amount:sum" }
```

- `count` with no `sql` → `*:count`; `count_distinct_approx` → `count_distinct`.
- Conditional `filters:` become a `CASE WHEN` on the column's `filter`. Two
measures over the same expression but different filters get distinct columns.
- A finite trailing `rolling_window` becomes a windowed aggregation
(`amount:sum(window='30d')`).
- Calculated measures (`type: number/string/time/boolean`) referencing other
measures become a `ModelMeasure` formula (`{revenue} / {count}` → `revenue / count`).
- `format` maps to `NumberFormat` (`percent`, `currency`, `number`).

### Dimensions → columns

`string`→`TEXT`, `number`→`DOUBLE`, `boolean`→`BOOLEAN`, `time`→`TIMESTAMP`.
`primary_key: true` carries over. A `case:` dimension becomes a `CASE WHEN`
column.

### Joins

A join's ON clause (`{CUBE}.customer_id = {customers.id}`) becomes
`join_pairs`; member references resolve to their physical columns. Composite
(`AND`-joined) keys are supported. All joins emit as `LEFT`.

### Segments → boolean columns

Each segment becomes a boolean column carrying the predicate, so it stays
filterable (`completed = true`) and group-able.

### Views → facade models

A Cube view (which owns no table) becomes a thin model anchored on its
`join_path` root cube: included dimensions become derived columns
(`sql: "customers.name"`), included measures become local or cross-model
`ModelMeasure`s (`customers.ltv:sum`), `prefix: true` prepends the cube name,
and `default_filters` become model filters.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### `extends`

Cube inheritance is **flattened** at import time — a child inherits the parent's
members (child wins on conflicts), and abstract bases (`public: false`) are
emitted as hidden models.

## What does not map (reported)

These are recorded in `cube_import_report.json` and, where useful, preserved
under `meta.cube_unmapped.<feature>`:

- Caching / infra: `pre_aggregations`, `refresh_key`, `calendar`, `sql_alias`.
- Presentation: `hierarchies`, `drill_members`, folders, dimension `links`/`order`.
- Security: `access_policy`.
- No SLayer equivalent: `geo` dimensions, `sub_query` dimensions, custom
`granularities`, per-cube `data_source`.
- Non-equality / non-column join ON clauses (the join is dropped).
- Files or members using Jinja templating (`{{ }}` / `{% %}`) — skipped, since
conversion is offline and does not render templates.

### Tesseract features (deferred)

Features that require the Tesseract SQL planner — `switch` dimensions,
`number_agg` measures, `case` measures, and the measure `filter` grain control —
have no clean SLayer mapping yet and are reported as `deferred_stage2`. The
cube's other members still convert.

## The report

`CubeConversionResult` carries the emitted `models` and a `CubeConversionReport`
of categorized issues (each with a category, severity, the owning cube/view/member,
a message, and the raw Cube fragment when useful). The CLI always writes it to
`cube_import_report.json` (override with `--report PATH`) and prints a summary
grouped by severity.

## CLI reference

```text
slayer import-cube <cube_project_path> [options]

Arguments:
cube_project_path Path to the Cube project (or its model directory)

Options:
--datasource NAME SLayer datasource name for the imported models (required)
--storage PATH Storage directory / .db file (default: platform path)
--report PATH JSON report path (default: <storage>/cube_import_report.json)
--include-hidden Also print hidden (public: false) models in the summary
```
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ nav:
- dbt:
- SLayer vs dbt: dbt/slayer_vs_dbt.md
- Importing dbt definitions: dbt/dbt_import.md
- Cube:
- Importing Cube definitions: cube/cube_import.md
- Tutorials:
- Make it dynamic: examples/01_dynamic/dynamic.md
- SQL vs DSL:
Expand Down
74 changes: 74 additions & 0 deletions slayer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,23 @@ def main(): # NOSONAR(S3776) — linear top-level CLI command dispatch (one eli
)
_add_storage_arg(import_dbt_parser)

# ── import-cube ───────────────────────────────────────────────────
import_cube_parser = subparsers.add_parser(
"import-cube",
help="Import Cube (Cube.js / Cube.dev) YAML data models into SLayer models",
epilog="""\
examples:
slayer import-cube ./cube_project --datasource my_postgres
slayer import-cube ./cube_project/model --datasource my_postgres --report ./report.json
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
import_cube_parser.add_argument("cube_project_path", help="Path to the Cube project (or its model directory)")
import_cube_parser.add_argument("--datasource", required=True, help="SLayer datasource name to file the imported models under")
import_cube_parser.add_argument("--report", default=None, help="Path for the JSON conversion report (default: <storage>/cube_import_report.json)")
import_cube_parser.add_argument("--include-hidden", action="store_true", help="Also print hidden (public: false) models in the summary")
_add_storage_arg(import_cube_parser)

# ── models ────────────────────────────────────────────────────────
models_parser = subparsers.add_parser(
"models",
Expand Down Expand Up @@ -761,6 +778,8 @@ def main(): # NOSONAR(S3776) — linear top-level CLI command dispatch (one eli
_run_validate_models(args)
elif args.command == "import-dbt":
_run_import_dbt(args)
elif args.command == "import-cube":
_run_import_cube(args)
elif args.command == "models":
_run_models(args)
elif args.command == "datasources":
Expand Down Expand Up @@ -1441,6 +1460,61 @@ def _run_import_dbt(args):
)


def _run_import_cube(args):
from slayer.cube.converter import CubeToSlayerConverter
from slayer.cube.parser import parse_cube_project

storage = _resolve_storage(args)
project, parse_issues = parse_cube_project(args.cube_project_path)

if not project.cubes and not project.views:
print(f"No cubes or views found in {args.cube_project_path}")
sys.exit(1)

result = CubeToSlayerConverter(
project=project, data_source=args.datasource, parse_issues=parse_issues,
).convert()
for model in result.models:
run_sync(storage.save_model(model))

_print_cube_import_summary(result, include_hidden=args.include_hidden)
report_path = _write_cube_report(result, args)
print(
f"\nDone: {result.report.model_count} models "
f"({result.report.hidden_count} hidden, {result.report.view_count} views), "
f"{len(result.report.issues)} report issues. Report: {report_path}"
)


def _print_cube_import_summary(result, *, include_hidden):
for model in result.models:
if model.hidden and not include_hidden:
continue
suffix = " [hidden]" if model.hidden else ""
kind = " (view)" if (model.meta or {}).get("cube_kind") == "view" else ""
print(
f"Imported model: {model.name}{kind}{suffix} "
f"({len(model.columns)} columns, {len(model.measures)} measures)"
)
for severity in ("error", "warning", "info"):
for issue in result.report.by_severity(severity):
print(f" {severity.upper()} [{issue.category.value}/{issue.context}]: {issue.message}")


def _write_cube_report(result, args) -> str:
import os

storage_base = args.storage or _STORAGE_DEFAULT
if storage_base.endswith(".db"):
storage_base = os.path.dirname(storage_base) or "."
report_path = args.report or os.path.join(storage_base, "cube_import_report.json")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# Report path is an intended, user-specified CLI output location.
os.makedirs(os.path.dirname(report_path) or ".", exist_ok=True) # NOSONAR(S8707)
with open(report_path, "w", encoding="utf-8") as fh: # NOSONAR(S8707)
fh.write(result.report.model_dump_json(indent=2))
return report_path


def _run_models(args):
import yaml

Expand Down
3 changes: 3 additions & 0 deletions slayer/cube/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Cube (Cube.js / Cube.dev) data-model ingestion — parse Cube YAML and
convert to SLayer models. Mirrors ``slayer/dbt/``. See DEV-1608.
"""
Loading
Loading