Step 4: Table Abstraction and Entity CRUD
The same entities get used hundreds of times across a codebase — constructing a query from scratch
every single time is tedious and error-prone. Vantage offers Table<> as an abstraction over your
entity definitions: it knows the table name, the columns, their types, and the ID field, so it can
build queries for you.
To use your persistence backend as a table source, you need to implement the TableSource trait.
Most of the heavy-lifting is done by the vantage-table crate — your job is to implement
TableSource trait methods.
Implement TableSource with placeholder methods
Start by adding the required dependencies:
# in your backend's Cargo.toml
vantage-table = { path = "../vantage-table" }
async-trait = "0.1"
Create a new test file (e.g. tests/<backend>/4_table_def.rs) that defines a table and populates
its columns. The columns rely on the type system you built in Step 1.
The TableSource implementation also declares several associated types:
Column— theColumntype supplied byvantage-tableis good enough for most backends.AnyTypeandValue— your type-erased value type from Step 1 (e.g.AnySqliteType).Id— useStringfor SQL databases, or a custom type if your IDs have special structure (e.g. SurrealDB’sThingwhich encodestable:id). Whatever you pick must be covered by your type system.
#![allow(unused)]
fn main() {
use async_trait::async_trait;
use vantage_table::column::core::{Column, ColumnType};
use vantage_table::traits::table_source::TableSource;
#[async_trait]
impl TableSource for SqliteDB {
type Column<Type> = Column<Type> where Type: ColumnType;
type AnyType = AnySqliteType;
type Value = AnySqliteType;
type Id = String;
// ...
}
}
Implement the following methods first — they’re all straightforward delegations:
- Column management —
create_column,to_any_column,convert_any_column:
#![allow(unused)]
fn main() {
fn create_column<Type: ColumnType>(&self, name: &str) -> Self::Column<Type> {
Column::new(name)
}
fn to_any_column<Type: ColumnType>(
&self,
column: Self::Column<Type>,
) -> Self::Column<Self::AnyType> {
Column::from_column(column)
}
fn convert_any_column<Type: ColumnType>(
&self,
any_column: Self::Column<Self::AnyType>,
) -> Option<Self::Column<Type>> {
Some(Column::from_column(any_column))
}
}
- Expression factory —
expr():
#![allow(unused)]
fn main() {
fn expr(
&self,
template: impl Into<String>,
parameters: Vec<ExpressiveEnum<Self::Value>>,
) -> Expression<Self::Value> {
Expression::new(template, parameters)
}
}
Every other method — should start as todo!(). You’ll implement them incrementally in the following
sections, driven by tests.
Define entity tables
With TableSource in place, define your entity structs and table constructors. The pattern is the
same across all backends — #[entity(YourType)] for the struct, plus a builder method that returns
Table<YourDB, Entity>:
#![allow(unused)]
fn main() {
use vantage_sql::sqlite::{SqliteType, SqliteDB, AnySqliteType};
use vantage_table::table::Table;
use vantage_types::entity;
#[entity(SqliteType)]
#[derive(Debug, Clone, PartialEq, Default)]
struct Product {
name: String,
calories: i64,
price: i64,
bakery_id: String,
is_deleted: bool,
inventory_stock: i64,
}
impl Product {
fn sqlite_table(db: SqliteDB) -> Table<SqliteDB, Product> {
Table::new("product", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_column_of::<i64>("calories")
.with_column_of::<i64>("price")
.with_column_of::<String>("bakery_id")
.with_column_of::<bool>("is_deleted")
.with_column_of::<i64>("inventory_stock")
}
}
}
Note that the entity struct does not include the id field — that’s handled separately by
with_id_column(), which registers the column and sets the table’s ID field. The remaining columns
are added with with_column_of::<Type>(), which creates typed columns via your
TableSource::create_column implementation.
Verify with a query generation test
Your first test should build a table, then call table.select(). Just like the Step 3 tests, you
can use preview() to check the rendered SQL, and later execute it against a real database:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_product_select() {
let db = SqliteDB::connect("sqlite::memory:").await.unwrap();
let table = Product::sqlite_table(db);
let select = table.select();
assert_eq!(
select.preview(),
"SELECT \"id\", \"name\", \"calories\", \"price\", \
\"bakery_id\", \"is_deleted\", \"inventory_stock\" FROM \"product\""
);
}
}
This works because table.select() (provided by vantage-table) calls your
SelectableDataSource::select() to get a fresh SELECT builder, then applies the table name via
set_source() and adds each registered column via add_field(). None of the todo!() methods are
hit — only the column and expression infrastructure you already implemented.
Implement the read methods
Table<T, E> implements two traits from vantage-dataset that provide read access:
-
ReadableValueSet— returns rawRecord<Value>(untyped storage values):list_values()→ all records asIndexMap<Id, Record<Value>>get_value(id)→Option<Record<Value>>—Noneif no record matches the idget_some_value()→ one arbitrary record (orNoneif empty)
-
ReadableDataSet<E>— returns deserialized entities (callsE::try_from_record()for you):list()→ all entities asIndexMap<Id, E>get(id)→Option<E>—Noneif no entity matches the idget_some()→ one arbitrary entity
Both traits delegate to three TableSource methods: list_table_values, get_table_value, and
get_table_some_value. The pattern is the same for all three:
- Get the id field name from
table.id_field()(falls back to"id") - Build a SELECT using
table.select()(which already applies columns, conditions, ordering) - Execute via
self.execute(&select.expr()) - Parse the result — split each row into an ID and a
Record
For get_table_value, add a WHERE condition on the id field and return Ok(None) when the
lookup misses — errors are reserved for actual connection or parse failures.
For get_table_some_value, set LIMIT 1 and return the first row (or None if empty).
Write tests for both ReadableValueSet and ReadableDataSet in separate files — import the traits
from vantage_dataset and call list_values(), get_value(), get_some_value(), list(),
get(), get_some() against your pre-populated test database. Keep these tests condition-free —
conditions get their own test file next.
Error handling
All TableSource methods return vantage_core::Result<T> (an alias for Result<T, VantageError>).
Use the error! macro from vantage_core to create errors with structured context:
#![allow(unused)]
fn main() {
use vantage_core::error;
// Simple error message
return Err(error!("expected array result"));
// With key = value context (NOT format args — the macro uses a different syntax)
return Err(error!("row missing id field", field = id_field_name));
// For database-specific errors, convert them with map_err
let rows = query.fetch_all(self.pool()).await
.map_err(|e| error!("SQLite query failed", details = e.to_string()))?;
}
The macro automatically captures file, line, and column. The key = value pairs are stored as
structured context, not interpolated into the message string.
To wrap external errors with additional context, use the Context trait:
#![allow(unused)]
fn main() {
use vantage_core::Context;
// Wraps the original error as the "source" of a new VantageError
let data = std::fs::read("config.json")
.context(error!("failed to load config"))?;
}
This chains errors — the original io::Error is preserved as the source, so Display renders both
messages and the source chain is available via std::error::Error::source().
Operation trait — condition building
Each backend provides an operation trait (e.g. SqliteOperation) with .eq(), .ne(), .gt(),
.gte(), .lt(), .lte(), and .in_() methods for building conditions. It has a blanket
implementation for all Expressive<T> types, so your columns get these methods automatically — no
explicit impl needed.
All methods accept impl Expressive<YourAnyType>, so you can pass native Rust values (false,
42, "hello"), other columns (table["other_field"]), or full expressions. This requires your
scalar types to implement Expressive<YourAnyType> — the same impls you added in Step 1 for the
vendor macro.
Testing conditions
Table carries conditions set via add_condition(), and table.select() applies them
automatically as WHERE clauses. Test a few patterns:
- Custom expression — pass columns as expression arguments via
table["field"]:
#![allow(unused)]
fn main() {
let mut table = Product::sqlite_table(db);
table.add_condition(sqlite_expr!("{} > {}", (table["price"]), 130));
}
- Multiple conditions — combined with AND, including field-to-field comparison:
#![allow(unused)]
fn main() {
let mut table = Product::sqlite_table(db);
table.add_condition(sqlite_expr!("{} > {}", (table["price"]), 130));
table.add_condition(sqlite_expr!("{} > {}", (table["price"]), (table["calories"])));
}
- SqliteOperation::eq() — the idiomatic way:
#![allow(unused)]
fn main() {
use vantage_sql::sqlite::operation::SqliteOperation;
let mut table = Product::sqlite_table(db);
table.add_condition(table["is_deleted"].eq(false));
}
Implement aggregates
Implement get_table_count, get_table_sum, get_table_max, and get_table_min in your
TableSource. These build aggregate queries from table.select() and extract the scalar result.
Once implemented, Table exposes shorter get_count, get_sum, get_max, get_min methods
directly:
#![allow(unused)]
fn main() {
let table = Product::sqlite_table(db);
assert_eq!(table.get_count().await.unwrap(), 5);
assert_eq!(table.get_max(&table["price"]).await.unwrap().try_get::<i64>().unwrap(), 299);
}
Implement write operations
Table also implements WritableDataSet (insert, replace, patch, delete) and InsertableDataSet
(insert with auto-generated ID). These delegate to six TableSource methods:
insert_table_value— INSERT with a known ID. Build anSqliteInsertwith the id field and record fields, execute, then read back viaget_table_value.replace_table_value— full replacement. For SQLite, useINSERT OR REPLACE INTO.patch_table_value— partial update. Build anSqliteUpdatewith only the provided fields and a WHERE condition on the id field.delete_table_value— DELETE with a WHERE condition on the id field.delete_table_all_values— DELETE without conditions.insert_table_return_id_value— INSERT without a known ID (auto-increment). UseRETURNING "id"to get the generated ID back from the database.
Test both WritableValueSet (raw records, no entity) and WritableDataSet (typed entities) using
in-memory SQLite:
#![allow(unused)]
fn main() {
// WritableValueSet — no entity needed
let rec = record(&[("name", "Gamma".into()), ("price", 30i64.into())]);
table.insert_value(&"c".to_string(), &rec).await.unwrap();
// WritableDataSet — typed entities
let item = Item { name: "Gamma".into(), price: 30 };
table.insert(&"c".to_string(), &item).await.unwrap();
// InsertableDataSet — auto-generated ID
let id = table.insert_return_id(&item).await.unwrap();
let fetched = table.get(id).await.unwrap();
}