بايثون وقواعد البيانات
درس 4 من 5
4بايثون وقواعد البيانات: من MySQLdb إلى ORM (Python & Databases: from MySQLdb to ORM)
الصورة الكبيرةكيف يتحدث برنامج بايثون مع قاعدة بيانات: أولًا بالطريقة المباشرة (برنامج تشغيل يرسل جمل SQL ويستلم صفوفًا)، ثم بالطريقة الكائنية حيث يصبح الجدول صنفًا والصف كائنًا وتتولى المكتبة كتابة SQL عنك.
ماذا تشاهد في الرسم: الكود يرسل الاستعلام وقيمه منفصلة عبر المشغّل، فتعود الصفوف إلى بايثون. والتغيير لا يصير دائمًا حتى تنادي commit.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
برنامجك وقاعدة البيانات يتكلمان لغتين مختلفتين. الطريقة الأولى أن ترسل لها رسائل مكتوبة بلغتها (SQL) عبر ساعي بريد (برنامج التشغيل). الطريقة الثانية أن تستأجر مترجمًا (ORM): تتعامل أنت مع أشياء بايثون العادية، وهو يكتب الرسائل عنك.
برنامج التشغيل يتبع DB-API مثل sqlite3: اتصال ثم مؤشر ثم execute بعلامات ? بدل دمج النص، وهذا ما يمنع حقن SQL. التغييرات لا تُحفظ حتى commit. وSQLAlchemy ORM يربط الجدول بصنف والصف بكائن، وفيه Engine وSession والنموذج والاستعلام، وflush يرسل دون أن يثبّت.
الأساسيات المطلوبة
هذا الجزء هو الجسر بين عالمين تعلّمتهما منفصلين، فتأكّد أنك مرتاح في الاثنين:
الأصناف والكائنات (درس الأصناف والكائنات)
ستحوّل جدولًا إلى صنف، فيجب أن تفهم __init__ والخصائص و __repr__ وأن الكائن له حالته المستقلة.
SQL و JOIN (درسا SQL: عمليات CRUD وSQL: الربط)
المكتبة تكتب SQL عنك، لكنك تحتاج أن تقرأه لتتأكد أنه ما تقصده. من لا يفهم SELECT … WHERE لا يستطيع تقييم ما يفعله الـORM خلف الستار.
تصميم الجداول (درس تصميم القواعد)
مفاتيح أساسية وأجنبية وعلاقات واحد-لمتعدد: نفس هذه الأفكار ستصبح سطورًا في تعريف الصنف (ForeignKey و relationship).
Exceptions (درس الاستثناءات)
الاتصال بقاعدة بيانات قد يفشل (كلمة مرور خاطئة، خادم متوقف). try/except/finally هو ما يضمن إغلاق الاتصال.
المفاهيم الأساسية
الفكرة الكبرى: المخزن (جدول في قاعدة) والاستخدام (كائن في بايثون) عالمان مختلفان. نحتاج طبقة تترجم بينهما، ولدينا خياران: ترجمة يدوية أو ترجمة آلية.
1برنامج التشغيل (Driver) و DB-API
برنامج التشغيل مكتبة تعرف بروتوكول خادم معيّن. لـMySQL المكتبة الشائعة اسمها mysqlclient وتُستورد باسم MySQLdb. كل برامج التشغيل في بايثون تتبع اتفاقية واحدة اسمها DB-API 2.0 (وثيقة PEP 249)، لذلك يتشابه الأسلوب: connect() ثم cursor() ثم execute() ثم fetch…() ثم commit() ثم close().
| الكائن | دوره | أهم ما يُستدعى |
|---|---|---|
| الاتصال Connection | قناة مفتوحة مع الخادم، وهي التي تملك المعاملة (transaction) الحالية. | commit() rollback() close() |
| المؤشر Cursor | أداة تنفّذ الجمل وتتنقل بين نتائجها. | execute() executemany() fetchone() fetchall() close() |
| المعاملات Parameters | قيم تُمرَّر منفصلة عن نص الجملة وتُركَّب في أماكن العلامات. | execute(sql, (v1, v2)) |
2العلامات بدل الدمج: ما يمنع حقن SQL
أخطر خطأ في هذه الطبقة هو بناء الجملة بدمج نصوص المستخدم داخلها. أي مدخل يحتوي علامة اقتباس يستطيع أن «يخرج» من القيمة ويكتب SQL خاصًا به. الحل أن تكتب الجملة بعلامات (%s في MySQLdb، و ? في sqlite3) وتمرّر القيم في وسيط ثانٍ؛ برنامج التشغيل هو من يُهرّب القيم بأمان.
| البرنامج | نمط العلامة | ملاحظة |
|---|---|---|
MySQLdb | %s | تُستخدم لكل الأنواع (نصوص وأرقام). لا تضع علامات حول %s. والنسبة المئوية الحرفية تُكتب %%. |
sqlite3 | ? | نمط مختلف لنفس الفكرة؛ لذلك لا تنسخ الجملة بين برامج التشغيل بدون مراجعة. |
"… WHERE name = '%s'" % user_input (عامل التنسيق % في بايثون) ظنًّا أنها نفس العلامة. الفرق قاتل: هذه تدمج النص قبل أن يصل لبرنامج التشغيل. الصحيح: cur.execute("… WHERE name = %s", (user_input,)) بفاصلة بين الوسيطين، والقيمة الواحدة تحتاج فاصلة لتصبح tuple.
3المعاملات (Transactions): لا تُحفظ التغييرات حتى commit
برنامج التشغيل يفتح معاملة ضمنيًا، فتبقى INSERT و UPDATE و DELETE معلّقة ولا يراها اتصال آخر حتى تستدعي commit(). وإن حدث خطأ في المنتصف فـrollback() يتراجع عن كل ما جرى منذ آخر commit. وجمل SELECT وحدها لا تحتاج commit.
4لماذا ORM؟ فجوة التمثيل
الصفوف تأتي من برنامج التشغيل صفوفًا خامًا (tuples مثل (1, 'Flat White', 1400))، فتصبح أنت المسؤول عن تذكّر أن الفهرس 2 هو السعر، وعن كتابة SQL لكل عملية، وعن تحويل الصفوف إلى كائنات. الـORM (Object-Relational Mapper) يلغي هذا العمل المتكرر بتعريف خريطة واحدة:
| في قاعدة البيانات | في بايثون |
|---|---|
جدول drinks | صنف Drink |
| صف | كائن (instance) من الصنف |
| عمود | خاصية على الصنف |
| مفتاح أجنبي | relationship: قائمة أو كائن مرتبط |
INSERT / UPDATE / DELETE | تعديل الكائن أو session.add / session.delete ثم commit |
5مكونات SQLAlchemy الأربعة
المحرك Engine
نقطة البداية: يُنشأ مرة واحدة من URL مثل mysql+mysqldb://user:pass@host/db، ويدير حوض اتصالات (connection pool) فلا تفتح اتصالًا جديدًا لكل عملية.
القاعدة Base والأصناف المربوطة
صنف أساسي من declarative_base()؛ كل صنف يرثه ويحدد __tablename__ وأعمدته يصبح «مربوطًا» بجدول. و Base.metadata.create_all(engine) ينشئ الجداول الناقصة فقط.
الجلسة Session
«محادثتك» مع القاعدة: تتتبع الكائنات التي حمّلتها أو أضفتها، وتحتفظ بـخريطة هوية (identity map) فلا يوجد إلا كائن واحد لكل مفتاح أساسي، وتؤخر كتابة التغييرات حتى flush.
الاستعلام Query
session.query(Drink).filter(…).order_by(…).all() يبني SQL من تعبيرات بايثون؛ .first() و .one() و .get() للحالات الأخرى.
6flush مقابل commit
flush يرسل الـSQL المعلّق إلى القاعدة داخل المعاملة (فيحصل الكائن الجديد على رقمه id) لكنه لا يحفظ نهائيًا. commit يعمل flush أولًا ثم يثبّت المعاملة. والجلسة تعمل flush تلقائيًا قبل أي استعلام، لذلك ترى تعديلاتك غير المثبتة في استعلاماتك التالية داخل نفس الجلسة.
with Session(engine) as session: فيُغلقها بايثون تلقائيًا عند نهاية الكتلة. وبعد commit تُعتبر الكائنات «منتهية الصلاحية» وتُعاد قراءتها عند أول وصول، وهذا السلوك الافتراضي مقصود.
كيف تتحدث بايثون مع القاعدة خطوة بخطوة؟
ثلاث صور تلخّص كل شيء: أين تقع كل طبقة، وماذا يحدث عند حفظ كائن، وما الحالات التي يمر بها الكائن.
الرسم 29: طبقات الوصول: المسار المنقّط هو الطريقة المباشرة بلا ORM، والمسار الكامل هو ما يحدث حين تستخدم ORM: وفي الحالتين يبقى برنامج التشغيل هو من يكلّم الخادم فعليًا.
الرسم 30: رحلة add ثم commit: لا SQL يُرسل عند add، وكل شيء يخرج دفعة واحدة عند commit (أو عند flush سابق له).
الرسم 31: دورة حياة الكائن مع الجلسة: أخطاء مثل «DetachedInstanceError» تعني غالبًا أنك لمست كائنًا بعد إغلاق جلسته.
- أنشئ المحرك مرة واحدة عند بدء البرنامج، وعرّف الأصناف، واستدعِ
create_allإن أردت إنشاء الجداول. - افتح جلسة لكل وحدة عمل قصيرة (طلب أو سكربت)، لا جلسة واحدة عالمية للأبد.
- أنشئ الكائنات و
addأو حمّلها بالاستعلام، ثم عدّل خصائصها كأي كائن بايثون. - استدعِ
commit()مرة عند اكتمال الوحدة، أوrollback()عند أي خطأ. - أغلق الجلسة (يفعلها
withعنك) وأعد الاتصال لحوض الاتصالات.
مثال تطبيقي: من SQL المباشر إلى الأصناف
نبقى في مقهانا (درس تصميم القواعد) بجدولين: baristas و drinks (كل مشروب يعدّه باريستا واحد). لتشغيل الأمثلة في أي جهاز بلا خادم نستخدم SQLite في الذاكرة؛ والانتقال إلى MySQL يغيّر سطر الاتصال فقط.
الخطوة 1: الطريقة المباشرة (DB-API)
import sqlite3
con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE drinks (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price_halalas INTEGER NOT NULL)")
cur.execute("INSERT INTO drinks (name, price_halalas) VALUES (?, ?)", ("Flat White", 1400))
cur.execute("INSERT INTO drinks (name, price_halalas) VALUES (?, ?)", ("Cortado", 1200))
con.commit() # بدونها لا تُحفظ التغييرات
cur.execute("SELECT name FROM drinks WHERE name = ?", ("Cortado",))
print(cur.fetchall()) # [('Cortado',)]
con.close()ومع MySQL يبقى الهيكل نفسه ويتغير الاتصال ونمط العلامة (%s). لاحظ أن كلمة المرور تأتي من متغير بيئة لا من الكود:
import os
import MySQLdb
conn = MySQLdb.connect(host="localhost", port=3306, user="cafe_user",
passwd=os.environ["CAFE_DB_PASSWORD"], db="cafe", charset="utf8mb4")
cur = conn.cursor()
try:
cur.execute("INSERT INTO drinks (name, price_halalas) VALUES (%s, %s)", ("V60", 1800))
conn.commit()
except MySQLdb.Error:
conn.rollback()
raise
finally:
cur.close()
conn.close()password و database، بينما صفحات كثيرة (ومنها مراجع المشروع) تستخدم passwd و db. تحقق من إصدارك المثبّت. والمحرف charset يجب تمريره بالاسم (keyword).
الخطوة 2: لماذا العلامات؟ جرّب الحقن بنفسك
evil = "x' OR '1'='1"
# آمن: القيمة تُعامل كنص واحد
cur.execute("SELECT name FROM drinks WHERE name = ?", (evil,))
print(cur.fetchall()) # []
# خطر: دمج النص يغيّر معنى الجملة
bad = "SELECT name FROM drinks WHERE name = '%s'" % evil
print(bad) # SELECT name FROM drinks WHERE name = 'x' OR '1'='1'
cur.execute(bad)
print(cur.fetchall()) # [('Flat White',), ('Cortado',)] كل الصفوف!الخطوة 3: نفس العالم بالـORM: خريطة واحدة
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import declarative_base, relationship, Session
Base = declarative_base()
class Barista(Base):
__tablename__ = "baristas"
id = Column(Integer, primary_key=True)
name = Column(String(60), nullable=False)
drinks = relationship("Drink", back_populates="barista", order_by="Drink.id")
class Drink(Base):
__tablename__ = "drinks"
id = Column(Integer, primary_key=True)
name = Column(String(60), nullable=False)
price_halalas = Column(Integer, nullable=False)
barista_id = Column(Integer, ForeignKey("baristas.id"))
barista = relationship("Barista", back_populates="drinks")
def __repr__(self):
return f"Drink({self.name!r}, {self.price_halalas})"
# SQLite للتجربة. مع MySQL: "mysql+mysqldb://user:pass@localhost/cafe?charset=utf8mb4"
engine = create_engine("sqlite://", pool_pre_ping=True)
Base.metadata.create_all(engine)الخطوة 4: إضافة كائنات ومشاهدة دورة الحياة
from sqlalchemy import inspect
with Session(engine) as s:
d = Drink(name="Flat White", price_halalas=1400)
print(inspect(d).transient) # True كائن جديد لا تعرفه أي جلسة
s.add(d)
print(inspect(d).pending) # True ينتظر الكتابة
s.flush()
print(inspect(d).persistent, d.id) # True 1 حصل على مفتاحه
s.commit()
with Session(engine) as s:
s.add_all([
Barista(name="Noura", drinks=[Drink(name="Cortado", price_halalas=1200),
Drink(name="Mocha", price_halalas=1600)]),
Barista(name="Sultan", drinks=[Drink(name="V60", price_halalas=1800)]),
])
s.commit() # باريستا + مشروباتهم في معاملة واحدة، والمفاتيح الأجنبية تُملأ تلقائيًاالخطوة 5: الاستعلام والتعديل
with Session(engine) as s:
cheap = (s.query(Drink)
.filter(Drink.price_halalas < 1500)
.order_by(Drink.price_halalas)
.all())
print(cheap) # [Drink('Cortado', 1200), Drink('Flat White', 1400)]
d = s.get(Drink, 1) # بحث بالمفتاح الأساسي
d.price_halalas = 1500 # تعديل الخاصية يكفي؛ لا UPDATE يدوي
s.commit()
print(s.query(Drink).filter_by(name="Flat White").one().price_halalas) # 1500الخطوة 6: فخ N+1: عدّ الاستعلامات
العلاقة تُحمَّل كسولًا (lazy) افتراضيًا: لا يُقرأ barista.drinks إلا عند أول وصول إليه. في حلقة على كل الباريستا يعني هذا استعلامًا واحدًا للباريستا + استعلامًا لكل واحد منهم. عددنا الاستعلامات بمستمع حدث على المحرك (before_cursor_execute) فكانت النتيجة:
| الأسلوب | الكود | عدد الاستعلامات (باريستا اثنان) |
|---|---|---|
| كسول (الافتراضي) | for b in s.query(Barista): b.drinks | 3 |
| تحميل مبكر بـ JOIN | s.query(Barista).options(joinedload(Barista.drinks)) | 1 |
from sqlalchemy.orm import joinedload
for b in s.query(Barista).options(joinedload(Barista.drinks)).order_by(Barista.id):
print(b.name, [x.name for x in b.drinks])
# Noura ['Cortado', 'Mocha']
# Sultan ['V60']create_engine(url, echo=True) أثناء التطوير فيطبع كل جملة SQL. إن رأيت نفس الجملة تتكرر عشرات المرات بقيم مختلفة فهذا N+1. اقرأ ما يولّده الـORM دائمًا: هو «يكتب SQL عنك» لا «يعفيك من فهمه».
خرافة مقابل حقيقة
| الخرافة | الحقيقة |
|---|---|
| «مع ORM لا أحتاج أن أتعلم SQL». | الـORM يولّد SQL وأنت المسؤول عن جودته. بدون فهم JOIN و WHERE لن تكتشف N+1 ولا استعلامًا بطيئًا. |
«commit يحمي من الحقن». | الحقن مسألة كيف بُنيت الجملة لا كيف حُفظت. العلامات (والـORM يستخدمها تلقائيًا) هي الحماية، ويبقى الخطر عند أي SQL خام تدمج نصه بيدك. |
| «الـORM يجعل الكود مستقلًا تمامًا عن نوع القاعدة». | يقرّبك كثيرًا (تغيير URL غالبًا)، لكن الأنواع والقيود وبعض الدوال تختلف بين المحركات، فاختبر على المحرك الحقيقي. |
| «جلسة واحدة عالمية أسهل وأسرع». | الجلسة ليست آمنة للاستخدام من عدة خيوط، وتتراكم فيها الكائنات والمعاملات المفتوحة. الأفضل جلسة قصيرة لكل وحدة عمل. |
أين يُستخدم هذا فعليًا؟
تطبيقات الويب
أطر مثل Flask و Django تتعامل مع القاعدة عبر ORM: كل طلب يفتح جلسة قصيرة، يقرأ ويكتب، ثم يغلقها. وهذا ما ستراه يجتمع في قسم «أطر العمل» الذي يلي هذا القسم.
الاختبار
تغيير URL إلى sqlite:// يعطيك قاعدة في الذاكرة لكل اختبار، بلا خادم ولا بيانات متبقية.
ترحيل التخزين
كتابة منطق العمل على كائنات بايثون يسهّل تبديل مكان التخزين لاحقًا (ملف ← قاعدة)، وهذه هي الفكرة وراء نماذج التخزين التي ستراها في أنماط التصميم.
السكربتات والتقارير
سكربت قصير بـMySQLdb يكفي أحيانًا لتقرير أو ترحيل بيانات؛ اختر الأداة الأخف التي تناسب حجم المهمة.
جرّب بنفسك
لماذا cur.execute("... WHERE name = %s", (name,)) أفضل من دمج الاسم في النص؟
القيمة تُرسل منفصلة فلا تُقرأ كأمر أبدًا، حتى لو كتب المستخدم علامة تنصيص أو أمر SQL.
بطاقات مراجعة سريعة
INSERT بدون commit()؟commit.%s لكل الأنواع، والقيم تُمرَّر كـtuple في وسيط ثانٍ.joinedload أو بتحميل مبكر مناسب.اختبر فهمك
1. لماذا لا نكتب cur.execute("SELECT * FROM drinks WHERE id = " + user_id) حتى لو كان user_id رقمًا؟
لأن القيمة تأتي من الخارج كنص، ويمكن أن تحمل 1 OR 1=1 أو أسوأ. القيمة يجب أن تُمرَّر كمعامل ليتولى برنامج التشغيل تهريبها: cur.execute("… WHERE id = %s", (user_id,)).
2. بعد session.add(d) ثم print(d.id) تظهر None. لماذا؟ وكيف نحصل على الرقم قبل commit؟
لأن الكائن pending ولم يُرسل INSERT بعد. استدعِ session.flush() فيُنفَّذ INSERT داخل المعاملة ويحصل الكائن على id.
3. (اكتشف الخطأ) سكربت يحمّل 500 باريستا ثم يطبع مشروبات كل واحد، ويستغرق دقيقة. ما أول فرضية؟
N+1: 501 استعلامًا بسبب التحميل الكسول. الحل بتحميل المشروبات مع الباريستا (joinedload) فيصبح استعلامًا واحدًا. تحقّق بتشغيل echo=True قبل أن تفترض.
4. لماذا نفتح الجلسة بـwith؟
لأن الإغلاق يتم تلقائيًا حتى عند حدوث استثناء، فلا تبقى اتصالات أو معاملات معلّقة.
5. (قرار) تريد جعل name فريدًا في جدول المشروبات. أين تضع هذه القاعدة: في الصنف أم في القاعدة؟
في القاعدة عبر قيد (unique=True في تعريف العمود ينشئ UNIQUE)، لأن القيد في القاعدة يحمي من أي تطبيق أو سكربت يكتب إليها، ثم تعالج IntegrityError في الكود.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي3
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.