Миграции баз данных gino + alembic

Миграции баз данных gino + alembic

Forzend

В активно развивающихся проектах часто вносятся изменения в структуру базы данных, поэтому хорошей практикой является работа с миграциями. Благодаря alembic вам не нужно будет сносить к х***м базу, чтобы потом поднять её с новыми таблицами, а также вы сможете откатиться на предыдущую версию базы, в случае необходимости. Миграции используются для правильного переезда базы данных на новую структуру. Версия базы данных должна соответствовать версии приложения, иначе приложение банально не будет правильно функционировать.

Представим. Вы работаете в команде разработчиков и работает над одним серьёзным проектом. На продакшене у вас уже имеется база на тысячи пользователей. Вы планируете внести изменения в логику проекта — модели и их использование. Главный вопрос, который возникает в такой ситуации — как без мороки и потери данных перенести базу данных на новую структуру, как написать код так. Решением этой проблемы является использование в своём проекте миграций. Поэтому в этой статье мы ,опираясь на бот-пример от aiogram, познакомимся с миграциями на примере alembic и его интеграцией с асинхронной ORM на базе ядра sqlalchemygino.

Важное примечание

Автор статьи поменял ссылки на свой telegram и github профиль, поэтому у вас могут не открываться некоторые ссылки.

Новая ссылка на github профиль автора, в котором находится репозиторий статей: https://github.com/F0rzend
Новая ссылка на мой telegram профиль, для обратной связи: https://t.me/F0rzend

Содержание статьи

.
├── Подготовка проекта
├── Создание моделей
|   └── Модель пользователя
├── Инициализация alembic
├── Настройка alembic config
├── Создание миграций
|   ├── Poetry
|   └── Использование make
├── Docker & docker-compose
|   ├── Dockerfile
|   └── docker-compose
└── Завершение

Подготовка проекта

В этой статье будет использоваться структура, описанная в одной из предыдущих статей (вы также можете найти её на github).

Сразу после создания проекта создадим виртуальное окружение, чтобы "не замарать" систему:

python3 -m venv venv

активируем его:

GNU/Linux: source venv/bin/activate

Windows: venv/Scripts/activate

После чего установим нужные нам библиотеки. Стандартные библиотеки для работы уже описаны в файле с зависимостями, установим их, а также необходимые для миграций библиотеки: gino, alembic и psycopg2-binary (Необходимая alembic зависимость):

pip install -r requirements.txt gino alembic psycopg2-binary

Также определим необходимые нам переменные в app/config.py и .env файле

Начнём с .env файла. Переименуем .env.dist в .env, заполним необходимые поля и добавим поля, необходимые для подключения к базе данных, а именно хост, порт, название базы данных, пользователя, пароль пользователя:

BOT_TOKEN = 0123456789:AB1CD2EF3GH4IJ5KL6MN7PQ8RS9TU-VWXYZ
SKIP_UPDATES = True
SUPERUSER_ID = 123456789

POSTGRES_HOST = localhost
POSTGRES_PORT = 5432
POSTGRES_DB = database
POSTGRES_USER = mypguser
POSTGRES_PASSWORD = qwerty123456

Теперь перейдём в app/config.py и добавим необходимые для подключения к базе данных константы. Обратите внимание, что кроме определённых в .env файле переменных, я добавляю константу POSTGRES_URI, которая создаётся на основе всех предыдущих. Именно эта константа нужна нам для подключения. После внесения в app/config.py всех необходимых констант, он принимает вид:

from pathlib import Path

from environs import Env

env = Env()
env.read_env()


BOT_TOKEN = env.str("BOT_TOKEN")
SKIP_UPDATES = env.bool("SKIP_UPDATES", False)
WORK_PATH: Path = Path(__file__).parent.parent

SUPERUSER_ID = env.list("SUPERUSER_ID")

POSTGRES_HOST = env.str("POSTGRES_HOST", default="localhost")
POSTGRES_PORT = env.int("POSTGRES_PORT", default=5432)
POSTGRES_DB = env.str("POSTGRES_DB")
POSTGRES_USER = env.str("POSTGRES_USER")
POSTGRES_PASSWORD = env.str("POSTGRES_PASSWORD")
POSTGRES_URI = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"

Проект подготовлен к работе.

Хочу немного пояснить что здесь происходит для людей, которые не очень понимают происходящее, ибо я не останавливался на файле app/config.py в статье о новой структуре. Понимающие могут пропустить эту часть статьи и продолжить с пункта "Создание моделей".

Для загрузки переменных я использую библиотеку environs, а не python-dotenv, который использует обычно Latand. Мне просто она больше нравится, вы можете использовать python-dotenv.

В следующих строках создаётся объект окружения, из которого мы и можем получать переменные окружения:

from environs import Env

env = Env()
env.read_env()

Далее все переменные подключаются с помощью следующего кода:

VARIABLE = env.str("VARIABE")

Здесь str указывает на строковый тип переменной, вы можете использовать bool, list или другие типы. Подробнее о них вы можете прочесть в readme на github environs.

WORK_PATH: Path = Path(__file__).parent.parent используется для банального получения полного пути в проекте. Она может вам пригодится, поэтому была добавлена в конфиг структуры. В этой статье мы её использовать не будем, поэтому вы можете просто её удалить.

Создание моделей

Теперь, когда наш проект готов к работе, мы можем приступить к написанию моделей, на которых будем тестировать миграции. В первую очередь создадим объект базы данных в app/loader.py (Не забудьте вписать его в __all__). Теперь файл app/loader.py имеет такой вид:

from aiogram import Bot, Dispatcher, types
from aiogram.contrib.fsm_storage.memory import MemoryStorage

from gino import Gino

from app import config

db = Gino()

bot = Bot(
    token=config.BOT_TOKEN,
    parse_mode=types.ParseMode.HTML,
)

storage = MemoryStorage()

dp = Dispatcher(
    bot=bot,
    storage=storage,
)

__all__ = (
    "bot",
    "storage",
    "dp",
    "db",
)

Создадим пакет app/models, в который поместим файл __init__.py, чтобы питон понимал, что это пакет; base, где мы опишем подключение к базе и разрыв соединения; модуль user, в котором мы опишем модель пользователя, на которой будем всё тестировать. Это должно выглядеть как-то так (на месте ". . ." находятся прочие файлы и папки, необходимые для работы бота):

.
├── app
|   ├── ...
|   ├── models
|   |   ├── __init__.py
|   |   ├── user.py
|   |   └── base.py
|   ├── loader.py
|   └── ...
└── ...

Откроем модуль app/models/base.py и определим в нём функцию для подключения к базе данных. Моё подключение практически не отличается от показанного в курсе. Единственное изменение, я предпочёл сделать postgres_uri аргументом функции, а не брать его напрямую из app/config.py, чтобы при необходимости, можно было использовать эту функцию и для подключение к другой базе:

from loguru import logger

from app.loader import db

async def connect(postgres_uri: str):
    await db.set_bind(postgres_uri)
    logger.info('PostgreSQL is successfully configured')

Также напишем функцию для разрыва соединения, на случай, если бот упадёт:

from contextlib import suppress

from gino import UninitializedError

from loguru import logger

from app.loader import db


async def close_connection():
    with suppress(UninitializedError):
        logger.info('Closing a database connection')
        await db.pop_bind().close()


Вас может смутить строка with suppress(UnitializedError): . . .

Это абсолютно то же самое, что и следующиее:

try:
. . .
except UnitializedError:
    pass

Согласитесь, использование контекстного менеджера (with) выглядит лаконичнее.

Законченный файл app/models/base.py имеет вид:

from contextlib import suppress

from gino import UninitializedError

from loguru import logger

from app.loader import db


async def connect(postgres_uri):
    await db.set_bind(postgres_uri)
    logger.info('PostgreSQL is successfully configured')


async def close_connection():
    with suppress(UninitializedError):
        logger.info('Closing a database connection')
        await db.pop_bind().close()

После всего этого добавим вызов этих функции в app/__main__.py при запуске и падении бота (функции, переданные в аргументы on_startup и on_shutdown executor.start_polling(*args)). Файл app/__main__.py обрёл следующий вид:

from aiogram import Dispatcher
from aiogram.utils import executor

from app import utils, config
from app.loader import dp

# The configuration of the modules using import
from app import middlewares, filters, handlers

from app.models import base


async def on_startup(dispatcher: Dispatcher):
    await base.connect(config.POSTGRES_URI)
    await utils.setup_default_commands(dispatcher)
    await utils.notify_admins(config.SUPERUSER_ID)


async def on_shutdown(dispatcher: Dispatcher):
    await base.close_connection()


if __name__ == '__main__':
    utils.setup_logger("INFO", ["sqlalchemy.engine", "aiogram.bot.api"])
    executor.start_polling(
        dp, on_startup=on_startup, on_shutdown=on_shutdown, skip_updates=config.SKIP_UPDATES
    )

Модель пользователя

Наконец приступим к написанию модели пользователя. Перейдём в модуль app/models/user и создадим там модель User:

from sqlalchemy.sql import Select, expression

from app.loader import db


class User(db.Model):
    __tablename__ = "users"
    id = db.Column(db.Integer, primary_key=True, index=True, unique=True)
    is_superuser = db.Column(db.Boolean, server_default=expression.false())
    query: Select

У вас может вызвать вопросы поле is_superuser, а именно аргумент server_default. Мы определяем его именно таким образом, потому alembic требует, чтобы значение по-умолчанию было заданно на уровне базы через server_default, а не через default, который определяет значение на уровне Python.

Из документации alembic:

Note that this statement uses the Column construct as is from the SQLAlchemy library. In particular, default values to be created on the database side are specified using the server_default parameter, and not default which only specifies Python-side defaults.

Подробнее о значениях типа expression.false() и зачем они применяются вы можете прочесть в официальной документации sqlalchemy.

После создания, данную модель необходимо импортировать в app/models/__init__.py:

from .user import User

Инициализация alembic

В первую очередь нам нужно создать необходимую "структуру", на базе которой будет работать alembic. Для этого выполним следующую команду:

alembic init migrations
"migrations" — название директории, которая будет находится в корне проекта и отвечать за миграции. Я советую называть её именно так, ибо данное название говорит само за себя

После выполнения данной команды, alembic создаст в корне файл alembic.ini и директорию migrations (или директорию с тем названием, которое вы передали как один из аргументов команды init). Таким образом корень проекта имеет вид:

.
├── app/
├── migrations/
├── .env
├── .gitignore
├── alembic.ini
└── requirements.txt

Настройка alembic config

Если идти по официальной документации gino, следующим шагом будет статично определить sqlalchemy.uri в файле alembic.ini. Об этом же просит у нас alembic в самой консоли:

 Please edit configuration/connection/logging settings in 'full/path/to/your/project/alembic.ini' before proceeding.

Но мы укажем его напрямую в конфигурационном файле alembic (далее конфиг), поэтому данный шаг мы пропусти и перейдём сразу к конфигу. Конфиг alembic находится в migrations/env.py, именно на этот файл опирается alembic при генерации миграций. Сейчас он имеет такой вид (Обратите внимание, что я удалил все комментарии, чтобы в коде было легче ориентироваться):

from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool

from alembic import context


config = context.config
fileConfig(config.config_file_name)
target_metadata = None


def run_migrations_offline():
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online():
    connectable = engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection, target_metadata=target_metadata
        )

        with context.begin_transaction():
            context.run_migrations()


if context.is_offline_mode():
    run_migrations_offline()
else:
   run_migrations_online()

Мы будем работать с первой половиной файла, до определения функции run_migrations_offline:

from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool

from alembic import context


config = context.config
fileConfig(config.config_file_name)
target_metadata = None


def run_migrations_offline():
. . .

Начнём нашу настройку с определения target_metadata, которую alembic использует для автоматической генерации миграций. Передадим в него экземпляр класса Gino, который мы определили в app/loader.py. Не забудем его импортировать. Также сразу определим тот самый sqlalchemy.uri с помощью config.set_main_option() и импортируем все наши модели, чтобы alembic мог получить к ним доступ. Теперь начало файла migrations/env.py выглядит так:

from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool

from alembic import context

from app.config import POSTGRES_URI
from app.loader import db
from app.models import *

config = context.config
fileConfig(config.config_file_name)
target_metadata = db

config.set_main_option('sqlalchemy.url', POSTGRES_URI)


def run_migrations_offline():
. . .

У нас всё готово. Можем создавать миграции

Создание миграций

Команда для создания миграций имеет вид:

alembic revision [arguments]

Добавим в неё несколько аргументов и выполним:

alembic revision -m "first migration" --autogenerate

Здесь:

  • -m "first migration" — комментарий к миграции, он используется, чтобы легче ориентироваться в версиях миграций;
  • --autogenerate — название говорит само за себя. Этот аргумент говорил alembic, чтобы тот создал инструкции для базы самостоятельно и нам не пришлось делать это руками;

После выполнения данной команды вы скорее всего получите ошибку. Вот это я мудак, правда? 🤪

Но не спешите закрывать статью!

Если ваша ошибка имеет вид:

 File "migrations/env.py", line 8, in <module>
  from app.config import POSTGRES_URI
ModuleNotFoundError: No module named 'app'

Её исправить довольно просто. Об этом даже написано в документации gino:

Either install your project locally with pip install -e .poetry install or python setup.py develop, or add you package to PYTHONPATH.

Последовав их совету, просто зададим переменную окружения PYTHONPATH. (Не забудьте заменить мой путь к проекту на свой)

GNU/Linux:

export PYTHONPATH=$PYTHONPATH:/home/forzend/works/python/articles/app

Windows:

set PYTHONPATH="%PYTHONPATH%;C:\works\python\articles\app"

После чего создаём миграции всё той же командой:

alembic revision -m "first migration" --autogenerate

В итоге alembic создаст файл migrations/versions/<version_number>_message.py, примерное содержание которого следующее:

"""first migration

Revision ID: 0c97763a0d2c
Revises: 
Create Date: 2020-11-29 20:35:01.559832

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '0c97763a0d2c'
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('users',
    sa.Column('id', sa.Integer(), nullable=False),
    sa.Column('is_superuser', sa.Boolean(), server_default=sa.text('false'), nullable=True),
    sa.PrimaryKeyConstraint('id')
    )
    op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=True)
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_index(op.f('ix_users_id'), table_name='users')
    op.drop_table('users')
    # ### end Alembic commands ###

Теперь мы можем спокойно применить миграции:

alembic upgrade head

Однако это придётся прописывать для каждого проекта, а в случае с GNU/Linux и вовсе при создании каждой миграции после перезапуска терминала. Поэтому давайте рассмотрим способы упростить нам жизнь.

Poetry

Poetry — очень мощный и удобный инструмент для управления зависимостями. Я давно его использую и без ума от счастья.

Прочесть о его установке вы можете в официальной документации и без меня, поэтому я этого показывать не буду. При установке обратите внимание, что вам не нужно виртуальное окружение при установке poetry, устанавливать его следует прямо в систему.

После установки poetry нам нужно его инициализировать. Перейдём в корень проекта и выполним следующую команду:

poetry init

После чего poetry попросит у вас ввести некоторые данные, чтобы заполнить конфиг. Я сделал это так:

This command will guide you through creating your pyproject.toml config.

Package name [articles]:  
Version [0.1.0]:  
Description []:  
Author [Your Name <you@example.com>, n to skip]: 
License []:  
Compatible Python versions [^3.8]:  

Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interactively? (yes/no) [yes] no
Generated file

. . .

Do you confirm generation? (yes/no) [yes] 

Я буквально просто везде нажимал enter, оставляя все значения стандартными, вы же можете ввести нужные вам значения. После инициализации poetry создаст лишь один файл. Вы также можете в интерактивном режиме заполнить зависимости, но я предлагаю добавить их после инициализации проекта самостоятельно следующей командой:

poetry add aiogram environs loguru gino alembic psycopg2-binary

Она автоматически пропишет в наш конфиг (pyproject.toml) все зависимости и сгенерирует poetry.lock, файл, необходимый poetry. Никогда не вносите изменения в файл poetry.lock вручную. Все изменения вносятся в файл pyproject.toml, а после применяются командой poetry update.

Перейдём в файл pyproject.toml. Сейчас у вас он выглядит примерно так:

[tool.poetry]
name = "bot"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.8"
aiogram = "^2.11.2"
environs = "^9.2.0"
loguru = "^0.5.3"
gino = "^1.0.1"
alembic = "^1.4.3"
psycopg2-binary = "^2.8.6"


[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

Впишем в [tool.poetry] packages = [{ include = "app" }], это позволит нам не прописывать PYTHONPATH. Блок [tool.poetry] в pyproject.toml с вписанным массивом packages у меня выглядит так:

[tool.poetry]
name = "bot"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
packages = [
    { include = "app" }
]

Теперь выполним команду

poetry install

Она создаст нам виртуальное окружение и установит необходимые зависимости.

Активируем наше окружение командой

poetry shell

Теперь мы можем вернуться к нашим миграциям. Запустим создание миграций через alembic из poetry:

poetry run alembic revision -m "first migration" --autogenerate

и применим миграции на нашу базу:

alembic upgrade head

Мне кажется это значительно удобнее, чем каждый раз определять переменную окружения. К тому же poetry очень удобный и гибкий инструмент, с которым приятно работать. Погрузиться в изучение poetry вы можете в официальной документации.

Использование Make

Пользователи GNU/Linux (ну и если запариться, то и windows), которые слишком стары, чтобы изучать новые технологии, такие как poetry, могут использовать make.

Создадим в корне проекта файл Makefile и приступим к его написанию.

В первую очередь подгрузим в него наши переменные окружения из файла .env:

include .env

Определим переменную PYTHONPATH, для этого возьмём текущий PYTHONPATH и добавим к нему текущую корень проекта (получить его можно с помощью функции shell pwd) и название нашего модуля — /app/:

PYTHONPATH := ${PYTHONPATH}:$(shell pwd)/app/

Для проверки напишем небольшую команду, которая просто поприветствует пользователя, и установим её командой по-умолчанию:

default:help

help:
   @echo "Hello, world!"

После чего попробуем запустить make:

Выполнение команды make

Всё работает, можем продолжать. Добавим четыре команды: для выполнения любой команды alembic; для применения миграций; для создания миграций; для отката миграций.

Итоговый Makefile выглядит так:

include .env

PYTHONPATH := ${PYTHONPATH}:$(shell pwd)/app/

default:help

help:
   @echo "Hello, world!"

alembic:
   PYTHONPATH=$(shell pwd):${PYTHONPATH} alembic ${args}

migrate:
   PYTHONPATH=$(shell pwd):${PYTHONPATH} alembic upgrade head

migration:
   PYTHONPATH=$(shell pwd):${PYTHONPATH} alembic revision --autogenerate -m "${message}"

downgrade:
   PYTHONPATH=$(shell pwd):${PYTHONPATH} alembic downgrade -1

Теперь можем спокойно создавать миграции, применять их и откатывать. Хочу обратить ваше внимание на то, что аргументы передаются как перемененные, например message=init, или message="first migration", если вам нужно передать строку, состоящую из двух или более слов:

Пример работы с make

Docker & docker-compose

Следующая проблема, которая может у вас возникнуть —применение миграций в докере перед запуском бота. В своё время я убил несколько дней на её решение, которое оказалось для меня не самым предсказуемым, однако пойдём по-порядку.

Не будем медлить, и начнём с самого сложного. Чтобы решить проблему с которой я столкнулся, нам понадобится создать небольшой bash-скрипт, который будет применять миграции перед запуском бота. Создадим scripts/docker-entrypoint.sh и наполним его следующим содержимым:

#!/bin/sh

set -e

if [ -n "${RUN_MIGRATIONS}" ]; then
  alembic upgrade head
fi

exec python -O -m app

Здесь мы создаём миграции, если это необходимо, и запускаем наше приложение. Вас может смутить аргумент -O, он передаётся для небольшой оптимизации, не зацикливайтесь на нём.

Dockerfile

Теперь создадим Dockerfile

Я буду использовать готовый образ python:3.8-slim-buster, чтобы контейнер был более легковесным:

FROM python:3.8-slim-buster

Укажем рабочую директорию и укажем нужные нам переменные окружения:

WORKDIR /src
ENV PYTHONPATH "${PYTHONPATH}:/src/"
ENV PATH "/src/scripts:${PATH}"

Мы вписываем /src/scripts в PATH, чтобы была возможность вызывать скрипты только по названию, не вводя полный путь к папке. Дальше копируем всё содержимое папки в контейнер:

COPY . /src

Обновляем pip в контейнере и устанавливаем зависимости (Если вы используете в своём проекте poetry и не хотите оставлять requirements.txt файл исключительно для докера, вы можете создать окружение poetry прямо в докере, как я сделал это в своём шаблоне):

RUN python -m pip install --upgrade pip && pip install -r requirements.txt

Присваиваем нашим скриптам права администратора, чтобы они могли выполняться без sudo:

RUN chmod +x /src/scripts/*

Указываем в качестве точки входа наш bash-скрипт (Я использую имеено entrypoint, а не cmd, чтобы была возможность сделать команды с аргументами командной строки, если будет такая необходимость):

ENTRYPOINT ["docker-entrypoint.sh"]

Итоговый Dockerfile выглядит следующим образом:

FROM python:3.8-slim-buster
WORKDIR /src
ENV PYTHONPATH "${PYTHONPATH}:/src/"
ENV PATH "/src/scripts:${PATH}"
COPY . /src
RUN python -m pip install --upgrade pip && pip install -r requirements.txt
RUN chmod +x /src/scripts/*
ENTRYPOINT ["docker-entrypoint.sh"]

docker-compose

Теперь давайте в корне проекта создадим docker-compose файл и наполним содержимым его:

version: "3"

services:
  db:
    container_name: database
    image: postgres:alpine
    restart: on-failure
    environment:
      POSTGRES_USER: $POSTGRES_USER
      POSTGRES_PASSWORD: $POSTGRES_PASSWORD
      POSTGRES_DB: $POSTGRES_DB
    ports:
      - 5432:5432
    volumes:
    - ./pgdata:/var/lib/postgresql/data

  bot:
    container_name: bot
    build:
      context: .
    restart: on-failure
    env_file:
      - .env
    environment:
      POSTGRES_HOST: db
    depends_on:
      - db

Так же, как и в ситуации с образом питона, в качестве образа для базы данных я использую postgres:alpine, чтобы его установка на сервере требовала меньше времени. Ниже я указываю переменные окружения в качестве переменных для базы данных, пробрасываются порты и data.

Бот собирается из Dockerfile, находящегося в одной директории с docker-compose. Указан env_file: .env, из которого подтянутся все необходимые переменные окружения. Переопределена переменная POSTGRES_HOST: db. Это необходимо для подключения бота к базе данных в контейнере. В зависимостях указана база данных, чтобы бот запускался только после её готовности.

На этом всё, мы можем запускать проект (Аргумент -d запустит его в режиме демона):

docker-compose up -d
Запуск проекта через docker-compose

Завершение

На этом всё. Мы разобрали как реализовать автоматическое создание миграций с помощью инструмента alembic. Также мы познакомились с отличным инструментом для работы с пакетами — poetry. Написали для проекта Makefile, и обернули проект с миграциями в docker.

Если вы используете tortoise-orm, вы можете посмотреть пример реализации миграций в одном из моих github репозиториев.

Статья получилась очень большой, и вы обязательно достигните успехов, если так усидчивы и не бросили её чтение на середине. Я очень благодарен каждому читателю за уделённое время. Весь код есть на github в отдельной ветке.

Report Page