Skip to content

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:

rust
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>>,
}
TraitPurpose
DebugEnable debug printing
CloneAllow value cloning
SerializeConvert to JSON for API responses
DeserializeParse from JSON (used in tests and internal)
FromRowMap 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

rust
#[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

rust
#[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

rust
#[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)

rust
#[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:

rust
// 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:

rust
// 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:

rust
// 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:

rust
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 Layer

A 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:

rust
/// 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.

See Also

  • Database — Schema conventions, migrations, and JSONB patterns
  • DTOs — Transforming models into API responses
  • Services — Business logic that uses models

Released under the MIT License.