В EF Core много полезных фич по работе с базами данных, но что, если этих возможностей не хватает? Я был удивлен, когда узнал, что фреймворк из коробки не умеет создавать вьюшки и отслеживать изменения их исходного кода. А что, если нам нужны не только вьюшки, но еще и синонимы, гранты и DB link? При этом мы хотим видеть их как на производственной БД, так и в интеграционных тестах! В посте будет инфа про загадочный внутренний мир фреймворка: про ключевые интерфейсы, отвечающие за генерацию и применение миграций, про то, как можно подменить эти интерфейсы, и, самое главное, почему тут не поможет контейнер, создаваемый в Startup. Также поговорим про основные объекты EF Core: что такое модель и зачем нужен снепшот? Из чего состоит миграция и зачем нужно транслировать операции в SQL? Пост будет интересен как тем разрабам, которые столкнулись с задачами создания и обновления вьюх, синонимов и других SQL-объектов (они узнают про наш пакет, позволяющий закрыть эти вопросы), так и тем, кто хочет написать свое расширение (они узнают про подмену сервисов). Если Вы хотите, чтобы мир EF Core стал для вас менее загадочным, но ничуть не менее интересным, добро пожаловать под кат ↓ В работе над одним из проектов мы столкнулись, казалось бы, с тривиальной задачей: в БД нужна вьюха. Что может быть проще? Хорошо, но что, если мы хотим отслеживать историю изменений вьюхи? Давайте сделаем константу, в которой сохраним исходники вьюхи, и во всех миграциях будем ссылаться на нее. Подождите, но теперь все миграции будут ссылаться на текущую (последнюю) версию вьюхи, а это уже проблема. Пусть в первой миграции мы создаем таблицу и вьюху, а во второй — добавляем колонку в оба объекта. Если на прод всё это поедет в одной версии, то в первой миграции будет попытка создать вьюху, которая ссылается на колонку, которая еще не существует! В некоторых БД такое сработает, а в некоторых — нет. Плюс с таким подходом теряется возможность установить конкретную версию модели на БД (там все равно окажется последняя версия вьюхи, а не та, которая была в момент написания миграции). Давайте сформулируем все требования, которые у нас есть по использованию вьюх в EF Core:  мы хотим уметь отслеживать изменения вьюхи (кто, когда и зачем там что-либо менял); миграции формируются стандартным образом (migrations add), т. е. EF Core «видит» появление, изменение или удаление вьюх и сам генерит UP и DOWN миграции; в БД вьюхи должны попадать тем способом, который хочет использовать конечный программист: – это может быть SQL-скрипт (migrations script),– либо обновление БД из командной строки (database update),– а также вызов Database.Migrate() из кода,– и даже Database.EnsureCreated(), что очень полезно в интеграционных тестах.    Выше мы говорили про вьюхи. Но что принципиально изменится, если вместо вьюхи нам потребуется синоним? Или хранимка? Или любой другой объект, который может быть описан посредством сырого SQL? Верно, ничего не изменится, поэтому будем решать общую задачу, в терминах сырых SQL-объектов. Давайте начнем с простого — с объектной модели EF Core. Ключевым понятием фрейморка является модель, которая описывает сущности (Entity Class, см. картинку ниже), их взаимосвязи и маппинг на БД. За создание модели отвечает DbContext. Снепшот (ModelSnapshot) — это своего рода фотография модели в некоторый момент времени. Зачем он нужен?  Модель с течением времени меняется. Чтобы знать, какие изменения произошли, EF Core при создании миграции (Add-Migration) сравнивает текущую модель со снепшотом. Миграция описывает некоторый инкремент, который надо совершить, чтобы переместиться из предыдущего снепшота в новую версию модели. Миграция может быть применена к базе данных разными способами, например, при помощи генерации и дальнейшего запуска SQL-скрипта (см. Script-Migration на картинке выше), при помощи непосредственного применения (Update-Database). Есть и другие способы. При создании миграции снепшот обновляется, так что в следующий раз EF Core будет сравнивать модель уже с новым снепшотом. Миграция в понятиях EF Core является атомарной, т. е. она либо полностью применяется, либо не применяется вообще. Кстати, не во всех БД с этим все хорошо, подробности можно узнать в статье про идемпотентность миграций. Каждая миграция состоит из операций, которые описывают изменение одного из объектов: добавление колонки, изменение названия таблицы и т. д. Операция описывает изменения в БД в терминах C#. Поскольку одно и то же изменение БД у разных вендоров описывается разным SQL-кодом, то требуется трансляция операции в SQL-код. Мы хотим, чтобы EF Core отслеживал изменения не только таблиц и их колонок, но еще и каких-то других объектов (в нашем случае — сырых SQL-объектов). По сути, нам надо как-то расширить модель EF Core и заставить фреймворк добавлять в модель, снепшот и миграции что-то еще. Создатели фреймворка хранят инфу обо всем, что не связано с сущностями, в аннотациях. Например, в аннотациях хранятся данные о последовательностях (sequence), функциях БД (DbFunctions) и др. Что же, последуем их примеру. Покажем, как можно расширить модель на примере SQL объектов. Для описания объекта будем использовать record SqlObject(string Name, string SqlCode). Как кажется, смысл полей предельно ясен. Name — для хранения имени, в БД это имя никак не пробрасывается. Имя необходимо, чтобы пакет понимал, появился ли какой-то новый объект? А может, он был изменен или удален? SqlCode — код для создания и обновления объекта (CREATE OR REPLACE VIEW AS…) Вернемся к аннотациям. Модель создается внутри DbContext.OnModelCreating Добавление наших объектов в аннотации выглядит вот так: Выше мы разобрались, как добавить в модель что-то свое. Но как добавить описание этих объектов в снепшот? Как научить EF Core сравнивать наши объекты и определять, нужна ли по ним миграция? Как, в конце концов, добавить в миграцию код, который внесет нужные нам изменения в БД? Начну издалека — разберемся, какие в EF Core есть контейнеры и почему. При расширении EF надо понимать, что стандартный контейнер (из Startup.ConfigureServices) для работы фреймворка не обязателен: в нем EF Core хранит DbContextOptions, он же используется для создания или освобождения DbContext. Однако делать это при помощи контейнера не обязательно — подойдет любой удобный для программиста способ (например, ручное создание и освобождение DbContext). Стандартный контейнер, таким образом, почти не влияет на работу EF Core, в то время как внутренний контейнер, по сути, определяет поведение фреймворка. Именно он отвечает за резолвинг сервисов, необходимых для работы EF Core (генерация снепшота, миграций и пр.). Этот контейнер, как правило, представляет для программиста черный ящик, это детали реализации. Наверное, вы не удивитесь, прочитав, что фреймворк работает в двух режимах: Runtime и DesignTime. Однако я был потрясен, узнав, что наполнение контейнеров этих двух режимов отличается! Это логично, но достаточно тяжело для первоначального восприятия. Runtime — это обычный режим, он используется во время работы приложения: EF Core читает и записывает какие-то данные. DesignTime — это режим работы для создания миграций (когда разработчик работает с EF из командной строки). Интересно, что применение миграций может осуществляться как в DesignTime (database update), так и в Runtime (Database.EnsureCreated() или Database.Migrate()). Очевидно, что в Runtime нужны не все сервисы, от которых зависит DesignTime, поэтому создание и наполнение контейнеров в этих двух режимах отличается. Интересно, что во внутреннем контейнере создается scope каждый раз, когда создается DbContext, при этом scope освобождается (Dispose) вместе с DbContext. В таблице ниже представлено описание контейнеров.  Стандартный  Внутренний Runtime Не обязателен для EF Core. Используется для хранения DbContextOptions и своевременного создания или уничтожения DbContext Обеспечивает работу EF Core в Runtime (например, здесь есть сервисы, необходимые для выполнения команд, получения connectionString и т. п.) DesignTime Обеспечивает работу EF Core в DesignTime (например, генерация миграций, снепшотов и т. д.) Хорошо, с теорией контейнеров разобрались. Стандартный контейнер оставим «как есть», а во внутреннем нам надо сделать подмену ряда сервисов. Как это осуществить? Начнем с Runtime-контейнера. Функция dbOptionsBuilder.ReplaceService() позволяет заменить один из EF Core сервисов на нашу имплементацию. Метод можно вызвать внутри services.AddDbContext либо DbContext.OnConfiguring. Вот пример: Но как быть, если наша имплементация сервиса зависит от каких-то кастомных сервисов? Например, CustomImplementation зависит от IMyNewService, а это интерфейс из нашего пакета? EF Core про него ничего не знает, dbOptionsBuilder.ReplaceService не сработает! Один из вариантов здесь — использовать DbContextOptionsBuilder.UseInternalServiceProvider(serviceProvider), где serviceProvider – это сконфигурированный вручную внутренний контейнер EF Core. Главный минус такого способа в том, что serviceProvider надо правильно настроить с нуля (добавить туда все нужные для EF Core сервисы), а это нетривиальная задача. К тому же, при переходе на новую версию EF Core состав контейнера может измениться, и придется изменять код, наполняющий контейнер. Более красивый способ — использование EF Core Extensions. Мы можем написать реализацию IDbContextOptionsExtension, которая содержит метод ApplyServices(IServiceCollection services). Параметр services здесь — это сервисы внутреннего контейнера. Добавление новых зависимостей больше не проблема: А чтобы добавить наше расширение достаточно вызвать: Выше дана информация об изменении Runtime контейнера, пришло время рассказать о DesignTime. Там дела обстоят немного по-другому. DesignTime контейнер представляет собой надмножество Runtime контейнера, т. е. он содержит в себе все Runtime-сервисы плюс некоторый дополнительный набор (см. картинку ниже). Подмена и добавление Runtime-сервисов делается стандартными для этого режима способами (см. выше), а вот для подмены и добавления DesignTime-сервисов используется отдельный механизм. В нашем расширении для изменения сервисов мы использовали реализацию специального интерфейса IDesignTimeServices. Важно, чтобы реализация была размещена в startup assembly (размещение в migrations assembly не допускается): Другой способ сконфигурировать DesignTime-контейнер — использовать . Атрибут может быть размещен на startup либо на migrations-сборке. На вход атрибут принимает имя типа, реализующего уже знакомый нам IDesignTimeServices. В этом разделе перечислены те сервисы, которые мы в нашем расширении подменили во внутреннем контейнере EF Core, а именно: в режиме Runtime:– (1) IMigrationsModelDiffer — находит отличия двух моделей (нам нужно научиться находить различия в SQL-объектах до и после внесения изменений);– (2) IMigrationsSqlGenerator — транслирует операции в SQL-код (в нашем случае здесь всё просто, поскольку объекты уже описаны в терминах SQL); в режиме DesignTime:– (3) ICSharpSnapshotGenerator — генерирует снепшот;– (4) IMigrationsCodeGenerator — генерирует код миграций;– (5) ICSharpMigrationOperationGenerator — генерирует код операций внутри миграции. Место каждого из сервисов в работе EF Core представлено на картинке: (1) IMigrationsModelDiffer отвечает за определение дифа между снепшотами. Мы расширили его реализацию, научив определять изменения, произошедшие в составе и содержании SQL-объектов. На вход данный интерфейс получает бывшую и целевую модели, на выходе порождает операции миграции (в нашем случае операции создания, изменения и удаления SQL-объектов). Кроме прочего, реализация IMigrationsModelDiffer должна генерировать операции в правильной последовательности (вначале удаление SQL-объектов, затем обычное создание или изменение схемы, затем создание или изменение SQL-объектов). Поэтому там перегружен еще и метод Sort. (2) IMigrationsSqlGenerator отвечает за трансляцию полученных выше операций в SQL. В нашем случае используются CreateOrUpdateSqlObjectSqlGenerator и DropSqlObjectSqlGenerator: наша трансляция предельно простая — SQL уже готов, просто возвращаем его. (3) ICSharpSnapshotGenerator отвечает за генерацию снепшотов и содержит единственный метод Generate, который всего-то на всего добавляет нужный код в StringBuilder. Наш CustomSnapshotGenerator генерирует для YourContextModelSnapshot код наподобие: Подмена (4) IMigrationsCodeGenerator нужна, чтобы в класс миграции были добавлены нужные пространства имен. (5) ICSharpMigrationOperationGenerator занимается предельно простой работой — по данным, полученным из (1) генерирует операции миграций. Здесь, как и в случае с генерацией снепшота, ничего сложного нет — обычный StringBuilder. На выходе получаем такие миграции: Как видно, пакет не умеет генерировать код для удаления SQL-объектов, но с этим, думаю, сложностей не будет. Описание такой операции требуется один раз 😊 Мы опубликовали расширение, добавляющее сырые SQL-объекты в EF Core в пакете CUSTIS.NetCore.EF.MigrationGenerationExtensions, который доступен по лицензии Apache-2.0.  Шаги, которые необходимо выполнить для начала использования пакета, описаны на GitHub. Чтобы добавить SQL-объекты в модель, необходимо вызвать modelBuilder.AddSqlObjects в OnModelCreating. Пакет позволяет добавлять объекты не только из C#-кода, но и из ресурсов, что особенно удобно: в SQL-файлах VisualStudio подсвечивает синтаксис. После добавления SQL-объектов, используйте стандартные способы создания миграций (например, dotnet ef migrations add InitialCreate). В миграцию попадет снимок кода в том виде, в котором SQL-объект существовал при добавлении миграции: Когда понадобится, вносите изменения в SQL-объекты напрямую, EF Core «увидит» ваши изменения при добавлении следующей миграции. В следующую миграцию в UP попадет новая версия кода, а в DOWN будет предыдущая версия: CUSTIS.NetCore.EF.MigrationGenerationExtensions решает задачи удобства и прозрачности использования SQL-объектов, а именно: Изменения SQL-объектов вносятся в одном месте (и можно отследить их историю); SQL-объекты накатываются на БД тем способом, который нужен разработчику. Будем рады вашим pull-request и использованию пакета! Изначально идея расширения EF Core возникла у нас, когда в одном из проектов в миграциях понадобились гранты, синонимы, вьюхи и DB link. В том проекте на каждый из этих объектов была создана своя структура, описывающая его, и соответствующий транслятор из терминов .NET в SQL (для Oracle). Вот пример: Как видно, в данном случае программисту не требуется писать SQL-код, за него это делает транслятор. С момента реализации того проекта прошло больше года, и в этом году в другом проекте мы столкнулись с подобной задачей (понадобились вьюхи). Мы решили написать максимально простой пакет, который позволит добавлять любые SQL-объекты в БД. Можно подумать, что пакет не зависит от вендора БД? Не тут-то было ☹️  К сожалению, с точки зрения EF Core наше расширение должно уметь транслировать операции в SQL. Да, в нашем случае это просто возврат готового кода, но фреймворк-то думает, что мы для какой-то конкретной БД это делаем! К счастью, написать свое расширение для любимой БД проще простого. Поэтому ждем pull-request от желающих поддержки своих БД. На текущий момент пакет имеет поддержку только для Postgres. Подробнее про контейнеры читайте в статье Dependency Injection in EF Core 1.1. Информация про написание своих расширений есть в Расширенной настройке EF Core. До новых встреч!  Программист