النماذج
النماذج (Models) هي بنى Rust تُعيّن مباشرة لجداول قاعدة البيانات. تُعرّف شكل بياناتك، وتتعامل مع التسلسل، وتوفر الأساس لاستعلامات قاعدة البيانات الآمنة من حيث النوع باستخدام SQLx. يُولّد FORGE نماذج لكل وحدة في مشروعك، متبعاً أنماطاً متسقة للحقول والـ traits والترجمات.
اصطلاحات النماذج
كل struct نموذج يشتق هذه الـ 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 | الغرض |
|---|---|
Debug | تمكين الطباعة للتصحيح |
Clone | السماح بنسخ القيمة |
Serialize | التحويل لـ JSON للاستجابات API |
Deserialize | التحليل من JSON (يُستخدم في الاختبارات والداخلي) |
FromRow | تعيين صفوف قاعدة البيانات لحقول الـ struct عبر SQLx |
نصيحة
FromRow يسمح لـ SQLx بتعيين نتائج الاستعلام لـ struct الخاص بك تلقائياً. أسماء الأعمدة يجب أن تطابق أسماء الحقول تماماً، أو يمكنك استخدام #[sqlx(rename = "column_name")] لعدم التطابق.
النماذج الأساسية
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>>,
}تحذير
حقل password يستخدم #[serde(skip_serializing)] لضمان عدم تضمينه أبداً في استجابات API. طبّق دائماً هذا الـ attribute على الحقول الحساسة.
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 (قابل للترجمة)
#[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>>,
}النماذج القابلة للترجمة
النماذج مع دعم متعدد اللغات تُخزّن الترجمات في عمود JSONB. حقل translations يحمل كائن JSON متداخل مفهرس باللغة:
// 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,
// ...
}استعلام الترجمات
استخراج ترجمة لغة محددة في SQL:
// Fetch content with specific language translation extraction
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?;تحديث الترجمات
تحديث لغة واحدة بدون الكتابة فوق الأخرى:
// 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?;نصيحة
استخدم دالة jsonb_set في PostgreSQL لتحديث ترجمات اللغات الفردية بدون قراءة وإعادة كتابة عمود JSONB بالكامل. هذا يتجنب حالات السباق عندما يُعدّل مستخدمون متعددون لغات مختلفة في نفس الوقت.
مساعدات الاستعلام
النماذج غالباً تتضمن دوال مرتبطة للاستعلامات الشائعة:
impl User {
/// Find 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 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
النماذج تمثل صف قاعدة البيانات تماماً. الـ DTOs (كائنات نقل البيانات) تمثل ما يُرسله ويستقبله الـ API. أبقهم منفصلين:
صف قاعدة البيانات ──▶ Model (User) ──▶ طبقة Service
│
طلب API ──▶ DTO (CreateUserRequest) │
▼
استجابة API ◀── DTO (UserResponse) ◀── طبقة Serviceقد يحتوي النموذج على حقول حساسة (مثل password) التي يجب ألا تظهر أبداً في استجابات API. الـ DTOs تتعامل مع هذا التحويل. انظر الـ DTOs للتفاصيل.
العلاقات
العلاقات تُحمّل عبر joins صريحة أو استعلامات منفصلة، وليس من خلال سحر ORM:
/// User with loaded roles
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 })
}
}نصيحة
SQLx لا يتضمن ORM تقليدي مع lazy-loading للعلاقات. بدلاً من ذلك، حمّل البيانات المرتبطة بشكل صريح. هذا يجعل سلوك الاستعلام قابلاً للتنبؤ ويتجنب مشكلة N+1 التي تُعاني منها ORMs.
انظر أيضاً
- قاعدة البيانات — اصطلاحات المخطط والترحيلات وأنماط JSONB
- الـ DTOs — تحويل النماذج لاستجابات API
- الـ Services — منطق الأعمال الذي يستخدم النماذج