تنفيذ CRUD الكامل — من مخزن البيانات إلى نقطة نهاية API
CRUD مثل العمليات الأربع الأساسية للمستودع—الاستلام (Create)، وجرد المخزون (Read)، ونقل البضائع (Update)، والشحن (Delete). نموذج المستودع يضمن توحيد العمليات، مما يمنع الارتباك الناتج عن مديري مستودعات مختلفين.
1. ما ستتعلمه
- نمط Repository: تغليف
ProductRepository/PriceRepository - التقسيم والفرز: نماذج استعلام Pydantic لـ
limit/offsetوorder_by - التحميل المسبق للبيانات المرتبطة:
selectinloadمقابلjoinedload—المقايضات الأدائية - إدارة المعاملات: ضمان الاتساق في العمليات عبر الجداول
- سيناريو Alice: واجهة استيراد أسعار مجمعة—معالجة معاملية لإدراج 1000 سجل أسعار في طلب واحد
2. القصة الحقيقية لـ Alice
(1) نقطة الألم: SQL مبعثرة في دوال التوجيه
في كود PriceTracker الخاص بـ Alice، تُكتب استعلامات SQL مباشرة داخل دوال معالج التوجيه، وهناك 30 حالة مكررة لمنطق select(Product).where(...) عبر 20 نقطة نهاية. عندما احتاجت إلى إضافة شرط "فلتر الحذف الناعم" لجميع استعلامات المنتجات، كان على Alice إجراء تغييرات في 15 مكانًا؛ تفويت اثنين فقط منهما تسبب في ظهور المنتجات المحذوفة في النتائج.
(2) حلول نموذج المستودع
غلف عمليات قاعدة البيانات داخل فئة Repository؛ يجب على دوال التوجيه استدعاء دوال Repository فقط. لإضافة فلتر عام، تحتاج فقط إلى إجراء تغيير واحد في Repository.
class ProductRepository:
async def get_all(self, db: AsyncSession, filters: ProductFilter) -> list[Product]:
stmt = select(Product).where(Product.deleted == False)
# إضافة فلاتر من نموذج Pydantic
if filters.category:
stmt = stmt.where(Product.category == filters.category)
result = await db.execute(stmt)
return result.scalars().all()
(3) العائد
تركز منطق SQL في Repository، مما يجعل دالة التوجيه أكثر إيجازًا (5 أسطر بدلاً من 15). يمكن تعديل شروط الفلتر العامة في مكان واحد لتسري مفعولها على مستوى النظام، لذا لن يرى Bob في الواجهة الأمامية المنتجات المحذوفة بعد الآن.
3. البنية الطبقية ونماذج مستودع البيانات
(1) بنية رباعية الطبقات
flowchart TD
Router[طبقة التوجيه] -->|استدعاء| Service[طبقة الخدمة]
Service -->|استدعاء| Repository[طبقة المستودع]
Repository -->|استعلام| SQLAlchemy[SQLAlchemy ORM]
SQLAlchemy -->|SQL| Database[(PostgreSQL)]
style Router fill:#e3f2fd
style Service fill:#fff3e0
style Repository fill:#e8f5e9
style SQLAlchemy fill:#fce4ec
| المستوى | المسؤوليات | أمثلة |
|---|---|---|
| التوجيه | التحقق من المعلمات، تنسيق الاستجابة | @app.get("/products") |
| الخدمة | منطق الأعمال، تنسيق المعاملات | ProductService.create_with_price() |
| المستودع | الوصول للبيانات، تغليف SQL | ProductRepository.get_by_id() |
| ORM | تعيين الكائنات العلائقية | select(Product).where(...) |
(1) ▶ مثال: ProductRepository
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
class ProductRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, product_id: int) -> Product | None:
stmt = select(Product).where(Product.id == product_id)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_all(
self,
category: str | None = None,
skip: int = 0,
limit: int = 20,
) -> list[Product]:
stmt = select(Product).offset(skip).limit(limit)
if category:
stmt = stmt.where(Product.category == category)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def create(self, product: ProductCreate) -> Product:
db_product = Product(**product.model_dump())
self.db.add(db_product)
await self.db.flush()
await self.db.refresh(db_product)
return db_product
async def update(self, product_id: int, data: dict) -> Product | None:
stmt = (
update(Product)
.where(Product.id == product_id)
.values(**data)
.returning(Product)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def delete(self, product_id: int) -> bool:
stmt = delete(Product).where(Product.id == product_id)
result = await self.db.execute(stmt)
return result.rowcount > 0
الناتج:
# تم تعريف الدالة بنجاح
(2) ▶ مثال: PriceRepository
class PriceRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_product(
self,
product_id: int,
min_price: float | None = None,
max_price: float | None = None,
skip: int = 0,
limit: int = 50,
) -> list[Price]:
stmt = (
select(Price)
.where(Price.product_id == product_id)
.order_by(Price.recorded_at.desc())
.offset(skip)
.limit(limit)
)
if min_price is not None:
stmt = stmt.where(Price.price >= min_price)
if max_price is not None:
stmt = stmt.where(Price.price <= max_price)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def bulk_create(self, prices: list[PriceCreate]) -> list[Price]:
db_prices = [Price(**p.model_dump()) for p in prices]
self.db.add_all(db_prices)
await self.db.flush()
return db_prices
الناتج:
# تم تعريف الدالة بنجاح
4. التقسيم والفرز
(1) نموذج استعلام Pydantic
غلف معلمات التقسيم في نموذج Pydantic لتجنب الاضطرار إلى تعريف معلمات الاستعلام بشكل متكرر في كل نقطة نهاية.
(1) ▶ مثال: نموذج الاستعلام المقسم
from pydantic import BaseModel, Field
from typing import Optional
class PaginationParams(BaseModel):
skip: int = Field(0, ge=0, description="عدد السجلات للتخطي")
limit: int = Field(20, ge=1, le=100, description="الحد الأقصى للسجلات المرجعة")
class ProductFilter(PaginationParams):
category: Optional[str] = Field(None, max_length=100)
min_price: Optional[float] = Field(None, ge=0, description="الحد الأدنى للسطح بالدولار")
max_price: Optional[float] = Field(None, ge=0, description="الحد الأقصى للسطح بالدولار")
sort_by: str = Field("name", pattern="^(name|base_price|created_at)$")
sort_order: str = Field("asc", pattern="^(asc|desc)$")
الناتج:
# التنفيذ ناجح
(2) ▶ مثال: نقاط نهاية باستخدام نموذج التقسيم
from fastapi import FastAPI, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
app = FastAPI()
@app.get("/products", response_model=list[ProductResponse])
async def list_products(
category: Optional[str] = Query(None),
min_price: Optional[float] = Query(None, ge=0),
max_price: Optional[float] = Query(None, ge=0),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
repo = ProductRepository(db)
return await repo.get_all(
category=category,
min_price=min_price,
max_price=max_price,
skip=skip,
limit=limit,
)
الناتج:
# تم تعريف الدالة بنجاح
(2) مقارنة استراتيجيات التقسيم
| الاستراتيجية | التنفيذ | المزايا | العيوب |
|---|---|---|---|
| تقسيم الإزاحة | OFFSET n LIMIT m |
بسيط، يدعم القفز بين الصفحات | أداء ضعيف مع الإزاحات الكبيرة |
| تقسيم المؤشر | WHERE id > cursor LIMIT m |
أداء جيد مع مجموعات البيانات الكبيرة | لا يدعم القفز بين الصفحات |
| تقسيم Keyset | WHERE created_at < last LIMIT m |
التقسيم حسب حقل الفرز يعمل بشكل جيد | يتطلب حقلًا مرتبًا |
5. التحميل المسبق للبيانات المرتبطة
(1) مشكلة استعلام N+1
(1) ▶ مثال: عرض مشكلة N+1
# سيء: استعلامات N+1 - استعلام واحد للمنتجات + N استعلام للأسعار
stmt = select(Product)
result = await db.execute(stmt)
products = result.scalars().all()
for p in products:
# كل وصول يؤدي إلى استعلام منفصل!
print(len(p.prices)) # N استعلام
الناتج:
# التنفيذ ناجح
(2) ▶ مثال: التحميل المسبق باستخدام selectinload
from sqlalchemy.orm import selectinload, joinedload
# جيد: استعلامان فقط - منتجات + أسعار (باستخدام SELECT IN)
stmt = select(Product).options(selectinload(Product.prices))
result = await db.execute(stmt)
products = result.scalars().all()
for p in products:
print(len(p.prices)) # لا استعلامات إضافية
الناتج:
# التنفيذ ناجح
(2) selectinload مقابل joinedload
| البعد | selectinload | joinedload |
|---|---|---|
| استراتيجية SQL | SELECT ... WHERE id IN (...) | LEFT OUTER JOIN |
| عدد الاستعلامات | 2 استعلام | 1 استعلام |
| حجم البيانات | دقيق (بدون صفوف مكررة) | JOIN قد ينتج صفوفًا مكررة |
| حالات الاستخدام | واحد إلى متعدد (مجموعة) | متعدد إلى واحد / واحد إلى واحد |
| الأداء | أفضل لمجموعات البيانات الكبيرة | أفضل لمجموعات البيانات ذات العلاقات القليلة |
| إزالة التكرار | غير مطلوب | مطلوب unique() |
(3) ▶ مثال: استعلام المنتج مع تحميل الأسعار مسبقًا
async def get_product_with_prices(
self, product_id: int
) -> Product | None:
stmt = (
select(Product)
.options(selectinload(Product.prices))
.where(Product.id == product_id)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
الناتج:
# تم تعريف الدالة بنجاح
6. إدارة المعاملات
(1) المعاملات عبر الجداول
(1) ▶ مثال: معاملة استيراد أسعار مجمعة
from sqlalchemy.ext.asyncio import AsyncSession
class PriceService:
def __init__(self, db: AsyncSession):
self.db = db
self.price_repo = PriceRepository(db)
self.product_repo = ProductRepository(db)
async def bulk_import_prices(
self,
product_id: int,
prices: list[PriceCreate],
) -> list[Price]:
# التحقق من وجود المنتج
product = await self.product_repo.get_by_id(product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
# التحقق من أن جميع الأسعار تشير إلى نفس المنتج
for p in prices:
p.product_id = product_id
# إدراج مجمّع داخل المعاملة (جلسة db تتعامل مع الالتزام/الإرجاع)
db_prices = await self.price_repo.bulk_create(prices)
# تحديث السعر الأساسي للمنتج إلى الأحدث
latest_price = db_prices[-1]
await self.product_repo.update(
product_id,
{"base_price": latest_price.price},
)
return db_prices
الناتج:
# تم تعريف الدالة بنجاح
(2) ▶ مثال: استيراد مجمّع لنقاط النهاية
@app.post("/products/{product_id}/prices/bulk", response_model=list[PriceResponse])
async def bulk_import(
product_id: int = Path(gt=0),
prices: list[PriceCreate] = ...,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
# فحص الاشتراك: المستخدمون المجانيون محدودون بـ 1000 سعر
if user["subscription"] == "free" and len(prices) > 1000:
raise HTTPException(
status_code=403,
detail="Free plan limited to 1000 prices per import. Upgrade to Pro.",
)
service = PriceService(db)
return await service.bulk_import_prices(product_id, prices)
الناتج:
# تم تعريف الدالة بنجاح
❓ أسئلة شائعة
flush و commit؟flush يرسل SQL إلى قاعدة البيانات ولكنه لا يلتزم المعاملة؛ يمكنه استرداد معرفات الزيادة التلقائية وتشغيل فحوصات قيود قاعدة البيانات. commit يلتزم المعاملة، مما يجعل التغييرات دائمة. في اعتمادات yield، نوصي باستخدام flush متبوعًا بـ commit تلقائي.response_model إلى الحقول المرتبطة. تأكد من التحميل المسبق للبيانات.bulk_create؟OFFSET 1000000 يحتاج أولًا إلى مسح أول مليون صف قبل تخطيها؛ وقت الاستعلام يتناسب مع الإزاحة. لمجموعات البيانات بالملايين، نوصي باستخدام تقسيم المؤشر.yield تلقائيًا عند حدوث استثناء. إذا كنت بحاجة إلى إرجاع جزئي، استخدم نقطة حفظ (await session.begin_nested()).📖 ملخص
- نمط Repository يغلف عمليات SQL داخل فئة Repository، لذا تركز دوال التوجيه فقط على منطق HTTP
- معلمات التقسيم مغلفة كنموذج Pydantic لتجنب تعريف معلمات الاستعلام بشكل متكرر لكل نقطة نهاية
selectinloadمناسب للتحميل المسبق واحد إلى متعدد (بدون صفوف مكررة)؛joinedloadمناسب لمتعدد إلى واحد (استعلام واحد)- العمليات المجمعة تُنفذ داخل معاملة؛ يعتمد
yieldعلى إدارة الالتزام/الإرجاع التلقائي - استيراد الأسعار المجمّع لـ PriceTracker يدعم آلاف السجلات؛ المستخدمون المجانيون محدودون بـ 1000 سجل
📝 تمارين
- تمرين أساسي (الصعوبة ⭐): نفذ دالتي
get_by_idوget_allفيProductRepository، وحقن جلسة DB في نقاط النهاية، واستدعهما. تلميح:select(Product).where(Product.id == product_id) - تمرين متقدم (الصعوبة ⭐⭐): أضف دعمًا للاستعلامات المقسمة (باستخدام معلمات
skipوlimit)، ونفذ التحميل المسبقselectinload(Product.prices)، وأعد تفاصيل المنتج التي تتضمن الأسعار. تلميح:options(selectinload(...)) - تحدي (الصعوبة ⭐⭐⭐): نفذ
PriceService.bulk_import_prices، وتحقق من وجود المنتج، وأدرج الأسعار بشكل مجمّع، وحدّثbase_priceللمنتج—كل ذلك ضمن معاملة واحدة. المستخدمون المجانيون محدودون بـ 1000 سجل. تلميح:bulk_create+update+Depends(get_current_user)
---|



