Models
Models are Rust structs that map directly to database tables. They define the shape of your data, handle serialization, and provide the foundation for type-safe database queries using SQLx. FORGE generates models for every module in your project, following consistent patterns for fields, traits, and translations.
Model Conventions
Every model struct derives these core traits:
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: Uuid,
pub name: String,
pub email: String,
// ...
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}| Trait | Purpose |
|---|---|
Debug | Enable debug printing |
Clone | Allow value cloning |
Serialize | Convert to JSON for API responses |
Deserialize | Parse from JSON (used in tests and internal) |
FromRow | Map database rows to struct fields via SQLx |
TIP
FromRow allows SQLx to automatically map query results to your struct. Column names must match field names exactly, or you can use #[sqlx(rename = "column_name")] for mismatches.
Core Models
User
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: Uuid,
pub name: String,
pub email: String,
#[serde(skip_serializing)]
pub password: String,
pub mobile: Option<String>,
pub avatar: Option<String>,
pub email_verified_at: Option<DateTime<Utc>>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}WARNING
The password field uses #[serde(skip_serializing)] to ensure it is never included in API responses. Always apply this attribute to sensitive fields.
Role
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Role {
pub id: Uuid,
pub name: String,
pub display_name: Option<String>,
pub description: Option<String>,
pub is_system: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}Permission
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Permission {
pub id: Uuid,
pub name: String,
pub display_name: Option<String>,
pub description: Option<String>,
pub group_name: Option<String>,
pub is_system: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}Content (Translatable)
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Content {
pub id: Uuid,
pub slug: String,
pub content_type: String,
pub translations: serde_json::Value,
pub metadata: Option<serde_json::Value>,
pub is_active: bool,
pub sort_order: i32,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}Translatable Models
Models with multi-language support store translations in a JSONB column. The translations field holds a nested JSON object keyed by locale:
// The translations field stores this structure:
// {
// "en": { "title": "About Us", "body": "We are..." },
// "ar": { "title": "عنا", "body": "نحن..." }
// }
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Content {
pub id: Uuid,
pub slug: String,
pub translations: serde_json::Value,
// ...
}Querying Translations
Extract a specific locale's translation in SQL:
// Fetch content with a specific locale's translations extracted
let content = sqlx::query_as!(
ContentLocalized,
r#"
SELECT
id,
slug,
translations->$1->>'title' AS "title?",
translations->$1->>'body' AS "body?",
translations->$1->>'description' AS "description?",
is_active,
created_at
FROM contents
WHERE slug = $2 AND deleted_at IS NULL
"#,
locale, // e.g., "en" or "ar"
slug
)
.fetch_optional(&pool)
.await?;Updating Translations
Update a single locale without overwriting others:
// Update only the Arabic translation
sqlx::query!(
r#"
UPDATE contents
SET translations = jsonb_set(
translations,
$1,
$2::jsonb
),
updated_at = now()
WHERE id = $3
"#,
&format!("{{{}}}", locale) as &str, // e.g., "{ar}"
serde_json::to_value(&translation)?,
content_id
)
.execute(&pool)
.await?;TIP
Use PostgreSQL's jsonb_set function to update individual locale translations without reading and rewriting the entire JSONB column. This avoids race conditions when multiple users edit different locales simultaneously.
Query Helpers
Models often include associated functions for common queries:
impl User {
/// Find a user by ID (excluding soft-deleted)
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as!(
User,
r#"
SELECT id, name, email, password, mobile, avatar,
email_verified_at, is_active,
created_at, updated_at, deleted_at
FROM users
WHERE id = $1 AND deleted_at IS NULL
"#,
id
)
.fetch_optional(pool)
.await
}
/// Find a user by email
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as!(
User,
r#"
SELECT id, name, email, password, mobile, avatar,
email_verified_at, is_active,
created_at, updated_at, deleted_at
FROM users
WHERE email = $1 AND deleted_at IS NULL
"#,
email
)
.fetch_optional(pool)
.await
}
/// List users with pagination
pub async fn paginate(
pool: &PgPool,
page: i64,
per_page: i64,
search: Option<&str>,
) -> Result<(Vec<User>, i64), sqlx::Error> {
let offset = (page - 1) * per_page;
let total = sqlx::query_scalar!(
r#"
SELECT COUNT(*) as "count!"
FROM users
WHERE deleted_at IS NULL
AND ($1::text IS NULL OR name ILIKE '%' || $1 || '%' OR email ILIKE '%' || $1 || '%')
"#,
search
)
.fetch_one(pool)
.await?;
let users = sqlx::query_as!(
User,
r#"
SELECT id, name, email, password, mobile, avatar,
email_verified_at, is_active,
created_at, updated_at, deleted_at
FROM users
WHERE deleted_at IS NULL
AND ($1::text IS NULL OR name ILIKE '%' || $1 || '%' OR email ILIKE '%' || $1 || '%')
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
search,
per_page,
offset
)
.fetch_all(pool)
.await?;
Ok((users, total))
}
}Model vs DTO
Models represent the database row exactly. DTOs (Data Transfer Objects) represent what the API sends and receives. Keep them separate:
Database Row ──▶ Model (User) ──▶ Service Layer
│
API Request ──▶ DTO (CreateUserRequest) │
▼
API Response ◀── DTO (UserResponse) ◀── Service LayerA model may contain sensitive fields (like password) that should never appear in API responses. DTOs handle this transformation. See DTOs for details.
Relationships
Relationships are loaded via explicit joins or separate queries, not through ORM magic:
/// User with their roles loaded
pub struct UserWithRoles {
pub user: User,
pub roles: Vec<Role>,
}
impl UserWithRoles {
pub async fn find(pool: &PgPool, user_id: Uuid) -> Result<Self, sqlx::Error> {
let user = User::find_by_id(pool, user_id)
.await?
.ok_or(sqlx::Error::RowNotFound)?;
let roles = sqlx::query_as!(
Role,
r#"
SELECT r.id, r.name, r.display_name, r.description,
r.is_system, r.created_at, r.updated_at
FROM roles r
INNER JOIN role_user ru ON ru.role_id = r.id
WHERE ru.user_id = $1
"#,
user_id
)
.fetch_all(pool)
.await?;
Ok(Self { user, roles })
}
}TIP
SQLx does not include a traditional ORM with lazy-loading relationships. Instead, load related data explicitly. This makes query behavior predictable and avoids the N+1 problem that plagues ORMs.