Ef Core

Індекси, Обмеження та Схема (Частина 2)

Check Constraints, Database Sequences і Hi-Lo, Collation, Database Comments, HasDefaultSchema, схема бази даних через Fluent API. Повний розбір обмежень цілісності в EF Core.

Обмеження, Послідовності та Схема

Це продовження статті «Індекси, Обмеження». Читайте послідовно.


Check Constraints: валідація на рівні бази даних

Check Constraint (обмеження перевірки) — SQL-вираз, що перевіряється базою даних при кожному INSERT і UPDATE. Якщо вираз повертає false — операція відхиляється з помилкою. Це остання лінія захисту цілісності даних після валідації на рівні C# і бізнес-логіки.

Навіщо Check Constraints у сучасній архітектурі?

Здавалося б: у нас є FluentValidation, є доменні валідатори, є перевірки у сервісному шарі. Навіщо ще й Check Constraint у базі?

Тому що дані можуть потрапити до бази в обхід вашого C# коду:

  • Прямий INSERT через SQL Management Studio або psql (DBA вручну виправляє дані)
  • Інший мікросервіс пише напряму до тієї самої БД
  • Legacy код без валідації
  • Bulk import з CSV
  • Міграція даних

Check Constraint — це інваріант у самій базі. Він не знає нічого про C#, але гарантує, що правило «кількість не може бути від'ємною» ніколи не порушиться, хто б не писав у таблицю.

HasCheckConstraint: синтаксис

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public decimal? DiscountPercent { get; set; }
    public int Stock { get; set; }
    public int ReservedStock { get; set; }
    public DateTime? ExpiresAt { get; set; }
    public DateTime CreatedAt { get; set; }
}
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.HasKey(p => p.Id);
        builder.Property(p => p.Name).IsRequired().HasMaxLength(200);
        builder.Property(p => p.Price).HasPrecision(12, 2);
        builder.Property(p => p.DiscountPercent).HasPrecision(5, 2);

        // Ціна не може бути від'ємною
        builder.ToTable(tb => tb.HasCheckConstraint(
            "CK_Products_Price",
            "[Price] >= 0"
        ));

        // Знижка від 0 до 100 відсотків (або NULL — немає знижки)
        builder.ToTable(tb => tb.HasCheckConstraint(
            "CK_Products_DiscountPercent",
            "[DiscountPercent] IS NULL OR ([DiscountPercent] >= 0 AND [DiscountPercent] <= 100)"
        ));

        // Залишок не може бути від'ємним
        builder.ToTable(tb => tb.HasCheckConstraint(
            "CK_Products_Stock",
            "[Stock] >= 0"
        ));

        // Зарезервований не може перевищувати загальний залишок
        builder.ToTable(tb => tb.HasCheckConstraint(
            "CK_Products_ReservedStock",
            "[ReservedStock] >= 0 AND [ReservedStock] <= [Stock]"
        ));

        // Дата закінчення строку (якщо є) — не в минулому відносно CreatedAt
        builder.ToTable(tb => tb.HasCheckConstraint(
            "CK_Products_ExpiresAt",
            "[ExpiresAt] IS NULL OR [ExpiresAt] > [CreatedAt]"
        ));
    }
}

Генерований DDL (SQL Server):

CREATE TABLE [Products] (
    [Id]              INT            NOT NULL IDENTITY,
    [Name]            NVARCHAR(200)  NOT NULL,
    [Price]           DECIMAL(12,2)  NOT NULL,
    [DiscountPercent] DECIMAL(5,2)   NULL,
    [Stock]           INT            NOT NULL,
    [ReservedStock]   INT            NOT NULL,
    [ExpiresAt]       DATETIME2      NULL,
    [CreatedAt]       DATETIME2      NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [CK_Products_Price]           CHECK ([Price] >= 0),
    CONSTRAINT [CK_Products_DiscountPercent] CHECK ([DiscountPercent] IS NULL OR
        ([DiscountPercent] >= 0 AND [DiscountPercent] <= 100)),
    CONSTRAINT [CK_Products_Stock]           CHECK ([Stock] >= 0),
    CONSTRAINT [CK_Products_ReservedStock]   CHECK ([ReservedStock] >= 0
        AND [ReservedStock] <= [Stock]),
    CONSTRAINT [CK_Products_ExpiresAt]       CHECK ([ExpiresAt] IS NULL
        OR [ExpiresAt] > [CreatedAt])
);

Тепер будь-яка спроба вставити Price = -50 або ReservedStock > Stock — база поверне помилку, незалежно від того, хто і як пише у таблицю.

Check Constraints для Enum-значень

public class Order
{
    public int Id { get; set; }
    public string Status { get; set; } = "Pending";
    public string PaymentMethod { get; set; } = string.Empty;
    public int Priority { get; set; }
}
builder.ToTable(tb =>
{
    // Enum як рядок: обмежити допустимі значення
    tb.HasCheckConstraint(
        "CK_Orders_Status",
        "[Status] IN ('Pending', 'Processing', 'Shipped', 'Delivered', 'Cancelled', 'Refunded')"
    );

    // Числовий пріоритет: від 1 до 5
    tb.HasCheckConstraint(
        "CK_Orders_Priority",
        "[Priority] BETWEEN 1 AND 5"
    );

    // PaymentMethod: допустимі значення
    tb.HasCheckConstraint(
        "CK_Orders_PaymentMethod",
        "[PaymentMethod] IN ('CreditCard', 'DebitCard', 'BankTransfer', 'Cash', 'Crypto')"
    );
});
Check Constraints і Enum: Якщо зберігаєте enum як рядок і додаєте Check Constraint з переліком допустимих значень — при додаванні нового значення enum обов'язково потрібна міграція для оновлення CHECK constraint. Якщо забудете — база почне відхиляти нові значення.

Check Constraints для email-формату (PostgreSQL)

// PostgreSQL підтримує regex у CHECK:
builder.ToTable(tb => tb.HasCheckConstraint(
    "CK_Customers_Email",
    @"""Email"" ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'"
));
-- PostgreSQL:
CONSTRAINT "CK_Customers_Email" CHECK (
    "Email" ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'
)

SQL Server не підтримує regex у CHECK без UDF. Для складних валідацій — або додаткова логіка у C#, або SQL UDF.


Database Sequences: централізована генерація ID

Database Sequence (послідовність бази даних) — об'єкт БД, що генерує монотонно зростаючі числа. На відміну від IDENTITY/AUTOINCREMENT (прив'язані до конкретної таблиці), Sequence — незалежний об'єкт, що може бути спільним для кількох таблиць або використовуватися для будь-яких цілей.

Мотивація для Sequences

Стандартний IDENTITY генерує ID в момент INSERT, а значення стає відомим лише після INSERT. Це ускладнює:

  • Отримання ID до збереження (для логування, domain events)
  • Генерацію ID у batch без збереження
  • Спільний ID-простір між таблицями (TPC-успадкування)
  • Hi-Lo алгоритм: один SELECT до БД для блоку ID

HasSequence: конфігурація

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Проста послідовність
    modelBuilder.HasSequence<int>("invoice_number_seq")
                .StartsAt(10000)    // починаємо з 10000
                .IncrementsBy(1);   // збільшення на 1

    // Послідовність з циклом (після max → починає знову)
    modelBuilder.HasSequence<long>("batch_id_seq")
                .StartsAt(1)
                .IncrementsBy(1)
                .HasMin(1)
                .HasMax(long.MaxValue)
                .IsCyclic(false);   // false = зупинитися при max

    // Послідовність у конкретній схемі
    modelBuilder.HasSequence<int>("order_seq", schema: "billing")
                .StartsAt(1)
                .IncrementsBy(10); // блоки по 10 (Hi-Lo варіант)

    modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
-- SQL Server DDL:
CREATE SEQUENCE [invoice_number_seq]
    AS INT
    START WITH 10000
    INCREMENT BY 1
    NO CYCLE;

CREATE SEQUENCE [billing].[order_seq]
    AS INT
    START WITH 1
    INCREMENT BY 10;

Використання Sequence як Default Value

public class Invoice
{
    public int Id { get; set; }
    public int InvoiceNumber { get; set; } // з послідовності
    public string Title { get; set; } = string.Empty;
}
public class InvoiceConfiguration : IEntityTypeConfiguration<Invoice>
{
    public void Configure(EntityTypeBuilder<Invoice> builder)
    {
        builder.HasKey(i => i.Id);

        // InvoiceNumber береться з послідовності, не з IDENTITY
        builder.Property(i => i.InvoiceNumber)
               .HasDefaultValueSql("NEXT VALUE FOR [invoice_number_seq]")
               .ValueGeneratedOnAdd();

        builder.Property(i => i.Title).IsRequired().HasMaxLength(200);

        // InvoiceNumber унікальний
        builder.HasIndex(i => i.InvoiceNumber).IsUnique();
    }
}
CREATE TABLE [Invoices] (
    [Id]            INT           NOT NULL IDENTITY,
    [InvoiceNumber] INT           NOT NULL DEFAULT (NEXT VALUE FOR [invoice_number_seq]),
    [Title]         NVARCHAR(200) NOT NULL,
    CONSTRAINT [PK_Invoices] PRIMARY KEY ([Id]),
    CONSTRAINT [UX_Invoices_InvoiceNumber] UNIQUE ([InvoiceNumber])
);

PostgreSQL (nextval):

builder.Property(i => i.InvoiceNumber)
       .HasDefaultValueSql("nextval('invoice_number_seq')")
       .ValueGeneratedOnAdd();

UseHiLo: батчеване отримання ID

Hi-Lo (High-Low) — алгоритм, що отримує блоки ID з послідовності замість одного за раз. EF Core бере один блок (наприклад, 10 ID) і генерує всі 10 у пам'яті без додаткових запитів до БД.

public class AppDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Послідовність з кроком 10 (блок Hi-Lo)
        modelBuilder.HasSequence<int>("entity_id_seq")
                    .StartsAt(1)
                    .IncrementsBy(10);

        // Всі entity використовують Hi-Lo з цієї послідовності
        modelBuilder.UseHiLo("entity_id_seq");

        // Або для конкретної entity:
        // modelBuilder.Entity<Product>().Property(p => p.Id).UseHiLo("entity_id_seq");

        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

Як Hi-Lo працює:

  1. EF Core виконує SELECT NEXT VALUE FOR entity_id_seq → отримує, наприклад, 101
  2. Локальний лічильник: від 101 * 10 до 101 * 10 + 9 = ID від 1010 до 1019
  3. Наступні 10 INSERTs — без звернення до БД за ID
  4. Коли блок вичерпується — новий SELECT NEXT VALUE FOR

UseSequence (EF Core 7+) для TPC

Для TPC-ієрархій UseSequence — більш чистий підхід, ніж UseHiLo:

modelBuilder.HasSequence<int>("publication_id_seq")
            .StartsAt(1)
            .IncrementsBy(1);

modelBuilder.Entity<Publication>(b =>
{
    b.UseTpcMappingStrategy();
    b.Property(p => p.Id)
     .UseSequence("publication_id_seq");
});
CREATE TABLE [Books] (
    [Id] INT NOT NULL DEFAULT (NEXT VALUE FOR [publication_id_seq]),
    -- ...
);
CREATE TABLE [Articles] (
    [Id] INT NOT NULL DEFAULT (NEXT VALUE FOR [publication_id_seq]),
    -- Та сама послідовність! Глобально унікальні ID
);

Collation: сортування і порівняння рядків

Collation (зіставлення) визначає правила порівняння і сортування символів у рядках. Це впливає на:

  • Чи 'А' = 'а' (case sensitive чи insensitive)
  • Чи 'е' = 'є' (accent sensitive чи insensitive)
  • Порядок сортування («А» перед «Б» чи після «Z»)

UseCollation: рівень стовпця

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Login { get; set; } = string.Empty;
}
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.HasKey(c => c.Id);

        // Name: Ukrainian CI (case insensitive), AS (accent sensitive)
        builder.Property(c => c.Name)
               .IsRequired()
               .HasMaxLength(200)
               .UseCollation("Ukrainian_CI_AS"); // SQL Server

        // Email: case insensitive (latin)
        builder.Property(c => c.Email)
               .IsRequired()
               .HasMaxLength(320)
               .UseCollation("SQL_Latin1_General_CP1_CI_AS");

        // Login: case sensitive! login ≠ Login
        builder.Property(c => c.Login)
               .IsRequired()
               .HasMaxLength(50)
               .UseCollation("Latin1_General_CS_AS");
    }
}
CREATE TABLE [Customers] (
    [Id]    INT            NOT NULL IDENTITY,
    [Name]  NVARCHAR(200)  NOT NULL COLLATE Ukrainian_CI_AS,
    [Email] NVARCHAR(320)  NOT NULL COLLATE SQL_Latin1_General_CP1_CI_AS,
    [Login] NVARCHAR(50)   NOT NULL COLLATE Latin1_General_CS_AS,
    -- ...
);

UseCollation на рівні бази даних

// OnModelCreating: Collation для всіх рядкових стовпців за замовчуванням
modelBuilder.UseCollation("Ukrainian_CI_AS");
-- SQL Server: змінює collation бази даних
ALTER DATABASE [MyDatabase] COLLATE Ukrainian_CI_AS;

PostgreSQL Collation

// PostgreSQL: ICU collation
builder.Property(c => c.Name)
       .UseCollation("uk-UA-x-icu"); // Українська через ICU

// Або для всього DbContext:
modelBuilder.UseCollation("uk-UA-x-icu");

Практичний вплив Collation на запити

// З Ukrainian_CI_AS (case insensitive):
context.Customers.Where(c => c.Name == "іван")
// Знайде: "Іван", "ІВАН", "іван", "іВан" — всі варіанти

// З Latin1_General_CS_AS (case sensitive):
context.Customers.Where(c => c.Login == "john")
// Знайде лише "john", НЕ "John" або "JOHN"
Рекомендація: Email завжди зберігайте з case-insensitive collation — за RFC 5321 локальна частина email є case sensitive, але на практиці всі поштові сервери його ігнорують. Логіни — case sensitive (unix-стиль). Імена — case insensitive.

HasComment: документація у базі даних

public class Payment
{
    public int Id { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; } = string.Empty;
    public int GatewayCode { get; set; }
}
public class PaymentConfiguration : IEntityTypeConfiguration<Payment>
{
    public void Configure(EntityTypeBuilder<Payment> builder)
    {
        // Коментар до таблиці
        builder.ToTable(tb => tb.HasComment("Таблиця платежів. GDPR: не зберігати PAN."));

        builder.HasKey(p => p.Id);

        builder.Property(p => p.Amount)
               .HasPrecision(14, 2)
               .HasComment("Сума у валюті Currency. Завжди позитивна.");

        builder.Property(p => p.Currency)
               .HasMaxLength(3)
               .IsUnicode(false)
               .HasComment("ISO 4217 код валюти (UAH, USD, EUR).");

        builder.Property(p => p.GatewayCode)
               .HasComment("Код повернення від платіжного шлюзу. 0 = успіх, інші = помилки по специфікації шлюзу.");
    }
}
-- SQL Server: через extended properties
EXEC sp_addextendedproperty
    @name = N'MS_Description',
    @value = N'Таблиця платежів. GDPR: не зберігати PAN.',
    @level0type = 'SCHEMA', @level0name = 'dbo',
    @level1type = 'TABLE', @level1name = 'Payments';

-- PostgreSQL: через COMMENT ON
COMMENT ON TABLE "Payments" IS 'Таблиця платежів. GDPR: не зберігати PAN.';
COMMENT ON COLUMN "Payments"."Amount" IS 'Сума у валюті Currency. Завжди позитивна.';

Коментарі видно у SQL Management Studio, pgAdmin, DBeaver — корисно для DBA і інших команд.


HasDefaultSchema: схема бази даних

Schema (схема) у SQL Server і PostgreSQL — простір імен для таблиць. Це дозволяє логічно групувати таблиці різних модулів у одній базі.

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Invoice> Invoices => Set<Invoice>();
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Всі таблиці за замовчуванням у схемі "sales"
        modelBuilder.HasDefaultSchema("sales");

        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
-- Таблиці отримують схему:
CREATE TABLE [sales].[Orders] (...);
CREATE TABLE [sales].[Invoices] (...);
CREATE TABLE [sales].[Customers] (...);

ToTable зі схемою для конкретних таблиць

public class HrEmployeeConfiguration : IEntityTypeConfiguration<Employee>
{
    public void Configure(EntityTypeBuilder<Employee> builder)
    {
        // Перевизначити схему для конкретної таблиці
        builder.ToTable("Employees", schema: "hr");

        builder.HasKey(e => e.Id);
        // ...
    }
}
-- Ця таблиця у схемі "hr", а не "sales"
CREATE TABLE [hr].[Employees] (...);

Bounded Contexts через схеми

// DbContext для модуля Product Catalog
public class CatalogDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Category> Categories => Set<Category>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("catalog");
    }
}

// DbContext для модуля Orders
public class OrdersDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("orders");
    }
}

// DbContext для HR
public class HrDbContext : DbContext
{
    public DbSet<Employee> Employees => Set<Employee>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("hr");
    }
}

Всі три DbContext — в одній фізичній базі, але у різних схемах. Це дозволяє:

  • Роздати різні права доступу (DBA може обмежити доступ hr схемою)
  • Логічно розділити модулі у одній БД
  • Потенційно винести до різних баз у майбутньому (міграція Bounded Contexts)

Повна конфігурація бази: практичний приклад

Зберемо всі концепції разом у реальному сценарії — мінімальний e-commerce з правильно налаштованою схемою:

public class EcommerceDbContext : DbContext
{
    public DbSet<Product>  Products  => Set<Product>();
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Order>    Orders    => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("shop");

        // Глобальна послідовність для Order Number
        modelBuilder.HasSequence<long>("order_number_seq", schema: "shop")
                    .StartsAt(100001)
                    .IncrementsBy(1);

        modelBuilder.ApplyConfigurationsFromAssembly(typeof(EcommerceDbContext).Assembly);
    }
}
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.ToTable("Products", schema: "shop",
            tb => tb.HasComment("Каталог товарів магазину"));

        builder.HasKey(p => p.Id);
        builder.Property(p => p.Name).IsRequired().HasMaxLength(300)
               .HasComment("Повна назва товару для відображення покупцю");
        builder.Property(p => p.Sku).IsRequired().HasMaxLength(50).IsUnicode(false)
               .HasComment("Stock Keeping Unit — внутрішній артикул");
        builder.Property(p => p.Price).HasPrecision(12, 2).IsRequired()
               .HasComment("Ціна у гривнях (UAH) включно з ПДВ");
        builder.Property(p => p.Stock).IsRequired()
               .HasComment("Кількість одиниць на складі. Не може бути від'ємним.");

        // Бізнес-правила як Check Constraints
        builder.ToTable(tb =>
        {
            tb.HasCheckConstraint("CK_Products_Price",    "[Price] >= 0");
            tb.HasCheckConstraint("CK_Products_Stock",    "[Stock] >= 0");
            tb.HasCheckConstraint("CK_Products_Sku_NotEmpty", "LEN(TRIM([Sku])) > 0");
        });

        // Alternate Key на SKU (для FK з OrderItem)
        builder.HasAlternateKey(p => p.Sku)
               .HasName("AK_Products_Sku");

        // Індекси
        builder.HasIndex(p => p.CategoryId)
               .HasDatabaseName("IX_Products_CategoryId");

        builder.HasIndex(p => new { p.IsActive, p.Price })
               .HasFilter("[IsActive] = 1")
               .HasDatabaseName("IX_Products_Active_Price");

        builder.HasIndex(p => p.Name)
               .HasDatabaseName("IX_Products_Name"); // для пошуку за назвою
    }
}

public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.ToTable("Customers", schema: "shop");
        builder.HasKey(c => c.Id);

        builder.Property(c => c.Email)
               .IsRequired().HasMaxLength(320)
               .UseCollation("SQL_Latin1_General_CP1_CI_AS"); // CI для email

        builder.Property(c => c.PhoneNumber).HasMaxLength(20);

        // Фільтровані унікальні індекси (soft delete friendly)
        builder.HasIndex(c => c.Email)
               .IsUnique()
               .HasFilter("[IsDeleted] = 0")
               .HasDatabaseName("UX_Customers_Email_Active");

        // Check Constraints
        builder.ToTable(tb =>
        {
            tb.HasCheckConstraint("CK_Customers_Email",
                "LEN([Email]) >= 5 AND [Email] LIKE '%@%.%'");
        });
    }
}

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders", schema: "shop",
            tb => tb.HasComment("Замовлення покупців"));

        builder.HasKey(o => o.Id);

        // OrderNumber з послідовності
        builder.Property(o => o.OrderNumber)
               .HasDefaultValueSql("NEXT VALUE FOR [shop].[order_number_seq]")
               .ValueGeneratedOnAdd()
               .HasComment("Унікальний номер замовлення для покупця");

        builder.Property(o => o.Status)
               .IsRequired().HasMaxLength(20)
               .HasConversion<string>();

        builder.Property(o => o.TotalAmount).HasPrecision(14, 2);
        builder.Property(o => o.TaxAmount).HasPrecision(14, 2);

        // Check Constraints
        builder.ToTable(tb =>
        {
            tb.HasCheckConstraint("CK_Orders_TotalAmount", "[TotalAmount] >= 0");
            tb.HasCheckConstraint("CK_Orders_TaxAmount",   "[TaxAmount] >= 0");
            tb.HasCheckConstraint("CK_Orders_Status",
                "[Status] IN ('Pending', 'Processing', 'Shipped', 'Delivered', 'Cancelled', 'Refunded')");
        });

        // FK до Customer
        builder.HasOne(o => o.Customer)
               .WithMany(c => c.Orders)
               .HasForeignKey(o => o.CustomerId)
               .OnDelete(DeleteBehavior.Restrict);

        // Індекси
        builder.HasIndex(o => o.CustomerId)
               .HasDatabaseName("IX_Orders_CustomerId");

        builder.HasIndex(o => new { o.Status, o.PlacedAt })
               .HasDatabaseName("IX_Orders_Status_PlacedAt");

        builder.HasIndex(o => o.OrderNumber)
               .IsUnique()
               .HasDatabaseName("UX_Orders_OrderNumber");
    }
}

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

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

Завдання 1.1: Check Constraints для бронювання

HotelReservation (Id, CheckInDate, CheckOutDate, GuestCount, RoomType, PricePerNight, TotalPrice). Додайте обмеження:

  • CheckOutDate > CheckInDate
  • GuestCount BETWEEN 1 AND 10
  • PricePerNight > 0
  • TotalPrice приблизно дорівнює PricePerNight * DATEDIFF(day, CheckInDate, CheckOutDate) — але з урахуванням можливих знижок: TotalPrice >= PricePerNight * DATEDIFF(day, CheckInDate, CheckOutDate) * 0.5
  • RoomType IN ('Standard', 'Deluxe', 'Suite', 'Presidential')

Завдання 1.2: Sequence для Invoice Number

Реалізуйте InvoiceNumber для Invoice, що генерується як INV-2024-000001 (рік + 6-значний номер з послідовності). Підказка: HasDefaultValueSql не підтримує конкатенацію при звичайному використанні — розгляньте computed column або генерацію у C# через ValueGenerator.

Завдання 1.3: Схеми для Bounded Contexts

Розбийте один AppDbContext на три: IdentityDbContext (схема identity), CatalogDbContext (схема catalog), FinanceDbContext (схема finance). Напишіть три міграції для кожного контексту. Як реєструвати кілька DbContext у DI?

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

Завдання 2.1: Комплексні Check Constraints

Для Employee (Id, FirstName, LastName, Salary, HireDate, TerminationDate?, Role, ManagerId?) додайте:

  • Salary > 0
  • TerminationDate IS NULL OR TerminationDate > HireDate
  • Якщо Role = 'Manager'ManagerId IS NULL (менеджер не може мати іншого менеджера)
  • Якщо Role != 'Manager'ManagerId IS NOT NULL (у не-менеджерів має бути менеджер)

Остання умова — чи можна реалізувати через один CHECK CONSTRAINT? Чи потрібні два? Напишіть тест, що верифікує ці правила.

Завдання 2.2: Collation для пошуку

Є Document (Id, Title, Content, AuthorName). Реалізуйте:

  • Title і AuthorName: Ukrainian CI AS (пошук без урахування регістру і акцентів)
  • Content: стандартне Latin collation (технічний контент)

Напишіть запит пошуку: WHERE Title LIKE @pattern OR AuthorName LIKE @pattern. Переконайтеся, що пошук за "іван" знаходить документи з "Іван" і "ІВАН".

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

Завдання 3.1: Повна схема для SaaS-платформи

Реалізуйте схему для SaaS з multi-tenancy через схеми БД:

  • Схема platform: Tenant (Id, Name, PlanId, CreatedAt), Plan (Id, Name, MaxUsers, MaxStorage)
  • Схема {tenantSlug}: кожен тенант має власну схему БД

Питання для дослідження:

  • Як у EF Core динамічно задати схему залежно від поточного тенанту?
  • Як створити міграцію для конкретного тенанту (шаблон + параметр схеми)?
  • Альтернатива: всі тенанти в одній схемі з TenantId в кожній таблиці (row-level security) — порівняйте підходи

Підсумок статті 13

Ця стаття охопила всі аспекти обмежень та схеми бази даних в EF Core:

Частина 1 — Індекси:

  • B-tree структура та принцип роботи
  • HasIndex, IsUnique, складені індекси (правило лівого префіксу)
  • Фільтровані індекси (HasFilter) — ідеальні для soft delete
  • Covering Indexes (IncludeProperties) — уникаємо bookmark lookup
  • PK vs Alternate Key vs Unique Index — три різних механізми

Частина 2 — Обмеження та схема:

  • HasCheckConstraint — інваріанти бізнес-логіки на рівні бази
  • Database Sequences — HasSequence, UseHiLo, UseSequence для TPC
  • UseCollation — правила порівняння рядків (CI vs CS, accents)
  • HasComment — документація схеми для DBA
  • HasDefaultSchema і ToTable(schema:) — логічне розбиття на модулі

Наступна стаття — Seed Data (стаття 14) — розкриє HasData(), міграційний seeding та стратегії початкового наповнення бази.


Додаткові ресурси