Отношения с SQLAlchemy
Одна из лучших вещей в использовании ORM — это использование отношений, которые они обеспечивают. SQLAlchemy имеет много возможностей для значительного облегчения жизни вашей базы данных. Основные моменты, на которые будет направлена эта статья:
- Что такое отношения?
- Автоматическое отслеживание базы данных: внешние ключи и наследование сеансов
- Запросы и нетерпеливая загрузка
- Локальный кеш результирующего набора
Начиная
Эта статья будет основываться на инициализации и настройке моделей в разделе Начало работы с SQLAlchemy. Если вы новичок в SQLAlchemy, я рекомендую ознакомиться с этой статьей, прежде чем продолжить. Определенная базовая структура таблицы состоит из Student
s, связанных с Activitys
через таблицу Enrollment
:
Student(id, name) Activity(id, name) Enrollment(id_student, id_activity, start_date, end_date)
Что такое отношения?
Отношение, по сути, позволяет вам перемещаться по отношениям внешнего ключа через атрибуты вашей модели. Отношения можно использовать для упрощения управления сеансами, упрощения запросов и условий соединения, ускоренной загрузки и, что наиболее важно, для простой навигации по внешнему ключу. Пока внешние ключи и отношения правильно определены, это просто вопрос связывания экземпляров моделей вместе через их отношения. Простое действие:
Student( name='Test Student', enrollments=[ Enrollment( activity=Activity(name='Test Activity'), start_date=date.today(), ) ] )
создаст 3 записи в 3 таблицах и будет управлять первичными ключами для вас.
Пример отслеживания данных и базы данных
Для приведенных выше моделей необходимы некоторые образцы данных. Отличное время для демонстрации настройки и поддержания состояния базы данных!
from datetime import date, timedelta import random students = [ Student(name=name) for name in ['Abby', 'Bob', 'Chris', 'Darlene', 'Edgar', 'Francine', 'Ginny'] ] activities = [ Activity(name=name) for name in ['Kick Ball', 'Baseball', 'Dancing', 'Art', 'Programming'] ] today = date.today() for student in students: potential_activities = random.sample( activities, k=random.randint(1, len(activities)) ) for activity in potential_activities: if random.randint(0, 1): end_date = today - timedelta(days=random.randint(1, 5)) else: end_date = None Enrollment( student=student, activity=activity, start_date=today - timedelta(days=random.randint(6, 9)), end_date=end_date ) print(f"Welcome {student.name} to {activity.name}!")
На данный момент сеанс не знает данных, и идентификаторы не были назначены:
student = students[0] activity = student.enrollments[0] print(student in session, activity in session, student.id, activity.id_student) > False False None None
Добавляя студентов в сеанс, SQLAlchemy неявно прокручивает все объекты, по которым можно перемещаться, через отношения и также добавляет их в сеанс. Однако присутствие в сеансе не означает, что база данных знает о них, поэтому идентификаторы по-прежнему равны None:
session.add_all(students) print(student in session, activity in session, student.id, activity.id_student) > True True None None
Затем явный сброс отправит данные в базу данных, а затем отправит обратно и назначит все идентификаторы:
session.flush() print(student in session, activity in session, student.id, activity.id_student) > True True 1 1
Что действительно круто. Отслеживание объектов в сеансе и идентификаторов — утомительное занятие, о котором я рад не беспокоиться!
Запросы и жадная загрузка
На данный момент все данные находятся в нашем локальном кеше. Для демонстрации мы будем очищать кеш сеанса для демонстрационных запросов с помощью session.expire_all().
В этой статье основное внимание будет уделено 4 отдельным способам запроса взаимосвязей: lazy
(по умолчанию), subqueryload
,joinedload
,и contains_eager
.
Ленивый
Ленивая загрузка используется по умолчанию и часто этого достаточно. Запрос вернет именно то, что вы запрашиваете, и доступ к любой связи сначала проверит кэш идентификаторов сеанса на наличие объекта. Если его там нет, будет выдан запрос для заполнения отношения только для этого объекта.
session.expire_all() query = session.query(Student) for student in query: student.enrollments[0].activity
Производит 11 запросов в моем случайном наборе — начальный запрос, по одному на каждого учащегося и один раз на действие.
SELECT student.id AS student_id, student.name AS student_name FROM student SELECT enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date FROM enrollment WHERE 1 = enrollment.id_student SELECT activity.id AS activity_id, activity.name AS activity_name FROM activity WHERE activity.id = 1 SELECT enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date FROM enrollment WHERE 2 = enrollment.id_student SELECT enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date FROM enrollment WHERE 3 = enrollment.id_student SELECT enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date FROM enrollment WHERE 4 = enrollment.id_student SELECT activity.id AS activity_id, activity.name AS activity_name FROM activity WHERE activity.id = 2 SELECT enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date FROM enrollment WHERE 5 = enrollment.id_student SELECT enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date FROM enrollment WHERE 6 = enrollment.id_student SELECT activity.id AS activity_id, activity.name AS activity_name FROM activity WHERE activity.id = 4 SELECT enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date FROM enrollment WHERE 7 = enrollment.id_student
объединенная нагрузка
joinedload
добавит к запросу дополнительное соединение, предназначенное для отношения, чтобы заполнить отношение результатами.
from sqlalchemy.orm import joinedload session.expire_all() query = session.query(Student) \ .options( joinedload(Student.enrollments) .joinedload(Enrollment.activity) ) for student in query: student.enrollments[0].activity
Когда это возможно, я всегда использую точный атрибут отношения из родительской модели. Явность всегда побеждает неявность, и гораздо проще понять, откуда берутся ваши данные и куда они поступают.
Создает один запрос для получения студента, его зачислений и всех действий:
SELECT student.id AS student_id, student.name AS student_name, activity_1.id AS activityStudent
id, activity_1.name AS activityStudent
name, enrollment_1.id_student AS enrollmentStudent
id_student, enrollment_1.id_class AS enrollmentStudent
id_class, enrollment_1.start_date AS enrollmentStudent
start_date, enrollment_1.end_date AS enrollmentStudent
end_date FROM student LEFT OUTER JOIN enrollment AS enrollment_1 ON student.id = enrollment_1.id_student LEFT OUTER JOIN activity AS activity_1 ON activity_1.id = enrollment_1.id_class
подзапрос
subqueryload
загрузит отношение, используя отдельный запрос, а не соединение.
from sqlalchemy.orm import subqueryload session.expire_all() query = session.query(Student) \ .options( subqueryload(Student.enrollments) .subqueryload(Enrollment.activity) ) for student in query: student.enrollments[0].activity
Выдает 3 запроса. Исходный запрос и один запрос на подзапрос:
SELECT student.id AS student_id, student.name AS student_name FROM student SELECT enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date, anon_1.student_id AS anonStudent
student_id FROM (SELECT student.id AS student_id FROM student) AS anon_1 JOIN enrollment ON anon_1.student_id = enrollment.id_student ORDER BY anon_1.student_id SELECT activity.id AS activity_id, activity.name AS activity_name, enrollment_1.id_class AS enrollmentStudent
id_class FROM (SELECT student.id AS student_id FROM student) AS anon_1 JOIN enrollment AS enrollment_1 ON anon_1.student_id = enrollment_1.id_student JOIN activity ON activity.id = enrollment_1.id_class ORDER BY enrollment_1.id_class
contains_eager
contains_eager
будет использовать результаты явного соединения в запросе для заполнения связи. Для этого есть два отличных варианта использования:
- Отношения уже являются частью запроса в соединении — так что продолжайте и заполните его.
- Вы хотите, чтобы отфильтрованное подмножество данных было заполнено отношением. Поскольку связь заполняется тем, что возвращается в запросе, если запрос отфильтровывает результаты связи, связь будет знать только то, что возвращается в запросе.
from sqlalchemy.orm import contains_eager session.expire_all() query = session.query(Student) \ .join(Student.enrollments) \ .join(Enrollment.activity) \ .options( contains_eager(Student.enrollments) .contains_eager(Enrollment.activity) ) for student in query: student.enrollments[0].activity
Что создает простейший сгенерированный запрос:
SELECT student.id AS student_id, student.name AS student_name, activity.id AS activity_id, activity.name AS activity_name, enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date FROM student JOIN enrollment ON student.id = enrollment.id_student JOIN activity ON activity.id = enrollment.id_class
Когда и почему вас это волнует?
Что хорошо во всех этих стратегиях, так это то, что если у SQLAlchemy нет необходимых данных, он запросит их. Во всех 4 примерах цикл for и доступ к активности первого зачисления для каждого учащегося абсолютно одинаковы. Это позволяет чрезвычайно легко настраивать и оптимизировать ваши стратегии загрузки. В духе «Предварительная оптимизация — корень всех зол», я предпочитаю оставлять вещи на ленивой загрузке, если только они не окажутся проблемой, или если я заранее не знаю, что это будет проблемой.
joinedload, subqueryload и contains_eager
Оба они достигают основной цели: сокращают количество запросов. joinedload
может показаться очевидным выбором. Он работает полностью в рамках одного запроса! Однако по мере того, как ваш запрос становится все более сложным, легко быстро превратиться в огромные наборы результатов. Каждое объединение, которое вы добавляете к своему запросу, означает, что каждая строка должна представлять каждую комбинацию данных во всех сопоставленных таблицах. С другой стороны, хотя subqueryload
может выдать дополнительный запрос, у него не будет такого же потенциала для возврата тех же огромных наборов данных. Как правило, с большими запросами рассмотрите возможность использования joinedload
с отношениями один к одному и subqueryload
с отношениями один ко многим. Используйте contains_eager
, если вы уже присоединяетесь к связи в запросе или если вам нужно загрузить связь с отфильтрованным набором результатов.
Смешивать и сочетать!
В примерах вы, возможно, заметили, что параметры загрузки связаны друг с другом, чтобы загрузить последующие отношения. Для каждого из них вы можете использовать любую из стратегий загрузки. Я часто subqueryload
первые отношения и joinedload
остальные отношения:
from sqlalchemy.orm import subqueryload, joinedload session.expire_all() query = session.query(Student) \ .options( subqueryload(Student.enrollments) .joinedload(Enrollment.activity) ) for student in query: student.enrollments[0].activity
Производит:
SELECT student.id AS student_id, student.name AS student_name FROM student SELECT enrollment.id_student AS enrollment_id_student, enrollment.id_class AS enrollment_id_class, enrollment.start_date AS enrollment_start_date, enrollment.end_date AS enrollment_end_date, anon_1.student_id AS anonStudent
student_id, activity_1.id AS activityStudent
id, activity_1.name AS activityStudent
name FROM (SELECT student.id AS student_id FROM student) AS anon_1 JOIN enrollment ON anon_1.student_id = enrollment.id_student LEFT OUTER JOIN activity AS activity_1 ON activity_1.id = enrollment.id_class ORDER BY anon_1.student_id
Использование локального кеша
Поскольку отношения в основном загружаются один раз, а затем повторно используются, они являются идеальными кандидатами для расширения с помощью свойств, чтобы обеспечить лучший доступ. Может показаться заманчивым определить два отдельных отношения, использующих secondary
для доступа к текущим и прошлым действиям:
class Student(Base): ... current_activities = relationship( lambda: Activity, primaryjoin=lambda: and_( Student.id == Enrollment.id_student, Enrollment.end_date == None ), secondary=lambda: Enrollment.__table__, backref='current_students' ) past_activities = relationship( lambda: Activity, primaryjoin=lambda: and_( Student.id == Enrollment.id_student, Enrollment.end_date != None ), secondary=lambda: Enrollment.__table__, backref='past_students' )
Однако это имеет несколько проблем. Это усложняет любую нетерпеливую стратегию загрузки. Его сложнее поддерживать. Для загрузки похожих наборов данных требуется несколько запросов. И не очевидно, как использовать отношения при добавлении новых данных.
С другой стороны, использование @property
s или даже @hybrid_property
s позволяет использовать одно отношение, а затем создавать разные представления этих данных. Вот current_activities
и past_activities
как свойства:
class Student(Base): ... @property def current_activities(self): for enrollment in self.enrollments: if not enrollment.end_date: yield enrollment.activity @property def past_activities(self): for enrollment in self.enrollments: if enrollment.end_date: yield enrollment.activity
Это обеспечивает большую гибкость и делает очень очевидным, как вы будете выполнять любую нетерпеливую загрузку или добавлять новые записи — просто используйте основные отношения!
Как и во всем в SQLAlchemy, это лишь малая часть того, что возможно. Не упоминаются самореферентные отношения, множественные внешние ключи и отношения к одной и той же таблице или управление сиротами через каскад.
Как только вы сядете в поезд отношений, он доставит вас туда, куда вам нужно. Всегда держите руки и ноги внутри автомобиля, не беспокойтесь о первичных ключах и смотрите вперед!