The base model provides common CRUD (Create, Read, Update, Delete) and DDL operations for all generated models.
Every generated model automatically includes methods from the base model, providing a consistent interface for database operations. Models are generated from your schema definitions using the TableSchema builder API.
When you run zig build generate-models, FluentORM generates:
- Model files (e.g.,
users.zig,posts.zig) with CRUD operations - base.zig - Common utilities and CRUD implementations
- query.zig - Query builder for type-safe filtering
- executor.zig - Unified database executor (Pool or Conn)
- transaction.zig - Generic transaction support
- root.zig - Barrel export for easy imports
All model methods accept an Executor as their first argument. The executor abstracts over *pg.Pool (for direct operations) and *pg.Conn (for transactions):
const Executor = @import("executor.zig").Executor;
// Direct pool access
const user = try Users.findById(Executor.fromPool(pool), allocator, id).unwrap();
// Within a transaction
var tx = try Transaction.begin(pool);
const user = try Users.findById(tx.executor(), allocator, id).unwrap();CRITICAL: All insert operations (insert, insertMany, upsert) return allocated UUID strings that MUST be freed to prevent memory leaks:
// ✅ Correct: Free the returned ID
const user_id = try Users.insert(Executor.fromPool(pool), allocator, .{
.email = "alice@example.com",
.name = "Alice",
}).unwrap();
defer allocator.free(user_id); // MUST free!
// ❌ Memory leak: ID not freed
_ = try Users.insert(Executor.fromPool(pool), allocator, .{...}).unwrap(); // LEAKS MEMORYWhen inserting multiple records, each returned ID must be freed:
const user1_id = try Users.insert(db, allocator, .{...}).unwrap();
defer allocator.free(user1_id); // Free first ID
const user2_id = try Users.insert(db, allocator, .{...}).unwrap();
defer allocator.free(user2_id); // Free second ID
const user3_id = try Users.insert(db, allocator, .{...}).unwrap();
defer allocator.free(user3_id); // Free third IDinsertMany returns a slice of allocated UUID strings:
const ids = try Users.insertMany(db, allocator, &.{
.{ .name = "Alice", .email = "alice@example.com" },
.{ .name = "Bob", .email = "bob@example.com" },
}).unwrap();
defer allocator.free(ids); // Frees the slice AND all individual IDsInsert a new record and get back the primary key:
const user_id = try Users.insert(Executor.fromPool(pool), allocator, .{
.email = "alice@example.com",
.name = "Alice",
.password_hash = "hashed_password",
}).unwrap();
defer allocator.free(user_id); // MUST free!Note: Fields marked with .create_input = .excluded (like auto-generated UUIDs and timestamps) are automatically excluded from the insert input struct.
const user = try Users.insertAndReturn(Executor.fromPool(pool), allocator, .{
.email = "alice@example.com",
.name = "Alice",
.password_hash = "hashed_password",
}).unwrap();
defer allocator.free(user);const find_result = Users.findById(Executor.fromPool(pool), allocator, user_id);
switch (find_result) {
.ok => |maybe_user| {
if (maybe_user) |user| {
defer allocator.free(user);
std.debug.print("Found user: {s}\n", .{user.name});
}
},
.err => |e| e.log(),
}Returns null if not found or if the record is soft-deleted.
var query = Users.query();
defer query.deinit();
const users = try query
.where(.{ .field = .email, .operator = .eq, .value = .{ .string = "$1" } })
.fetch(Executor.fromPool(pool), allocator, .{"alice@example.com"})
.unwrap();
defer allocator.free(users);const all_users = try Users.findAll(Executor.fromPool(pool), allocator, false).unwrap();
defer allocator.free(all_users);To include soft-deleted records, pass true:
const all_including_deleted = try Users.findAll(Executor.fromPool(pool), allocator, true).unwrap();
defer allocator.free(all_including_deleted);Update specific fields for a record:
try Users.update(Executor.fromPool(pool), user_id, .{
.name = "Alice Smith",
.email = "alice.smith@example.com",
}).unwrap();Note: Fields marked with .update_input = false (like created_at, auto-generated IDs) are excluded from the update input struct.
const updated_user = try Users.updateAndReturn(Executor.fromPool(pool), allocator, user_id, .{
.name = "Alice Smith",
}).unwrap();
defer allocator.free(updated_user);Insert a record, or update if a unique constraint violation occurs:
const user_id = try Users.upsert(Executor.fromPool(pool), allocator, .{
.email = "alice@example.com",
.name = "Alice",
.password_hash = "hashed_password",
}).unwrap();
defer allocator.free(user_id);Requirement: Your schema must have at least one unique constraint (besides the primary key).
const user = try Users.upsertAndReturn(Executor.fromPool(pool), allocator, .{
.email = "alice@example.com",
.name = "Alice",
.password_hash = "hashed_password",
}).unwrap();
defer allocator.free(user);If your schema includes a deleted_at field, you can use soft deletes:
try Users.softDelete(Executor.fromPool(pool), user_id).unwrap();Soft-deleted records are automatically excluded from queries by default. Use .withDeleted() on queries to include them.
Permanently removes the record:
try Users.hardDelete(Executor.fromPool(pool), user_id).unwrap();Warning: This is irreversible and bypasses any soft-delete logic.
Base models provide limited Data Definition Language (DDL) operations.
Remove all data but keep the table structure:
try Users.truncate(Executor.fromPool(pool)).unwrap();Warning: This permanently deletes all data in the table.
const exists = try Users.tableExists(Executor.fromPool(pool)).unwrap();
if (exists) {
std.debug.print("Table exists\n", .{});
}Note:
createTable(),dropTable(), andcreateIndexes()methods are not currently available. Use the generated SQL migration files to create/drop tables. See MIGRATIONS.md for details.
const total_users = try Users.count(Executor.fromPool(pool), false).unwrap();
std.debug.print("Total users: {d}\n", .{total_users});
// Include soft-deleted
const total_including_deleted = try Users.count(Executor.fromPool(pool), true).unwrap();Helper to convert a pg.zig row result into a model instance:
const user = try Users.fromRow(row, allocator);const table_name = Users.tableName();
std.debug.print("Table: {s}\n", .{table_name});Generated models include JSON-safe response types that convert UUIDs from byte arrays to hex strings:
Includes all fields:
if (try Users.findById(&pool, allocator, user_id).unwrap()) |user| {
defer allocator.free(user);
const json_response = try user.toJsonResponse();
// json_response.id is now a [36]u8 hex string like "550e8400-e29b-41d4-a716-446655440000"
}Excludes fields marked with .redacted = true (like password_hash):
if (try Users.findById(&pool, allocator, user_id).unwrap()) |user| {
defer allocator.free(user);
const safe_response = try user.toJsonResponseSafe();
// password_hash is NOT included in this response
}All model fields are accessible as struct members with proper Zig types:
std.debug.print("User: {s} ({s})\n", .{ user.name, user.email });
std.debug.print("Created at: {d}\n", .{user.created_at});
std.debug.print("Active: {}\n", .{user.is_active});If you've defined relationships in your schema, the generator creates typed methods for fetching related records.
// Post belongs to User
const result = post.fetchPostAuthor(&pool, allocator);
switch (result) {
.ok => |maybe_author| {
if (maybe_author) |author| {
defer allocator.free(author);
std.debug.print("Author: {s}\n", .{author.name});
}
},
.err => |e| e.log(),
}
// Profile has one User (using unwrap)
if (try profile.fetchProfileUser(&pool, allocator).unwrap()) |user| {
defer allocator.free(user);
std.debug.print("User: {s}\n", .{user.name});
}// User has many Posts (defined with t.hasMany())
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});
}
// User has many Comments
const user_comments = try user.fetchComments(&pool, allocator).unwrap();
defer allocator.free(user_comments);// Comment has parent comment (self-reference)
if (try comment.fetchParent(&pool, allocator).unwrap()) |parent| {
defer allocator.free(parent);
std.debug.print("Reply to: {s}\n", .{parent.content});
}
// Comment has many replies (self-referential hasMany)
const replies = try comment.fetchReplies(&pool, allocator).unwrap();
defer allocator.free(replies);See RELATIONSHIPS.md for more details on defining and querying relationships.
FluentORM generates compile-time type-safe code:
- Field names are enum values (autocompletion support)
- PostgreSQL types map to appropriate Zig types
- Optional fields use Zig optionals (
?T) - Input structs only include allowed fields based on schema configuration
All database operations return Result types that provide detailed error information:
const result = Users.insert(db, allocator, .{
.email = "alice@example.com",
.name = "Alice",
});
switch (result) {
.ok => |user_id| {
defer allocator.free(user_id);
std.debug.print("Created user: {s}\n", .{user_id});
},
.err => |e| {
if (e.isUniqueViolation()) {
std.debug.print("Email already exists\n", .{});
if (e.constraintName()) |c| {
std.debug.print("Constraint: {s}\n", .{c});
}
} else {
e.log(); // Log full error details
}
},
}// Propagates the underlying Zig error if Result is .err
const user_id = try Users.insert(db, allocator, .{
.email = "alice@example.com",
.name = "Alice",
}).unwrap();
defer allocator.free(user_id);When an error occurs, OrmError provides:
code- Categorized error type (UniqueViolation, ForeignKeyViolation, etc.)message- Human-readable error messagepg_error- Full PostgreSQL error details (constraint, table, column, etc.)
| Code | Description |
|---|---|
UniqueViolation |
Duplicate key (unique constraint failed) |
ForeignKeyViolation |
Referenced record doesn't exist |
NotNullViolation |
Required field was null |
NoRowsReturned |
Query returned no rows |
See ERROR_HANDLING.md for complete documentation.