Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
539 changes: 539 additions & 0 deletions CUBE_IMPORT_SPEC.md

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions docs/cube/cube_import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 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"`), and included measures become local or cross-model
`ModelMeasure`s that reference the measure's **underlying column** — a joined
measure `revenue` with `sql: {CUBE}.amount` becomes `customers.amount:sum`, not
`customers.revenue:sum`. `prefix: true` prepends the cube name, and
`default_filters` become model filters.

### `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
81 changes: 81 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,68 @@ 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()
from slayer.cube.report import CubeConversionIssue, CubeIssueCategory
for model in result.models:
try:
run_sync(storage.save_model(model))
except Exception as exc: # noqa: BLE001 — one bad model shouldn't abort the import
result.report.add(CubeConversionIssue(
category=CubeIssueCategory.PARSE_ERROR, severity="error",
cube=model.name,
message=f"Failed to save model '{model.name}': {exc}"))

_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 args.models_dir 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")
# 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