Config-Driven Vistas: YAML & Rhai
The Vista chapter built Vistas by wrapping typed tables — Rust code, compiled in. This page covers the other construction path: declare the Vista in YAML, load it at runtime, and seal it behind the same honest handle. Change the YAML, rebuild the Vista, and the consumer sees the new shape — no recompiling.
This is the path configuration-driven tooling takes: admin panels reading a folder of model files, AI agents writing schema on a user’s behalf, and any application (Vantage UI among them) whose data layer is user-editable data rather than user-compiled code. For everything YAML can’t express — vendor expressions, derived queries, scripted traversal — there’s an optional Rhai layer that compiles to native queries.
- One page of YAML, one working Vista
- Anatomy of a spec
- References
- The Rhai layer
- Contained relations
- Cross-persistence: the VistaCatalog
- Sealed at runtime: data scripts
- Choosing a path
One page of YAML, one working Vista
Every driver’s factory implements VistaFactory, whose from_yaml
parses a spec and lowers it:
#![allow(unused)]
fn main() {
let yaml = r#"
name: product_view
columns:
id:
type: string
flags: [id]
name:
type: string
flags: [title, searchable]
price:
type: int
sqlite:
table: product
"#;
let vista = db.vista_factory().from_yaml(yaml)?;
assert_eq!(vista.name(), "product_view");
assert_eq!(vista.get_id_column(), Some("id"));
let rows = vista.list_values().await?; // reads the `product` table
}
The factory builds a typed Table under the hood — each type: becomes a real Column<T> — and
wraps it exactly as from_table would. From here on, nothing downstream can tell how the Vista was
made: same schema introspection, same conditions, same capability contract.
Two implicit rules worth knowing:
- The Vista’s name is the spec’s
name, not the storage name.product_viewis what a UI tab or a catalog key sees;sqlite: { table: product }is where the rows live. Omit the block and the spec name doubles as the table name. - The id column resolves in a fixed order: explicit
id_column:at the top level wins, then the first column flaggedid, then a driver default ("id"for SQL,"_id"for MongoDB). The explicit key exists so a YAML author can correct a wrong flag without touching anything else.
Anatomy of a spec
A VistaSpec has a uniform core that every driver understands, plus
one driver-named block at each level:
| Key | What it is |
|---|---|
name | The vista’s public name (catalog key, UI label) |
datasource | Optional datasource key, for inventories that manage several |
id_column | Explicit id override (see resolution order above) |
columns | Ordered map of column name → { type, flags, references, lazy, <driver> } |
references | Named relations to other vistas (see below) |
contained | Embedded-in-row relations (see below) |
<driver> | The driver’s table-level block: sqlite:, surreal:, mongo:, csv: |
Column types
type: names lower to real typed columns; unknown names are a parse error, not a fallback:
YAML type | Rust column |
|---|---|
int, integer, i64, i32 | Column<i64> |
float, double, f64, f32 | Column<f64> |
bool, boolean | Column<bool> |
string, text, str (default) | Column<String> |
decimal, numeric | Column<Decimal> |
date / time / datetime | chrono naive types |
timestamp | DateTime<Utc> |
flags: is the same open vocabulary the Vista chapter introduced — id, title, searchable,
orderable, hidden, mandatory — and drives the same consumer behaviour (title columns label
rows, searchable feeds quicksearch, hidden drops out of default views).
Typos fail at parse time
Every driver block sets deny_unknown_fields, so sqlite: { tabel: product } is rejected when the
YAML is parsed — with the offending key in the message — instead of silently falling back to a
default and failing later at query time. Driver-block validation follows the same rule: an empty
nested_path, a a..b path, mutually-exclusive options — all surface as parse errors naming the
column.
Column mapping per driver
When the spec column name differs from the storage name, the column’s driver block maps it:
| Driver | Column block | Meaning |
|---|---|---|
| SQLite | sqlite: { column: unit_price } | SELECTed under the SQL name, aliased back to the spec name |
| Surreal | surreal: { field: unitPrice } | Read/written under the Surreal field name |
| MongoDB | mongo: { field: unitPrice } | Single-level BSON rename |
| MongoDB | mongo: { nested_path: price.amount } | Dotted path into a nested document — projected on read, sub-documents rebuilt on write, dotted form pushed down on filter |
| CSV | csv: { source: "Unit Price" } | CSV header to read from |
CSV is also the one driver whose table block is mandatory — csv: { path: data/products.csv } —
because without a path there is no file.
References
A relation to another vista, in the same uniform vocabulary the typed with_one/with_many uses:
name: category
columns:
id: { type: string, flags: [id] }
name: { type: string, flags: [title] }
references:
products:
table: product
kind: has_many
foreign_key: category_id
Three shapes are available:
-
Full form (above):
table,kind(has_one/has_many),foreign_key. -
Column sugar — when the foreign key is the column, declare it inline:
columns: category_id: type: string references: category # has_one, foreign_key = the column itself -
Multi-key joins —
keys:lists{ to, from }pairs when the child is narrowed by more than one parent field (a deployment matched on bothproduct_idandversion_id).
The spec resolver
A reference names its target by spec name — so the factory needs a way to find the target’s current spec at traversal time. That’s the spec resolver, attached once:
#![allow(unused)]
fn main() {
let resolver: SqliteSpecResolver = Arc::new(move |name| specs.get(name).cloned());
let factory = db.vista_factory().with_resolver(resolver);
let category = factory.from_yaml(category_yaml)?;
// Traversal rebuilds `product` from its live spec:
let row = /* a category row */;
let products = category.get_ref("products", &row)?;
}
The resolver is a plain closure — back it with an in-memory map, a folder of .yaml files, or a
hot-reloading inventory. Because targets are re-resolved on every traversal, editing a spec changes
what the next traversal builds. Without a resolver, a traversal falls back to a column-less
target and the next query fails loudly.
The Rhai layer
YAML declares structure. For anything with an expression in it, specs escalate to Rhai — a
small, embeddable scripting language whose Vantage vocabulary compiles to native queries. The
expression primitives are shared across backends where the concept overlaps (count, avg,
coalesce, case_when, date_format …) — see SQL Primitives and
SurrealDB Primitives for the full vocabularies. Rhai appears in a spec
in five places, each with a distinct job. The first two build native queries; the next two use the
data vocabulary taught in Scripting with Rhai; the last computes a column from one
row.
1. Query-sourced vistas — rhai:
Replace the physical table with a script-built SELECT:
name: expensive_products
columns:
id: { type: string, flags: [id] }
name: { type: string }
sqlite:
rhai: |
select().from("product").field("id").field("name").where(expr("price > 150"))
The script runs once at build time and its SELECT becomes the vista’s source. A query-sourced
vista is read-only — the factory clears can_insert / can_update / can_delete, because
there’s no single table a write could honestly land in. Capability honesty is preserved
automatically; consumers find out by checking, same as ever.
2. Derived vistas — base: + inherit + transform
Derive one vista from another, inheriting schema and transforming the query. The base’s select()
is seeded into the script’s scope as base:
name: category_totals
id_column: category_id
columns:
total_price: { type: int }
sqlite:
base: product
inherit:
columns: [category_id]
rhai: |
base.clear_fields().field("category_id")
.expression(expr("SUM(price) AS total_price"))
.group_by(expr("category_id"))
This is the aggregate pattern: group the base by a key, declare the aggregate outputs as the
derived vista’s own columns:, and re-key with id_column. base: resolves eagerly through the
same spec resolver as references. Derived vistas are query-sourced, hence read-only.
3. Post-build tweaks — modify: (SurrealDB)
A script applied to the finished vista, exposed as self, using the
table handle’s verbs plus vendor expressions YAML keys can’t state (see
modify: scripts):
name: active_products
columns:
id: { type: thing, flags: [id] }
name: { type: string, flags: [title] }
surreal:
table: product
modify: |
self.with_condition(ident("is_deleted") == false)
.sort("name", "asc")
Unlike a query source, modify: narrows a real table — so the vista stays writable. It runs
last, composing with table:, rhai:, or base:.
4. Scripted reference traversal — reference-level rhai: (SurrealDB)
When a relation can’t be expressed as a foreign-key equality — a graph edge, a computed join — the
reference carries its own build script, evaluated lazily at traversal time with the parent row in
scope:
references:
products:
table: product
kind: has_many
foreign_key: category
surreal:
rhai: |
table("product").where("category", row.id)
The script ends on a table handle, which becomes the narrowed target vista; foreign_key remains as metadata for consumers that
introspect the relation. See Reference build scripts.
5. Computed columns — column-level lazy: (every driver)
A column whose value no backend stores, computed by the Vista from the row it read:
columns:
net: { type: int }
vat: { type: int, lazy: "row.net / 5" }
The script sees the row as row, and its last expression is the value. Columns compute in
declaration order, writes drop them, and filtering, ordering or aggregating on one is an error
naming the column. A has-one reference may use one as its foreign key; a has-many may not. Full
details in Computed Columns.
The division of labour is deliberate: YAML stays the canonical, declarative format that every
backend understands; Rhai is the serializable escape hatch you reach for only when a source,
transform, or traversal needs an expression. A spec with no Rhai in it works on a build without the
rhai feature; one that uses it fails loudly there instead of degrading.
Contained relations
Embedded objects and arrays — an order’s lines, a JSON column — declare as a contained:
section and surface as editable sub-vistas:
name: order
columns:
id: { type: string, flags: [id] }
lines: { type: string } # the host column — declare it so it's selected
contained:
lines:
host_column: lines
kind: contains_many
id_column: line_id # omit for positional ids
columns:
product: { type: string }
quantity: { type: int }
Reads project the embedded collection into records; writes patch it back into the host column. The mechanics — and the sharp edges — are covered in Contained Relations.
Cross-persistence: the VistaCatalog
A single Vista is strictly single-backend. When a system spans several — categories in Postgres,
products behind a REST API — VistaCatalog sits one layer up: it holds a
loader per model name and traverses relations whose target lives in a different persistence:
#![allow(unused)]
fn main() {
let mut catalog = VistaCatalog::new();
catalog.register("category", Arc::new(move || pg_factory.from_yaml(&category_yaml)));
catalog.register("product", Arc::new(move || api_factory.from_yaml(&product_yaml)));
catalog.register_relation("category", Relation::single_key(
"products", "product", ReferenceKind::HasMany,
"category_id", // target column to constrain
"id", // parent-row field whose value narrows it
));
let category = catalog.build_vista("category")?;
let row = /* a category row */;
let products = catalog.traverse_from("category", "products", &row)?; // Postgres → REST
}
Loaders return a fresh, unconditioned Vista on every call, so the catalog composes with
hot-reloading inventories the same way spec resolvers do. The catalog is also what
Augmentation uses to resolve its detail sources — the augment: block in a
table’s configuration is this same machinery pointed at row enrichment.
Sealed at runtime: data scripts
Once vistas are config-defined, the last step of the story is consuming them from config too.
vantage-vista’s rhai feature ships run_script, a sandboxed evaluator where table(name)
resolves through a catalog-style resolver. It’s the surface an AI agent or an MCP tool drives: the schema it sees, the capabilities it must
respect, and the rows it reads all come from the same sealed handles this page built. The
vocabulary, its limits and every other place it runs are covered in
Scripting with Rhai.
Choosing a path
Both construction paths produce the same Vista — the choice is about who edits the definition:
- Typed
from_tablewhen the model is code: business logic, compile-time safety, entity structs,with_expressionclosures. This is the path the introduction guide walks. - YAML (+ Rhai) when the model is data: inventories on disk, user- or agent-edited schema, hot-reload, no recompiling. Structure in YAML; expressions in Rhai; capabilities sealed either way.
They mix freely — a catalog can hold typed-table loaders next to YAML loaders, and a YAML spec’s reference can resolve to either. If you’re implementing this machinery for a new backend, the driver-side walkthrough is Adding a New Persistence, Step 8.