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. from_yaml is a trait method, so the trait has to be in scope; it is imported by name from vantage_vista, not through a prelude. The block reads a populated product table, so it is compiled but not run:

use vantage_dataset::ReadableValueSet;
use vantage_sql::prelude::*;
use vantage_vista::VistaFactory;

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db").await.context("connect")?;
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.id_column(), Some("id"));
let rows = vista.list_values().await?;   // reads the `product` table
let _ = rows;
Ok(())
}

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
GraphQLgraphql: { field: unit_price }Selected as a GraphQL alias, filtered and sorted under the server name (GraphQL blocks)

MongoDB adds the column types object_id, datetime (aliases date, timestamp; a BSON DateTime) and decimal (Decimal128). Writes keep these types, and text that doesn’t parse as the column’s type is rejected. A column named with a dotted path through a reference (client.name) joins via $lookup, which compares types strictly: a string foreign key does not join an ObjectId _id.

A MongoDB table block can replace the collection read with an aggregation pipeline, or script it:

Table blockMeaning
mongo: { pipeline: [...] }Read-only view over an aggregation pipeline (MongoView)
mongo: { rhai: "..." }Builds the pipeline in Rhai from args (rhai feature)
mongo: { modify: "..." }Rhai run last against the built vista as self
mongo: { view: true }The collection is a server-side view: read-only, not watched, not imported

Change streams (Capability::Subscribe) and atomic imports (Capability::Import) need a replica set; see Live data. Streams run on a dedicated client sized by MongoDB::with_stream_pool_size (default 100).

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

Each entry lowers to one Reference in the vista’s metadata. Three shapes are available:

  • Full form (above): table, kind (has_one / has_many), foreign_key. A has_one matches its foreign_key on this row to the target’s id; a has_many matches this row’s id to the target’s foreign_key. Without foreign_key:, the entry’s name is the key.

  • Column sugar — when the foreign key is the column, declare it inline. The relation takes the column’s name, and a references: entry of the same name takes precedence:

    columns:
      category_id:
        type: string
        references: category      # has_one named `category_id`, foreign_key = the column itself
    
  • Multi-key joins — keys: lists { from, to } pairs when the child is narrowed by more than one parent field (a deployment matched on both product_id and version_id). Each pair reads the parent row’s from column and constrains the target’s to column, and traversal applies every pair. When keys: is present it is the whole join, and foreign_key: is not read:

    references:
      deployments:
        table: deployment
        kind: has_many
        keys:
          - { from: product_id, to: product }
          - { from: version_id, to: version }
    

On MongoDB a reference key must be stored under its own field name. The vista fails to build when a reference’s foreign key, or the id column it matches on the other side, is renamed with field: or moved with nested_path:. A reference with a mongo: { rhai: } script (below) is exempt, as its traversal runs the script. Joined columns (client.city) can still read nested fields of the target.

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:

use std::collections::HashMap;
use std::sync::Arc;

use ciborium::Value as CborValue;
use vantage_sql::prelude::*;
use vantage_sql::sqlite::vista::SqliteVistaSpec;
use vantage_types::Record;
use vantage_vista::{ColumnSpec, SpecResolver, VistaFactory};

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let category_yaml = r#"
name: category
columns:
  id: { type: string, flags: [id] }
  name: { type: string, flags: [title] }
references:
  products:
    table: product
    kind: has_many
    foreign_key: category_id
"#;
let mut product = SqliteVistaSpec::new("product");
product.columns.insert("id".to_string(), ColumnSpec::new().with_type("string").with_flag("id"));
product.columns.insert("category_id".to_string(), ColumnSpec::new().with_type("string"));
let specs: HashMap<String, SqliteVistaSpec> = HashMap::from([("product".to_string(), product)]);
let resolver: Arc<dyn SpecResolver<SqliteVistaSpec>> =
    Arc::new(move |name: &str| 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: Record<CborValue> = [("id".to_string(), CborValue::Text("pastry".into()))].into_iter().collect();
let products = category.get_ref("products", Some(&row))?;
assert!(products.is_narrowed());
Ok(())
}

SpecResolver<S> is the same trait for every driver, with the driver’s spec type as S, and a factory holds it as Arc<dyn SpecResolver<S>>. Any Fn(&str) -> Option<S> closure implements it (annotate its argument as &str, as above, or the closure won’t take every lifetime), and a type of your own can implement resolve directly — 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. A name the resolver doesn’t know, or a target that fails to build, is a traversal error, never a wider table. Without a resolver at all, a traversal falls back to a column-less target and the next query fails loudly.

The build injector

Some rules belong on every table a factory builds, not on one spec: a tenant filter, an access-control condition, hooks that stamp who wrote a row. Copying them into each spec is how one gets forgotten. A build injector runs on every table the factory builds from a spec: the root, each reference target when it is traversed, each base: table under a derived vista, and tables built inside scripts. It runs after the table is built and before it becomes a vista, and it can add conditions, register hooks, or fail the build:

use std::sync::Arc;

use vantage_sql::prelude::*;
use vantage_table::table::{BuildKind, InjectCx, TableInjector};
use vantage_table::traits::table_source::TableSource;

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let tenant_only: TableInjector<SqliteDB> = Arc::new(|cx: &InjectCx<'_, SqliteDB>, table| {
    if cx.kind == BuildKind::Root && cx.key == "audit_log" {
        return Err(vantage_core::error!("audit_log is not exposed"));
    }
    let tenant = AnySqliteType::new("acme".to_string());
    let condition = table.data_source().eq_value_condition("tenant_id", tenant)?;
    table.add_condition(condition);
    Ok(())
});
let factory = db.vista_factory().with_injector(tenant_only);
let _ = factory;
Ok(())
}

InjectCx carries the spec key, the BuildKind (Root, Reference { from, relation } or Base { derived }), and build, which builds another table by key through the same factory and injector. That is how an injector expresses “orders the caller may see are those of clients the caller may see”: it builds client and narrows by its ids. A build that would re-enter a key already being built is an error naming the cycle (a -> b -> a), not a stack overflow.

On a Base build only the conditions take effect. The derived vista is a fresh table over the base’s select(), so the base’s conditions carry into it but hooks an injector registers on the base never fire. The injector runs on the derived build too (as Root or Reference); register hooks there. BuildKind and InjectCx are #[non_exhaustive], so match BuildKind with a wildcard arm.

The SQLite, Postgres, MySQL, SurrealDB, MongoDB and CSV factories take with_injector. A reference target that fails injection fails its traversal, as above. A vista whose writes go through Table, so that the injector’s hooks fire on them, advertises Capability::WritesReachHooks (writes_reach_hooks).


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. A script calls them through its backend’s namespace (sql::count(..), surreal::count(..), mongo::oid(..)), so they never shadow the data vocabulary’s table(name). 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: |
    sql::select().from("product").field("id").field("name").where(sql::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 leaves Insert, Update, Delete and ConfineWrites out of its capabilities, 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(sql::expr("SUM(price) AS total_price"))
        .group_by(sql::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, MongoDB, MySQL)

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(surreal::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:. Writability follows the underlying source: a modify: on a rhai: or base: vista stays read-only, while on a plain table it stays writable. MySQL takes the same script in a mysql: block (mysql: { modify: "..." }), with the with_condition(expr) verb, which hands its expression to the shell as a native condition (from Rust: Vista::add_native_condition).

4. Scripted reference traversal — reference-level rhai: (SurrealDB, MongoDB, MySQL)

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.

MongoDB takes the same script in the reference’s mongo: block (rhai feature):

references:
  orders:
    table: order
    kind: has_many
    foreign_key: client_id
    mongo:
      rhai: |
        table("order").where("client_id", row._id)

MySQL takes it in the reference’s mysql: block (mysql: { rhai: "..." }, rhai feature).

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 Catalog

A single Vista is strictly single-backend. When a system spans several — categories in Postgres, products behind a REST API — a Catalog sits one layer up: it holds a loader per model name, and a relation whose target lives in a different persistence is a Reference narrowed over the target the catalog builds. Here two in-memory SQLite databases stand in for the two backends:

use ciborium::Value as CborValue;
use vantage_sql::prelude::*;
use vantage_types::Record;
use vantage_vista::{Catalog, JoinKey, Reference, VistaFactory, VistaResolver};

#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let pg = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let api = SqliteDB::connect("sqlite::memory:").await.context("connect")?;
let category_yaml = "name: category\ncolumns:\n  id: { type: string, flags: [id] }\n".to_string();
let product_yaml =
    "name: product\ncolumns:\n  id: { type: string, flags: [id] }\n  category_id: { type: string }\n"
        .to_string();
let (pg_factory, api_factory) = (pg.vista_factory(), api.vista_factory());
let catalog = Catalog::new()
    .with("category", move |_: &str| pg_factory.from_yaml(&category_yaml))
    .with("product", move |_: &str| api_factory.from_yaml(&product_yaml));
let relation = Reference::has_many(
    "products",
    "product",
    JoinKey::new("id", "category_id"), // parent `id` constrains the target's `category_id`
);

let row: Record<CborValue> = [("id".to_string(), CborValue::Text("pastry".into()))].into_iter().collect();
let products = relation.narrow(catalog.vista(&relation.target)?, &row)?;  // one backend → the other
assert!(products.is_narrowed());
Ok(())
}

Loaders return a fresh, unconditioned Vista on every call, so the catalog composes with hot-reloading inventories the same way spec resolvers do. A name the catalog doesn’t hold is a not-found error; a loader that fails keeps its own error. The catalog is a VistaResolver, so it goes wherever a resolver is expected: it 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.