Skip to main content

2 posts tagged with "graph"

View All Tags

Saving Object Graphs Is Not Just Insert

· 5 min read
TeaQL Code Gen
Core Contributor

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.

Graph Writes and Query DSL

· One min read
TeaQL Code Gen
Core Contributor

The Rust runtime now supports typed graph mutations and a fluent query DSL.

Typed Entity Graph Saves

let mut graph = EntityGraph::new();
graph.add(User::new().name("Alice"));
graph.add(Order::new().user_id("Alice.id"));

db.save_graph(&graph).await?;

The runtime validates foreign keys, plans insertion order, and executes within a transaction.

SQL Id Space Generator

let id_gen = SqlIdSpace::new("user_id_seq", 1000);
let batch = id_gen.next_batch(10).await?;

Reserves id ranges in bulk. Reduces database round-trips during bulk inserts.

Decimal for Aggregates

let total: Decimal = db.query(Order::total())
.filter(Order::status().eq("paid"))
.sum()
.await?;

rust_decimal replaces f64 for all aggregate results. Eliminates floating-point drift in financial calculations.

Query DSL

let users = db.query(User::class())
.filter(User::age().gte(18))
.order_by(User::created_at().desc())
.limit(20)
.list()
.await?;

Methods chain naturally. The macro expands field references into type-safe column identifiers at compile time.

Checker Events

Graph mutations emit checker events:

  • Pre-save validation
  • Post-save side effects
  • Rollback on failure

This hooks into the transactional planner for complex business rules.