Relationships
Relationships
- Relationships
Overview
ic-dbms supports foreign key relationships between tables, providing:
- Referential integrity: Ensures foreign keys point to valid records
- Delete behaviors: Control what happens when referenced records are deleted
- Eager loading: Load related records in a single query
Defining Foreign Keys
Foreign Key Syntax
Use the #[foreign_key] attribute to define relationships:
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[table = "posts"]
pub struct Post {
#[primary_key]
pub id: Uint32,
pub title: Text,
pub content: Text,
#[foreign_key(entity = "User", table = "users", column = "id")]
pub author_id: Uint32,
}
Attribute parameters:
| Parameter | Description |
|---|---|
entity |
The Rust struct name of the referenced table |
table |
The table name (as specified in #[table = "..."]) |
column |
The column in the referenced table (usually the primary key) |
Foreign Key Constraints
When you define a foreign key:
- The field type must match the referenced column type
- The referenced table must be included in your
DbmsCanisterdefinition - Foreign key values must reference existing records (enforced on insert/update)
// Both tables must be in the canister definition
#[derive(DbmsCanister)]
#[tables(User = "users", Post = "posts")]
pub struct MyDbmsCanister;
Referential Integrity
ic-dbms enforces referential integrity automatically.
Insert Validation
When inserting a record with a foreign key, the referenced record must exist:
// This user exists
client.insert::<User>(User::table_name(), UserInsertRequest {
id: 1.into(),
name: "Alice".into(),
..
}, None).await??;
// Insert post referencing existing user - OK
client.insert::<Post>(Post::table_name(), PostInsertRequest {
id: 1.into(),
title: "My Post".into(),
author_id: 1.into(), // User 1 exists
..
}, None).await??;
// Insert post referencing non-existent user - FAILS
let result = client.insert::<Post>(Post::table_name(), PostInsertRequest {
id: 2.into(),
title: "Another Post".into(),
author_id: 999.into(), // User 999 doesn't exist
..
}, None).await?;
assert!(matches!(
result,
Err(IcDbmsError::Query(QueryError::BrokenForeignKeyReference))
));
Update Validation
Updates are also validated:
// Changing author_id to non-existent user fails
let update = PostUpdateRequest::builder()
.set_author_id(999.into()) // User 999 doesn't exist
.filter(Filter::eq("id", Value::Uint32(1.into())))
.build();
let result = client.update::<Post>(Post::table_name(), update, None).await?;
assert!(matches!(
result,
Err(IcDbmsError::Query(QueryError::BrokenForeignKeyReference))
));
Delete Behaviors
When deleting a record that is referenced by other records, you must specify how to handle the references.
Restrict
Behavior: Fail if any records reference this one.
use ic_dbms_api::prelude::DeleteBehavior;
// User has posts - delete fails
let result = client.delete::<User>(
User::table_name(),
DeleteBehavior::Restrict,
Some(Filter::eq("id", Value::Uint32(1.into()))),
None
).await?;
match result {
Err(IcDbmsError::Query(QueryError::ForeignKeyConstraintViolation)) => {
println!("Cannot delete: user has posts");
}
_ => {}
}
// Delete posts first, then user
client.delete::<Post>(
Post::table_name(),
DeleteBehavior::Restrict,
Some(Filter::eq("author_id", Value::Uint32(1.into()))),
None
).await??;
// Now user can be deleted
client.delete::<User>(
User::table_name(),
DeleteBehavior::Restrict,
Some(Filter::eq("id", Value::Uint32(1.into()))),
None
).await??;
Use when: You want to prevent accidental data loss. The caller must explicitly handle related records.
Cascade
Behavior: Delete all records that reference this one (recursively).
// Deletes user AND all their posts
client.delete::<User>(
User::table_name(),
DeleteBehavior::Cascade,
Some(Filter::eq("id", Value::Uint32(1.into()))),
None
).await??;
Cascade is recursive:
// Schema:
// User -> Posts -> Comments
// Deleting a user cascades to posts, which cascades to comments
client.delete::<User>(
User::table_name(),
DeleteBehavior::Cascade,
Some(Filter::eq("id", Value::Uint32(1.into()))),
None
).await??;
// User deleted
// All user's posts deleted
// All comments on those posts deleted
Use when: Related records have no meaning without the parent (e.g., comments on a deleted post).
Choosing a Delete Behavior
| Scenario | Recommended Behavior |
|---|---|
| User account deletion (remove everything) | Cascade |
| Prevent accidental deletion | Restrict |
| Soft delete pattern | Don’t delete; use status field |
| Comments on posts | Cascade (comments meaningless without post) |
| Products in orders | Restrict (orders are historical records) |
Eager Loading
Eager loading fetches related records in a single query, avoiding N+1 query problems.
Basic Eager Loading
Use .with() to eager load a related table:
// Load posts with their authors
let query = Query::builder()
.all()
.with("users") // Name of the related table
.build();
let posts = client.select::<Post>(Post::table_name(), query, None).await??;
// Each post now has author data available
for post in posts {
println!("Post '{}' by author_id {}", post.title, post.author_id);
}
Multiple Relations
Load multiple related tables:
// Schema:
// Post -> User (author)
// Post -> Category
let query = Query::builder()
.all()
.with("users")
.with("categories")
.build();
let posts = client.select::<Post>(Post::table_name(), query, None).await??;
Eager Loading with Filters
Combine eager loading with filters:
// Load published posts with their authors
let query = Query::builder()
.filter(Filter::eq("published", Value::Boolean(true)))
.order_by("created_at", OrderDirection::Descending)
.limit(10)
.with("users")
.build();
let posts = client.select::<Post>(Post::table_name(), query, None).await??;
Cross-Table Queries with Joins
In addition to eager loading, ic-dbms supports SQL-style joins (INNER, LEFT, RIGHT, FULL) for combining rows from multiple tables into a flat result set. Joins are useful when you need columns from several tables in a single row — for example, listing post titles alongside author names. Unlike eager loading, joins return untyped results via the select_raw path.
See the Querying Guide — Joins section for full details and examples.
Common Patterns
One-to-Many
A user has many posts:
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[table = "users"]
pub struct User {
#[primary_key]
pub id: Uint32,
pub name: Text,
}
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[table = "posts"]
pub struct Post {
#[primary_key]
pub id: Uint32,
pub title: Text,
#[foreign_key(entity = "User", table = "users", column = "id")]
pub author_id: Uint32,
}
// Query all posts by a user
let query = Query::builder()
.filter(Filter::eq("author_id", Value::Uint32(user_id.into())))
.build();
let user_posts = client.select::<Post>(Post::table_name(), query, None).await??;
Many-to-Many
Use a junction table for many-to-many relationships:
// Students and Courses (many-to-many)
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[table = "students"]
pub struct Student {
#[primary_key]
pub id: Uint32,
pub name: Text,
}
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[table = "courses"]
pub struct Course {
#[primary_key]
pub id: Uint32,
pub title: Text,
}
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[table = "enrollments"]
pub struct Enrollment {
#[primary_key]
pub id: Uint32,
#[foreign_key(entity = "Student", table = "students", column = "id")]
pub student_id: Uint32,
#[foreign_key(entity = "Course", table = "courses", column = "id")]
pub course_id: Uint32,
pub enrolled_at: DateTime,
}
// Find all courses for a student
let query = Query::builder()
.filter(Filter::eq("student_id", Value::Uint32(student_id.into())))
.with("courses")
.build();
let enrollments = client.select::<Enrollment>(Enrollment::table_name(), query, None).await??;
Self-Referential
A table can reference itself (e.g., categories with parent categories, employees with managers):
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[table = "employees"]
pub struct Employee {
#[primary_key]
pub id: Uint32,
pub name: Text,
#[foreign_key(entity = "Employee", table = "employees", column = "id")]
pub manager_id: Nullable<Uint32>, // Nullable for top-level employees
}
// Find all employees under a manager
let query = Query::builder()
.filter(Filter::eq("manager_id", Value::Uint32(manager_id.into())))
.build();
let direct_reports = client.select::<Employee>(Employee::table_name(), query, None).await??;
#[derive(Debug, Table, CandidType, Deserialize, Clone, PartialEq, Eq)]
#[table = "categories"]
pub struct Category {
#[primary_key]
pub id: Uint32,
pub name: Text,
#[foreign_key(entity = "Category", table = "categories", column = "id")]
pub parent_id: Nullable<Uint32>, // Nullable for root categories
}
// Find root categories
let query = Query::builder()
.filter(Filter::is_null("parent_id"))
.build();
let root_categories = client.select::<Category>(Category::table_name(), query, None).await??;
// Find children of a category
let query = Query::builder()
.filter(Filter::eq("parent_id", Value::Uint32(parent_id.into())))
.build();
let children = client.select::<Category>(Category::table_name(), query, None).await??;