Якщо попросити досвідченого .NET розробника назвати тему в EF Core, де найчастіше роблять помилки, — більшість вкаже на зв'язки. Додавання нового запису до колекції і він не зберігається. Видалення батьківського запису і отримання FK-violation. Загрузка сутності і навігаційна властивість порожня, хоча дані в базі є. Cascade delete, що несподівано видаляє набагато більше, ніж очікувалось.
Причина цих проблем — не в складності синтаксису EF Core. Він доволі простий. Причина — у тому, що зв'язки між сутностями мають кілька ортогональних вимірів, і кожен з них впливає на поведінку:
Ця стаття розбирає перші два типи зв'язків — One-to-One і One-to-Many — не як набір API-викликів, а як концепцію з усіма наведеними вимірами. Розуміння кожного з них дозволить передбачати поведінку EF Core без звернення до документації.
Перш ніж говорити про конкретні типи зв'язків, розберемо найважливіші поняття, яких не вистачає більшості пояснень.
У будь-якому зв'язку між двома сутностями є асиметрія: одна сторона «знає» про іншу через зовнішній ключ, інша цього зовнішнього ключа не має.
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
}
Це розрізнення критично для розуміння:
Один автор може написати багато книг. Одна категорія може містити багато продуктів. Один відділ може мати багато співробітників. One-to-Many — це основний зв'язок у більшості доменних моделей.
EF Core ідентифікує One-to-Many за комбінацією навігаційних властивостей:
ICollection<T>, IList<T>, List<T>, IEnumerable<T>)Author, Category — не колекція)Якщо ці умови виконані — EF Core автоматично визначає тип зв'язку без будь-якої конфігурації.
Більшість 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.
Іноді зв'язок необов'язковий: у автора може не бути видавця (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 (забороняє видалення, доки є залежні).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 зв'язок складніший, ніж здається. У SQL не існує нативного механізму гарантувати One-to-One на рівні FK — FK constraint дозволяє N рядків з однаковим FK. Унікальність забезпечується через Unique Index або через те, що FK є водночас PK.
EF Core реалізує One-to-One одним із двох способів:
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");
Більш рідкісний, але елегантний: 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 вже унікальний
Особливість: EF Core не може автоматично визначити, хто є Principal, а хто Dependent у One-to-One — обидві сторони мають навігацію без колекції. Тому Fluent API є обов'язковим або принаймні рекомендованим:
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);
}
}
HasForeignKey<TDependent>() з явним generic-параметром, що вказує на Dependent. Без цього EF Core може переплутати Principal і Dependent, і FK буде у неправильній таблиці.Навігаційні властивості є «мостом» між об'єктною моделлю і реляційними зв'язками. Вони дозволяють переходити між сутностями через C#-властивості замість явних SQL JOIN.
Використовується для 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)
}
Використовується для 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 звертається до поля
Двостороння (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!
Односторонні навігації корисні, коли:
Order знає про Customer, але Customer не завжди потребує список всіх своїх Orders у моделі)DDL для односторонньої навігації ідентичний DDL для двосторонньої. Це виключно конструкція C#-моделі.
Самореферентний (self-referencing) зв'язок — це зв'язок сутності зі своїм власним типом. Найкласичний приклад — ієрархічна структура: категорії з підкатегоріями, коментарі з відповідями, організаційна ієрархія співробітників.
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
);
Restrict і видаляти дочірні вручну знизу вгору.Fluent API для самореферентного One-to-Many:
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, і кількість рівнів треба знати заздалегідь. При невідомій або великій глибині дерева потрібні альтернативи.
Якщо дерево довільної глибини, але не надто велике за кількістю вузлів — можна завантажити всі категорії одним запитом, а потім зібрати дерево в пам'яті:
// Завантажуємо ВСІ категорії одним запитом (без 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 рядків.
Коли таблиця категорій велика і потрібна лише гілка дерева починаючи з певного вузла — 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 виконується повністю на рівні бази даних — результат вже відфільтрована і відсортована гілка дерева. Ідеально для великих ієрархій (тисячі вузлів).
Самореферентний зв'язок є природнім для реалізації вкладених коментарів — коментар може бути відповіддю на інший коментар:
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>();
}
Конфігурація:
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();
}
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 — БД одразу переходить до потрібної точки через індекс. При мільйоні коментарів перша і мільйонна сторінки завантажуються з однаковою швидкістю.
Щоб це ефективно працювало, потрібен складений індекс:
// Додаємо у 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
});
}
Рідкісний, але зустрічається — наприклад, ланцюжок версій документа, де кожна версія вказує на попередню:
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 раніше. Тепер розберемо кожен варіант на конкретних прикладах, щоб чітко розуміти вибір.
У цьому зв'язку 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 зв'язками: Department → Course (Required, один відділ може бути видалений тільки якщо немає курсів), Course → Lesson (Required, каскадне видалення), Student → Submission (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.
HasForeignKey<TDependent>().DeleteBehavior: Cascade (видаляє), Restrict (забороняє), SetNull (обнуляє FK), ClientSetNull (обнуляє тільки у ChangeTracker).= new List<Book>().Наступна стаття — Зв'язки Advanced: Many-to-Many та Складні Сценарії — розбирає M:N зв'язки з implicit та explicit join entity, skip navigations, alternate keys, shadow FK, складні графи об'єктів.
Fluent API та Data Annotations — Явна конфігурація моделі
Детальний розбір двох підходів до конфігурації EF Core моделі — Data Annotations і Fluent API. Коли що використовувати, повний перелік атрибутів і методів, організація через IEntityTypeConfiguration, ApplyConfigurationsFromAssembly та практичні патерни.
Зв'язки Advanced — Many-to-Many та Складні Сценарії
Глибокий розбір Many-to-Many зв'язків в EF Core — implicit і explicit join entity, skip navigations, alternate keys, composite FK, shadow FK, backing fields, polymorphic associations як антипатерн та правильні альтернативи, складні графи об'єктів.