Использование PostgreSQL Enum в SQLAlchemy. И проблемы с ним

от автора

Начнем с начала. В postgres enum — статический упорядоченный набор значений.

В ходе использования postgresql совместно с sqlalchemy я столкнулся с рядом особенностей, которые слабо документированы, о которых мне и хотелось бы поведать.

Данная статья будет состоять из 3 частей:

  1. Базовые примеры использования

  2. Проблемы с миграциями

  3. Продвинутое использование

Базовые примеры использования

Представим, что у нас есть модель заказа

from sqlalchemy import Integer from app.database import BaseModel  class Order(BaseModel):     id = Column(Integer, primary_key=True, autoincrement=True)     # ... другие поля

Вероятнее всего, у него будут разные статусы. Это можно реализовать как просто числовое поле, но появляются проблемы с ограничением такого поля и в целом интерпритации его при выборке из бд. Поэтому лучше использовать enum. Для примера давайте напишем простейший перечисление используя стандартную библиотеку python.

from enum import Enum  class OrderStatusEnum(Enum):     WAITING_FOR_WORKER = 'WAITING_FOR_WORKER'     IN_PROGRESS = 'IN_PROGRESS'     DONE = 'DONE' 

И теперь для добавления колонки-перечисления требуется всего лишь добавить подобную строку

from sqlalchemy import Integer from sqlalchemy.dialects.postgresql import ENUM as PgEnum  from app.database import BaseModel   class Order(BaseModel):     id = Column(Integer, primary_key=True, autoincrement=True)      status = Column(PgEnum(OrderStatusEnum, name='order_status_enum', create_type=False), nullable=False, default=OrderStatusEnum.WAITING_FOR_WORKER)     # ... другие поля 

Про параметры:

  • name — имя, с которым будет создан enum в бд

  • create_type — автоматическое создание/удаление (которое при автоматической генерации не работает) перечисления в миграции. По этой причине не советую пользоваться данным параметром

Проблемы с миграциями

Итак, теперь переходим к разделу, из-за которого во многом эта статья и была написана. Мы написали первый enum, и если не прописывать параметр create_type=False, то может показаться что все хорошо и работает как надо. Миграция создалась и применилась.

def upgrade():     # ### commands auto generated by Alembic - please adjust! ###     op.create_table('order',     sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),     sa.Column('status', postgresql.ENUM('WAITING_FOR_WORKER', 'IN_PROGRESS', 'DONE', name='order_status'), nullable=True),     sa.PrimaryKeyConstraint('id', name=op.f('order_pkey'))     )     # ### end Alembic commands ###   def downgrade():     # ### commands auto generated by Alembic - please adjust! ###     op.drop_table('order')     # ### end Alembic commands ###

Но это не совсем так. Если мы сделаем downgrade, а потом попробуем вновь сделать upgrade, то выяснится, что в downgrade не удалился enum. Как же это исправить? Необходимо добавить строчку, которая удалит наш enum. Выглядеть это будет следующим образом (для единообразия кода также прописал явное создание)

def _get_order_status():     return postgresql.ENUM('WAITING_FOR_WORKER', 'IN_PROGRESS',                            'DONE', name='order_status_enum')     def upgrade():     order_status = _get_order_status()     order_status.create(op.get_bind())     # ### commands auto generated by Alembic - please adjust! ###     op.create_table('order',     sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),     sa.Column('status', order_status, nullable=True),     sa.PrimaryKeyConstraint('id', name=op.f('order_pkey'))     )     # ### end Alembic commands ###   def downgrade():     # ### commands auto generated by Alembic - please adjust! ###     op.drop_table('order')     # ### end Alembic commands ###     _get_order_status().drop(op.get_bind())

Отлично. Мы наконец смогли создать enum и сделать корректную миграцию. Но развитие приложения не стоит на месте, и нам понадобилось добавить новый статус заказа. И мы легко можем это сделать:

class OrderStatusEnum(Enum):     WAITING_FOR_WORKER = 'WAITING_FOR_WORKER'     IN_PROGRESS = 'IN_PROGRESS'     DONE = 'DONE'     CANCELED = 'CANCELED'

Давайте же попробуем запустить автогенерацию миграции теперь. И что же произошло? Правильно, ничего! alembic просто игнорирует изменения в enum-е и автоматически миграция не генерируется. Что же с этим делать? Например, можно добавить подобное в миграцию

def upgrade():     # ### commands auto generated by Alembic - please adjust! ###     # ### end Alembic commands ###     op.execute("ALTER TYPE order_status_enum ADD VALUE 'CANCELED'")  def downgrade():     # ### commands auto generated by Alembic - please adjust! ###     # ### end Alembic commands ###     query_str = f"""             ALTER TYPE order_status_enum RENAME TO order_status_enum_old;             CREATE TYPE order_status_enum AS ENUM('WAITING_FOR_WORKER', 'IN_PROGRESS', 'DONE');             ALTER TABLE order ALTER status DROP DEFAULT;             ALTER TABLE order ALTER COLUMN status TYPE order_status_enum USING status::text::order_status_enum;             DROP TYPE order_status_enum_old;             """     for q in query_str.strip().split(';')[:-1]:         op.execute(q)  

Это вполне себе решение, но оно не слишком удобно, так как об этой проблеме придется постоянно помнить и копировать код из старых миграций. Что же делать? Первое появившаяся идея — просто обернуть это в функцию:

from typing import Iterable, Sequence from alembic import op   def set_enum_values(enum_name: str, new_values: Iterable[str], references: Iterable[Sequence[str]]):     """      @param enum_name: Системное наименование enum     @param new_values: Новые значения enum     @param references: Упоминания enum в моделях      Example:         set_enum_values('promo_type_enum', (             'BEST_OFFER',             'NEW_PRODUCT',             'NO_PROMOTION',         ), [('advertisement_sale_package', 'promo_type')])     """     query_str = f"""             ALTER TYPE {enum_name} RENAME TO {enum_name}_old;             CREATE TYPE {enum_name} AS ENUM({', '.join(f"'{value}'" for value in new_values)});             """     for table_name, column_name in references:         query_str += f"""             ALTER TABLE {table_name} ALTER {column_name} DROP DEFAULT;             ALTER TABLE {table_name} ALTER COLUMN {column_name} TYPE {enum_name} USING {column_name}::text::{enum_name};         """     query_str += f"""DROP TYPE {enum_name}_old;"""     for q in query_str.split(';')[:-1]:         op.execute(q) 

Тогда downgrade можно переписать следующим образом:

def downgrade():     # ### commands auto generated by Alembic - please adjust! ###     # ### end Alembic commands ###     set_enum_values('order_status_enum', (                     'WAITING_FOR_WORKER',                     'IN_PROGRESS',                     'DONE'     ), [('order', 'status')])

Но об этом по прежнему стоит помнить и добавлять ручками. По этой причине @RustyGuard при моей поддержке создал замечательную библиотеку alembic-postgresql-enum. Она полностью решает проблемы с миграциями enum-ов. Призываю всех кто использует postgresql совместно с sqlalchemy попробовать ее и писать о встреченных проблемах.

Продвинутое использование

Напоследок, хотел бы рассказать о случае, когда мне пришлось менять поведение в enum по умолчанию. Тут следует отметить важную деталь, при генерации перечисления sqlalchemy берет атрибут name, а не value. И когда мне понадобилось написать подобное перечисление

class DecorationState(Enum):     NONE = 'Без отделки'     MUNICIPAL = 'Муниципальный ремонт'     MODERN = 'Современная отделка'

Возникла проблема, так как мне требовалась фильтрация по тексту, и необходимо было чтобы в базу данных попадали атрибуты value, а не name. К счастью sqlalchemy позволяет создавать пользовательские типы и я успешно решил эту задачу написав такой класс:

values_enum.py
from copy import copy from enum import Enum  import sqlalchemy.types as types   class ValuesEnum(types.TypeDecorator):     impl = types.Enum      cache_ok = True      def __init__(self, *args, **kwargs):         super().__init__(*args, **kwargs)         objects = copy(self._object_lookup)         objects.pop(None)         self._reversed_object_lookup = {v.value: v for v in objects.values()}         self._reversed_object_lookup[None] = None      def process_bind_param(self, value, dialect):         if isinstance(value, Enum):             return value.value         return value      def _object_value_for_elem(self, value):         return self._reversed_object_lookup[value]      def result_processor(self, dialect, coltype):         def process(value):             value = self._object_value_for_elem(value)             return value          return process

Таков мой опыт работы с enum-ами в sqlalchemy. Пишите если есть что добавить и пробуйте библиотеку.


ссылка на оригинал статьи https://habr.com/ru/articles/751140/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *