mirror of
https://github.com/Canner/WrenAI.git
synced 2026-09-01 15:34:04 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
|
||||
|
@@ -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
|
||||
|
@@ -0,0 +1,6 @@
|
||||
NA,Q1,1500
|
||||
NA,Q2,1800
|
||||
EU,Q1,1200
|
||||
EU,Q2,1500
|
||||
APAC,Q1,800
|
||||
APAC,Q2,1100
|
||||
|
@@ -106,7 +106,6 @@
|
||||
primaryKey: 'id',
|
||||
}],
|
||||
relationships: [],
|
||||
metrics: [],
|
||||
views: [],
|
||||
};
|
||||
document.getElementById('mdl').textContent = JSON.stringify(mdl, null, 2);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 1–4 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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user