From 0228c0d6a838981dc56a137684724f0ff572b6da Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Wed, 12 Aug 2026 11:26:10 +0200 Subject: [PATCH 1/2] docs(DEV-1782): add end-to-end Cube import example notebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs/examples/15_cube_import/ walking through `slayer import-cube` on a small jaffle-shop Cube project: YAML cubes + a view + a JS FILTER_PARAMS cube (required `category`, optional `region`, both list-valued). The notebook runs the real CLI, inspects the generated models (the `Variables:` skeleton line and `meta.cube_variables`), and queries them — optional-block collapse to (1=1), scalar->one-element-list coercion, and the required-omitted raise — checking every answer against gold SQL. Adds a paired prose page, a Tutorials nav entry, a back-link from the Cube reference, and a non-integration test guarding the sample configs + helper. --- .gitignore | 3 + docs/cube/cube_import.md | 6 + docs/examples/15_cube_import/cube_import.md | 59 ++ .../15_cube_import/cube_import_nb.ipynb | 873 ++++++++++++++++++ .../cube_project/model/cubes/customers.yml | 16 + .../cube_project/model/cubes/order_facts.js | 58 ++ .../cube_project/model/cubes/orders.yml | 29 + .../model/views/orders_overview.yml | 15 + docs/examples/15_cube_import/setup_cube.py | 227 +++++ tests/test_cube_import_example.py | 284 ++++++ zensical.toml | 4 + 11 files changed, 1574 insertions(+) create mode 100644 docs/examples/15_cube_import/cube_import.md create mode 100644 docs/examples/15_cube_import/cube_import_nb.ipynb create mode 100644 docs/examples/15_cube_import/cube_project/model/cubes/customers.yml create mode 100644 docs/examples/15_cube_import/cube_project/model/cubes/order_facts.js create mode 100644 docs/examples/15_cube_import/cube_project/model/cubes/orders.yml create mode 100644 docs/examples/15_cube_import/cube_project/model/views/orders_overview.yml create mode 100644 docs/examples/15_cube_import/setup_cube.py create mode 100644 tests/test_cube_import_example.py diff --git a/.gitignore b/.gitignore index 828667e9..120d090f 100644 --- a/.gitignore +++ b/.gitignore @@ -225,3 +225,6 @@ docs/examples/11_dbt_metricflow/.cache/ # Auto-generated by the OSI import demo (DuckDB + converted models) docs/examples/13_osi_import/.cache/ + +# Auto-generated by the Cube import demo (DuckDB + imported models + report) +docs/examples/15_cube_import/.cache/ diff --git a/docs/cube/cube_import.md b/docs/cube/cube_import.md index f6ce4461..27a6665c 100644 --- a/docs/cube/cube_import.md +++ b/docs/cube/cube_import.md @@ -22,6 +22,12 @@ to the storage directory, and writes `cube_import_report.json` next to 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. +> **Worked example.** [From Cube to SLayer](../examples/15_cube_import/cube_import.md) +> imports a small jaffle-shop project end-to-end — YAML cubes, a view, and a JS +> `FILTER_PARAMS` cube — and queries the result. Its +> [notebook](../examples/15_cube_import/cube_import_nb.ipynb) runs the whole flow +> offline against a deterministic DuckDB. + ## What gets converted ### Cubes → models diff --git a/docs/examples/15_cube_import/cube_import.md b/docs/examples/15_cube_import/cube_import.md new file mode 100644 index 00000000..65715ad4 --- /dev/null +++ b/docs/examples/15_cube_import/cube_import.md @@ -0,0 +1,59 @@ +# From Cube to SLayer + +SLayer imports [Cube](https://cube.dev) data models — cubes and views, in **YAML +or JavaScript** — and turns them into queryable SLayer models. The conversion is +**fully offline**: column types come from Cube's own dimension / measure +declarations, so no database connection is needed to import. This example runs +the whole path on a small jaffle-shop-flavored project. + +## The one-liner + +```bash +slayer import-cube cube_project --datasource shop_cube --storage .cache/slayer_models +``` + +`--datasource` is just the SLayer datasource name to file the models under — it +doesn't have to exist or be reachable at import time. To *query* the imported +models you register a datasource of that name (the notebook does this against a +tiny DuckDB) and run SLayer queries as usual. See +[Importing Cube definitions](../../cube/cube_import.md) for the full conversion +reference — what maps, what fails cleanly, and the JSON report. + +## What this example shows + +The `cube_project/` has four files, exercising the main conversions: + +| Cube source | Becomes | Feature shown | +|-------------|---------|---------------| +| `cubes/orders.yml` | a table-anchored model + a join | cube → model; `join` → SLayer join | +| `cubes/customers.yml` | a table-anchored model | the join target (`region` dimension) | +| `views/orders_overview.yml` | a facade model | a Cube **view** → thin model over the join path | +| `cubes/order_facts.js` | an sql-mode model | JS cube; `FILTER_PARAMS` → `{var}` / `{? ?}` | + +The JS cube's `FILTER_PARAMS` are the interesting part. Cube's +`FILTER_PARAMS...filter('col')` renders a caller-supplied filter, +and SLayer represents it with [`{variable}` substitution](../14_variable_substitution/variable_substitution_nb.ipynb): + +- **`category`** carries `meta.required`, so its pushdown is **required** — a bare + `p.category IN ({category})`. Omit it and the query raises, naming the variable. +- **`region`** has no `meta.required`, so its pushdown is **optional** — wrapped in + a block `{? c.region IN ({region}) ?}` that collapses to `(1=1)` when omitted. + +Both are set-membership (`IN`) filters, so the importer marks them `list_valued` +in `meta.cube_variables`: pass a list, or a bare scalar the engine wraps into a +one-element list (`region="North"` ≡ `region=["North"]`). The generated model's +`Variables:` inspect line reads `category (required), region`. + +## Try it — the notebook + +[`cube_import_nb.ipynb`](cube_import_nb.ipynb) is self-contained and fully +offline: it builds a deterministic DuckDB, runs `slayer import-cube`, inspects the +generated models (the `Variables:` line and `meta.cube_variables`), and queries +them — the view, the inferred join, and every `FILTER_PARAMS` behavior above — +checking each answer against gold SQL. + +## Further reading + +- [Importing Cube definitions](../../cube/cube_import.md) — the conversion reference. +- [Variable Substitution](../14_variable_substitution/variable_substitution_nb.ipynb) — + the `{var}` / `{? ?}` mechanics imported models rely on, across every raw-SQL surface. diff --git a/docs/examples/15_cube_import/cube_import_nb.ipynb b/docs/examples/15_cube_import/cube_import_nb.ipynb new file mode 100644 index 00000000..13d970de --- /dev/null +++ b/docs/examples/15_cube_import/cube_import_nb.ipynb @@ -0,0 +1,873 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "05809536", + "metadata": {}, + "source": [ + "# From Cube to SLayer\n", + "\n", + "**TL;DR:** SLayer can import [Cube](https://cube.dev) data models — cubes and views, in **YAML or JavaScript** — and turn them into queryable SLayer models. This notebook imports a small jaffle-shop-flavored Cube project end-to-end with `slayer import-cube`, then queries the result and checks every answer against gold SQL.\n", + "\n", + "Cube import is **fully offline**: column types come from Cube's own dimension / measure declarations, so no database connection is needed to import. The DuckDB below exists only so we can *query* the imported models.\n", + "\n", + "Four steps (everything generated lands in a gitignored `.cache/` next to this notebook):\n", + "\n", + "1. **Build the data** — a tiny retail DuckDB (orders, customers, products) with deterministic rows.\n", + "2. **The Cube configs** — two YAML cubes, a view, and a JS cube using `FILTER_PARAMS`.\n", + "3. **Reference answers** — gold SQL, run up front.\n", + "4. **Import & query** — run `slayer import-cube`, inspect the generated models, and query them — including the `FILTER_PARAMS` pushdowns.\n", + "\n", + "See also: [Importing Cube definitions](../../cube/cube_import.md) · [Variable Substitution](../14_variable_substitution/variable_substitution_nb.ipynb) — the `{var}` / `{? ?}` mechanics imported models rely on." + ] + }, + { + "cell_type": "markdown", + "id": "2bce2338", + "metadata": {}, + "source": [ + "## Step 1 — Build the demo database\n", + "\n", + "Create the retail DuckDB the cubes bind to. Cube import itself needs no database — but we query the imported models afterwards, so the data has to exist." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "7c3a7530", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:39.119444Z", + "iopub.status.busy": "2026-08-12T09:19:39.119323Z", + "iopub.status.idle": "2026-08-12T09:19:39.652502Z", + "shell.execute_reply": "2026-08-12T09:19:39.652046Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Built retail DuckDB 'shop.duckdb' with orders, customers, products.\n" + ] + } + ], + "source": [ + "import json\n", + "import os\n", + "import sys\n", + "\n", + "import pandas as pd\n", + "\n", + "# The setup helper lives next to this notebook.\n", + "sys.path.insert(0, os.getcwd())\n", + "\n", + "from setup_cube import (\n", + " build_shop_duckdb,\n", + " compute_gold,\n", + " import_cube_cli,\n", + " save_datasource,\n", + " CUBE_PROJECT,\n", + " DB_PATH,\n", + " MODELS_DIR,\n", + " REPORT_PATH,\n", + " DATASOURCE_NAME,\n", + ")\n", + "from slayer.async_utils import run_sync\n", + "from slayer.engine.query_engine import SlayerQueryEngine\n", + "from slayer.inspect.model_render import render_model_skeleton\n", + "from slayer.storage.yaml_storage import YAMLStorage\n", + "\n", + "db_path = build_shop_duckdb(DB_PATH)\n", + "print(f\"Built retail DuckDB '{db_path.name}' with orders, customers, products.\")" + ] + }, + { + "cell_type": "markdown", + "id": "fa2a2f2f", + "metadata": {}, + "source": [ + "## Step 2 — The Cube configs\n", + "\n", + "The project has four files. `orders` and `customers` are ordinary table-anchored cubes (with a join between them); `orders_overview` is a **view** (it owns no table); and `order_facts` is a **JavaScript** cube whose raw SQL uses `FILTER_PARAMS` — Cube's mechanism for caller-supplied filters. Watch the two `FILTER_PARAMS` lines: `category` carries `meta.required` (a *required* pushdown), `region` does not (an *optional* one)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "6338d895", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:39.653431Z", + "iopub.status.busy": "2026-08-12T09:19:39.653213Z", + "iopub.status.idle": "2026-08-12T09:19:39.655851Z", + "shell.execute_reply": "2026-08-12T09:19:39.655395Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "# ===== model/cubes/orders.yml =====\n", + "# A table-anchored Cube. Becomes a SLayer model bound to the `orders` table,\n", + "# with a join to `customers` the importer turns into a SLayer join.\n", + "cubes:\n", + " - name: orders\n", + " sql_table: orders\n", + " description: One row per order line\n", + " joins:\n", + " - name: customers\n", + " relationship: many_to_one\n", + " sql: \"{CUBE}.customer_id = {customers.customer_id}\"\n", + " measures:\n", + " - name: count\n", + " type: count\n", + " - name: total_amount\n", + " type: sum\n", + " sql: \"{CUBE}.amount\"\n", + " title: Total Amount\n", + " format: currency\n", + " dimensions:\n", + " - name: order_id\n", + " sql: \"{CUBE}.order_id\"\n", + " type: number\n", + " primary_key: true\n", + " - name: status\n", + " sql: \"{CUBE}.status\"\n", + " type: string\n", + " - name: ordered_at\n", + " sql: \"{CUBE}.ordered_at\"\n", + " type: time\n", + "\n", + "# ===== model/cubes/customers.yml =====\n", + "# The join target for `orders`. Supplies the `region` dimension the view and\n", + "# the join queries group by.\n", + "cubes:\n", + " - name: customers\n", + " sql_table: customers\n", + " dimensions:\n", + " - name: customer_id\n", + " sql: \"{CUBE}.customer_id\"\n", + " type: number\n", + " primary_key: true\n", + " - name: name\n", + " sql: \"{CUBE}.name\"\n", + " type: string\n", + " - name: region\n", + " sql: \"{CUBE}.region\"\n", + " type: string\n", + "\n", + "# ===== model/views/orders_overview.yml =====\n", + "# A Cube view owns no table — it becomes a thin SLayer facade model over the\n", + "# `orders` join path. Included dimensions become derived columns; included\n", + "# measures become model measures. No `prefix`, no `default_filters`, so the\n", + "# included `region` stays named `region` and no WHERE is always applied.\n", + "views:\n", + " - name: orders_overview\n", + " cubes:\n", + " - join_path: orders\n", + " includes:\n", + " - count\n", + " - total_amount\n", + " - status\n", + " - join_path: orders.customers\n", + " includes:\n", + " - region\n", + "\n", + "# ===== model/cubes/order_facts.js =====\n", + "// A JavaScript sql-mode Cube with FILTER_PARAMS pushdowns. The importer turns\n", + "// each `FILTER_PARAMS...filter('col')` into a SLayer `{var}`\n", + "// substitution over a `col IN ({var})` template:\n", + "// * `category` carries `meta.required`, so its pushdown is REQUIRED — a bare\n", + "// `p.category IN ({category})`; omitting it raises.\n", + "// * `region` has no `meta.required`, so its pushdown is OPTIONAL — wrapped in\n", + "// an optional block `{? c.region IN ({region}) ?}` that collapses to\n", + "// `(1=1)` when the caller omits it.\n", + "// Both are string-form, so the importer marks them `list_valued` and the engine\n", + "// coerces a bare scalar to a one-element list before substituting.\n", + "cube(`order_facts`, {\n", + " sql: `\n", + " SELECT o.order_id, o.amount, o.status, c.region, p.category\n", + " FROM orders o\n", + " LEFT JOIN customers c ON o.customer_id = c.customer_id\n", + " LEFT JOIN products p ON o.product_id = p.product_id\n", + " WHERE 1 = 1\n", + " AND ${FILTER_PARAMS.order_facts.region.filter('c.region')}\n", + " AND ${FILTER_PARAMS.order_facts.category.filter('p.category')}\n", + " `,\n", + "\n", + " dimensions: {\n", + " order_id: {\n", + " sql: `${CUBE}.order_id`,\n", + " type: `number`,\n", + " primaryKey: true,\n", + " public: false,\n", + " },\n", + " status: {\n", + " sql: `${CUBE}.status`,\n", + " type: `string`,\n", + " },\n", + " region: {\n", + " sql: `${CUBE}.region`,\n", + " type: `string`,\n", + " description: `Optional pushdown — filters when supplied, no-op when omitted.`,\n", + " },\n", + " category: {\n", + " sql: `${CUBE}.category`,\n", + " type: `string`,\n", + " description: `Required pushdown — must be supplied or the query raises.`,\n", + " meta: {\n", + " required: true,\n", + " },\n", + " },\n", + " },\n", + "\n", + " measures: {\n", + " count: {\n", + " type: `count`,\n", + " },\n", + " total_amount: {\n", + " sql: `${CUBE}.amount`,\n", + " type: `sum`,\n", + " title: `Total Amount`,\n", + " },\n", + " },\n", + "});\n", + "\n" + ] + } + ], + "source": [ + "for rel in [\n", + " \"model/cubes/orders.yml\",\n", + " \"model/cubes/customers.yml\",\n", + " \"model/views/orders_overview.yml\",\n", + " \"model/cubes/order_facts.js\",\n", + "]:\n", + " print(f\"# ===== {rel} =====\")\n", + " print((CUBE_PROJECT / rel).read_text().rstrip())\n", + " print()" + ] + }, + { + "cell_type": "markdown", + "id": "580a43f3", + "metadata": {}, + "source": [ + "## Step 3 — Reference answers (gold SQL)\n", + "\n", + "Run the gold SQL **first**, up front, and stash the expected numbers. SLayer opens the DuckDB file with a read-write engine that a second raw connection can't share, so every gold query runs *before* any SLayer query touches the file." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "01376371", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:39.656676Z", + "iopub.status.busy": "2026-08-12T09:19:39.656583Z", + "iopub.status.idle": "2026-08-12T09:19:39.717702Z", + "shell.execute_reply": "2026-08-12T09:19:39.717339Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "total amount (all) : 1400.0\n", + "order_facts count (all) : 6\n", + "count region=North : 4\n", + "count region=South : 2\n", + "count category=Beverages : 3\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
regionamount
0North750.0
1South650.0
\n", + "
" + ], + "text/plain": [ + " region amount\n", + "0 North 750.0\n", + "1 South 650.0" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "GOLD = compute_gold(DB_PATH)\n", + "\n", + "print(\"total amount (all) :\", GOLD[\"total\"])\n", + "print(\"order_facts count (all) :\", GOLD[\"of_all\"])\n", + "print(\"count region=North :\", GOLD[\"of_north\"])\n", + "print(\"count region=South :\", GOLD[\"of_south\"])\n", + "print(\"count category=Beverages :\", GOLD[\"of_beverages\"])\n", + "pd.DataFrame(GOLD[\"by_region\"])" + ] + }, + { + "cell_type": "markdown", + "id": "69a7913e", + "metadata": {}, + "source": [ + "## Step 4 — Import with `slayer import-cube`\n", + "\n", + "This is the one-liner an operator runs:\n", + "\n", + "```bash\n", + "slayer import-cube cube_project --datasource shop_cube --storage .cache/slayer_models\n", + "```\n", + "\n", + "It reads every `.yml` / `.yaml` / `.js` under the path, converts each cube and view to a SLayer model, and writes a JSON report. Below we run exactly that command (through the current interpreter) and print its summary. The two `FILTER_PARAMS` members show up as an *optional* (`region`) and a *required* (`category`) variable." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5609cc2f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:39.718468Z", + "iopub.status.busy": "2026-08-12T09:19:39.718385Z", + "iopub.status.idle": "2026-08-12T09:19:40.599048Z", + "shell.execute_reply": "2026-08-12T09:19:40.598587Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Imported model: customers (3 columns, 0 measures)\n", + "Imported model: orders (4 columns, 2 measures)\n", + "Imported model: order_facts (5 columns, 2 measures)\n", + "Imported model: orders_overview (view) (3 columns, 2 measures)\n", + " INFO [filter_params_variable/order_facts]: FILTER_PARAMS member 'region' → optional variable(s) ['region'].\n", + " INFO [filter_params_variable/order_facts]: FILTER_PARAMS member 'category' → required variable(s) ['category'].\n", + "\n", + "Done: 4 of 4 models saved (0 hidden, 1 views), 2 report issues. Report: cube_import_report.json\n" + ] + } + ], + "source": [ + "proc = import_cube_cli(models_dir=MODELS_DIR, report_path=REPORT_PATH)\n", + "print(proc.stdout.replace(str(REPORT_PATH.resolve()), REPORT_PATH.name).rstrip())\n", + "\n", + "report = json.loads(REPORT_PATH.read_text())\n", + "errors = [i for i in report[\"issues\"] if i[\"severity\"] == \"error\"]\n", + "assert not errors, errors" + ] + }, + { + "cell_type": "markdown", + "id": "925f5b2d", + "metadata": {}, + "source": [ + "`import-cube` files the models under the datasource **name** but doesn't create the datasource itself. Querying needs it registered — and because the import wipes the model directory first, we register it now, *after* importing." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "b693faec", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:40.600265Z", + "iopub.status.busy": "2026-08-12T09:19:40.600184Z", + "iopub.status.idle": "2026-08-12T09:19:40.603866Z", + "shell.execute_reply": "2026-08-12T09:19:40.603367Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Registered datasource: shop_cube\n" + ] + } + ], + "source": [ + "save_datasource(MODELS_DIR, DB_PATH)\n", + "print(\"Registered datasource:\", DATASOURCE_NAME)" + ] + }, + { + "cell_type": "markdown", + "id": "6d0fed50", + "metadata": {}, + "source": [ + "## Step 5 — Inspect the generated models\n", + "\n", + "`order_facts` became an **sql-mode** model whose `FILTER_PARAMS` turned into `{var}` placeholders. The model skeleton's `Variables:` line classifies them — `category (required)` vs a bare optional `region` — and `meta.cube_variables` records the per-variable contract the engine reads (`list_valued`, `required`, Cube `kind`)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "84eb2990", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:40.604638Z", + "iopub.status.busy": "2026-08-12T09:19:40.604542Z", + "iopub.status.idle": "2026-08-12T09:19:40.610002Z", + "shell.execute_reply": "2026-08-12T09:19:40.609710Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Model skeleton for 'order_facts':\n", + "\n", + "Columns: status, region, category, amount\n", + "Measures: count, total_amount\n", + "Aggregations: _(none)_\n", + "Joins to: _(none)_\n", + "Variables: category (required), region\n", + "\n", + "meta.cube_variables:\n", + " region: required=False, list_valued=True, kind='string'\n", + " category: required=True, list_valued=True, kind='string'\n" + ] + } + ], + "source": [ + "storage = YAMLStorage(base_dir=str(MODELS_DIR))\n", + "order_facts = run_sync(storage.get_model(\"order_facts\", data_source=DATASOURCE_NAME))\n", + "\n", + "print(\"Model skeleton for 'order_facts':\\n\")\n", + "print(render_model_skeleton(model=order_facts))\n", + "\n", + "print(\"\\nmeta.cube_variables:\")\n", + "for name, spec in order_facts.meta[\"cube_variables\"].items():\n", + " print(f\" {name}: required={spec['required']}, list_valued={spec['list_valued']}, kind={spec['kind']!r}\")" + ] + }, + { + "cell_type": "markdown", + "id": "5d1663bc", + "metadata": {}, + "source": [ + "## Query the view and the join\n", + "\n", + "Point the engine at the imported models. `orders_overview` (the view) and the raw `orders` cube both reach `region` on `customers` — through the join the importer inferred from the Cube config. No SQL join is written by hand." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "335371e3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:40.610938Z", + "iopub.status.busy": "2026-08-12T09:19:40.610818Z", + "iopub.status.idle": "2026-08-12T09:19:40.692759Z", + "shell.execute_reply": "2026-08-12T09:19:40.692425Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
orders_overview.regionorders_overview.total_amount
0North750.0
1South650.0
\n", + "
" + ], + "text/plain": [ + " orders_overview.region orders_overview.total_amount\n", + "0 North 750.0\n", + "1 South 650.0" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "OK — view total by region matches gold: {'North': 750.0, 'South': 650.0}\n" + ] + } + ], + "source": [ + "engine = SlayerQueryEngine(storage=storage)\n", + "gold_by_region = {g[\"region\"]: g[\"amount\"] for g in GOLD[\"by_region\"]}\n", + "\n", + "view_rows = engine.execute_sync(query={\n", + " \"source_model\": \"orders_overview\",\n", + " \"measures\": [\"total_amount\"],\n", + " \"dimensions\": [\"region\"],\n", + " \"order\": [{\"column\": \"region\"}],\n", + "}).data\n", + "display(pd.DataFrame(view_rows))\n", + "\n", + "got = {r[\"orders_overview.region\"]: r[\"orders_overview.total_amount\"] for r in view_rows}\n", + "assert got == gold_by_region, f\"{got} != {gold_by_region}\"\n", + "print(\"OK — view total by region matches gold:\", gold_by_region)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "942cdf3e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:40.693564Z", + "iopub.status.busy": "2026-08-12T09:19:40.693476Z", + "iopub.status.idle": "2026-08-12T09:19:40.707470Z", + "shell.execute_reply": "2026-08-12T09:19:40.707134Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "OK — the raw `orders` cube reaches `customers.region` via the imported join: {'North': 750.0, 'South': 650.0}\n" + ] + } + ], + "source": [ + "join_rows = engine.execute_sync(query={\n", + " \"source_model\": \"orders\",\n", + " \"measures\": [\"total_amount\"],\n", + " \"dimensions\": [\"customers.region\"],\n", + " \"order\": [{\"column\": \"customers.region\"}],\n", + "}).data\n", + "\n", + "got = {r[\"orders.customers.region\"]: r[\"orders.total_amount\"] for r in join_rows}\n", + "assert got == gold_by_region\n", + "print(\"OK — the raw `orders` cube reaches `customers.region` via the imported join:\", got)" + ] + }, + { + "cell_type": "markdown", + "id": "d4c89ef0", + "metadata": {}, + "source": [ + "## The `FILTER_PARAMS` pushdowns\n", + "\n", + "`order_facts` takes two caller-supplied filters. `category` is **required** (a bare `p.category IN ({category})`); `region` is **optional** — wrapped in a block `{? c.region IN ({region}) ?}` that collapses to `(1=1)` when omitted. Both are set-membership (`IN`) filters, so the importer marked them `list_valued`: pass a list, or a bare scalar the engine wraps into a one-element list." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "83922cc8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:40.708290Z", + "iopub.status.busy": "2026-08-12T09:19:40.708198Z", + "iopub.status.idle": "2026-08-12T09:19:40.710201Z", + "shell.execute_reply": "2026-08-12T09:19:40.709973Z" + } + }, + "outputs": [], + "source": [ + "BOTH = [\"Beverages\", \"Bakery\"]\n", + "\n", + "\n", + "def count(**variables):\n", + " r = engine.execute_sync(query={\n", + " \"source_model\": \"order_facts\",\n", + " \"measures\": [{\"formula\": \"count\"}],\n", + " \"variables\": variables,\n", + " })\n", + " return r.data[0][\"order_facts.count\"], r.sql\n", + "\n", + "\n", + "def where_lines(sql, *needles):\n", + " return \"\\n\".join(ln.rstrip() for ln in sql.splitlines() if any(n in ln for n in needles))" + ] + }, + { + "cell_type": "markdown", + "id": "54a27037", + "metadata": {}, + "source": [ + "### Optional omitted → the block collapses to `(1=1)`\n", + "\n", + "Supply the required `category` (both values, so nothing is excluded) and omit `region`. The optional block disappears and every order counts." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "54aff11e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:40.711006Z", + "iopub.status.busy": "2026-08-12T09:19:40.710927Z", + "iopub.status.idle": "2026-08-12T09:19:40.725016Z", + "shell.execute_reply": "2026-08-12T09:19:40.724728Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "count (region omitted): 6 gold: 6\n", + "\n", + "The optional `region` block collapsed to (1 = 1):\n", + " 1 = 1 AND (\n", + " 1 = 1\n", + " ) AND p.category IN ('Beverages', 'Bakery')\n" + ] + } + ], + "source": [ + "n, sql = count(category=BOTH) # region omitted\n", + "print(\"count (region omitted):\", n, \" gold:\", GOLD[\"of_all\"])\n", + "assert n == GOLD[\"of_all\"]\n", + "print(\"\\nThe optional `region` block collapsed to (1 = 1):\")\n", + "print(where_lines(sql, \"1 = 1\", \"IN (\"))" + ] + }, + { + "cell_type": "markdown", + "id": "e5ae23a0", + "metadata": {}, + "source": [ + "### Optional supplied (a list) → a real `IN (...)`\n", + "\n", + "Now pass `region=[\"North\"]`. The block renders and filters." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "3fca8aee", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:40.725697Z", + "iopub.status.busy": "2026-08-12T09:19:40.725619Z", + "iopub.status.idle": "2026-08-12T09:19:40.741278Z", + "shell.execute_reply": "2026-08-12T09:19:40.740951Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "count region=['North']: 4 gold: 4\n", + "\n", + "Rendered filter:\n", + " c.region,\n", + " c.region IN ('North')\n", + " ) AND p.category IN ('Beverages', 'Bakery')\n" + ] + } + ], + "source": [ + "n_north, sql = count(category=BOTH, region=[\"North\"])\n", + "print(\"count region=['North']:\", n_north, \" gold:\", GOLD[\"of_north\"])\n", + "assert n_north == GOLD[\"of_north\"]\n", + "print(\"\\nRendered filter:\")\n", + "print(where_lines(sql, \"region\", \"IN (\"))" + ] + }, + { + "cell_type": "markdown", + "id": "32842287", + "metadata": {}, + "source": [ + "### A scalar for a list-valued variable → coerced to a one-element list\n", + "\n", + "The importer wrote the `IN (...)` parentheses, so a caller has nowhere to put per-element quotes. SLayer therefore wraps a bare scalar into a one-element list — `region=\"North\"` means exactly `region=[\"North\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "9a11984d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:40.741990Z", + "iopub.status.busy": "2026-08-12T09:19:40.741906Z", + "iopub.status.idle": "2026-08-12T09:19:40.791820Z", + "shell.execute_reply": "2026-08-12T09:19:40.790870Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "region='North' (scalar): 4 region=['North'] (list): 4\n", + "category='Beverages' (scalar): 3 gold: 3\n" + ] + } + ], + "source": [ + "n_scalar, _ = count(category=BOTH, region=\"North\") # bare string, not a list\n", + "print(\"region='North' (scalar):\", n_scalar, \" region=['North'] (list):\", n_north)\n", + "assert n_scalar == n_north\n", + "\n", + "n_bev, _ = count(category=\"Beverages\") # scalar for the required var; region omitted\n", + "print(\"category='Beverages' (scalar):\", n_bev, \" gold:\", GOLD[\"of_beverages\"])\n", + "assert n_bev == GOLD[\"of_beverages\"]" + ] + }, + { + "cell_type": "markdown", + "id": "23a1096e", + "metadata": {}, + "source": [ + "### The required pushdown is not optional\n", + "\n", + "Omit `category` (supplying only `region`) and the query raises, naming the missing variable — a required pushdown fails loudly rather than matching nothing." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "f56e91d3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-12T09:19:40.795168Z", + "iopub.status.busy": "2026-08-12T09:19:40.795058Z", + "iopub.status.idle": "2026-08-12T09:19:40.803168Z", + "shell.execute_reply": "2026-08-12T09:19:40.802659Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Omitting the required `category` raises:\n", + " Undefined variable 'category' in filter: '\\n SELECT o.order_id, o.amount, o.status, c.region, p.category\\n FROM orders o\\n LEFT JOIN customers c ON o.customer_id = c.customer_id\\n LEFT JOIN products p ON o.product_id = p.product_id\\n WHERE 1 = 1\\n AND {? c.region IN ({region}) ?}\\n AND p.category IN ({category})\\n '. Available variables: ['region']\n" + ] + } + ], + "source": [ + "try:\n", + " count(region=[\"North\"]) # category (required) omitted\n", + "except ValueError as e:\n", + " print(\"Omitting the required `category` raises:\")\n", + " print(\" \", e)" + ] + }, + { + "cell_type": "markdown", + "id": "50c5270b", + "metadata": {}, + "source": [ + "## Recap\n", + "\n", + "Starting from a Cube project we did not write, `slayer import-cube`:\n", + "\n", + "- turned two YAML cubes and a view into queryable SLayer models, with a join inferred from the Cube config,\n", + "- converted a JS cube's `FILTER_PARAMS` into SLayer `{var}` substitution — a **required** pushdown (`category`) and an **optional** one (`region`) that collapses to `(1=1)` when omitted,\n", + "- marked both set-membership pushdowns `list_valued`, so a bare scalar is coerced to a one-element list.\n", + "\n", + "Every answer matched gold SQL.\n", + "\n", + "### Further reading\n", + "\n", + "- [Importing Cube definitions](../../cube/cube_import.md) — the full conversion reference (what maps, what fails cleanly, the report).\n", + "- [Variable Substitution](../14_variable_substitution/variable_substitution_nb.ipynb) — the `{var}` / `{? ?}` mechanics, across all of SLayer's raw-SQL surfaces." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/examples/15_cube_import/cube_project/model/cubes/customers.yml b/docs/examples/15_cube_import/cube_project/model/cubes/customers.yml new file mode 100644 index 00000000..3382cbc2 --- /dev/null +++ b/docs/examples/15_cube_import/cube_project/model/cubes/customers.yml @@ -0,0 +1,16 @@ +# The join target for `orders`. Supplies the `region` dimension the view and +# the join queries group by. +cubes: + - name: customers + sql_table: customers + dimensions: + - name: customer_id + sql: "{CUBE}.customer_id" + type: number + primary_key: true + - name: name + sql: "{CUBE}.name" + type: string + - name: region + sql: "{CUBE}.region" + type: string diff --git a/docs/examples/15_cube_import/cube_project/model/cubes/order_facts.js b/docs/examples/15_cube_import/cube_project/model/cubes/order_facts.js new file mode 100644 index 00000000..362a9ba1 --- /dev/null +++ b/docs/examples/15_cube_import/cube_project/model/cubes/order_facts.js @@ -0,0 +1,58 @@ +// A JavaScript sql-mode Cube with FILTER_PARAMS pushdowns. The importer turns +// each `FILTER_PARAMS...filter('col')` into a SLayer `{var}` +// substitution over a `col IN ({var})` template: +// * `category` carries `meta.required`, so its pushdown is REQUIRED — a bare +// `p.category IN ({category})`; omitting it raises. +// * `region` has no `meta.required`, so its pushdown is OPTIONAL — wrapped in +// an optional block `{? c.region IN ({region}) ?}` that collapses to +// `(1=1)` when the caller omits it. +// Both are string-form, so the importer marks them `list_valued` and the engine +// coerces a bare scalar to a one-element list before substituting. +cube(`order_facts`, { + sql: ` + SELECT o.order_id, o.amount, o.status, c.region, p.category + FROM orders o + LEFT JOIN customers c ON o.customer_id = c.customer_id + LEFT JOIN products p ON o.product_id = p.product_id + WHERE 1 = 1 + AND ${FILTER_PARAMS.order_facts.region.filter('c.region')} + AND ${FILTER_PARAMS.order_facts.category.filter('p.category')} + `, + + dimensions: { + order_id: { + sql: `${CUBE}.order_id`, + type: `number`, + primaryKey: true, + public: false, + }, + status: { + sql: `${CUBE}.status`, + type: `string`, + }, + region: { + sql: `${CUBE}.region`, + type: `string`, + description: `Optional pushdown — filters when supplied, no-op when omitted.`, + }, + category: { + sql: `${CUBE}.category`, + type: `string`, + description: `Required pushdown — must be supplied or the query raises.`, + meta: { + required: true, + }, + }, + }, + + measures: { + count: { + type: `count`, + }, + total_amount: { + sql: `${CUBE}.amount`, + type: `sum`, + title: `Total Amount`, + }, + }, +}); diff --git a/docs/examples/15_cube_import/cube_project/model/cubes/orders.yml b/docs/examples/15_cube_import/cube_project/model/cubes/orders.yml new file mode 100644 index 00000000..d6cb1a4a --- /dev/null +++ b/docs/examples/15_cube_import/cube_project/model/cubes/orders.yml @@ -0,0 +1,29 @@ +# A table-anchored Cube. Becomes a SLayer model bound to the `orders` table, +# with a join to `customers` the importer turns into a SLayer join. +cubes: + - name: orders + sql_table: orders + description: One row per order line + joins: + - name: customers + relationship: many_to_one + sql: "{CUBE}.customer_id = {customers.customer_id}" + measures: + - name: count + type: count + - name: total_amount + type: sum + sql: "{CUBE}.amount" + title: Total Amount + format: currency + dimensions: + - name: order_id + sql: "{CUBE}.order_id" + type: number + primary_key: true + - name: status + sql: "{CUBE}.status" + type: string + - name: ordered_at + sql: "{CUBE}.ordered_at" + type: time diff --git a/docs/examples/15_cube_import/cube_project/model/views/orders_overview.yml b/docs/examples/15_cube_import/cube_project/model/views/orders_overview.yml new file mode 100644 index 00000000..fad75b81 --- /dev/null +++ b/docs/examples/15_cube_import/cube_project/model/views/orders_overview.yml @@ -0,0 +1,15 @@ +# A Cube view owns no table — it becomes a thin SLayer facade model over the +# `orders` join path. Included dimensions become derived columns; included +# measures become model measures. No `prefix`, no `default_filters`, so the +# included `region` stays named `region` and no WHERE is always applied. +views: + - name: orders_overview + cubes: + - join_path: orders + includes: + - count + - total_amount + - status + - join_path: orders.customers + includes: + - region diff --git a/docs/examples/15_cube_import/setup_cube.py b/docs/examples/15_cube_import/setup_cube.py new file mode 100644 index 00000000..a1b24ca2 --- /dev/null +++ b/docs/examples/15_cube_import/setup_cube.py @@ -0,0 +1,227 @@ +"""Setup helper for the Cube -> SLayer demo notebook. + +Self-contained and **fully offline**: builds a tiny jaffle-shop-flavored DuckDB +with deterministic rows, and imports the committed ``cube_project`` (YAML cubes + +a view + a JS ``FILTER_PARAMS`` cube) into SLayer models. Cube import needs no +database connection — types come from the Cube declarations — so the DuckDB is +only used to *query* the imported models. + +Two import paths are exposed: :func:`import_cube_cli` runs the real +``slayer import-cube`` command (what the notebook shows), and +:func:`import_cube_lib` runs the same conversion in-process (used by the test). +Both wipe the model directory first, so the datasource must be saved *after* +importing. + +Gold-answer helper note: SLayer opens the DuckDB file through a read-write engine +that a second raw connection cannot share, so :func:`compute_gold` must run +*before* any SLayer query touches the file — the notebook precomputes all gold +answers up front for exactly this reason. +""" + +import logging +import shutil +import subprocess +import sys +from pathlib import Path +from typing import List, Union + +import duckdb + +from slayer.async_utils import run_sync +from slayer.core.models import DatasourceConfig +from slayer.cube.converter import CubeToSlayerConverter +from slayer.cube.parser import parse_cube_project +from slayer.cube.report import CubeConversionResult +from slayer.storage.yaml_storage import YAMLStorage + +logger = logging.getLogger(__name__) + +DATASOURCE_NAME = "shop_cube" + +_THIS_DIR = Path(__file__).resolve().parent +CUBE_PROJECT = _THIS_DIR / "cube_project" +CACHE_DIR = _THIS_DIR / ".cache" +DB_PATH = CACHE_DIR / "shop.duckdb" +MODELS_DIR = CACHE_DIR / "slayer_models" +REPORT_PATH = CACHE_DIR / "cube_import_report.json" + +_PathLike = Union[str, Path] + +# DuckDB DDL for the three tables the cubes bind to. +_SCHEMA = [ + "CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name VARCHAR, region VARCHAR)", + "CREATE TABLE products (product_id INTEGER PRIMARY KEY, name VARCHAR, category VARCHAR)", + "CREATE TABLE orders (order_id INTEGER PRIMARY KEY, customer_id INTEGER, " + "product_id INTEGER, amount DOUBLE, quantity INTEGER, ordered_at DATE, status VARCHAR)", +] + +# Deterministic rows so every gold number below is exact. +_CUSTOMERS = [ + (1, "Alice", "North"), + (2, "Bob", "North"), + (3, "Carol", "South"), +] +_PRODUCTS = [ + (1, "Latte", "Beverages"), + (2, "Bagel", "Bakery"), +] +_ORDERS = [ + # order_id, customer_id, product_id, amount, quantity, ordered_at, status + (1, 1, 1, 100.0, 2, "2024-01-01", "completed"), + (2, 1, 2, 200.0, 1, "2024-01-05", "completed"), + (3, 2, 1, 300.0, 3, "2024-02-01", "completed"), + (4, 2, 2, 150.0, 1, "2024-02-10", "pending"), + (5, 3, 1, 250.0, 5, "2024-03-01", "completed"), + (6, 3, 2, 400.0, 2, "2024-03-15", "completed"), +] + + +def build_shop_duckdb(db_path: _PathLike = DB_PATH) -> Path: + """Create the retail DuckDB (three tables + deterministic rows). + + Overwrites any existing file so a re-run always starts from a clean, known + dataset. Returns the database path. + """ + db_path = Path(db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + if db_path.exists(): + db_path.unlink() + + conn = duckdb.connect(str(db_path)) + try: + for ddl in _SCHEMA: + conn.execute(ddl) + conn.executemany("INSERT INTO customers VALUES (?, ?, ?)", _CUSTOMERS) + conn.executemany("INSERT INTO products VALUES (?, ?, ?)", _PRODUCTS) + conn.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?, ?)", _ORDERS) + finally: + conn.close() + logger.info("Built retail DuckDB at %s", db_path) + return db_path + + +def import_cube_lib( + cube_project: _PathLike = CUBE_PROJECT, + models_dir: _PathLike = MODELS_DIR, +) -> CubeConversionResult: + """Import the cube_project in-process and persist the models. + + Wipes ``models_dir`` first (so the datasource must be saved afterwards), + parses the project, runs :class:`CubeToSlayerConverter`, and saves each + model. Returns the full :class:`CubeConversionResult` (models + report). + """ + models_dir = Path(models_dir) + if models_dir.exists(): + shutil.rmtree(models_dir) + + project, parse_issues = parse_cube_project(str(Path(cube_project).resolve())) + result = CubeToSlayerConverter( + project=project, data_source=DATASOURCE_NAME, parse_issues=parse_issues + ).convert() + + storage = YAMLStorage(base_dir=str(models_dir)) + for model in result.models: + run_sync(storage.save_model(model)) + return result + + +def import_cube_cli( + cube_project: _PathLike = CUBE_PROJECT, + models_dir: _PathLike = MODELS_DIR, + report_path: _PathLike = REPORT_PATH, + check: bool = True, +) -> subprocess.CompletedProcess: + """Run the real ``slayer import-cube`` CLI as a subprocess. + + Uses the current interpreter (``python -m slayer.cli``) and absolute paths so + it behaves identically from a notebook or a test. Wipes ``models_dir`` first. + With ``check`` (the default) a non-zero exit raises with both streams in the + message; the test passes ``check=True`` and inspects the returned process. + """ + models_dir = Path(models_dir) + report_path = Path(report_path) + if models_dir.exists(): + shutil.rmtree(models_dir) + models_dir.mkdir(parents=True, exist_ok=True) + report_path.parent.mkdir(parents=True, exist_ok=True) + + cmd = [ + sys.executable, "-m", "slayer.cli", "import-cube", + str(Path(cube_project).resolve()), + "--datasource", DATASOURCE_NAME, + "--storage", str(models_dir.resolve()), + "--report", str(report_path.resolve()), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, cwd=str(_THIS_DIR)) + if check and proc.returncode != 0: + raise RuntimeError( + f"`slayer import-cube` failed (exit {proc.returncode}).\n" + f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}" + ) + return proc + + +def save_datasource( + models_dir: _PathLike = MODELS_DIR, db_path: _PathLike = DB_PATH +) -> None: + """Register the DuckDB as a SLayer datasource in the model store. + + Cube import files models under the datasource *name* but never creates the + datasource itself, so querying the imported models needs this. Call it AFTER + importing (the importers wipe ``models_dir``). + """ + storage = YAMLStorage(base_dir=str(Path(models_dir))) + ds = DatasourceConfig( + name=DATASOURCE_NAME, type="duckdb", database=str(Path(db_path).resolve()) + ) + run_sync(storage.save_datasource(ds)) + + +def fetch_gold(db_path: _PathLike, sql: str) -> List[dict]: + """Run a raw gold SQL query against the DuckDB file and return rows as dicts. + + MUST be called before any SLayer query opens ``db_path``: SLayer holds a + read-write engine on the file that a second raw connection cannot share. + """ + conn = duckdb.connect(str(db_path), read_only=True) + try: + cur = conn.execute(sql) + columns = [c[0] for c in cur.description] + return [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + + +_GOLD_BY_REGION_SQL = """ + SELECT c.region AS region, SUM(o.amount) AS amount + FROM orders o JOIN customers c ON o.customer_id = c.customer_id + GROUP BY c.region + ORDER BY c.region +""" + + +def compute_gold(db_path: _PathLike = DB_PATH) -> dict: + """Compute all reference answers up front (before SLayer opens ``db_path``). + + Keys: ``total`` (all order amount), ``by_region`` (list of ``{region, + amount}``), and the ``order_facts`` counts ``of_all`` / ``of_north`` / + ``of_south`` / ``of_beverages``. + """ + one = lambda sql: fetch_gold(db_path, sql)[0]["v"] # noqa: E731 + return { + "total": one("SELECT SUM(amount) AS v FROM orders"), + "by_region": fetch_gold(db_path, _GOLD_BY_REGION_SQL), + "of_all": one("SELECT COUNT(*) AS v FROM orders"), + "of_north": one( + "SELECT COUNT(*) AS v FROM orders o " + "JOIN customers c ON o.customer_id = c.customer_id WHERE c.region = 'North'" + ), + "of_south": one( + "SELECT COUNT(*) AS v FROM orders o " + "JOIN customers c ON o.customer_id = c.customer_id WHERE c.region = 'South'" + ), + "of_beverages": one( + "SELECT COUNT(*) AS v FROM orders o " + "JOIN products p ON o.product_id = p.product_id WHERE p.category = 'Beverages'" + ), + } diff --git a/tests/test_cube_import_example.py b/tests/test_cube_import_example.py new file mode 100644 index 00000000..0912b8ec --- /dev/null +++ b/tests/test_cube_import_example.py @@ -0,0 +1,284 @@ +"""Non-integration coverage for the ``docs/examples/15_cube_import`` walkthrough. + +The example notebook itself only runs under ``pytest -m integration`` (the +``tests/integration/test_notebooks.py`` glob harness). This module guards the +sample Cube configs and the ``setup_cube.py`` helper in the DEFAULT unit suite: +it seeds the same deterministic DuckDB, imports the committed ``cube_project`` +(both the library path and the real ``slayer import-cube`` CLI), and asserts the +variable contract, optional-block collapse, scalar→one-element-list coercion, +required-omitted and empty-list raises, and the view/join numbers — every answer +checked against gold SQL computed from the same rows. +""" +import json +import os +import sys + +import pytest + +duckdb = pytest.importorskip("duckdb") + +from slayer.async_utils import run_sync # noqa: E402 +from slayer.core.models import DatasourceConfig # noqa: E402 +from slayer.core.query import ( # noqa: E402 + SlayerQuery, + extract_model_variables, + extract_variable_refs, + list_valued_variable_names, +) +from slayer.engine.query_engine import SlayerQueryEngine # noqa: E402 +from slayer.inspect.model_render import render_model_skeleton # noqa: E402 +from slayer.storage.yaml_storage import YAMLStorage # noqa: E402 + +_EXAMPLE_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "docs", "examples", "15_cube_import") +) +sys.path.insert(0, _EXAMPLE_DIR) + +import setup_cube # noqa: E402 + +DS = setup_cube.DATASOURCE_NAME +EXPECTED_MODELS = {"orders", "customers", "order_facts", "orders_overview"} +_BOTH = ["Beverages", "Bakery"] + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # +async def _prepare(tmp_path): + """Seed DuckDB, import the cube_project (library path), register the + datasource, and return ``(engine, storage, gold, db_path)``. + + Order matches the notebook: build → gold (read-only) → import (wipes the + model store) → save datasource → engine. The datasource is saved AFTER the + import because the importer wipes the model directory first. + """ + db_path = tmp_path / "shop.duckdb" + store = tmp_path / "slayer_models" + setup_cube.build_shop_duckdb(db_path) + gold = setup_cube.compute_gold(db_path) # read-only, before SLayer opens RW + + result = setup_cube.import_cube_lib(models_dir=store) + assert not result.report.has_errors, [ + i.message for i in result.report.issues if i.severity == "error" + ] + + storage = YAMLStorage(base_dir=str(store)) + await storage.save_datasource( + DatasourceConfig(name=DS, type="duckdb", database=str(db_path.resolve())) + ) + return SlayerQueryEngine(storage=storage), storage, gold, db_path + + +def _sole(resp): + assert resp.row_count == 1, f"expected 1 row: {resp.data}" + assert len(resp.data[0]) == 1, f"expected 1 column: {resp.data[0]}" + return next(iter(resp.data[0].values())) + + +def _unique(row, suffix): + keys = [k for k in row if k == suffix or k.endswith("." + suffix)] + assert len(keys) == 1, f"expected exactly one {suffix!r} key in {list(row)}" + return row[keys[0]] + + +def _by_group(resp, *, group="region", value="total_amount"): + return {_unique(r, group): _unique(r, value) for r in resp.data} + + +async def _count(engine, **variables): + """Count order_facts rows with the given FILTER_PARAMS variables.""" + q = SlayerQuery( + source_model="order_facts", + measures=[{"formula": "count"}], + variables=variables, + ) + return _sole(await engine.execute(q)) + + +# --------------------------------------------------------------------------- # +# fixture / gold are independently correct (guards a coordinated seed+gold bug) +# --------------------------------------------------------------------------- # +def test_gold_values_are_exact(tmp_path): + db_path = tmp_path / "shop.duckdb" + setup_cube.build_shop_duckdb(db_path) + gold = setup_cube.compute_gold(db_path) + assert gold["total"] == 1400 + assert {g["region"]: g["amount"] for g in gold["by_region"]} == { + "North": 750, + "South": 650, + } + assert (gold["of_all"], gold["of_north"], gold["of_south"], gold["of_beverages"]) == ( + 6, + 4, + 2, + 3, + ) + + +# --------------------------------------------------------------------------- # +# import boundary: both the library result and the persisted CLI report +# --------------------------------------------------------------------------- # +def test_library_import_persists_all_models(tmp_path): + store = tmp_path / "slayer_models" + result = setup_cube.import_cube_lib(models_dir=store) + assert not result.report.has_errors + assert {m.name for m in result.models} == EXPECTED_MODELS + fp_members = { + i.member + for i in result.report.issues + if i.category.value == "filter_params_variable" + } + assert {"category", "region"} <= fp_members + + +def test_cli_import_writes_report_and_models(tmp_path): + store = tmp_path / "slayer_models" + report = tmp_path / "cube_import_report.json" + proc = setup_cube.import_cube_cli(models_dir=store, report_path=report) + assert proc.returncode == 0, proc.stderr + + data = json.loads(report.read_text()) + errors = [i for i in data["issues"] if i["severity"] == "error"] + assert not errors, errors + + storage = YAMLStorage(base_dir=str(store)) + for name in EXPECTED_MODELS: + model = run_sync(storage.get_model(name, data_source=DS)) + assert model is not None and model.name == name + + fp_members = { + i["member"] for i in data["issues"] if i["category"] == "filter_params_variable" + } + assert {"category", "region"} <= fp_members + + +# --------------------------------------------------------------------------- # +# variable contract (skeleton + meta.cube_variables + structural SQL) +# --------------------------------------------------------------------------- # +async def test_variable_contract_required_optional(tmp_path): + _, storage, _, _ = await _prepare(tmp_path) + model = await storage.get_model("order_facts", data_source=DS) + mv = extract_model_variables(model) + assert mv.required == ["category"] + assert mv.optional == ["region"] + + +async def test_both_pushdowns_are_list_valued(tmp_path): + _, storage, _, _ = await _prepare(tmp_path) + model = await storage.get_model("order_facts", data_source=DS) + assert list_valued_variable_names(model) == {"category", "region"} + cube_vars = model.meta["cube_variables"] + for name in ("category", "region"): + spec = cube_vars[name] + assert spec["member"] == name + assert spec["kind"] == "string" + assert spec["list_valued"] is True + assert cube_vars["category"]["required"] is True + assert cube_vars["region"]["required"] is False + + +async def test_skeleton_variables_line_exact(tmp_path): + _, storage, _, _ = await _prepare(tmp_path) + model = await storage.get_model("order_facts", data_source=DS) + lines = render_model_skeleton(model=model).splitlines() + var_line = next(ln for ln in lines if ln.startswith("Variables:")) + assert var_line == "Variables: category (required), region" + + +async def test_required_bare_optional_blocked_in_sql(tmp_path): + """Structural proof: the required var is bare (outside any ``{? ?}`` block) + and the optional var lives inside one; all three tables are joined.""" + _, storage, _, _ = await _prepare(tmp_path) + model = await storage.get_model("order_facts", data_source=DS) + bare, blocked = extract_variable_refs(model.sql) + assert "category" in bare and "category" not in blocked + assert "region" in blocked and "region" not in bare + low = model.sql.lower() + assert "orders" in low and "customers" in low and "products" in low + + +# --------------------------------------------------------------------------- # +# YAML structural contract (join + clean view) +# --------------------------------------------------------------------------- # +async def test_orders_joins_customers(tmp_path): + _, storage, _, _ = await _prepare(tmp_path) + orders = await storage.get_model("orders", data_source=DS) + assert "customers" in {j.target_model for j in orders.joins} + + +async def test_view_is_clean_facade(tmp_path): + _, storage, _, _ = await _prepare(tmp_path) + view = await storage.get_model("orders_overview", data_source=DS) + assert "region" in {c.name for c in view.columns} + assert not view.filters # no default_filters -> no always-applied WHERE + + +# --------------------------------------------------------------------------- # +# FILTER_PARAMS behavior (counts vs gold) +# --------------------------------------------------------------------------- # +async def test_optional_omitted_is_unfiltered(tmp_path): + engine, _, gold, _ = await _prepare(tmp_path) + # required category supplied (both), optional region omitted -> block collapses + assert await _count(engine, category=_BOTH) == gold["of_all"] + + +async def test_optional_list_filters(tmp_path): + engine, _, gold, _ = await _prepare(tmp_path) + assert await _count(engine, category=_BOTH, region=["North"]) == gold["of_north"] + assert await _count(engine, category=_BOTH, region=["South"]) == gold["of_south"] + + +async def test_scalar_coerced_to_one_element_list(tmp_path): + engine, _, gold, _ = await _prepare(tmp_path) + scalar = await _count(engine, category=_BOTH, region="North") + listed = await _count(engine, category=_BOTH, region=["North"]) + assert scalar == listed == gold["of_north"] + # a scalar for the *required* list-valued var coerces too + assert await _count(engine, category="Beverages") == gold["of_beverages"] + + +async def test_required_omitted_raises_naming_category(tmp_path): + engine, _, _, _ = await _prepare(tmp_path) + q = SlayerQuery( + source_model="order_facts", + measures=[{"formula": "count"}], + variables={"region": ["North"]}, # category (required) omitted + ) + with pytest.raises(ValueError, match=r"Undefined variable 'category'"): + await engine.execute(q) + + +async def test_empty_list_raises_not_treated_as_omission(tmp_path): + engine, _, _, _ = await _prepare(tmp_path) + q = SlayerQuery( + source_model="order_facts", + measures=[{"formula": "count"}], + variables={"category": _BOTH, "region": []}, + ) + with pytest.raises(ValueError, match=r"cannot be an empty list"): + await engine.execute(q) + + +# --------------------------------------------------------------------------- # +# view + join (totals vs gold) +# --------------------------------------------------------------------------- # +async def test_view_total_by_region(tmp_path): + engine, _, gold, _ = await _prepare(tmp_path) + q = SlayerQuery( + source_model="orders_overview", + measures=["total_amount"], + dimensions=["region"], + ) + got = _by_group(await engine.execute(q)) + assert got == {g["region"]: g["amount"] for g in gold["by_region"]} + + +async def test_join_total_by_customer_region(tmp_path): + engine, _, gold, _ = await _prepare(tmp_path) + q = SlayerQuery( + source_model="orders", + measures=["total_amount"], + dimensions=["customers.region"], + ) + got = _by_group(await engine.execute(q)) + assert got == {g["region"]: g["amount"] for g in gold["by_region"]} diff --git a/zensical.toml b/zensical.toml index 256dccbd..d6a71b75 100644 --- a/zensical.toml +++ b/zensical.toml @@ -111,6 +111,10 @@ nav = [ "examples/14_variable_substitution/variable_substitution.md", { "Notebook" = "examples/14_variable_substitution/variable_substitution_nb.ipynb" }, ]}, + { "Cube Import" = [ + "examples/15_cube_import/cube_import.md", + { "Notebook" = "examples/15_cube_import/cube_import_nb.ipynb" }, + ]}, { "Schema Drift (worked example)" = "examples/schema-drift.md" }, ]}, { "Configuration" = [ From 29c7048737547a833df5171408e479eb1c070414 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Wed, 12 Aug 2026 11:51:59 +0200 Subject: [PATCH 2/2] docs(DEV-1782): address SonarQube nitpicks Split composite `assert X and Y` statements in the example test into separate assertions (python:S9073), and factor the notebook's repeated " gold:" print label into a small `show()` helper (ipython:S1192). --- .../15_cube_import/cube_import_nb.ipynb | 180 +++++++++--------- tests/test_cube_import_example.py | 13 +- 2 files changed, 100 insertions(+), 93 deletions(-) diff --git a/docs/examples/15_cube_import/cube_import_nb.ipynb b/docs/examples/15_cube_import/cube_import_nb.ipynb index 13d970de..1816f908 100644 --- a/docs/examples/15_cube_import/cube_import_nb.ipynb +++ b/docs/examples/15_cube_import/cube_import_nb.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "05809536", + "id": "ab450b79", "metadata": {}, "source": [ "# From Cube to SLayer\n", @@ -23,7 +23,7 @@ }, { "cell_type": "markdown", - "id": "2bce2338", + "id": "2971248a", "metadata": {}, "source": [ "## Step 1 — Build the demo database\n", @@ -34,13 +34,13 @@ { "cell_type": "code", "execution_count": 1, - "id": "7c3a7530", + "id": "8559cda1", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:39.119444Z", - "iopub.status.busy": "2026-08-12T09:19:39.119323Z", - "iopub.status.idle": "2026-08-12T09:19:39.652502Z", - "shell.execute_reply": "2026-08-12T09:19:39.652046Z" + "iopub.execute_input": "2026-08-12T09:49:46.378332Z", + "iopub.status.busy": "2026-08-12T09:49:46.378210Z", + "iopub.status.idle": "2026-08-12T09:49:46.946350Z", + "shell.execute_reply": "2026-08-12T09:49:46.945887Z" } }, "outputs": [ @@ -84,7 +84,7 @@ }, { "cell_type": "markdown", - "id": "fa2a2f2f", + "id": "903626b6", "metadata": {}, "source": [ "## Step 2 — The Cube configs\n", @@ -95,13 +95,13 @@ { "cell_type": "code", "execution_count": 2, - "id": "6338d895", + "id": "b5755efb", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:39.653431Z", - "iopub.status.busy": "2026-08-12T09:19:39.653213Z", - "iopub.status.idle": "2026-08-12T09:19:39.655851Z", - "shell.execute_reply": "2026-08-12T09:19:39.655395Z" + "iopub.execute_input": "2026-08-12T09:49:46.947598Z", + "iopub.status.busy": "2026-08-12T09:49:46.947344Z", + "iopub.status.idle": "2026-08-12T09:49:46.951779Z", + "shell.execute_reply": "2026-08-12T09:49:46.950452Z" } }, "outputs": [ @@ -252,7 +252,7 @@ }, { "cell_type": "markdown", - "id": "580a43f3", + "id": "f5c35235", "metadata": {}, "source": [ "## Step 3 — Reference answers (gold SQL)\n", @@ -263,13 +263,13 @@ { "cell_type": "code", "execution_count": 3, - "id": "01376371", + "id": "f34875a7", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:39.656676Z", - "iopub.status.busy": "2026-08-12T09:19:39.656583Z", - "iopub.status.idle": "2026-08-12T09:19:39.717702Z", - "shell.execute_reply": "2026-08-12T09:19:39.717339Z" + "iopub.execute_input": "2026-08-12T09:49:46.952707Z", + "iopub.status.busy": "2026-08-12T09:49:46.952559Z", + "iopub.status.idle": "2026-08-12T09:49:47.042471Z", + "shell.execute_reply": "2026-08-12T09:49:47.041783Z" } }, "outputs": [ @@ -348,7 +348,7 @@ }, { "cell_type": "markdown", - "id": "69a7913e", + "id": "3faa2250", "metadata": {}, "source": [ "## Step 4 — Import with `slayer import-cube`\n", @@ -365,13 +365,13 @@ { "cell_type": "code", "execution_count": 4, - "id": "5609cc2f", + "id": "412a3466", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:39.718468Z", - "iopub.status.busy": "2026-08-12T09:19:39.718385Z", - "iopub.status.idle": "2026-08-12T09:19:40.599048Z", - "shell.execute_reply": "2026-08-12T09:19:40.598587Z" + "iopub.execute_input": "2026-08-12T09:49:47.043702Z", + "iopub.status.busy": "2026-08-12T09:49:47.043565Z", + "iopub.status.idle": "2026-08-12T09:49:48.006028Z", + "shell.execute_reply": "2026-08-12T09:49:48.005533Z" } }, "outputs": [ @@ -401,7 +401,7 @@ }, { "cell_type": "markdown", - "id": "925f5b2d", + "id": "a1fd6b26", "metadata": {}, "source": [ "`import-cube` files the models under the datasource **name** but doesn't create the datasource itself. Querying needs it registered — and because the import wipes the model directory first, we register it now, *after* importing." @@ -410,13 +410,13 @@ { "cell_type": "code", "execution_count": 5, - "id": "b693faec", + "id": "0c3de681", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:40.600265Z", - "iopub.status.busy": "2026-08-12T09:19:40.600184Z", - "iopub.status.idle": "2026-08-12T09:19:40.603866Z", - "shell.execute_reply": "2026-08-12T09:19:40.603367Z" + "iopub.execute_input": "2026-08-12T09:49:48.007068Z", + "iopub.status.busy": "2026-08-12T09:49:48.006973Z", + "iopub.status.idle": "2026-08-12T09:49:48.010136Z", + "shell.execute_reply": "2026-08-12T09:49:48.009853Z" } }, "outputs": [ @@ -435,7 +435,7 @@ }, { "cell_type": "markdown", - "id": "6d0fed50", + "id": "602b20e4", "metadata": {}, "source": [ "## Step 5 — Inspect the generated models\n", @@ -446,13 +446,13 @@ { "cell_type": "code", "execution_count": 6, - "id": "84eb2990", + "id": "f565e7bd", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:40.604638Z", - "iopub.status.busy": "2026-08-12T09:19:40.604542Z", - "iopub.status.idle": "2026-08-12T09:19:40.610002Z", - "shell.execute_reply": "2026-08-12T09:19:40.609710Z" + "iopub.execute_input": "2026-08-12T09:49:48.011106Z", + "iopub.status.busy": "2026-08-12T09:49:48.011014Z", + "iopub.status.idle": "2026-08-12T09:49:48.019172Z", + "shell.execute_reply": "2026-08-12T09:49:48.018663Z" } }, "outputs": [ @@ -488,7 +488,7 @@ }, { "cell_type": "markdown", - "id": "5d1663bc", + "id": "ad24be8c", "metadata": {}, "source": [ "## Query the view and the join\n", @@ -499,13 +499,13 @@ { "cell_type": "code", "execution_count": 7, - "id": "335371e3", + "id": "25f89e42", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:40.610938Z", - "iopub.status.busy": "2026-08-12T09:19:40.610818Z", - "iopub.status.idle": "2026-08-12T09:19:40.692759Z", - "shell.execute_reply": "2026-08-12T09:19:40.692425Z" + "iopub.execute_input": "2026-08-12T09:49:48.020206Z", + "iopub.status.busy": "2026-08-12T09:49:48.020061Z", + "iopub.status.idle": "2026-08-12T09:49:48.104945Z", + "shell.execute_reply": "2026-08-12T09:49:48.104446Z" } }, "outputs": [ @@ -586,13 +586,13 @@ { "cell_type": "code", "execution_count": 8, - "id": "942cdf3e", + "id": "01f447b1", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:40.693564Z", - "iopub.status.busy": "2026-08-12T09:19:40.693476Z", - "iopub.status.idle": "2026-08-12T09:19:40.707470Z", - "shell.execute_reply": "2026-08-12T09:19:40.707134Z" + "iopub.execute_input": "2026-08-12T09:49:48.105972Z", + "iopub.status.busy": "2026-08-12T09:49:48.105841Z", + "iopub.status.idle": "2026-08-12T09:49:48.126763Z", + "shell.execute_reply": "2026-08-12T09:49:48.126298Z" } }, "outputs": [ @@ -619,7 +619,7 @@ }, { "cell_type": "markdown", - "id": "d4c89ef0", + "id": "fe37a075", "metadata": {}, "source": [ "## The `FILTER_PARAMS` pushdowns\n", @@ -630,13 +630,13 @@ { "cell_type": "code", "execution_count": 9, - "id": "83922cc8", + "id": "5c437651", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:40.708290Z", - "iopub.status.busy": "2026-08-12T09:19:40.708198Z", - "iopub.status.idle": "2026-08-12T09:19:40.710201Z", - "shell.execute_reply": "2026-08-12T09:19:40.709973Z" + "iopub.execute_input": "2026-08-12T09:49:48.127850Z", + "iopub.status.busy": "2026-08-12T09:49:48.127710Z", + "iopub.status.idle": "2026-08-12T09:49:48.130939Z", + "shell.execute_reply": "2026-08-12T09:49:48.130494Z" } }, "outputs": [], @@ -654,12 +654,17 @@ "\n", "\n", "def where_lines(sql, *needles):\n", - " return \"\\n\".join(ln.rstrip() for ln in sql.splitlines() if any(n in ln for n in needles))" + " return \"\\n\".join(ln.rstrip() for ln in sql.splitlines() if any(n in ln for n in needles))\n", + "\n", + "\n", + "def show(label, got, gold):\n", + " print(f\"{label}: {got} (gold {gold})\")\n", + " assert got == gold, f\"{label}: {got} != {gold}\"" ] }, { "cell_type": "markdown", - "id": "54a27037", + "id": "39b9cceb", "metadata": {}, "source": [ "### Optional omitted → the block collapses to `(1=1)`\n", @@ -670,13 +675,13 @@ { "cell_type": "code", "execution_count": 10, - "id": "54aff11e", + "id": "fb50bda9", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:40.711006Z", - "iopub.status.busy": "2026-08-12T09:19:40.710927Z", - "iopub.status.idle": "2026-08-12T09:19:40.725016Z", - "shell.execute_reply": "2026-08-12T09:19:40.724728Z" + "iopub.execute_input": "2026-08-12T09:49:48.131919Z", + "iopub.status.busy": "2026-08-12T09:49:48.131786Z", + "iopub.status.idle": "2026-08-12T09:49:48.149315Z", + "shell.execute_reply": "2026-08-12T09:49:48.148851Z" } }, "outputs": [ @@ -684,7 +689,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "count (region omitted): 6 gold: 6\n", + "count (region omitted): 6 (gold 6)\n", "\n", "The optional `region` block collapsed to (1 = 1):\n", " 1 = 1 AND (\n", @@ -695,15 +700,14 @@ ], "source": [ "n, sql = count(category=BOTH) # region omitted\n", - "print(\"count (region omitted):\", n, \" gold:\", GOLD[\"of_all\"])\n", - "assert n == GOLD[\"of_all\"]\n", + "show(\"count (region omitted)\", n, GOLD[\"of_all\"])\n", "print(\"\\nThe optional `region` block collapsed to (1 = 1):\")\n", "print(where_lines(sql, \"1 = 1\", \"IN (\"))" ] }, { "cell_type": "markdown", - "id": "e5ae23a0", + "id": "d0880014", "metadata": {}, "source": [ "### Optional supplied (a list) → a real `IN (...)`\n", @@ -714,13 +718,13 @@ { "cell_type": "code", "execution_count": 11, - "id": "3fca8aee", + "id": "9420f05f", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:40.725697Z", - "iopub.status.busy": "2026-08-12T09:19:40.725619Z", - "iopub.status.idle": "2026-08-12T09:19:40.741278Z", - "shell.execute_reply": "2026-08-12T09:19:40.740951Z" + "iopub.execute_input": "2026-08-12T09:49:48.150637Z", + "iopub.status.busy": "2026-08-12T09:49:48.150480Z", + "iopub.status.idle": "2026-08-12T09:49:48.168775Z", + "shell.execute_reply": "2026-08-12T09:49:48.168383Z" } }, "outputs": [ @@ -728,7 +732,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "count region=['North']: 4 gold: 4\n", + "count region=['North']: 4 (gold 4)\n", "\n", "Rendered filter:\n", " c.region,\n", @@ -739,15 +743,14 @@ ], "source": [ "n_north, sql = count(category=BOTH, region=[\"North\"])\n", - "print(\"count region=['North']:\", n_north, \" gold:\", GOLD[\"of_north\"])\n", - "assert n_north == GOLD[\"of_north\"]\n", + "show(\"count region=['North']\", n_north, GOLD[\"of_north\"])\n", "print(\"\\nRendered filter:\")\n", "print(where_lines(sql, \"region\", \"IN (\"))" ] }, { "cell_type": "markdown", - "id": "32842287", + "id": "361f736b", "metadata": {}, "source": [ "### A scalar for a list-valued variable → coerced to a one-element list\n", @@ -758,13 +761,13 @@ { "cell_type": "code", "execution_count": 12, - "id": "9a11984d", + "id": "ad2b02df", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:40.741990Z", - "iopub.status.busy": "2026-08-12T09:19:40.741906Z", - "iopub.status.idle": "2026-08-12T09:19:40.791820Z", - "shell.execute_reply": "2026-08-12T09:19:40.790870Z" + "iopub.execute_input": "2026-08-12T09:49:48.169817Z", + "iopub.status.busy": "2026-08-12T09:49:48.169688Z", + "iopub.status.idle": "2026-08-12T09:49:48.199863Z", + "shell.execute_reply": "2026-08-12T09:49:48.199551Z" } }, "outputs": [ @@ -773,7 +776,7 @@ "output_type": "stream", "text": [ "region='North' (scalar): 4 region=['North'] (list): 4\n", - "category='Beverages' (scalar): 3 gold: 3\n" + "category='Beverages' (scalar): 3 (gold 3)\n" ] } ], @@ -783,13 +786,12 @@ "assert n_scalar == n_north\n", "\n", "n_bev, _ = count(category=\"Beverages\") # scalar for the required var; region omitted\n", - "print(\"category='Beverages' (scalar):\", n_bev, \" gold:\", GOLD[\"of_beverages\"])\n", - "assert n_bev == GOLD[\"of_beverages\"]" + "show(\"category='Beverages' (scalar)\", n_bev, GOLD[\"of_beverages\"])" ] }, { "cell_type": "markdown", - "id": "23a1096e", + "id": "31b6d8bb", "metadata": {}, "source": [ "### The required pushdown is not optional\n", @@ -800,13 +802,13 @@ { "cell_type": "code", "execution_count": 13, - "id": "f56e91d3", + "id": "32ce951e", "metadata": { "execution": { - "iopub.execute_input": "2026-08-12T09:19:40.795168Z", - "iopub.status.busy": "2026-08-12T09:19:40.795058Z", - "iopub.status.idle": "2026-08-12T09:19:40.803168Z", - "shell.execute_reply": "2026-08-12T09:19:40.802659Z" + "iopub.execute_input": "2026-08-12T09:49:48.200993Z", + "iopub.status.busy": "2026-08-12T09:49:48.200902Z", + "iopub.status.idle": "2026-08-12T09:49:48.207022Z", + "shell.execute_reply": "2026-08-12T09:49:48.206623Z" } }, "outputs": [ @@ -829,7 +831,7 @@ }, { "cell_type": "markdown", - "id": "50c5270b", + "id": "19c1fd38", "metadata": {}, "source": [ "## Recap\n", diff --git a/tests/test_cube_import_example.py b/tests/test_cube_import_example.py index 0912b8ec..49631f01 100644 --- a/tests/test_cube_import_example.py +++ b/tests/test_cube_import_example.py @@ -144,7 +144,8 @@ def test_cli_import_writes_report_and_models(tmp_path): storage = YAMLStorage(base_dir=str(store)) for name in EXPECTED_MODELS: model = run_sync(storage.get_model(name, data_source=DS)) - assert model is not None and model.name == name + assert model is not None + assert model.name == name fp_members = { i["member"] for i in data["issues"] if i["category"] == "filter_params_variable" @@ -191,10 +192,14 @@ async def test_required_bare_optional_blocked_in_sql(tmp_path): _, storage, _, _ = await _prepare(tmp_path) model = await storage.get_model("order_facts", data_source=DS) bare, blocked = extract_variable_refs(model.sql) - assert "category" in bare and "category" not in blocked - assert "region" in blocked and "region" not in bare + assert "category" in bare + assert "category" not in blocked + assert "region" in blocked + assert "region" not in bare low = model.sql.lower() - assert "orders" in low and "customers" in low and "products" in low + assert "orders" in low + assert "customers" in low + assert "products" in low # --------------------------------------------------------------------------- #