Доброго времени суток, товарищи, эта статья, так скажем, продолжение предыдущей статьи об SQLAlchemy 2.0 для новичков, в этой статье мы узнаем что такое Python Generic и как его можно использовать в наших целях при взаимодействии с БД.
Первоначальная настройка
Для того чтобы всё правильно работало, необходимо установить MyPy. Это расширение включает более глубокую проверку типов в вашей IDE, для VS Code есть такое расширение от майкрософт.
Установка MyPy в VS Code
Находим расширение, устанавливаем и заходим в параметры VS Code. Там есть следующий пункт: "Mypy-type-checker: Args". Жмём кнопку добавить элемент и вписываем: "mypy-type-checker.args" = ["--enable-incomplete-feature=NewGenericSyntax"]
. Это позволяет использовать новый синтаксис объявления дженериков, который был добавлен в Python 3.12.x, обращаю внимание, именно эту версию мы и будем использовать.
Эта же строчка будет информативна при настройке MyPy в других IDE. На этом подготовка заканчивается
Что такое Generic?
Generic, или дженерики - это обобщённый тип данных, для создания обобщённых функций и классов, которые могут работать с любыми типами данных, или только с теми, которые мы укажем, при создании обобщённого типа данных.
Звучит непонятно, поэтому разберём на примере.
Создание обобщённой функции
Создадим обобщённую функцию:
from typing import Type
def foo[T](data: T) -> Type[T]:
return type(data) # возвращаем тип аргумента
print(foo(5),
foo("hello"),
foo(4.56)
)
Вывод в терминале:
<class 'int'> <class 'str'> <class 'float'>
[T]
- создаёт наш обобщённый тип данных T
, который в будущем, при вызове функции станет любым типом данных, объект которого вы передадите в аргументе.
А теперь попробуйте заменить [T]
на [T: (int, float)]
и ваша IDE начнёт жаловаться на foo("hello")
, потому что вы ограничили обобщённый тип, однако, если вы запустите код, то всё будет работать.
def foo[T: (int, float)](data: T) -> T:
return type(data) # возвращаем тип аргумента
print(foo(5),
foo("hello"), # не ОК
foo(4.56)
)
Вы, наверное, думаете: "Я же это мог делать и без дженериков, да и реальных ограничений они не создают, так зачем они мне тогда нужны?". На самом деле всё просто - это добавит условно строгую типизацию в ваш код, но только на уровне анализа кода(как раз с помощью MyPy), самому Python без разницы на ваши указания, динамическая типизация есть динамическая типизация. И это, отчасти, удобно, но создаёт трудности в читаемости кода. Если вы решите не использовать дженерики для обобщённых функций/классов, то попрощайтейсь с подсказками от вашей IDE и читаемостью кода.
Создание обобщённого класса
Теперь напишем более сложную структуру - обобщённый класс:
class Stack[T]:
def __init__(self) -> None:
# Создание пустого списка с типом данных T
self.items: list[T] = []
def push(self, item: T) -> None:
self.items.append(item)
def pop(self) -> T:
return self.items.pop()
def empty(self) -> bool:
return not self.items
stack = Stack[int]()
stack.push(5)
При создании объекта класса и вызове функции push()
, наблюдаем подсказку следующего вида:
Если мы попробуем добавить число типа float
, то увидим следующее:
Что, в целом, логично. Теперь мы можем создать отдельный стек для нужного нам типа данных, так мы приблизились к нашей основной цели - созданию универсального репозитория, что же начнём.
Создание универсального репозитория с помощью Generic и SQLALchemy
Для начала добавим необходимые импорты:
from typing import Type, Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.repositories.database import engine
Первый импорт: Type
понадобится при передаче модели данных вовнутрь репозитория, Sequence
- тип данных "Последовательность", нужен, т.к. функция all()
возвращает данные такого типа.
Второй и третий импорты: функция select
для получения данных из БД, класс Session
для открытия сессии
Четвёртый импортирует интерфейс для взаимодействия с БД, который мы создавали ранее в предыдущей статье (если не знаете что это такое - читайте предыдущую статью, в контексте данной темы информация о том, как подключать БД будет излишней).
Затем мы создаём класс Repository
, который будет взаимодействовать с БД:
class Repository[M]:
def __init__(self, model: Type[M]) -> None:
self.Model: Type[M] = model
model: Type[M]
- через аргумент функции-конструктора передаём модель данных вовнутрь репозитория. Type
в данной ситуации нужен, т.к. мы передаём именно класс, а не его экземпляр.
Представим, что у нас есть две модели данных: Category
и Product
, создадим для них репозитории:
# добавляем соответствующие импорты
from app.models.category import Category
from app.models.product import Product
...
category_repo = Repository[Category](Category)
product_repo = Repository[Product](Product)
Теперь объясняю: в квадратных скобках [Category]
мы передаём тип данных, т.е. благодаря нему у нас появятся подсказки при использовании функций, в обычных скобках (Category)
мы передаём модель данных, чтобы мы могли внутри функций работать с экземплярами модели данных.
Напишем функцию для создания объектов в БД:
def create(self, new_object: M) -> None:
with Session(engine) as session:
session.add(new_object)
session.commit()
Обратите внимание на new_object: M
, здесь мы добавляем аннотацию типа, как это сыграет нам на руку? Вот так:
А ведь при объявлении функции мы написали M
, а не Category
, но наша IDE понимает как работают дженерики и даёт нам правильные подсказки.
P.S. красным подсвечивается из-за пустых скобок.
Теперь напишем функцию для получения всех объектов из БД:
def get_all(self) -> Sequence[M]:
with Session(engine) as session:
statement = select(self.Model)
objects = session.scalars(statement).all()
return objects
Взглянем на функцию get_all()
: внутри мы видим объявленную нами self.Model
, которая является переданной нами при инициализации экземпляра репозитория моделью данных, и теперь она может использоваться для таких функций, как select
, а также при использовании get_all()
мы можем наблюдать следующее:
Т.е. IDE показывает тип данных, которые функция нам вернёт.
Весь код:
from typing import Type, Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.repositories.database import engine
from app.models.category import Category
from app.models.product import Product
class Repository[M]:
def __init__(self, model: Type[M]) -> None:
self.Model = model
def create(self, new_object: M) -> None:
with Session(engine) as session:
session.add(new_object)
session.commit()
def get_all(self) -> Sequence[M]:
with Session(engine) as session:
statement = select(self.Model)
objects = session.scalars(statement).all()
return objects
category_repo = Repository[Category](Category)
product_repo = Repository[Product](Product)
category_repo.get_all()
Лучший способ запомнить информацию - применить на практике, поэтому я предлагаю Вам создать функции update()
, get_by_name()
и delete()
. Варианты этих функций без дженериков имеются в предыдущей статье.
Подведём итог: дженерики добавляет условно-строгую типизацию для нас, других разработчиков и IDE, но не для интерпретатора Python! Что позволяет сделать наш код более читабельным, а также добавляет подсказки типов от IDE.
Если вы хотите сделать код максимально читабельным, можете создавать репозиторий под каждую модель данных, но если у Вас таковых штук 50... выводы делайте сами :)
4wards1
За открытие отдельной сессии на каждый чих нужно бить по рукам, а лучше по голове.
Сессия - это не просто "какая-то штука", которую можно открывать по 5 раз внутри каждой вьюшки.
Во-первых, это Identity Map, который предотвращает повторное создание уже заполненных ранее объектов, ассоциированных с одинаковыми строками в БД.
Во-вторых, это абстракция для транзакции, в пределах которой вы можете делать rollback и commit. Как вы будете нормально работать с транзакциями, если у вас каждый отдельный запрос к БД оборачивается в отдельную транзакцию?
DarkStussy
Да видно, что некомпетентный человек написал эту статью.
outlingo
По рукам, а лучше по голове, следует бить тех, кто выбрасывает наружу ORM-mapped объекты паттерна ActiveRecord, завязанные на сессию, после чего терминирует сессию.
Но начать следует с того, что по рукам, а лучше по голове, следует бить тех, кто использует глобальные и статические объекты. И тех, кто не хранит маппинги класс-таблица у себя во внутренних структурах, а внедряет их прямо в классы. Хотя стоп, подождите - ведь это именно то, что делает SQLAlchemy?!
В общем, SQLAlchemy «не самый удачный» образец ORM, и даже то что она де-факто «один из стандартов» в питонячьей разарботке, не делает ее правильной. А что до ошибок которые сделал автор статьи - снявши голову (связавшись с алхимией), по волосам не плачут