ORM و SQLAlchemy
در دو فصل گذشته SQL نوشتیم و بعد همان SQL را از داخل پایتون اجرا کردیم. حالا یک قدم بالاتر میرویم: دیگر SQL نمینویسیم. با شیء کار میکنیم و کتابخانه، SQL را به جای ما میسازد. برای اینکه این جادو راز نماند، اول یک ORM کوچک را خودمان میسازیم و بعد سراغ SQLAlchemy میرویم.
۱. ORM یعنی چه
ORM کوتاهشده Object Relational Mapper است: نگاشتدهنده شیء به رابطه. کار آن یک ترجمه ساده است و کل ایده در سه سطر جا میشود.
| در پایگاه داده | در پایتون |
|---|---|
| جدول | کلاس |
| سطر | شیء (نمونه کلاس) |
| ستون | صفت شیء |
| کلید خارجی | ارجاع مستقیم به شیء دیگر |
INSERT | ساختن شیء و ذخیره کردنش |
SELECT | صدا زدن یک متد جستوجو |
UPDATE | تغییر دادن یک صفت |
DELETE | صدا زدن delete() |
یعنی همه چیزهایی که در فصل بیست و یکم تا بیست و چهارم درباره شیءگرایی
یاد گرفتید، حالا به کارتان میآید. یک ORM چیزی جز یک لایه شیءگرای
هوشمند روی sqlite3 نیست.
۲. یک کار، دو نوشتن
بهترین راه دیدن فایده ORM این است که یک کار کاملاً یکسان را دو بار
بنویسیم. نمونه 01_why_orm/app.py دقیقاً همین کار را
میکند: یک دانشجو را درج میکند و بعد پیدایش میکند.
def with_raw_sql() -> None:
'''The way of session 31.
Problems:
* the SQL is spread all over the program;
* a row is a tuple, so we read it with row[2] and nobody knows what
column 2 is;
* every table needs its own copy-pasted insert/select/update/delete;
* changing a column name means hunting through the whole project.
'''
conn = sqlite3.connect(DB)
cursor = conn.cursor()
cursor.execute('''CREATE TABLE IF NOT EXISTS students (
student_id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL)''')
cursor.execute(
'INSERT OR IGNORE INTO students (first_name, last_name, email) '
'VALUES (?, ?, ?)',
('Ali', 'Rezaei', 'ali@kadoosedu.ir'),
)
conn.commit()
cursor.execute('SELECT * FROM students WHERE email = ?',
('ali@kadoosedu.ir',))
row = cursor.fetchone()
print('raw SQL ->', row, '| the name is', row[1]) # row[1]... which one?
conn.close()
روش فصل قبل: نمونه 01_why_orm/app.py
def with_an_orm() -> None:
'''The same thing, with the tiny ORM of folder 02.
The SQL lives in ONE place (the model), and the rest of the program only
ever talks about objects and attributes.
'''
from models import Student
Student.create_table()
student = Student.get_or_create(
email='ali@kadoosedu.ir',
first_name='Ali',
last_name='Rezaei',
)
print('ORM ->', student, '| the name is', student.first_name)
همان کار با ORM
شرح مثال
- نسخه دوم کوتاهتر است، ولی مهمتر از کوتاهی، خوانایی است:
student.first_nameدر برابرrow[1]. - در نسخه اول اگر روزی ستونی به جدول اضافه شود، شمارهها جابهجا میشوند و کد بیسروصدا غلط میشود. در نسخه دوم هیچ اتفاقی نمیافتد.
- در نسخه اول SQL در سراسر برنامه پخش است. در نسخه دوم SQL فقط در یک جا زندگی میکند: داخل مدل.
- چون همه پرسوجوها از یک جا رد میشوند، امن بودنشان هم یک بار تضمین میشود، نه صد بار.
هر ORM در نهایت همان INSERT و SELECT را
میسازد و به پایگاه داده میفرستد. اگر SQL بلد نباشید، ORM را هم
نمیتوانید درست به کار ببرید و وقتی کند شود نمیفهمید چرا. به همین
دلیل فصل سیام قبل از این فصل آمده است.
۳. کلاس پایه: قلب ORM دستساز
حالا خودمان یک ORM کوچک میسازیم. فکر اصلی این است: هر مدل فقط باید بگوید نامش چیست و چه ستونهایی دارد؛ بقیه کارها را یک کلاس پایه انجام میدهد که همه از آن ارث میبرند.
'''The shared part of every model.
This is the heart of the tiny ORM: the connection handling and the generic
CRUD helpers live here ONCE, and every model inherits them.
'''
import sqlite3
DATABASE_PATH = 'database.db'
class Model:
'''Base class of every model.
A subclass only has to define:
table the name of the table
pk the name of its primary key column
columns the other columns, in order
SCHEMA the CREATE TABLE statement
'''
table: str = ''
pk: str = 'id'
columns: list[str] = []
SCHEMA: str = ''
@classmethod
def connect(cls) -> sqlite3.Connection:
'Return a configured connection. Everything goes through here.'
conn = sqlite3.connect(DATABASE_PATH)
conn.execute('PRAGMA foreign_keys = ON')
conn.row_factory = sqlite3.Row # rows readable by column name
return conn
@classmethod
def create_table(cls) -> None:
'Create the table of this model.'
with cls.connect() as conn:
conn.executescript(cls.SCHEMA)
کلاس پایه: نمونه 02_simple_orm/models/base.py
شرح مثال
- چهار صفت کلاسی
tableوpkوcolumnsوSCHEMAتنها چیزی هستند که یک مدل جدید باید پر کند. connectهمان تابع کمکی اتصال فصل قبل است، فقط این بار به شکل یک متد کلاسی.PRAGMAوrow_factoryبرای همیشه حل شدهاند.- تفاوت
@classmethodبا متد معمولی را از فصل بیست و دوم به یاد بیاورید: این متدها روی خود کلاس کار میکنند، نه روی یک شیء خاص، چون هنوز هیچ شیئی وجود ندارد. clsدر زمان اجرا به کلاس فرزند اشاره میکند. یعنی وقتیStudent.create_table()را صدا میزنید،cls.SCHEMAهمان اسکیمای دانشجو است. این دقیقاً همان چندریختی فصل بیست و سوم است.
۴. خواندن و نوشتن در کلاس پایه
حالا چهار عمل اصلی را یک بار برای همیشه در همین کلاس پایه مینویسیم. اول خواندن:
@classmethod
def all(cls) -> list['Model']:
'Return every row as a list of objects.'
conn = cls.connect()
try:
rows = conn.execute(
f'SELECT * FROM {cls.table} ORDER BY {cls.pk}'
).fetchall()
finally:
conn.close()
return [cls.from_row(row) for row in rows]
@classmethod
def get(cls, **where) -> 'Model | None':
'''Return the first row matching the given columns, or None.
Example: Student.get(email='ali@kadoosedu.ir')
'''
# The column NAMES come from our own code, the VALUES from the caller,
# so only the values become '?' parameters.
conditions = ' AND '.join(f'{column} = ?' for column in where)
values = tuple(where.values())
conn = cls.connect()
try:
row = conn.execute(
f'SELECT * FROM {cls.table} WHERE {conditions}', values
).fetchone()
finally:
conn.close()
return cls.from_row(row) if row else None
@classmethod
def from_row(cls, row: sqlite3.Row) -> 'Model':
'Build an object out of a database row.'
obj = cls.__new__(cls) # create it without __init__
for key in row.keys():
setattr(obj, key, row[key])
return obj
خواندن: ادامه base.py
شرح مثال
**whereاز فصل سیزدهم میآید:Student.get(email='...')را میشود با هر ستونی صدا زد و کد شرط را خودش میسازد.- به نکته امنیتی دقت کنید: نام ستونها از کد خودمان میآید و داخل f-string مینشیند، ولی مقدارها همیشه به شکل پارامتر
?فرستاده میشوند. این همان قاعده فصل قبل است. from_rowباcls.__new__(cls)یک شیء خالی میسازد و__init__را دور میزند، چون دادهها از پایگاه داده میآیند و نباید دوباره اعتبارسنجی یا مقداردهی شوند.setattr(obj, key, row[key])ستونها را یکییکی به صفت تبدیل میکند. همین یک خط، بخش «نگاشت» ORM است.
و حالا نوشتن:
def save(self) -> None:
'Insert this object, and store the id it received.'
columns = ', '.join(self.columns)
placeholders = ', '.join('?' for _ in self.columns)
values = tuple(getattr(self, column) for column in self.columns)
with self.connect() as conn:
cursor = conn.execute(
f'INSERT INTO {self.table} ({columns}) VALUES ({placeholders})',
values,
)
setattr(self, self.pk, cursor.lastrowid)
def update(self, **changes) -> None:
'''Change some columns of this object AND of its row.'''
for column in changes:
if column not in self.columns:
raise ValueError(f'unknown column: {column}')
assignments = ', '.join(f'{column} = ?' for column in changes)
values = tuple(changes.values()) + (getattr(self, self.pk),)
with self.connect() as conn:
conn.execute(
f'UPDATE {self.table} SET {assignments} WHERE {self.pk} = ?',
values,
)
# Keep the object and the row in agreement.
for column, value in changes.items():
setattr(self, column, value)
def delete(self) -> None:
'Delete the row of this object.'
with self.connect() as conn:
conn.execute(
f'DELETE FROM {self.table} WHERE {self.pk} = ?',
(getattr(self, self.pk),),
)
نوشتن: ادامه base.py
شرح مثال
- جای علامتهای سؤال با
', '.join('?' for _ in self.columns)ساخته میشود، یعنی به تعداد ستونها. مدل هر تعداد ستون داشته باشد کار میکند. - بعد از درج، شناسه تولیدشده با
cursor.lastrowidروی خود شیء نوشته میشود. از آن لحظه شیء «میداند» کدام سطر است. updateاول نام ستونها را اعتبارسنجی میکند. این خط ظاهراً کوچک، جلوی نوشتن نام دلخواه داخل SQL را میگیرد.- نکتهای که خیلیها فراموش میکنند: بعد از
UPDATEباید خود شیء هم بهروز شود، وگرنه شیء و سطر با هم اختلاف پیدا میکنند.
Student(...) فقط یک شیء در حافظه میسازد و هیچ چیزی در
پایگاه داده نمینویسد. تا وقتی save() را صدا نزنید،
چیزی ذخیره نشده است. همین جدایی را در SQLAlchemy هم میبینید، آنجا
با نام add() و commit().
۵. اولین مدل: Student
حالا ببینید یک مدل واقعی چقدر کم کد دارد. هر چه تا اینجا نوشتیم، اینجا رایگان به ارث میرسد.
'''The Student model: CRUD operations on the students table.'''
from .base import Model
class Student(Model):
'One row of the students table.'
table = 'students'
pk = 'student_id'
columns = ['first_name', 'last_name', 'email']
SCHEMA = '''
CREATE TABLE IF NOT EXISTS students (
student_id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
'''
def __init__(self,
email: str,
first_name: str = 'New',
last_name: str = 'Student') -> None:
# Creating the OBJECT does not touch the database; save() does.
self.student_id: int | None = None
self.email = email
self.first_name = first_name
self.last_name = last_name
def __str__(self) -> str:
return f'{self.first_name} {self.last_name} <{self.email}>'
def __repr__(self) -> str:
return f'Student({self.email!r})'
@property
def full_name(self) -> str:
'The two names together.'
return f'{self.first_name} {self.last_name}'
مدل دانشجو: نمونه 02_simple_orm/models/students.py
شرح مثال
- چهار صفت بالای کلاس، همه چیزی است که کلاس پایه برای ساختن SQL لازم دارد.
student_idدر ابتداNoneاست، چون هنوز سطری وجود ندارد. بعد ازsave()مقدار میگیرد.__str__و__repr__و@propertyهمان چیزهای فصل بیست و دوم و بیست و چهارم هستند. یک مدل هم بالاخره یک کلاس معمولی است و میتواند رفتار دلخواه داشته باشد.full_nameنمونه یک ویژگی است که در پایگاه داده ستونی ندارد و فقط محاسبه میشود. این یکی از سودهای بزرگ ORM است: منطق کنار داده مینشیند.
۶. مدل دوم تقریباً رایگان است
'''The Teacher model.
Notice how little code a new model needs: everything else is inherited.
'''
from .base import Model
class Teacher(Model):
'One row of the teachers table.'
table = 'teachers'
pk = 'teacher_id'
columns = ['first_name', 'last_name', 'department']
SCHEMA = '''
CREATE TABLE IF NOT EXISTS teachers (
teacher_id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
department TEXT DEFAULT 'General'
);
'''
def __init__(self,
first_name: str,
last_name: str,
department: str = 'General') -> None:
self.teacher_id: int | None = None
self.first_name = first_name
self.last_name = last_name
self.department = department
def __str__(self) -> str:
return f'{self.first_name} {self.last_name} ({self.department})'
مدل استاد: نمونه 02_simple_orm/models/teachers.py
این مدل حدود سی خط است و با همان سی خط، متدهای
all و get و save و
update و delete و
get_or_create را دارد. اگر بدون ORM مینوشتیم، باید همه
آنها را برای جدول دوم دوباره کپی میکردیم. این پاسخ تمرین چهارم این
فصل است.
'''The models package: one module per table, re-exported here.'''
from .base import Model
from .students import Student
from .teachers import Teacher
__all__ = ['Model', 'Student', 'Teacher']
بسته مدلها: models/__init__.py
ساختار بسته دقیقاً همان چیزی است که در فصل هفدهم یاد گرفتید: هر جدول یک
ماژول، و __init__.py همه را در یک جا در معرض دید میگذارد
تا بقیه برنامه فقط بنویسد from models import Student.
۷. برنامهای بدون یک خط SQL
'''A menu-driven program using our own tiny ORM.
Not one line of SQL in this file: everything goes through the models.
'''
from models import Student, Teacher
def students_section() -> None:
'Everything about the students.'
while True:
clear()
student_menu()
choice = input('Enter the number: ').strip()
if choice == '1':
students = Student.all()
if not students:
print('(no student yet)')
for student in students:
print(f'{student.student_id:<5}{student}')
pause()
elif choice == '3':
email = input('Enter the email: ').strip()
if Student.get(email=email) is not None:
print('That email is already used.')
pause()
continue
student = Student(email=email,
first_name=input('First name: ').strip() or 'New',
last_name=input('Last name: ').strip() or 'Student')
student.save()
print(f'Student added with the id {student.student_id}.')
pause()
elif choice == '4':
student = Student.get(email=input('Enter the email: ').strip())
if student is None:
print('No student with that email.')
else:
student.update(first_name=input('New first name: ').strip())
print('Updated:', student)
pause()
elif choice == '9':
return
برنامه منویی: نمونه 02_simple_orm/app.py
در کل این فایل حتی یک کلمه SELECT یا
INSERT پیدا نمیکنید. این همان هدف ORM است: لایه رابط
کاربر درباره پایگاه داده چیزی نمیداند. اگر فردا تصمیم بگیرید دادهها
را در PostgreSQL نگه دارید، فقط کلاس پایه عوض میشود و این فایل دست
نمیخورد.
پوشه S32-orm-and-sqlalchemy/02_simple_orm را باز کنید
و app.py را اجرا کنید. بعد سعی کنید یک مدل سوم به نام
Course اضافه کنید و ببینید چقدر کم کد لازم دارد.
۸. SQLAlchemy: نصب و موتور
ORM دستساز ما برای فهمیدن ایده عالی بود، ولی برای یک پروژه واقعی کم میآورد: پیوند، بارگذاری تنبل، مهاجرت ساختار جدولها و پشتیبانی از پایگاههای داده دیگر. ابزار استاندارد دنیای پایتون SQLAlchemy است.
pip install -r requirements.txt # requirements.txt SQLAlchemy==2.0.50
from sqlalchemy import create_engine, event
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from models import Base, Course, Enrollment, Student, Teacher
DATABASE_URL = 'sqlite:///database.db'
# The engine is the connection pool: ONE per application.
# echo=True prints every SQL statement - switch it on to see what the ORM does.
engine = create_engine(DATABASE_URL, echo=False, future=True)
@event.listens_for(engine, 'connect')
def set_sqlite_pragma(dbapi_connection, connection_record) -> None:
'''Turn the foreign keys on for every new connection.
Exactly the "PRAGMA foreign_keys = ON" of session 31, but done once here
instead of in every function.
'''
cursor = dbapi_connection.cursor()
cursor.execute('PRAGMA foreign_keys=ON')
cursor.close()
# A factory that produces sessions. A Session is one "conversation" with the
# database: it collects the changes and writes them at commit().
SessionLocal = sessionmaker(bind=engine, future=True)
موتور و کارخانه نشست: نمونه 03_sqlalchemy/app.py
شرح مثال
- موتور (engine) استخر اتصالهاست و در کل برنامه فقط یکی ساخته میشود. ساختن موتور در هر تابع، اشتباه رایج تازهکارهاست.
- نشانی پایگاه داده یک URL است. برای عوض کردن پایگاه داده معمولاً فقط همین رشته تغییر میکند، مثلاً
postgresql://user:pass@host/db. echo=Trueهر دستور SQL تولیدشده را چاپ میکند. برای یادگیری حتماً یک بار روشنش کنید تا ببینید ORM پشت پرده چه مینویسد.- همان
PRAGMA foreign_keysفصل قبل، این بار به شکل یک رویداد که در هر اتصال تازه خودکار اجرا میشود. بدون آن، حذف آبشاری در SQLite کار نمیکند. sessionmakerیک کارخانه است؛ هر بار که به پایگاه داده کار داریم، یک نشست تازه از آن میگیریم.
۹. تعریف مدلها
در SQLAlchemy نسخه ۲، مدلها را با راهنمای نوع تعریف میکنیم. کتابخانه
از روی Mapped[int] و Mapped[str] نوع ستون را
میفهمد. همان راهنمای نوعی که در فصل سیزدهم خواندید، اینجا کار واقعی
انجام میدهد.
from typing import List, Optional
from sqlalchemy import Float, ForeignKey, String
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
mapped_column,
relationship,
)
class Base(DeclarativeBase):
'Every model inherits from this; it collects the metadata of the tables.'
class Teacher(Base):
__tablename__ = 'teacher'
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
# One teacher -> many courses.
# back_populates links the two sides so that both stay in agreement.
courses: Mapped[List['Course']] = relationship(
back_populates='teacher',
cascade='all, delete-orphan', # deleting a teacher deletes the courses
passive_deletes=True, # let the DATABASE do the cascade
)
def __repr__(self) -> str:
return f'<Teacher(id={self.id}, name={self.name!r})>'
class Course(Base):
__tablename__ = 'course'
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
# The foreign key column itself ...
teacher_id: Mapped[int] = mapped_column(
ForeignKey('teacher.id', ondelete='CASCADE'), nullable=False
)
# ... and the Python side of the relation.
teacher: Mapped['Teacher'] = relationship(back_populates='courses')
enrollments: Mapped[List['Enrollment']] = relationship(
back_populates='course',
cascade='all, delete-orphan',
passive_deletes=True,
)
مدلها: نمونه 03_sqlalchemy/models.py
شرح مثال
BaseازDeclarativeBaseارث میبرد و فهرست همه جدولها را جمع میکند. دقیقاً همان نقشی که کلاسModelدر ORM دستساز داشت.__tablename__نام جدول است، مثل صفتtableما.mapped_columnجزئیات ستون را میگوید: کلید اصلی، طول، یکتا بودن و اجازه خالی ماندن.- دو چیز متفاوت کنار هم نشستهاند:
teacher_idیک ستون واقعی است، وteacherیک رابطه پایتونی که ستون نیست و در جدول وجود ندارد. back_populatesدو سر رابطه را به هم گره میزند. اگر درسی را به فهرستteacher.coursesاضافه کنید، خودبهخودcourse.teacherهم پر میشود.
جدول Enrollment نمونه مهمی است: رابطه چندبهچند بین دانشجو
و درس، بهعلاوه یک داده اضافه یعنی نمره. هر وقت رابطه چندبهچند شما
داده اضافه دارد، باید جدول جداگانهای با مدل خودش داشته باشد.
class Enrollment(Base):
'''The link between a student and a course.
It is a table of its own because it carries extra data: the grade.
'''
__tablename__ = 'enrollment'
id: Mapped[int] = mapped_column(primary_key=True)
grade: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
student_id: Mapped[int] = mapped_column(
ForeignKey('student.id', ondelete='CASCADE'), nullable=False
)
course_id: Mapped[int] = mapped_column(
ForeignKey('course.id', ondelete='CASCADE'), nullable=False
)
student: Mapped['Student'] = relationship(back_populates='enrollments')
course: Mapped['Course'] = relationship(back_populates='enrollments')
جدول واسط با داده اضافه
۱۰. نشست: درج داده
نشست (Session) یک گفتوگوی
کامل با پایگاه داده است: تغییرها را جمع میکند و در
commit() همه را با هم مینویسد. این همان تراکنش فصل قبل
است، فقط با چهره شیءگرا.
def example_1_create_tables() -> None:
'Example 1: create every table described by the models.'
Base.metadata.create_all(engine)
print('Tables created successfully.')
def example_2_insert_data() -> None:
'Example 2: insert teachers, courses and students.'
with SessionLocal() as session:
t1 = Teacher(name='Dr. Smith', email='smith@edu.com')
t2 = Teacher(name='Prof. Johnson', email='johnson@edu.com')
session.add_all([t1, t2])
# flush() sends the INSERTs so that the ids exist, but does NOT commit.
session.flush()
session.add_all([
Course(title='Mathematics 101', teacher_id=t1.id),
Course(title='Physics 101', teacher_id=t1.id),
Course(title='History 101', teacher_id=t2.id),
])
session.add_all([
Student(name='Alice', email='alice@student.edu'),
Student(name='Bob', email='bob@student.edu'),
Student(name='Charlie', email='charlie@student.edu'),
])
session.commit()
print('Data inserted and committed.')
ساخت جدولها و درج: نمونه 03_sqlalchemy/app.py
شرح مثال
create_allاز روی مدلها همه جدولها را میسازد. جدولی که از قبل باشد دست نمیخورد، پس اجرای دوباره بیخطر است.addوadd_allشیء را فقط به نشست میسپارند؛ هنوز هیچ چیزی ذخیره نشده.flush()دستورهایINSERTرا میفرستد تا شناسهها ساخته شوند، ولی تراکنش را نمیبندد. به همین دلیل بلافاصله بعد از آنt1.idمقدار دارد.commit()تراکنش را میبندد و همه چیز را ماندگار میکند.with SessionLocal() as sessionتضمین میکند نشست بسته شود، حتی اگر خطایی رخ دهد. این همان مدیر زمینه فصل بیست و پنجم است.
برخلاف sqlite3، بستن نشست به معنی commit
نیست. اگر commit() را فراموش کنید، هیچ چیزی نوشته
نمیشود و هیچ خطایی هم نمیگیرید؛ فقط دادهها غیبشان میزند.
۱۱. خواندن، تغییر و حذف
def example_3_read_data() -> None:
'Example 3: read every teacher and every student.'
with SessionLocal() as session:
for teacher in session.query(Teacher).all():
print(f' {teacher.name} ({teacher.email})')
def example_4_update_record() -> None:
'Example 4: change the email of a student.'
with SessionLocal() as session:
student = session.query(Student).filter_by(name='Alice').first()
if student is None:
print('Alice not found.')
return
old_email = student.email
# Simply assigning to the attribute is enough: the session notices it.
student.email = 'alice.new@student.edu'
session.commit()
print(f'Alice email updated from {old_email} to {student.email}')
def example_5_delete_course() -> None:
'Example 5: delete a course.'
with SessionLocal() as session:
course = session.query(Course).filter_by(title='History 101').first()
if course is None:
print('Course not found.')
return
session.delete(course)
session.commit()
خواندن، تغییر و حذف
شرح مثال
session.query(Student)یک پرسوجو میسازد وfilter_byشرط آن را تعیین میکند. مقایسه کنید باWHERE: همان است، فقط پایتونی.first()یک شیء یاNoneمیدهد وall()یک لیست. همان تله فصل قبل اینجا هم هست: حتماً نتیجهfirst()را باNoneبسنجید.- برای بهروزرسانی هیچ دستور
UPDATEی نمینویسید. فقط صفت را عوض میکنید وcommitمیزنید. نشست خودش میفهمد چه چیزی تغییر کرده و SQL را میسازد. session.delete(obj)جایDELETE FROMرا میگیرد.
۱۲. رابطهها و حذف آبشاری
اینجاست که ORM واقعاً برنده میشود. برای برداشتن نام دانشجو از روی یک ثبتنام، ضرورتی ندارد JOIN بنویسید: کافی است نقطه بگذارید.
def example_6_enroll_students() -> None:
'Example 6: create enrolments (many-to-many with an extra grade).'
with SessionLocal() as session:
alice = session.query(Student).filter_by(name='Alice').first()
bob = session.query(Student).filter_by(name='Bob').first()
math = session.query(Course).filter_by(title='Mathematics 101').first()
physics = session.query(Course).filter_by(title='Physics 101').first()
if not all([alice, bob, math, physics]):
print('Required records missing. Run example 2 first.')
return
# The objects are given directly: SQLAlchemy fills the ids in for us.
session.add_all([
Enrollment(student=alice, course=math, grade=85.5),
Enrollment(student=bob, course=math, grade=79.0),
Enrollment(student=alice, course=physics, grade=92.0),
])
session.commit()
def example_7_query_enrollments() -> None:
'Example 7: read the enrolments through the relations.'
with SessionLocal() as session:
for e in session.query(Enrollment).all():
# e.student and e.course are real objects: no JOIN to write.
print(f'{e.student.name} enrolled in {e.course.title} '
f'- Grade: {e.grade}')
# Navigating the other way round is just as easy.
alice = session.query(Student).filter_by(name='Alice').first()
if alice:
print(f'Alice follows {len(alice.enrollments)} course(s):',
[e.course.title for e in alice.enrollments])
رابطهها در عمل
Alice enrolled in Mathematics 101 - Grade: 85.5 Bob enrolled in Mathematics 101 - Grade: 79.0 Alice enrolled in Physics 101 - Grade: 92.0 Alice follows 2 course(s): ['Mathematics 101', 'Physics 101']
شرح مثال
- در
Enrollment(student=alice, course=math)خود شیءها را میدهیم، نه شناسهها را. SQLAlchemy شناسهها را در زمان نوشتن پر میکند. e.student.nameپشت پرده یکSELECTجداگانه میزند و آن را در حافظه نگه میدارد. به این میگویند بارگذاری تنبل.- رفتن در هر دو جهت ممکن است: از ثبتنام به دانشجو، و از دانشجو به فهرست ثبتنامهایش. این هدیه
back_populatesاست.
راحتی بارگذاری تنبل یک هزینه دارد. اگر روی صد ثبتنام حلقه بزنید و
هر بار e.student.name را بخوانید، ممکن است صد و یک
پرسوجوی جداگانه به پایگاه داده برود. نامش مسئله
N+1 است. راه حلش
joinedload یا selectinload است که یک
JOIN میزنند و همه چیز را یکجا میآورند. برای دیدنش،
یک بار echo=True را روشن کنید و تعداد پرسوجوها را
بشمارید.
حذف آبشاری هم بدون یک خط SQL انجام میشود. حذف یک دانشجو، ثبتنامهایش را هم میبرد:
def example_8_cascade_delete_student() -> None:
'Example 8: deleting a student also deletes their enrolments.'
with SessionLocal() as session:
bob = session.query(Student).filter_by(name='Bob').first()
if bob is None:
print('Bob not found.')
return
bob_id = bob.id
before = session.query(Enrollment).filter_by(student_id=bob_id).count()
print(f'Bob has {before} enrollment(s).')
session.delete(bob)
session.commit()
after = session.query(Enrollment).filter_by(student_id=bob_id).count()
print(f"After deletion, Bob's enrollments: {after}")
حذف آبشاری: نمونه 03_sqlalchemy/app.py
Bob has 1 enrollment(s). After deletion, Bob's enrollments: 0
| تنظیم | کجا نوشته میشود | وظیفه آن |
|---|---|---|
ondelete='CASCADE' | روی ForeignKey | قانون واقعی در خود پایگاه داده |
cascade='all, delete-orphan' | روی relationship | قانون در سمت پایتون و نشست |
passive_deletes=True | روی relationship | کار را به پایگاه داده بسپار، سطرها را بیخود نخوان |
PRAGMA foreign_keys=ON | در رویداد اتصال | بدون آن، SQLite اصلاً آبشار نمیکند |
۱۳. تراکنش و IntegrityError
def example_10_transaction_rollback() -> None:
'Example 10: a failed transaction is rolled back.'
with SessionLocal() as session:
try:
# The email already belongs to Alice, and the column is UNIQUE.
session.add(Student(name='Duplicate',
email='alice.new@student.edu'))
session.commit()
except IntegrityError:
session.rollback()
print('IntegrityError caught! Transaction rolled back.')
duplicate = session.query(Student).filter_by(name='Duplicate').first()
print(f"Duplicate student in DB? {'Yes' if duplicate else 'No'}")
برگرداندن تراکنش: نمونه 03_sqlalchemy/app.py
IntegrityError caught! Transaction rolled back. Duplicate student in DB? No
شرح مثال
- ساختار همان ساختار فصل قبل است:
tryباcommit، وexceptباrollback. IntegrityErrorیعنی محدودیتی مثلUNIQUEیاNOT NULLنقض شده است. این یک اتفاق عادی است، نه باگ، و باید مدیریت شود.- نکتهای که حتماً باید بدانید: بعد از یک خطای
commit، نشست در وضعیت خراب است و تا وقتیrollback()نزنید، هر پرسوجوی بعدی هم خطا ایجاد میکند.
۱۴. کی ORM، کی SQL خام
| موقعیت | انتخاب بهتر |
|---|---|
| کارهای روزمره: درج، ویرایش، حذف، نمایش فهرست | ORM |
| برنامهای که ممکن است پایگاه دادهاش عوض شود | ORM |
| پروژهای که چند نفر رویش کار میکنند | ORM |
| گزارش تحلیلی سنگین با چند پیوند و زیرپرسوجو | SQL خام |
| پرسوجویی که باید تا آخرین ذره بهینه باشد | SQL خام |
| یک اسکریپت دهخطی یکبارمصرف | SQL خام |
در عمل بیشتر پروژهها هر دو را به کار میبرند و این کاملاً طبیعی است.
SQLAlchemy اجازه میدهد هر وقت لازم شد، با text() یک
پرسوجوی خام را در همان نشست اجرا کنید.
در فصل سی و هشتم همین مدلها را زیر Flask میبینید و در فصل سی و نهم، پروژه پایانی روی SQLAlchemy ساخته میشود. هر چه اینجا خوب یاد بگیرید، دو فصل آینده را سادهتر میکند.
پوشه S32-orm-and-sqlalchemy شامل سه بخش است:
01_why_orm برای مقایسه،
02_simple_orm برای ORM دستساز، و
03_sqlalchemy برای کتابخانه واقعی. بخش سوم نصب لازم
دارد: pip install -r requirements.txt. اگر خواستید از
صفر شروع کنید، فایل database.db را پاک کنید.
خلاصه فصل
| ابزار | وظیفه آن |
|---|---|
| ORM | جدول به کلاس، سطر به شیء، ستون به صفت |
create_engine(url) | موتور، یکی برای کل برنامه |
echo=True | نمایش SQL تولیدشده، برای یادگیری و اشکالزدایی |
DeclarativeBase | کلاس پایه همه مدلها |
__tablename__ | نام جدول |
mapped_column(...) | تعریف یک ستون |
ForeignKey('t.id') | کلید خارجی |
relationship(back_populates=...) | رابطه دوطرفه بین دو مدل |
Base.metadata.create_all(engine) | ساخت همه جدولها |
sessionmaker(bind=engine) | کارخانه نشست |
session.add و add_all | سپردن شیء به نشست |
session.flush() | گرفتن شناسهها بدون بستن تراکنش |
session.commit() | نوشتن همه تغییرها |
session.rollback() | برگرداندن تراکنش شکستخورده |
query(M).filter_by(...) | پرسوجو با شرط |
first() و all() و count() | گرفتن نتیجه |
session.delete(obj) | حذف یک رکورد |
cascade='all, delete-orphan' | حذف آبشاری فرزندان |
IntegrityError | نقض محدودیت. باید گرفته شود |
تمرینهای فصل سی و دو
- به ORM دستساز یک مدل
Courseبا ستونهایtitleوcreditوteacher_idاضافه کنید. - متد
Student.enroll(course)را بنویسید که یک سطر در جدولenrollmentsبسازد. - متد کلاسی
Student.search(text)را بنویسید که باLIKEو پارامتر?جستوجو کند. - توضیح دهید وقتی مدل چهارم را اضافه میکنید، کلاس پایه
Modelدقیقاً چند متد را برایتان ذخیره کرده است. - در بخش SQLAlchemy یک مدل
Departmentبسازید وTeacherرا با رابطه یکبهچند به آن وصل کنید. - پرسوجویی بنویسید که همه درسهای یک استاد مشخص را برگرداند.
- پرسوجویی بنویسید که میانگین نمره هر درس را بدهد.
- روی موتور
echo=Trueبگذارید و SQL تولیدشده برای تمرین هفتم را بخوانید و توضیح دهید. - یک استاد را حذف کنید و بگویید بر سر درسهایش چه میآید و چرا.
- روی
Enrollmentیک محدودیت یکتا روی زوج(student_id, course_id)بگذارید وIntegrityErrorثبتنام تکراری را بگیرید.