One-to-Many є природна реляційна конструкція: FK у залежній таблиці — і всі справи. Але Many-to-Many у реляційних базах даних неможливо реалізувати напряму. Стовпець у таблиці не може зберігати «список значень» — він зберігає рівно одне. Тому M:N завжди реалізується через проміжну (join) таблицю, що містить по одному FK до кожної зі сторін зв'язку.
До EF Core 5 M:N завжди вимагав явного класу join entity і двох One-to-Many зв'язків. EF Core 5 ввів implicit join entity — можливість описати M:N напряму через skip navigations, без явного C#-класу. EF Core сам генерує join таблицю і управляє нею.
Але це зовнішній погляд. Насправді вибір між implicit і explicit join entity — це суттєве архітектурне рішення, яке впливає на те, скільки даних ви можете зберігати у зв'язку, як ви отримуєте доступ до join таблиці і яку гнучкість маєте у запитах.
Implicit Many-to-Many підходить, коли вам потрібно лише зафіксувати факт зв'язку між двома сутностями — без жодних додаткових даних про цей зв'язок.
Класичний приклад: книги і теги. Книга може мати багато тегів, тег може стояти на багатьох книгах. Самого факту зв'язку достатньо — нічого більше знати про нього не потрібно.
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public decimal Price { get; set; }
// Skip navigation: Book → Tags (оминаючи join entity)
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
// Skip navigation: Tag → Books (оминаючи join entity)
public ICollection<Book> Books { get; set; } = new List<Book>();
}
Це все, що потрібно. EF Core виявить M:N автоматично: обидва класи мають колекційні навігаційні властивості один до одного, і жоден з них не міг би мати FK до іншого (оскільки обидва є «principal» одночасно у різних напрямках).
Що EF Core генерує:
CREATE TABLE "Books" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"Title" TEXT NOT NULL,
"Price" TEXT NOT NULL
);
CREATE TABLE "Tags" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL
);
-- Автоматично згенерована join таблиця
CREATE TABLE "BookTag" (
"BooksId" INTEGER NOT NULL,
"TagsId" INTEGER NOT NULL,
CONSTRAINT "PK_BookTag" PRIMARY KEY ("BooksId", "TagsId"),
CONSTRAINT "FK_BookTag_Books_BooksId" FOREIGN KEY ("BooksId") REFERENCES "Books" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_BookTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE
);
Зверніть на назву join таблиці: "BookTag" — об'єднання назв двох типів у алфавітному порядку. EF Core також додає обидва каскади: видалення Book видаляє всі рядки з BookTag для цієї книги; видалення Tag — аналогічно.
Мінімальна конфігурація для перейменування join таблиці або зміни назв FK-стовпців:
public class BookConfiguration : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
builder.HasMany(b => b.Tags)
.WithMany(t => t.Books)
.UsingEntity(joinEntity =>
{
// Кастомна назва join таблиці
joinEntity.ToTable("BookTags");
// Кастомні назви FK-стовпців
joinEntity.Property<int>("BooksId").HasColumnName("BookId");
joinEntity.Property<int>("TagsId").HasColumnName("TagId");
});
}
}
З повним контролем над join таблицею:
builder.HasMany(b => b.Tags)
.WithMany(t => t.Books)
.UsingEntity<Dictionary<string, object>>(
"BookTags", // назва join entity type у моделі та таблиці
jt => jt.HasOne<Tag>().WithMany().HasForeignKey("TagId"),
jt => jt.HasOne<Book>().WithMany().HasForeignKey("BookId"),
jt =>
{
jt.HasKey("BookId", "TagId");
jt.HasIndex("TagId"); // додатковий індекс для пошуку по TagId
});
// Додавання зв'язку: просто додаємо до колекції
var book = await context.Books.FindAsync(1);
var tag = await context.Tags
.FirstOrDefaultAsync(t => t.Name == "dotnet");
if (tag is null)
{
tag = new Tag { Name = "dotnet" };
}
book!.Tags.Add(tag);
await context.SaveChangesAsync();
// SQL: INSERT INTO "BookTags" ("BookId", "TagId") VALUES (1, ...)
// Видалення зв'язку: видаляємо з колекції
var bookWithTags = await context.Books
.Include(b => b.Tags)
.FirstAsync(b => b.Id == 1);
var tagToRemove = bookWithTags.Tags.FirstOrDefault(t => t.Name == "dotnet");
if (tagToRemove is not null)
{
bookWithTags.Tags.Remove(tagToRemove);
await context.SaveChangesAsync();
// SQL: DELETE FROM "BookTags" WHERE "BookId" = 1 AND "TagId" = ...
}
tag завантажений з іншого контексту або є Detached — book.Tags.Add(tag) може кинути виключення або зберегти дублікат. Для безпечного UPSERT зв'язку краще використовувати знайдений через той самий контекст або явно context.Attach(tag).Implicit M:N зручний, але має суттєве обмеження: join таблиця містить лише два FK. Як тільки потрібно зберегти додаткові дані про зв'язок — дату призначення, роль у зв'язку, пріоритет — implicit перестає виконувати своє завдання.
Для цього використовують explicit join entity — звичайний C#-клас, що представляє рядок у join таблиці.
public class Student
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
// Skip navigation: Student → Courses (через Enrollment)
public ICollection<Course> Courses { get; set; } = new List<Course>();
// Direct navigation до join entity (якщо потрібна деталізація)
public ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();
}
public class Course
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public int Credits { get; set; }
public ICollection<Student> Students { get; set; } = new List<Student>();
public ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();
}
// Explicit join entity — з payload (додатковими даними)
public class Enrollment
{
// Складений PK (або окремий Id — розглянемо нижче)
public int StudentId { get; set; }
public int CourseId { get; set; }
// Payload: дані про сам зв'язок
public DateTime EnrolledAt { get; set; }
public decimal? Grade { get; set; }
public EnrollmentStatus Status { get; set; }
public string? Notes { get; set; }
// Navigation properties
public Student Student { get; set; } = null!;
public Course Course { get; set; } = null!;
}
public enum EnrollmentStatus { Active, Completed, Dropped, Suspended }
Конфігурація:
public class EnrollmentConfiguration : IEntityTypeConfiguration<Enrollment>
{
public void Configure(EntityTypeBuilder<Enrollment> builder)
{
// Складений первинний ключ
builder.HasKey(e => new { e.StudentId, e.CourseId });
// Payload властивості
builder.Property(e => e.EnrolledAt)
.HasDefaultValueSql("CURRENT_TIMESTAMP");
builder.Property(e => e.Grade)
.HasPrecision(4, 2);
builder.Property(e => e.Status)
.HasConversion<string>()
.HasMaxLength(50);
builder.Property(e => e.Notes)
.HasMaxLength(1000);
// Зв'язки
builder.HasOne(e => e.Student)
.WithMany(s => s.Enrollments)
.HasForeignKey(e => e.StudentId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.Course)
.WithMany(c => c.Enrollments)
.HasForeignKey(e => e.CourseId)
.OnDelete(DeleteBehavior.Cascade);
}
}
Конфігурація skip navigations у StudentConfiguration і CourseConfiguration:
public class StudentConfiguration : IEntityTypeConfiguration<Student>
{
public void Configure(EntityTypeBuilder<Student> builder)
{
builder.HasKey(s => s.Id);
builder.Property(s => s.Name).IsRequired().HasMaxLength(200);
// Зв'язок Student ↔ Course через Enrollment
builder.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.UsingEntity<Enrollment>(
se => se.HasOne(e => e.Course).WithMany(c => c.Enrollments).HasForeignKey(e => e.CourseId),
se => se.HasOne(e => e.Student).WithMany(s => s.Enrollments).HasForeignKey(e => e.StudentId));
}
}
Є дві стратегії для PK join entity:
Складений PK (рекомендовано):
builder.HasKey(e => new { e.StudentId, e.CourseId });
Переваги: Автоматично гарантує унікальність пари (студент не може бути записаний на курс двічі), семантично правильно, менше індексів.
Недоліки: Складений PK може бути незручним при деяких ORMах або якщо join entity стає референтною таблицею для інших (маловірогідно).
Surrogate Id (окремий автоінкрементний ключ):
public class Enrollment
{
public int Id { get; set; } // surrogate PK
public int StudentId { get; set; }
public int CourseId { get; set; }
// ...
}
builder.HasKey(e => e.Id);
builder.HasIndex(e => new { e.StudentId, e.CourseId }).IsUnique(); // замінник складеного PK
Переваги: Простіший PK для зовнішніх посилань, звичний AutoIncrement.
Недоліки: Потрібен Unique Index для захисту від дублікатів, один зайвий стовпець.
EF Core 5 ввів концепцію skip navigation — навігаційна властивість, що дозволяє переходити «через» join entity, не маючи справи з нею у C#-коді:
// Student.Courses — skip navigation: Student → через Enrollment → Course
var studentWithCourses = await context.Students
.Include(s => s.Courses) // підтягує Courses через join таблицю
.FirstAsync(s => s.Id == 1);
// Ми бачимо Courses напряму — Enrollments не потрібні
foreach (var course in studentWithCourses.Courses)
{
Console.WriteLine(course.Title);
}
Генерований SQL виконує JOIN на join таблицю автоматично:
SELECT s."Id", s."Name", c."Id", c."Title", c."Credits"
FROM "Students" AS s
INNER JOIN "Enrollments" AS e ON s."Id" = e."StudentId"
INNER JOIN "Courses" AS c ON e."CourseId" = c."Id"
WHERE s."Id" = 1
Але якщо потрібні дані з самого join entity (наприклад, Grade або EnrolledAt):
// Потрібна деталізація з join entity → завантажуємо через Enrollments
var studentWithDetails = await context.Students
.Include(s => s.Enrollments)
.ThenInclude(e => e.Course) // через join → до Course
.FirstAsync(s => s.Id == 1);
foreach (var enrollment in studentWithDetails.Enrollments)
{
Console.WriteLine($"{enrollment.Course.Title}: {enrollment.Grade ?? "no grade"}");
Console.WriteLine($"Enrolled: {enrollment.EnrolledAt:d}, Status: {enrollment.Status}");
}
Skip navigations і прямі навігації до join entity можуть існувати одночасно в одному класі — і це нормально. Skip navigation для простих випадків «які курси є у студента», пряма навігація для деталізованих випадків з payload.
Для розуміння і сумісності з існуючим кодом — класичний підхід без skip navigations:
// До EF Core 5: немає skip navigations, тільки навігації через join entity
public class StudentLegacy
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
// Тільки навігація до join entity — НЕ до Course
public ICollection<EnrollmentLegacy> Enrollments { get; set; } = new List<EnrollmentLegacy>();
}
public class CourseLegacy
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public ICollection<EnrollmentLegacy> Enrollments { get; set; } = new List<EnrollmentLegacy>();
}
public class EnrollmentLegacy
{
public int StudentId { get; set; }
public int CourseId { get; set; }
public StudentLegacy Student { get; set; } = null!;
public CourseLegacy Course { get; set; } = null!;
}
У цьому випадку отримати курси студента — через student.Enrollments.Select(e => e.Course). Більш багатослівно, але дозволяє завжди бачити join entity явно.
За замовчуванням FK завжди вказує на Primary Key referenced сутності. Але іноді потрібно побудувати зв'язок, де FK вказує на унікальне, але не PK поле — наприклад, ISBN книги або Email користувача.
Такі поля в EF Core називаються Alternate Keys (альтернативні ключі). Вони є унікальними ідентифікаторами сутності, відмінними від PK. Alternate Key стає target для FK іншої сутності.
Уявіть систему замовлень, де Order має посилання на Product не через ProductId (числовий PK), а через ProductSku (рядковий код, унікальний для продукту). Це реальна ситуація при інтеграції з зовнішніми системами, що ідентифікують продукти через SKU, а не через внутрішній Id.
public class Product
{
public int Id { get; set; } // Primary Key
public string Sku { get; set; } = string.Empty; // Alternate Key (унікальний)
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
public class OrderItem
{
public int Id { get; set; }
public int Quantity { get; set; }
// FK вказує не на Product.Id, а на Product.Sku!
public string ProductSku { get; set; } = string.Empty;
public Product Product { get; set; } = null!;
}
Конфігурація alternate key і FK до нього:
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
// Визначаємо Alternate Key на Sku
builder.HasAlternateKey(p => p.Sku);
// Це автоматично додає UNIQUE CONSTRAINT і дозволяє використовувати Sku як FK target
}
}
public class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
{
public void Configure(EntityTypeBuilder<OrderItem> builder)
{
builder.HasKey(oi => oi.Id);
// FK вказує на Alternate Key Product.Sku
builder.HasOne(oi => oi.Product)
.WithMany()
.HasForeignKey(oi => oi.ProductSku) // FK у OrderItem
.HasPrincipalKey(p => p.Sku); // target = Sku (не дефолтний PK)
}
}
SQL, що генерується:
ALTER TABLE "Products"
ADD CONSTRAINT "AK_Products_Sku" UNIQUE ("Sku"); -- Alternate Key constraint
ALTER TABLE "OrderItems"
ADD CONSTRAINT "FK_OrderItems_Products_ProductSku"
FOREIGN KEY ("ProductSku") REFERENCES "Products" ("Sku"); -- FK → AK
public class Author
{
public int Id { get; set; }
// Складений альтернативний ключ: комбінація FirstName + LastName унікальна
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}
// Конфігурація:
builder.HasAlternateKey(a => new { a.FirstName, a.LastName });
// FK до складеного AK:
builder.HasOne(b => b.Author)
.WithMany()
.HasForeignKey(b => new { b.AuthorFirstName, b.AuthorLastName })
.HasPrincipalKey(a => new { a.FirstName, a.LastName });
Owned Types — це можливість включити «вкладену» сутність у таблицю батьківської без окремої таблиці. Це EF Core реалізація патерну Domain-Driven Design Value Object.
Основна відмінність від звичайного зв'язку: Owned Type не має власної ідентичності — він не може існувати без свого «власника» (owner) і не має власного PK.
// Value Object: Адреса без власної ідентичності
public class Address
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
// Немає ID — це не сутність, це Value Object
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
// Address не є окремою сутністю — вона є частиною Customer
public Address ShippingAddress { get; set; } = null!;
public Address BillingAddress { get; set; } = null!;
}
Конфігурація OwnsOne:
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.HasKey(c => c.Id);
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
// ShippingAddress — зберігається у стовпцях таблиці Customers
builder.OwnsOne(c => c.ShippingAddress, addr =>
{
// Префікс для стовпців: ShippingAddress_Street, ShippingAddress_City...
addr.Property(a => a.Street)
.HasColumnName("ShippingAddress_Street")
.HasMaxLength(200);
addr.Property(a => a.City)
.HasColumnName("ShippingAddress_City")
.HasMaxLength(100);
addr.Property(a => a.Country)
.HasColumnName("ShippingAddress_Country")
.HasMaxLength(100);
addr.Property(a => a.PostalCode)
.HasColumnName("ShippingAddress_PostalCode")
.HasMaxLength(20)
.IsUnicode(false);
});
builder.OwnsOne(c => c.BillingAddress, addr =>
{
addr.Property(a => a.Street).HasColumnName("BillingAddress_Street").HasMaxLength(200);
addr.Property(a => a.City).HasColumnName("BillingAddress_City").HasMaxLength(100);
addr.Property(a => a.Country).HasColumnName("BillingAddress_Country").HasMaxLength(100);
addr.Property(a => a.PostalCode).HasColumnName("BillingAddress_PostalCode").HasMaxLength(20);
});
}
}
DDL результат:
CREATE TABLE "Customers" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL,
-- ShippingAddress розкладений у стовпці таблиці Customers
"ShippingAddress_Street" TEXT NOT NULL,
"ShippingAddress_City" TEXT NOT NULL,
"ShippingAddress_Country" TEXT NOT NULL,
"ShippingAddress_PostalCode" TEXT NOT NULL,
-- BillingAddress теж
"BillingAddress_Street" TEXT NOT NULL,
"BillingAddress_City" TEXT NOT NULL,
"BillingAddress_Country" TEXT NOT NULL,
"BillingAddress_PostalCode" TEXT NOT NULL
);
Зверніть: жодної окремої таблиці для Address. Все в одній таблиці Customers. Це і є суть Owned Types — агрегація.
Якщо Owned Type занадто великий (наприклад, 15+ стовпців) або nullable — можна розмістити його в окремій таблиці через ToTable:
builder.OwnsOne(c => c.ExtendedProfile, ep =>
{
// Окрема таблиця для Owned Type
ep.ToTable("CustomerProfiles");
ep.Property(p => p.DateOfBirth);
ep.Property(p => p.PhoneNumber).HasMaxLength(20);
ep.Property(p => p.AvatarUrl).HasMaxLength(500);
// FK до Customer генерується автоматично
});
Якщо Value Object є колекцією — використовується OwnsMany. Аналог OwnsOne, але для ICollection<T>:
public class Order
{
public int Id { get; set; }
public string OrderNumber { get; set; } = string.Empty;
// Колекція Value Objects — tracking history
public ICollection<OrderStatusChange> StatusHistory { get; set; } = new List<OrderStatusChange>();
}
public class OrderStatusChange
{
// Немає Id! Value Objects не мають власної ідентичності
public OrderStatus NewStatus { get; set; }
public DateTime ChangedAt { get; set; }
public string? Comment { get; set; }
}
builder.OwnsMany(o => o.StatusHistory, sh =>
{
// OwnsMany ЗАВЖДИ потребує окремої таблиці
sh.ToTable("OrderStatusChanges");
// EF Core автоматично додасть FK до Order і Shadow PK
sh.Property(s => s.NewStatus)
.HasConversion<string>()
.HasMaxLength(50);
sh.Property(s => s.ChangedAt)
.HasDefaultValueSql("CURRENT_TIMESTAMP");
sh.Property(s => s.Comment)
.HasMaxLength(500);
});
DDL для OwnsMany:
CREATE TABLE "OrderStatusChanges" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, -- Shadow PK
"OrderId" INTEGER NOT NULL, -- Shadow FK до Order
"NewStatus" TEXT NOT NULL,
"ChangedAt" TEXT NOT NULL,
"Comment" TEXT NULL,
CONSTRAINT "FK_OrderStatusChanges_Orders_OrderId"
FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE CASCADE
);
Table Splitting — протилежна до OwnsMany концепція. Де OwnsOne «згорнула» об'єкт у батька, Table Splitting ділить одну таблицю між кількома C#-класами. Це корисно для:
Product і ProductDetails в одній таблиці, але завантажуються окремоpublic class Article
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public DateTime PublishedAt { get; set; }
// Navigation до важкої частини, що завантажується окремо
public ArticleBody? Body { get; set; }
}
public class ArticleBody
{
public int Id { get; set; } // Той самий Id, що і Article.Id
public string Content { get; set; } = string.Empty; // Великий текст
public string? RawHtml { get; set; }
public Article Article { get; set; } = null!;
}
Конфігурація Table Splitting:
public class ArticleConfiguration : IEntityTypeConfiguration<Article>
{
public void Configure(EntityTypeBuilder<Article> builder)
{
builder.ToTable("Articles"); // Article → таблиця Articles
builder.HasOne(a => a.Body)
.WithOne(b => b.Article)
.HasForeignKey<ArticleBody>(b => b.Id); // Shared PK
}
}
public class ArticleBodyConfiguration : IEntityTypeConfiguration<ArticleBody>
{
public void Configure(EntityTypeBuilder<ArticleBody> builder)
{
// РАЗОМ зі статтею — та сама таблиця!
builder.ToTable("Articles");
}
}
-- Одна таблиця містить стовпці обох класів!
CREATE TABLE "Articles" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"Title" TEXT NOT NULL, -- Article
"Slug" TEXT NOT NULL, -- Article
"PublishedAt" TEXT NOT NULL, -- Article
"Content" TEXT NULL, -- ArticleBody (може бути null при читанні легкого Article)
"RawHtml" TEXT NULL -- ArticleBody
);
Завантаження:
// Без Include: Article.Body буде null
var lightArticle = await context.Articles
.Where(a => a.Slug == "ef-core-guide")
.FirstAsync();
// SQL: SELECT Id, Title, Slug, PublishedAt FROM Articles WHERE Slug = ...
// Content і RawHtml не завантажуються!
// З Include: отримуємо повний контент
var fullArticle = await context.Articles
.Include(a => a.Body)
.FirstAsync(a => a.Slug == "ef-core-guide");
// SQL: SELECT Id, Title, Slug, PublishedAt, Content, RawHtml FROM Articles WHERE Slug = ...
Одна з найчастіших точок плутанини — як EF Core обробляє граф об'єктів: набір пов'язаних сутностей, де одні є новими, інші — існуючими, треті — зміненими.
// Сценарій: Order з новими OrderItems призначений існуючому Customer.
var newOrder = new Order
{
OrderNumber = "ORD-2024-001",
CustomerId = existingCustomerId, // FK вказує на існуючого клієнта
Items = new List<OrderItem>
{
new() { ProductId = 101, Quantity = 2, UnitPrice = 150.00m },
new() { ProductId = 102, Quantity = 1, UnitPrice = 280.00m }
}
};
// context.Add: реєструє Order і всі Items як Added
// CustomerId=existingCustomerId — EF Core бачить що це FK, не намагається вставити Customer
context.Orders.Add(newOrder);
await context.SaveChangesAsync();
// SQL: INSERT INTO Orders (...) VALUES (...)
// INSERT INTO OrderItems (...) VALUES (...) ← два записи
// Немає INSERT INTO Customers — бо Customer не є частиною добавленого графа
Якщо граф містить і нові, і існуючі сутності явно:
// Отримуємо існуючого Customer
var customer = await context.Customers.FindAsync(customerId);
// Будуємо новий Order прив'язаний до нього через навігаційну властивість
var newOrder = new Order { OrderNumber = "ORD-2024-002" };
customer!.Orders.Add(newOrder); // EF Core автоматично заповнить CustomerId
// Додаємо нові OrderItems через навігаційну властивість Order
var item = new OrderItem { ProductId = 101, Quantity = 3, UnitPrice = 50m };
newOrder.Items.Add(item);
// Один SaveChanges: вставляє Order і OrderItem
await context.SaveChangesAsync();
context.ChangeTracker.TrackGraph() дозволяє обходити граф і вручну встановлювати стан кожного об'єкту. Це корисно при роботі з відключеними сутностями (Disconnected Entities) — наприклад, коли граф прийшов з API і треба правильно розрізнити нові та оновлені сутності:
public async Task SaveOrderGraphAsync(OrderDto orderDto)
{
// Конвертуємо DTO у доменні об'єкти
var order = MapToOrder(orderDto);
// TrackGraph обходить Order і всі пов'язані об'єкти
context.ChangeTracker.TrackGraph(order, node =>
{
var entity = node.Entry;
if (entity.Entity is ITrackable trackable)
{
// Якщо IsNew — Add; інакше — Modified
entity.State = trackable.IsNew
? EntityState.Added
: EntityState.Modified;
}
else
{
// Дефолт для сутностей без маркера
entity.State = entity.IsKeySet
? EntityState.Unchanged // Має PK → вже існує
: EntityState.Added; // Немає PK → нова
}
});
await context.SaveChangesAsync();
}
Детальніше Disconnected Entities і виклики з API розглядаються у статті про Change Tracking (Блок 5).
Звичайно FK-поле явно присутнє в класі: public int AuthorId { get; set; }. Але іноді FK потрібен у схемі БД, але без відповідної властивості в C#-класі. Такий FK називається Shadow Property — він існує у EF Core моделі і в базі, але не має відображення у C#.
Najчастіша причина shadow FK — спрощення моделі: коли залежна сутність «не повинна знати» про свій FK (наприклад, з міркувань інкапсуляції).
public class BlogPost
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
// Навігаційна властивість є, але FK-поля немає!
public Blog Blog { get; set; } = null!;
// Немає public int BlogId { get; set; }
}
public class Blog
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<BlogPost> Posts { get; set; } = new List<BlogPost>();
}
EF Core автоматично створює shadow property BlogId (ім'я виводиться за конвенцією) — воно присутнє у схемі таблиці BlogPosts, але недоступне як post.BlogId у C#.
Явна конфігурація shadow FK через Fluent API:
public class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
{
public void Configure(EntityTypeBuilder<BlogPost> builder)
{
builder.HasOne(p => p.Blog)
.WithMany(b => b.Posts)
// Shadow property: рядкова назва, не лямбда
.HasForeignKey("BlogId")
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
}
}
Явне оголошення shadow property і доступ до нього:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Явне оголошення shadow property
modelBuilder.Entity<BlogPost>()
.Property<int>("BlogId") // тип і назва shadow property
.IsRequired();
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
Доступ до shadow property у коді через EF.Property<T>():
// Читання shadow property з відстежуваного об'єкта
var post = await context.BlogPosts.FirstAsync(p => p.Id == 1);
var blogId = context.Entry(post).Property<int>("BlogId").CurrentValue;
Console.WriteLine($"Post belongs to Blog #{blogId}");
// Фільтрація за shadow property
var postsForBlog = await context.BlogPosts
.Where(p => EF.Property<int>(p, "BlogId") == 5)
.ToListAsync();
// Встановлення shadow property
var newPost = new BlogPost { Title = "Hello", Content = "World" };
context.BlogPosts.Add(newPost);
context.Entry(newPost).Property("BlogId").CurrentValue = existingBlogId;
await context.SaveChangesAsync();
EF.Property<int>(p, "BlogId") є набагато менш очевидним, ніж p.BlogId. У більшості сценаріїв рекомендується явне FK-поле. Shadow FK виправданий переважно у DDD-сценаріях з backing fields.Коли Principal entity має складений Primary Key, зовнішній ключ у Dependent теж повинен бути складеним — він має відображати всі компоненти PK батька.
// Principal зі складеним PK
public class OrderHeader
{
public int OrderId { get; set; } // частина складеного PK
public string Region { get; set; } = string.Empty; // частина складеного PK
public string CustomerName { get; set; } = string.Empty;
public ICollection<OrderLineItem> LineItems { get; set; } = new List<OrderLineItem>();
}
// Dependent зі складеним FK
public class OrderLineItem
{
public int Id { get; set; } // власний PK
// Складений FK: відображає обидва компоненти PK батька
public int OrderId { get; set; } // FK part 1
public string Region { get; set; } = string.Empty; // FK part 2
public int ProductId { get; set; }
public int Quantity { get; set; }
public OrderHeader Order { get; set; } = null!;
}
Конфігурація складеного FK:
public class OrderHeaderConfiguration : IEntityTypeConfiguration<OrderHeader>
{
public void Configure(EntityTypeBuilder<OrderHeader> builder)
{
// Складений PK
builder.HasKey(o => new { o.OrderId, o.Region });
builder.Property(o => o.Region).HasMaxLength(50);
builder.Property(o => o.CustomerName).IsRequired().HasMaxLength(200);
}
}
public class OrderLineItemConfiguration : IEntityTypeConfiguration<OrderLineItem>
{
public void Configure(EntityTypeBuilder<OrderLineItem> builder)
{
builder.HasKey(li => li.Id);
// Складений FK: обидва поля разом утворюють посилання на складений PK
builder.HasOne(li => li.Order)
.WithMany(o => o.LineItems)
.HasForeignKey(li => new { li.OrderId, li.Region }) // складений FK
.OnDelete(DeleteBehavior.Cascade);
}
}
DDL результат:
CREATE TABLE "OrderHeaders" (
"OrderId" INTEGER NOT NULL,
"Region" TEXT NOT NULL,
"CustomerName" TEXT NOT NULL,
CONSTRAINT "PK_OrderHeaders" PRIMARY KEY ("OrderId", "Region")
);
CREATE TABLE "OrderLineItems" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"OrderId" INTEGER NOT NULL,
"Region" TEXT NOT NULL, -- частина складеного FK
"ProductId" INTEGER NOT NULL,
"Quantity" INTEGER NOT NULL,
CONSTRAINT "FK_OrderLineItems_OrderHeaders"
FOREIGN KEY ("OrderId", "Region") -- складений FK
REFERENCES "OrderHeaders" ("OrderId", "Region")
ON DELETE CASCADE
);
Id: int IDENTITY) у Principal і використання простого FK у Dependent. Це суттєво спрощує схему.Racking fields у зв'язках — це спосіб прихати навігаційну властивість від зовнішнього коду і надати доступ тільки через методи (DDD Aggregate Root патерн). EF Core все одно може зберігати і завантажувати через приватне поле.
public class Order
{
public int Id { get; set; }
public string OrderNumber { get; set; } = string.Empty;
// Приватне backing field — EF Core пише і читає сюди
private readonly List<OrderItem> _items = new();
// Публічна read-only проєкція — зовнішній код тільки читає
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
// Бізнес-метод для додавання — єдина точка входу
public void AddItem(int productId, int quantity, decimal unitPrice)
{
if (quantity <= 0)
throw new DomainException("Quantity must be positive");
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
if (existingItem is not null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
_items.Add(new OrderItem(productId, quantity, unitPrice));
}
}
public void RemoveItem(int productId)
{
var item = _items.FirstOrDefault(i => i.ProductId == productId)
?? throw new DomainException($"Item {productId} not found in order");
_items.Remove(item);
}
}
Конфігурація backing field для навігації:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.OrderNumber)
.IsRequired()
.HasMaxLength(50);
// Повідомляємо EF Core де шукати колекцію
builder.HasMany(o => o.Items) // ← EF Core шукає Items через рефлексію
.WithOne()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
// Явна вказівка на backing field (якщо автовиявлення не спрацювало)
builder.Navigation(o => o.Items)
.HasField("_items")
.UsePropertyAccessMode(PropertyAccessMode.Field); // EF читає/пише через поле
}
}
Завантаження через backing field повністю прозоре:
// Include завантажує Items у _items через рефлексію
var order = await context.Orders
.Include(o => o.Items)
.FirstAsync(o => o.Id == 1);
// Зовнішній код бачить тільки IReadOnlyCollection
foreach (var item in order.Items) { ... } // ✅ Read-only
// order._items.Add(new OrderItem()); // ❌ Не компілюється — private!
order.AddItem(productId: 5, quantity: 2, unitPrice: 100m); // ✅ Через метод
await context.SaveChangesAsync();
Той самий підхід для одиночних навігаційних властивостей:
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
private Blog? _blog;
private int _blogId;
// Публічний доступ — тільки читання
public Blog? Blog => _blog;
public int BlogId => _blogId;
// Метод для зміни приналежності
internal void AssignToBlog(Blog blog)
{
_blog = blog;
_blogId = blog.Id;
}
}
builder.Property(p => p.BlogId).HasField("_blogId");
builder.Navigation(p => p.Blog).HasField("_blog");
Polymorphic association — це один із найнебезпечніших патернів в ORM, що прийшов зі світу ActiveRecord (Ruby on Rails). Ідея: одне FK-поле може вказувати на рядки різних таблиць залежно від значення ще одного поля (discriminator).
Уявіть систему коментарів, де коментар може належати або до Post, або до Video, або до Product:
// ❌ АНТИПАТЕРН: Polymorphic Association
public class Comment
{
public int Id { get; set; }
public string Text { get; set; } = string.Empty;
// FK, що вказує на РІЗНІ таблиці — залежно від CommentableType!
public int CommentableId { get; set; }
public string CommentableType { get; set; } = string.Empty; // "Post", "Video", "Product"
// Немає реального FK constraint у БД — ключ не до чого прив'язати!
}
-- Антипатерн у SQL: немає справжніх FK
CREATE TABLE Comments (
Id INT PRIMARY KEY,
Text TEXT NOT NULL,
CommentableId INT NOT NULL, -- Куди це посилається? На Posts? Videos? Products?
CommentableType VARCHAR(50) NOT NULL -- "Post", "Video", "Product"
-- Немає FOREIGN KEY REFERENCES! Referential integrity порушена.
);
Чому це небезпечно:
CommentableId = 9999, якого не існує ні в Posts, ні в Videos.Простий і зрозумілий підхід — дублювання структури Comment, але з реальними FK:
// ✅ Правильно: окремі join-таблиці
public class PostComment
{
public int Id { get; set; }
public string Text { get; set; } = string.Empty;
public int PostId { get; set; } // реальний FK → Posts
public Post Post { get; set; } = null!;
}
public class VideoComment
{
public int Id { get; set; }
public string Text { get; set; } = string.Empty;
public int VideoId { get; set; } // реальний FK → Videos
public Video Video { get; set; } = null!;
}
Мінус: дублювання структури. Прийнятно для 2-3 типів, незручно при 10+.
Якщо потрібна єдина таблиця коментарів, але зі збереженням FK:
// ✅ TPH: абстрактний базовий тип + конкретні підтипи з реальними FK
public abstract class Comment
{
public int Id { get; set; }
public string Text { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public string AuthorName { get; set; } = string.Empty;
}
public class PostComment : Comment
{
public int PostId { get; set; } // реальний FK, НЕ NULL для цього типу
public Post Post { get; set; } = null!;
}
public class VideoComment : Comment
{
public int VideoId { get; set; } // реальний FK, НЕ NULL для цього типу
public Video Video { get; set; } = null!;
}
public class ProductComment : Comment
{
public int ProductId { get; set; } // реальний FK, НЕ NULL для цього типу
public Product Product { get; set; } = null!;
}
Конфігурація TPH (Table-Per-Hierarchy):
public class CommentConfiguration : IEntityTypeConfiguration<Comment>
{
public void Configure(EntityTypeBuilder<Comment> builder)
{
builder.ToTable("Comments"); // Одна таблиця для всієї ієрархії
// Discriminator column з явними значеннями
builder.HasDiscriminator<string>("CommentType")
.HasValue<PostComment>("Post")
.HasValue<VideoComment>("Video")
.HasValue<ProductComment>("Product");
builder.Property(c => c.Text)
.IsRequired()
.HasMaxLength(5000);
builder.Property(c => c.AuthorName)
.IsRequired()
.HasMaxLength(200);
builder.Property(c => c.CreatedAt)
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
}
public class PostCommentConfiguration : IEntityTypeConfiguration<PostComment>
{
public void Configure(EntityTypeBuilder<PostComment> builder)
{
builder.Property(c => c.PostId).IsRequired();
builder.HasOne(c => c.Post)
.WithMany(p => p.Comments) // Post.Comments = ICollection<PostComment>
.HasForeignKey(c => c.PostId)
.OnDelete(DeleteBehavior.Cascade);
}
}
DDL результат:
CREATE TABLE "Comments" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"CommentType" TEXT NOT NULL, -- Discriminator
"Text" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"AuthorName" TEXT NOT NULL,
-- PostComment-specific:
"PostId" INTEGER NULL, -- NULL для VideoComment та ProductComment
-- VideoComment-specific:
"VideoId" INTEGER NULL, -- NULL для PostComment та ProductComment
-- ProductComment-specific:
"ProductId" INTEGER NULL,
CONSTRAINT "FK_Comments_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id"),
CONSTRAINT "FK_Comments_Videos_VideoId" FOREIGN KEY ("VideoId") REFERENCES "Videos" ("Id"),
CONSTRAINT "FK_Comments_Products_ProductId" FOREIGN KEY ("ProductId") REFERENCES "Products" ("Id")
);
Тепер маємо реальні FK constraints — БД гарантує referential integrity. Discriminator — "CommentType", а не "CommentableType" з магічним ID.
Коли об'єкт (Comment) справді не є ієрархічним типом і просто потрібно зробити його опціонально пов'язаним з кількома типами — nullable FK на кожен тип:
public class Comment
{
public int Id { get; set; }
public string Text { get; set; } = string.Empty;
// Тільки один з FK буде NOT NULL — решта NULL
public int? PostId { get; set; }
public int? VideoId { get; set; }
public int? ProductId { get; set; }
public Post? Post { get; set; }
public Video? Video { get; set; }
public Product? Product { get; set; }
}
З Check constraint для гарантії «рівно один не-null»:
builder.HasCheckConstraint(
"CK_Comments_ExactlyOneParent",
"(CASE WHEN PostId IS NOT NULL THEN 1 ELSE 0 END +"
+ " CASE WHEN VideoId IS NOT NULL THEN 1 ELSE 0 END +"
+ " CASE WHEN ProductId IS NOT NULL THEN 1 ELSE 0 END) = 1");
| Підхід | Referential Integrity | NULL стовпці | JOIN складність | Масштабованість |
|---|---|---|---|---|
| Polymorphic Association (антипатерн) | ❌ Порушена | ❌ Немає | ❌ Складний | ❌ Погана |
| Окремі таблиці | ✅ Повна | ✅ Немає | ✅ Простий | ⚠️ Дублювання |
| TPH + Discriminator | ✅ Повна | ⚠️ Є (nullable FK) | ✅ Один запит | ✅ Добра |
| Nullable FK + Check | ✅ Повна | ⚠️ Є | ✅ Простий | ⚠️ До ~5 типів |
Рівень 1: Many-to-Many базово
Завдання 1.1 — Реалізуйте модель соціальної мережі з implicit M:N: User ↔ User (підписки: один користувач може підписуватись на іншого). Це self-referencing Many-to-Many. Яка назва join таблиці буде згенерована EF Core? Перевірте у міграції. Напишіть запит: «знайти всіх, на кого підписаний User з Id=1».
Завдання 1.2 — Перетворіть implicit M:N між Product і Tag на explicit з join entity ProductTag. Додайте поле AddedAt: DateTime і AddedBy: string. Збережіть skip navigations. Переконайтесь, що код product.Tags.Add(tag) більше не компілюється — тепер потрібно явно створювати ProductTag.
Завдання 1.3 — Продемонструйте різницю Include: завантажте Student через Include(s => s.Courses) (skip navigation) і через Include(s => s.Enrollments).ThenInclude(e => e.Course). Порівняйте SQL через логування. Поясніть, коли який підхід вибрати.
Рівень 2: Owned Types та складні сценарії
Завдання 2.1 — Value Objects повністю: Реалізуйте сутність ShippingOrder з Owned Types: Address PickupAddress, Address DeliveryAddress, TimeRange DeliveryWindow (де TimeRange { DateTime From; DateTime To }). Переконайтесь, що всі стовпці у одній таблиці з відповідними префіксами.
Завдання 2.2 — Table Splitting: Реалізуйте Product (легка версія: Id, Name, Price, Sku) і ProductDetails (важка версія: Description, HtmlContent, ManualPdf byte) в одній таблиці Products. Запустіть міграцію і дослідіть DDL. Напишіть два запити: (а) список продуктів без деталей (SELECT Id, Name, Price), (б) повний продукт з деталями (з Include).
Завдання 2.3 — Alternate Keys: Система інтеграції: ExternalProduct { ExternalSku: string (AK) } і ImportedOrder { Items: List<ImportedOrderItem> }. ImportedOrderItem посилається на ExternalProduct через ExternalSku, а не через Id. Налаштуйте через HasPrincipalKey. Перевірте що FK constraint у DDL вказує на AK constraint.
Рівень 3: Складні графи
Завдання 3.1 — Повна M:N система з payload: Реалізуйте систему навичок HR-відділу: Employee ↔ Skill через EmployeeSkill { Level: ProficiencyLevel, AcquiredAt, CertificationUrl? }. Вимоги: (а) skip navigation Employee.Skills і Skill.Employees, (б) пряма navigation Employee.EmployeeSkills для доступу до Level, (в) унікальний constraint гарантує що Employee-Skill пара не повторюється, (г) запит: «Топ-5 найпопулярніших навичок з кількістю співробітників рівня Expert або above».
Завдання 3.2 — Рекурсивний граф: Реалізуйте систему конфігурацій: ConfigNode { Id, Key, Value?, ParentId?, Children }. Напишіть метод GetFlattenedConfig(int rootId), що рекурсивно обходить все дерево конфігурацій і повертає Dictionary<string, string> де ключ — повний шлях (наприклад "Database.Connection.Host"). Реалізуйте в два варіанти: (а) через багаторівневий Include (ThenInclude до 5 рівнів), (б) через raw SQL з CTE WITH RECURSIVE. Порівняйте, при якій глибині ієрархії кожен підхід стає неефективним.
{FK1, FK2} гарантує унікальність.HasAlternateKey() + HasPrincipalKey() для FK, що вказує не на PK, а на інше унікальне поле.EF.Property<T>() і context.Entry(e).Property("Name"). Виправданий у DDD з backing fields, ускладнює читабельність.HasForeignKey(e => new { e.FK1, e.FK2 }).UsePropertyAccessMode(PropertyAccessMode.Field) — EF читає/пише через поле. Ключовий інструмент Aggregate Root у DDD.OwnsOne — в батьківській таблиці; OwnsMany — в окремій таблиці з FK і shadow PK.Наступна стаття — LINQ-запити: від Where до GroupBy — детально розбирає LINQ-провайдер EF Core: матеріалізація, переведення виразів у SQL, клієнтська vs серверна оцінка, всі методи (Select, Where, Join, GroupBy, Window Functions), і антипатерни.
Зв'язки — One-to-One та One-to-Many
Глибокий розбір зв'язків в Entity Framework Core — Principal і Dependent, конфігурація Required та Optional навігацій, однонаправлені та двонаправлені зв'язки, самореферентні відносини, всі варіанти DeleteBehavior з реальними прикладами.
Властивості — Типи, Конвертери, Компаратори (Частина 1)
Глибокий розбір конфігурації властивостей в EF Core — маппінг C# типів на SQL, HasColumnType, HasPrecision, Value Converters (HasConversion), вбудовані конвертери, strongly typed IDs, шифрування та JSON-серіалізація через конвертери.