Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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_view is 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 flagged id, 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:

KeyWhat it is
nameThe vista’s public name (catalog key, UI label)
datasourceOptional datasource key, for inventories that manage several
id_columnExplicit id override (see resolution order above)
columnsOrdered map of column name → { type, flags, references, lazy, <driver> }
referencesNamed relations to other vistas (see below)
containedEmbedded-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 typeRust column
int, integer, i64, i32Column<i64>
float, double, f64, f32Column<f64>
bool, booleanColumn<bool>
string, text, str (default)Column<String>
decimal, numericColumn<Decimal>
date / time / datetimechrono naive types
timestampDateTime<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:

DriverColumn blockMeaning
SQLitesqlite: { column: unit_price }SELECTed under the SQL name, aliased back to the spec name
Surrealsurreal: { field: unitPrice }Read/written under the Surreal field name
MongoDBmongo: { field: unitPrice }Single-level BSON rename
MongoDBmongo: { nested_path: price.amount }Dotted path into a nested document — projected on read, sub-documents rebuilt on write, dotted form pushed down on filter
CSVcsv: { 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 both product_id and version_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.

YAML primary, Rhai targeted

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_table when the model is code: business logic, compile-time safety, entity structs, with_expression closures. 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.