Обычно фреймворк EF Core используют в сочетании с MS SQL — другим продуктом Microsoft. Однако это не догма. Например, мы в CUSTIS пишем бизнес-логику на C#, а для управления базами данных используем Oracle. В EF Core есть замечательный механизм миграций, но в нашем случае они не идемпотентны. Дело в том, что Oracle и ряд других БД, например MySQL, не поддерживают транзакционный DDL. Значит, если миграция упадет где-то посередине, ее не получится ни накатить, ни откатить. Как же реализовать идемпотентные миграции на EF Core без MS SQL?
Предыстория
В нашей компании есть достаточно мощный инструмент для установки патчей на БД Oracle, который мы используем в ряде проектов. Его писали, когда еще не было Liquibase, миграций EF и других открытых инструментов. Патчер позволяет работать с сотней БД, отслеживать историю установок, просматривать логи, хранить секреты и многое другое. Скрипты для изменения БД пишутся в виде SQL- или m4-макросов. С их помощью можно в числе прочего модифицировать структуру: создавать таблицы, колонки и другие объекты. При этом m4-макросы идемпотентны. Это значит, что при повторной попытке создать, например, таблицу скрипт не упадет, а увидит, что она уже существует, и пропустит создание.
Предположим, скрипт по установке патча состоит из двух операций:
- Создание таблицы А.
- Создание таблицы В.
Если скрипт упадет после первой операции, в Oracle останется таблица А. Повторное применение патча отработает корректно: скрипт проверит, что А уже существует, поэтому сразу же перейдет ко второй операции.
Помимо достоинств у патчера все же есть недостаток — инструмент закрытый и используется только в CUSTIS. Разработчикам приходится учиться работать с ним, а за пределами компании такой опыт не очень ценен. Кроме того, патчер не поддерживает режим работы Code First, поэтому все скрипты для изменения структуры БД приходится писать вручную.
Мы хотели попробовать какой-то готовый механизм установки патчей и выбрали миграции. В конце 2019 года как раз стартовал очередной проект для заказчика, на котором мы решили протестировать новый подход. Главной проблемой этого механизма оказалась неидемпотентность миграций.
Проблема
В MS SQL цепочка DDL-операторов или миграция выполняется в виде одной составной транзакции. В случае прерывания операция полностью отменяется. В Oracle DDL нетранзакционный, поэтому падение миграции приведет к неконсистентному состоянию БД.
Вернемся к патчу, состоящему из двух операций: создания таблиц А и B. Если мигратор упадет после первой, в Oracle останется таблица А. Повторный запуск ничего не даст — оператору
CREATE TABLE
не понравится, что А уже существует. Откатить миграцию также не удастся: EF Core пишет в системную таблицу, что миграция выполнена, только в самом конце процесса. С точки зрения EF Core, если миграция еще не завершена, то и откатывать нечего.Решение
Поиск готового решения для Oracle в интернете не дал результатов. Все, что я нашел, — статьи про способы написания и установки патчей при работе с EF. Чуть позже на StackOverflow натолкнулся на идею — сделать свой IMigrationsSqlGenerator. Этот интерфейс отвечает за формирование SQL-кода, обрабатывающего операции EF.
В пакет Oracle.EntityFrameworkCore включен OracleMigrationsSqlGenerator, реализующий IMigrationsSqlGenerator. К примеру, если требуется добавить колонку, будет сгенерирован такой код:
ALTER TABLE MY_TABLE ADD (MY_COLUMN DATE)
Затем код передается в другие классы для запуска в БД.
Для начала я попробовал переопределить пару операций OracleMigrationsSqlGenerator. Задача оказалась вполне посильной, и я приступил к написанию идемпотентного мигратора. Так появился CUSTIS.OracleIdempotentSqlGenerator.
Перед операцией EF наш мигратор проверяет, была ли она выполнена ранее. Например, колонка добавляется так:
DECLARE
i NUMBER;
BEGIN
SELECT COUNT(*) INTO i
FROM user_tab_columns
WHERE table_name = UPPER('MY_TABLE') AND column_name = UPPER('MY_COLUMN');
IF I != 1 THEN
EXECUTE IMMEDIATE 'ALTER TABLE MY_TABLE ADD (MY_COLUMN DATE)';
END IF;
END;
Использование
Использовать пакет очень просто — необходимо лишь подменить
IMigrationsSqlGenerator
в нужном контексте:public class MyDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.ReplaceService<IMigrationsSqlGenerator, IdempotentSqlGenerator>();
}
}
Миграции формируются и устанавливаются стандартными для EF Core средствами:
dotnet ef migrations add v1.0.1
dotnet ef database update
Общий подход, заложенный в CUSTIS.OracleIdempotentSqlGenerator, может быть реализован в генераторах, написанных для MySQL, MariaDB, Teradata, AmazonAurora и других БД, в которых DDL не является транзакционным.
Ссылки
Пакет доступен в NuGet
Исходники на GitHub