Доброго времени суток, товарищи, эта статья, так скажем, продолжение предыдущей статьи об 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... выводы делайте сами :)

Комментарии (3)


  1. 4wards1
    25.10.2024 19:35

    За открытие отдельной сессии на каждый чих нужно бить по рукам, а лучше по голове.

    Сессия - это не просто "какая-то штука", которую можно открывать по 5 раз внутри каждой вьюшки.

    Во-первых, это Identity Map, который предотвращает повторное создание уже заполненных ранее объектов, ассоциированных с одинаковыми строками в БД.

    Во-вторых, это абстракция для транзакции, в пределах которой вы можете делать rollback и commit. Как вы будете нормально работать с транзакциями, если у вас каждый отдельный запрос к БД оборачивается в отдельную транзакцию?


    1. DarkStussy
      25.10.2024 19:35

      Да видно, что некомпетентный человек написал эту статью.


    1. outlingo
      25.10.2024 19:35

      По рукам, а лучше по голове, следует бить тех, кто выбрасывает наружу ORM-mapped объекты паттерна ActiveRecord, завязанные на сессию, после чего терминирует сессию.

      Но начать следует с того, что по рукам, а лучше по голове, следует бить тех, кто использует глобальные и статические объекты. И тех, кто не хранит маппинги класс-таблица у себя во внутренних структурах, а внедряет их прямо в классы. Хотя стоп, подождите - ведь это именно то, что делает SQLAlchemy?!

      В общем, SQLAlchemy «не самый удачный» образец ORM, и даже то что она де-факто «один из стандартов» в питонячьей разарботке, не делает ее правильной. А что до ошибок которые сделал автор статьи -  снявши голову (связавшись с алхимией), по волосам не плачут