Saving Object Graphs Is Not Just Insert
Object graph persistence looks simple until the first update path arrives. A new parent with new children is close to a recursive insert. A real business object graph is not.
TeaQL's save_graph runtime exists because the runtime needs to understand
entity identity, relation ownership, reference semantics, missing children,
soft delete, and transaction boundaries at the same time.
The Easy Case
The simple case is a parent object with children:
let order = Order {
id: 1,
version: 1,
name: "graph-order".to_owned(),
lines: SmartList::from(vec![
OrderLine {
id: 10,
order_id: 0,
name: "first-line".to_owned(),
product_id: 100,
product: Some(Product {
id: 100,
name: "keyboard".to_owned(),
}),
},
]),
};
repo.save_entity_graph(order)?;
The runtime can create the product, create the order, attach the line to the order, and create the line. If that were the whole problem, graph save would be a convenience wrapper over insert.
The harder cases are the reason TeaQL treats graph persistence as a plan.
Identity and State
When a graph reaches the runtime, each node has to be classified:
Create: the row does not exist yet and should be inserted;Update: the row exists and selected fields should be changed;Reference: the row must exist, but the graph should not mutate it;Remove: the row is explicitly removed from a relation or graph.
Those states are not interchangeable. A referenced row should fail if it does not exist. A removed row should not silently become an update. A create with a duplicate id should not be treated as a harmless no-op.
TeaQL exposes this through graph operation hints and validates the state against the repository when needed.
Relation Ownership
An object relation may attach a foreign key, or it may be detached. A one-to-many relation may delete missing children, or it may keep missing children untouched.
That relation metadata matters during updates:
#[teaql(relation(
target = "OrderLine",
local_key = "id",
foreign_key = "order_id",
many,
delete_missing = false
))]
lines: SmartList<OrderLine>,
With delete_missing = false, sending an order with one line does not mean all
other order lines should be deleted. Without that metadata, a graph update can
look correct in tests and still be dangerous in production.
Attach metadata is similar. Some relations express ownership and should write a foreign key. Others are navigational and should not.
Missing Children
The most common graph update question is what to do with children that were in the database but are missing from the submitted graph.
There are several possible meanings:
- the caller did not load that child and is not trying to change it;
- the caller loaded the full relation and intentionally removed that child;
- the relation is a projection and should not imply ownership;
- the child should be soft-deleted rather than physically deleted.
TeaQL avoids guessing from the shape alone. Relation descriptors carry the policy. The graph planner uses that policy to decide whether to keep missing rows or plan a remove/soft-delete operation.
Planning Before Execution
The runtime builds a mutation plan before executing:
Order:
update: [id=1, fields=name]
OrderLine:
create: [id=12]
update: [id=10, fields=name]
delete: [id=11]
Product:
reference: [id=100]
This plan is useful for three reasons.
First, it lets TeaQL validate relation semantics before making changes.
Second, it allows compatible mutations to be batched by entity type and updated field set.
Third, it gives developers a debugging surface. UserContext::plan_for_save_graph()
can be used to inspect what the runtime thinks it is going to do.
Transactions Are Part of the Feature
A graph write that touches multiple tables has to be transactional. If a child insert fails after the parent update succeeds, the application now has a partial business object.
TeaQL requires graph writes to go through a transactional executor. SQLite and PostgreSQL SQLx paths both have transaction-boundary support. The test suite verifies rollback behavior for graph writes.
That requirement is deliberately strict. A graph write without a transaction is usually worse than no graph-write helper at all.
Typed Entity Graphs
Generated service crates should not force application code to build raw graph nodes by hand. The current generated API can work with typed entities and turn them into graph nodes internally:
merchant
.update_name("TeaQL Merchant")
.update_platform_id(1_u64)
.save(&ctx)
.await?;
The runtime still uses a generic graph representation after extraction. That is intentional. The typed layer is for application ergonomics; the generic layer is for planning, batching, validation, and execution.
What This Is Not
This is not a general object database. The database remains relational. TeaQL does not try to hide SQL or table design completely.
It is also not a replacement for explicit migrations. Graph writes operate
against descriptors and repositories. Schema evolution is handled separately by
the additive ensure_schema path for local development and tests, or by a
proper migration process in production.
Why It Matters
In small systems, graph save can be overkill. In large business systems, object graphs are often the natural unit of change: an order and its lines, a platform and its merchants, a service contract and its documents.
The hard part is not creating the first version. The hard part is updating the second version without losing data, silently mutating references, or leaving a partial graph after a failure.
That is why TeaQL treats object graph persistence as planning first and execution second.
