Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Как писать и тестировать миграции БД с Alembic

Как писать и тестировать миграции БД с Alembic

Александр Васин (Яндекс) @ MoscowPython Meetup 69

"Python приложения могут работать с самыми различными БД (Postgres, SQLite, MariaDB и др.) и перед разработчиками приложений возникает задача реализовать возможность легко и безопасно изменять состояние БД: как структуры так и самих данных, от версии к версии приложения.
В докладе я поделюсь опытом использования хорошо зарекомендовавшего себя инструмента для управления миграциями - alembic.

Расскажу, почему стоит остановить свой выбор именно на нем, как с его помощью подготовить миграции, как их запускать (автоматически или вручную), зачем тестировать, какие проблемы могут выявить тесты и как эти тесты реализовать.

Мы рассмотрим проблемы необратимых изменений в миграциях, а также несколько лайфхаков alembic, которые сделают работу с миграциями легкой и приятной".

Видео: http://www.moscowpython.ru/meetup/69/talk-from-yandex/

Moscow Python Meetup

October 30, 2019
Tweet

More Decks by Moscow Python Meetup

Other Decks in Programming

Transcript

  1. Что такое миграции? 3 App v1 App v2 table 1’

    table 2 table 3 App v3 table 1’ table 2 table 3 table 1 table 2 rollout
  2. Миграция 3 Миграция 1 Что такое миграции? 4 App v1

    App v2 table 1’ table 2 table 3 App v3 table 1’ table 2 table 3 table 1 table 2 Миграция 2 upgrade downgrade rollout
  3. Код, который переводит БД из одного определенного состояния в другое

    (структура данных, данные) Код, который откатывает эти изменения обратно Процесс, при котором выполняется код, изменяющий состояние БД Что такое миграции? 5
  4. Атомарность - миграция (или группа миграций) должна быть применена либо

    полностью, либо никак Обратимость - миграции должны содержать код, который позволит вернуться к предыдущему состоянию Упорядоченность - должно быть понятно, в каком порядке нужно накатывать миграции, какую катить следующей Какими свойствами они должны обладать? 6
  5. CREATE TABLE table1 ( id SERIAL PRIMARY KEY, name TEXT,

    ... ) CREATE TABLE table2 (...) Файлы с SQL запросами 9 DROP TABLE table1; DROP TABLE table2; upgrade_v1.sql downgrade_v1.sql
  6. Необходимо вручную хранить состояние БД (какие миграции были выполнены) Отсутствует

    защита от выполнения миграции, не рассчитанной на работу с текущим состоянием БД Запросы в миграциях необходимо писать вручную, учитывая связи таблиц В каждой миграции нужно не забыть использовать транзакции Файлы с SQL запросами: недостатки 10
  7. UPGRADE = """ CREATE TABLE table1 ( id SERIAL PRIMARY

    KEY, name TEXT, ... ); CREATE TABLE table2 (...); """ DOWNGRADE = """ DROP TABLE table1; DROP TABLE table2; """ yoyo-migrations: как выглядит миграция 12 from yoyo import step steps = [ step(UPGRADE, DOWNGRADE), ]
  8. # Создать новую миграцию yoyo new ./migrations -m "Add column

    to foo" # Применить (upgrade) yoyo apply --database postgresql://user:hackme@localhost/db ./migrations # Откатить yoyo rollback --database mysql://user:hackme@localhost/db ./migrations yoyo-migrations: как использовать 13
  9. Автоматически создает специальную таблицу _yoyo_migration, в которой сохраняет состояние БД

    (примененные миграции) Перед запуском миграции проверяет, можно ли ее запустить в текущем состоянии БД Выполняет каждую миграцию в отдельной транзакции yoyo-migrations: достоинства 14
  10. Запросы в миграциях необходимо писать вручную, учитывая связи таблиц Много

    рутинной работы, автоматизировать невозможно Использовать бизнес-логику, написанную на python для обработки данных в миграциях невозможно yoyo-migrations: недостатки 15
  11. Framework agnostic, построен поверх SQLAlchemy Умеет автоматически генерировать код миграций

    на основе описанных в коде моделей и анализа БД Позволяет писать очень сложную бизнес- логику в миграциях без ограничений Умеет работать с ограниченными возможностями ALTER в SQLite Alembic 16
  12. Недостатки 〉Нельзя переиспользовать, расширять 〉Большие запросы сложно читать, писать во

    вложенных конструкциях из-за отступов 〉Нет инструментов для динамического построения запросов 〉При миграциях нужно погрепать все сырые запросы в проекте Сырые SQL запросы 18 Достоинства Не требуется дополнительных библиотек Нет накладных расходов на обработку
  13. Недостатки 〉За удобство приходится платить накладными расходами - временем на

    генерацию запросов и памятью Query builder 19 Достоинства 〉Python-синтаксис, легко разделять на части и читать 〉Запросы можно декомпозировать, переиспользовать и расширять 〉Построение динамических запросов 〉При изменениях достаточно статического анализа кода
  14. Отлично работает как с синхронными драйверами (psycopg2), так и с

    асинхронными (aiopg, asyncpgsa) Поддерживает огромное кол-во драйверов, баз данных, очень богатые возможности, при этом очень гибкая и дает честный единый интерфейс Отличная документация, большое сообщество, активно разрабатывается Есть очень много инструментов для разработки (sqlalchemy_utils, alembic, etc.) Не диктует фреймворков и библиотек Почему же SQLAlchemy? 20
  15. Контейнер, содержит информацию о схеме БД: всех таблицах, индексах, типах

    данных и т.п. Умеет получать информацию о том, какие сущности сейчас есть в базе (reflect) Позволяет указать шаблоны именования индексов и constraint- ов по умолчанию SQLAlchemy: MetaData 22
  16. convention = { 'all_column_names': lambda constraint, table: '_'.join([ column.name for

    column in constraint.columns.values() ]), 'ix': 'ix__%(table_name)s__%(all_column_names)s', 'uq': 'uq__%(table_name)s__%(all_column_names)s', 'ck': 'ck__%(table_name)s__%(constraint_name)s', 'fk': ( 'fk__%(table_name)s__%(all_column_names)s__' '%(referred_table_name)s' ), 'pk': 'pk__%(table_name)s' } metadata = sqlalchemy.MetaData(naming_convention=convention) MetaData naming conventions 23
  17. Описывает структуру определенной таблицы (типы столбцов, индексы и др). Необходима,

    чтобы SQLAlchemy могла генерировать запросы SQLAlchemy: Table 24 users_table = Table( 'users', metadata, Column('user_id', Integer, primary_key=True), Column('email', String(256), nullable=False, unique=True) )
  18. Скрывает за собой пул подключений и диалект, которые работают непосредственно

    с модулем DBAPI (драйвером) и БД SQLAlchemy: Engine 25 from sqlalchemy import create_engine engine = create_engine( 'postgresql://user:[email protected]/db', echo=True ) with engine.connect() as conn: # {sqlalchemy.engine.result.RowProxy} (1,) data = conn.execute('SELECT 1').fetchone()
  19. Используется для общения с различными имплементациями DBAPI, драйверами и базами

    данных. 
 Из коробки поддерживает: 〉PostgreSQL 〉MySQL 〉SQLite 〉Oracle 〉Microsoft SQL Server SQLAlchemy: Dialect 26
  20. with engine.connect() as conn: # {Insert}: # INSERT INTO users

    (email) VALUES (:email) # RETURNING users.user_id, users.email query = users_table.insert().values( email='[email protected]' ).returning(users_table) # {RowProxy} (4, '[email protected]') data = conn.execute(query).fetchone() SQLAlchemy: добавление данных 27
  21. with engine.connect() as conn: query = users_table.insert().values([ {'email': '[email protected]'}, {'email':

    '[email protected]'} ]).returning(users_table) # INSERT INTO users (email) # VALUES (%(email_m0)s), (%(email_m1)s) # RETURNING users.user_id, users.email print(query.compile(engine)) # <class 'list'>: [ # (5, '[email protected]'), (6, '[email protected]') # ] data = conn.execute(query).fetchall() SQLAlchemy: массовое добавление данных 28
  22. with engine.connect() as conn: # {Select}: # SELECT users.user_id, users.email

    \nFROM users query = users_table.select() # <class 'list'>: [ # (1, '[email protected]'), # (2, '[email protected]'), ...] data = conn.execute(query).fetchall() SQLAlchemy: получение данных 29
  23. # {Update} # UPDATE users SET email=:email # WHERE users.user_id

    = :user_id_1 # RETURNING users.user_id, users.email query = users_table.update().values( {'email': '[email protected]'} ).where( users_table.c.user_id == 1 ).returning(users_table) SQLAlchemy: обновление данных 30
  24. def get_users(is_deleted=False) -> Select: return users_table.select().where( users_table.c.is_deleted == is_deleted )

    def get_new_users() -> Select: date = datetime.now() - timedelta(days=2) return get_users().where( users_table.c.created_at >= date ) Расширение и декомпозиция запросов 31
  25. $ alembic init alembic Как начать использовать alembic? 33 При

    инициализации будут созданы следующие файлы 〉alembic/env.py 〉alembic/script.py.mako 〉alembic/versions/ 〉alembic.ini
  26. alembic.ini - файл c конфигурацей 34 [alembic] # path to

    migration scripts script_location = alembic # template used to generate migration files file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s # version location specification; this defaults # to alembic/versions. sqlalchemy.url = postgresql://user:hackme@localhost/db # Logging configuration # ...
  27. """${message} Revision ID: ${up_revision}, revises: ${down_revision | comma,n} Create Date:

    ${create_date} """ import sqlalchemy as sa from alembic import op ${imports if imports else ""} revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} script.py.mako - шаблон для новых миграций 35
  28. def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades

    if downgrades else "pass"} script.py.mako - шаблон для новых миграций 36
  29. from logging.config import fileConfig from alembic import context from staff

    import schema config = context.config fileConfig(config.config_file_name) # Объект MetaData, к которому подключены модели/таблицы target_metadata = schema.metadata if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() alembic/env.py - кастомизация 37 Необходимо подключить объект MetaData, который содержит всю информацию о таблицах (и др. сущностях) проекта
  30. config = context.config target_metadata = models.Base.metadata def run_migrations_online(): engine =

    engine_from_config( config.get_section(config.config_ini_section), ... ) with engine.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata ) with context.begin_transaction(): context.run_migrations() alembic/env.py: режим “онлайн” 38
  31. config = context.config target_metadata = models.Base.metadata def run_migrations_offline(): url =

    config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, ... ) with context.begin_transaction(): context.run_migrations() alembic/env.py: режим “оффлайн” 39
  32. alembic revision --message="Initial" --autogenerate Как сгенерировать миграции? 40 Эта команда

    выполнит следующее: 〉Получит схему существующей БД, последнюю примененную миграцию (если есть) 〉Проанализирует разницу между описанными сущностями в проекте (таблицы, индексы, типы данных и т.п.) и в БД 〉Сгенерирует новый файл (на основе script.py.mako) с DDL, который попытается привести БД в одинаковое состояние с объектом MetaData
  33. """Initial Revision ID: 058939c545ff Revises: Create Date: 2019-10-30 15:18:22.209941 """

    from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '058939c545ff' down_revision = None branch_labels = None depends_on = None Миграция (часть 1) 41
  34. def upgrade(): # ### commands auto generated by Alembic -

    please adjust! ### op.create_table('users', sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('email', sa.String(length=256), nullable=False), sa.PrimaryKeyConstraint('user_id', name=op.f('pk__offers')), sa.UniqueConstraint('email', name=op.f('uq__offers__email')) ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('users') # ### end Alembic commands ### Миграция (часть 2) 42
  35. Для применения всех доступных миграций необходимо выполнить команду alembic upgrade

    head Применение миграций 43 $ alembic upgrade head INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. INFO [alembic.runtime.migration] Running upgrade -> 058939c545ff, Initial
  36. Если приложение on-premises или доступ к нему ограничен - можно

    и нужно накатывать миграции автоматически, например при запуске приложения Если у вас важный сервис, к которому имеется доступ - накатывание миграций вручную дает больший контроль и позволяет оперативно реагировать на проблемы Когда и как применять миграции? 44
  37. Если вам больше не нужна большая таблица или столбец и

    вы хотите их удалить без возможности восстановить данные - не стоит их удалять сразу Убрать все обращения к ресурсам в коде как будто их не существует Столбцы / таблицы / типы данных стоит пометить специальным декоратором, который будет сообщать о доступе к ресурсам, которые должны быть удалены Создать задачу в бэклог на последующее удаление Как быть с необратимыми изменениями? 45
  38. Применение миграции в production всегда сопряжено с риском Production БД

    обычно огромны, стары и полны сюрпризов БД используемые при разработке и тестировании, как правило, меньше и чище. Данные в них лучше понимаются или, если все остальное не удается, объем данных достаточно мал для обработки человеком Зачем тестировать миграции? 47
  39. Поврежденные данные, которые были написаны старыми версиями программного обеспечения и

    не очищены должным образом Подразумеваемые зависимости в данных, о которых больше никто не знает Люди непосредственно меняют базу данных без использования назначенных инструментов Ошибки в инструментах миграции схем Ошибки в предположениях о том, как следует переносить данные Из-за чего могут возникнуть проблемы? 48
  40. Миграция запускается, в коде нет глупых опечаток Написан корректный downgrade

    В downgrade() удаляются все созданные типы данных, которые SQLAlchemy создала автоматически в upgrade() Если миграция меняет какие-либо данные - что данные корректно изменяются в методе upgrade() и что эти изменения полностью откатываются в downgrade() без артефактов Что мы можем проверить? 49
  41. На каждый тест лучше использовать отдельную БД, это позволяет полностью

    изолировать тесты друг от друга и в перспективе использовать параллельный запуск тестов. sqlalchemy_utils поможет в создании и удалении БД. Эта библиотека учитывает, что к БД может быть кто-то подключен и перед удалением отключит всех клиентов. Как подготовить базу? 50
  42. @pytest.fixture def temp_db() -> str: tmp_db_name = '.'.join([uuid.uuid4().hex, 'pytest']) tmp_db_url

    = str(URL(DB_URL).with_path(tmp_db_name)) create_database(tmp_db_url) try: yield tmp_db_url finally: drop_database(tmp_db_url) Как подготовить базу? 51
  43. @pytest.fixture() def temp_db_engine(temp_db) -> Engine: engine = create_engine(temp_db, echo=True) try:

    yield engine finally: engine.dispose() Как подготовить Engine? 52
  44. Тест получает список всех миграций и итерируется по ним. Для

    каждой он вызывает upgrade(), downgrade() и еще раз upgrade(). Простой, но эффективный метод проверить - полностью ли миграция откатила изменения. Можно добавить в проект 1 раз и забыть. Stairway тест 53
  45. Stairway тест 54 5 4 3 2 1 1 1

    1 2 1 2 1 2 1 3 2 1 3 2 1 3 2 1 4 3 2 1 4 3 2 1 4 3 2 1 5 4 3 2 1 5 4 3 2 1 5 4 3 2 1
  46. from tests.conftest import DB_URL from staff.testing import get_revisions, get_alembic_config REVISIONS

    = list(get_revisions(DB_URL)) MODULE_PATH = os.path.dirname(os.path.dirname(__file__)) @pytest.mark.parametrize('rev_index', reversed(range(len(REVISIONS)))) def test_migrations_stairway(temp_db_engine: Engine, rev_index: int): revision = REVISIONS[rev_index] config = get_alembic_config(str(temp_db_engine.url)) upgrade(config, revision.revision) downgrade(config, revision.down_revision or '-1') upgrade(config, revision.revision) Stairway тест 55
  47. Если в миграции не просто добавляется новый столбец или таблица,

    а каким-либо образом меняются существующие данные - то ошибка может иметь самые серьезные последствия Обычно такие миграции кажутся чем-то простым, но по опыту именно в них бывает очень много багов, которые очень сложно обнаружить Дорогой в разработке, но очень надежный Тестирование миграций с данными 56
  48. Применяются все миграции до тестируемой (не включительно) Создается набор данных,

    который будет изменен тестируемой миграцией Выполняется upgrade и проверяется, что все данные были корректно изменены Выполняется downgrade до предыдущей миграции, проверяется, что все изменения были корректно отменены Тестирование миграций с данными 57
  49. @pytest.mark.parametrize(...) def test_data_migrations( temp_db_engine: Engine, rev_base: str, rev_head: str, on_init:

    Callable, on_upgrade: Callable, on_downgrade: Callable ): """ Check data is migrated correctly in "data" migrations. """ config = get_alembic_config(str(temp_db_engine.url)) upgrade(config, rev_base) on_init(engine=temp_db_engine) upgrade(config, rev_head) on_upgrade(engine=temp_db_engine) downgrade(config, rev_base) on_downgrade(engine=temp_db_engine) Тестирование миграций с данными 58
  50. На что обратить внимание: 〉staff-db - обертка над alembic, которую

    можео зывывать из любой папки Где посмотреть пример 59