Tables and Typed Data Access
A table is a structure representing a collection of records in a database:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
name: String,
price: i64,
}
impl Product {
fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("price")
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db")
.await
.context("Failed to connect to products.db")?;
let products = Product::table(db);
for (id, product) in products.list().await? {
println!("{id}: {} — {} cents", product.name, product.price);
}
Ok(())
}
Think of Table<SqliteDB, Product> as a Vec<Product> — except the records aren’t in memory. They
live somewhere else (a database, a file, an API), and you don’t know how many there are or what they
contain until you list them.
If you’ve used an ORM before, you might expect Vantage to work with individual records — load one, change it, save it back. Vantage works differently: you always operate on a set of records. A set might contain one record, no records, or millions — you don’t pull them into memory to find out.
Operations like counting, filtering, and updating happen on the database side — Vantage builds the right query and sends it over. Even traversal operations and sub-queries are done by building queries and executing remotely — if the database supports it, of course.
When you .clone() a table, you don’t clone the data — you clone the definition. From there you
can narrow it down by adding conditions, turning it into a subset. Some examples of sets you might
work with:
- all user records except those with a soft-delete flag
- orders placed today
- orders of paid customers
- customers who have an unpaid invoice older than 30 days
- products that sold at least 5 items today
Each of these is a set — defined by conditions, not a list of IDs — and one set can serve as the condition of another (“orders of paid customers”). Acting on a set is a single call: notify those customers, archive those orders, apply that discount. Put together, even complex operations are easy to express in code:
use vantage_sql::prelude::*;
use vantage_sql::primitives;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Customer {
name: String,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Invoice {
status: String,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Order {
total: i64,
}
impl Customer {
fn table(db: SqliteDB) -> Table<SqliteDB, Customer> {
Table::new("customer", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_many("orders", "customer_id", Order::table)
}
}
impl Invoice {
fn table(db: SqliteDB) -> Table<SqliteDB, Invoice> {
Table::new("invoice", db)
.with_id_column("id")
.with_column_of::<String>("status")
.with_column_of::<String>("issued_at")
.with_one("customer", "customer_id", Customer::table)
}
}
impl Order {
fn table(db: SqliteDB) -> Table<SqliteDB, Order> {
Table::new("order", db)
.with_id_column("id")
.with_column_of::<i64>("total")
.with_column_of::<String>("placed_at")
}
}
fn days_ago(days: i64) -> Expression<AnySqliteType> {
let offset = format!("-{days} days");
sqlite_expr!("date('now', {})", offset)
}
trait InvoiceTable {
fn ref_customer(&self) -> Table<SqliteDB, Customer>;
}
impl InvoiceTable for Table<SqliteDB, Invoice> {
fn ref_customer(&self) -> Table<SqliteDB, Customer> {
self.get_ref_as("customer").unwrap()
}
}
trait CustomerTable {
fn subquery_orders(&self) -> Table<SqliteDB, Order>;
async fn send_reminder(&self) -> VantageResult<()>;
}
impl CustomerTable for Table<SqliteDB, Customer> {
fn subquery_orders(&self) -> Table<SqliteDB, Order> {
self.get_subquery_as("orders").unwrap()
}
async fn send_reminder(&self) -> VantageResult<()> {
for (_, customer) in self.list().await? {
println!("reminder sent to {}", customer.name);
}
Ok(())
}
}
trait OrderTable {
fn only_this_month(self) -> Self;
fn field_sum(&self, column: &str) -> Expression<AnySqliteType>;
}
impl OrderTable for Table<SqliteDB, Order> {
fn only_this_month(self) -> Self {
let placed_at = self["placed_at"].clone();
self.with_condition(placed_at.gt(sqlite_expr!("date('now', 'start of month')")))
}
fn field_sum(&self, column: &str) -> Expression<AnySqliteType> {
self.get_sum_query(&self[column])
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:shop.db").await.context("Failed to connect")?;
// "notify customers who have an unpaid invoice older than 30 days"
let mut overdue = Invoice::table(db.clone());
overdue.add_condition(overdue["status"].eq("unpaid"));
overdue.add_condition(overdue["issued_at"].lt(days_ago(30)));
overdue.ref_customer().send_reminder().await?;
// "apply a discount to customers who spent more than $500 this month"
let customers = Customer::table(db)
.with_expression("spent_this_month", |c| {
c.subquery_orders().only_this_month().field_sum("total")
})
.with_expression("discount", |c| {
let spent = c.get_column_expr("spent_this_month").unwrap();
primitives::ternary(spent.gt(500), 10, 0).expr()
});
Ok(())
}
Don’t worry about the exact syntax — we’ll get to all of it. ref_customer, send_reminder,
subquery_orders, only_this_month and field_sum aren’t framework calls: they’re methods the
model defines on its own tables, and Extension traits below shows how. The
point is that sets compose naturally: define one, use it to narrow another, then act on the
result.
Defining a Table
The syntax for defining a table looks similar to query building from chapter 1, but there are important differences:
| Query | Table | |
|---|---|---|
| Lifetime | Built, executed, dropped | Sticks around, spawns many queries |
| Operations | One SQL statement (SELECT, INSERT, …) | Higher-level CRUD: list, get, add, patch, delete |
| Columns | String field names via .with_field() | Typed Column<T> definitions |
| Database | Not bound — just a struct | Holds a database reference (Arc) |
| Idempotency | INSERT fails on duplicate key | every write is retry-safe (Safe writes) |
| Data sources | Only databases with a query language | Any data source: SQL, CSV, APIs, queues, etc. |
A Table is typically defined in its own file alongside the entity.
Create src/product.rs:
// src/product.rs
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
pub name: String,
pub price: i64,
}
impl Product {
pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
let is_deleted = Column::<bool>::new("is_deleted");
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("price")
.with_column_of::<bool>("is_deleted")
.with_condition(is_deleted.eq(false))
}
}
fn main() {}
In most cases you already know which database a table lives in — passing db every time
is noise. A common pattern is to wrap the connection in a global accessor:
#![allow(unused)]
fn main() {
impl Product {
fn table() -> Table<SqliteDB, Product> {
Table::new("product", get_sqlite_db())
// ...columns...
}
}
}
We’ll keep passing db explicitly in this tutorial for clarity, but real applications
typically use this pattern.
This defines the product table once. You can now generate queries from it:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
name: String,
price: i64,
}
impl Product {
fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
let is_deleted = Column::<bool>::new("is_deleted");
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("price")
.with_column_of::<bool>("is_deleted")
.with_condition(is_deleted.eq(false))
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let table = Product::table(db);
// Full SELECT with all columns and conditions applied
let select = table.select();
println!("{}", select.preview());
// SELECT "id", "name", "price", "is_deleted" FROM "product" WHERE is_deleted = 0
// Count query (does not execute — just builds the expression)
let count_query = table.get_count_query();
// (SELECT COUNT(*) FROM "product" WHERE is_deleted = 0)
// Sum query for a specific column
let sum_query = table.get_sum_query(&table["price"]);
// (SELECT SUM(price) FROM "product" WHERE is_deleted = 0)
assert_eq!(
select.preview(),
r#"SELECT "id", "name", "price", "is_deleted" FROM "product" WHERE is_deleted = 0"#
);
assert_eq!(
count_query.preview(),
r#"(SELECT COUNT(*) FROM "product" WHERE is_deleted = 0)"#
);
assert_eq!(
sum_query.preview(),
r#"(SELECT SUM(price) FROM "product" WHERE is_deleted = 0)"#
);
Ok(())
}
These return the same SqliteSelect and Expression types from chapter 1 — the table just
assembles them for you, with its conditions already applied. The count and sum queries come
wrapped in parentheses so they can nest inside another query as a subquery. But most of the time
you won’t need the raw query at all.
CRUD operations
With the table defined, all four operations — create, read, update, delete — are one-liners. Each comes from a trait in the prelude:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
name: String,
price: i64,
}
impl Product {
fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
let is_deleted = Column::<bool>::new("is_deleted");
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("price")
.with_column_of::<bool>("is_deleted")
.with_condition(is_deleted.eq(false))
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite:products.db")
.await
.context("Failed to connect to products.db")?;
let table = Product::table(db);
// Read — list all, get one by ID
let all = table.list().await?; // IndexMap<String, Product>
let pie = table.get("4").await?; // Option<Product> — id 4 is Pie
// Create — insert with a known ID
let muffin = Product { name: "Muffin".into(), price: 175 };
table.insert("8", &muffin).await?;
// Update — replace the entire record
let updated = Product { name: "Blueberry Muffin".into(), price: 195 };
table.replace("8", &updated).await?;
// Delete
table.delete("8").await?;
Ok(())
}
That’s it. list() returns an IndexMap<Id, Product> — ordered and keyed by ID. get() returns
Option<Product> — None when the ID doesn’t exist, so you can pattern-match or .ok_or(...) into
your own not-found error. There’s also get_some() which returns an (Id, Product) pair (still
Option-wrapped) for sampling, and insert_return_id() for when you want the database to generate
the ID; see Safe writes for why a client-made id is the
retry-safe choice.
Our id column is INTEGER PRIMARY KEY, yet every id above is a string. That’s deliberate:
at the Vantage API level ids are always strings — the one type that can carry any backend’s
key (SQLite rowids, Mongo ObjectIds, S3 object keys). The persistence binds the string into
the id-column comparison when it builds the query, so get("4") matches the integer 4.
You’ll meet the same convention wherever ids travel through the framework — including the
JSON output in the next chapter.
Try repeating the insert, replace and delete calls — the result is the same: an insert
of an existing id returns the stored row, a replace with the same values changes nothing, and
deleting a row that’s gone succeeds. See Safe writes.
Listing any table
Table<SqliteDB, Product> is great when you know the types at compile time. Sometimes you want a
function that lists any table — products, orders, customers — without naming the entity type.
With generics you keep full type safety, limited to Rust callers:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
async fn list_table<D, E>(table: &D) -> VantageResult<()>
where
D: ReadableDataSet<E>,
D::Id: std::fmt::Display,
E: Entity<D::Value> + std::fmt::Debug,
{
for (id, entity) in table.list().await? {
println!(" {}: {:?}", id, entity);
}
Ok(())
}
fn main() {}
The entity bound names the dataset’s own value type (Entity<D::Value>): an entity is only
an entity of a particular type system, and that’s what lets list() decode rows losslessly.
To go fully type-erased — iterate columns by name, get runtime values, and expose tables
outside Rust (a web admin UI, FFI, scripting) — wrap the table in a Vista, the universal
schema-bearing data handle. That’s the subject of its own chapter; Vista
replaced the older AnyTable carrier in 0.5.
Relationships
Our database has a category table that we haven’t used yet. Let’s define it and connect it to
products.
Create src/category.rs:
// src/category.rs
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use crate::product::Product;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Category {
pub name: String,
}
impl Category {
pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
Table::new("category", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_many("products", "category_id", Product::table)
}
}
mod product {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
pub name: String,
pub price: i64,
}
impl Product {
pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
let is_deleted = Column::<bool>::new("is_deleted");
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("price")
.with_column_of::<bool>("is_deleted")
.with_condition(is_deleted.eq(false))
}
}
}
fn main() {}
The important line is .with_many("products", "category_id", Product::table). This declares:
- “products” — the name of the relationship (you’ll use this to traverse it)
- “category_id” — the foreign key column on the
producttable Product::table— a function that builds the target table (same one we defined earlier)
No changes to Product are needed — the relationship is declared on the side that “owns” it.
Category has many products; the foreign key lives on product.
Given a set of categories, get_ref_as traverses the relationship and returns matching products:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
name: String,
price: i64,
}
impl Product {
fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
let is_deleted = Column::<bool>::new("is_deleted");
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("price")
.with_column_of::<bool>("is_deleted")
.with_condition(is_deleted.eq(false))
}
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Category {
name: String,
}
impl Category {
fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
Table::new("category", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_many("products", "category_id", Product::table)
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let categories = Category::table(db.clone());
let products = categories.get_ref_as::<Product>("products")?;
Ok(())
}
The result is a Table<SqliteDB, Product> with an extra condition — only products whose
category_id matches one of the category IDs. Narrow the categories first and the subquery narrows
too. Let’s use this in a CLI that accepts an optional search filter. The .with_search() method
adds a LIKE condition across all columns of the table — handy for quick filtering.
Update src/main.rs. Declare the two modules at the top with mod category; and mod product;,
then:
mod product {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
pub name: String,
pub price: i64,
}
impl Product {
pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
let is_deleted = Column::<bool>::new("is_deleted");
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("price")
.with_column_of::<bool>("is_deleted")
.with_condition(is_deleted.eq(false))
}
}
}
mod category {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use crate::product::Product;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Category {
pub name: String,
}
impl Category {
pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
Table::new("category", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_many("products", "category_id", Product::table)
}
}
}
use category::Category;
use product::Product;
use vantage_sql::prelude::*;
async fn list_products(table: &Table<SqliteDB, Product>) -> VantageResult<()> {
for (id, p) in table.list().await? {
println!(" {:<4} {:<20} {:>3} cents", id, p.name, p.price);
}
Ok(())
}
#[tokio::main]
async fn main() {
if let Err(e) = run().await {
e.report();
}
}
async fn run() -> VantageResult<()> {
let db = SqliteDB::connect("sqlite:products.db")
.await
.context("Failed to connect to products.db")?;
let filter = std::env::args().nth(1);
let products = match &filter {
Some(search) => Category::table(db.clone())
.with_search(search)
.get_ref_as::<Product>("products")?,
None => Product::table(db),
};
list_products(&products).await?;
Ok(())
}
Try it:
cargo run # all active products
cargo run "Sweet" # Cupcake, Doughnut, Cookies
cargo run "Pastries" # Tart, Pie
cargo run "t" # matches "Sweet Treats" AND "Pastries" — 5 products
The search "t" matches two categories (“Sweet Treats” and “Pastries”), so products from both are
returned.
Notice what happened with cargo run "t": the search matched two categories, and we got
products from both — without writing a JOIN or collecting IDs manually.
This is because Vantage relationships work through sets. with_search("t") narrowed
the category table to two rows. get_ref_as then generated a subquery:
SELECT ... FROM "product"
WHERE "category_id" IN (SELECT "id" FROM "category" WHERE "name" LIKE '%t%')
AND "is_deleted" = 0
The subquery comes from whatever conditions are on the source table. Add more conditions and the subquery narrows further. The relationship definition stays the same — you never need to rewrite the traversal logic.
Computed fields with expressions
So far, list_products only shows name and price. It would be nice to show the category name too —
but Product doesn’t have a category field, and the category name lives in a different table.
Vantage solves this with expressions — computed fields that are evaluated as part of the SELECT query. You define them on the table, and they appear alongside regular columns.
First, add a with_one relationship on Product (the reverse of with_many on Category):
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
name: String,
price: i64,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Category {
name: String,
}
impl Category {
fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
Table::new("category", db)
.with_id_column("id")
.with_column_of::<String>("name")
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let products: Table<SqliteDB, Product> = Table::new("product", db)
.with_id_column("id")
.with_column_of::<i64>("category_id")
.with_one("category", "category_id", Category::table)
;
Ok(())
}
This says: each product has one category, linked through category_id. Now you can use
get_subquery_as inside with_expression to build a correlated subquery:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Product {
name: String,
price: i64,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
struct Category {
name: String,
}
impl Category {
fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
Table::new("category", db)
.with_id_column("id")
.with_column_of::<String>("name")
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let products: Table<SqliteDB, Product> = Table::new("product", db)
.with_id_column("id")
.with_column_of::<i64>("category_id")
.with_one("category", "category_id", Category::table)
.with_expression("category", |t| {
t.get_subquery_as::<Category>("category")
.unwrap()
.select_column("name")
.expect("Category has a 'name' column")
})
;
Ok(())
}
select_column("name") builds a subquery like
SELECT "name" FROM "category" WHERE "id" = "product"."category_id" — a correlated subquery that
fetches the category name for each product row. It returns an Option, None when the target
table has no such column; the name here is hard-coded and known to exist, so .expect() is the
honest unwrap.
Here’s the updated src/product.rs:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use crate::category::Category;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Product {
pub name: String,
pub price: i64,
pub category: Option<String>,
}
impl Product {
pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
let is_deleted = Column::<bool>::new("is_deleted");
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("price")
.with_column_of::<bool>("is_deleted")
.with_condition(is_deleted.eq(false))
.with_one("category", "category_id", Category::table)
.with_expression("category", |t| {
t.get_subquery_as::<Category>("category")
.unwrap()
.select_column("name")
.expect("Category has a 'name' column")
})
}
}
mod category {
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
use crate::Product;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
pub struct Category {
pub name: String,
}
impl Category {
pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
Table::new("category", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_many("products", "category_id", Product::table)
}
}
}
fn main() {}
A few things to note:
category: Option<String>— the field isOptionbecause a product might not have a category (NULL in the database). The expression result is deserialized into this field automatically.- No
with_column_offor “category” — the expression replaces what would normally be a column. Vantage adds it to the SELECT as a computed field. with_one+with_expression— the relationship defines how to traverse; the expression defines what to fetch. They work together but serve different purposes.
Update list_products to show the category:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Debug, Clone, Default)]
struct Product {
name: String,
price: i64,
category: Option<String>,
}
async fn list_products(table: &Table<SqliteDB, Product>) -> VantageResult<()> {
for (id, p) in table.list().await? {
let cat = p.category.as_deref().unwrap_or("-");
println!(" {:<4} {:<20} {:>3} cents [{}]", id, p.name, p.price, cat);
}
Ok(())
}
fn main() {}
Run it:
cargo run
1 Cupcake 120 cents [Sweet Treats]
2 Doughnut 135 cents [Sweet Treats]
3 Tart 220 cents [Pastries]
4 Pie 299 cents [Pastries]
5 Cookies 199 cents [Sweet Treats]
7 Sourdough Loaf 350 cents [Breads]
The category name comes from a subquery — no JOIN, no extra round trip. And the category filter still works:
cargo run "t"
1 Cupcake 120 cents [Sweet Treats]
2 Doughnut 135 cents [Sweet Treats]
3 Tart 220 cents [Pastries]
4 Pie 299 cents [Pastries]
5 Cookies 199 cents [Sweet Treats]
Expressions can reference other expressions. For example, we could add a product_count
expression to Category that counts products via a subquery, then build a title expression
that combines the name and count:
#![allow(unused)]
fn main() {
// In Category::table()
.with_expression("product_count", |t| {
t.get_subquery_as::<Product>("products")
.unwrap()
.get_count_query()
})
.with_expression("title", |t| {
let name = t.get_column_expr("name").unwrap();
let count = t.get_column_expr("product_count").unwrap();
concat_!(name, " (", count, ")").expr()
})
}
get_column_expr returns the expression for either a real column or a computed expression
by name. Here it resolves "product_count" to the count subquery, so title becomes
something like name || ' (' || (SELECT COUNT(*) FROM product WHERE ...) || ')'.
Now change Product’s category expression to fetch title instead of name:
#![allow(unused)]
fn main() {
.with_expression("category", |t| {
t.get_subquery_as::<Category>("category")
.unwrap()
.select_column("title")
.expect("Category has a 'title' column")
})
}
select_column("title") resolves the title expression on Category and builds it into
the subquery. The result:
1 Cupcake 120 cents [Sweet Treats (3)]
2 Doughnut 135 cents [Sweet Treats (3)]
3 Tart 220 cents [Pastries (2)]
4 Pie 299 cents [Pastries (2)]
5 Cookies 199 cents [Sweet Treats (3)]
7 Sourdough Loaf 350 cents [Breads (1)]
Vantage renders everything into a single SQL query. The nested subqueries might look redundant, but modern SQL databases optimise and execute them efficiently — there’s no extra round trip to the database.
Extension traits
Throughout this chapter you’ve seen calls like categories.get_ref_as::<Product>("products"). The
turbofish ::<Product> and the string "products" are repetitive and error-prone — get the string
wrong and you get a runtime error.
Rust’s extension traits solve this. Define a trait on Table<SqliteDB, Category> that wraps the
traversal:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
name: String,
price: i64,
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Category {
name: String,
}
pub trait CategoryTable {
fn ref_products(&self) -> Table<SqliteDB, Product>;
}
impl CategoryTable for Table<SqliteDB, Category> {
fn ref_products(&self) -> Table<SqliteDB, Product> {
self.get_ref_as("products").unwrap()
}
}
fn main() {}
The trait declares the vocabulary; the impl block supplies the bodies. Nothing is
auto-implemented — ref_products() appears on Table<SqliteDB, Category> the moment you write
this impl, and on nothing else. The bodies must live impl-side, too: methods like get_ref_as are
inherent to Table, so they’re only reachable where self is concretely that table — inside the
impl, not inside a trait default.
The unwrap() is safe because we know “products” is defined on every CategoryTable. If the
string were wrong, it would panic immediately during development, not silently fail at runtime.
Now callers write categories.ref_products() — no turbofish, no string, no ?, and the compiler
catches typos.
The same pattern works for typed column access:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
name: String,
price: i64,
}
pub trait ProductTable {
fn price(&self) -> Column<i64>;
}
impl ProductTable for Table<SqliteDB, Product> {
fn price(&self) -> Column<i64> {
self.get_column("price").unwrap()
}
}
fn main() {}
With both traits in place, code reads naturally:
use vantage_sql::prelude::*;
use vantage_types::prelude::*;
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Product {
name: String,
price: i64,
}
impl Product {
pub fn table(db: SqliteDB) -> Table<SqliteDB, Product> {
let is_deleted = Column::<bool>::new("is_deleted");
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("price")
.with_column_of::<bool>("is_deleted")
.with_condition(is_deleted.eq(false))
}
}
#[entity(SqliteType)]
#[derive(Clone, Default)]
pub struct Category {
name: String,
}
impl Category {
pub fn table(db: SqliteDB) -> Table<SqliteDB, Category> {
Table::new("category", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_many("products", "category_id", Product::table)
}
}
pub trait CategoryTable {
fn ref_products(&self) -> Table<SqliteDB, Product>;
}
impl CategoryTable for Table<SqliteDB, Category> {
fn ref_products(&self) -> Table<SqliteDB, Product> {
self.get_ref_as("products").unwrap()
}
}
pub trait ProductTable {
fn price(&self) -> Column<i64>;
}
impl ProductTable for Table<SqliteDB, Product> {
fn price(&self) -> Column<i64> {
self.get_column("price").unwrap()
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> vantage_core::Result<()> {
let db = SqliteDB::connect("sqlite::memory:").await.context("Failed to connect")?;
let categories = Category::table(db);
let products = categories.ref_products();
let price = products.price();
let expensive = products.with_condition(price.gt(200));
assert!(expensive.select().preview().contains("price > 200"));
Ok(())
}
price() is read before with_condition because with_condition consumes the table it’s
called on; taking the column first keeps the borrow and the move apart.
Define extension traits in the same file as the entity — product.rs gets ProductTable,
category.rs gets CategoryTable. They’re part of your model’s public API: anyone who
imports the entity gets the typed accessors for free.
This is how Vantage scales to large codebases. The table definition (columns, conditions, relationships, expressions) is declared once. Extension traits give it a clean, typed API. Business logic code never sees raw strings or turbofish — just method calls.
Extension traits aren’t limited to column and relationship accessors — you can add any
business logic. For example, the vantage-cli-util crate provides print_table(), which
accepts any &Table<T, E> and renders it for the terminal. Declare the method on your trait
and implement it in the same impl block as the accessors:
#![allow(unused)]
fn main() {
pub trait ProductTable {
fn price(&self) -> Column<i64>;
async fn print(&self) -> VantageResult<()>;
}
impl ProductTable for Table<SqliteDB, Product> {
fn price(&self) -> Column<i64> {
self.get_column("price").unwrap()
}
async fn print(&self) -> VantageResult<()> {
vantage_cli_util::print_table(self).await
}
}
}
Now products.print().await? gives you a formatted table in the terminal:
+----+----------------+-------+-------------------+
| id | name | price | category |
+----+----------------+-------+-------------------+
| 1 | Cupcake | 120 | Sweet Treats (3) |
| 2 | Doughnut | 135 | Sweet Treats (3) |
| 3 | Tart | 220 | Pastries (2) |
| ... |
+----+----------------+-------+-------------------+
Other methods you might add: only_expensive() that returns a filtered clone,
total_revenue() that computes a sum, or export_csv() that writes to a file.
The table is yours to extend.
Persistence Abstraction
The files you have written so far — Cargo.toml, product.rs, category.rs, main.rs — are the
makings of an extensible business software architecture: scalable, maintainable, testable, and
portable across databases without rewriting a single line of business logic.
What you have is four distinct components, each with a clear job:
flowchart TD
A["<b>Business code</b><br/><code>main.rs</code><br/>minimal, domain-focused"]
B["<b>Model definitions</b><br/><code>product.rs</code>, <code>category.rs</code><br/>entities, tables, relationships, domain methods"]
C["<b>Persistence crate</b><br/><code>vantage-sql</code> (or surrealdb, mongodb, csv…)<br/>query builder, CRUD, type system"]
D["<b>Vantage framework</b><br/><code>vantage-table</code>, <code>vantage-expressions</code>, <code>vantage-types</code><br/>the extensible mechanism that ties it together"]
A --> B
B --> C
C --> D
The majority of your business code can work with data without any knowledge of where it’s stored or how. It operates on typed entities, relationships, and domain methods — nothing else.
The model definitions focus on describing where and how data is stored, but they don’t micro-manage the process. If storage requirements change — a new backend, a schema migration, a cached layer in front — the model is where you make the tweaks. Business code remains untouched.
This separation gives you the ergonomics scripting-language ORMs are known for — concise, domain-focused code — with Rust’s type safety and performance underneath: the abstraction compiles down to direct calls. When storage requirements shift, the change stays contained in the model layer instead of rippling through business code, and the whole architecture travels as a single static binary — server, desktop, or embedded.
Table has considerably more surface than one chapter can show. The reference pages pick up where
this one stops:
- Records: Traversal, Invariants & Hooks — lifecycle hooks (audit stamps, validation, soft-delete that intercepts a real delete), set invariants that fill and enforce foreign keys on every write through a relation, and traversing relations from a single loaded record.
- Expressions & Queries — the machinery underneath
with_expressionand every condition you wrote in this chapter. - Model-Driven Architecture — how entities, table constructors, and connection management are organised in a real model crate, including one entity with constructors for several backends.
- Three Paths for Developers — how this chapter’s entity framework relates to raw query building and custom persistences, and when to reach for each.
The pattern you’ve just built scales well beyond a product catalog:
- Client-side caching and reactive data — the second half of this guide puts a model behind a persistent cache with live updates streaming to a UI, over a slow external API or your own relational database — whichever path matches your problem.
- Cross-persistence traversal — follow a relationship from a Postgres table into a MongoDB collection, expressed in Rust, executed efficiently on each side.
- UI framework integration — the same model drives desktop, terminal, and web UIs through
vantage-ui-adapters: define once, render anywhere.