Graph Queries¶
Graph queries are ORMDB's solution to the N+1 problem. Instead of issuing separate queries for related data, you specify what relations to include and ORMDB fetches everything in a single request.
The N+1 Problem¶
The N+1 problem is one of the most common performance issues in applications using ORMs. Consider this scenario:
# Traditional ORM approach
users = User.all() # 1 query
for user in users:
print(user.posts) # N queries (one per user)
If you have 100 users, this generates 101 database queries. With nested relations, it gets exponentially worse.
ORMDB Solution¶
let query = GraphQuery::new("User")
.include("posts"); // All users + all their posts in ONE query
let result = client.query(query).await?;
ORMDB loads all users and all related posts in a single round trip. The database handles the join internally, eliminating network overhead.
How Graph Queries Work¶
Basic Structure¶
A GraphQuery specifies:
- Root entity - The starting point (e.g., "User")
- Fields - Which fields to return (optional, defaults to all)
- Filter - Conditions for root entities
- Includes - Related entities to fetch
- Pagination - Limit and offset
GraphQuery::new("User")
.with_fields(vec!["id", "name", "email"])
.with_filter(FilterExpr::eq("status", Value::String("active")))
.include(RelationInclude::new("posts")
.with_fields(vec!["id", "title"]))
.with_pagination(Pagination::new(10, 0))
Nested Includes¶
You can include relations of relations using dot notation:
GraphQuery::new("User")
.include("posts") // User's posts
.include("posts.comments") // Posts' comments
.include("posts.comments.author") // Comments' authors
This creates a tree of related data:
Include Options¶
Each include can have its own filter, pagination, and field selection:
RelationInclude::new("posts")
.with_fields(vec!["id", "title", "published_at"])
.with_filter(FilterExpr::eq("published", Value::Bool(true)))
.with_order(OrderSpec::desc("published_at"))
.with_pagination(Pagination::new(5, 0)) // Latest 5 posts per user
Query Planning¶
When you submit a graph query, ORMDB's query planner:
- Resolves schema - Validates entities and relations exist
- Validates fields - Ensures requested fields are defined
- Checks depth - Enforces maximum include depth
- Optimizes order - Reorders includes by estimated fanout
- Enforces budget - Ensures query won't exceed limits
Query Plan Structure¶
pub struct QueryPlan {
pub root_entity: String,
pub fields: Vec<String>,
pub filter: Option<FilterExpr>,
pub order_by: Vec<OrderSpec>,
pub pagination: Option<Pagination>,
pub includes: Vec<IncludePlan>,
pub budget: FanoutBudget,
}
Fanout Budgets¶
Fanout budgets prevent runaway queries that could overwhelm the database or return massive result sets.
Default Limits¶
FanoutBudget {
max_entities: 10_000, // Total entities across all blocks
max_edges: 50_000, // Total relationships traversed
max_depth: 5, // Maximum include nesting
}
Custom Budgets¶
For specific queries, you can set custom limits:
let budget = FanoutBudget::new(
100, // max_entities
500, // max_edges
3, // max_depth
);
let plan = planner.plan_with_budget(&query, budget)?;
Budget Enforcement¶
If a query would exceed its budget, ORMDB returns an error:
This happens at planning time, not execution time, so you get fast feedback.
Fanout Estimation¶
ORMDB estimates fanout based on relation cardinality:
| Cardinality | Estimated Fanout |
|---|---|
| OneToOne | 1 |
| OneToMany | 10 |
| ManyToMany | 25 |
The planner uses these estimates to optimize include order and check budgets.
Query Execution¶
Execution Flow¶
- Root fetch - Load root entities matching filter
- Include batching - For each include, batch-fetch related entities
- Edge linking - Build the entity-to-entity relationships
- Result assembly - Return entities + edges as structured result
Join Strategies¶
For each include, the executor chooses between:
Nested Loop Join - Simple O(N*M), efficient for small parent sets
for parent_id in parent_ids {
let children = fetch_children(parent_id)?;
results.extend(children);
}
Hash Join - O(N+M), efficient for larger datasets
// Build hash map of child_id -> child
let children: HashMap<_, _> = fetch_all_children();
// Probe for each parent
for parent in parents {
let related = children.get(&parent.id);
}
The executor automatically selects the best strategy based on estimated result sizes.
Result Structure¶
Query results contain two types of blocks:
Entity Blocks¶
Collections of entities grouped by type:
Edge Blocks¶
Relationships between entities:
Navigating Results¶
let result = client.query(query).await?;
// Get all users
for user in result.entities("User") {
println!("User: {}", user.get_string("name")?);
// Get related posts for this user
for post in result.related(&user, "posts") {
println!(" Post: {}", post.get_string("title")?);
}
}
Performance Comparison¶
Benchmark: Users with Posts¶
Loading 100 users with their posts (avg 5 posts per user):
| Approach | Queries | Time |
|---|---|---|
| N+1 (separate queries) | 101 | 45ms |
| SQL JOIN | 1 | 8ms |
| ORMDB Graph Query | 1 | 7ms |
Benchmark: Nested Relations¶
Loading users with posts and comments (100 users, 500 posts, 2000 comments):
| Approach | Queries | Time |
|---|---|---|
| N+1 | 2,601 | 890ms |
| Manual batching | 3 | 35ms |
| ORMDB Graph Query | 1 | 28ms |
ORMDB's graph queries match or exceed manually optimized SQL while providing a simpler API.
Best Practices¶
1. Select Only Needed Fields¶
// Good: Only fetch what you need
GraphQuery::new("User")
.with_fields(vec!["id", "name"])
.include(RelationInclude::new("posts")
.with_fields(vec!["id", "title"]))
// Avoid: Fetching all fields when you only need a few
GraphQuery::new("User")
.include("posts")
2. Use Pagination for Large Result Sets¶
// Good: Paginate root entities
GraphQuery::new("User")
.with_pagination(Pagination::new(20, 0))
.include("posts")
// Good: Paginate includes too
GraphQuery::new("User")
.include(RelationInclude::new("posts")
.with_pagination(Pagination::new(10, 0)))
3. Filter at the Deepest Level¶
// Good: Filter where data lives
GraphQuery::new("User")
.include(RelationInclude::new("posts")
.with_filter(FilterExpr::eq("published", Value::Bool(true))))
// Avoid: Loading everything then filtering in application
4. Mind the Depth¶
Deeply nested includes can still cause performance issues:
// Be cautious with deep nesting
GraphQuery::new("User")
.include("posts")
.include("posts.comments")
.include("posts.comments.author")
.include("posts.comments.author.profile") // Getting deep...
Consider whether you really need all that data in one query.
Common Patterns¶
Eager Loading for Display¶
Load all data needed for a page in one query:
// Dashboard: User profile with recent activity
let query = GraphQuery::new("User")
.with_filter(FilterExpr::eq("id", Value::Uuid(user_id)))
.include(RelationInclude::new("posts")
.with_order(OrderSpec::desc("created_at"))
.with_pagination(Pagination::new(5, 0)))
.include(RelationInclude::new("notifications")
.with_filter(FilterExpr::eq("read", Value::Bool(false)))
.with_pagination(Pagination::new(10, 0)));
List with Preview¶
Load list items with a preview of related data:
// Blog post list with author info
let query = GraphQuery::new("Post")
.with_fields(vec!["id", "title", "excerpt", "published_at"])
.with_filter(FilterExpr::eq("published", Value::Bool(true)))
.with_order(OrderSpec::desc("published_at"))
.with_pagination(Pagination::new(20, 0))
.include(RelationInclude::new("author")
.with_fields(vec!["id", "name", "avatar_url"]));
Recursive Relationships¶
For self-referential relations (like comments with replies):
// Comments with one level of replies
let query = GraphQuery::new("Comment")
.with_filter(FilterExpr::null("parent_id")) // Top-level comments
.include(RelationInclude::new("replies")
.with_pagination(Pagination::new(3, 0))); // First 3 replies
Next Steps¶
- Typed Protocol - How queries are structured
- Performance Guide - Optimization tips
- Filtering - Filter expression reference