Ef Core

Управління Схемою та Database-First (Частина 1)

[object Object]

Управління Схемою та Database-First

Хто має право голосу: код чи база?

Будь-який застосунок що працює з реляційною базою даних стоїть перед фундаментальним ArchitectureDecision: хто є джерелом правди про схему?

Це питання не технічне — воно про процеси, команди і відповідальність.

В одних організаціях є окрема команда DBA (Database Administrators), що пишуть схему вручну, оптимізують індекси, контролюють типи стовпців. Розробники подають запити на зміни, DBA оцінюють вплив і реалізують. Схема в базі — первинна. Код лише відображає її.

В інших організаціях є agile-команди де розробники і є архітекторами схеми. Вони думають у термінах доменних об'єктів, складають С# класи і дозволяють EF Core генерувати схему. Бізнес-логіка і структура даних розвиваються разом — в одному pull request.

Обидва підходи мають право на існування. Проблема виникає коли команда не розуміє чим відрізняються ці підходи і застосовує інструменти «не за призначенням».


Три підходи до моделювання

Code-First: модель народжується у коді

Code-First — ви пишете C# класи (entity), конфігуруєте їх через Fluent API або Data Annotations, а EF Core генерує DDL-схему на їх основі. Міграції відображають зміни у коді.

C# Entities   →   EF Core   →   Migrations   →   Database Schema
  (джерело)     (трансляція)   (версіонування)     (результат)

Де сильний:

  • Developer-centric команди, нові проєкти
  • Domain-Driven Design — entity є доменними об'єктами
  • Часті ітерації: бізнес-вимоги змінюються раз у два тижні
  • Один розробник або маленька команда без окремого DBA

Де слабкий:

  • Легко забути створити критичний індекс
  • Складні схеми (партиціонування, columnstore indexes) важко виразити через Fluent API
  • DBA не мають прямого контролю

Database-First: модель народжується в базі

Database-First — DBA або існуюча система визначає схему SQL. EF Core генерує C# entity і DbContext з існуючої БД через scaffold-dbcontext. Розробник отримує готові класи.

Database Schema   →   scaffold-dbcontext   →   C# Entities + DbContext
   (джерело)           (генерація)               (результат)

Де сильний:

  • Legacy системи: база існує роками, перейшли з ADO.NET на EF Core
  • DBA-орієнтовані організації
  • Складні схеми з тонкою оптимізацією (партиції, columnstore, специфічні типи)
  • Кілька застосунків поділяють одну БД

Де слабкий:

  • Регенерація при зміні схеми → ручні правки втрачаються
  • Генерований код часто не відповідає доменній мові
  • Важче підтримувати доменну логіку у entity

Model-First: схема народжується у діаграмі

Model-First — використання візуальних дизайнерів (Entity Data Model Designer у старих версіях Visual Studio) для креслення схеми, з подальшою генерацією і коду і БД. EF Core цей підхід вже не підтримує — він є спадщиною EF 6 і формату .edmx.

Model-First у EF Core не існує. *.edmx файли — формат EF 6 і старіших версій. Якщо ви мігруєте з EF 6 на EF Core — ваш .edmx доведеться переписати у Code-First або Database-First підхід.

Матриця вибору підходу

🟢 Новий проєкт, agile команда

Code-First. Домен і схема розвиваються разом. Розробники повністю контролюють модель. EF Core Migrations для версіонування.

🟡 Legacу база, новий застосунок

Database-First. Scaffold-DbContext щоб отримати відправну точку. Потім Partial Classes для доменної логіки. Re-scaffold при зміні БД.

🔵 DBA-контрольована організація

Database-First + Міграції. DBA пишуть SQL міграції, розробники регенерують entity після кожного release. Або DBA-скрипти через migrationBuilder.Sql().

🔴 Кілька застосунків, одна БД

Database-First для всіх, або Code-First лише для відповідального сервісу. Інші читають з БД і регенеруються.

EnsureCreated vs Migrate: критична різниця

Перш ніж переходити до scaffold — розберемо фундаментальну різницю між двома підходами до ініціалізації бази. Це одна з найчастіших точок плутанини.

Database.EnsureCreated(): для прототипів і тестів

EnsureCreated() перевіряє чи існує база даних і якщо ні — створює її на основі поточної EF Core моделі, без жодних міграцій:

await context.Database.EnsureCreatedAsync();
// Якщо БД не існує → CREATE DATABASE + створює всі таблиці
// Якщо БД існує → нічого не робить (навіть якщо схема застаріла!)

Коли правильно использовувати:

  • Unit і Integration тести з SQLite InMemory або temp database
  • Локальне прототипування де схема часто скидається
  • Розробка нових фіч з EnsureDeleted() + EnsureCreated() cycle

Чому НІКОЛИ не використовувати у production:

// ❌ Антипатерн у production:
await context.Database.EnsureCreatedAsync();
// Проблема 1: Якщо БД існує але застаріла — нічого не відбувається!
// Проблема 2: Таблиця __EFMigrationsHistory НЕ створюється
// Проблема 3: Перехід на Migrate потім неможливий без ручного втручання
EnsureCreated() і Migrate() є несумісними. Якщо ви почали з EnsureCreated(), таблиця __EFMigrationsHistory не існує. Перехід на міграції потребуватиме ручного baseline — навчити EF Core що «всі міграції вже застосовані» без реального їх виконання.

Database.MigrateAsync(): для production

MigrateAsync() застосовує всі Pending міграції — саме те що потрібно у production (детально розглянуто у статті 23).

EnsureDeleted + EnsureCreated: для тестів

// Ідіома для тестів: кожен тест-запуск — чиста база
public class TestDbFixture : IDisposable
{
    public AppDbContext Context { get; }

    public TestDbFixture()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql("Host=localhost;Database=shop_test;...")
            .Options;

        Context = new AppDbContext(options);

        // Скидаємо і відновлюємо схему
        Context.Database.EnsureDeleted();
        Context.Database.EnsureCreated();
    }

    public void Dispose() => Context.Dispose();
}
EnsureCreatedMigrateAsync
Створює БД якщо немає
Застосовує зміни до існуючої
Створює __EFMigrationsHistory
Сумісний з Migrations
Для production
Для тестів⚠️

Database-First: Scaffold-DbContext

scaffold-dbcontext — команда dotnet ef що підключається до існуючої бази і генерує C# entity класи та DbContext. Це точка входу у Database-First workflow.

Передумови

Крім стандартних EF Core пакетів потрібні:

# Встановити design-time інструмент
dotnet tool restore  # або dotnet tool install --global dotnet-ef

# NuGet: обов'язково наявний Microsoft.EntityFrameworkCore.Design

Базовий синтаксис

dotnet ef dbcontext scaffold \
    "Server=.;Database=ShopDb;Trusted_Connection=True" \
    Microsoft.EntityFrameworkCore.SqlServer \
    --output-dir Models \
    --context-dir Data \
    --context AppDbContext

Параметри цієї команди:

dotnet ef dbcontext scaffold [connection] [provider] [options]

[connection]  Connection string або ім'я з appsettings.json (--configuration ...)
[provider]    NuGet провайдер: Microsoft.EntityFrameworkCore.SqlServer
                               Npgsql.EntityFrameworkCore.PostgreSQL
                               Microsoft.EntityFrameworkCore.Sqlite

Options:
  -o, --output-dir      Директорія для entity класів (відносно проєкту)
  --context-dir         Директорія для DbContext файлу
  -c, --context         Ім'я DbContext класу
  --namespace           Namespace для entity
  --context-namespace   Namespace для DbContext
  -f, --force           Перезаписати наявні файли (для re-scaffolding)
  -t, --table           Scaffold лише вказані таблиці
  --schema              Scaffold лише вказану схему
  --no-onconfiguring    Не генерувати OnConfiguring (connection string не у класі)
  --no-pluralize        Не плюралізувати імена DbSet
  --use-database-names  Зберігати оригінальні імена таблиць/стовпців (не PascalCase)
  -d, --data-annotations Використовувати Data Annotations замість Fluent API

Приклад: повна реальна команда

dotnet ef dbcontext scaffold \
    "Name=ConnectionStrings:DefaultConnection" \     # з appsettings.json
    Microsoft.EntityFrameworkCore.SqlServer \
    --output-dir Infrastructure/Entities \
    --context-dir Infrastructure \
    --context ShopDbContext \
    --namespace YourApp.Infrastructure.Entities \
    --context-namespace YourApp.Infrastructure \
    --no-onconfiguring \                             # connection string не у класі!
    --force                                          # перезаписати при re-scaffold
scaffold-dbcontext output
$ dotnet ef dbcontext scaffold "Server=.;Database=ShopDb;Trusted_Connection=True" Microsoft.EntityFrameworkCore.SqlServer --output-dir Models --context AppDbContext --force
Build started...
Build succeeded.
To protect potentially sensitive information in your connection string, you should move it out of source code.
You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration.
Scaffolding DbContext...
Models/Category.cs
Models/Product.cs
Models/Order.cs
Models/OrderLineItem.cs
AppDbContext.cs
Done.

Фільтрація: конкретні таблиці або схеми

Великі legacy бази мають сотні таблиць. Scaffold всіх — не потрібно. Фільтруйте:

# Scaffold лише конкретних таблиць
dotnet ef dbcontext scaffold [connection] [provider] \
    --table Products \
    --table Categories \
    --table Orders

# Scaffold всіх таблиць конкретної схеми
dotnet ef dbcontext scaffold [connection] [provider] \
    --schema sales \
    --schema inventory

# Комбінація: схема + конкретні таблиці з іншої схемы
dotnet ef dbcontext scaffold [connection] [provider] \
    --schema sales \
    --table dbo.Products \
    --table dbo.Categories

Анатомія згенерованого коду

Розберемо що генерує scaffold-dbcontext для типової таблиці Products.

Вихідна таблиця у SQL Server

CREATE TABLE Products (
    Id           INT           NOT NULL IDENTITY(1,1),
    Name         NVARCHAR(200) NOT NULL,
    Price        DECIMAL(10,2) NOT NULL,
    Description  NVARCHAR(MAX) NULL,
    IsActive     BIT           NOT NULL DEFAULT(1),
    CategoryId   INT           NOT NULL,
    CreatedAt    DATETIME2     NOT NULL DEFAULT(GETUTCDATE()),
    CONSTRAINT PK_Products PRIMARY KEY (Id),
    CONSTRAINT FK_Products_Categories FOREIGN KEY (CategoryId)
        REFERENCES Categories(Id),
    CONSTRAINT CK_Products_Price CHECK (Price >= 0)
);

CREATE INDEX IX_Products_CategoryId ON Products(CategoryId);
CREATE UNIQUE INDEX UX_Products_Name_CategoryId ON Products(Name, CategoryId);

Згенерований entity (Fluent API, за замовчуванням)

// Models/Product.cs — згенерований EF Core scaffold
public partial class Product            // partial ← ключове слово!
{
    public int      Id          { get; set; }
    public string   Name        { get; set; } = null!;  // NOT NULL → = null!
    public decimal  Price       { get; set; }
    public string?  Description { get; set; }           // NULL → nullable string?
    public bool     IsActive    { get; set; }
    public int      CategoryId  { get; set; }
    public DateTime CreatedAt   { get; set; }

    // Навігаційна властивість (FK до Categories)
    public virtual Category Category { get; set; } = null!;

    // Зворотна навігація (якщо є зв'язки від Product до інших таблиць)
}

Зверніть увагу на partial class

Scaffold генерує partial class! Це принципово важливо. partial дозволяє розширити клас в окремому файлі без ризику втрати розширень при re-scaffold. Ми розглянемо це детально у наступному розділі.

Згенерований DbContext

// AppDbContext.cs — згенерований
public partial class AppDbContext : DbContext
{
    public AppDbContext() { }

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }

    public virtual DbSet<Category> Categories { get; set; }
    public virtual DbSet<Product>  Products   { get; set; }
    public virtual DbSet<Order>    Orders     { get; set; }

    // УВАГА: якщо не використовували --no-onconfiguring
    // connection string потрапить сюди (витік секретів у код!)
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            // ⚠️ НЕ хочемо щоб connection string був тут у production!
            optionsBuilder.UseSqlServer("Server=.;Database=ShopDb;...");
        }
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(e => e.Id);

            entity.HasIndex(e => e.CategoryId, "IX_Products_CategoryId");
            entity.HasIndex(e => new { e.Name, e.CategoryId }, "UX_Products_Name_CategoryId")
                  .IsUnique();

            entity.Property(e => e.Name).HasMaxLength(200);
            entity.Property(e => e.Price).HasPrecision(10, 2);
            entity.Property(e => e.IsActive).HasDefaultValue(true);
            entity.Property(e => e.CreatedAt)
                  .HasDefaultValueSql("getutcdate()");

            entity.HasCheckConstraint("CK_Products_Price", "[Price] >= 0");

            entity.HasOne(e => e.Category)
                  .WithMany(e => e.Products)
                  .HasForeignKey(e => e.CategoryId)
                  .OnDelete(DeleteBehavior.ClientSetNull)
                  .HasConstraintName("FK_Products_Categories");
        });

        // ... інші entity конфігурації
    }
}

Fluent API vs Data Annotations: що генерується за замовчуванням

За замовчуванням scaffold-dbcontext генерує Fluent API конфігурацію у OnModelCreating. Це правильний вибір — Fluent API є виразнішим і підтримує більше опцій.

З прапором --data-annotations — частина конфігурації переміщується до атрибутів entity:

dotnet ef dbcontext scaffold [connection] [provider] --data-annotations
// З --data-annotations: атрибути у entity
[Table("Products")]
public partial class Product
{
    [Key]
    public int Id { get; set; }

    [Required]
    [StringLength(200)]
    public string Name { get; set; } = null!;

    [Precision(10, 2)]
    public decimal Price { get; set; }

    // Навігаційна властивість залишається у Fluent API
    // (деякі конфігурації неможливо виразити через атрибути)
}
При використанні --data-annotationsне всі конфігурації переходять у атрибути — складні речі (Check Constraints, Default SQL values, складні індекси) залишаються у OnModelCreating. Тому Fluent API підхід (за замовчуванням) є більш послідовним і передбачуваним.

T4 Templates: кастомізація генерованого коду

T4 (Text Template Transformation Toolkit) — шаблонний рушій Microsoft що використовується EF Core для генерації коду при scaffold. Ви можете замінити стандартні шаблони своїми, щоб отримати якісніший, більш доменно-орієнтований код.

Коли T4 Templates рятують

Уявіть legacy база з 300 таблицями. Стандартний scaffold генерує 300 entity — всі partial class, всі базово правильні. Але:

  • Імена таблиць у snake_case (product_categories), а ми хочемо PascalCase entity (ProductCategory)
  • Хочемо щоб кожен entity реалізував IEntity<TKey> інтерфейс
  • Хочемо автоматично додавати [JsonIgnore] до певних навігаційних властивостей
  • Хочемо record замість class для деяких entity

Без T4 — правити 300 файлів вручну. З T4 — правити один шаблон.

Встановлення EF Core Design-Time Templates пакету

Починаючи з EF Core 7, шаблони можна кастомізувати через dotnet ef dbcontext script або спеціальний пакет:

dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet ef dbcontext scaffold --help  # переконатись що tools встановлено

Для T4-шаблонів потрібен окремий пакет:

dotnet add package Microsoft.EntityFrameworkCore.Templates --version 9.0.*

Генерація стандартних шаблонів

# Генерує .t4 шаблони у поточній директорії для редагування
dotnet ef dbcontext scaffold --t4-templates
# АБО через спеціальну команду (залежить від версії tools):
dotnet ef dbcontext scaffold ... --use-t4

Після виконання у папці CodeTemplates/EFCore/ з'являються:

CodeTemplates/
└── EFCore/
    ├── DbContext.t4       ← шаблон для DbContext
    └── EntityType.t4      ← шаблон для entity class

Анатомія EntityType.t4

<#@ template hostSpecific="true" #>
<#@ assembly name="Microsoft.EntityFrameworkCore.Design" #>
<#@ parameter name="Model" type="Microsoft.EntityFrameworkCore.Metadata.IEntityType" #>
<#@ parameter name="Options" type="Microsoft.EntityFrameworkCore.Scaffolding.ModelCodeGenerationOptions" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="Microsoft.EntityFrameworkCore" #>
<#@ import namespace="Microsoft.EntityFrameworkCore.Design" #>
<#
    // Логіка C# тут, у <# ... #> блоках
    var className = Model.GetTableName(); // отримати ім'я таблиці
#>
// <auto-generated />
namespace <#= Options.ModelNamespace #>;

/// <summary>
/// Represents the <#= Model.GetTableName() #> table.
/// </summary>
public partial class <#= Model.Name #> : IEntity<int>  // ← додаємо інтерфейс!
{
<# foreach (var property in Model.GetProperties()) { #>
    public <#= property.ClrType.Name #><#= ... #> <#= property.Name #> { get; set; }
<# } #>
}

Практичний приклад: кастомний шаблон entity

// CodeTemplates/EFCore/EntityType.t4 (спрощено)
<#@ template hostSpecific="true" #>
<#@ output extension=".cs" #>
// <auto-generated> This file was auto-generated by EF Core scaffolding. </auto-generated>
// ⚠️ DO NOT EDIT DIRECTLY — use Partial Classes for extensions
using System.ComponentModel.DataAnnotations;
using YourApp.Core.Interfaces;

namespace <#= Options.ModelNamespace #>;

/// <summary>
/// Entity class for table [<#= schema #>].[<#= tableName #>].
/// Generated: <#= DateTime.UtcNow.ToString("yyyy-MM-dd") #>
/// </summary>
[Table("<#= tableName #>", Schema = "<#= schema #>")]
public partial class <#= entityName #>
<# if (hasAuditFields) { #>
    : IAuditableEntity
<# } #>
{
    // Generated properties...
<# foreach (var prop in properties) { #>
    /// <summary><#= prop.Comment ?? prop.Name #></summary>
    public <#= prop.TypeName #> <#= prop.Name #> { get; set; }<#= prop.DefaultInit #>
<# } #>

    // Navigation properties
<# foreach (var nav in navigations) { #>
    public virtual <#= nav.TypeName #> <#= nav.Name #> { get; set; } = null!;
<# } #>
}
Реалістична порада по T4: кастомізація T4 шаблонів — це вкладення часу. Виправдано коли у вас 50+ таблиць і re-scaffold буде відбуватись часто. Для 5-10 таблиць — простіше використати Partial Classes.

Практичні завдання (Частина 1)

Рівень 1 — Базовий

Завдання 1.1: Перший Database-First проєкт

  1. Створіть базу ShopDb з таблицями: Categories (Id, Name, Description), Products (Id, Name, Price, CategoryId FK, IsActive BIT DEFAULT 1), Suppliers (Id, Name, Email UNIQUE)
  2. Виконайте scaffold: dotnet ef dbcontext scaffold ... --output-dir Models --context ShopDbContext --no-onconfiguring
  3. Порівняйте згенерований Entity з вашою SQL таблицею:
    • Чи правильно замаплені типи? (BIT → bool, DECIMAL → decimal)
    • Чи є навігаційні властивості для FK?
    • Де знаходиться конфігурація DEFAULT і UNIQUE?
  4. Відкрийте ShopDbContext.cs — знайдіть OnModelCreating. Скільки рядків конфігурації?

Завдання 1.2: EnsureCreated vs MigrateAsync

Напишіть два тести:

  • Test A: EnsureCreated → додайте новий стовпець у SQL → EnsureCreated знову → перевірте чи стовпець з'явився (Спойлер: ні)
  • Test B: MigrateAsync + Code-First міграція → додайте стовпець у C# → нова міграція → MigrateAsync → перевірте чи стовпець є

Поясніть різницю у поведінці.

Завдання 1.3: Фільтрована генерація

Ваша legacy база має 50 таблиць: 10 бізнес-таблиць і 40 системних. Scaffold лише бізнес-таблиці через --table. Перевірте що:

  • Генеруються лише зазначені таблиці
  • Навігаційні властивості до відфільтрованих таблиць відсутні або nullable

Рівень 2 — Логіка

Завдання 2.1: --data-annotations vs Fluent API порівняння

Виконайте scaffold двічі:

  1. Без флагів (Fluent API) → збережіть у Models/FluentApi/
  2. З --data-annotations → збережіть у Models/Annotations/

Порівняйте результати:

  • Які конфігурації перейшли у атрибути?
  • Які залишились у OnModelCreating?
  • Яка версія читабельніша?
  • Яка підтримує більше опцій?

Завдання 2.2: --no-onconfiguring та налаштування через DI

Отримайте scaffold з --no-onconfiguring. Налаштуйте DbContext через:

  1. Program.cs: AddDbContext<ShopDbContext>(options => options.UseSqlServer(...))
  2. appsettings.json: "ConnectionStrings": { "DefaultConnection": "..." }
  3. IDesignTimeDbContextFactory<ShopDbContext>: для dotnet ef команд без запуску Api

Рівень 3 — Архітектура

Завдання 3.1: Кастомний T4 Template

Реалізуйте кастомний EntityType.t4 що:

  1. Додає /// <summary> коментарі (з db_comment якщо є, інакше ім'я таблиці)
  2. Додає [Obsolete] до entity чиї таблиці мають суфікс _Legacy або _Old
  3. Реалізує IAuditableEntity якщо entity має поля CreatedAt і UpdatedAt
  4. Додає // <auto-generated> заголовок

Перевірте: після scaffold --force всі entity відповідають новому шаблону.


Підсумок частини 1

  • Три підходи: Code-First (C# → БД), Database-First (БД → C#), Model-First (застарів у EF Core)
  • EnsureCreated vs MigrateAsync: несумісні стратегії. EnsureCreated — для тестів і прототипів. MigrateAsync — для production
  • Scaffold-DbContext: підключається до існуючої БД і генерує partial class entity та DbContext. Параметр --no-onconfiguring запобігає витоку connection string
  • Анатомія: partial class entity (властивості + навігаційні), DbContext з OnModelCreating (Fluent API конфігурації)
  • T4 Templates: Microsoft.EntityFrameworkCore.Templates, файли CodeTemplates/EFCore/EntityType.t4 і DbContext.t4. Виправдані при 50+ таблицях і частому re-scaffoldу

У другій частині — Partial Classes для розширення згенерованих entity, Re-scaffolding workflow, Database Schema Comparison Tools та Multi-database scenarios.