feat(wasm): full Cube support — validate, translate, PyO3, CLI, WASM, docs (#2282)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jax Liu
2026-05-15 12:53:02 +08:00
committed by GitHub
parent 6732d97d03
commit 026111e54e
46 changed files with 5749 additions and 95 deletions
+2
View File
@@ -48,6 +48,8 @@ WrenAI is the open context layer that fills that gap. You model your business in
A Rust engine powered by [Apache DataFusion](https://datafusion.apache.org/) translates the modeled SQL and runs it against 20+ data sources (PostgreSQL, BigQuery, Snowflake, Spark, etc.). Use it as a Python SDK, a CLI, a WASM module in the browser, or as building blocks for agent skills.
**Pre-aggregation cubes** — model business metrics once (revenue, order count, retention) with measures, dimensions, and time grains. AI agents query cubes with a structured input instead of hand-writing `GROUP BY` / `DATE_TRUNC` SQL, cutting error rates substantially on small / local models. See the [Cube guide](./docs/core/guides/modeling/cube.md).
## Quick start
The fastest path is to let an AI coding agent (Claude Code, Cursor, Aider, etc.) drive the install:
+22
View File
@@ -0,0 +1,22 @@
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use wren_core::mdl::{cube_query_to_sql as cube_query_to_sql_rs, CubeQuery};
use wren_core_base::mdl::manifest::Manifest;
/// Translate a structured CubeQuery (JSON) into a SQL string using the cube
/// definitions in the supplied manifest (JSON).
///
/// Both inputs are JSON strings so the binding stays serde-driven and
/// callers don't need to construct typed Rust objects from Python.
///
/// Raises `ValueError` on bad JSON or on translation errors (unknown
/// cube/measure/dimension, cyclic derived measures, …).
#[pyfunction]
pub fn cube_query_to_sql(cube_query_json: &str, manifest_json: &str) -> PyResult<String> {
let query: CubeQuery = serde_json::from_str(cube_query_json)
.map_err(|e| PyValueError::new_err(format!("Invalid CubeQuery JSON: {e}")))?;
let manifest: Manifest = serde_json::from_str(manifest_json)
.map_err(|e| PyValueError::new_err(format!("Invalid manifest JSON: {e}")))?;
cube_query_to_sql_rs(&query, &manifest)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
+2
View File
@@ -3,6 +3,7 @@ use pyo3::prelude::*;
use remote_functions::PyRemoteFunction;
pub mod context;
mod cube;
mod errors;
mod extractor;
mod manifest;
@@ -25,5 +26,6 @@ fn wren_core_wrapper(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(validation::validate_rlac_rule, m)?)?;
m.add_function(wrap_pyfunction!(manifest::is_backward_compatible, m)?)?;
m.add_function(wrap_pyfunction!(manifest::migrate_manifest_json, m)?)?;
m.add_function(wrap_pyfunction!(cube::cube_query_to_sql, m)?)?;
Ok(())
}
+142
View File
@@ -0,0 +1,142 @@
"""Tests for the cube_query_to_sql PyO3 binding."""
import json
import pytest
from wren_core import cube_query_to_sql
MANIFEST = json.dumps(
{
"catalog": "test",
"schema": "public",
"models": [
{
"name": "orders",
"tableReference": {"schema": "main", "table": "orders"},
"columns": [
{"name": "o_totalprice", "type": "double"},
{"name": "o_orderstatus", "type": "varchar"},
{"name": "o_orderdate", "type": "date"},
],
}
],
"cubes": [
{
"name": "order_metrics",
"baseObject": "orders",
"measures": [
{
"name": "revenue",
"expression": "SUM(o_totalprice)",
"type": "DOUBLE",
},
{
"name": "order_count",
"expression": "COUNT(*)",
"type": "BIGINT",
},
],
"dimensions": [
{
"name": "status",
"expression": "o_orderstatus",
"type": "VARCHAR",
}
],
"timeDimensions": [
{
"name": "created_at",
"expression": "o_orderdate",
"type": "DATE",
}
],
}
],
}
)
def test_basic_cube_query():
query = json.dumps(
{
"cube": "order_metrics",
"measures": ["revenue"],
"dimensions": ["status"],
}
)
sql = cube_query_to_sql(query, MANIFEST)
assert "SUM(o_totalprice) AS revenue" in sql
assert "o_orderstatus AS status" in sql
assert "FROM orders" in sql
assert "GROUP BY" in sql
def test_time_dimension_with_date_range():
query = json.dumps(
{
"cube": "order_metrics",
"measures": ["revenue"],
"timeDimensions": [
{
"dimension": "created_at",
"granularity": "month",
"dateRange": ["2024-01-01", "2025-01-01"],
}
],
}
)
sql = cube_query_to_sql(query, MANIFEST)
assert "DATE_TRUNC('month', o_orderdate)" in sql
assert "o_orderdate >= '2024-01-01'" in sql
assert "o_orderdate < '2025-01-01'" in sql
def test_filter_eq():
query = json.dumps(
{
"cube": "order_metrics",
"measures": ["revenue"],
"filters": [
{"dimension": "status", "operator": "eq", "value": "completed"}
],
}
)
sql = cube_query_to_sql(query, MANIFEST)
assert "WHERE o_orderstatus = 'completed'" in sql
def test_limit_offset():
query = json.dumps(
{
"cube": "order_metrics",
"measures": ["revenue"],
"limit": 10,
"offset": 5,
}
)
sql = cube_query_to_sql(query, MANIFEST)
assert sql.endswith("LIMIT 10 OFFSET 5")
def test_unknown_cube_error():
query = json.dumps({"cube": "nonexistent", "measures": ["revenue"]})
with pytest.raises(ValueError, match="not found"):
cube_query_to_sql(query, MANIFEST)
def test_unknown_measure_error():
query = json.dumps({"cube": "order_metrics", "measures": ["no_such"]})
with pytest.raises(ValueError, match="Unknown measure"):
cube_query_to_sql(query, MANIFEST)
def test_invalid_cube_query_json():
with pytest.raises(ValueError, match="Invalid CubeQuery JSON"):
cube_query_to_sql("not json at all", MANIFEST)
def test_invalid_manifest_json():
query = json.dumps({"cube": "order_metrics", "measures": ["revenue"]})
with pytest.raises(ValueError, match="Invalid manifest JSON"):
cube_query_to_sql(query, "not json")
+65 -5
View File
@@ -6,7 +6,7 @@ Reference for AI agents generating browser-based HTML dashboard artifacts using
```html
<script type="module">
import { WrenEngine } from 'https://unpkg.com/@wrenai/wren-core-wasm@0.1.0/dist/index.js';
import { WrenEngine } from 'https://unpkg.com/@wrenai/wren-core-wasm@0.3.0/dist/index.js';
</script>
```
@@ -46,6 +46,10 @@ await engine.registerJson('orders', [
const parquetBytes = Uint8Array.from(atob(PARQUET_BASE64), c => c.charCodeAt(0));
await engine.registerParquet('orders', parquetBytes.buffer);
// Or from CSV (string or bytes). Inference handles common cases; pass
// `{ schema: [...] }` to force a specific Arrow schema.
await engine.registerCsv('orders', csvString);
await engine.loadMDL(mdlJson, { source: '' });
const rows = await engine.query('SELECT * FROM "Orders" LIMIT 100');
```
@@ -72,7 +76,6 @@ const mdl = {
},
],
relationships: [],
metrics: [],
views: [],
};
```
@@ -86,6 +89,63 @@ const mdl = {
- **console.table**: `console.table(rows)`
- **HTML table**: iterate `rows` to build `<tr>/<td>` elements
## Cube Query API
For aggregation queries, prefer `cubeQuery()` over raw SQL. The cube layer
generates correct `GROUP BY`, `DATE_TRUNC`, and `WHERE` clauses from a
structured input — fewer hand-written errors for the agent.
> ⚠️ Both `listCubes()` and `cubeQuery()` require `await engine.loadMDL(...)`
> to have completed first — they throw an error otherwise.
### List available cubes
```javascript
const cubes = engine.listCubes();
// → [{ name: "order_metrics", baseObject: "orders", measures: [...],
// dimensions: [...], timeDimensions: [...], hierarchies: {...} }]
```
### Execute a cube query
```javascript
const rows = await engine.cubeQuery({
cube: "order_metrics",
measures: ["revenue", "order_count"],
dimensions: ["status"],
timeDimensions: [{
dimension: "created_at",
granularity: "month",
dateRange: ["2024-01-01", "2025-01-01"],
}],
filters: [
{ dimension: "status", operator: "eq", value: "completed" },
],
limit: 100,
});
```
`rows` has the same `Record<string, unknown>[]` shape as `query()`.
### `cubeQuery` vs `query`
| Situation | Use |
|---|---|
| Aggregating measures over dimensions (with optional time bucket) | `cubeQuery` |
| Free-form SQL — joins across models, window functions, custom CTEs | `query` |
| MDL has no cubes defined | `query` |
### Filter operators
`eq`, `neq`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `contains`,
`starts_with`, `is_null`, `is_not_null`. Pass `value` as an array for
`in`/`not_in`; omit `value` for `is_null`/`is_not_null`.
### Time granularity
`year` | `quarter` | `month` | `week` | `day` | `hour` | `minute`.
`dateRange` is `[startInclusive, endExclusive]`.
## Complete HTML Template (Inline Mode)
```html
@@ -103,7 +163,7 @@ const mdl = {
<div id="status">Loading engine...</div>
<script type="module">
import { WrenEngine } from 'https://unpkg.com/@wrenai/wren-core-wasm@0.1.0/dist/index.js';
import { WrenEngine } from 'https://unpkg.com/@wrenai/wren-core-wasm@0.3.0/dist/index.js';
const status = document.getElementById('status');
@@ -134,7 +194,7 @@ const mdl = {
],
primaryKey: 'id',
}],
relationships: [], metrics: [], views: [],
relationships: [], views: [],
};
await engine.loadMDL(mdl, { source: '' });
@@ -170,7 +230,7 @@ const mdl = {
## Common Pitfalls
1. **Model names are case-sensitive** — use double quotes: `FROM "Orders"`, not `FROM Orders`
2. **`loadMDL` must be called after `registerJson`/`registerParquet`** in inline mode
2. **`loadMDL` must be called after `registerJson`/`registerParquet`/`registerCsv`** in inline mode
3. **WASM binary is ~68 MB** — show a loading indicator during `WrenEngine.init()`
4. **`source: ''`** means "use pre-registered tables only" — don't pass `''` if you expect URL mode
5. **CORS required** for URL mode — `file://` protocol won't work for fetching remote Parquet
+1 -1
View File
@@ -33,7 +33,7 @@ datafusion = { version = "53", default-features = false, features = [
object_store = { version = "0.13.1", features = ["aws", "http"] }
# --- Arrow / Parquet ---
arrow = { version = "58.1", default-features = false, features = ["json"] }
arrow = { version = "58.1", default-features = false, features = ["json", "csv"] }
# No zstd: requires C library (zstd-sys) which cannot compile to WASM.
# Snappy and LZ4 are pure Rust and WASM-compatible.
parquet = { version = "58.1", default-features = false, features = ["arrow", "snap", "lz4"] }
+72 -1
View File
@@ -48,7 +48,6 @@ const mdl = {
},
],
relationships: [],
metrics: [],
views: [],
};
@@ -77,12 +76,54 @@ await engine.registerJson('orders', [
const response = await fetch('orders.parquet');
await engine.registerParquet('orders', await response.arrayBuffer());
// Or register CSV — string or bytes, with optional schema / delimiter / quote
await engine.registerCsv('orders', 'id,customer,amount\n1,Alice,100\n2,Bob,200');
// Load MDL with empty source (uses pre-registered tables)
await engine.loadMDL(mdl, { source: '' });
const rows = await engine.query('SELECT * FROM "Orders" LIMIT 10');
```
## Examples
The `examples/` directory ships runnable browser demos. They import the
local WASM build from `pkg/`, so they always reflect the current source
— useful while iterating on the Rust or TypeScript side.
```bash
# Build the WASM binary (debug build is fine for examples)
just build-wasm-dev
# Start the static dev server with CORS + Range support
just serve
```
The server prints every demo URL on startup. Open any of them in a browser:
| Demo | URL | What it shows |
|---|---|---|
| Inline data | http://localhost:8787/examples/inline.html | `registerJson` + raw SQL `query()` |
| URL mode | http://localhost:8787/examples/url-mode.html | Remote Parquet via HTTP range requests |
| CDN smoke test | http://localhost:8787/examples/test-cdn.html | Loading the published package from unpkg |
| **Cube quickstart** | http://localhost:8787/examples/cube-quickstart.html | Minimal `cubeQuery()` — three preset queries (group-by, filter, time bucket) |
| **Cube explorer** | http://localhost:8787/examples/cube-explorer.html | Form-driven builder for `CubeQuery` — pick measures/dimensions, add filters, choose granularity + date range |
| **CSV quickstart** | http://localhost:8787/examples/csv-quickstart.html | `registerCsv()` against real files in `data/` — inferred schema, custom delimiter (TSV), and headerless CSV with explicit schema |
### Cube quickstart vs explorer
- **Quickstart** loads a single `order_metrics` cube and fires hardcoded
`cubeQuery()` calls when you click a button. Read the source to see
the smallest end-to-end cube example.
- **Explorer** is interactive: a checkbox/select form generates the
`CubeQuery` JSON live (shown next to the result), and you can add as
many filters as you like with all 12 `FilterOperator` values. The
demo data is spread across regions / customers / months so groupings
produce non-trivial numbers.
After Rust changes, re-run `just build-wasm-dev` and refresh the page —
the examples import directly from `pkg/wren_core_wasm.js`.
## API Reference
### `WrenEngine.init(options?)`
@@ -126,6 +167,36 @@ Register JSON data as a named table. Call before `loadMDL` in inline mode.
async registerJson(name: string, data: object[]): Promise<void>
```
### `engine.registerCsv(name, data, options?)`
Register CSV data as a named table. Accepts a string (treated as UTF-8) or any
`BufferSource` (ArrayBuffer / TypedArray / Node Buffer). By default the first
row is the header and the schema is inferred from the first 1000 rows.
```typescript
async registerCsv(
name: string,
data: string | BufferSource,
options?: CsvReadOptions,
): Promise<void>
```
| Option (camelCase) | Type | Default | Description |
|---|---|---|---|
| `header` | `boolean` | `true` | First row is a header. |
| `delimiter` | `string` | `","` | Field delimiter (single ASCII char). |
| `quote` | `string` | `"\""` | Quote character (single ASCII char). |
| `escape` | `string` | unset | Escape character (single ASCII char). |
| `terminator` | `string` | any of `\n`, `\r\n` | Record terminator (single ASCII char). |
| `batchSize` | `number` | `8192` | RecordBatch size. |
| `inferRows` | `number` | `1000` | Rows scanned for inference. Ignored when `schema` is set. |
| `schema` | `CsvSchemaColumn[]` | inferred | Explicit Arrow schema `{ name, type, nullable? }[]`. |
Schema column types (case-insensitive): `int8`/`int16`/`int32`/`int64`,
`uint8`/`uint16`/`uint32`/`uint64`, `float32`/`float64`, `boolean`,
`string` (alias `utf8`/`varchar`/`text`), `date`/`date32`/`date64`,
`timestamp` and `timestamp_{s,ms,us,ns}`.
### `engine.query(sql)`
Execute a SQL query through the semantic layer. Returns parsed result objects.
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>wren-core-wasm: CSV Quickstart</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 900px; margin: 1.5em auto; padding: 0 1em; color: #333; }
h1 { margin: 0 0 0.2em; }
.sub { color: #666; margin: 0 0 1em; font-size: 0.95em; }
.log { padding: 0.3em 0.6em; margin: 0.2em 0; border-left: 3px solid #666; font-size: 0.85em; }
.ok { border-color: #2a2; color: #2a2; }
.err { border-color: #a22; color: #a22; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2em; margin-top: 1em; }
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
.panel { border: 1px solid #ddd; border-radius: 6px; padding: 0.8em 1em; }
.panel h3 { margin: 0 0 0.4em; font-size: 1em; }
.panel .src { color: #888; font-size: 0.75em; margin-bottom: 0.4em; font-family: monospace; }
pre { background: #f7f7f7; padding: 0.6em; border-radius: 4px; font-size: 0.8em; overflow-x: auto; margin: 0.4em 0; }
button { padding: 0.4em 1em; margin: 0.3em 0; cursor: pointer; }
table { border-collapse: collapse; margin: 0.4em 0; width: 100%; font-size: 0.85em; }
th, td { border: 1px solid #ddd; padding: 0.25em 0.55em; text-align: left; }
th { background: #f4f4f4; }
.row-meta { font-size: 0.85em; color: #666; }
</style>
</head>
<body>
<h1>CSV Quickstart</h1>
<p class="sub">Three patterns for <code>registerCsv()</code>:
<strong>(1)</strong> standard CSV with inferred schema,
<strong>(2)</strong> TSV with a custom delimiter, and
<strong>(3)</strong> headerless CSV with an explicit Arrow schema.
All three load real files from <code>data/</code>.</p>
<div id="log"></div>
<div class="grid">
<div class="panel">
<h3>1. orders.csv — inferred schema</h3>
<div class="src">data/orders.csv · defaults</div>
<pre>await engine.registerCsv('orders', csv);</pre>
<button data-q="orders">Run query</button>
<div class="row-meta" data-meta="orders"></div>
<div data-result="orders"></div>
</div>
<div class="panel">
<h3>2. products.tsv — tab delimiter</h3>
<div class="src">data/products.tsv · delimiter: "\t"</div>
<pre>await engine.registerCsv('products', tsv, {
delimiter: '\t',
});</pre>
<button data-q="products">Run query</button>
<div class="row-meta" data-meta="products"></div>
<div data-result="products"></div>
</div>
<div class="panel" style="grid-column: 1 / -1;">
<h3>3. region_targets.csv — no header, explicit schema</h3>
<div class="src">data/region_targets.csv · header: false · schema: [region, quarter, target]</div>
<pre>await engine.registerCsv('region_targets', csv, {
header: false,
schema: [
{ name: 'region', type: 'string' },
{ name: 'quarter', type: 'string' },
{ name: 'target', type: 'int64' },
],
});</pre>
<button data-q="targets">Run query</button>
<div class="row-meta" data-meta="targets"></div>
<div data-result="targets"></div>
</div>
</div>
<script type="module">
import init, { WrenEngine } from '../pkg/wren_core_wasm.js';
const logEl = document.getElementById('log');
function log(msg, ok = true) {
const div = document.createElement('div');
div.className = `log ${ok ? 'ok' : 'err'}`;
div.textContent = msg;
logEl.appendChild(div);
}
function escapeHtml(v) {
return String(v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderTable(rows) {
if (!rows.length) return '<p>No rows.</p>';
const keys = Object.keys(rows[0]);
return '<table><tr>' + keys.map(k => `<th>${escapeHtml(k)}</th>`).join('')
+ '</tr>' + rows.map(r => '<tr>' + keys.map(k => `<td>${escapeHtml(r[k] ?? '')}</td>`).join('') + '</tr>').join('')
+ '</table>';
}
async function fetchBytes(path) {
const res = await fetch(path);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${path}`);
return new Uint8Array(await res.arrayBuffer());
}
// The raw WASM `registerCsv` signature is
// registerCsv(table_name, data: Uint8Array, options_json: string)
// with all three args required. The TypeScript SDK adds a friendlier
// overload (string + object), but examples here import the raw module
// straight from pkg/ for transparency, so we call it raw-style.
function registerCsv(name, bytes, options) {
const optsJson = options ? JSON.stringify(options) : '';
return engine.registerCsv(name, bytes, optsJson);
}
async function runQuery(key, sql, resultKey = key) {
const meta = document.querySelector(`[data-meta="${resultKey}"]`);
const out = document.querySelector(`[data-result="${resultKey}"]`);
try {
const t = performance.now();
const json = await engine.query(sql);
const rows = JSON.parse(json || '[]');
const ms = (performance.now() - t).toFixed(1);
meta.textContent = `${rows.length} row(s) in ${ms}ms`;
out.innerHTML = renderTable(rows);
} catch (e) {
meta.textContent = '';
out.innerHTML = '';
const err = document.createElement('div');
err.className = 'log err';
err.textContent = e.message || String(e);
out.appendChild(err);
}
}
let engine = null;
try {
await init();
engine = new WrenEngine();
log('Engine ready');
// ── 1. Standard CSV with inferred schema ─────────────────────────
const ordersBytes = await fetchBytes('data/orders.csv');
await registerCsv('orders', ordersBytes);
log('Registered "orders" from CSV (inferred schema)');
// ── 2. TSV: tab delimiter ────────────────────────────────────────
const productsBytes = await fetchBytes('data/products.tsv');
await registerCsv('products', productsBytes, { delimiter: '\t' });
log('Registered "products" from TSV (delimiter: tab)');
// ── 3. Headerless CSV with explicit Arrow schema ────────────────
const targetsBytes = await fetchBytes('data/region_targets.csv');
await registerCsv('region_targets', targetsBytes, {
header: false,
schema: [
{ name: 'region', type: 'string' },
{ name: 'quarter', type: 'string' },
{ name: 'target', type: 'int64' },
],
});
log('Registered "region_targets" from CSV (header=false, explicit schema)');
// Auto-run the three queries so the page lands populated.
document.querySelectorAll('button[data-q]').forEach(btn => {
btn.addEventListener('click', () => runFor(btn.dataset.q));
});
await runFor('orders');
await runFor('products');
await runFor('targets');
} catch (e) {
log(`Init failed: ${e.message || e}`, false);
console.error(e);
}
function runFor(key) {
if (key === 'orders') {
return runQuery('orders',
"SELECT region, status, count(*) AS n, sum(amount) AS revenue " +
"FROM orders GROUP BY region, status ORDER BY region, status");
}
if (key === 'products') {
return runQuery('products',
"SELECT category, count(*) AS n, " +
" avg(unit_price) AS avg_price, " +
" sum(CASE WHEN in_stock THEN 1 ELSE 0 END) AS in_stock_count " +
"FROM products GROUP BY category ORDER BY category");
}
if (key === 'targets') {
// Join the explicit-schema CSV to the inferred-schema CSV.
return runQuery('targets',
"SELECT t.region, t.quarter, t.target, " +
" COALESCE(SUM(o.amount), 0) AS actual " +
"FROM region_targets t " +
"LEFT JOIN orders o ON o.region = t.region " +
" AND ('Q' || CAST(((EXTRACT(MONTH FROM CAST(o.created_at AS DATE)) - 1) / 3) + 1 AS VARCHAR)) = t.quarter " +
"GROUP BY t.region, t.quarter, t.target " +
"ORDER BY t.region, t.quarter");
}
}
</script>
</body>
</html>
@@ -0,0 +1,435 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>wren-core-wasm: Cube Explorer</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 1100px; margin: 1.5em auto; padding: 0 1em; color: #333; }
h1 { margin: 0 0 0.2em; }
.sub { color: #666; margin: 0 0 1em; font-size: 0.95em; }
.log { padding: 0.3em 0.6em; margin: 0.2em 0; border-left: 3px solid #666; font-size: 0.85em; }
.ok { border-color: #2a2; color: #2a2; }
.err { border-color: #a22; color: #a22; }
.layout { display: grid; grid-template-columns: 1fr 1.4fr; gap: 1.5em; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
.panel { border: 1px solid #ddd; border-radius: 6px; padding: 0.8em 1em; }
.panel h3 { margin: 0 0 0.6em; font-size: 1.05em; }
.field { margin: 0.45em 0; }
.field label { display: block; font-size: 0.85em; color: #555; margin-bottom: 0.15em; }
.checks { display: flex; flex-wrap: wrap; gap: 0.3em 0.8em; padding: 0.3em 0; }
.checks label { font-size: 0.9em; color: #333; }
select, input[type="text"], input[type="number"] { width: 100%; box-sizing: border-box; padding: 0.3em 0.4em; font-size: 0.9em; font-family: inherit; }
button { padding: 0.4em 0.9em; margin-right: 0.4em; cursor: pointer; font-size: 0.9em; }
button.primary { background: #2962ff; color: white; border: 1px solid #1a4ec2; }
.filter-row { display: grid; grid-template-columns: 1fr 130px 1.4fr 24px; gap: 0.3em; align-items: center; margin: 0.25em 0; }
.filter-row button { padding: 0 0.5em; line-height: 1; }
pre { background: #f7f7f7; padding: 0.7em; border-radius: 4px; font-size: 0.8em; overflow-x: auto; margin: 0.4em 0; }
table { border-collapse: collapse; margin: 0.4em 0; width: 100%; font-size: 0.85em; }
th, td { border: 1px solid #ddd; padding: 0.25em 0.55em; text-align: left; }
th { background: #f4f4f4; }
details { margin: 0.4em 0; }
details summary { cursor: pointer; font-size: 0.85em; color: #555; }
.row-meta { font-size: 0.85em; color: #666; margin: 0.3em 0; }
</style>
</head>
<body>
<h1>Cube Explorer</h1>
<p class="sub">Build <code>cubeQuery()</code> inputs by clicking. Useful for exploring what an agent can do without writing JSON by hand.</p>
<div id="log"></div>
<div class="layout">
<div class="panel">
<h3>Available cubes</h3>
<div class="field">
<label for="cube-select">Cube</label>
<select id="cube-select" disabled></select>
</div>
<details>
<summary>Cube schema</summary>
<pre id="cube-schema"></pre>
</details>
<h3 style="margin-top: 1.2em;">Build query</h3>
<div class="field">
<label>Measures (one or more)</label>
<div id="measures" class="checks"></div>
</div>
<div class="field">
<label>Dimensions</label>
<div id="dimensions" class="checks"></div>
</div>
<div class="field">
<label>Time dimension (optional)</label>
<div class="filter-row" style="grid-template-columns: 1fr 130px 1fr;">
<select id="td-name"></select>
<select id="td-granularity">
<option value="">(no bucket)</option>
<option value="year">year</option>
<option value="quarter">quarter</option>
<option value="month">month</option>
<option value="week">week</option>
<option value="day">day</option>
<option value="hour">hour</option>
<option value="minute">minute</option>
</select>
<input type="text" id="td-range" placeholder="2024-01-01,2025-01-01 (optional)">
</div>
</div>
<div class="field">
<label>Filters</label>
<div id="filters"></div>
<button id="add-filter" type="button">+ Add filter</button>
</div>
<div class="field" style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5em;">
<div>
<label>Limit</label>
<input type="number" id="limit" min="1" placeholder="(none)">
</div>
<div>
<label>Offset</label>
<input type="number" id="offset" min="0" placeholder="(none)">
</div>
</div>
<div style="margin-top: 0.8em;">
<button id="run" class="primary" disabled>Run cubeQuery</button>
<button id="reset" type="button">Reset</button>
</div>
</div>
<div class="panel">
<h3>Generated CubeQuery JSON</h3>
<pre id="query-json">{}</pre>
<h3>Result</h3>
<div class="row-meta" id="result-meta"></div>
<div id="result"></div>
<details>
<summary>Raw JSON</summary>
<pre id="result-raw"></pre>
</details>
</div>
</div>
<script type="module">
import init, { WrenEngine } from '../pkg/wren_core_wasm.js';
// ── Demo MDL ──
const mdl = {
catalog: 'wren',
schema: 'public',
models: [{
name: 'orders',
tableReference: { table: 'orders' },
columns: [
{ name: 'id', type: 'INTEGER' },
{ name: 'customer', type: 'VARCHAR' },
{ name: 'region', type: 'VARCHAR' },
{ name: 'status', type: 'VARCHAR' },
{ name: 'amount', type: 'DOUBLE' },
{ name: 'created_at', type: 'DATE' },
],
}],
relationships: [], views: [],
cubes: [{
name: 'order_metrics',
baseObject: 'orders',
measures: [
{ name: 'revenue', expression: 'SUM(amount)', type: 'DOUBLE' },
{ name: 'order_count', expression: 'COUNT(*)', type: 'BIGINT' },
{ name: 'avg_order', expression: 'revenue / order_count', type: 'DOUBLE' },
{ name: 'max_amount', expression: 'MAX(amount)', type: 'DOUBLE' },
],
dimensions: [
{ name: 'status', expression: 'status', type: 'VARCHAR' },
{ name: 'customer', expression: 'customer', type: 'VARCHAR' },
{ name: 'region', expression: 'region', type: 'VARCHAR' },
],
timeDimensions: [
{ name: 'created_at', expression: 'created_at', type: 'DATE' },
],
hierarchies: { time_drill: ['created_at'] },
}],
};
// Spread across regions / customers / months so groupings are interesting.
const data = [
{ id: 1, customer: 'Alice', region: 'NA', status: 'open', amount: 150, created_at: '2024-01-15' },
{ id: 2, customer: 'Bob', region: 'EU', status: 'open', amount: 200, created_at: '2024-01-20' },
{ id: 3, customer: 'Alice', region: 'NA', status: 'closed', amount: 300, created_at: '2024-02-10' },
{ id: 4, customer: 'Bob', region: 'EU', status: 'open', amount: 100, created_at: '2024-02-15' },
{ id: 5, customer: 'Carol', region: 'APAC', status: 'cancelled', amount: 50, created_at: '2024-02-28' },
{ id: 6, customer: 'Alice', region: 'NA', status: 'open', amount: 250, created_at: '2024-03-05' },
{ id: 7, customer: 'Carol', region: 'APAC', status: 'closed', amount: 175, created_at: '2024-03-12' },
{ id: 8, customer: 'Dan', region: 'EU', status: 'open', amount: 425, created_at: '2024-03-22' },
{ id: 9, customer: 'Dan', region: 'EU', status: 'cancelled', amount: 30, created_at: '2024-04-02' },
{ id: 10, customer: 'Alice', region: 'NA', status: 'closed', amount: 600, created_at: '2024-04-18' },
{ id: 11, customer: 'Carol', region: 'APAC', status: 'open', amount: 220, created_at: '2024-05-03' },
{ id: 12, customer: 'Bob', region: 'EU', status: 'closed', amount: 380, created_at: '2024-05-19' },
];
// ── DOM helpers ──
const $ = (id) => document.getElementById(id);
const logEl = $('log');
function log(msg, ok = true) {
const div = document.createElement('div');
div.className = `log ${ok ? 'ok' : 'err'}`;
div.textContent = msg;
logEl.appendChild(div);
}
function escapeHtml(v) {
return String(v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const OPERATORS = ['eq', 'neq', 'in', 'not_in', 'gt', 'gte', 'lt', 'lte',
'contains', 'starts_with', 'is_null', 'is_not_null'];
// ── State ──
let engine = null;
let currentCube = null;
let filterIdSeq = 0;
function checkboxGroup(containerId, items, namePrefix) {
const container = $(containerId);
container.innerHTML = '';
for (const item of items) {
const id = `${namePrefix}-${item.name}`;
const label = document.createElement('label');
const input = document.createElement('input');
input.type = 'checkbox';
input.id = id;
input.value = item.name;
const typeSpan = document.createElement('span');
typeSpan.style.color = '#999';
typeSpan.style.fontSize = '0.85em';
typeSpan.textContent = item.type || '';
label.appendChild(input);
label.appendChild(document.createTextNode(` ${item.name} `));
label.appendChild(typeSpan);
container.appendChild(label);
}
}
function getCheckedValues(containerId) {
return [...$(containerId).querySelectorAll('input:checked')].map(el => el.value);
}
function renderFilters() {
const container = $('filters');
container.innerHTML = '';
for (const f of filterRows) {
const row = document.createElement('div');
row.className = 'filter-row';
const dimOptions = [...currentCube.dimensions, ...currentCube.timeDimensions]
.map(d => `<option value="${escapeHtml(d.name)}" ${f.dim === d.name ? 'selected' : ''}>${escapeHtml(d.name)}</option>`).join('');
const opOptions = OPERATORS
.map(o => `<option value="${escapeHtml(o)}" ${f.op === o ? 'selected' : ''}>${escapeHtml(o)}</option>`).join('');
const placeholder = (f.op === 'in' || f.op === 'not_in')
? 'a,b,c'
: 'value (or empty for is_null/is_not_null)';
row.innerHTML = `
<select data-fid="${escapeHtml(f.id)}" data-attr="dim">${dimOptions}</select>
<select data-fid="${escapeHtml(f.id)}" data-attr="op">${opOptions}</select>
<input type="text" data-fid="${escapeHtml(f.id)}" data-attr="value" value="${escapeHtml(f.value || '')}"
placeholder="${escapeHtml(placeholder)}">
<button type="button" data-fid="${escapeHtml(f.id)}" data-action="del" title="Remove">×</button>`;
container.appendChild(row);
}
}
let filterRows = [];
function addFilter() {
const dim = currentCube.dimensions[0]?.name
|| currentCube.timeDimensions[0]?.name
|| '';
filterRows.push({ id: ++filterIdSeq, dim, op: 'eq', value: '' });
renderFilters();
}
function readFilterRows() {
const result = [];
for (const f of filterRows) {
const dim = $('filters').querySelector(`[data-fid="${f.id}"][data-attr="dim"]`).value;
const op = $('filters').querySelector(`[data-fid="${f.id}"][data-attr="op"]`).value;
const raw = $('filters').querySelector(`[data-fid="${f.id}"][data-attr="value"]`).value;
const filter = { dimension: dim, operator: op };
if (op === 'is_null' || op === 'is_not_null') {
// No value.
} else if (op === 'in' || op === 'not_in') {
filter.value = raw.split(',').map(s => s.trim()).filter(Boolean);
} else if (raw !== '') {
// Try numeric first; fall back to string.
const n = Number(raw);
filter.value = (!Number.isNaN(n) && raw.trim() !== '') ? n : raw;
}
result.push(filter);
}
return result;
}
function buildQuery() {
if (!currentCube) return null;
const measures = getCheckedValues('measures');
const dimensions = getCheckedValues('dimensions');
const q = { cube: currentCube.name, measures };
if (dimensions.length) q.dimensions = dimensions;
const tdName = $('td-name').value;
const tdGran = $('td-granularity').value;
if (tdName && tdGran) {
const td = { dimension: tdName, granularity: tdGran };
const range = $('td-range').value.trim();
if (range) {
const parts = range.split(',').map(s => s.trim());
if (parts.length === 2) td.dateRange = parts;
}
q.timeDimensions = [td];
}
const filters = readFilterRows();
if (filters.length) q.filters = filters;
const limit = $('limit').value;
if (limit !== '') q.limit = Number(limit);
const offset = $('offset').value;
if (offset !== '') q.offset = Number(offset);
return q;
}
function renderTable(rows) {
if (!rows.length) return '<p>No rows.</p>';
const keys = Object.keys(rows[0]);
return '<table><tr>' + keys.map(k => `<th>${escapeHtml(k)}</th>`).join('')
+ '</tr>' + rows.map(r => '<tr>' + keys.map(k => `<td>${escapeHtml(r[k] ?? '')}</td>`).join('') + '</tr>').join('')
+ '</table>';
}
function renderError(containerId, msg) {
const el = $(containerId);
el.innerHTML = '';
const div = document.createElement('div');
div.className = 'log err';
div.textContent = msg;
el.appendChild(div);
}
async function runQuery() {
const q = buildQuery();
if (!q) return;
if (!q.measures || !q.measures.length) {
renderError('result', 'Pick at least one measure.');
return;
}
try {
const t = performance.now();
const json = await engine.cubeQuery(JSON.stringify(q));
const rows = JSON.parse(json || '[]');
const ms = (performance.now() - t).toFixed(1);
$('result-meta').textContent = `${rows.length} row(s) in ${ms}ms`;
$('result').innerHTML = renderTable(rows);
$('result-raw').textContent = JSON.stringify(rows, null, 2);
} catch (e) {
$('result-meta').textContent = '';
renderError('result', e.message || String(e));
$('result-raw').textContent = '';
}
}
function syncQueryDisplay() {
const q = buildQuery();
$('query-json').textContent = q ? JSON.stringify(q, null, 2) : '{}';
}
function loadCube(cube) {
currentCube = cube;
$('cube-schema').textContent = JSON.stringify(cube, null, 2);
checkboxGroup('measures', cube.measures, 'm');
checkboxGroup('dimensions', cube.dimensions, 'd');
const tdSel = $('td-name');
tdSel.innerHTML = cube.timeDimensions
.map(td => `<option value="${escapeHtml(td.name)}">${escapeHtml(td.name)}</option>`).join('');
if (!cube.timeDimensions.length) {
tdSel.innerHTML = '<option value="">(none defined)</option>';
}
filterRows = [];
renderFilters();
// Sensible default: first measure + first dimension.
const firstMeasure = $('measures').querySelector('input');
if (firstMeasure) firstMeasure.checked = true;
const firstDim = $('dimensions').querySelector('input');
if (firstDim) firstDim.checked = true;
syncQueryDisplay();
}
function attachAutoSync() {
for (const ev of ['change', 'input']) {
document.addEventListener(ev, e => {
if (e.target.closest('#measures, #dimensions, #filters, #td-name, #td-granularity, #td-range, #limit, #offset')) {
syncQueryDisplay();
}
});
}
$('filters').addEventListener('click', e => {
if (e.target.dataset?.action === 'del') {
const fid = Number(e.target.dataset.fid);
filterRows = filterRows.filter(f => f.id !== fid);
renderFilters();
syncQueryDisplay();
}
});
$('add-filter').addEventListener('click', () => { addFilter(); syncQueryDisplay(); });
$('run').addEventListener('click', runQuery);
$('reset').addEventListener('click', () => { loadCube(currentCube); });
}
// ── Boot ──
try {
await init();
engine = new WrenEngine();
log('Engine ready');
await engine.registerJson('orders', JSON.stringify(data));
log(`Registered "orders" (${data.length} rows)`);
await engine.loadMDL(JSON.stringify(mdl), '');
log('MDL loaded');
const cubes = JSON.parse(engine.listCubes() || '[]');
const sel = $('cube-select');
sel.innerHTML = cubes.map(c => `<option value="${escapeHtml(c.name)}">${escapeHtml(c.name)}</option>`).join('');
sel.disabled = false;
sel.addEventListener('change', e => {
const c = cubes.find(c => c.name === e.target.value);
if (c) loadCube(c);
});
attachAutoSync();
loadCube(cubes[0]);
$('run').disabled = false;
// Auto-run the default selection so the page lands populated.
runQuery();
} catch (e) {
log(`Init failed: ${e.message || e}`, false);
console.error(e);
}
</script>
</body>
</html>
@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>wren-core-wasm: Cube Quickstart</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 800px; margin: 2em auto; padding: 0 1em; color: #333; }
.log { padding: 0.3em 0.6em; margin: 0.2em 0; border-left: 3px solid #666; font-size: 0.9em; }
.ok { border-color: #2a2; color: #2a2; }
.err { border-color: #a22; color: #a22; }
pre { background: #f4f4f4; padding: 0.6em; border-radius: 4px; font-size: 0.85em; overflow-x: auto; }
button { padding: 0.4em 1em; margin: 0.3em 0.3em 0.3em 0; cursor: pointer; }
table { border-collapse: collapse; margin: 0.5em 0; width: 100%; font-size: 0.9em; }
th, td { border: 1px solid #ddd; padding: 0.3em 0.6em; text-align: left; }
th { background: #f4f4f4; }
h3 { margin-top: 1.5em; }
</style>
</head>
<body>
<h1>Cube Quickstart</h1>
<p>The simplest <code>cubeQuery()</code> call: aggregate <code>revenue</code> by <code>status</code> over an <code>order_metrics</code> cube. The MDL embeds the cube definition next to the model.</p>
<div id="log"></div>
<h3>Cube definition (from MDL)</h3>
<pre id="cube-def"></pre>
<h3>Cube query</h3>
<pre id="query"></pre>
<button id="run" disabled>Run cubeQuery</button>
<button id="run-filtered" disabled>Run with filter (status = 'open')</button>
<button id="run-time" disabled>Run with month bucket</button>
<h3>Result</h3>
<div id="result"></div>
<script type="module">
import init, { WrenEngine } from '../pkg/wren_core_wasm.js';
const logEl = document.getElementById('log');
const resultEl = document.getElementById('result');
function log(msg, ok = true) {
const div = document.createElement('div');
div.className = `log ${ok ? 'ok' : 'err'}`;
div.textContent = msg;
logEl.appendChild(div);
}
function escapeHtml(v) {
return String(v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderTable(rows) {
if (!rows.length) return '<p>No rows.</p>';
const keys = Object.keys(rows[0]);
return '<table><tr>' + keys.map(k => `<th>${escapeHtml(k)}</th>`).join('')
+ '</tr>' + rows.map(r => '<tr>' + keys.map(k => `<td>${escapeHtml(r[k] ?? '')}</td>`).join('') + '</tr>').join('')
+ '</table>';
}
async function runCubeQuery(query) {
document.getElementById('query').textContent = JSON.stringify(query, null, 2);
try {
const t = performance.now();
const json = await engine.cubeQuery(JSON.stringify(query));
const rows = JSON.parse(json || '[]');
const ms = (performance.now() - t).toFixed(1);
resultEl.innerHTML = `<p>${rows.length} row(s) in ${ms}ms</p>` + renderTable(rows);
} catch (e) {
const err = document.createElement('div');
err.className = 'log err';
err.textContent = e.message || String(e);
resultEl.innerHTML = '';
resultEl.appendChild(err);
}
}
let engine = null;
const mdl = {
catalog: 'wren',
schema: 'public',
models: [{
name: 'orders',
tableReference: { table: 'orders' },
columns: [
{ name: 'id', type: 'INTEGER' },
{ name: 'customer', type: 'VARCHAR' },
{ name: 'status', type: 'VARCHAR' },
{ name: 'amount', type: 'DOUBLE' },
{ name: 'created_at', type: 'DATE' },
],
}],
relationships: [], views: [],
cubes: [{
name: 'order_metrics',
baseObject: 'orders',
measures: [
{ name: 'revenue', expression: 'SUM(amount)', type: 'DOUBLE' },
{ name: 'order_count', expression: 'COUNT(*)', type: 'BIGINT' },
// Derived measure inlined at query time.
{ name: 'avg_order', expression: 'revenue / order_count', type: 'DOUBLE' },
],
dimensions: [
{ name: 'status', expression: 'status', type: 'VARCHAR' },
{ name: 'customer', expression: 'customer', type: 'VARCHAR' },
],
timeDimensions: [
{ name: 'created_at', expression: 'created_at', type: 'DATE' },
],
hierarchies: { time_drill: ['created_at'] },
}],
};
try {
await init();
engine = new WrenEngine();
log('Engine ready');
await engine.registerJson('orders', JSON.stringify([
{ id: 1, customer: 'Alice', status: 'open', amount: 150, created_at: '2024-01-15' },
{ id: 2, customer: 'Bob', status: 'open', amount: 200, created_at: '2024-01-20' },
{ id: 3, customer: 'Alice', status: 'closed', amount: 300, created_at: '2024-02-10' },
{ id: 4, customer: 'Bob', status: 'open', amount: 100, created_at: '2024-02-15' },
{ id: 5, customer: 'Carol', status: 'cancelled', amount: 50, created_at: '2024-02-28' },
{ id: 6, customer: 'Alice', status: 'open', amount: 250, created_at: '2024-03-05' },
{ id: 7, customer: 'Carol', status: 'closed', amount: 175, created_at: '2024-03-12' },
]));
log('Registered "orders" table (7 rows)');
await engine.loadMDL(JSON.stringify(mdl), '');
log('MDL loaded with cube: order_metrics');
// Display the cube definition from the loaded MDL via listCubes().
const cubes = JSON.parse(engine.listCubes() || '[]');
document.getElementById('cube-def').textContent = JSON.stringify(cubes[0], null, 2);
document.getElementById('run').disabled = false;
document.getElementById('run-filtered').disabled = false;
document.getElementById('run-time').disabled = false;
// Default: revenue + order_count + avg_order grouped by status.
await runCubeQuery({
cube: 'order_metrics',
measures: ['revenue', 'order_count', 'avg_order'],
dimensions: ['status'],
});
} catch (e) {
log(`Init failed: ${e.message || e}`, false);
console.error(e);
}
document.getElementById('run').addEventListener('click', () => runCubeQuery({
cube: 'order_metrics',
measures: ['revenue', 'order_count', 'avg_order'],
dimensions: ['status'],
}));
document.getElementById('run-filtered').addEventListener('click', () => runCubeQuery({
cube: 'order_metrics',
measures: ['revenue', 'order_count'],
dimensions: ['customer'],
filters: [{ dimension: 'status', operator: 'eq', value: 'open' }],
}));
document.getElementById('run-time').addEventListener('click', () => runCubeQuery({
cube: 'order_metrics',
measures: ['revenue'],
timeDimensions: [{
dimension: 'created_at',
granularity: 'month',
dateRange: ['2024-01-01', '2024-04-01'],
}],
}));
</script>
</body>
</html>
@@ -0,0 +1,16 @@
id,customer,region,status,amount,created_at
1,Alice,NA,open,150.00,2024-01-15
2,Bob,EU,open,200.50,2024-01-20
3,Alice,NA,closed,300.75,2024-02-10
4,Bob,EU,open,100.00,2024-02-15
5,Carol,APAC,cancelled,50.25,2024-02-28
6,Alice,NA,open,250.00,2024-03-05
7,Carol,APAC,closed,175.50,2024-03-12
8,Dan,EU,open,425.00,2024-03-22
9,Dan,EU,cancelled,30.00,2024-04-02
10,Alice,NA,closed,600.00,2024-04-18
11,Carol,APAC,open,220.75,2024-05-03
12,Bob,EU,closed,380.25,2024-05-19
13,Alice,NA,open,95.00,2024-05-25
14,Dan,EU,open,310.50,2024-06-08
15,Carol,APAC,closed,475.00,2024-06-20
1 id customer region status amount created_at
2 1 Alice NA open 150.00 2024-01-15
3 2 Bob EU open 200.50 2024-01-20
4 3 Alice NA closed 300.75 2024-02-10
5 4 Bob EU open 100.00 2024-02-15
6 5 Carol APAC cancelled 50.25 2024-02-28
7 6 Alice NA open 250.00 2024-03-05
8 7 Carol APAC closed 175.50 2024-03-12
9 8 Dan EU open 425.00 2024-03-22
10 9 Dan EU cancelled 30.00 2024-04-02
11 10 Alice NA closed 600.00 2024-04-18
12 11 Carol APAC open 220.75 2024-05-03
13 12 Bob EU closed 380.25 2024-05-19
14 13 Alice NA open 95.00 2024-05-25
15 14 Dan EU open 310.50 2024-06-08
16 15 Carol APAC closed 475.00 2024-06-20
@@ -0,0 +1,9 @@
product_id name category unit_price in_stock
P001 Wren T-Shirt apparel 19.99 true
P002 DataFusion Mug merch 12.50 true
P003 Arrow Sticker Pack stationery 4.99 true
P004 Parquet Hoodie apparel 49.00 false
P005 SQL Sticker stationery 2.50 true
P006 Engineering Notebook stationery 14.95 true
P007 Wren Cap apparel 22.00 false
P008 Datafest Pin merch 6.75 true
1 product_id name category unit_price in_stock
2 P001 Wren T-Shirt apparel 19.99 true
3 P002 DataFusion Mug merch 12.50 true
4 P003 Arrow Sticker Pack stationery 4.99 true
5 P004 Parquet Hoodie apparel 49.00 false
6 P005 SQL Sticker stationery 2.50 true
7 P006 Engineering Notebook stationery 14.95 true
8 P007 Wren Cap apparel 22.00 false
9 P008 Datafest Pin merch 6.75 true
@@ -0,0 +1,6 @@
NA,Q1,1500
NA,Q2,1800
EU,Q1,1200
EU,Q2,1500
APAC,Q1,800
APAC,Q2,1100
1 NA Q1 1500
2 NA Q2 1800
3 EU Q1 1200
4 EU Q2 1500
5 APAC Q1 800
6 APAC Q2 1100
-1
View File
@@ -106,7 +106,6 @@
primaryKey: 'id',
}],
relationships: [],
metrics: [],
views: [],
};
document.getElementById('mdl').textContent = JSON.stringify(mdl, null, 2);
+8 -3
View File
@@ -22,6 +22,8 @@ const MIME = {
'.wasm': 'application/wasm',
'.json': 'application/json; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.tsv': 'text/tab-separated-values; charset=utf-8',
'.parquet': 'application/octet-stream',
};
@@ -72,7 +74,10 @@ createServer(async (req, res) => {
}
}).listen(PORT, () => {
console.log(`Serving on http://localhost:${PORT}`);
console.log(` inline demo: http://localhost:${PORT}/examples/inline.html`);
console.log(` url-mode demo: http://localhost:${PORT}/examples/url-mode.html`);
console.log(` cdn demo: http://localhost:${PORT}/examples/test-cdn.html`);
console.log(` inline demo: http://localhost:${PORT}/examples/inline.html`);
console.log(` url-mode demo: http://localhost:${PORT}/examples/url-mode.html`);
console.log(` cdn demo: http://localhost:${PORT}/examples/test-cdn.html`);
console.log(` cube quickstart: http://localhost:${PORT}/examples/cube-quickstart.html`);
console.log(` cube explorer: http://localhost:${PORT}/examples/cube-explorer.html`);
console.log(` csv quickstart: http://localhost:${PORT}/examples/csv-quickstart.html`);
});
@@ -96,7 +96,6 @@
primaryKey: 'id',
}],
relationships: [],
metrics: [],
views: [],
};
document.getElementById('mdl').textContent = JSON.stringify(mdl, null, 2);
@@ -106,7 +106,6 @@
primaryKey: 'o_orderkey',
}],
relationships: [],
metrics: [],
views: [],
};
document.getElementById('mdl').textContent = JSON.stringify(mdl, null, 2);
+211
View File
@@ -5,6 +5,148 @@ export interface WrenProfile {
source: string;
}
export type Granularity =
| "year"
| "quarter"
| "month"
| "week"
| "day"
| "hour"
| "minute";
export type FilterOperator =
| "eq"
| "neq"
| "in"
| "not_in"
| "gt"
| "gte"
| "lt"
| "lte"
| "contains"
| "starts_with"
| "is_null"
| "is_not_null";
export type FilterValue =
| string
| number
| boolean
| (string | number | boolean)[];
export interface TimeDimensionInput {
dimension: string;
granularity: Granularity;
/** Inclusive start, exclusive end. */
dateRange?: [string, string];
}
export interface CubeFilterInput {
dimension: string;
operator: FilterOperator;
/** Omit for `is_null`/`is_not_null`. Use an array for `in`/`not_in`. */
value?: FilterValue;
}
export interface CubeQueryInput {
cube: string;
measures: string[];
dimensions?: string[];
timeDimensions?: TimeDimensionInput[];
filters?: CubeFilterInput[];
limit?: number;
offset?: number;
}
export interface CubeMeasureInfo {
name: string;
expression: string;
type: string;
}
export interface CubeDimensionInfo {
name: string;
expression: string;
type: string;
}
export interface CubeInfo {
name: string;
baseObject: string;
measures: CubeMeasureInfo[];
dimensions: CubeDimensionInfo[];
timeDimensions: CubeDimensionInfo[];
hierarchies: Record<string, string[]>;
}
/** Subset of Arrow types accepted by {@link CsvReadOptions.schema}. */
export type CsvColumnType =
| "int8"
| "int16"
| "int32"
| "int64"
| "uint8"
| "uint16"
| "uint32"
| "uint64"
| "float32"
| "float64"
| "boolean"
| "string"
| "utf8"
| "varchar"
| "text"
| "date"
| "date32"
| "date64"
| "timestamp"
| "timestamp_s"
| "timestamp_ms"
| "timestamp_us"
| "timestamp_ns"
// Aliases accepted by the Rust side (case-insensitive).
| "int"
| "integer"
| "bigint"
| "long"
| "float"
| "double"
| "real"
| "number"
| "bool";
export interface CsvSchemaColumn {
name: string;
type: CsvColumnType | string;
/** Defaults to true. */
nullable?: boolean;
}
/**
* Optional configuration for {@link WrenEngine.registerCsv}. All fields are
* optional; omit a field to take the arrow-csv default. Single-character
* fields (delimiter/quote/escape/terminator) take only the first byte of the
* supplied string and must be ASCII.
*/
export interface CsvReadOptions {
/** First row is a header. Default: true. */
header?: boolean;
/** Field delimiter. Default: ",". */
delimiter?: string;
/** Quote character. Default: '"'. */
quote?: string;
/** Escape character. Default: unset (no escape). */
escape?: string;
/** Record terminator. Default: any of "\\n", "\\r\\n". */
terminator?: string;
/** RecordBatch size. Default: 8192. */
batchSize?: number;
/** Rows to read for schema inference. Default: 1000. Ignored when `schema` is supplied. */
inferRows?: number;
/** Explicit Arrow schema; when set, inference is skipped. */
schema?: CsvSchemaColumn[];
}
export interface WrenEngineOptions {
/**
* WASM binary source. Accepts:
@@ -77,6 +219,48 @@ export class WrenEngine {
await this.engine.registerJson(name, JSON.stringify(data));
}
/**
* Register CSV data as a named table.
* Call before loadMDL when using local mode.
*
* `data` may be a CSV string (treated as UTF-8) or any BufferSource —
* ArrayBuffer, TypedArray (e.g., `Uint8Array`), or Node.js Buffer.
*
* By default the first row is treated as a header and the column schema is
* inferred from the first 1000 rows. Pass {@link CsvReadOptions.schema} to
* skip inference.
*
* @example
* ```ts
* await engine.registerCsv("orders", "id,amount\n1,100\n2,200");
*
* await engine.registerCsv("metrics", csvBytes, {
* header: true,
* delimiter: ";",
* schema: [
* { name: "id", type: "int64" },
* { name: "amount", type: "float64" },
* ],
* });
* ```
*/
async registerCsv(
name: string,
data: string | BufferSource,
options?: CsvReadOptions,
): Promise<void> {
let bytes: Uint8Array;
if (typeof data === "string") {
bytes = new TextEncoder().encode(data);
} else if (data instanceof ArrayBuffer) {
bytes = new Uint8Array(data);
} else {
bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
const optionsJson = options ? JSON.stringify(options) : "";
await this.engine.registerCsv(name, bytes, optionsJson);
}
/**
* Execute SQL query through the semantic layer.
* Returns parsed result objects.
@@ -87,6 +271,33 @@ export class WrenEngine {
return JSON.parse(jsonStr);
}
/**
* Execute a structured cube query against the loaded MDL.
*
* Translates the CubeQuery to SQL via wren-core, then executes the SQL
* through the same path as `query()`. Requires `loadMDL` first.
*
* Prefer this over hand-written SQL for aggregation queries — the cube
* layer assembles `GROUP BY`, `DATE_TRUNC`, and `WHERE` clauses for you.
*/
async cubeQuery(query: CubeQueryInput): Promise<Record<string, unknown>[]> {
const jsonStr = await this.engine.cubeQuery(JSON.stringify(query));
if (!jsonStr) return [];
return JSON.parse(jsonStr);
}
/**
* List the cubes defined in the loaded MDL.
*
* Useful for an agent to discover what's queryable before calling
* `cubeQuery`. Requires `loadMDL` first.
*/
listCubes(): CubeInfo[] {
const jsonStr = this.engine.listCubes();
if (!jsonStr) return [];
return JSON.parse(jsonStr);
}
/** Release WASM memory. */
free(): void {
this.engine.free();
+7
View File
@@ -10,8 +10,15 @@ export class WrenEngine {
constructor();
registerJson(table_name: string, json_data: string): Promise<void>;
registerParquet(table_name: string, data: Uint8Array): Promise<void>;
registerCsv(
table_name: string,
data: Uint8Array,
options_json: string,
): Promise<void>;
loadMDL(mdl_json: string, source: string): Promise<void>;
query(sql: string): Promise<string>;
cubeQuery(cube_query_json: string): Promise<string>;
listCubes(): string;
}
export type InitInput =
+275 -3
View File
@@ -38,7 +38,6 @@ function minimalMDL(modelName, physicalTable) {
},
],
relationships: [],
metrics: [],
views: [],
};
}
@@ -55,6 +54,7 @@ describe("WrenEngine.init", () => {
assert.equal(typeof engine.loadMDL, "function");
assert.equal(typeof engine.registerJson, "function");
assert.equal(typeof engine.registerParquet, "function");
assert.equal(typeof engine.registerCsv, "function");
assert.equal(typeof engine.free, "function");
engine.free();
});
@@ -155,6 +155,102 @@ describe("registerJson + query", () => {
});
});
// =========================================================================
// registerCsv + query
// =========================================================================
describe("registerCsv + query", () => {
it("registers a CSV string with inferred schema", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await engine.registerCsv(
"orders",
"id,name,amount\n1,Alice,100.5\n2,Bob,200\n3,Carol,300.25\n",
);
const rows = await engine.query(
"SELECT count(*) AS cnt, sum(amount) AS total FROM orders",
);
assert.equal(rows.length, 1);
assert.equal(rows[0].cnt, 3);
assert.ok(Math.abs(rows[0].total - 600.75) < 1e-6);
engine.free();
});
it("accepts Uint8Array input", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
const bytes = new TextEncoder().encode("id,v\n1,10\n2,20\n");
await engine.registerCsv("t", bytes);
const rows = await engine.query("SELECT sum(v) AS total FROM t");
assert.equal(rows[0].total, 30);
engine.free();
});
it("supports custom delimiter and quote", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await engine.registerCsv(
"t",
"id;label;amount\n1;'hello;world';10\n2;'plain';20\n",
{ delimiter: ";", quote: "'" },
);
const rows = await engine.query("SELECT label FROM t WHERE id = 1");
assert.equal(rows[0].label, "hello;world");
engine.free();
});
it("supports header=false with an explicit schema", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await engine.registerCsv("t", "1,100\n2,200\n3,300\n", {
header: false,
schema: [
{ name: "id", type: "int64" },
{ name: "amount", type: "int64" },
],
});
const rows = await engine.query("SELECT sum(amount) AS total FROM t");
assert.equal(rows[0].total, 600);
engine.free();
});
it("rejects unknown schema column types", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await assert.rejects(
() =>
engine.registerCsv("t", "id\n1\n", {
schema: [{ name: "id", type: "bogus" }],
}),
/Unsupported CSV column type/,
);
engine.free();
});
it("rejects an empty CSV body", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await assert.rejects(
() => engine.registerCsv("t", "id,amount\n"),
/No data in CSV input/,
);
engine.free();
});
it("rejects non-ASCII delimiter", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await assert.rejects(
() =>
engine.registerCsv("t", "a,b\n1,2\n", {
delimiter: "",
}),
/single ASCII character/,
);
engine.free();
});
});
// =========================================================================
// loadMDL (semantic layer)
// =========================================================================
@@ -211,8 +307,7 @@ describe("loadMDL", () => {
},
],
relationships: [],
metrics: [],
views: [],
views: [],
};
await assert.rejects(
@@ -333,3 +428,180 @@ describe("free", () => {
// No assertion needed — just verify it doesn't throw
});
});
// =========================================================================
// cubeQuery + listCubes
// =========================================================================
function cubeMDL() {
return {
catalog: "wren",
schema: "public",
models: [
{
name: "orders",
tableReference: { table: "orders" },
columns: [
{ name: "amount", type: "DOUBLE" },
{ name: "status", type: "VARCHAR" },
{ name: "created_at", type: "DATE" },
],
},
],
relationships: [],
views: [],
cubes: [
{
name: "order_metrics",
baseObject: "orders",
measures: [
{ name: "total", expression: "SUM(amount)", type: "DOUBLE" },
{ name: "order_count", expression: "COUNT(*)", type: "BIGINT" },
],
dimensions: [
{ name: "status", expression: "status", type: "VARCHAR" },
],
timeDimensions: [
{ name: "created_at", expression: "created_at", type: "DATE" },
],
hierarchies: { time_drill: ["created_at"] },
},
],
};
}
describe("cubeQuery + listCubes", () => {
it("listCubes returns cubes from the loaded MDL", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await engine.registerJson("orders", [{ amount: 10, status: "open" }]);
await engine.loadMDL(cubeMDL(), { source: "" });
const cubes = engine.listCubes();
assert.equal(cubes.length, 1);
assert.equal(cubes[0].name, "order_metrics");
assert.equal(cubes[0].baseObject, "orders");
assert.equal(cubes[0].measures.length, 2);
assert.equal(cubes[0].measures[0].name, "total");
assert.equal(cubes[0].dimensions.length, 1);
assert.equal(cubes[0].dimensions[0].name, "status");
assert.equal(cubes[0].timeDimensions.length, 1);
assert.equal(cubes[0].timeDimensions[0].name, "created_at");
assert.deepEqual(cubes[0].hierarchies, { time_drill: ["created_at"] });
engine.free();
});
it("cubeQuery aggregates by dimension", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await engine.registerJson("orders", [
{ amount: 10, status: "open" },
{ amount: 25, status: "open" },
{ amount: 7, status: "closed" },
]);
await engine.loadMDL(cubeMDL(), { source: "" });
const rows = await engine.cubeQuery({
cube: "order_metrics",
measures: ["total", "order_count"],
dimensions: ["status"],
});
assert.equal(rows.length, 2);
const byStatus = Object.fromEntries(rows.map((r) => [r.status, r]));
assert.equal(byStatus.open.total, 35);
assert.equal(byStatus.open.order_count, 2);
assert.equal(byStatus.closed.total, 7);
assert.equal(byStatus.closed.order_count, 1);
engine.free();
});
it("cubeQuery rejects an unknown cube", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await engine.registerJson("orders", [{ amount: 10, status: "open" }]);
await engine.loadMDL(cubeMDL(), { source: "" });
await assert.rejects(
() => engine.cubeQuery({ cube: "nonexistent", measures: ["total"] }),
/not found/i,
);
engine.free();
});
it("cubeQuery without loadMDL fails clearly", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await assert.rejects(
() => engine.cubeQuery({ cube: "order_metrics", measures: ["total"] }),
/No MDL loaded/i,
);
engine.free();
});
it("listCubes without loadMDL fails clearly", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
assert.throws(
() => engine.listCubes(),
/No MDL loaded/i,
);
engine.free();
});
it("cubeQuery applies dimension filters", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await engine.registerJson("orders", [
{ amount: 10, status: "open", created_at: "2024-01-15" },
{ amount: 25, status: "open", created_at: "2024-02-20" },
{ amount: 7, status: "closed", created_at: "2024-01-05" },
{ amount: 5, status: "cancelled", created_at: "2024-02-10" },
]);
await engine.loadMDL(cubeMDL(), { source: "" });
const rows = await engine.cubeQuery({
cube: "order_metrics",
measures: ["total"],
dimensions: ["status"],
filters: [
{ dimension: "status", operator: "in", value: ["open", "closed"] },
],
});
assert.equal(rows.length, 2);
const byStatus = Object.fromEntries(rows.map((r) => [r.status, r.total]));
assert.equal(byStatus.open, 35);
assert.equal(byStatus.closed, 7);
assert.equal(byStatus.cancelled, undefined);
engine.free();
});
it("cubeQuery bucketizes a time dimension with date range", async () => {
const engine = await WrenEngine.init({ wasmUrl: wasmBytes });
await engine.registerJson("orders", [
{ amount: 10, status: "open", created_at: "2024-01-15" },
{ amount: 25, status: "open", created_at: "2024-01-20" },
{ amount: 7, status: "closed", created_at: "2024-02-05" },
// Outside the dateRange window — excluded.
{ amount: 1000, status: "open", created_at: "2025-01-15" },
]);
await engine.loadMDL(cubeMDL(), { source: "" });
const rows = await engine.cubeQuery({
cube: "order_metrics",
measures: ["total"],
timeDimensions: [
{
dimension: "created_at",
granularity: "month",
dateRange: ["2024-01-01", "2025-01-01"],
},
],
});
assert.equal(rows.length, 2);
// Buckets are exposed as `<dim>__<granularity>` columns.
const bucketCol = "created_at__month";
assert.ok(bucketCol in rows[0], `expected ${bucketCol} column in ${JSON.stringify(rows[0])}`);
const totals = Object.fromEntries(rows.map((r) => [r[bucketCol], r.total]));
// Two distinct months — Jan totals 35, Feb totals 7.
const values = Object.values(totals).sort((a, b) => a - b);
assert.deepEqual(values, [7, 35]);
engine.free();
});
});
+449 -8
View File
@@ -32,11 +32,14 @@ use wasm_bindgen::prelude::*;
/// Wren Engine WASM instance.
///
/// Holds a DataFusion SessionContext and (in M3+) an AnalyzedWrenMDL.
/// All query execution happens in-browser via DataFusion.
/// Holds a DataFusion SessionContext and (after `loadMDL`) the analyzed
/// MDL. `analyzed_mdl` is kept so the cube API (`cubeQuery`, `listCubes`)
/// can read the manifest after `loadMDL` returns. All query execution
/// happens in-browser via DataFusion.
#[wasm_bindgen]
pub struct WrenEngine {
ctx: datafusion::execution::context::SessionContext,
analyzed_mdl: Option<std::sync::Arc<wren_core::mdl::AnalyzedWrenMDL>>,
}
#[wasm_bindgen]
@@ -59,7 +62,10 @@ impl WrenEngine {
let ctx = datafusion::execution::context::SessionContext::new_with_config(config);
Ok(WrenEngine { ctx })
Ok(WrenEngine {
ctx,
analyzed_mdl: None,
})
}
/// Register an in-memory table from a JSON array of objects.
@@ -150,6 +156,109 @@ impl WrenEngine {
Ok(())
}
/// Register an in-memory table from CSV bytes.
///
/// CSV is read with `arrow::csv::ReaderBuilder`. Schema is inferred from
/// the first `inferRows` rows (default 1000) unless an explicit schema is
/// provided in `options.schema`.
///
/// # Arguments
/// * `table_name` - Name to register the table under
/// * `data` - CSV bytes
/// * `options_json` - Optional JSON-encoded `CsvReadOptions`. Empty / `""`
/// uses defaults (header on, comma delimiter, double-quote, batch 8192).
///
/// # Options shape (camelCase)
/// ```json
/// {
/// "header": true,
/// "delimiter": ",",
/// "quote": "\"",
/// "escape": "\\",
/// "terminator": "\n",
/// "batchSize": 8192,
/// "inferRows": 1000,
/// "schema": [
/// {"name": "id", "type": "int64"},
/// {"name": "amount", "type": "float64"}
/// ]
/// }
/// ```
/// All fields are optional. Single-character options (delimiter/quote/…)
/// take only the first byte of the supplied string.
#[wasm_bindgen(js_name = registerCsv)]
pub async fn register_csv(
&self,
table_name: &str,
data: &[u8],
options_json: &str,
) -> Result<(), JsError> {
use arrow::csv::reader::Format;
use arrow::csv::ReaderBuilder;
use datafusion::datasource::MemTable;
use std::io::Cursor;
use std::sync::Arc;
let opts: CsvReadOptions = if options_json.trim().is_empty() {
CsvReadOptions::default()
} else {
serde_json::from_str(options_json).map_err(|e| {
JsError::new(&format!("Invalid CSV options JSON: {e}"))
})?
};
let header = opts.header.unwrap_or(true);
let mut format = Format::default().with_header(header);
if let Some(c) = single_byte(&opts.delimiter, "delimiter")? {
format = format.with_delimiter(c);
}
if let Some(c) = single_byte(&opts.quote, "quote")? {
format = format.with_quote(c);
}
if let Some(c) = single_byte(&opts.escape, "escape")? {
format = format.with_escape(c);
}
if let Some(c) = single_byte(&opts.terminator, "terminator")? {
format = format.with_terminator(c);
}
let schema = if let Some(cols) = &opts.schema {
Arc::new(arrow_schema_from_columns(cols)?)
} else {
let infer_rows = opts.infer_rows.or(Some(1000));
let (inferred, _) = format
.infer_schema(Cursor::new(data), infer_rows)
.map_err(|e| JsError::new(&format!("Failed to infer CSV schema: {e}")))?;
Arc::new(inferred)
};
let mut builder = ReaderBuilder::new(Arc::clone(&schema)).with_format(format);
if let Some(n) = opts.batch_size {
builder = builder.with_batch_size(n);
}
let reader = builder
.build(Cursor::new(data))
.map_err(|e| JsError::new(&format!("Failed to build CSV reader: {e}")))?;
let batches: Vec<_> = reader
.collect::<Result<Vec<_>, _>>()
.map_err(|e| JsError::new(&format!("Failed to read CSV batches: {e}")))?;
if batches.is_empty() {
return Err(JsError::new("No data in CSV input"));
}
let table = MemTable::try_new(schema, vec![batches])
.map_err(|e| JsError::new(&format!("Failed to create table: {e}")))?;
self.ctx
.register_table(table_name, Arc::new(table))
.map_err(|e| JsError::new(&format!("Failed to register table: {e}")))?;
Ok(())
}
/// Load an MDL (Modeling Definition Language) manifest.
///
/// Parses the MDL JSON, builds the semantic layer (AnalyzedWrenMDL),
@@ -198,11 +307,19 @@ impl WrenEngine {
let properties: Arc<HashMap<String, Option<String>>> = Arc::new(HashMap::new());
let new_ctx = apply_wren_on_ctx(&self.ctx, analyzed_mdl, properties, Mode::LocalRuntime)
.await
.map_err(|e| JsError::new(&format!("Failed to apply MDL rules: {e}")))?;
// Clone the Arc so `apply_wren_on_ctx` can take ownership while
// we keep a handle on `self` for cubeQuery/listCubes access.
let new_ctx = apply_wren_on_ctx(
&self.ctx,
Arc::clone(&analyzed_mdl),
properties,
Mode::LocalRuntime,
)
.await
.map_err(|e| JsError::new(&format!("Failed to apply MDL rules: {e}")))?;
self.ctx = new_ctx;
self.analyzed_mdl = Some(analyzed_mdl);
Ok(())
}
@@ -484,6 +601,77 @@ impl WrenEngine {
String::from_utf8(buf).map_err(|e| JsError::new(&format!("UTF-8 encoding error: {e}")))
}
/// Execute a structured CubeQuery against the loaded MDL.
///
/// Takes a JSON-encoded `CubeQuery` (matching the camelCase shape used
/// by the Python binding), translates it to SQL via wren-core, and
/// runs the SQL through the existing `query()` path. Returns a JSON
/// array of result rows.
///
/// Requires `loadMDL` to have been called first.
#[wasm_bindgen(js_name = cubeQuery)]
pub async fn cube_query(&self, cube_query_json: &str) -> Result<String, JsError> {
let analyzed = self
.analyzed_mdl
.as_ref()
.ok_or_else(|| JsError::new("No MDL loaded. Call loadMDL() first."))?;
let wren_mdl = analyzed.wren_mdl();
let manifest = &wren_mdl.manifest;
let query: wren_core::mdl::CubeQuery = serde_json::from_str(cube_query_json)
.map_err(|e| JsError::new(&format!("Invalid CubeQuery JSON: {e}")))?;
let sql = wren_core::mdl::cube_query_to_sql(&query, manifest)
.map_err(|e| JsError::new(&format!("CubeQuery error: {e}")))?;
self.query(&sql).await
}
/// List the cubes defined in the loaded MDL.
///
/// Returns a JSON array of `{ name, baseObject, measures, dimensions,
/// timeDimensions, hierarchies }` records. Requires `loadMDL` to have
/// been called first.
#[wasm_bindgen(js_name = listCubes)]
pub fn list_cubes(&self) -> Result<String, JsError> {
let analyzed = self
.analyzed_mdl
.as_ref()
.ok_or_else(|| JsError::new("No MDL loaded. Call loadMDL() first."))?;
let wren_mdl = analyzed.wren_mdl();
let manifest = &wren_mdl.manifest;
let cubes: Vec<serde_json::Value> = manifest
.cubes
.iter()
.map(|c| {
serde_json::json!({
"name": c.name,
"baseObject": c.base_object,
"measures": c.measures.iter().map(|m| serde_json::json!({
"name": m.name,
"expression": m.expression,
"type": m.r#type,
})).collect::<Vec<_>>(),
"dimensions": c.dimensions.iter().map(|d| serde_json::json!({
"name": d.name,
"expression": d.expression,
"type": d.r#type,
})).collect::<Vec<_>>(),
"timeDimensions": c.time_dimensions.iter().map(|td| serde_json::json!({
"name": td.name,
"expression": td.expression,
"type": td.r#type,
})).collect::<Vec<_>>(),
"hierarchies": c.hierarchies,
})
})
.collect();
serde_json::to_string(&cubes)
.map_err(|e| JsError::new(&format!("Serialization error: {e}")))
}
}
/// Returns true if `source` starts with a URL scheme that `load_mdl`
@@ -560,6 +748,106 @@ impl Default for WrenEngine {
}
}
// ---------------------------------------------------------------------------
// register_csv supporting types
// ---------------------------------------------------------------------------
/// User-supplied CSV reader options, deserialized from JSON. All fields are
/// optional — omit a field to take the arrow-csv default.
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
struct CsvReadOptions {
header: Option<bool>,
delimiter: Option<String>,
quote: Option<String>,
escape: Option<String>,
terminator: Option<String>,
batch_size: Option<usize>,
infer_rows: Option<usize>,
schema: Option<Vec<CsvColumn>>,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CsvColumn {
name: String,
#[serde(rename = "type")]
ty: String,
#[serde(default)]
nullable: Option<bool>,
}
/// Single-character option helper: the arrow CSV reader takes a `u8`, but the
/// JS side passes a string. We accept any non-empty string and use its first
/// byte — single ASCII characters cover every realistic CSV separator. Reject
/// strings that start with a multi-byte UTF-8 char to avoid silently slicing
/// a codepoint.
fn single_byte(s: &Option<String>, field: &str) -> Result<Option<u8>, JsError> {
match s {
None => Ok(None),
Some(v) if v.is_empty() => Err(JsError::new(&format!(
"CSV option '{field}' must be a single character, got empty string"
))),
Some(v) => {
let bytes = v.as_bytes();
if bytes[0] >= 0x80 {
return Err(JsError::new(&format!(
"CSV option '{field}' must be a single ASCII character"
)));
}
Ok(Some(bytes[0]))
}
}
}
/// Build an Arrow `Schema` from the user-supplied `[{name, type}]` list.
/// Mirrors a small subset of Arrow types — enough for the common CSV column
/// shapes (numeric / string / boolean / date / timestamp).
fn arrow_schema_from_columns(cols: &[CsvColumn]) -> Result<arrow::datatypes::Schema, JsError> {
use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
if cols.is_empty() {
return Err(JsError::new("CSV schema must have at least one column"));
}
let fields = cols
.iter()
.map(|c| {
let dt = match c.ty.to_ascii_lowercase().as_str() {
"int8" => DataType::Int8,
"int16" => DataType::Int16,
"int32" | "int" | "integer" => DataType::Int32,
"int64" | "bigint" | "long" => DataType::Int64,
"uint8" => DataType::UInt8,
"uint16" => DataType::UInt16,
"uint32" => DataType::UInt32,
"uint64" => DataType::UInt64,
"float32" | "float" | "real" => DataType::Float32,
"float64" | "double" | "number" => DataType::Float64,
"boolean" | "bool" => DataType::Boolean,
"utf8" | "string" | "varchar" | "text" => DataType::Utf8,
"date" | "date32" => DataType::Date32,
"date64" => DataType::Date64,
"timestamp" | "timestamp_ns" => {
DataType::Timestamp(TimeUnit::Nanosecond, None)
}
"timestamp_us" => DataType::Timestamp(TimeUnit::Microsecond, None),
"timestamp_ms" => DataType::Timestamp(TimeUnit::Millisecond, None),
"timestamp_s" => DataType::Timestamp(TimeUnit::Second, None),
other => {
return Err(JsError::new(&format!(
"Unsupported CSV column type '{other}' (column '{}')",
c.name
)));
}
};
Ok(Field::new(&c.name, dt, c.nullable.unwrap_or(true)))
})
.collect::<Result<Vec<_>, JsError>>()?;
Ok(Schema::new(fields))
}
// =============================================================================
// Tests (run via wasm-bindgen-test in browser/node)
// =============================================================================
@@ -651,7 +939,6 @@ mod tests {
"primaryKey": "id"
}],
"relationships": [],
"metrics": [],
"views": []
})
.to_string()
@@ -755,7 +1042,6 @@ mod tests {
}
],
"relationships": [],
"metrics": [],
"views": []
})
.to_string();
@@ -776,4 +1062,159 @@ mod tests {
"expected 'lineitem' in error: {msg}"
);
}
// ── register_csv ────────────────────────────────────────────────────────
#[wasm_bindgen_test]
async fn test_register_csv_basic() {
let engine = WrenEngine::new().unwrap();
let csv = "id,name,amount\n1,Alice,100.5\n2,Bob,200\n3,Carol,300.25\n";
engine
.register_csv("orders", csv.as_bytes(), "")
.await
.unwrap();
let json = engine
.query("SELECT count(*) AS cnt, sum(amount) AS total FROM orders")
.await
.unwrap();
let rows: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
assert_eq!(rows[0]["cnt"], 3);
// Allow either float or int formatting for the total.
let total = rows[0]["total"].as_f64().unwrap_or_default();
assert!((total - 600.75).abs() < 1e-6, "total={total}");
}
#[wasm_bindgen_test]
async fn test_register_csv_custom_delimiter_and_quote() {
let engine = WrenEngine::new().unwrap();
let csv = "id;label;amount\n1;'hello;world';10\n2;'plain';20\n";
let options = r#"{"delimiter":";","quote":"'"}"#;
engine
.register_csv("t", csv.as_bytes(), options)
.await
.unwrap();
let json = engine
.query("SELECT label FROM t WHERE id = 1")
.await
.unwrap();
let rows: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
assert_eq!(rows[0]["label"], "hello;world");
}
#[wasm_bindgen_test]
async fn test_register_csv_no_header_with_schema() {
let engine = WrenEngine::new().unwrap();
let csv = "1,100\n2,200\n3,300\n";
let options = r#"{
"header": false,
"schema": [
{"name": "id", "type": "int64"},
{"name": "amount", "type": "int64"}
]
}"#;
engine
.register_csv("t", csv.as_bytes(), options)
.await
.unwrap();
let json = engine
.query("SELECT sum(amount) AS total FROM t")
.await
.unwrap();
let rows: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
assert_eq!(rows[0]["total"], 600);
}
#[wasm_bindgen_test]
async fn test_register_csv_rejects_unknown_type() {
let engine = WrenEngine::new().unwrap();
let csv = "id\n1\n";
let options = r#"{"schema":[{"name":"id","type":"bogus"}]}"#;
let err = engine
.register_csv("t", csv.as_bytes(), options)
.await
.unwrap_err();
let msg = js_sys::Error::from(JsValue::from(err))
.message()
.as_string()
.unwrap_or_default();
assert!(msg.contains("Unsupported CSV column type"), "msg={msg}");
}
#[wasm_bindgen_test]
async fn test_register_csv_rejects_invalid_options_json() {
let engine = WrenEngine::new().unwrap();
let err = engine
.register_csv("t", b"a\n1\n", "{not json")
.await
.unwrap_err();
let msg = js_sys::Error::from(JsValue::from(err))
.message()
.as_string()
.unwrap_or_default();
assert!(msg.contains("Invalid CSV options JSON"), "msg={msg}");
}
#[wasm_bindgen_test]
async fn test_register_csv_rejects_empty_body() {
// Header-only CSV — schema infers, but the body has no rows.
let engine = WrenEngine::new().unwrap();
let err = engine
.register_csv("t", b"id,amount\n", "")
.await
.unwrap_err();
let msg = js_sys::Error::from(JsValue::from(err))
.message()
.as_string()
.unwrap_or_default();
assert!(msg.contains("No data in CSV input"), "msg={msg}");
}
#[wasm_bindgen_test]
async fn test_register_csv_rejects_multibyte_delimiter() {
let engine = WrenEngine::new().unwrap();
let csv = "a,b\n1,2\n";
let options = r#"{"delimiter":""}"#;
let err = engine
.register_csv("t", csv.as_bytes(), options)
.await
.unwrap_err();
let msg = js_sys::Error::from(JsValue::from(err))
.message()
.as_string()
.unwrap_or_default();
assert!(msg.contains("single ASCII character"), "msg={msg}");
}
#[test]
fn test_arrow_schema_from_columns_maps_aliases() {
use arrow::datatypes::DataType;
let cols = vec![
CsvColumn {
name: "a".into(),
ty: "int".into(),
nullable: None,
},
CsvColumn {
name: "b".into(),
ty: "DOUBLE".into(),
nullable: Some(false),
},
CsvColumn {
name: "c".into(),
ty: "string".into(),
nullable: None,
},
];
let schema = arrow_schema_from_columns(&cols).unwrap();
assert_eq!(schema.field(0).data_type(), &DataType::Int32);
assert_eq!(schema.field(1).data_type(), &DataType::Float64);
assert!(!schema.field(1).is_nullable());
assert_eq!(schema.field(2).data_type(), &DataType::Utf8);
}
}
File diff suppressed because it is too large Load Diff
+233 -3
View File
@@ -10,7 +10,7 @@ use petgraph::Graph;
use crate::logical_plan::utils::from_qualified_name;
use crate::mdl::{utils, WrenMDL};
use super::manifest::{JoinType, Relationship};
use super::manifest::{Cube, JoinType, Relationship};
use super::utils::{
collect_identifiers, qualify_name_from_column_name, quoted, to_expr_queue,
};
@@ -299,6 +299,76 @@ impl Display for DatasetLink {
}
}
/// Validate every cube in the manifest:
/// 1. `base_object` must point to an existing Model or View.
/// 2. Derived-measure expressions must not form a cycle.
/// 3. Every level in each hierarchy must reference a defined dimension or time_dimension.
pub(super) fn validate_cubes(mdl: &WrenMDL) -> Result<()> {
for cube in mdl.manifest.cubes.iter() {
if mdl.get_model(&cube.base_object).is_none()
&& mdl.get_view(&cube.base_object).is_none()
{
return plan_err!(
"Cube '{}': baseObject '{}' is not a defined Model or View",
cube.name,
cube.base_object
);
}
validate_measure_cycles(cube)?;
let all_dim_names: HashSet<&str> = cube
.dimensions
.iter()
.map(|d| d.name.as_str())
.chain(cube.time_dimensions.iter().map(|td| td.name.as_str()))
.collect();
for (hierarchy_name, levels) in &cube.hierarchies {
for level in levels {
if !all_dim_names.contains(level.as_str()) {
return plan_err!(
"Cube '{}': hierarchy '{}' references unknown dimension '{}'",
cube.name,
hierarchy_name,
level
);
}
}
}
}
Ok(())
}
fn validate_measure_cycles(cube: &Cube) -> Result<()> {
let mut graph: Graph<&str, ()> = Graph::new();
let mut node_map: HashMap<&str, _> = HashMap::new();
for measure in cube.measures.iter() {
let idx = graph.add_node(measure.name.as_str());
node_map.insert(measure.name.as_str(), idx);
}
let measure_names: HashSet<&str> = node_map.keys().copied().collect();
for measure in cube.measures.iter() {
let identifiers = collect_identifiers(&measure.expression)?;
for ident in &identifiers {
if measure_names.contains(ident.name.as_str()) {
let from = node_map[measure.name.as_str()];
let to = node_map[ident.name.as_str()];
graph.add_edge(from, to, ());
}
}
}
if !utils::is_dag(&graph) {
return plan_err!(
"Cube '{}': circular dependency detected in measure expressions",
cube.name
);
}
Ok(())
}
fn get_dataset_link_revers_if_need(
source: Dataset,
rs: Arc<Relationship>,
@@ -320,13 +390,17 @@ mod test {
use datafusion::common::{Column, Spans};
use datafusion::error::Result;
use datafusion::sql::TableReference;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::mdl::builder::{
ColumnBuilder, ManifestBuilder, ModelBuilder, RelationshipBuilder,
ColumnBuilder, CubeBuilder, CubeDimensionBuilder, ManifestBuilder,
MeasureBuilder, ModelBuilder, RelationshipBuilder, TimeDimensionBuilder,
};
use crate::mdl::context::Mode;
use crate::mdl::lineage::Lineage;
use crate::mdl::manifest::JoinType;
use crate::mdl::AnalyzedWrenMDL;
use crate::mdl::Dataset;
use crate::mdl::WrenMDL;
@@ -742,4 +816,160 @@ mod test {
.column(ColumnBuilder::new("b1", "varchar").build())
.primary_key("id")
}
#[test]
fn test_validate_cube_valid() {
let manifest = ManifestBuilder::new()
.model(
ModelBuilder::new("orders")
.table_reference("orders")
.column(ColumnBuilder::new("o_totalprice", "double").build())
.column(ColumnBuilder::new("o_orderstatus", "varchar").build())
.column(ColumnBuilder::new("o_orderdate", "date").build())
.build(),
)
.cube(
CubeBuilder::new("order_metrics", "orders")
.measure(
MeasureBuilder::new("revenue", "SUM(o_totalprice)", "DOUBLE")
.build(),
)
.dimension(
CubeDimensionBuilder::new("status", "o_orderstatus", "VARCHAR")
.build(),
)
.time_dimension(
TimeDimensionBuilder::new("created_at", "o_orderdate", "DATE")
.build(),
)
.hierarchy("time_drill", vec!["created_at"])
.build(),
)
.build();
if let Err(err) = AnalyzedWrenMDL::analyze(
manifest,
Arc::new(HashMap::default()),
Mode::Unparse,
) {
panic!("expected Ok, got error: {err}");
}
}
#[test]
fn test_validate_cube_bad_base_object() {
let manifest = ManifestBuilder::new()
.cube(
CubeBuilder::new("bad_cube", "nonexistent_model")
.measure(MeasureBuilder::new("count", "COUNT(*)", "BIGINT").build())
.build(),
)
.build();
let result = AnalyzedWrenMDL::analyze(
manifest,
Arc::new(HashMap::default()),
Mode::Unparse,
);
let Err(err) = result else {
panic!("expected error for unknown baseObject");
};
assert!(
err.to_string().contains("not a defined Model or View"),
"unexpected error: {err}"
);
}
#[test]
fn test_validate_cube_measure_cycle() {
let manifest = ManifestBuilder::new()
.model(
ModelBuilder::new("orders")
.table_reference("orders")
.column(ColumnBuilder::new("amount", "double").build())
.build(),
)
.cube(
CubeBuilder::new("cycle_cube", "orders")
.measure(MeasureBuilder::new("a", "b + 1", "DOUBLE").build())
.measure(MeasureBuilder::new("b", "a + 1", "DOUBLE").build())
.build(),
)
.build();
let result = AnalyzedWrenMDL::analyze(
manifest,
Arc::new(HashMap::default()),
Mode::Unparse,
);
let Err(err) = result else {
panic!("expected error for measure cycle");
};
assert!(
err.to_string().contains("circular dependency"),
"unexpected error: {err}"
);
}
#[test]
fn test_validate_cube_measure_self_reference() {
let manifest = ManifestBuilder::new()
.model(
ModelBuilder::new("orders")
.table_reference("orders")
.column(ColumnBuilder::new("amount", "double").build())
.build(),
)
.cube(
CubeBuilder::new("self_ref_cube", "orders")
.measure(
MeasureBuilder::new("revenue", "revenue * 1.1", "DOUBLE").build(),
)
.build(),
)
.build();
let result = AnalyzedWrenMDL::analyze(
manifest,
Arc::new(HashMap::default()),
Mode::Unparse,
);
let Err(err) = result else {
panic!("expected error for self-referential measure");
};
assert!(
err.to_string().contains("circular dependency"),
"unexpected error: {err}"
);
}
#[test]
fn test_validate_cube_bad_hierarchy() {
let manifest = ManifestBuilder::new()
.model(
ModelBuilder::new("orders")
.table_reference("orders")
.column(ColumnBuilder::new("status_col", "varchar").build())
.build(),
)
.cube(
CubeBuilder::new("bad_hier", "orders")
.measure(MeasureBuilder::new("count", "COUNT(*)", "BIGINT").build())
.dimension(
CubeDimensionBuilder::new("status", "status_col", "VARCHAR")
.build(),
)
.hierarchy("drill", vec!["status", "nonexistent_dim"])
.build(),
)
.build();
let result = AnalyzedWrenMDL::analyze(
manifest,
Arc::new(HashMap::default()),
Mode::Unparse,
);
let Err(err) = result else {
panic!("expected error for bad hierarchy");
};
assert!(
err.to_string().contains("unknown dimension"),
"unexpected error: {err}"
);
}
}
+5
View File
@@ -38,6 +38,8 @@ pub mod builder {
pub use wren_core_base::mdl::builder::*;
}
pub mod context;
pub(crate) mod cube;
pub use cube::{cube_query_to_sql, CubeQuery};
pub(crate) mod dataset;
mod dialect;
pub mod function;
@@ -82,6 +84,7 @@ impl AnalyzedWrenMDL {
let wren_mdl = Arc::new(WrenMDL::infer_and_register_remote_table(
manifest, properties, mode,
)?);
lineage::validate_cubes(&wren_mdl)?;
let lineage = Arc::new(lineage::Lineage::new(&wren_mdl)?);
Ok(AnalyzedWrenMDL { wren_mdl, lineage })
}
@@ -94,6 +97,7 @@ impl AnalyzedWrenMDL {
for (name, table) in register_tables {
wren_mdl.register_table(name, table);
}
lineage::validate_cubes(&wren_mdl)?;
let lineage = lineage::Lineage::new(&wren_mdl)?;
Ok(AnalyzedWrenMDL {
wren_mdl: Arc::new(wren_mdl),
@@ -159,6 +163,7 @@ impl AnalyzedWrenMDL {
wren_mdl.register_table(table_reference.to_string(), Arc::new(table));
}
lineage::validate_cubes(&wren_mdl)?;
let lineage = lineage::Lineage::new(&wren_mdl)?;
Ok(AnalyzedWrenMDL {
wren_mdl: Arc::new(wren_mdl),
+144 -63
View File
@@ -120,6 +120,75 @@
},
"required": ["name", "required"],
"additionalProperties": false
},
"measure": {
"description": "A measure within a Cube — a named aggregation expression.",
"type": "object",
"properties": {
"name": {
"description": "the name of the measure",
"type": "string",
"minLength": 1
},
"expression": {
"description": "the SQL expression of the measure (typically an aggregation such as SUM, COUNT, AVG)",
"type": "string",
"minLength": 1
},
"type": {
"description": "the data type of the measure result",
"type": "string",
"minLength": 1
}
},
"required": ["name", "expression", "type"],
"additionalProperties": false
},
"cubeDimension": {
"description": "A grouping attribute within a Cube.",
"type": "object",
"properties": {
"name": {
"description": "the name of the dimension",
"type": "string",
"minLength": 1
},
"expression": {
"description": "the SQL expression of the dimension (typically a column reference)",
"type": "string",
"minLength": 1
},
"type": {
"description": "the data type of the dimension",
"type": "string",
"minLength": 1
}
},
"required": ["name", "expression", "type"],
"additionalProperties": false
},
"timeDimension": {
"description": "A time-based dimension within a Cube. Granularity is applied at query time via `wren cube query`, not declared here.",
"type": "object",
"properties": {
"name": {
"description": "the name of the time dimension",
"type": "string",
"minLength": 1
},
"expression": {
"description": "the SQL expression of the time dimension (typically a date/timestamp column reference)",
"type": "string",
"minLength": 1
},
"type": {
"description": "the data type of the time dimension (DATE, TIMESTAMP, …)",
"type": "string",
"minLength": 1
}
},
"required": ["name", "expression", "type"],
"additionalProperties": false
}
},
"type": "object",
@@ -129,6 +198,11 @@
"type": "string",
"const": "https://raw.githubusercontent.com/Canner/WrenAI/main/wren-mdl/mdl.schema.json"
},
"layoutVersion": {
"description": "the MDL wire-format layout version (default 1)",
"type": "integer",
"minimum": 1
},
"catalog": {
"description": "the catalog name of WrenMDL",
"type": "string",
@@ -145,10 +219,50 @@
"minLength": 1
},
"dataSource": {
"description": "the data source type (case insensitive). Valid values are: BIGQUERY, CLICKHOUSE, CANNER, TRINO, MSSQL, MYSQL, POSTGRES, SNOWFLAKE, DUCKDB, LOCAL_FILE, S3_FILE, GCS_FILE, MINIO_FILE, ORACLE, ATHENA, REDSHIFT",
"description": "the data source type. Canonical form is UPPERCASE; Rust serde also accepts lowercase aliases (and the wren CLI emits lowercase by default).",
"type": "string",
"pattern": "^(?:[Bb][Ii][Gg][Qq][Uu][Ee][Rr][Yy]|[Cc][Ll][Ii][Cc][Kk][Hh][Oo][Uu][Ss][Ee]|[Cc][Aa][Nn][Nn][Ee][Rr]|[Tt][Rr][Ii][Nn][Oo]|[Mm][Ss][Ss][Qq][Ll]|[Mm][Yy][Ss][Qq][Ll]|[Pp][Oo][Ss][Tt][Gg][Rr][Ee][Ss]|[Ss][Nn][Oo][Ww][Ff][Ll][Aa][Kk][Ee]|[Dd][Uu][Cc][Kk][Dd][Bb]|[Ll][Oo][Cc][Aa][Ll]_[Ff][Ii][Ll][Ee]|[Ss]3_[Ff][Ii][Ll][Ee]|[Gg][Cc][Ss]_[Ff][Ii][Ll][Ee]|[Mm][Ii][Nn][Ii][Oo]_[Ff][Ii][Ll][Ee]|[Oo][Rr][Aa][Cc][Ll][Ee]|[Aa][Tt][Hh][Ee][Nn][Aa]|[Rr][Ee][Dd][Ss][Hh][Ii][Ff][Tt])$",
"minLength": 1
"enum": [
"BIGQUERY",
"CLICKHOUSE",
"CANNER",
"TRINO",
"MSSQL",
"MYSQL",
"DORIS",
"POSTGRES",
"SNOWFLAKE",
"DATAFUSION",
"DUCKDB",
"LOCAL_FILE",
"S3_FILE",
"GCS_FILE",
"MINIO_FILE",
"ORACLE",
"ATHENA",
"REDSHIFT",
"DATABRICKS",
"SPARK",
"bigquery",
"clickhouse",
"canner",
"trino",
"mssql",
"mysql",
"doris",
"postgres",
"snowflake",
"datafusion",
"duckdb",
"local_file",
"s3_file",
"gcs_file",
"minio_file",
"oracle",
"athena",
"redshift",
"databricks",
"spark"
]
},
"models": {
"description": "the list of models",
@@ -295,94 +409,61 @@
"required": ["name", "models", "joinType", "condition"]
}
},
"metrics": {
"description": "(WIP) the list of metrics",
"cubes": {
"description": "the list of cubes (pre-aggregation semantic objects on top of a Model or View)",
"type": "array",
"unevaluatedItems": false,
"items": {
"description": "(WIP) the metric",
"description": "a cube — measures + dimensions + time dimensions + optional hierarchies over a baseObject",
"type": "object",
"properties": {
"name": {
"description": "the name of the metric",
"description": "the name of the cube",
"type": "string",
"minLength": 1
},
"baseObject": {
"description": "the base object of the metric",
"description": "the base object of the cube (must reference a defined Model or View)",
"type": "string",
"minLength": 1
},
"dimension": {
"description": "the list of dimensions",
"measures": {
"description": "the list of measures (aggregation expressions)",
"type": "array",
"items": {
"$ref": "#/$defs/column"
}
},
"measure": {
"description": "the list of measures",
"type": "array",
"items": {
"$ref": "#/$defs/column"
"$ref": "#/$defs/measure"
},
"minItems": 1
},
"timeGrain": {
"description": "the time grain fields of the metric",
"dimensions": {
"description": "the list of dimensions (grouping attributes)",
"type": "array",
"unevaluatedItems": false,
"items": {
"description": "the time grain field. It's should belong to the dimension fields",
"type": "object",
"properties": {
"name": {
"description": "the name of the time grain field",
"type": "string",
"minLength": 1
},
"refColumn": {
"description": "the reference column name of the time grain field",
"type": "string",
"minLength": 1
},
"dateParts": {
"description": "the acceptable time units of the time grain field",
"type": "array",
"items": {
"type": "string",
"enum": [
"YEAR",
"QUARTER",
"MONTH",
"WEEK",
"DAY",
"HOUR",
"MINUTE",
"SECOND"
]
}
}
},
"required": ["name", "refColumn", "dateParts"]
"$ref": "#/$defs/cubeDimension"
}
},
"cached": {
"type": "boolean"
"timeDimensions": {
"description": "the list of time-based dimensions; granularity is applied at query time",
"type": "array",
"items": {
"$ref": "#/$defs/timeDimension"
}
},
"refreshTime": {
"type": "string",
"description": "the cache refresh time of the metric",
"pattern": "^\\s*(\\d+(?:\\.\\d+)?)\\s*([a-zA-Z]+)\\s*$"
},
"properties": {
"hierarchies": {
"description": "drill-down paths — map of hierarchy name → ordered list of dimension or timeDimension names (coarsest to finest)",
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean", "object", "array", "null"]
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
}
}
},
"required": ["name", "baseObject", "dimension", "measure"]
"required": ["name", "baseObject", "measures"],
"additionalProperties": false
}
},
"views": {
+15
View File
@@ -104,6 +104,21 @@ wren --sql 'SELECT order_id FROM "orders" LIMIT 10'
For the full CLI reference and per-datasource connection field reference, see [`docs/cli.md`](docs/cli.md) and [`docs/connections.md`](docs/connections.md).
**4a. (Optional) Aggregation queries with cubes** — define cubes under `cubes/`,
then query them with a structured input instead of writing `GROUP BY` SQL by hand:
```bash
wren cube list
wren cube describe order_metrics
wren cube query --cube order_metrics --measures revenue --time-dimension "created_at:month"
```
The translator produces `DATE_TRUNC` / `GROUP BY` / `WHERE` clauses for you and
runs them through the same engine path as `wren --sql`. See the
[Cube guide](../../docs/core/guides/modeling/cube.md) for full YAML structure
and the [CLI reference](../../docs/core/reference/cli.md#wren-cube--pre-aggregation-queries) for all
flags.
**5. (Optional) Configure security policy** — create `~/.wren/config.json`:
```json
+2
View File
@@ -633,9 +633,11 @@ def docs_connection_info(
app.add_typer(docs_app)
from wren.cube_cli import cube_app # noqa: E402, PLC0415
from wren.utils_cli import utils_app # noqa: E402, PLC0415
app.add_typer(context_app)
app.add_typer(cube_app)
app.add_typer(utils_app)
try:
+109
View File
@@ -584,6 +584,25 @@ def _load_views_v2(project_path: Path) -> list[dict]:
return views
def load_cubes(project_path: Path) -> list[dict]:
"""Load cubes from project_path/cubes/*.yml.
Each file is one cube definition. Files use the same YAML shape on
every schema version (no v1/v2 dispatch cubes were added after the
directory-per-entity migration).
"""
cubes_dir = project_path / "cubes"
if not cubes_dir.is_dir():
return []
cubes = []
for f in sorted(cubes_dir.glob("*.yml")):
data = yaml.safe_load(f.read_text())
if isinstance(data, dict):
data["_source_file"] = f.name
cubes.append(data)
return cubes
def load_relationships(project_path: Path) -> list[dict]:
"""Load relationships from project_path/relationships.yml."""
rel_file = project_path / "relationships.yml"
@@ -615,12 +634,15 @@ def build_manifest(project_path: Path) -> dict:
models = load_models(project_path)
views = load_views(project_path)
relationships = load_relationships(project_path)
cubes = load_cubes(project_path)
# Strip internal metadata
for m in models:
m.pop("_source_dir", None)
for v in views:
v.pop("_source_dir", None)
for c in cubes:
c.pop("_source_file", None)
manifest: dict = {
"catalog": project_config.get("catalog", "wren"),
@@ -628,6 +650,7 @@ def build_manifest(project_path: Path) -> dict:
"models": models,
"relationships": relationships,
"views": views,
"cubes": cubes,
}
data_source = project_config.get("data_source")
if data_source:
@@ -731,6 +754,7 @@ def validate_project(project_path: Path) -> list[ValidationError]:
models = load_models(project_path)
views = load_views(project_path)
relationships = load_relationships(project_path)
cubes = load_cubes(project_path)
model_names: set[str] = set()
view_names: set[str] = set()
@@ -947,6 +971,91 @@ def validate_project(project_path: Path) -> list[ValidationError]:
)
)
# Check cubes — only structural / reference checks here. Deep validation
# (measure cycles, hierarchy levels) runs Rust-side in
# AnalyzedWrenMDL::analyze (see wren-core lineage::validate_cubes).
cube_names: set[str] = set()
for i, cube in enumerate(cubes):
src = cube.get("_source_file", f"cubes[{i}]")
src_path = f"cubes/{src}"
name = cube.get("name")
if not name:
errors.append(ValidationError("error", src_path, "cube missing 'name'"))
continue
if name in cube_names:
errors.append(
ValidationError("error", src_path, f"duplicate cube name '{name}'")
)
cube_names.add(name)
base = cube.get("base_object")
if not base:
errors.append(
ValidationError(
"error",
f"{src_path} > {name}",
"cube missing 'base_object'",
)
)
elif base not in all_entity_names:
errors.append(
ValidationError(
"error",
f"{src_path} > {name}",
f"base_object '{base}' is not a defined model or view",
)
)
measures = cube.get("measures") or []
if not measures:
errors.append(
ValidationError(
"warning",
f"{src_path} > {name}",
"cube has no measures",
)
)
# Sanity-check hierarchy levels reference declared dimensions /
# time_dimensions. Rust-side validation does the same check, but
# surfacing it at YAML time gives faster feedback before build.
# Only keep string names so a malformed YAML entry doesn't leak a
# non-hashable value into the set lookup below.
dim_names = {
d.get("name")
for d in (cube.get("dimensions") or [])
if isinstance(d, dict) and isinstance(d.get("name"), str)
}
td_names = {
td.get("name")
for td in (cube.get("time_dimensions") or [])
if isinstance(td, dict) and isinstance(td.get("name"), str)
}
known_dims = dim_names | td_names
hierarchies = cube.get("hierarchies") or {}
if isinstance(hierarchies, dict):
for hname, levels in hierarchies.items():
if not isinstance(levels, list):
continue
for level in levels:
if not isinstance(level, str):
errors.append(
ValidationError(
"error",
f"{src_path} > {name} > hierarchies.{hname}",
"hierarchy levels must be strings",
)
)
continue
if level not in known_dims:
errors.append(
ValidationError(
"error",
f"{src_path} > {name} > hierarchies.{hname}",
f"references unknown dimension '{level}'",
)
)
return errors
+1
View File
@@ -107,6 +107,7 @@ def init(
# Create directory structure
(project_path / "models").mkdir(parents=True, exist_ok=True)
(project_path / "views").mkdir(parents=True, exist_ok=True)
(project_path / "cubes").mkdir(parents=True, exist_ok=True)
# wren_project.yml
project_yml = (
+317
View File
@@ -0,0 +1,317 @@
"""Typer sub-app for ``wren cube`` commands."""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Annotated, Optional
import typer
cube_app = typer.Typer(
name="cube",
help="Query and inspect cubes — structured measure/dimension queries over the semantic layer.",
)
_MdlOpt = Annotated[
Optional[str],
typer.Option(
"--mdl",
"-m",
help="Path to MDL JSON file. Defaults to <project>/target/mdl.json.",
),
]
def _load_mdl_json(mdl: str | None) -> str:
"""Return the raw JSON contents of mdl.json (decoded if a path is given)."""
from wren.cli import _require_mdl # noqa: PLC0415
path_str = _require_mdl(mdl)
path = Path(path_str).expanduser()
if path.exists():
return path.read_text()
typer.echo(f"Error: MDL file not found: {path}", err=True)
raise typer.Exit(1)
def _parse_filter(spec: str) -> dict:
"""Parse a CLI ``--filter`` spec.
Format::
dimension:operator # e.g. status:is_null
dimension:operator:value # e.g. status:eq:completed
dimension:in:a,b,c # IN filter, comma-separated values
Values are kept as strings; numeric operators rely on the engine to
coerce. ``in`` / ``not_in`` always produce a list value.
"""
parts = spec.split(":", 2)
if len(parts) < 2:
raise typer.BadParameter(f"--filter expects 'dim:op[:value]', got '{spec}'")
dim, op = parts[0], parts[1]
f: dict = {"dimension": dim, "operator": op}
if len(parts) == 3:
raw = parts[2]
if op in {"in", "not_in"}:
values = [v.strip() for v in raw.split(",") if v.strip()]
if not values:
raise typer.BadParameter(
f"--filter with {op} requires at least one value, got '{spec}'"
)
f["value"] = values
else:
f["value"] = raw
elif op in {"in", "not_in"}:
raise typer.BadParameter(
f"--filter with {op} expects 'dim:{op}:value1,value2,…', got '{spec}'"
)
return f
def _parse_time_dimension(spec: str) -> dict:
"""Parse a ``--time-dimension`` spec ``name:granularity[:start,end]``."""
parts = spec.split(":", 2)
if len(parts) < 2:
raise typer.BadParameter(
f"--time-dimension expects 'name:granularity[:start,end]', got '{spec}'"
)
td: dict = {"dimension": parts[0], "granularity": parts[1]}
if len(parts) == 3:
dates = [d.strip() for d in parts[2].split(",")]
if len(dates) != 2:
raise typer.BadParameter(
"--time-dimension dateRange must be 'start,end' (exactly two dates)"
)
td["dateRange"] = dates
return td
def _build_cube_query(
cube: str,
measures: str,
dimensions: str,
time_dimension: str | None,
filters: list[str],
limit: int | None,
offset: int | None,
) -> dict:
q: dict = {
"cube": cube,
"measures": [m.strip() for m in measures.split(",") if m.strip()],
}
if dimensions:
q["dimensions"] = [d.strip() for d in dimensions.split(",") if d.strip()]
if time_dimension:
q["timeDimensions"] = [_parse_time_dimension(time_dimension)]
if filters:
q["filters"] = [_parse_filter(f) for f in filters]
if limit is not None:
q["limit"] = limit
if offset is not None:
q["offset"] = offset
return q
def _load_cube_query_from(source: str) -> dict:
"""Load a CubeQuery dict from ``-`` (stdin) or a JSON file path."""
if source == "-":
raw = sys.stdin.read()
label = "stdin"
else:
p = Path(source).expanduser()
if not p.exists():
typer.echo(f"Error: CubeQuery file not found: {p}", err=True)
raise typer.Exit(1)
raw = p.read_text()
label = str(p)
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
typer.echo(f"Error: invalid JSON in {label}: {e}", err=True)
raise typer.Exit(1) from e
if not isinstance(data, dict):
typer.echo(f"Error: CubeQuery in {label} must be a JSON object.", err=True)
raise typer.Exit(1)
return data
def _load_manifest_dict(mdl: str | None) -> dict:
"""Read mdl.json, parse, surface clean errors on bad JSON / non-object."""
mdl_json = _load_mdl_json(mdl)
try:
manifest = json.loads(mdl_json)
except json.JSONDecodeError as e:
typer.echo(f"Error: invalid MDL JSON: {e}", err=True)
raise typer.Exit(1) from e
if not isinstance(manifest, dict):
typer.echo("Error: MDL JSON must be an object.", err=True)
raise typer.Exit(1)
return manifest
# ── wren cube list ─────────────────────────────────────────────────────────
@cube_app.command(name="list")
def list_cubes(mdl: _MdlOpt = None) -> None:
"""List all cubes defined in the project."""
manifest = _load_manifest_dict(mdl)
cubes = manifest.get("cubes", []) or []
if not cubes:
typer.echo("No cubes defined.")
return
for cube in cubes:
name = cube.get("name", "<unnamed>")
base = cube.get("baseObject", "?")
measures = ", ".join(m.get("name", "") for m in cube.get("measures", []))
dims = ", ".join(d.get("name", "") for d in cube.get("dimensions", []))
time_dims = ", ".join(
td.get("name", "") for td in cube.get("timeDimensions", [])
)
typer.echo(f" {name} (base: {base})")
if measures:
typer.echo(f" measures: {measures}")
if dims:
typer.echo(f" dimensions: {dims}")
if time_dims:
typer.echo(f" time dimensions: {time_dims}")
# ── wren cube describe ─────────────────────────────────────────────────────
@cube_app.command()
def describe(
name: Annotated[str, typer.Argument(help="Cube name to describe")],
mdl: _MdlOpt = None,
) -> None:
"""Print the full schema for a cube (JSON)."""
manifest = _load_manifest_dict(mdl)
cubes = manifest.get("cubes", []) or []
cube = next((c for c in cubes if c.get("name") == name), None)
if cube is None:
typer.echo(f"Cube '{name}' not found.", err=True)
raise typer.Exit(1)
typer.echo(json.dumps(cube, indent=2))
# ── wren cube query ────────────────────────────────────────────────────────
@cube_app.command()
def query(
cube: Annotated[
Optional[str],
typer.Option("--cube", "-c", help="Cube name"),
] = None,
measures: Annotated[
Optional[str],
typer.Option("--measures", help="Comma-separated measure names"),
] = None,
dimensions: Annotated[
Optional[str],
typer.Option("--dimensions", help="Comma-separated dimension names"),
] = None,
time_dimension: Annotated[
Optional[str],
typer.Option(
"--time-dimension",
help="Format: name:granularity[:start,end]",
),
] = None,
filter_: Annotated[
Optional[list[str]],
typer.Option(
"--filter",
help=(
"Repeatable. Format: dim:op[:value]. "
"For 'in'/'not_in', value is comma-separated."
),
),
] = None,
limit: Annotated[
Optional[int], typer.Option("--limit", "-l", help="Max rows to return")
] = None,
offset: Annotated[
Optional[int], typer.Option("--offset", help="Skip N rows")
] = None,
from_json: Annotated[
Optional[str],
typer.Option(
"--from",
help="Load CubeQuery from a JSON file (or '-' for stdin).",
),
] = None,
sql_only: Annotated[
bool,
typer.Option(
"--sql-only",
help="Print the generated SQL and exit without executing.",
),
] = False,
mdl: _MdlOpt = None,
connection_info: Annotated[
Optional[str],
typer.Option("--connection-info", help="Inline JSON connection string"),
] = None,
connection_file: Annotated[
Optional[str],
typer.Option("--connection-file", help="Path to JSON connection file"),
] = None,
output: Annotated[
str, typer.Option("--output", "-o", help="Output format: json|csv|table")
] = "table",
) -> None:
"""Execute a structured cube query.
Build the query from CLI flags (``--cube`` / ``--measures`` / ) or load
it from JSON via ``--from <file|->``. The CubeQuery is translated to
SQL by wren-core, then executed through WrenEngine just like a regular
``wren query``.
"""
if from_json:
cube_query = _load_cube_query_from(from_json)
else:
if not cube or not measures:
typer.echo(
"Error: --cube and --measures are required (or use --from).",
err=True,
)
raise typer.Exit(1)
cube_query = _build_cube_query(
cube,
measures,
dimensions or "",
time_dimension,
filter_ or [],
limit,
offset,
)
mdl_json = _load_mdl_json(mdl)
from wren_core import cube_query_to_sql # noqa: PLC0415
try:
sql = cube_query_to_sql(json.dumps(cube_query), mdl_json)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1) from e
if sql_only:
typer.echo(sql)
return
from wren.cli import _build_engine, _print_result # noqa: PLC0415
with _build_engine(mdl, connection_info, connection_file) as engine:
try:
result = engine.query(sql)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1) from e
_print_result(result, output)
+188
View File
@@ -58,6 +58,12 @@ def describe_schema(manifest: dict) -> str:
for view in manifest.get("views", []):
_describe_view(view, lines)
cubes = manifest.get("cubes", []) or []
if isinstance(cubes, list):
for cube in cubes:
if isinstance(cube, dict):
_describe_cube(cube, lines)
return "\n".join(lines)
@@ -119,6 +125,61 @@ def _describe_relationship(rel: dict, lines: list[str]) -> None:
lines.append("")
def _describe_cube(cube: dict, lines: list[str]) -> None:
name = cube.get("name", "")
base = cube.get("baseObject", "?")
lines.append(f"### Cube: {name} (base: {base})")
measures = [m for m in (cube.get("measures") or []) if isinstance(m, dict)]
if measures:
lines.append(" Measures:")
for m in measures:
mname = m.get("name", "")
expr = m.get("expression", "")
mtype = m.get("type", "")
line = f" - {mname}"
if mtype:
line += f" ({mtype})"
if expr:
line += f": {expr}"
lines.append(line)
dims = [d for d in (cube.get("dimensions") or []) if isinstance(d, dict)]
if dims:
lines.append(" Dimensions:")
for d in dims:
dname = d.get("name", "")
expr = d.get("expression", "")
dtype = d.get("type", "")
line = f" - {dname}"
if dtype:
line += f" ({dtype})"
if expr and expr != dname:
line += f": {expr}"
lines.append(line)
tdims = [td for td in (cube.get("timeDimensions") or []) if isinstance(td, dict)]
if tdims:
lines.append(" Time dimensions:")
for td in tdims:
tname = td.get("name", "")
expr = td.get("expression", "")
ttype = td.get("type", "")
line = f" - {tname}"
if ttype:
line += f" ({ttype})"
if expr and expr != tname:
line += f": {expr}"
lines.append(line)
hierarchies = cube.get("hierarchies") or {}
if isinstance(hierarchies, dict) and hierarchies:
lines.append(" Hierarchies:")
for hname, levels in hierarchies.items():
if not isinstance(levels, list):
continue
safe = [lv for lv in levels if isinstance(lv, str)]
if safe:
lines.append(f" - {hname}: {''.join(safe)}")
lines.append("")
def _describe_view(view: dict, lines: list[str]) -> None:
name = view["name"]
stmt = view.get("statement", "")
@@ -150,6 +211,23 @@ def extract_schema_items(manifest: dict) -> list[dict]:
for view in manifest.get("views", []):
items.append(_view_record(view, mdl_h, now))
cubes = manifest.get("cubes", []) or []
if isinstance(cubes, list):
for cube in cubes:
if not isinstance(cube, dict):
continue
items.append(_cube_record(cube, mdl_h, now))
cube_name = cube.get("name", "")
for measure in cube.get("measures", []) or []:
if isinstance(measure, dict):
items.append(_measure_record(measure, cube_name, mdl_h, now))
for dim in cube.get("dimensions", []) or []:
if isinstance(dim, dict):
items.append(_cube_dimension_record(dim, cube_name, mdl_h, now))
for tdim in cube.get("timeDimensions", []) or []:
if isinstance(tdim, dict):
items.append(_time_dimension_record(tdim, cube_name, mdl_h, now))
return items
@@ -260,6 +338,116 @@ def _view_record(view: dict, mdl_h: str, now: datetime) -> dict:
}
def _cube_record(cube: dict, mdl_h: str, now: datetime) -> dict:
name = cube.get("name", "")
base = cube.get("baseObject", "?")
measures = ", ".join(
m.get("name", "") for m in (cube.get("measures") or []) if isinstance(m, dict)
)
dims = ", ".join(
d.get("name", "") for d in (cube.get("dimensions") or []) if isinstance(d, dict)
)
time_dims = ", ".join(
td.get("name", "")
for td in (cube.get("timeDimensions") or [])
if isinstance(td, dict)
)
parts = [f"Cube '{name}' over '{base}'"]
if measures:
parts.append(f". Measures: {measures}")
if dims:
parts.append(f". Dimensions: {dims}")
if time_dims:
parts.append(f". Time dimensions: {time_dims}")
text = "".join(parts) + "."
return {
"text": text,
"item_type": "cube",
"model_name": base,
"item_name": name,
"data_type": None,
"expression": None,
"is_calculated": False,
"mdl_hash": mdl_h,
"indexed_at": now,
}
def _measure_record(measure: dict, cube_name: str, mdl_h: str, now: datetime) -> dict:
name = measure.get("name", "")
expr = measure.get("expression") or None
dtype = measure.get("type") or None
text = f"Measure '{name}' in cube '{cube_name}'"
if dtype:
text += f" ({dtype})"
if expr:
text += f". Expression: {expr}"
text += "."
return {
"text": text,
"item_type": "measure",
"model_name": cube_name,
"item_name": name,
"data_type": dtype,
"expression": expr,
"is_calculated": True,
"mdl_hash": mdl_h,
"indexed_at": now,
}
def _cube_dimension_record(
dim: dict, cube_name: str, mdl_h: str, now: datetime
) -> dict:
name = dim.get("name", "")
expr = dim.get("expression") or None
dtype = dim.get("type") or None
text = f"Dimension '{name}' in cube '{cube_name}'"
if dtype:
text += f" ({dtype})"
if expr:
text += f". Expression: {expr}"
text += "."
return {
"text": text,
"item_type": "cube_dimension",
"model_name": cube_name,
"item_name": name,
"data_type": dtype,
"expression": expr,
"is_calculated": False,
"mdl_hash": mdl_h,
"indexed_at": now,
}
def _time_dimension_record(
tdim: dict, cube_name: str, mdl_h: str, now: datetime
) -> dict:
name = tdim.get("name", "")
expr = tdim.get("expression") or None
dtype = tdim.get("type") or None
text = f"Time dimension '{name}' in cube '{cube_name}'"
if dtype:
text += f" ({dtype})"
if expr:
text += f". Expression: {expr}"
text += "."
return {
"text": text,
"item_type": "time_dimension",
"model_name": cube_name,
"item_name": name,
"data_type": dtype,
"expression": expr,
"is_calculated": False,
"mdl_hash": mdl_h,
"indexed_at": now,
}
def _prop_description(obj: dict) -> str:
"""Extract description from the ``properties`` dict, if present."""
props = obj.get("properties") or {}
+167
View File
@@ -17,6 +17,7 @@ from wren.context import (
convert_mdl_to_project,
discover_project_path,
get_schema_version,
load_cubes,
load_instructions,
load_models,
load_relationships,
@@ -770,6 +771,172 @@ def test_validate_view_dialect_unknown(tmp_path):
assert any("unknown dialect" in e.message for e in errors)
# ── Cubes ───────────────────────────────────────────────────────────────────
def _make_v3_cube_project(tmp_path: Path) -> Path:
"""v3 project with an orders model, ready for cubes/*.yml files."""
(tmp_path / "wren_project.yml").write_text(
"schema_version: 3\nname: test\ndata_source: postgres\ncatalog: wren\nschema: public\n"
)
d = tmp_path / "models" / "orders"
d.mkdir(parents=True)
(d / "metadata.yml").write_text(
"name: orders\n"
"table_reference:\n table: orders\n"
"columns:\n"
" - name: o_totalprice\n type: double\n"
" - name: o_orderstatus\n type: varchar\n"
)
return tmp_path
def test_load_cubes_returns_empty_when_no_dir(tmp_path):
assert load_cubes(tmp_path) == []
def test_load_cubes_parses_yaml(tmp_path):
_make_v3_cube_project(tmp_path)
cubes_dir = tmp_path / "cubes"
cubes_dir.mkdir()
(cubes_dir / "order_metrics.yml").write_text(
"name: order_metrics\n"
"base_object: orders\n"
"measures:\n"
" - name: revenue\n expression: SUM(o_totalprice)\n type: DOUBLE\n"
"dimensions:\n"
" - name: status\n expression: o_orderstatus\n type: VARCHAR\n"
)
cubes = load_cubes(tmp_path)
assert len(cubes) == 1
assert cubes[0]["name"] == "order_metrics"
assert cubes[0]["base_object"] == "orders"
assert cubes[0]["measures"][0]["name"] == "revenue"
def test_build_manifest_includes_cubes(tmp_path):
_make_v3_cube_project(tmp_path)
cubes_dir = tmp_path / "cubes"
cubes_dir.mkdir()
(cubes_dir / "order_metrics.yml").write_text(
"name: order_metrics\n"
"base_object: orders\n"
"measures:\n"
" - name: revenue\n expression: SUM(o_totalprice)\n type: DOUBLE\n"
)
manifest = build_manifest(tmp_path)
assert "cubes" in manifest
assert manifest["cubes"][0]["name"] == "order_metrics"
assert "_source_file" not in manifest["cubes"][0]
def test_build_json_cube_camel_case(tmp_path):
_make_v3_cube_project(tmp_path)
cubes_dir = tmp_path / "cubes"
cubes_dir.mkdir()
(cubes_dir / "order_metrics.yml").write_text(
"name: order_metrics\n"
"base_object: orders\n"
"measures:\n"
" - name: revenue\n expression: SUM(o_totalprice)\n type: DOUBLE\n"
"time_dimensions:\n"
" - name: created_at\n expression: o_orderdate\n type: DATE\n"
)
result = build_json(tmp_path)
cube = result["cubes"][0]
assert cube["baseObject"] == "orders"
assert cube["timeDimensions"][0]["name"] == "created_at"
def test_validate_cube_unknown_base_object(tmp_path):
_make_v3_cube_project(tmp_path)
cubes_dir = tmp_path / "cubes"
cubes_dir.mkdir()
(cubes_dir / "bad.yml").write_text(
"name: bad\nbase_object: nosuch\nmeasures: [{name: c, expression: 'COUNT(*)', type: BIGINT}]\n"
)
errors = validate_project(tmp_path)
assert any("base_object 'nosuch'" in e.message for e in errors)
def test_validate_cube_duplicate_name(tmp_path):
_make_v3_cube_project(tmp_path)
cubes_dir = tmp_path / "cubes"
cubes_dir.mkdir()
body = (
"name: order_metrics\nbase_object: orders\n"
"measures: [{name: c, expression: 'COUNT(*)', type: BIGINT}]\n"
)
(cubes_dir / "a.yml").write_text(body)
(cubes_dir / "b.yml").write_text(body)
errors = validate_project(tmp_path)
assert any("duplicate cube name" in e.message for e in errors)
def test_validate_cube_missing_base_object_uses_snake_case(tmp_path):
"""Validation error should reference the YAML field name (snake_case)."""
_make_v3_cube_project(tmp_path)
cubes_dir = tmp_path / "cubes"
cubes_dir.mkdir()
(cubes_dir / "om.yml").write_text(
"name: order_metrics\n"
"measures: [{name: c, expression: 'COUNT(*)', type: BIGINT}]\n"
)
errors = validate_project(tmp_path)
assert any("'base_object'" in e.message for e in errors)
assert not any("baseObject" in e.message for e in errors)
def test_validate_cube_non_string_hierarchy_level(tmp_path):
"""Non-string hierarchy levels must be reported, not crash."""
_make_v3_cube_project(tmp_path)
cubes_dir = tmp_path / "cubes"
cubes_dir.mkdir()
(cubes_dir / "om.yml").write_text(
"name: order_metrics\n"
"base_object: orders\n"
"measures: [{name: c, expression: 'COUNT(*)', type: BIGINT}]\n"
"dimensions: [{name: status, expression: o_orderstatus, type: VARCHAR}]\n"
"hierarchies:\n"
" drill:\n"
" - status\n"
" - [nested, list]\n"
)
errors = validate_project(tmp_path)
assert any("hierarchy levels must be strings" in e.message for e in errors)
def test_validate_cube_bad_hierarchy(tmp_path):
_make_v3_cube_project(tmp_path)
cubes_dir = tmp_path / "cubes"
cubes_dir.mkdir()
(cubes_dir / "om.yml").write_text(
"name: order_metrics\n"
"base_object: orders\n"
"measures: [{name: c, expression: 'COUNT(*)', type: BIGINT}]\n"
"dimensions: [{name: status, expression: o_orderstatus, type: VARCHAR}]\n"
"hierarchies:\n"
" drill: [status, nonexistent_dim]\n"
)
errors = validate_project(tmp_path)
assert any("nonexistent_dim" in e.message for e in errors)
def test_validate_cube_ok(tmp_path):
_make_v3_cube_project(tmp_path)
cubes_dir = tmp_path / "cubes"
cubes_dir.mkdir()
(cubes_dir / "om.yml").write_text(
"name: order_metrics\n"
"base_object: orders\n"
"measures: [{name: c, expression: 'COUNT(*)', type: BIGINT}]\n"
"dimensions: [{name: status, expression: o_orderstatus, type: VARCHAR}]\n"
)
errors = validate_project(tmp_path)
# No cube-specific errors.
assert not any("cube" in e.message.lower() for e in errors)
# ── Upgrade ──────────────────────────────────────────────────────────────────
+337
View File
@@ -0,0 +1,337 @@
"""Unit tests for the `wren cube` CLI sub-app."""
from __future__ import annotations
import json
from pathlib import Path
from typer.testing import CliRunner
from wren.cli import app
runner = CliRunner()
# ── Helpers ────────────────────────────────────────────────────────────────
def _make_mdl(tmp_path: Path) -> Path:
"""Write a minimal target/mdl.json with one model + one cube."""
target = tmp_path / "target"
target.mkdir(parents=True)
mdl = {
"catalog": "wren",
"schema": "public",
"models": [
{
"name": "orders",
"tableReference": {"schema": "main", "table": "orders"},
"columns": [
{"name": "o_totalprice", "type": "double"},
{"name": "o_orderstatus", "type": "varchar"},
],
}
],
"cubes": [
{
"name": "order_metrics",
"baseObject": "orders",
"measures": [
{
"name": "revenue",
"expression": "SUM(o_totalprice)",
"type": "DOUBLE",
},
{
"name": "order_count",
"expression": "COUNT(*)",
"type": "BIGINT",
},
],
"dimensions": [
{
"name": "status",
"expression": "o_orderstatus",
"type": "VARCHAR",
}
],
}
],
}
out = target / "mdl.json"
out.write_text(json.dumps(mdl))
return out
# ── list ────────────────────────────────────────────────────────────────────
def test_cube_list(tmp_path):
mdl = _make_mdl(tmp_path)
result = runner.invoke(app, ["cube", "list", "--mdl", str(mdl)])
assert result.exit_code == 0, result.output
assert "order_metrics" in result.output
assert "base: orders" in result.output
assert "revenue" in result.output
assert "status" in result.output
def test_cube_list_empty(tmp_path):
target = tmp_path / "target"
target.mkdir(parents=True)
mdl_file = target / "mdl.json"
mdl_file.write_text(json.dumps({"catalog": "c", "schema": "s", "cubes": []}))
result = runner.invoke(app, ["cube", "list", "--mdl", str(mdl_file)])
assert result.exit_code == 0
assert "No cubes defined" in result.output
# ── describe ────────────────────────────────────────────────────────────────
def test_cube_describe(tmp_path):
mdl = _make_mdl(tmp_path)
result = runner.invoke(
app, ["cube", "describe", "order_metrics", "--mdl", str(mdl)]
)
assert result.exit_code == 0, result.output
schema = json.loads(result.output)
assert schema["name"] == "order_metrics"
assert schema["baseObject"] == "orders"
assert len(schema["measures"]) == 2
def test_cube_describe_unknown(tmp_path):
mdl = _make_mdl(tmp_path)
result = runner.invoke(app, ["cube", "describe", "nosuch", "--mdl", str(mdl)])
assert result.exit_code == 1
assert "not found" in result.output
# ── query --sql-only ────────────────────────────────────────────────────────
def test_cube_query_sql_only(tmp_path):
mdl = _make_mdl(tmp_path)
result = runner.invoke(
app,
[
"cube",
"query",
"--cube",
"order_metrics",
"--measures",
"revenue",
"--dimensions",
"status",
"--sql-only",
"--mdl",
str(mdl),
],
)
assert result.exit_code == 0, result.output
assert "SUM(o_totalprice) AS revenue" in result.output
assert "o_orderstatus AS status" in result.output
assert "FROM orders" in result.output
assert "GROUP BY" in result.output
def test_cube_query_sql_only_filter(tmp_path):
mdl = _make_mdl(tmp_path)
result = runner.invoke(
app,
[
"cube",
"query",
"--cube",
"order_metrics",
"--measures",
"revenue",
"--filter",
"status:eq:completed",
"--sql-only",
"--mdl",
str(mdl),
],
)
assert result.exit_code == 0, result.output
assert "WHERE o_orderstatus = 'completed'" in result.output
def test_cube_query_sql_only_in_filter(tmp_path):
mdl = _make_mdl(tmp_path)
result = runner.invoke(
app,
[
"cube",
"query",
"--cube",
"order_metrics",
"--measures",
"revenue",
"--filter",
"status:in:a,b,c",
"--sql-only",
"--mdl",
str(mdl),
],
)
assert result.exit_code == 0, result.output
assert "o_orderstatus IN ('a', 'b', 'c')" in result.output
def test_cube_query_from_json_file(tmp_path):
mdl = _make_mdl(tmp_path)
qfile = tmp_path / "q.json"
qfile.write_text(
json.dumps(
{
"cube": "order_metrics",
"measures": ["revenue", "order_count"],
"limit": 10,
}
)
)
result = runner.invoke(
app,
[
"cube",
"query",
"--from",
str(qfile),
"--sql-only",
"--mdl",
str(mdl),
],
)
assert result.exit_code == 0, result.output
assert "SUM(o_totalprice) AS revenue" in result.output
assert "COUNT(*) AS order_count" in result.output
assert result.output.rstrip().endswith("LIMIT 10")
def test_cube_query_unknown_cube(tmp_path):
mdl = _make_mdl(tmp_path)
result = runner.invoke(
app,
[
"cube",
"query",
"--cube",
"nosuch",
"--measures",
"revenue",
"--sql-only",
"--mdl",
str(mdl),
],
)
assert result.exit_code == 1
assert "not found" in result.output
def test_cube_query_in_filter_requires_values(tmp_path):
"""`status:in:` (empty) should be a clean CLI error, not silent empty IN()."""
mdl = _make_mdl(tmp_path)
result = runner.invoke(
app,
[
"cube",
"query",
"--cube",
"order_metrics",
"--measures",
"revenue",
"--filter",
"status:in:",
"--sql-only",
"--mdl",
str(mdl),
],
)
assert result.exit_code != 0
assert "in" in result.output.lower() and "value" in result.output.lower()
def test_cube_query_in_filter_missing_value(tmp_path):
"""`status:in` (no value segment) should also be rejected."""
mdl = _make_mdl(tmp_path)
result = runner.invoke(
app,
[
"cube",
"query",
"--cube",
"order_metrics",
"--measures",
"revenue",
"--filter",
"status:in",
"--sql-only",
"--mdl",
str(mdl),
],
)
assert result.exit_code != 0
assert "in" in result.output.lower() and "value" in result.output.lower()
def test_cube_query_invalid_from_json(tmp_path):
"""Malformed JSON file should produce a clean CLI error, not a traceback."""
mdl = _make_mdl(tmp_path)
bad = tmp_path / "bad.json"
bad.write_text("{not valid json")
result = runner.invoke(
app,
[
"cube",
"query",
"--from",
str(bad),
"--sql-only",
"--mdl",
str(mdl),
],
)
assert result.exit_code == 1
assert "invalid JSON" in result.output
def test_cube_query_from_json_not_object(tmp_path):
mdl = _make_mdl(tmp_path)
bad = tmp_path / "list.json"
bad.write_text('["not", "an", "object"]')
result = runner.invoke(
app,
[
"cube",
"query",
"--from",
str(bad),
"--sql-only",
"--mdl",
str(mdl),
],
)
assert result.exit_code == 1
assert "must be a JSON object" in result.output
def test_cube_list_bad_mdl_json(tmp_path):
target = tmp_path / "target"
target.mkdir(parents=True)
bad_mdl = target / "mdl.json"
bad_mdl.write_text("not json at all")
result = runner.invoke(app, ["cube", "list", "--mdl", str(bad_mdl)])
assert result.exit_code == 1
assert "invalid MDL JSON" in result.output
def test_cube_query_missing_required(tmp_path):
mdl = _make_mdl(tmp_path)
result = runner.invoke(
app,
["cube", "query", "--measures", "revenue", "--mdl", str(mdl)],
)
assert result.exit_code == 1
assert "required" in result.output.lower()
+133
View File
@@ -162,6 +162,139 @@ class TestExtractSchemaItems:
assert items[0]["item_type"] == "model"
# ── Cube fixture ──────────────────────────────────────────────────────────
_CUBE_MANIFEST = {
"catalog": "test",
"schema": "public",
"models": [
{
"name": "orders",
"tableReference": "test.public.orders",
"columns": [
{"name": "o_totalprice", "type": "double", "isCalculated": False},
{"name": "o_orderstatus", "type": "varchar", "isCalculated": False},
{"name": "o_orderdate", "type": "date", "isCalculated": False},
],
}
],
"cubes": [
{
"name": "order_metrics",
"baseObject": "orders",
"measures": [
{
"name": "revenue",
"expression": "SUM(o_totalprice)",
"type": "DOUBLE",
},
{"name": "order_count", "expression": "COUNT(*)", "type": "BIGINT"},
],
"dimensions": [
{"name": "status", "expression": "o_orderstatus", "type": "VARCHAR"}
],
"timeDimensions": [
{"name": "created_at", "expression": "o_orderdate", "type": "DATE"}
],
"hierarchies": {"time_drill": ["created_at"]},
}
],
}
@pytest.mark.unit
class TestCubeSchemaItems:
def test_cube_record(self):
items = extract_schema_items(_CUBE_MANIFEST)
cubes = [i for i in items if i["item_type"] == "cube"]
assert len(cubes) == 1
cube = cubes[0]
assert cube["item_name"] == "order_metrics"
assert cube["model_name"] == "orders"
assert "revenue" in cube["text"]
assert "status" in cube["text"]
assert "created_at" in cube["text"]
def test_measure_records(self):
items = extract_schema_items(_CUBE_MANIFEST)
measures = [i for i in items if i["item_type"] == "measure"]
assert len(measures) == 2
revenue = next(m for m in measures if m["item_name"] == "revenue")
assert revenue["expression"] == "SUM(o_totalprice)"
assert revenue["model_name"] == "order_metrics"
assert revenue["is_calculated"] is True
def test_cube_dimension_record(self):
items = extract_schema_items(_CUBE_MANIFEST)
dims = [i for i in items if i["item_type"] == "cube_dimension"]
assert len(dims) == 1
assert dims[0]["item_name"] == "status"
assert dims[0]["expression"] == "o_orderstatus"
def test_time_dimension_record(self):
items = extract_schema_items(_CUBE_MANIFEST)
tdims = [i for i in items if i["item_type"] == "time_dimension"]
assert len(tdims) == 1
assert tdims[0]["item_name"] == "created_at"
assert tdims[0]["expression"] == "o_orderdate"
def test_total_count(self):
items = extract_schema_items(_CUBE_MANIFEST)
# 1 model + 3 columns + 1 cube + 2 measures + 1 dim + 1 time-dim = 9
assert len(items) == 9
def test_describe_schema_includes_cube(self):
text = describe_schema(_CUBE_MANIFEST)
assert "### Cube: order_metrics (base: orders)" in text
assert "revenue (DOUBLE): SUM(o_totalprice)" in text
assert "status (VARCHAR): o_orderstatus" in text
assert "created_at (DATE): o_orderdate" in text
assert "time_drill: created_at" in text
def test_extract_skips_cubes_when_absent(self):
# The original _MANIFEST has no cubes — confirm nothing leaks in.
items = extract_schema_items(_MANIFEST)
cube_types = {"cube", "measure", "cube_dimension", "time_dimension"}
assert not any(i["item_type"] in cube_types for i in items)
def test_malformed_cube_entries_are_skipped(self):
"""Non-dict cubes / measures / dimensions / non-string hierarchy levels
must not raise the indexer should drop the bad entries and keep
going so semantic memory rebuilds never fail on dirty manifests."""
manifest = {
"cubes": [
"not a dict",
{
"name": "ok_cube",
"baseObject": "orders",
"measures": [
"not a dict",
{"name": "revenue", "expression": "SUM(x)", "type": "DOUBLE"},
],
"dimensions": [None, {"name": "status", "expression": "s"}],
"timeDimensions": [{"name": "ts", "expression": "ts"}, 42],
"hierarchies": {"drill": ["status", ["nested"], None]},
},
]
}
items = extract_schema_items(manifest)
names = {(i["item_type"], i["item_name"]) for i in items}
assert ("cube", "ok_cube") in names
assert ("measure", "revenue") in names
assert ("cube_dimension", "status") in names
assert ("time_dimension", "ts") in names
# Bad entries are silently dropped, not promoted to records.
assert sum(1 for i in items if i["item_type"] == "measure") == 1
assert sum(1 for i in items if i["item_type"] == "cube_dimension") == 1
assert sum(1 for i in items if i["item_type"] == "time_dimension") == 1
# describe_schema also tolerates the same shape.
text = describe_schema(manifest)
assert "### Cube: ok_cube" in text
# Non-string hierarchy levels are filtered out of the printed line.
assert "drill: status" in text
# ── describe_schema tests ─────────────────────────────────────────────────
+21
View File
@@ -260,6 +260,27 @@ The more you ask, the smarter the system gets — each stored query improves fut
---
## Step 8 — Query a cube (optional)
If your MDL defines cubes, use the cube CLI for aggregation queries — agents
don't have to hand-write `GROUP BY` / `DATE_TRUNC` SQL:
```bash
wren cube list
wren cube query \
--cube order_metrics \
--measures revenue \
--time-dimension "created_at:month"
```
Cube queries are the recommended path for aggregation when a cube covers the
question. Lower error rate, especially on small / local models. See the
[Cube guide](../guides/modeling/cube.md) for the YAML structure and the
[CLI reference](../reference/cli.md#wren-cube--pre-aggregation-queries) for all flags.
---
## What's in the project
After setup, your project directory looks like this:
+188
View File
@@ -0,0 +1,188 @@
# Cube
A **Cube** is a pre-aggregation semantic layer object that defines reusable
aggregations on top of a Model or View. Clients send a structured `CubeQuery`
(measures, dimensions, optional time bucket + filters); the engine produces
`SELECT … GROUP BY` SQL and runs it through the same path as `wren --sql`.
## When to use
Define a cube when you want to:
- run aggregation queries (`SUM`, `COUNT`, `AVG`) grouped by dimensions
- group by time (`year` / `quarter` / `month` / `week` / `day` / `hour` / `minute`)
- share business metrics between AI agents, BI dashboards, and the browser SDK
- expose drill-down hierarchies for dashboard navigation
Cubes are particularly useful for AI agents: instead of writing `GROUP BY` and
`DATE_TRUNC` SQL by hand (and getting it wrong on small / local models), an
agent picks a measure + dimension + granularity from the cube definition and
the translator builds the SQL.
## Structure
Each cube lives in its own file under `cubes/` as `cubes/<name>.yml`. The
YAML uses `snake_case`; `wren context build` converts to `camelCase` for
the engine.
```yaml
# cubes/order_metrics.yml
name: order_metrics
base_object: orders # name of a defined Model or View
measures:
- name: revenue
expression: "SUM(o_totalprice)"
type: DOUBLE
- name: order_count
expression: "COUNT(*)"
type: BIGINT
- name: avg_order_value
expression: "revenue / order_count" # ← derived measure (auto-inlined)
type: DOUBLE
dimensions:
- name: status
expression: "o_orderstatus"
type: VARCHAR
time_dimensions:
- name: created_at
expression: "o_orderdate"
type: DATE
hierarchies:
time_drill:
- created_at # add finer-grained levels here for drill-down
```
### JSON format (MDL manifest)
After `wren context build`, the same cube serialises to camelCase:
```json
{
"name": "order_metrics",
"baseObject": "orders",
"measures": [
{ "name": "revenue", "expression": "SUM(o_totalprice)", "type": "DOUBLE" }
],
"dimensions": [
{ "name": "status", "expression": "o_orderstatus", "type": "VARCHAR" }
],
"timeDimensions": [
{ "name": "created_at", "expression": "o_orderdate", "type": "DATE" }
],
"hierarchies": { "time_drill": ["created_at"] }
}
```
## Fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Unique cube identifier (used by `wren cube describe`, `cubeQuery.cube`, …) |
| `base_object` | Yes | Name of a defined Model or View; becomes `FROM <base_object>` in the generated SQL |
| `measures` | Yes | List of `{ name, expression, type }`. `expression` may reference physical columns or other measure names (derived measure) |
| `dimensions` | No | List of `{ name, expression, type }` used for `GROUP BY` and filters |
| `time_dimensions` | No | List of `{ name, expression, type }`. Granularity is picked at query time, not in the cube definition |
| `hierarchies` | No | Map of `name → [dimension_names]`, for BI drill-down navigation. Levels must reference declared dimensions or time dimensions. |
## Time granularity
Supported values at query time: `year`, `quarter`, `month`, `week`, `day`,
`hour`, `minute`.
When a query specifies a time dimension with a granularity, the translator
emits `DATE_TRUNC(granularity, expr)` in the projection and `GROUP BY`. The
column alias is `<name>__<granularity>` (e.g., `created_at__month`). An
optional `dateRange: [start, end]` becomes a half-open `[start, end)` `WHERE`
clause.
## Derived measures
A measure's `expression` may reference other measures by name:
```yaml
- name: avg_order_value
expression: "revenue / order_count"
```
The translator inlines `revenue` and `order_count` before emitting SQL:
```text
avg_order_value → (SUM(o_totalprice)) / (COUNT(*))
```
Substitution is longest-prefix-first to avoid partial-token replacement
(e.g., `revenue_2` substitutes before `revenue`). At query time the
translator resolves only the transitive closure of the measures that the
request actually names. Cube validity — including cycle detection across
all derived measures — is enforced earlier during MDL analysis (see
[Validation](#validation) below), so an invalid cube is rejected at load
time regardless of which measures a later query references.
Expressions containing `$` (Postgres `$1` placeholders or `$$tag$$`
dollar-quoted strings) are preserved literally — the translator does not
treat them as regex capture-group templates.
## Filter operators
`cubeQuery.filters` accepts these operators:
`eq` · `neq` · `in` · `not_in` · `gt` · `gte` · `lt` · `lte` ·
`contains` · `starts_with` · `is_null` · `is_not_null`
`in` / `not_in` take an array value. `is_null` / `is_not_null` take no value.
`contains` / `starts_with` produce `LIKE` patterns.
## Cube vs. View vs. Model
| Use case | Use |
|---|---|
| Expose raw rows (optionally with calculated fields) | [Model](./model.md) |
| Name a complex `SELECT` for reuse | [View](./view.md) |
| Predefined aggregation API (measures × dimensions) for agents / BI | **Cube** |
## CLI
- `wren cube list` — list every cube in the loaded MDL
- `wren cube describe <name>` — pretty-print the cube schema
- `wren cube query` — build a CubeQuery (CLI flags or `--from <json>`) and run it
- `wren cube query --sql-only ...` — print the generated SQL without executing
See the [CLI reference](../../reference/cli.md#wren-cube--pre-aggregation-queries).
## WASM (browser)
The same translator is exposed in `@wrenai/wren-core-wasm`:
```javascript
const cubes = engine.listCubes();
const rows = await engine.cubeQuery({
cube: "order_metrics",
measures: ["revenue"],
timeDimensions: [{ dimension: "created_at", granularity: "month" }],
});
```
See the [WASM SDK doc](../../sdk/wasm.md) for setup, the
[WASM Agent Guide](https://github.com/Canner/WrenAI/blob/main/core/wren-core-wasm/AGENT_GUIDE.md)
for embedding-in-an-agent patterns, and
[`cube-explorer.html`](https://github.com/Canner/WrenAI/blob/main/core/wren-core-wasm/examples/cube-explorer.html)
for an interactive form-driven builder.
## Validation
Wren-core validates cubes during `AnalyzedWrenMDL::analyze` — i.e., when the
manifest is loaded into the engine, not just at query time:
- `base_object` must resolve to a defined Model or View
- Derived measures must not form a cycle within the transitive closure of
requested measures
- Levels in `hierarchies` must reference a declared `dimension` or
`time_dimension`
The CLI's `wren context validate` also runs structural checks on cube YAML
(unique names, `base_object` exists, hierarchy levels) before the manifest
reaches the engine, so common mistakes surface at edit time.
+19
View File
@@ -76,6 +76,24 @@ Views are useful when the object you want to expose is query-shaped rather than
See [View](./view.md).
### Cube
A **Cube** is a pre-aggregated semantic object: a `baseObject` (a Model or
View), plus declared measures, dimensions, time dimensions, and optional
hierarchies.
Use a cube when you need to:
- expose pre-defined aggregations (e.g., total revenue by month)
- give AI agents a structured aggregation API (no hand-written `GROUP BY`)
- define drill-down hierarchies (year → quarter → month) for BI dashboards
- share business metrics across CLI, browser (WASM), and downstream consumers
Cubes complement models: a model exposes the rows; a cube exposes the metrics
defined on top of those rows.
See [Cube](./cube.md).
### Memory
The **Memory** layer is a LanceDB-backed semantic index that gives AI agents targeted schema context and few-shot query examples — without sending the entire schema in every prompt.
@@ -98,6 +116,7 @@ Use this rule of thumb:
- Use a **Relationship** to define how models join to each other.
- Use a **Calculated Field** to define reusable expression logic inside a model.
- Use a **View** to publish a reusable query result.
- Use a **Cube** to publish a structured aggregation API (measures × dimensions).
- Use **Memory** to give AI agents targeted context and learning from past queries.
## Why this matters
+69
View File
@@ -200,3 +200,72 @@ Drop all memory tables and start fresh.
wren memory reset # prompts for confirmation
wren memory reset --force # skip confirmation
```
---
## `wren cube` — Pre-aggregation Queries
For aggregation queries where the MDL defines cubes, use `wren cube` instead
of writing raw SQL. The translator produces correct `GROUP BY`, `DATE_TRUNC`,
and `WHERE` clauses from a structured input.
### `wren cube list`
List all cubes in the loaded MDL with their measures and dimensions.
```bash
wren cube list
```
### `wren cube describe <name>`
Pretty-print the full cube schema as JSON: `baseObject`, measures (with
expressions), dimensions, time dimensions, hierarchies.
```bash
wren cube describe order_metrics
```
### `wren cube query`
Build a CubeQuery and translate it to SQL via wren-core, then execute through
the same path as `wren --sql`. Two input modes:
**CLI flags:**
```bash
wren cube query \
--cube order_metrics \
--measures revenue,order_count \
--dimensions status \
--time-dimension "created_at:month:2024-01-01,2025-01-01" \
--filter "status:eq:completed" \
--limit 100
```
**JSON input** (`--from <file|->`):
```bash
cat query.json | wren cube query --from -
```
| Flag | Description |
|------|-------------|
| `--cube` | Cube name (required unless using `--from`) |
| `--measures` | Comma-separated measure names (required unless using `--from`) |
| `--dimensions` | Comma-separated dimension names |
| `--time-dimension` | `<name>:<granularity>[:start,end]` — one time dimension with optional date range |
| `--filter` | Repeatable. `<dimension>:<operator>[:value]`. For `in` / `not_in`, value is comma-separated. |
| `--limit` / `--offset` | Pagination |
| `--from <file\|->` | Load CubeQuery as JSON from a file or stdin |
| `--sql-only` | Print the generated SQL and exit without executing |
| `--mdl` | Path to MDL JSON (defaults to `<project>/target/mdl.json`) |
| `--output` | `table` (default), `json`, `csv` |
**Supported granularities:** `year`, `quarter`, `month`, `week`, `day`, `hour`, `minute`.
**Supported filter operators:** `eq`, `neq`, `in`, `not_in`, `gt`, `gte`, `lt`,
`lte`, `contains`, `starts_with`, `is_null`, `is_not_null`.
See the [Cube guide](../guides/modeling/cube.md) for YAML structure and
validation rules.
+1
View File
@@ -6,6 +6,7 @@ Wren AI Core integrates with popular AI agent frameworks. Each SDK exposes a CLI
- **[LangChain / LangGraph](./langchain.md)** — `wren-langchain` on PyPI
- **[Pydantic AI](./pydantic.md)** — `wren-pydantic` on PyPI
- **[Browser / WebAssembly](./wasm.md)** — `@wrenai/wren-core-wasm` on npm. Runs the semantic engine entirely in the browser; no server, no CLI bootstrap.
## Other access modes
+389
View File
@@ -0,0 +1,389 @@
# wren-core-wasm
Browser-native semantic SQL engine. The Rust wren-core engine compiled to
WebAssembly, plus a TypeScript SDK that runs queries through an MDL semantic
layer entirely in the browser — no server, no roundtrip.
**Use this SDK when**: you're building a client-side analytics UI, a notebook,
or an LLM-in-the-browser experience where the data lives in static Parquet
files (or can be inlined as JSON/CSV) and you want the agent to write SQL
against an MDL model. For server-side Python agents, use
[`wren-langchain`](./langchain.md) or [`wren-pydantic`](./pydantic.md)
instead — they wrap the same engine but talk to your real database.
---
## How it differs from the other SDKs
| | `wren-core-wasm` | `wren-langchain` / `wren-pydantic` |
|---|---|---|
| **Runtime** | Browser (WebAssembly) | Python server |
| **Data source** | Remote Parquet, inline JSON/CSV, or uploaded files | Any datasource the CLI supports (Postgres, BigQuery, …) |
| **Connection profile** | None — data is fetched / registered client-side | Required (built via `wren profile add`) |
| **MDL source** | Passed as a JS object per page load | Read from `target/mdl.json` per tool call |
| **Memory / agent tools** | Not in the WASM SDK | Built-in tools (`wren_query`, `wren_recall_queries`, …) |
| **Query path** | DataFusion executes the SQL in-browser | Engine plans SQL, target database executes |
There is no `WrenToolkit` here — the WASM SDK exposes the engine primitives
(`registerJson` / `registerParquet` / `registerCsv` / `loadMDL` / `query` /
`cubeQuery` / `listCubes`) and you wire them into your own UI or agent loop.
---
## Installation
### npm
```bash
npm install @wrenai/wren-core-wasm
```
```javascript
import { WrenEngine } from '@wrenai/wren-core-wasm';
```
### CDN
```html
<script type="module">
import { WrenEngine } from 'https://unpkg.com/@wrenai/wren-core-wasm@0.3.0/dist/index.js';
</script>
```
> ⚠️ Use **unpkg**, not jsDelivr. jsDelivr's free CDN has a 50 MB per-file
> cap; the WASM binary is ~72 MB raw. Bundlers (Vite, Webpack, esbuild) are
> fine — see [Bundler configuration](#bundler-configuration).
---
## Quickstart
The same `WrenEngine` instance handles three data-loading modes. Pick the one
that fits your data:
### 1. URL mode (remote Parquet)
DataFusion fetches Parquet files via HTTP range requests; no registration
needed.
```javascript
const engine = await WrenEngine.init();
const mdl = {
catalog: 'wren',
schema: 'public',
models: [{
name: 'Orders',
tableReference: { table: 'orders' }, // resolves to {source}/orders.parquet
columns: [
{ name: 'id', type: 'INTEGER' },
{ name: 'customer', type: 'VARCHAR' },
{ name: 'amount', type: 'DOUBLE' },
],
primaryKey: 'id',
}],
relationships: [], views: [],
};
await engine.loadMDL(mdl, { source: 'https://cdn.example.com/data/' });
const rows = await engine.query(
'SELECT customer, SUM(amount) AS total FROM "Orders" GROUP BY customer'
);
console.table(rows);
```
### 2. Inline data (JSON / CSV / Parquet)
Pre-register every table before `loadMDL`. Pass `source: ''` to let the
engine auto-detect that no URL prefix is in play and use the registered
tables (see [`loadMDL`](#engineloadmdlmdl-profile) for the full mode
matrix).
```javascript
const engine = await WrenEngine.init();
// JSON
await engine.registerJson('orders', [
{ id: 1, customer: 'Alice', amount: 150 },
{ id: 2, customer: 'Bob', amount: 250 },
]);
// CSV — string or Uint8Array; options object is optional
await engine.registerCsv('events', csvString, {
header: true,
delimiter: ',',
// optional explicit Arrow schema; omit to infer
schema: [
{ name: 'id', type: 'int64' },
{ name: 'event_type', type: 'string' },
],
});
// Parquet — BufferSource (ArrayBuffer / Uint8Array / Node Buffer)
const file = await fetch('orders.parquet').then(r => r.arrayBuffer());
await engine.registerParquet('orders_pq', file);
await engine.loadMDL(mdl, { source: '' }); // auto-detect; uses the registered tables
```
### 3. Cube queries (structured aggregation)
When the MDL defines a [cube](../guides/modeling/cube.md), prefer `cubeQuery`
over hand-written `GROUP BY` SQL. The engine assembles `DATE_TRUNC` / filters
/ projections from a JSON request — useful for an agent that doesn't need to
think about SQL syntax.
```javascript
await engine.loadMDL(mdlWithCubes, { source: '' });
const cubes = engine.listCubes(); // discover what's queryable
const rows = await engine.cubeQuery({
cube: 'order_metrics',
measures: ['revenue', 'order_count'],
dimensions: ['customer'],
timeDimensions: [{
dimension: 'created_at',
granularity: 'month',
dateRange: ['2024-01-01', '2024-04-01'],
}],
filters: [{ dimension: 'status', operator: 'eq', value: 'open' }],
});
```
See [`examples/cube-explorer.html`](https://github.com/Canner/WrenAI/blob/main/core/wren-core-wasm/examples/cube-explorer.html)
for an interactive form-driven builder and
[`examples/csv-quickstart.html`](https://github.com/Canner/WrenAI/blob/main/core/wren-core-wasm/examples/csv-quickstart.html)
for the three CSV patterns (inferred schema, custom delimiter, explicit
schema).
---
## API Reference
### `WrenEngine.init(options?)`
Initialize the engine and load the WASM binary. Call once per page lifecycle.
```typescript
static async init(options?: WrenEngineOptions): Promise<WrenEngine>
```
| Option | Type | Description |
|---|---|---|
| `wasmUrl` | `string \| URL \| BufferSource` | WASM binary source. Defaults to the sibling `wren_core_wasm_bg.wasm` resolved via `import.meta.url`. |
### `engine.loadMDL(mdl, profile)`
Load an MDL manifest and reconfigure the session with Wren analyzer rules.
```typescript
async loadMDL(mdl: object, profile: WrenProfile): Promise<void>
```
| `profile.source` | Mode | Behaviour |
|---|---|---|
| `"https://…/"` / `"http://…/"` | **URL mode** | Auto-registers a `ListingTable` for each model at `{source}/{table_name}.parquet`. No pre-registration needed. |
| `""` (empty) | **Auto-detect mode** | For each model, picks URL mode if its `tableReference` looks like a URL, otherwise expects the table to already be registered. Used by the inline quickstart above. |
| anything else | **Strict local mode** | All tables must be pre-registered via `register*`. Any missing table raises `Unresolved models: [...]` immediately from `loadMDL` instead of deferring to query time. |
Bare model names resolve under the MDL's catalog/schema after this call.
Use strict local mode (any non-URL, non-empty source string) when you've
pre-registered everything and want missing-table errors to surface up
front; use auto-detect (`""`) when matching the behaviour of the bundled
browser examples.
### `engine.registerJson(name, data)`
Register a JSON array as a named table. Schema is inferred from the first
row. Call before `loadMDL` when using auto-detect or strict local mode.
```typescript
async registerJson(name: string, data: object[]): Promise<void>
```
### `engine.registerParquet(name, data)`
Register a Parquet file as a named table.
```typescript
async registerParquet(name: string, data: BufferSource): Promise<void>
```
`BufferSource` covers `ArrayBuffer`, any `TypedArray` (`Uint8Array`), and
Node.js `Buffer`. The view's `byteOffset` / `byteLength` are honored.
### `engine.registerCsv(name, data, options?)`
Register CSV data as a named table. Accepts a string (UTF-8) or `BufferSource`.
```typescript
async registerCsv(
name: string,
data: string | BufferSource,
options?: CsvReadOptions,
): Promise<void>
```
| Option (camelCase) | Type | Default |
|---|---|---|
| `header` | `boolean` | `true` |
| `delimiter` | `string` (1 ASCII char) | `,` |
| `quote` | `string` (1 ASCII char) | `"` |
| `escape` | `string` (1 ASCII char) | unset |
| `terminator` | `string` (1 ASCII char) | `\n` / `\r\n` |
| `batchSize` | `number` | `8192` |
| `inferRows` | `number` | `1000` |
| `schema` | `[{name, type, nullable?}]` | inferred |
Schema column types (case-insensitive): `int8`/`int16`/`int32`/`int64`,
`uint8`/`uint16`/`uint32`/`uint64`, `float32`/`float64`, `boolean`,
`string` (alias `utf8`/`varchar`/`text`), `date`/`date32`/`date64`,
`timestamp` and `timestamp_{s,ms,us,ns}`.
### `engine.query(sql)`
Execute a SQL query through the semantic layer. Returns parsed rows.
```typescript
async query(sql: string): Promise<Record<string, unknown>[]>
```
### `engine.cubeQuery(query)`
Run a structured cube query against the loaded MDL. Translates the
`CubeQuery` JSON to SQL, then runs it through the same path as `query()`.
Requires `loadMDL` first.
```typescript
async cubeQuery(query: CubeQueryInput): Promise<Record<string, unknown>[]>
interface CubeQueryInput {
cube: string;
measures: string[];
dimensions?: string[];
timeDimensions?: TimeDimensionInput[];
filters?: CubeFilterInput[];
limit?: number;
offset?: number;
}
```
See [`docs/core/guides/modeling/cube.md`](../guides/modeling/cube.md) for the
full input shape and filter operator list.
### `engine.listCubes()`
Return the cubes defined in the loaded MDL. Synchronous — useful for an
agent to discover what's queryable before calling `cubeQuery`.
```typescript
listCubes(): CubeInfo[]
```
### `engine.free()`
Release WASM memory. Call when the engine is no longer needed (e.g. SPA
route unmount).
---
## Integration patterns
### Bundler configuration
Bundlers must copy the `.wasm` file into your output. Most setups handle this
automatically; if not, pass the URL explicitly:
```javascript
import wasmUrl from '@wrenai/wren-core-wasm/dist/wren_core_wasm_bg.wasm?url';
const engine = await WrenEngine.init({ wasmUrl });
```
Vite recognises the `?url` suffix. Webpack 5 needs `experiments.asyncWebAssembly`.
For other bundlers, fetch the binary yourself and pass the `ArrayBuffer`.
### Multiple engines per page
`WrenEngine.init()` is safe to call more than once — each call returns an
independent engine with its own catalog. Useful for sandboxing per-tenant
data on the same page. The WASM module itself is shared via the standard
`import` cache, so only the first init pays the binary fetch.
### Cubes as the agent surface
For LLM-driven UIs, prefer `cubeQuery` over teaching the agent SQL:
1. Call `listCubes()` once after `loadMDL` and inject the result into the
system prompt as the agent's "menu" of available aggregations.
2. Have the agent emit a `CubeQueryInput` JSON object — no `GROUP BY` /
`DATE_TRUNC` knowledge required.
3. `cubeQuery()` validates the request against the MDL before running, so
typos (`measures: ['revnu']`) raise structured errors you can feed back to
the LLM.
---
## Examples
Runnable browser demos in [`examples/`](https://github.com/Canner/WrenAI/tree/main/core/wren-core-wasm/examples):
| Demo | Shows |
|---|---|
| `inline.html` | `registerJson` + raw SQL |
| `url-mode.html` | Remote Parquet via HTTP range |
| `csv-quickstart.html` | Three `registerCsv` patterns (inferred / TSV / explicit schema) |
| `cube-quickstart.html` | Minimal `cubeQuery` — three preset queries |
| `cube-explorer.html` | Form-driven `CubeQuery` builder |
```bash
cd core/wren-core-wasm
just build-wasm-dev
just serve # http://localhost:8787
```
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| `Unresolved models: [foo]` from `loadMDL` | Strict local mode but `foo`'s physical table wasn't pre-registered | Call `register*` for every model before `loadMDL`, or switch to URL mode |
| `undefined is not an object (evaluating 'arg.length')` | Calling the raw WASM API with the wrong arg count or type (e.g. passing a string where bytes are expected) | Use the TypeScript SDK overloads (`engine.registerCsv(name, str)`); the raw `pkg/` API requires bytes + explicit options JSON |
| `Cube query for 'X' must include at least one measure…` | Empty `measures` + `dimensions` + `timeDimensions` | A cube query must project something — add at least one field |
| `Unsupported CSV column type 'bogus'` | Typo in explicit schema | See the type list under `registerCsv` for accepted names |
| Page hangs on `init()` for >5s | Initial WASM fetch (~72 MB raw, ~15 MB gzip) | Add a loading indicator; consider self-hosting + caching the binary instead of CDN |
| jsDelivr returns 404 | jsDelivr's 50 MB per-file CDN limit blocks the binary | Use [unpkg](https://unpkg.com/) instead, or self-host |
---
## Compatibility
| `@wrenai/wren-core-wasm` | wren-core | Browser |
|---|---|---|
| 0.3.x | 0.5.x | Any with WASM + ES modules (Chrome 91+, Firefox 89+, Safari 15+) |
The package ships a single `dist/` bundle (ES modules) plus the `.wasm`
binary. There is no UMD or CommonJS build.
---
## Limitations
- **~72 MB WASM binary.** Cold-load is 14 s on a fast connection; surface a
loading state. Subsequent loads use the HTTP cache.
- **In-memory tables only.** `registerJson` / `registerCsv` materialise the
entire dataset as Arrow batches — practical ceiling is "as much as the
browser tab can hold" (typically 100s of MB before pressure kicks in).
- **No streaming.** Each `register*` call buffers fully before becoming
queryable. For very large remote files, use URL mode (DataFusion streams
Parquet via range requests) instead.
- **Single-threaded.** The DataFusion build is configured with
`target_partitions = 1` — no Web Worker pool. Long queries block the main
thread; consider running the engine in a Worker if responsiveness matters.
- **Upstream DataFusion (not the Canner fork).** WASM doesn't need the
unparser fixes; the trade-off is that some advanced wren-core analyzer
paths that depend on fork-only behaviour aren't exercised here.
- **No memory module.** Semantic-search memory (LanceDB) is a server-side
feature exposed by `wren-langchain` / `wren-pydantic` — not available in
the WASM SDK.
+1 -1
View File
@@ -65,7 +65,7 @@
},
{
"name": "wren-usage",
"version": "2.2",
"version": "2.3",
"description": "Wren Engine CLI workflow guide for AI agents. Triggers on data questions, reports, metrics, revenue, trends, 'how many', 'show me', 'top N', 'compare', 'breakdown'. Answer data questions end-to-end using the wren CLI.",
"tags": [
"wren",
+1 -1
View File
@@ -2,5 +2,5 @@
"wren-dlt-connector": "1.0",
"wren-generate-mdl": "2.2",
"wren-onboarding": "2.1",
"wren-usage": "2.2"
"wren-usage": "2.3"
}
+9 -2
View File
@@ -186,12 +186,19 @@ This creates:
```text
project/
├── wren_project.yml
├── models/
├── views/
├── models/ # business-facing tables/models
├── views/ # named SQL statements
├── cubes/ # pre-aggregation cubes (measures + dimensions)
├── relationships.yml
└── instructions.md
```
> **When to define cubes:** If the user asks aggregation questions like
> "revenue by month" or "top customers", define cubes alongside models —
> they give agents a structured query API instead of forcing them to
> hand-write `GROUP BY` / `DATE_TRUNC` SQL. See the
> [Cube guide](https://github.com/Canner/WrenAI/blob/main/docs/core/guides/modeling/cube.md).
> **IMPORTANT: `catalog` and `schema` in `wren_project.yml`**
>
> These are Wren Engine's internal namespace — they are NOT the database's
+93 -1
View File
@@ -4,7 +4,7 @@ description: "Wren Engine CLI workflow guide for AI agents. Answer data question
license: Apache-2.0
metadata:
author: wren-engine
version: "2.2"
version: "2.3"
---
# Wren Engine CLI — Agent Workflow Guide
@@ -304,6 +304,7 @@ wren --sql "SELECT * FROM <changed_model> LIMIT 1"
```text
Get data back → wren --sql "..."
Aggregation across dims → wren cube query --cube <name> --measures <m> (if cube defined)
See translated SQL only → wren dry-plan --sql "..." (accepts -d <datasource> if no active profile)
Validate against DB → wren dry-run --sql "..."
Schema context → wren memory fetch -q "..."
@@ -320,6 +321,97 @@ Switch profile → wren profile switch <name>
---
## Cube Query Workflow
When the user asks an aggregation question (e.g., "total revenue by month",
"top customers"), check if the MDL defines cubes before writing raw SQL.
### Step 1: Discover cubes
```bash
wren cube list
```
If cubes exist and cover the user's question, prefer cube query over raw SQL.
Lower error rate, especially for small / local models — agents don't have to
hand-write GROUP BY / DATE_TRUNC.
### Step 2: Inspect cube structure
```bash
wren cube describe <cube_name>
```
Shows the cube's baseObject, measures (with expressions), dimensions,
time dimensions, and hierarchies.
### Step 3: Match user's question to cube measures + dimensions
| User phrase | Maps to |
|---|---|
| "total revenue" | `--measures revenue` |
| "by month" | `--time-dimension "created_at:month"` |
| "in 2024" | `--time-dimension "created_at:month:2024-01-01,2025-01-01"` |
| "for completed orders" | `--filter "status:eq:completed"` |
| "top N customers" | `--dimensions customer --limit N` |
### Step 4: Execute via CLI flags OR JSON input
CLI flags:
```bash
wren cube query \
--cube order_metrics \
--measures revenue,order_count \
--time-dimension "created_at:month:2024-01-01,2025-01-01" \
--filter "status:eq:completed" \
--limit 100
```
JSON input (good for agent-generated structured queries):
```bash
echo '{"cube":"order_metrics","measures":["revenue"]}' | wren cube query --from -
```
Add `--sql-only` to print the generated SQL without executing — useful for
verification before paying for execution on a remote warehouse.
### Step 5: Error recovery
| Error | Action |
|---|---|
| `Unknown measure 'X'` | `wren cube describe <cube>` for available measures |
| `Unknown dimension 'X'` | `wren cube describe <cube>` for available dimensions |
| `Cube 'X' not found` | `wren cube list` |
| `Circular dependency detected` | Derived measure references itself — inspect the cube YAML |
### When NOT to use cube query
Fall back to `wren --sql` when:
- Custom JOINs across multiple models
- Window functions, CTEs, or subqueries
- Queries with no aggregation
- No cubes defined in the MDL
---
## Aggregation decision tree
```text
User question → Is it an aggregation question?
(SUM, COUNT, AVG, GROUP BY, "by month", "per customer", ...)
├── Yes → Are cubes defined? (`wren cube list` once at start of session)
│ ├── Yes → Does a cube cover the question? (`wren cube describe`)
│ │ ├── Yes → Use `wren cube query` (preferred — lower error rate)
│ │ └── No → Write raw SQL with `wren --sql`
│ └── No → Write raw SQL with `wren --sql`
└── No → Write raw SQL with `wren --sql` (look for memory recall first)
```
---
## Things to avoid
- Do not guess model or column names — check context first
+18
View File
@@ -23,6 +23,24 @@ Override with `--threshold`:
wren memory fetch -q "revenue" --threshold 50000 # raise for larger context windows
```
### Cube schema items
When the MDL defines cubes, `wren memory index` emits these additional schema items:
- `cube:<cube_name>` — cube overview (base object, measure list, dimension list)
- `measure:<cube>.<measure_name>` — each measure (with expression and type)
- `cube_dimension:<cube>.<dimension_name>` — each dimension
- `time_dimension:<cube>.<time_dim_name>` — each time dimension
These items are reachable via `wren memory fetch "<question>"`. For aggregation
questions like "revenue by month", cube schema items typically rank higher than
model columns because they match the aggregation intent more directly — then
the agent should follow up with `wren cube describe <cube>` and `wren cube query`
rather than hand-writing `GROUP BY` SQL.
`wren memory describe` also adds a cube section that lists each cube's measures,
dimensions, time dimensions, and hierarchies in markdown.
---
## Indexing: `wren memory index`
+48
View File
@@ -106,3 +106,51 @@ If the rewriter detects no model references in your SQL (e.g. `SELECT 1` or quer
- Queries that don't reference any MDL model still work
- The fallback path does NOT use CTE injection — it transforms the whole query at once
- If you expect model expansion but get none, check that your FROM clause uses model names from the MDL
---
## Cube query SQL generation
`wren cube query` doesn't execute SQL directly — it produces SQL from a
structured CubeQuery input and hands it to the engine. Inspecting
`--sql-only` output is how an agent reverse-engineers cube expansion logic.
### Generated SQL pattern
```sql
SELECT DATE_TRUNC('month', o_orderdate) AS created_at__month,
o_orderstatus AS status,
SUM(o_totalprice) AS revenue,
COUNT(*) AS order_count
FROM orders -- ← cube.baseObject
WHERE o_orderdate >= '2024-01-01'
AND o_orderdate < '2025-01-01' -- ← dateRange (end exclusive)
AND o_orderstatus = 'completed' -- ← filter
GROUP BY 1, 2 -- ← GROUP BY ordinals for all dims
ORDER BY 1
LIMIT 100
```
### Key points
- **`FROM` is the cube's `baseObject`** — wren-core then resolves it to the
underlying model/view, so all existing model rewrite rules still apply.
- **Time dimensions use `DATE_TRUNC(granularity, expr)`**; the column alias
is `<name>__<granularity>` (e.g., `created_at__month`).
- **Date range is `[start, end)` half-open** — the `end` day is excluded.
- **Derived measures inline-expand**: `avg_order_value = revenue / order_count`
becomes `(SUM(o_totalprice)) / (COUNT(*))`. Longest dependency name
substitutes first to avoid partial-token bugs (e.g., `revenue_2` before
`revenue`).
- **Expressions containing `$` are safe**: Postgres `$1` parameter placeholders
and `$$tag$$` dollar-quoted literals are kept literal, not misread as
regex capture-group templates.
### Diagnosing cube SQL errors
1. `wren cube query --sql-only ...` to inspect the generated SQL.
2. If the SQL looks reasonable, run `wren cube query ...` (drop `--sql-only`).
3. If the execution error is "unknown column / table", the cube YAML's
`expression` is likely wrong — not the translator.
4. If translation itself fails (e.g., cyclic measure), the error is raised
before execution and names the offending measure.