This guide details how to define and use relationships between tables using FluentORM's TableSchema API.
Relationships define how tables reference each other using foreign keys. FluentORM generates helper methods for navigating between related models, making it easy to fetch associated data.
FluentORM uses efficient correlated subqueries with JSONB aggregation for hasMany relationships to prevent Cartesian products and maintain query performance.
FluentORM provides convenience methods for common relationship types, plus the generic t.foreign() method for advanced use cases.
| Relationship | Method | Description |
|---|---|---|
| Many-to-One | t.belongsTo() |
This table has a FK to another table (e.g., post → user) |
| One-to-One | t.hasOne() |
This table has a unique FK to another table |
| One-to-Many | t.hasMany() |
Another table has FKs pointing to this table |
| Many-to-Many | t.manyToMany() |
Junction table relationship |
| Generic | t.foreign() |
Low-level method with full control |
pub fn build(t: *TableSchema) void {
// ... other fields ...
t.uuid(.{ .name = "user_id" }); // Foreign key column
// Define the relationship using convenience method
t.belongsTo(.{
.name = "post_author",
.column = "user_id",
.references_table = "users",
.on_delete = .cascade,
});
}pub fn build(t: *TableSchema) void {
// ... other fields ...
t.uuid(.{ .name = "user_id" }); // Foreign key column
// Define the relationship using generic method
t.foreign(.{
.name = "post_author",
.column = "user_id",
.references_table = "users",
.references_column = "id",
.relationship_type = .many_to_one,
.on_delete = .cascade,
});
}The .foreign() method accepts these options:
| Property | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Unique name for the relationship (used in generated method names) |
column |
string | Yes | Column in the current table that holds the foreign key |
references_table |
string | Yes | Name of the target table |
references_column |
string | Yes | Column in the target table (usually "id") |
relationship_type |
enum | Yes | Type of relationship (see below) |
on_delete |
enum | No | Action when referenced record is deleted (default: .no_action) |
on_update |
enum | No | Action when referenced record is updated (default: .no_action) |
The current table has a foreign key pointing to another table. This is the most common relationship type.
Example: Many posts belong to one user.
// In schemas/02_posts.zig
pub fn build(t: *TableSchema) void {
t.uuid(.{ .name = "id", .primary_key = true });
t.string(.{ .name = "title" });
t.uuid(.{ .name = "user_id" }); // Foreign key
// Using convenience method (recommended)
t.belongsTo(.{
.name = "post_author",
.column = "user_id",
.references_table = "users",
.on_delete = .cascade,
});
}belongsTo Options:
| Option | Type | Default | Description |
|---|---|---|---|
name |
string | required | Relationship name (used in method names) |
column |
string | required | FK column in this table |
references_table |
string | required | Target table name |
references_column |
string | "id" |
Target column (usually PK) |
on_delete |
enum | .no_action |
Action on parent delete |
on_update |
enum | .no_action |
Action on parent update |
Generated SQL:
CONSTRAINT fk_post_author FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADEGenerated Method:
// Fetch the user who authored this post
const author = try post.fetchPostAuthor(&pool, allocator).unwrap();
defer if (author) |a| allocator.free(a);One record in the current table can be referenced by multiple records in another table. Use the hasMany() method to define this relationship.
Example: One user has many posts.
// In schemas/01_users.zig
pub fn build(t: *TableSchema) void {
t.uuid(.{ .name = "id", .primary_key = true });
t.string(.{ .name = "name" });
// Define one-to-many relationship using hasMany()
// Note: The FK constraint is in the posts table, not here
// This is metadata for generating fetch methods and includes
t.hasMany(.{
.name = "user_posts",
.foreign_table = "posts",
.foreign_column = "user_id",
});
// You can define multiple hasMany relationships
t.hasMany(.{
.name = "user_comments",
.foreign_table = "comments",
.foreign_column = "user_id",
});
}Generated Methods:
// Fetch all posts by this user
const posts = try user.fetchPosts(&pool, allocator).unwrap();
defer allocator.free(posts);
// Fetch all comments by this user
const comments = try user.fetchComments(&pool, allocator).unwrap();
defer allocator.free(comments);hasMany Options:
| Option | Type | Description |
|---|---|---|
name |
string | Relationship name (used to generate method suffix) |
foreign_table |
string | The child table that has the FK |
foreign_column |
string | The FK column in the child table |
[!NOTE] >
hasMany()does not create a FK constraint. The FK constraint should be defined in the child table usingbelongsTo()orforeign().
t.hasManyList(&.{
.{ .name = "user_posts", .foreign_table = "posts", .foreign_column = "user_id" },
.{ .name = "user_comments", .foreign_table = "comments", .foreign_column = "user_id" },
});A strict one-to-one mapping between two tables.
Example: One user has one profile.
// In schemas/02_profiles.zig
pub fn build(t: *TableSchema) void {
t.uuid(.{ .name = "id", .primary_key = true });
t.uuid(.{ .name = "user_id", .unique = true }); // Unique constraint enforces 1:1
t.string(.{ .name = "bio" });
// Using convenience method (recommended)
t.hasOne(.{
.name = "profile_user",
.column = "user_id",
.references_table = "users",
.on_delete = .cascade,
});
}Generated Method:
// Returns a single user or null
const user = try profile.fetchProfileUser(&pool, allocator).unwrap();
defer if (user) |u| allocator.free(u);hasOne Options:
| Option | Type | Default | Description |
|---|---|---|---|
name |
string | required | Relationship name |
column |
string | required | FK column in this table |
references_table |
string | required | Target table name |
references_column |
string | "id" |
Target column |
on_delete |
enum | .no_action |
Action on parent delete |
on_update |
enum | .no_action |
Action on parent update |
Many-to-many relationships require a junction (join) table. Use manyToMany() to define the relationship on the junction table.
Example: Posts can have multiple categories, and categories can have multiple posts.
// Junction table: schemas/05_post_categories.zig
pub fn build(t: *TableSchema) void {
t.uuid(.{ .name = "id", .primary_key = true });
t.uuid(.{ .name = "post_id" });
t.uuid(.{ .name = "category_id" });
// Using manyToMany convenience method
t.manyToMany(.{
.name = "post_category_post",
.column = "post_id",
.references_table = "posts",
.references_column = "id",
});
t.manyToMany(.{
.name = "post_category_category",
.column = "category_id",
.references_table = "categories",
.references_column = "id",
});
}manyToMany Options:
| Option | Type | Default | Description |
|---|---|---|---|
name |
string | required | Relationship name |
column |
string | required | FK column in junction table |
references_table |
string | required | Target table name |
references_column |
string | required | Target column |
on_delete |
enum | .cascade |
Action on parent delete |
on_update |
enum | .no_action |
Action on parent update |
To query many-to-many relationships, you'll need to join through the junction table using the query builder.
// Get all categories for a post
var query = PostCategory.query();
defer query.deinit();
const post_cats = try query
.where(.{ .field = .post_id, .operator = .eq, .value = .{ .string = "$1" } })
.fetch(&pool, allocator, .{post_id})
.unwrap();
defer allocator.free(post_cats);
for (post_cats) |pc| {
if (try pc.fetchPostCategoryCategory(&pool, allocator).unwrap()) |cat| {
defer allocator.free(cat);
std.debug.print("Category: {s}\n", .{cat.name});
}
}FluentORM uses correlated subqueries with JSONB aggregation instead of traditional JOINs for hasMany relationships. This prevents Cartesian products that would otherwise multiply rows.
Traditional JOIN (❌ Creates Cartesian Product):
-- If a user has 10 posts and 100 comments, this returns 1000 rows
SELECT users.*, posts.*, comments.*
FROM users
LEFT JOIN posts ON posts.user_id = users.id
LEFT JOIN comments ON comments.user_id = users.id
WHERE users.id = $1FluentORM Subquery Approach (✅ Returns 1 Row):
SELECT users.*,
(SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id,
'title', title,
'created_at', (extract(epoch from created_at) * 1000000)::bigint,
...
)), '[]'::jsonb)
FROM posts
WHERE posts.user_id = users.id) AS posts,
(SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id,
'content', content,
...
)), '[]'::jsonb)
FROM comments
WHERE comments.user_id = users.id) AS comments
FROM users
WHERE users.id = $1Timestamps are automatically cast to microsecond epochs in JSONB aggregations for compatibility with Zig's i64 type:
'created_at', (extract(epoch from created_at) * 1000000)::bigintThis ensures proper JSON serialization and parsing without data loss.
You can specify which fields to include in relationships:
var query = Users.query();
defer query.deinit();
_ = query.include(.{
.posts = .{
.model_name = .posts,
.select = &.{ "id", "title", "created_at" } // Only these fields
}
});
// Generate SQL with only selected fields in the jsonb_build_object
const users = try query.fetchWithRel(
Users.Rel.UsersWithPosts,
&pool,
allocator,
.{}
).unwrap();Add WHERE clauses to included relationships:
var query = Users.query();
defer query.deinit();
_ = query.include(.{
.comments = .{
.model_name = .comments,
.where = &.{.{
.where_type = .@"and",
.field = .is_approved,
.operator = .eq,
.value = .{ .boolean = true }
}}
}
});
const users = try query.fetchWithRel(
Users.Rel.UsersWithComments,
&pool,
allocator,
.{}
).unwrap();
// Only approved comments are loadedLoad multiple relationships in a single query efficiently:
var query = Users.query();
defer query.deinit();
_ = query
.include(.{ .posts = .{ .model_name = .posts } })
.include(.{ .comments = .{ .model_name = .comments } });
const users = try query.fetchWithRel(
Users.Rel.UsersWithAllRelations, // Generated type with all relations
&pool,
allocator,
.{}
).unwrap();
defer allocator.free(users);
for (users) |user| {
std.debug.print("User: {s}\n", .{user.name});
if (user.posts) |posts| {
std.debug.print(" Posts: {d}\n", .{posts.len});
for (posts) |post| {
std.debug.print(" - {s}\n", .{post.title});
}
}
if (user.comments) |comments| {
std.debug.print(" Comments: {d}\n", .{comments.len});
}
}For custom handling or when you only need specific fields:
const UserWithJsonRelations = struct {
name: []const u8,
posts: ?[]const u8, // Raw JSONB string
comments: ?[]const u8, // Raw JSONB string
};
var query = Users.query();
defer query.deinit();
_ = query
.select(&.{.name})
.include(.{ .posts = .{ .model_name = .posts } })
.include(.{ .comments = .{ .model_name = .comments } });
const results = try query.fetchAs(UserWithJsonRelations, &pool, allocator, .{}).unwrap();
defer allocator.free(results);
for (results) |res| {
std.debug.print("User: {s}\n", .{res.name});
std.debug.print(" Posts JSON: {s}\n", .{res.posts orelse "[]"});
std.debug.print(" Comments JSON: {s}\n", .{res.comments orelse "[]"});
}Control what happens when a referenced record is deleted or updated:
| Action | Description |
|---|---|
.cascade |
Delete/update dependent rows automatically |
.set_null |
Set the foreign key to NULL |
.set_default |
Set the foreign key to its default value |
.restrict |
Prevent the change if there are dependent rows |
.no_action |
Similar to RESTRICT, but checks are deferred (default) |
Example with CASCADE:
t.foreign(.{
.name = "post_author",
.column = "user_id",
.references_table = "users",
.references_column = "id",
.relationship_type = .many_to_one,
.on_delete = .cascade, // Delete all posts when user is deleted
});Example with SET NULL:
t.foreign(.{
.name = "post_author",
.column = "user_id",
.references_table = "users",
.references_column = "id",
.relationship_type = .many_to_one,
.on_delete = .set_null, // Set user_id to NULL when user is deleted
});
// Make sure the column is nullable!
t.uuid(.{ .name = "user_id", .not_null = false });When you define a relationship, FluentORM generates fetch* methods on your model.
| Definition | Generated Method | Return Type |
|---|---|---|
belongsTo(.{ .name = "post_author", ... }) |
fetchPostAuthor() |
!?Users |
hasOne(.{ .name = "profile_user", ... }) |
fetchProfileUser() |
!?Users |
hasMany(.{ .name = "user_posts", ... }) |
fetchPosts() |
![]Posts |
manyToMany(.{ .name = "post_category", ... }) |
fetchPostCategory() |
!?Categories |
const std = @import("std");
const pg = @import("pg");
const models = @import("models/generated/root.zig");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){}();
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Setup database connection
var pool = try pg.Pool.init(allocator, .{
.size = 5,
.connect = .{ .host = "localhost", .port = 5432 },
.auth = .{ .username = "postgres", .password = "password", .database = "mydb" },
});
defer pool.deinit();
// Fetch a post
const post = (try models.Posts.findById(&pool, allocator, post_id).unwrap()).?;
defer allocator.free(post);
// Fetch the author using belongsTo relationship
if (try post.fetchUser(&pool, allocator).unwrap()) |author| {
defer allocator.free(author);
std.debug.print("Post authored by: {s}\n", .{author.name});
}
// Fetch a user
const user = (try models.Users.findById(&pool, allocator, user_id).unwrap()).?;
defer allocator.free(user);
// Fetch all posts by this user using hasMany relationship
const user_posts = try user.fetchPosts(&pool, allocator).unwrap();
defer allocator.free(user_posts);
for (user_posts) |p| {
std.debug.print("Post: {s}\n", .{p.title});
}
// Fetch all comments by this user
const user_comments = try user.fetchComments(&pool, allocator).unwrap();
defer allocator.free(user_comments);
}The generated method name depends on the relationship type:
- Pattern:
fetch{PascalCaseRelationshipName} - Example:
name = "post_author"→fetchPostAuthor()
- Pattern:
fetch{PascalCaseForeignTable}(derived from relationship name) - Example:
name = "user_posts"→fetchPosts()
Choose descriptive relationship names that reflect the domain relationship:
// Good names for belongsTo/hasOne
.name = "post_author" // fetchPostAuthor() -> returns ?Users
.name = "order_customer" // fetchOrderCustomer() -> returns ?Customers
// Good names for hasMany
.name = "user_posts" // fetchPosts() -> returns []Posts
.name = "category_products" // fetchProducts() -> returns []Products
// Avoid generic names
.name = "relation1" // fetchRelation1() (unclear)Generated model struct names use PascalCase plural form:
| Table Name | Struct Name | Import |
|---|---|---|
users |
Users |
@import("users.zig") |
posts |
Posts |
@import("posts.zig") |
post_categories |
PostCategories |
@import("post_categories.zig") |
For more complex queries involving relationships, use the query builder:
// Find all posts by a specific user
var query = models.Posts.query();
defer query.deinit();
const user_posts = try query
.where(.{ .field = .user_id, .operator = .eq, .value = .{ .string = "$1" } })
.orderBy(.{ .field = .created_at, .direction = .desc })
.fetch(&pool, allocator, .{user_id})
.unwrap();
defer allocator.free(user_posts);See QUERY.md for more details on the query builder.
FluentORM generates explicit relation types in rel.zig for each model. These types provide full IntelliSense support for eager-loaded relations.
For a model with relations, the generator creates:
src/models/generated/users/
├── model.zig # Base model
├── rel.zig # Relation types (UsersWithPosts, etc.)
└── query.zig # Query builder
Each rel.zig contains:
| Type | Description |
|---|---|
UsersWithPosts |
User model with posts: ?[]Posts field |
UsersWithComments |
User model with comments: ?[]Comments field |
UsersWithAllRelations |
User model with all relation fields |
Access via the model's Rel namespace:
const Users = @import("models/generated/users/model.zig");
// Individual relation types
const UsersWithPosts = Users.Rel.UsersWithPosts;
const UsersWithComments = Users.Rel.UsersWithComments;
// All relations at once
const UsersWithAllRelations = Users.Rel.UsersWithAllRelations;Or via the registry:
const Client = @import("models/generated/registry.zig").Client;
const UsersWithPosts = Client.Rel.Users.UsersWithPosts;Use fetchWithRel or firstWithRel to load relations with automatic JSONB parsing:
const UsersWithPosts = Users.Rel.UsersWithPosts;
var query = Users.query();
defer query.deinit();
// Eager load posts
const users = try query
.include(.{ .posts = .{ .model_name = .posts } })
.fetchWithRel(UsersWithPosts, &pool, allocator, .{})
.unwrap();
defer allocator.free(users);
for (users) |user| {
std.debug.print("User: {s}\n", .{user.name});
// Full IntelliSense on user.posts!
if (user.posts) |posts| {
for (posts) |post| {
std.debug.print(" - {s}\n", .{post.title});
}
}
}Each relation type provides:
| Method | Description |
|---|---|
fromBase(model) |
Convert base model to relation type (relations = null) |
toBase(self) |
Extract base model from relation type |
fromRow(row, allocator) |
Parse database row with JSONB relation columns |
// Convert base model to relation type
const base_user = try Users.findById(&pool, allocator, id).unwrap();
var user_with_posts = UsersWithPosts.fromBase(base_user.?);
// Manually set posts if needed
user_with_posts.posts = some_posts;
// Convert back to base model
const base = user_with_posts.toBase();All relationship methods are fully type-safe:
- Return types match the referenced model
- Relationship names are checked at compile time
- Foreign key types are validated
If you try to call a relationship method that doesn't exist, you'll get a compile-time error.