Доброго дня.
Сегодня хочу рассказать про ORM SQLAlchemy. Поговорим о том, что это, про его возможности и гибкость, а также рассмотрим случаи, которые не всегда понятно описаны.
Данная ORM имеет порог вхождения выше среднего, поэтому я попытаюсь объяснить всё простым языком и с примерами. Статья будет полезна тем, кто уже работает с sqlalchemy и хочет прокачать свои навыки или только знакомится с этой библиотекой.
Используемый язык программирования — python 3.6.
БД — PostgreSQL.
Ссылка на github
Итак, что такое ORM?
ORM (Object-Relational Mapping) — это технология, которая позволяет сопоставлять модели, типы которых несовместимы. Например: таблица базы данных и объект языка программирования.
Иными словами, можно обращаться к объектам классов для управления данными в таблицах БД. Также можно создавать, изменять, удалять, фильтровать и, самое главное, наследовать объекты классов, сопоставленные с таблицами БД, что существенно сокращает наполнение кодовой базы.
Чтобы использовать возможности SQLAlchemy, необходимо понять принцип его работы.
Разработчикам, которые используют Django-ORM, придется немного перестроить образ мышления для создания ORM запросов. На мой взгляд, SQLAlchemy — функциональный монстр, возможностями которого можно и нужно пользоваться, но нужно понимать, что ORM не всегда идеальны. Поэтому обсудим моменты, когда использование этой технологии целесообразно.
В SQLAlchemy есть понятие декларативных и недекларативных определений моделей.
Недекларативные определения подразумевают использования mapper(), описывающего сопоставление каждой колонки БД и классом модели.
В данной статье используется декларативное определение моделей.
Подробнее здесь
Структура БД
Для полной консистентности данных давайте создадим следующие таблицы.
Базовая модель служит для определения базовых колонок в БД.
class BaseModel(Base):
__abstract__ = True
id = Column(Integer, nullable=False, unique=True, primary_key=True, autoincrement=True)
created_at = Column(TIMESTAMP, nullable=False)
updated_at = Column(TIMESTAMP, nullable=False)
def __repr__(self):
return "<{0.__class__.__name__}(id={0.id!r})>".format(self)
Employee — таблица, описывающая работника, который работает в офисе
class Employee(BaseModel):
__tablename__ = 'employees'
first_name = Column(VARCHAR(255), nullable=False)
last_name = Column(VARCHAR(255), nullable=False)
phone = Column(VARCHAR(255), unique=True, nullable=True)
description = Column(VARCHAR(255), nullable=True)
EmployeeWithSkills — не таблица. Класс наследуемый от Employee. Отличная возможность разделить логику и использовать класс, будто это отдельная таблица.
class EmployeeWithSkills(Employee):
skills = relation(Skill, secondary=EmployeesSkills.__tablename__, lazy='joined')
Department — отдел, в котором работает этот сотрудник. Человек может состоять в нескольких отделах.
class Department(BaseModel):
__tablename__ = 'departments'
name = Column(VARCHAR(255), nullable=False)
description = Column(VARCHAR(255), nullable=False)
Таблица соответствий работника и подразделений, в которых он состоит.
class EmployeeDepartments(BaseModel):
__tablename__ = 'employee_departments'
employee_id = Column(Integer, ForeignKey('employees.id', ondelete='CASCADE'), nullable=False, index=True)
department_id = Column(Integer, ForeignKey('departments.id', ondelete='CASCADE'), nullable=False, index=True)
Таблица соответствий сотрудников и их умений.
class EmployeesSkills(BaseModel):
__tablename__ = 'employees_skills'
employee_id = Column(ForeignKey('employee.id', ondelete='CASCADE'), nullable=False, index=True)
skill_id = Column(ForeignKey('skills.id', ondelete='CASCADE'), nullable=False, index=True)
Создаем миграции с помощью пакета alembic, позволяющего генерировать их автоматически. В рамках данного урока автогенерация миграций вполне допустима.
В последней миграции присутствуют тестовые данные, которые наполнят базу.
Как настроить alembic можно почитать здесь
Выполняем заветные alembic upgrade head, чтобы выполнить миграцию.
Запросы и relations
Давайте сделаем первый запрос и получим информацию о сотруднике по его id.
Запрос будет выглядеть так:
lesson1:
employee = session.query(Employee).filter(Employee.id == eid).one()
output:
ID: 2, Tony Stark
.one()
в конце обозначает, что мы намерены получить только одну запись. Если записей будет несколько, возникнет соответствующее исключение.
Если мы захотим получить все имеющиеся отделы, то можно воспользоваться следующим запросом c использованием .all()
lesson2:
emmployee = session.query(Department).all()
output:
ID: 2, name: Guards
ID: 4, name: Legions
Рассмотрим работу с функциями агрегации.
Мы можем получить количество имеющихся департаментов с помощью встроенной функции
.count()
или использовать func.count()
. С помощью второго метода можно обращаться к любым функциям SQL, используя для select
или для вычисления промежуточных результатов.
lesson3:
def get_departments_count(session: DBSession) -> int:
count = session.query(Department).count()
return count
def get_departments_func_count(session: DBSession) -> int:
count = session.query(func.count(Department.id)).scalar()
return count
Многие разработчики используют функцию count()
для проверки наличия данных в запросе. Это не очень хорошая практика, порождающая использование дополнительных ресурсов БД и увеличение времени выполнения запроса. Хорошим решением будет использование функции exists()
, возвращающей скалярное значение:
lesson3:
def check_department_exists(session: DBSession, department_name: str) -> bool:
is_exists = session.query(exists().where(Department.name == department_name)).scalar()
return is_exists
Двигаясь дальше, усложним задачу и познакомимся с сущностью relation
или relationship
. Дело в том, что в SQLAlchemy
кроме использования foreign_key
на уровне базы данных, используются еще и отношения между объектами.
Таким образом мы можем получить зависимую по foreign key строку БД в объекте.
Эти объекты являются проекцией на таблицы БД, связанные между собой.
Relations
в SQLAlchemy
имеют гибкую настройку, позволяя получать данные из БД разными способами в разное время с помощью именованного аргумента lazy
.
Основные степени "ленивости":
select
— по умолчанию. ORM делает запрос только тогда, когда обращаются к данным. Осуществляется отдельным запросом.dynamic
— позволяет получить объект запроса, который можно модифицировать по желанию. Получает данные из БД только после вызова all() или one() или любых других доступных методов.joined
— в основной запрос добавляется с помощью LEFT JOIN. Выполняется сразу.subquery
— похож на select, но выполняется как подзапрос.
По умолчанию — select
.
Фильтрация в запросах может быть статической и динамической. Динамическая фильтрация позволяет наполнить запрос фильтрами, которые могут изменяться в зависимости от хода выполнения функции.
lesson4:
def dynamic_filter(session: DBSession, filter: DFilter = None):
query = session.query(Employee)
if filter is not None:
query = query.filter(*filter.conds)
employees = query.all()
return employees
В классе фильтра DFilter указаны фильтры на основе каких-либо входных данных. Если класс фильтра определён, но далее в запросе применяются условия.
Функция .filter() принимает принимает бинарные условия SQLAlchemy, поэтому может быть представлена с помощью *
Применение динамических фильтров ограничивается только фантазией. Результат выполнения запроса показывает, какие герои сейчас неактивны.
output:
Inactive_heros:
Name: Tony Stark
Name: Scott Lang
Name: Peter Parker
Предлагаю поработать с отношением many-to-many.
Мы имеем таблицу Employee, в которой присутствует relation к таблице соответствий EmployeesSkills. Она содержит foreign_key на таблицу сотрудников и foreign_key
на таблицу умений.
lesson 5:
def get_employee_with_skills(session: DBSession, eid: int):
employee = session.query(EmployeeWithSkills).filter(EmployeeWithSkills.id == eid).one()
return employee
output:
Employee Tony Stark has skills:
Skill: Fly, Desc: I belive I can Fly. I belive I can touch the sky
Skill: Light Shield, Desc: Light protect. Perfect for everything
Используя класс EmployeeWithSkills в запросе выше, мы обращаемся к нему, как к таблице БД, но на самом деле такой таблицы не существует. Это класс отличается от Employee наличие relation, которое имеет отношение many-to-many. Так мы можем разграничивать логику работы классов, наполняя разным набором relations. В результате запроса мы увидим умения одного из сотрудников.
Так как сотрудник может состоять в нескольких подразделениях, создадим relation, позволяющий получить эту информацию.
Создадим класс EmployeeWithDepartments, наследуемый от Employee и добавим следующее:
class EmployeeWithDepartments(Employee):
departments = relation(
Department,
# primaryjoin=EmployeeDepartments.employee_id == Employee.id,
secondary=EmployeeDepartments.__tablename__,
# secondaryjoin=EmployeeDepartments.department_id == Department.id,
)
Созданный класс не является новой таблицей БД. Это все та же таблица Employee, только расширенная c помощью relation
. Таким образом, вы можете обращаться к таблице Employee
или EmployeeWithDepartments
в запросах. Разница будет лишь в отсутствии/наличии relation
.
Первый аргумент указывает к какой таблице мы будем создавать relation
.
primaryjoin
— это условие, по которому будет подключаться вторая таблица до её присоединения к объекту.
secondary
— имя таблицы, содержащее foreign_keys для сопоставления. Используется в случае many-to-many.
secondaryjoin
— условия сопоставления промежуточной таблицы с последней.
primaryjoin
и secondaryjoin
служат для явного указания соответствий в сложных ситуациях.
Порой возникают ситуации, когда необходимо создавать фильтры, поля которых объявлены в отношениях, а отношения в свою очередь являются отношениями исходного класса.
EmployeeWithCadreMovements -> relation(CadreMovement) -> field
Если отношение отображает список значений, то нужно использовать .any(), если значение предусмотрено только одно, то необходимо использовать .has()
Для лучшего понимания, данная конструкция будет интерпретирована на SQL языка в конструкцию exists().
Вызовем функцию получения с указанием параметра причины reason
, например, simple
.
lesson6
def has_in_relations(session: DBSession, reason: str):
employees = session.query(EmployeeWithCadreMovements).filter(EmployeeWithCadreMovements.cadre_movements.any(CadreMovement.reason == reason)).all()
return employees
output:
[Steve Rogers, Tony Stark]
lession7
Рассмотрим возможность получения relation с помощью функции агрегации. Например, получим последнее кадровое движение определенного пользователя.
primaryjoin является условием присоединения таблиц (в случае использования lazy='joined'). Напомним, что по умолчанию используется select.
В этом случае, формируется отдельный запрос при обращении к атрибуту класса. Именно для этого запроса мы и можем указать условия фильтрации.
Как известно, нельзя использовать функции агрегации в "чистом" виде в WHERE условии, поэтому мы можем реализовать данную возможность, указав relation
со следующими параметрами:
last_cadre_movement = relation(
CadreMovement,
primaryjoin=and_(
CadreMovement.employee == Employee.id,
uselist=False,
CadreMovement.id == select([func.max(CadreMovement.id)]).where(CadreMovement.employee == Employee.id)
)
)
При выполнении запрос скомпилируется так:
SELECT
cadre_movements.id AS cadre_movements_id,
cadre_movements.created_at AS cadre_movements_created_at,
cadre_movements.updated_at AS cadre_movements_updated_at,
cadre_movements.employee AS cadre_movements_employee,
cadre_movements.old_department AS cadre_movements_old_department,
cadre_movements.new_department AS cadre_movements_new_department,
cadre_movements.reason AS cadre_movements_reason
FROM cadre_movements
WHERE cadre_movements.employee = %(param_1)s
AND cadre_movements.id = (
SELECT max(cadre_movements.id) AS max_1
FROM cadre_movements
WHERE cadre_movements.employee = %(param_1)s
)
Итог
SQLAlchemy является мощнейшим инструментом в построении запросов, который уменьшает время разработки, поддерживая наследование.
Но стоит соблюдать тонкую грань между использованием ORM и написанием сложных запросов. В некоторых случаях ORM может запутать разработчика или сделать код громоздким и нечитаемым.
Удачи!
Комментарии (8)
REDkiy
08.10.2019 07:22Тяжеловатая статья. Тем кто собирается изучить SQLAlchemy предлагаю начать с Мега-учебника Гринберга и после сразу переключиться на официальную документацию.
По моему мнению декларативное описание несколько чудное и отношения раскрыты недостаточно (да я видел слово «основы» в названии).
Непонятно почему используется VARCHAR, а не String.
PaiNt-git
08.10.2019 07:22Статическая типизация по дефолту и эти сахарные-этажи в формат-синтаксисе это фишечки питона 3.6+
Неплохая статья но мне кажется не выдержан темп, для какого-то «промо-обучения» все скомкано все и сумбурно и текст оформлен некрасиво, а если «для тех кто уже работает с алхимией» — то обозначенные кейсы обычные, в документации все описано (за исключением конечно "<{0.__class__.__name__}(id={0.id!r})>" :), но это питон 3.6)
в уроке 7 насколько я разглядел опечатка с «uselist=False» он внутри скобки с and_(). И вообще, может я конечно чего-то не понял — но урок 7 смотрится как-то вообще оторванным от контекста — во первых из только текста этого урока непонятно к какой родительской модельке относится этот релатион, и определение моделек CadreMovement, EmployeeWithCadreMovements и т.п. выше не описано
Еще круто бы в статье описать действительно сложные случаи в алхимии как например использование CTE и рекурсивных запросов или про правильное понимание .correlate() и когда и как его нужно использовать. Сам последнее время ковыряюсь с различными JSON-функциями postgres типа jsonb_array_elements чтоб их впилить в проект на алхимии, в этой области тоже довольно много интересного при составлении запросов :).
И вообще с итогом не соглашусь всетаки, случаи когда алхимический запрос именно будет «нечитаемым» перед аналогичным написанием в программе питоне в сыром виде — редки. Да он может «по строчкам» оказаться больше, однако читать его будет гораздо легче, потому что первостепенно — гораздо проще его можно разложить на смысловые блоки. Алхимия тем и хороша что довольно гибка и огромное количество фишек которые предоставляет база — можно воплотить с помощью нее.
qgod
08.10.2019 07:22Вот у меня один вопрос — в этом мире кроме TypeOrm и EntityFramework есть хоть одна orm которая может генерить миграции по изменениям в коде моделей?
Andy_U
Один я не знал, что так можно?
onegreyonewhite
Судя по всему да.
Вообще можно даже значение словаря получить по ключу (пишите так же квадратные скобки, но значение ключа без кавычек) или значение массива по индексу.
Andy_U
Я, честно говоря, не очень внимательно документацию читал, но не помню там описания такого трюка. Ну и вообще, это какая-то дверь в ад, типа eval и exec:
onegreyonewhite
Как по мне, дверью в ад являются f-strings (Python>=3.6), которые упомянули ниже.
Там напрямую функции легко можно вызвать.
Поэтому форматирование нужно использовать обдуманно и не форматировать строки извне, а только те, которым вы доверяете (т.е. находящимся внутри кода).
Tihon_V
Как по мне — f-strings тут к месту