Ef Core

Зв'язки — One-to-One та One-to-Many

Глибокий розбір зв'язків в Entity Framework Core — Principal і Dependent, конфігурація Required та Optional навігацій, однонаправлені та двонаправлені зв'язки, самореферентні відносини, всі варіанти DeleteBehavior з реальними прикладами.

Зв'язки: One-to-One та One-to-Many

Чому зв'язки — найскладніша частина ORM

Якщо попросити досвідченого .NET розробника назвати тему в EF Core, де найчастіше роблять помилки, — більшість вкаже на зв'язки. Додавання нового запису до колекції і він не зберігається. Видалення батьківського запису і отримання FK-violation. Загрузка сутності і навігаційна властивість порожня, хоча дані в базі є. Cascade delete, що несподівано видаляє набагато більше, ніж очікувалось.

Причина цих проблем — не в складності синтаксису EF Core. Він доволі простий. Причина — у тому, що зв'язки між сутностями мають кілька ортогональних вимірів, і кожен з них впливає на поведінку:

  • Кардинальність: один до одного, один до багатьох, багато до багатьох
  • Спрямованість: є навігація тільки з одного боку чи з обох?
  • Обов'язковість: FK може бути NULL чи ні?
  • Ролі: хто є «принципалом» (батько), хто «залежним» (дитина)?
  • Поведінка при видаленні: cascade, restrict, set null?

Ця стаття розбирає перші два типи зв'язків — One-to-One і One-to-Many — не як набір API-викликів, а як концепцію з усіма наведеними вимірами. Розуміння кожного з них дозволить передбачати поведінку EF Core без звернення до документації.


Principal і Dependent: фундаментальний поділ

Перш ніж говорити про конкретні типи зв'язків, розберемо найважливіші поняття, яких не вистачає більшості пояснень.

У будь-якому зв'язку між двома сутностями є асиметрія: одна сторона «знає» про іншу через зовнішній ключ, інша цього зовнішнього ключа не має.

Principal (головна сутність, «батько») — та, на яку посилаються. Вона містить первинний ключ, на який вказує зовнішній ключ іншої сторони. Author у прикладі книг — principal, бо Book.AuthorId посилається на Author.Id.

Dependent (залежна сутність, «дитина») — та, що посилається. Вона містить зовнішній ключ. Book — dependent, бо несе AuthorId — пряме посилання на Author.

public class Author           // ← Principal
{
    public int Id { get; set; }  // PK — на нього посилаються

    public ICollection<Book> Books { get; set; } = new List<Book>();
}

public class Book             // ← Dependent
{
    public int Id { get; set; }

    public int AuthorId { get; set; }            // FK — посилається на Author.Id
    public Author Author { get; set; } = null!;  // Navigation property
}

Це розрізнення критично для розуміння:

  1. Хто має бути збережений першим: Principal завжди зберігається перед Dependent (EF Core сам визначає порядок у транзакції)
  2. Де фізично знаходиться зовнішній ключ: завжди у Dependent
  3. Що відбувається при видаленні: видаляти Principal складніше — є залежні дані
  4. Cascade delete: налаштовується на стороні Dependent (у FK constraint)

One-to-Many: найпоширеніший зв'язок

Один автор може написати багато книг. Одна категорія може містити багато продуктів. Один відділ може мати багато співробітників. One-to-Many — це основний зв'язок у більшості доменних моделей.

Як EF Core виявляє One-to-Many

EF Core ідентифікує One-to-Many за комбінацією навігаційних властивостей:

  • У Principal є колекція (ICollection<T>, IList<T>, List<T>, IEnumerable<T>)
  • У Dependent є посилання (Author, Category — не колекція)
  • Присутній FK-поле або Shadow Property

Якщо ці умови виконані — EF Core автоматично визначає тип зв'язку без будь-якої конфігурації.

Required One-to-Many (FK NOT NULL)

Більшість One-to-Many зв'язків є required: кожна книга обов'язково має автора, кожна позиція замовлення обов'язково є частиною замовлення. FK не може бути NULL.

public class Order
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }

    // Principal side: колекція дочірніх елементів
    public ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}

public class OrderItem
{
    public int Id { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }

    // FK: NOT NULL (int — не nullable)
    public int OrderId { get; set; }

    // Navigation property до Principal
    public Order Order { get; set; } = null!;
}

Генерований DDL:

CREATE TABLE "Orders" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY AUTOINCREMENT,
    "CreatedAt" TEXT NOT NULL
);

CREATE TABLE "OrderItems" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_OrderItems" PRIMARY KEY AUTOINCREMENT,
    "Quantity" INTEGER NOT NULL,
    "UnitPrice" TEXT NOT NULL,
    "OrderId" INTEGER NOT NULL,  -- NOT NULL: required relationship
    CONSTRAINT "FK_OrderItems_Orders_OrderId"
        FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id")
        ON DELETE CASCADE        -- Cascade: за замовчуванням для required
);

Зверніть на ON DELETE CASCADE: за замовчуванням EF Core налаштовує каскадне видалення для обов'язкових зв'язків. Видалення Order автоматично видалить всі його OrderItems.

Optional One-to-Many (FK NULL)

Іноді зв'язок необов'язковий: у автора може не бути видавця (Publisher), стаття може не мати категорії. FK може бути NULL.

public class Article
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;

    // FK nullable: стаття може існувати без категорії
    public int? CategoryId { get; set; }

    // Nullable navigation property: Optional relationship
    public Category? Category { get; set; }
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    public ICollection<Article> Articles { get; set; } = new List<Article>();
}
CREATE TABLE "Articles" (
    "Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "CategoryId" INTEGER NULL,  -- NULL дозволяється: optional
    CONSTRAINT "FK_Articles_Categories_CategoryId"
        FOREIGN KEY ("CategoryId") REFERENCES "Categories" ("Id")
        ON DELETE SET NULL  -- або NO ACTION залежно від налаштувань
);

Для optional зв'язків DeleteBehavior за замовчуванням — ClientSetNull, а не CASCADE. Це означає: при видаленні Category EF Core обнуляє CategoryId у завантажених у ChangeTracker Article, але не виконує CASCADE на рівні БД. Якщо Article не завантажені — вони залишаться з недійсним FK або заблокують видалення (залежно від СУБД).

Явно завжди задавайте OnDelete() для Optional зв'язків. ClientSetNull — поведінка, що може призвести до сюрпризів. Для більшості Optional зв'язків кращим вибором є SetNull (встановлює NULL в БД) або Restrict (забороняє видалення, доки є залежні).

Fluent API для One-to-Many

Configurations/OrderItemConfiguration.cs
public class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
{
    public void Configure(EntityTypeBuilder<OrderItem> builder)
    {
        // HasOne: OrderItem має ОДНУ Order
        // WithMany: Order має БАГАТО OrderItems
        // HasForeignKey: FK знаходиться у OrderItem
        builder
            .HasOne(item => item.Order)        // навігація зі сторони Dependent
            .WithMany(order => order.Items)    // навігація зі сторони Principal
            .HasForeignKey(item => item.OrderId)
            .IsRequired()                      // FK NOT NULL
            .OnDelete(DeleteBehavior.Cascade)  // явно, хоч і є дефолт
            .HasConstraintName("FK_OrderItems_Orders"); // назва FK constraint у БД
    }
}

Якщо навігаційна властивість існує тільки з одного боку:

// Тільки Order → Items (немає Item.Order)
builder
    .HasMany(order => order.Items)      // починаємо з Principal
    .WithOne()                          // Dependent без навігації
    .HasForeignKey("OrderId")           // Shadow property або string-назва
    .OnDelete(DeleteBehavior.Cascade);

One-to-One: зв'язок один до одного

One-to-One зв'язок складніший, ніж здається. У SQL не існує нативного механізму гарантувати One-to-One на рівні FK — FK constraint дозволяє N рядків з однаковим FK. Унікальність забезпечується через Unique Index або через те, що FK є водночас PK.

EF Core реалізує One-to-One одним із двох способів:

Спосіб 1: FK + Unique Index (найпоширеніший)

Dependent містить FK до Principal, і цей FK має unique index:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    // Навігація до associated ContactInfo
    public AuthorContactInfo? ContactInfo { get; set; }
}

public class AuthorContactInfo
{
    public int Id { get; set; }
    public string? Phone { get; set; }
    public string? Website { get; set; }
    public string? TwitterHandle { get; set; }

    // FK до Author — завжди NOT NULL (кожен ContactInfo належить одному Author)
    public int AuthorId { get; set; }
    public Author Author { get; set; } = null!;
}

EF Core автоматично визначає One-to-One (а не One-to-Many), бо навігаційна властивість Author.ContactInfo є одиночним об'єктом, а не колекцією.

Генерований DDL для SQLite:

CREATE TABLE "Authors" (
    "Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "Name" TEXT NOT NULL
);

CREATE TABLE "AuthorContactInfos" (
    "Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "Phone" TEXT NULL,
    "Website" TEXT NULL,
    "TwitterHandle" TEXT NULL,
    "AuthorId" INTEGER NOT NULL,
    CONSTRAINT "FK_AuthorContactInfos_Authors_AuthorId"
        FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id")
        ON DELETE CASCADE
);

-- Unique index: гарантує One-to-One (один Author → один ContactInfo)
CREATE UNIQUE INDEX "IX_AuthorContactInfos_AuthorId"
    ON "AuthorContactInfos" ("AuthorId");

Спосіб 2: Shared Primary Key (FK = PK)

Більш рідкісний, але елегантний: Dependent використовує той самий Id, що і Principal. Це гарантує 1:1 на рівні фізичного ключа:

public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    public EmployeeProfile? Profile { get; set; }
}

public class EmployeeProfile
{
    // PK і FK одночасно — цей Id є і PK, і FK до Employee
    public int Id { get; set; }
    public string? Bio { get; set; }
    public string? LinkedInUrl { get; set; }

    public Employee Employee { get; set; } = null!;
}

Fluent API для Shared Primary Key:

modelBuilder.Entity<Employee>()
    .HasOne(e => e.Profile)
    .WithOne(p => p.Employee)
    .HasForeignKey<EmployeeProfile>(p => p.Id); // FK = PK у Dependent
CREATE TABLE "EmployeeProfiles" (
    "Id" INTEGER NOT NULL,  -- PK і FK одночасно
    "Bio" TEXT NULL,
    CONSTRAINT "PK_EmployeeProfiles" PRIMARY KEY ("Id"),
    CONSTRAINT "FK_EmployeeProfiles_Employees_Id"
        FOREIGN KEY ("Id") REFERENCES "Employees" ("Id")
        ON DELETE CASCADE
);
-- Немає separate unique index — PK вже унікальний

Fluent API для One-to-One

Особливість: EF Core не може автоматично визначити, хто є Principal, а хто Dependent у One-to-One — обидві сторони мають навігацію без колекції. Тому Fluent API є обов'язковим або принаймні рекомендованим:

Configurations/AuthorContactInfoConfiguration.cs
public class AuthorContactInfoConfiguration : IEntityTypeConfiguration<AuthorContactInfo>
{
    public void Configure(EntityTypeBuilder<AuthorContactInfo> builder)
    {
        // HasOne: ContactInfo має одного Author
        // WithOne: Author має один ContactInfo
        // HasForeignKey<AuthorContactInfo>: FK знаходиться у ContactInfo (Dependent)
        builder
            .HasOne(ci => ci.Author)
            .WithOne(a => a.ContactInfo)
            .HasForeignKey<AuthorContactInfo>(ci => ci.AuthorId) // явно вказуємо Dependent
            .IsRequired()
            .OnDelete(DeleteBehavior.Cascade);
    }
}
При конфігурації One-to-One через Fluent API обов'язково вказуйте HasForeignKey<TDependent>() з явним generic-параметром, що вказує на Dependent. Без цього EF Core може переплутати Principal і Dependent, і FK буде у неправильній таблиці.

Навігаційні властивості: типи і вибір

Навігаційні властивості є «мостом» між об'єктною моделлю і реляційними зв'язками. Вони дозволяють переходити між сутностями через C#-властивості замість явних SQL JOIN.

Reference Navigation (єдиний об'єкт)

Використовується для Many-to-One або One-to-One зі сторони Dependent:

public Book
{
    // Reference navigation: одна сутність
    public Author Author { get; set; } = null!;           // Required (Non-null)
    public Publisher? Publisher { get; set; }             // Optional (Nullable)
}

Collection Navigation (колекція)

Використовується для One-to-Many зі сторони Principal:

public class Author
{
    public ICollection<Book> Books { get; set; } = new List<Book>();    // найпоширеніший вибір
    public IList<Book> OrderedBooks { get; set; } = new List<Book>();   // коли потрібен порядок
    public IReadOnlyCollection<Book> ImmutableBooks => _books.AsReadOnly(); // read-only exposure
    private List<Book> _books = new List<Book>();                         // backing field (DDD)
}

Рекомендації щодо типу колекції:

ICollection<T> — найпоширеніший вибір. Не нав'язує порядок (що відповідає SQL без ORDER BY), дозволяє Add і Remove. EF Core може замінити внутрішню реалізацію своїм proxy-об'єктом при Lazy Loading.

IReadOnlyCollection<T> з backing field — практика DDD. Доменна сутність сама контролює, хто може додавати елементи до колекції. Зовнішній код не може напряму додати книгу до автора — тільки через метод Author.AddBook(book):

public class Author
{
    private readonly List<Book> _books = new();

    // Публічно: тільки читання
    public IReadOnlyCollection<Book> Books => _books.AsReadOnly();

    // Метод для інкапсульованого додавання
    public void AddBook(Book book)
    {
        ArgumentNullException.ThrowIfNull(book);
        if (_books.Any(b => b.Isbn == book.Isbn))
            throw new DomainException($"Book with ISBN {book.Isbn} already exists");
        _books.Add(book);
    }
}

Для цього потрібна конфігурація backing field:

builder.Navigation(a => a.Books)
       .UsePropertyAccessMode(PropertyAccessMode.Field); // EF Core звертається до поля

Односторонні vs двосторонні навігації

Двостороння (bidirectional): навігація є з обох сторін:

public class Author
{
    public ICollection<Book> Books { get; set; } = new List<Book>(); // → зворотня
}
public class Book
{
    public int AuthorId { get; set; }             // FK
    public Author Author { get; set; } = null!;  // → пряма
}

Одностороння (unidirectional): навігація тільки з одного боку:

// Тільки Book знає про Author, Author нічого не знає про Books
public class Book
{
    public int AuthorId { get; set; }
    public Author Author { get; set; } = null!; // тільки ця навігація існує
}
// Немає Author.Books!

Односторонні навігації корисні, коли:

  • Колекція з боку Principal є величезною (тисячі елементів) і ніколи не завантажується повністю
  • Концептуально зв'язок має сенс тільки в одному напрямку (наприклад, Order знає про Customer, але Customer не завжди потребує список всіх своїх Orders у моделі)
  • Ви дотримуєтесь строгих принципів DDD і хочете контролювати, хто може «бачити» колекцію

DDL для односторонньої навігації ідентичний DDL для двосторонньої. Це виключно конструкція C#-моделі.


Самореферентні зв'язки: дерева та ієрархії

Самореферентний (self-referencing) зв'язок — це зв'язок сутності зі своїм власним типом. Найкласичний приклад — ієрархічна структура: категорії з підкатегоріями, коментарі з відповідями, організаційна ієрархія співробітників.

One-to-Many самореферентний (ієрархія)

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    // FK до батьківської категорії (nullable: коренева категорія не має батька)
    public int? ParentCategoryId { get; set; }

    // Навігація до батька (Optional — може бути null)
    public Category? ParentCategory { get; set; }

    // Навігація до дочірніх категорій
    public ICollection<Category> SubCategories { get; set; } = new List<Category>();
}
CREATE TABLE "Categories" (
    "Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "Name" TEXT NOT NULL,
    "ParentCategoryId" INTEGER NULL,  -- NULL для кореневих категорій
    CONSTRAINT "FK_Categories_Categories_ParentCategoryId"
        FOREIGN KEY ("ParentCategoryId") REFERENCES "Categories" ("Id")
        -- !! Sqlite та деякі СУБД не підтримують CASCADE на self-referencing
        ON DELETE RESTRICT  -- або NO ACTION
);
Cascade delete для самореферентних зв'язків потребує обережності і може не підтримуватись деякими СУБД (наприклад, SQLite у старих версіях). Для ієрархій зазвичай краще використовувати Restrict і видаляти дочірні вручну знизу вгору.

Fluent API для самореферентного One-to-Many:

Configurations/CategoryConfiguration.cs
public class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
    public void Configure(EntityTypeBuilder<Category> builder)
    {
        builder.HasKey(c => c.Id);

        builder.Property(c => c.Name)
               .IsRequired()
               .HasMaxLength(200);

        // Self-referencing One-to-Many
        builder
            .HasOne(c => c.ParentCategory)     // дочірня → батьківська
            .WithMany(c => c.SubCategories)    // батьківська → список дочірніх
            .HasForeignKey(c => c.ParentCategoryId)
            .IsRequired(false)                 // Optional: коренева категорія не має батька
            .OnDelete(DeleteBehavior.Restrict); // Забороняємо видалення категорії з підкатегоріями
    }
}

Завантаження ієрархії

Рекурсивне завантаження ієрархії через Include() потребує окремої уваги. ThenInclude() дозволяє заглибитись на кілька рівнів, але не рекурсивно:

// Завантажити 3 рівні ієрархії
var categories = await context.Categories
    .Where(c => c.ParentCategoryId == null) // тільки кореневі
    .Include(c => c.SubCategories)           // рівень 1
        .ThenInclude(c => c.SubCategories)  // рівень 2
            .ThenInclude(c => c.SubCategories) // рівень 3
    .ToListAsync();

Цей підхід простий, але обмежений: кожен ThenInclude — це додатковий JOIN у SQL, і кількість рівнів треба знати заздалегідь. При невідомій або великій глибині дерева потрібні альтернативи.

Рекурсивне завантаження в C# (ітеративний підхід)

Якщо дерево довільної глибини, але не надто велике за кількістю вузлів — можна завантажити всі категорії одним запитом, а потім зібрати дерево в пам'яті:

// Завантажуємо ВСІ категорії одним запитом (без Include!)
var allCategories = await context.Categories
    .AsNoTracking()
    .ToListAsync();

// Групуємо у словник: ParentId → List<Category>
var childrenByParent = allCategories
    .Where(c => c.ParentCategoryId.HasValue)
    .GroupBy(c => c.ParentCategoryId!.Value)
    .ToDictionary(g => g.Key, g => g.ToList());

// Знаходимо кореневі категорії
var roots = allCategories
    .Where(c => c.ParentCategoryId == null)
    .ToList();

// Рекурсивно заповнюємо SubCategories
void FillChildren(Category category)
{
    if (childrenByParent.TryGetValue(category.Id, out var children))
    {
        foreach (var child in children)
        {
            category.SubCategories.Add(child);
            FillChildren(child); // рекурсія
        }
    }
}

foreach (var root in roots)
    FillChildren(root);
// Результат: roots — список кореневих з вкладеними дітьми на всіх рівнях
// SQL: ONE single SELECT без JOIN

Цей підхід генерує один SQL-запит незалежно від глибини ієрархії. Недолік: всі записи таблиці завантажуються в пам'ять. Прийнятно для таблиць до ~50,000 рядків.

CTE WITH RECURSIVE: рекурсивний SQL

Коли таблиця категорій велика і потрібна лише гілка дерева починаючи з певного вузла — Raw SQL з WITH RECURSIVE є оптимальним рішенням:

// PostgreSQL / SQLite синтаксис CTE
var subtree = await context.Categories
    .FromSqlRaw("""
        WITH RECURSIVE category_tree AS (
            -- Anchor: стартовий вузол (наприклад, Id = 5)
            SELECT "Id", "Name", "ParentCategoryId", 0 AS "Depth"
            FROM "Categories"
            WHERE "Id" = {0}

            UNION ALL

            -- Recursive member: діти кожного знайденого вузла
            SELECT c."Id", c."Name", c."ParentCategoryId", ct."Depth" + 1
            FROM "Categories" c
            INNER JOIN category_tree ct ON c."ParentCategoryId" = ct."Id"
            WHERE ct."Depth" < 10  -- захист від циклів, якщо є
        )
        SELECT * FROM category_tree ORDER BY "Depth", "Name"
        """, 5)
    .AsNoTracking()
    .ToListAsync();

Для SQL Server синтаксис аналогічний, але з квадратними дужками:

-- SQL Server варіант
WITH CategoryTree AS (
    SELECT [Id], [Name], [ParentCategoryId], 0 AS [Depth]
    FROM [Categories]
    WHERE [Id] = @startId

    UNION ALL

    SELECT c.[Id], c.[Name], c.[ParentCategoryId], ct.[Depth] + 1
    FROM [Categories] c
    INNER JOIN CategoryTree ct ON c.[ParentCategoryId] = ct.[Id]
)
SELECT * FROM CategoryTree ORDER BY [Depth], [Name]

CTE виконується повністю на рівні бази даних — результат вже відфільтрована і відсортована гілка дерева. Ідеально для великих ієрархій (тисячі вузлів).

Приклад: ієрархія коментарів (threaded comments)

Самореферентний зв'язок є природнім для реалізації вкладених коментарів — коментар може бути відповіддю на інший коментар:

public class Comment
{
    public int Id { get; set; }
    public string Text { get; set; } = string.Empty;
    public string AuthorName { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }

    // FK до поста (обов'язковий — коментар завжди до конкретного поста)
    public int PostId { get; set; }
    public BlogPost Post { get; set; } = null!;

    // FK до батьківського коментаря (nullable — коментар верхнього рівня не має батька)
    public int? ParentCommentId { get; set; }

    // Навігація до батьківського коментаря
    public Comment? ParentComment { get; set; }

    // Навігація до відповідей
    public ICollection<Comment> Replies { get; set; } = new List<Comment>();
}

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;

    // Тільки коментарі верхнього рівня або всі?
    // Рекомендується: все через окремий запит
    public ICollection<Comment> Comments { get; set; } = new List<Comment>();
}

Конфігурація:

Configurations/CommentConfiguration.cs
public class CommentConfiguration : IEntityTypeConfiguration<Comment>
{
    public void Configure(EntityTypeBuilder<Comment> builder)
    {
        builder.HasKey(c => c.Id);

        builder.Property(c => c.Text)
               .IsRequired()
               .HasMaxLength(10000);

        builder.Property(c => c.AuthorName)
               .IsRequired()
               .HasMaxLength(200);

        builder.Property(c => c.CreatedAt)
               .HasDefaultValueSql("CURRENT_TIMESTAMP");

        // Зв'язок Comment → BlogPost (required)
        builder.HasOne(c => c.Post)
               .WithMany(p => p.Comments)
               .HasForeignKey(c => c.PostId)
               .IsRequired()
               .OnDelete(DeleteBehavior.Cascade); // видалення поста → видалення всіх коментарів

        // Self-referencing: Comment → ParentComment (optional)
        builder.HasOne(c => c.ParentComment)
               .WithMany(c => c.Replies)
               .HasForeignKey(c => c.ParentCommentId)
               .IsRequired(false)
               .OnDelete(DeleteBehavior.Restrict); // не каскад - щоб не видалити ланцюжок
    }
}

Завантаження дворівневого дерева коментарів для поста:

// Варіант 1: ThenInclude до 2-3 рівнів
var postWithComments = await context.BlogPosts
    .Where(p => p.Slug == "my-post")
    .Include(p => p.Comments.Where(c => c.ParentCommentId == null)) // тільки root коментарі
        .ThenInclude(c => c.Replies)         // перший рівень відповідей
            .ThenInclude(c => c.Replies)     // другий рівень відповідей
    .FirstAsync();

// Варіант 2: один запит, збірка дерева в пам'яті (краще для глибоких дерев)
var allComments = await context.Comments
    .Where(c => c.PostId == postId)
    .OrderBy(c => c.CreatedAt)
    .AsNoTracking()
    .ToListAsync();

// Групуємо відповіді за батьківським Id
var repliesByParent = allComments
    .Where(c => c.ParentCommentId.HasValue)
    .GroupBy(c => c.ParentCommentId!.Value)
    .ToDictionary(g => g.Key, g => g.OrderBy(c => c.CreatedAt).ToList());

// Кореневі коментарі (без батька для цього поста)
var rootComments = allComments
    .Where(c => c.ParentCommentId == null)
    .OrderBy(c => c.CreatedAt)
    .ToList();

// Рекурсивна збірка
void AttachReplies(Comment comment)
{
    if (repliesByParent.TryGetValue(comment.Id, out var replies))
    {
        foreach (var reply in replies)
        {
            comment.Replies.Add(reply);
            AttachReplies(reply);
        }
    }
}

foreach (var root in rootComments)
    AttachReplies(root);

Видалення коментаря з відповідями (оскільки Restrict, треба власноруч):

// Видалення коментаря і всіх його відповідей знизу вгору
public async Task DeleteCommentTreeAsync(int commentId)
{
    // Завантажуємо всі відповіді рекурсивно
    var toDelete = new List<Comment>();

    async Task CollectReplies(int parentId)
    {
        var replies = await context.Comments
            .Where(c => c.ParentCommentId == parentId)
            .ToListAsync();

        foreach (var reply in replies)
        {
            await CollectReplies(reply.Id); // спочатку глибші рівні
            toDelete.Add(reply);
        }
    }

    await CollectReplies(commentId);

    // Додаємо сам коментар
    var comment = await context.Comments.FindAsync(commentId);
    if (comment is not null) toDelete.Add(comment);

    // Видаляємо від листків до кореня (щоб не порушити FK Restrict)
    context.Comments.RemoveRange(toDelete);
    await context.SaveChangesAsync();
}
Для глибоких ієрархій коментарів розгляньте зберігання шляху (Materialized Path патерн): додайте поле Path: string зі значенням типу /1/5/12/ — це дозволяє вибрати всіх нащадків одним WHERE Path LIKE '/1/5/%' без рекурсії.

Курсорна пагінація коментарів

Коли коментарів під постом тисячі — завантажувати їх усі одразу нерозумно. OFFSET-пагінація (Skip(20).Take(10)) має відомий недолік: при великому зміщенні база все одно «пробігає» пропущені рядки. Для коментарів природніша курсорна пагінація (keyset pagination): замість «пропусти N рядків» — «покажи рядки після цього конкретного коментаря».

Курсором для коментарів зручно використовувати пару (CreatedAt, Id) — це дає стабільний порядок навіть при однаковому CreatedAt:

// Запит першої сторінки кореневих коментарів
public record CommentCursor(DateTime CreatedAt, int Id);

public record CommentsPage(
    IReadOnlyList<Comment> Items,
    CommentCursor? NextCursor,  // null якщо це остання сторінка
    bool HasMore);

public async Task<CommentsPage> GetRootCommentsAsync(
    int postId,
    CommentCursor? afterCursor = null,  // null = перша сторінка
    int pageSize = 20)
{
    var query = context.Comments
        .Where(c => c.PostId == postId && c.ParentCommentId == null)
        .AsNoTracking();

    // Застосовуємо cursor: беремо коментарі "після" відомої точки
    if (afterCursor is not null)
    {
        query = query.Where(c =>
            c.CreatedAt > afterCursor.CreatedAt ||           // пізніші за датою
            (c.CreatedAt == afterCursor.CreatedAt && c.Id > afterCursor.Id)); // або в той самий момент, але з більшим Id
    }

    // Беремо на один більше щоб перевірити: є ще сторінки?
    var items = await query
        .OrderBy(c => c.CreatedAt)
        .ThenBy(c => c.Id)          // стабільне сортування: Id гарантує порядок при однаковому часі
        .Take(pageSize + 1)
        .ToListAsync();

    var hasMore = items.Count > pageSize;
    if (hasMore)
        items.RemoveAt(items.Count - 1); // прибираємо "зайвий" елемент-маркер

    // Наступний cursor — це останній елемент поточної сторінки
    var nextCursor = hasMore
        ? new CommentCursor(items[^1].CreatedAt, items[^1].Id)
        : null;

    return new CommentsPage(items, nextCursor, hasMore);
}

Використання в коді (наприклад, у Controller або Service):

// Перша сторінка
var page1 = await GetRootCommentsAsync(postId: 42, pageSize: 20);

// Відображаємо page1.Items
foreach (var comment in page1.Items)
    Console.WriteLine($"[{comment.CreatedAt:HH:mm}] {comment.AuthorName}: {comment.Text}");

// Якщо є наступна сторінка — зберігаємо cursor для кнопки "Завантажити ще"
if (page1.HasMore)
{
    // Друга сторінка: передаємо cursor від першої
    var page2 = await GetRootCommentsAsync(
        postId: 42,
        afterCursor: page1.NextCursor,
        pageSize: 20);
}

Генерований SQL для другої сторінки виглядає приблизно так:

-- PostgreSQL
SELECT c."Id", c."Text", c."AuthorName", c."CreatedAt", c."ParentCommentId"
FROM "Comments" c
WHERE c."PostId" = 42
  AND c."ParentCommentId" IS NULL
  AND (c."CreatedAt" > '2024-03-15 14:23:01'              -- після курсора
    OR (c."CreatedAt" = '2024-03-15 14:23:01' AND c."Id" > 1047))
ORDER BY c."CreatedAt" ASC, c."Id" ASC
LIMIT 21;   -- pageSize + 1 для перевірки "є ще?"

Жодного OFFSET — БД одразу переходить до потрібної точки через індекс. При мільйоні коментарів перша і мільйонна сторінки завантажуються з однаковою швидкістю.

Щоб це ефективно працювало, потрібен складений індекс:

Configurations/CommentConfiguration.cs
// Додаємо у Configure():
builder.HasIndex(c => new { c.PostId, c.ParentCommentId, c.CreatedAt, c.Id })
       .HasDatabaseName("IX_Comments_Post_Cursor");
// Цей індекс покриває WHERE PostId = ? AND ParentCommentId IS NULL ORDER BY CreatedAt, Id

Для відповідей (replies) на конкретний коментар — та сама логіка, але фільтруємо по ParentCommentId:

public async Task<CommentsPage> GetRepliesAsync(
    int parentCommentId,
    CommentCursor? afterCursor = null,
    int pageSize = 10)   // відповідей зазвичай менше — менша сторінка
{
    var query = context.Comments
        .Where(c => c.ParentCommentId == parentCommentId)
        .AsNoTracking();

    if (afterCursor is not null)
    {
        query = query.Where(c =>
            c.CreatedAt > afterCursor.CreatedAt ||
            (c.CreatedAt == afterCursor.CreatedAt && c.Id > afterCursor.Id));
    }

    var items = await query
        .OrderBy(c => c.CreatedAt)
        .ThenBy(c => c.Id)
        .Take(pageSize + 1)
        .ToListAsync();

    var hasMore = items.Count > pageSize;
    if (hasMore) items.RemoveAt(items.Count - 1);

    return new CommentsPage(
        items,
        hasMore ? new CommentCursor(items[^1].CreatedAt, items[^1].Id) : null,
        hasMore);
}

Типова інтеграція для REST API — через query parameters:

// GET /api/posts/42/comments?after_id=1047&after_date=2024-03-15T14:23:01Z&page_size=20
[HttpGet("{postId}/comments")]
public async Task<IActionResult> GetComments(
    int postId,
    [FromQuery(Name = "after_id")] int? afterId,
    [FromQuery(Name = "after_date")] DateTime? afterDate,
    [FromQuery(Name = "page_size")] int pageSize = 20)
{
    CommentCursor? cursor = (afterId.HasValue && afterDate.HasValue)
        ? new CommentCursor(afterDate.Value, afterId.Value)
        : null;

    var page = await GetRootCommentsAsync(postId, cursor, Math.Min(pageSize, 100));

    return Ok(new
    {
        items    = page.Items.Select(MapToDto),
        has_more = page.HasMore,
        // Cursor для наступного запиту — клієнт передає їх назад
        next_after_id   = page.NextCursor?.Id,
        next_after_date = page.NextCursor?.CreatedAt
    });
}
Курсорна пагінація має одне обмеження порівняно з OFFSET: не можна перейти на довільну сторінку (наприклад, «сторінка 5 з 20»). Вона підходить для нескінченної прокрутки (infinite scroll) і кнопки «завантажити ще» — саме так і організовані коментарі у більшості соціальних мереж і платформ.

One-to-One самореферентний

Рідкісний, але зустрічається — наприклад, ланцюжок версій документа, де кожна версія вказує на попередню:

public class DocumentVersion
{
    public int Id { get; set; }
    public string Content { get; set; } = string.Empty;
    public int Version { get; set; }

    public int? PreviousVersionId { get; set; }
    public DocumentVersion? PreviousVersion { get; set; } // Optional One-to-One
}

DeleteBehavior: повний розбір із прикладами

Ми вже стикались із DeleteBehavior раніше. Тепер розберемо кожен варіант на конкретних прикладах, щоб чітко розуміти вибір.

Сценарій: Author → Books (Required, FK NOT NULL)

У цьому зв'язку Book завжди має Author. Що відбудеться, якщо видалити Author?

DeleteBehavior.Cascade (SQL CASCADE)

.OnDelete(DeleteBehavior.Cascade)

EF Core налаштовує ON DELETE CASCADE у FK constraint. При видаленні Author СУБД автоматично видаляє всі пов'язані Books. Це відбувається на рівні бази — навіть якщо Books не завантажені в ChangeTracker.

// Cascade: Books видаляються автоматично
var author = await context.Authors.FindAsync(1);
context.Authors.Remove(author!);
await context.SaveChangesAsync(); // Видалить author і всі його books. БД сама дбає.

Використовуйте Cascade для: залежних сутностей без власного сенсу існування (OrderItems без Order, Comments без BlogPost).

DeleteBehavior.Restrict

.OnDelete(DeleteBehavior.Restrict)

Видалення Author заборонено, якщо є хоча б одна пов'язана Book. База кине FK violation exception.

var author = await context.Authors
    .Include(a => a.Books)
    .FirstAsync(a => a.Id == 1);

if (author.Books.Any())
{
    // Треба спочатку видалити або перепризначити всі книги
    throw new BusinessException("Не можна видалити автора з книгами");
}

context.Authors.Remove(author);
await context.SaveChangesAsync();

Використовуйте Restrict для: бізнес-сутностей, де видалення батька без очищення дочірніх є логічною помилкою (Customer з Orders, Product зі OrderItems).

DeleteBehavior.SetNull

.OnDelete(DeleteBehavior.SetNull) // Вимагає nullable FK!

При видаленні Principal — FK у Dependent встановлюється в NULL. Дочірні записи залишаються, але вже без батька. Вимагає nullable FK.

// Book.AuthorId повинен бути int? (nullable)
// При видаленні Author: Book.AuthorId = NULL
var author = await context.Authors.FindAsync(1);
context.Authors.Remove(author!);
await context.SaveChangesAsync();
// SQL: DELETE FROM Authors WHERE Id = 1;
//      UPDATE Books SET AuthorId = NULL WHERE AuthorId = 1;

Використовуйте SetNull для: необов'язкових зв'язків, де дочірня сутність має сенс без батька (Article без assigned Editor, Product без assigned Category).

DeleteBehavior.NoAction

База не виконує жодних дій при видаленні. Якщо є залежні записи — отримаєте FK violation при commit. Відрізняється від Restrict моментом перевірки: Restrict перевіряє одразу, NoAction може відкласти перевірку до кінця транзакції (залежить від СУБД).

DeleteBehavior.ClientSetNull (дефолт для Optional)

EF Core обнуляє FK у завантажених і відстежуваних об'єктах, але не задає ON DELETE на рівні БД. Якщо залежні об'єкти не завантажені — FK залишається з попереднім значенням і отримаєте FK violation.

// ClientSetNull: працює ТІЛЬКИ якщо Books завантажені в ChangeTracker
var author = await context.Authors
    .Include(a => a.Books) // ← критично важливо!
    .FirstAsync(a => a.Id == 1);

context.Authors.Remove(author);
await context.SaveChangesAsync();
// EF Core: book.AuthorId = null для кожного завантаженого book
// БД: немає ON DELETE → авторська перевірка на стороні клієнта
ClientSetNull — небезпечна поведінка за замовчуванням для Optional зв'язків. Якщо забудете Include() — отримаєте FK violation або orphaned records. Явно задавайте SetNull або Restrict замість покладання на дефолт.

Ключові сценарії: конфігурація по ситуаціях

Зберемо найпоширеніші сценарії у практичний довідник:

Блог: пост та коментарі

// Required One-to-Many: коментар ЗАВЖДИ належить посту
builder.HasOne(c => c.Post)
       .WithMany(p => p.Comments)
       .HasForeignKey(c => c.PostId)
       .IsRequired()
       .OnDelete(DeleteBehavior.Cascade); // видалення поста → видалення всіх коментарів

Магазин: замовлення та клієнт

// Required One-to-Many, але без каскаду (клієнт з замовленнями не видаляється)
builder.HasOne(o => o.Customer)
       .WithMany(c => c.Orders)
       .HasForeignKey(o => o.CustomerId)
       .IsRequired()
       .OnDelete(DeleteBehavior.Restrict); // не можна видалити клієнта з замовленнями

Стаття та категорія (optional)

// Optional One-to-Many: стаття може бути без категорії
builder.HasOne(a => a.Category)
       .WithMany(c => c.Articles)
       .HasForeignKey(a => a.CategoryId) // int? nullable
       .IsRequired(false)
       .OnDelete(DeleteBehavior.SetNull); // видалення категорії → CategoryId = NULL

Автор та контактна інформація

// Optional One-to-One: Author може існувати без ContactInfo
builder.HasOne(a => a.ContactInfo)
       .WithOne(ci => ci.Author)
       .HasForeignKey<ContactInfo>(ci => ci.AuthorId)
       .IsRequired()
       .OnDelete(DeleteBehavior.Cascade); // видалення автора → видалення ContactInfo

Правила хорошого тону при роботі зі зв'язками

Ряд практик, що суттєво спрощують роботу зі зв'язками в EF Core:

Правило 1: Завжди включайте FK-властивість явно.

Не покладайтесь на Shadow Properties для FK. Явне public int AuthorId { get; set; } дає вам доступ до FK без завантаження навігаційної властивості, спрощує перевірку і дає кращу продуктивність:

// ✅ Явний FK: можна оновити без завантаження Author
var book = await context.Books.FindAsync(bookId);
book!.AuthorId = newAuthorId; // Просто змінюємо int — без Include!
await context.SaveChangesAsync();

// ❌ Без FK: потрібно завантажувати Author
var book = await context.Books.Include(b => b.Author).FirstAsync(b => b.Id == bookId);
book.Author = await context.Authors.FindAsync(newAuthorId);
await context.SaveChangesAsync();

Правило 2: Явно задавайте OnDelete.

Не покладайтесь на дефолтні значення. OnDelete(DeleteBehavior.Cascade) або OnDelete(DeleteBehavior.Restrict) — явно, у конфігурації, з коментарем якщо потрібно.

Правило 3: Ініціалізуйте колекції.

// ✅ Колекція завжди не null — без NullReferenceException
public ICollection<Book> Books { get; set; } = new List<Book>();

// ❌ Null колекція: NullReferenceException при foreach без Include
public ICollection<Book> Books { get; set; }

Правило 4: При зборці зв'язків — завантажуйте Principal до додавання Dependent.

// ❌ Неправильно: Author не завантажений, EF Core може не правильно відслідкувати
var book = new Book { AuthorId = 1, Title = "New Book" };
context.Books.Add(book);
await context.SaveChangesAsync(); // Нормально, але Author.Books порожня

// ✅ Правильно: якщо потрібна двостороння консистентність
var author = await context.Authors.FindAsync(1);
var book = new Book { Title = "New Book" };
author!.Books.Add(book); // EF Core автоматично заповнить book.AuthorId
await context.SaveChangesAsync();

Практичні завдання

Рівень 1: Базові зв'язки

Завдання 1.1 — Реалізуйте модель університету з кількома One-to-Many зв'язками: DepartmentCourse (Required, один відділ може бути видалений тільки якщо немає курсів), CourseLesson (Required, каскадне видалення), StudentSubmission (Required, каскад). Запустіть міграцію і перевірте DDL: де CASCADE, де RESTRICT?

Завдання 1.2 — Реалізуйте Optional One-to-One між Employee і ParkingSpot (одному співробітнику може бути виданий одне місце парковки, або не видано). Де буде FK — в Employee чи ParkingSpot? Чому? Перевірте обидва варіанти і поясніть різницю у DDL.

Завдання 1.3 — Продемонструйте різницю між DeleteBehavior.Cascade і DeleteBehavior.ClientSetNull. Напишіть два тести: (а) видалення з завантаженими залежними (Include) → ClientSetNull спрацьовує; (б) видалення без завантаження (без Include) → ClientSetNull не захищає. Що потрібно змінити у другому тесті?

Рівень 2: Ієрархія та складні сценарії

Завдання 2.1 — Реалізуйте самореферентну ієрархію для системи навігаційного меню: MenuItem { Id, Title, Url, OrderIndex, ParentId?, SubItems }. Напишіть метод GetMenuTree(), що повертає всі кореневі пункти меню з підменю до 3 рівнів. Порівняйте кількість SQL-запитів з Include().ThenInclude() vs ітеративним завантаженням.

Завдання 2.2 — Реалізуйте систему, де між User і Role є явна join entity UserRole з додатковим полем AssignedAt: DateTime. Налаштуйте складений PK {UserId, RoleId}. Напишіть код, що: (а) призначає роль користувачу, (б) перевіряє, чи є у користувача роль, (в) відкликає роль. Переконайтесь, що один і той самий User не може мати дублюючу Role.

Завдання 2.3 — Використовуйте DebugView.LongView ChangeTracker'а для детального вивчення того, як EF Core відстежує зв'язані сутності. Створіть Author з трьома Books. Після збереження: (а) виведіть DebugView, (б) видаліть одну Book, виведіть DebugView знову, (в) після SaveChanges виведіть DebugView вкотре. Проаналізуйте зміни станів.

Рівень 3: Складні патерни

Завдання 3.1 — Ланцюжок версій: Реалізуйте систему версіонування документів: Document { Id, CurrentVersionId, CreatedById } і DocumentVersion { Id, DocumentId, Number, Content, CreatedAt, PreviousVersionId? }. Зв'язки: Document → DocumentVersion (Current: One-to-One), Document → ICollection<DocumentVersion> (History: One-to-Many), DocumentVersion → DocumentVersion (PreviousVersion: Self-ref One-to-One Optional). Напишіть метод CreateNewVersion(int documentId, string content), що атомарно: (а) зберігає нову версію, (б) оновлює Document.CurrentVersionId.

Завдання 3.2 — Backing field + DDD: Реалізуйте ShoppingCart з CartItem, де: (а) CartItem доступні зовні тільки через IReadOnlyCollection<CartItem> через backing field, (б) додавання CartItem тільки через cart.AddItem(product, qty) з валідацією (не можна додати той самий продукт двічі — натомість збільшити кількість), (в) видалення через cart.RemoveItem(productId). Налаштуйте Fluent API для backing field. Перевірте, що EF Core правильно зберігає і завантажує CartItem через backing field.


Підсумок

Ключові думки цієї статті:
  • Principal — сторона зі своїм PK (батько); Dependent — сторона з FK (дитина). FK завжди у Dependent.
  • Required One-to-Many: FK NOT NULL → Cascade за замовчуванням. Optional: FK nullable → ClientSetNull за замовчуванням (небезпечно! краще SetNull або Restrict явно).
  • One-to-One: EF Core не може автоматично визначити Principal/Dependent — необхідний Fluent API з HasForeignKey<TDependent>().
  • Навігаційна властивість — колекція → One-to-Many; посилання → One-to-One або Many-to-One.
  • Односторонні навігації: ідентичний DDL, різна C#-модель. Краще для великих колекцій і строгого DDD.
  • Self-referencing: nullable FK для кореневих елементів; Restrict замість Cascade (обережно з СУБД).
  • DeleteBehavior: Cascade (видаляє), Restrict (забороняє), SetNull (обнуляє FK), ClientSetNull (обнуляє тільки у ChangeTracker).
  • Завжди оголошуйте FK-поле явно — це дозволяє оновлювати зв'язок без завантаження навігаційних властивостей.
  • Ініціалізуйте колекції у конструкторі або в місці оголошення: = new List<Book>().

Наступна стаття — Зв'язки Advanced: Many-to-Many та Складні Сценарії — розбирає M:N зв'язки з implicit та explicit join entity, skip navigations, alternate keys, shadow FK, складні графи об'єктів.