У попередній статті ми з'ясували, чому існують ORM-фреймворки та яке місце EF Core займає в архітектурі .NET-застосунку. Тепер — практика. Найкращий спосіб зрозуміти нову технологію — створити щось реальне і спостерігати за кожним кроком.
Ми побудуємо просту систему каталогу книг: сутності Author і Book, один DbContext, і повний набір операцій — Create, Read, Update, Delete. Але головне не сам CRUD — головне розібрати кожен рядок цього коду: чому він написаний саме так, що відбувається під час виконання та яких помилок уникнути на самому початку.
До кінця статті у вас буде:
EnsureCreated() та міграціямиПроєкт EF Core складається з кількох NuGet-пакетів. Кожен відповідає за свою частину:
Microsoft.EntityFrameworkCore
DbContext, DbSet<T>, LINQ-провайдер, Change Tracker, систему моделювання. Не залежить від конкретної СУБД.Microsoft.EntityFrameworkCore.Sqlite
Npgsql.EntityFrameworkCore.PostgreSQL, для SQL Server — Microsoft.EntityFrameworkCore.SqlServer.Microsoft.EntityFrameworkCore.Tools
dotnet ef migrations add, dotnet ef database update тощо. Встановлюється лише для розробки.Створимо консольний проєкт і додамо пакети:
Зверніть увагу: Microsoft.EntityFrameworkCore не потрібно додавати окремо — він є транзитивною залежністю провайдера Sqlite і підтягнеться автоматично.
Перший крок у будь-якому EF Core проєкті — описати сутності (entities). Сутність — це звичайний C#-клас, що представляє таблицю в базі даних. Кожна властивість класу відповідає стовпцю.
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Biography { get; set; }
public DateTime BornAt { get; set; }
public ICollection<Book> Books { get; set; } = new List<Book>();
}
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Isbn { get; set; } = string.Empty;
public int Year { get; set; }
public decimal Price { get; set; }
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
}
Розберемо кожну частину цих класів детально.
Властивість Id або {ClassName}Id — EF Core автоматично розпізнає їх як первинний ключ. Це конвенція, а не обов'язковий атрибут. Фреймворк просто знає: якщо клас називається Author, а в ньому є Id або AuthorId — це PK. Для int-ключів EF Core автоматично налаштовує стратегію AutoIncrement (IDENTITY в SQL Server, AUTOINCREMENT в SQLite).
У сучасному C# з включеними Nullable Reference Types (NRT) — а з .NET 6+ вони включені за замовчуванням — EF Core використовує nullable-анотації для визначення обов'язковості стовпця:
string Name — NOT NULL (рядкові типи без ? вважаються обов'язковими)string? Biography — NULL (nullable string — стовпець може бути порожнім)int Year — NOT NULL (value types завжди обов'язкові)int? SomeValue — NULL (nullable value type — дозволяє NULL)Ця поведінка є однією з найважливіших конвенцій EF Core — ми детально розглянемо її у статті про конвенції.
public int AuthorId { get; set; } // Foreign Key (FK) — числове поле
public Author Author { get; set; } = null!; // Navigation property — посилання на об'єкт
AuthorId — це звичайний int, але EF Core за конвенцією розпізнає його як зовнішній ключ, бо існує навігаційна властивість Author типу Author. Назва AuthorId відповідає шаблону {NavigationPropertyName}Id.
Author — навігаційна властивість. Через неї ви можете звертатись до пов'язаного об'єкта: book.Author.Name. EF Core завантажує пов'язані дані або через Eager Loading (Include()), або через innej механізми, про які детально поговоримо у Блоці 3.
= null! — pragma для компілятора: "я знаю, що тут null під час ініціалізації, але під час роботи програми це поле буде заповнено". EF Core заповнить навігаційну властивість після завантаження з бази.
ICollection<Book> Books у Author — колекція зворотньої навігації. Дозволяє звертатись як author.Books для отримання всіх книг автора.
Якщо сутності — це опис що зберігається, то DbContext — це як це зберігається і через що ви взаємодієте з базою.
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite("Data Source=bookcatalog.db");
}
}
Це мінімальний валідний DbContext. Розберемо кожну частину.
DbContext — базовий клас з Microsoft.EntityFrameworkCore, що реалізує патерни Unit of Work і Repository. Він містить:
Ваш клас AppDbContext наслідує всю цю функціональність і додає лише специфічне для вашої бази: які таблиці (DbSet) і яке налаштування (OnConfiguring або OnModelCreating).
public DbSet<Author> Authors => Set<Author>();
DbSet<Author> — це не колекція об'єктів у пам'яті. Це IQueryable — провайдер запитів, що дозволяє будувати LINQ-запити, які транслюватимуться у SQL. Коли ви пишете context.Authors.Where(a => a.Name == "Тарас"), ніяких даних ще не завантажено. LINQ-вираз лише будує Expression Tree.
Дані з бази завантажуються лише тоді, коли ви явно матеріалізуєте запит: через ToList(), ToListAsync(), FirstOrDefault(), Count() тощо.
Set<Author>() є рекомендованим способом оголошення DbSet у нових версіях EF Core — він трохи коротший за явну властивість { get; set; } і дозволяє EF Core краще управляти станом. Обидва варіанти є правильними.Метод OnConfiguring викликається EF Core під час ініціалізації контексту. DbContextOptionsBuilder — builder-об'єкт для конфігурації всіх аспектів поведінки DbContext: провайдер, рядок підключення, логування, пул з'єднань тощо.
UseSqlite("Data Source=bookcatalog.db") — це extension method від пакету Microsoft.EntityFrameworkCore.Sqlite, що підключає SQLite-провайдер і вказує на файл бази даних.
Рядок підключення (connection string) — це рядок tексту, що описує, куди і як підключатись до бази даних. Формат залежить від провайдера:
Data Source=bookcatalog.db
Data Source=C:\Databases\books.db
Data Source=:memory: ← повністю в пам'яті (для тестів)
SQLite не потребує хосту, порту чи облікових даних — лише шлях до файлу.
Host=localhost;Port=5432;Database=bookcatalog;Username=postgres;Password=secret
Або у вигляді URI:
postgresql://postgres:secret@localhost:5432/bookcatalog
Server=localhost;Database=BookCatalog;User Id=sa;Password=secret;TrustServerCertificate=True
Або через Windows Authentication:
Server=.\SQLEXPRESS;Database=BookCatalog;Trusted_Connection=True
Безпосередньо в коді — лише для навчання. У реальних проєктах рядок підключення ніколи не має бути в коді, особливо в публічних репозиторіях.
Правильні підходи для зберігання:
У консольному застосунку / навчанні — змінні середовища або appsettings.json (не комітити в git):
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=bookcatalog.db"
}
}
Для розробки в ASP.NET Core — User Secrets. Це локальний зашифрований сховок, що не потрапляє в git:
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Data Source=bookcatalog.db"
Ремарка: Управління конфігурацією, User Secrets та Dependency Injection детально вивчатимуться у розділі ASP.NET Core. Тут ми використовуємо найпростіший підхід — рядок прямо в OnConfiguring — виключно для навчальних цілей.
Підхід з OnConfiguring, який ми використовуємо зараз, — найпростіший, але не рекомендований для реальних проєктів. Розберемо чому.
Проблема OnConfiguring: DbContext жорстко прив'язаний до конкретної конфігурації. Щоб змінити рядок підключення (наприклад, для тестів), потрібно або змінювати код, або наслідувати клас і перевизначати метод.
Правильний підхід — через конструктор: DbContext приймає DbContextOptions<T> через конструктор. Конфігурація підготовлюється зовні та передається при створенні:
public class AppDbContext : DbContext
{
// Конструктор приймає готові опції ззовні
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
// Метод OnConfiguring відсутній — конфігурація передається зовні
}
Тепер створення контексту виглядає так:
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=bookcatalog.db")
.Options;
using var context = new AppDbContext(options);
У ASP.NET Core цей шаблон ще простіший — ви реєструєте DbContext у DI-контейнері одним рядком, і фреймворк сам керує його lifecycle:
// Одна строчка — і DbContext доступний у всьому застосунку через DI
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
Для цієї статті ми продовжуємо з OnConfiguring для простоти. Починаючи зі статті про ASP.NET Core ви побачите DI-підхід у дії.
Ми маємо сутності і DbContext. Але таблиці в базі ще не існують. Як їх створити?
EF Core пропонує два підходи, і важливо розуміти різницю між ними з самого початку.
using var context = new AppDbContext();
context.Database.EnsureCreated();
Цей метод перевіряє, чи існує база даних (файл для SQLite, або схема для SQL Server/PostgreSQL). Якщо не існує — створює базу і всі таблиці відповідно до поточної моделі. Якщо вже існує — нічого не робить.
Це зручно для навчальних проєктів і прототипів. Але у нього є критичне обмеження:
EnsureCreated()не сумісний з міграціями. Якщо ви використаєте EnsureCreated(), а потім додасте міграцію, вона може не застосуватись або спричинить конфлікт. Ці два підходи взаємовиключні.Міграції — це керована еволюція схеми бази даних. Кожна зміна в моделі C# → окремий файл міграції (Up/Down) → застосовується через dotnet ef database update. Це дозволяє відстежувати зміни у Git, відкочувати їх, і безпечно оновлювати продакшн-базу.
Повністю міграції розглядаються у Блоці 5 цього курсу. Для навчальних цілей поки що використаємо EnsureCreated().
EnsureCreated() | Migrations | |
|---|---|---|
| Складність | Нуль | Потребує розуміння |
| Контроль | Автоматично | Повний контроль |
| Оновлення схеми | Не підтримує | Підтримує |
| Продакшн | ❌ Не рекомендовано | ✅ Yes |
| Навчання/прототип | ✅ Perfect | Зайвий overhead |
Перш ніж писати CRUD, подивимося на структуру файлів, яку ми будуємо:
А ось як ця модель виглядає у вигляді ER-діаграми:
Тепер — повний CRUD. Починаємо зі створення нових записів.
// Крок 1: Ініціалізація і створення схеми
using var context = new AppDbContext();
context.Database.EnsureCreated();
// Крок 2: Створення об'єкта
var author = new Author
{
Name = "Іван Франко",
Biography = "Видатний український письменник, поет, учений.",
BornAt = new DateTime(1856, 8, 27)
};
// Крок 3: Реєстрація в контексті
context.Authors.Add(author);
// Крок 4: Збереження до бази даних
await context.SaveChangesAsync();
Console.WriteLine($"Автор збережений з Id = {author.Id}");
Поглянемо на кожен крок детально.
context.Authors.Add(author) не виконує жодного SQL. Він лише реєструє об'єкт author у Change Tracker зі станом EntityState.Added. Це все — жодного звернення до бази.
Ви можете додати 100 об'єктів через Add() підряд — і лише один SaveChanges() виконає всі 100 INSERT-ів однією транзакцією та, де можливо, одним батч-запитом.
Це центральний метод, де відбувається вся "магія". Ось покроково:
EF Core обходить всі відстежувані об'єкти і порівнює їх поточний стан зі збереженим знімком. Знаходить всі Added, Modified, Deleted.
Для кожної зміни генерує відповідний SQL. Для EntityState.Added → INSERT INTO Authors (...) VALUES (...).
SQL-команди об'єднуються у батчі і виконуються у неявній транзакції. Якщо будь-яка команда провалиться — вся транзакція відкотиться.
Після виконання INSERT база повертає згенерований Id. EF Core автоматично заповнює author.Id — тому після SaveChangesAsync() у об'єкта вже є реальний Id з бази.
Всі об'єкти переходять зі стану Added у стан Unchanged. Change Tracker оновлює знімки до актуальних значень.
Ось чому після SaveChangesAsync() ми можемо прочитати author.Id — EF Core вже заповнив його значенням з бази.
Що насправді виконує EF Core? Вмикаємо логування і дивимось:
INSERT INTO "Authors" ("Biography", "BornAt", "Name")
VALUES ('Видатний українс...', '1856-08-27 00:00:00', 'Іван Франко')
RETURNING "Id";
Зверніть увагу: Id не передається в INSERT — він генерується базою (AUTOINCREMENT у SQLite). RETURNING "Id" — SQLite-специфічна інструкція, що повертає згенероване значення.
EF Core надає кілька способів читати дані — від простого пошуку за Id до складних LINQ-запитів.
// Пошук за PK — спочатку перевіряє Change Tracker, потім іде в базу
var author = await context.Authors.FindAsync(1);
if (author is null)
{
Console.WriteLine("Автор не знайдений");
return;
}
Console.WriteLine($"Знайдено: {author.Name}, народився {author.BornAt:d}");
FindAsync(id) має особливість: спочатку він перевіряє Change Tracker — чи є вже завантажений об'єкт з таким Id. Якщо є — повертає його без запиту до БД. Це корисна оптимізація для сценаріїв, де ви вже завантажили об'єкт раніше.
// Всі книги дорожче 200 грн, посортовані за назвою
var expensiveBooks = await context.Books
.Where(b => b.Price > 200m)
.OrderBy(b => b.Title)
.Select(b => new { b.Id, b.Title, b.Price })
.ToListAsync();
foreach (var book in expensiveBooks)
{
Console.WriteLine($"[{book.Id}] {book.Title} — {book.Price:C}");
}
Розберемо цей запит:
context.Books — повертає DbSet<Book>, що реалізує IQueryable<Book>.Where(b => b.Price > 200m) — додає умову фільтрації до Expression Tree.OrderBy(b => b.Title) — додає сортування до Expression Tree.Select(b => new { b.Id, b.Title, b.Price }) — проєкція: вибираємо лише потрібні поля, а не весь об'єкт.ToListAsync() — матеріалізація: тут EF Core транслює весь Expression Tree у SQL і виконує запитГенерований SQL:
SELECT b."Id", b."Title", b."Price"
FROM "Books" AS b
WHERE b."Price" > 200.0
ORDER BY b."Title"
Select() для проєкцій, якщо вам не потрібні всі поля. EF Core зробить SELECT Id, Title, Price замість SELECT *. При великих сутностях (з великими текстовими або бінарними полями) це суттєво знижує обсяг переданих даних.// Завантажити автора разом зі всіма його книгами (Eager Loading)
var authorWithBooks = await context.Authors
.Include(a => a.Books)
.FirstOrDefaultAsync(a => a.Id == 1);
if (authorWithBooks is not null)
{
Console.WriteLine($"Автор: {authorWithBooks.Name}");
Console.WriteLine($"Кількість книг: {authorWithBooks.Books.Count}");
foreach (var book in authorWithBooks.Books)
{
Console.WriteLine($" — {book.Title} ({book.Year})");
}
}
Include(a => a.Books) — це Eager Loading: EF Core завантажить і автора, і його колекцію книг в одному SQL-запиті з LEFT JOIN. Без Include властивість author.Books буде порожньою колекцією (або спричинить LazyLoadingException, залежно від налаштувань).
Детально стратегії завантаження пов'язаних даних — Eager, Lazy, Explicit Loading — розглядаються у Блоці 3.
Оновлення в EF Core засноване на механізмі Change Tracking. Ви не пишете UPDATE SQL — ви просто змінюєте властивості об'єкта, і EF Core сам визначає, що змінилось.
// 1. Завантажуємо об'єкт (він автоматично стає відстежуваним)
var book = await context.Books.FindAsync(1);
if (book is null)
{
Console.WriteLine("Книга не знайдена");
return;
}
// 2. Зберігаємо оригінальне значення для демонстрації
var originalPrice = book.Price;
// 3. Змінюємо властивість — EF Core відразу помітить це
book.Price = book.Price * 1.1m; // підвищення ціни на 10%
book.Title = book.Title.Trim(); // очищення пробілів у назві
// 4. SaveChanges генерує UPDATE тільки для змінених полів
await context.SaveChangesAsync();
Console.WriteLine($"Ціна оновлена: {originalPrice:C} → {book.Price:C}");
Коли FindAsync(1) повертає об'єкт, EF Core одночасно зберігає snapshot (знімок) початкових значень у Change Tracker. Коли ви викликаєте SaveChangesAsync(), EF Core викликає DetectChanges() — порівнює поточні значення зі знімком — і визначає, які поля змінились.
Якщо ви змінили Price і Title, але не чіпали Year і Isbn, EF Core згенерує UPDATE лише для двох змінених стовпців:
UPDATE "Books"
SET "Price" = 220.0, "Title" = 'Кобзар'
WHERE "Id" = 1;
Це розумний UPDATE. Не SET * — лише те, що реально змінилось. Це зменшує обсяг переданих даних і захищає від конфліктів при паралельних змінах.
new Book() і заповнили його поля, але не передали через context.Books.Add() або не завантажили через запит — EF Core нічого про нього не знає і не збереже зміни.Видалення, як і оновлення, вимагає, щоб сутність була відстежувана контекстом.
// Спосіб 1: Завантажити, потім видалити
var book = await context.Books.FindAsync(3);
if (book is not null)
{
context.Books.Remove(book);
await context.SaveChangesAsync();
Console.WriteLine($"Книга з Id=3 видалена");
}
context.Books.Remove(book) переводить сутність у стан EntityState.Deleted. Після SaveChangesAsync() EF Core виконає:
DELETE FROM "Books"
WHERE "Id" = 3;
Якщо вам не потрібен сам об'єкт — лише виконати DELETE за умовою — EF Core 7 додав ExecuteDeleteAsync(). Це значно ефективніше: без завантаження об'єкта, без Change Tracking:
// Видалити всі книги старіші за 1900 рік — без завантаження в пам'ять
var deletedCount = await context.Books
.Where(b => b.Year < 1900)
.ExecuteDeleteAsync();
Console.WriteLine($"Видалено {deletedCount} застарілих книг");
Генерований SQL:
DELETE FROM "Books"
WHERE "Year" < 1900;
Детально про ExecuteDelete та ExecuteUpdate — у статті про просунуті запити (Блок 3).
EF Core генерує SQL-запити за лаштунками. Щоб бачити кожен з них — вмикаємо логування. Це критично важливо при розробці: ви повинні знати, що саме виконується.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlite("Data Source=bookcatalog.db")
.LogTo(Console.WriteLine, LogLevel.Information) // виводити в консоль
.EnableSensitiveDataLogging(); // показувати значення параметрів
}
Тепер кожен SQL буде виводитись у консоль:
За замовчуванням EF Core не логує фактичні значення параметрів (тільки @p0, @p1). Це зроблено з міркувань безпеки — значення можуть містити паролі, персональні дані. EnableSensitiveDataLogging() вмикає показ значень.
EnableSensitiveDataLogging() — тільки для розробки. Ніколи не вмикайте його у production. Використовуйте умовне увімкнення:if (environment.IsDevelopment())
optionsBuilder.EnableSensitiveDataLogging();
Якщо логів забагато і вони заважають — можна фільтрувати лише SQL-команди:
optionsBuilder.LogTo(
Console.WriteLine,
new[] { DbLoggerCategory.Database.Command.Name }, // тільки SQL
LogLevel.Information
);
Зберемо все написане в єдину програму і запустимо:
using Microsoft.EntityFrameworkCore;
// Ініціалізація і створення схеми
await using var context = new AppDbContext();
await context.Database.EnsureCreatedAsync();
Console.WriteLine("=== CREATE ===");
var author = new Author
{
Name = "Іван Франко",
Biography = "Видатний український письменник.",
BornAt = new DateTime(1856, 8, 27)
};
context.Authors.Add(author);
await context.SaveChangesAsync();
Console.WriteLine($"Автор створений: Id={author.Id}");
var book1 = new Book
{
Title = "Захар Беркут",
Isbn = "978-966-10-0000-1",
Year = 1883,
Price = 150m,
AuthorId = author.Id
};
var book2 = new Book
{
Title = "Борислав сміється",
Isbn = "978-966-10-0000-2",
Year = 1881,
Price = 120m,
AuthorId = author.Id
};
context.Books.AddRange(book1, book2); // AddRange — кілька за раз
await context.SaveChangesAsync();
Console.WriteLine($"Книги створені: Id={book1.Id}, Id={book2.Id}");
Console.WriteLine("\n=== READ ===");
var authorWithBooks = await context.Authors
.Include(a => a.Books)
.FirstAsync(a => a.Id == author.Id);
Console.WriteLine($"Автор: {authorWithBooks.Name}");
foreach (var b in authorWithBooks.Books)
Console.WriteLine($" — {b.Title} ({b.Year}), {b.Price:C}");
Console.WriteLine("\n=== UPDATE ===");
book1.Price = 200m;
await context.SaveChangesAsync();
Console.WriteLine($"Ціна '{book1.Title}' оновлена до {book1.Price:C}");
Console.WriteLine("\n=== DELETE ===");
context.Books.Remove(book2);
await context.SaveChangesAsync();
Console.WriteLine($"Книга '{book2.Title}' видалена");
Console.WriteLine("\n=== FINAL STATE ===");
var remaining = await context.Books.ToListAsync();
Console.WriteLine($"Залишилось книг: {remaining.Count}");
Узагальнимо, що відбувається при кожному запиті:
Зверніть увагу на два ключових моменти:
Матеріалізація відбувається лише при виклику термінального оператора (ToListAsync, FirstAsync, CountAsync тощо). До цього моменту ви просто будуєте Expression Tree — SQL ще не виконується.
Change Tracker реєструє всі завантажені об'єкти. Це дозволяє EF Core відслідковувати зміни і генерувати UPDATE при SaveChanges. Якщо вам не потрібне відстеження — використовуйте AsNoTracking():
// Без Change Tracking — для read-only операцій (швидше)
var books = await context.Books
.AsNoTracking()
.Where(b => b.Year > 2000)
.ToListAsync();
Ось кілька помилок, з якими стикається більшість розробників на старті:
Що роблять: Add(a); SaveChanges(); Add(b); SaveChanges();
Чому це погано: Кожен SaveChanges() відкриває нову транзакцію і робить окремий round-trip до бази. 100 викликів = 100 транзакцій.
Правильно: Накопичити всі зміни, потім один SaveChanges():
context.Authors.AddRange(author1, author2, author3);
await context.SaveChangesAsync(); // один INSERT BATCH
Що роблять: Завантажують об'єкт через context1, намагаються зберегти через context2.
Чому це погано: Об'єкт відстежується лише тим контекстом, через який завантажений. Інший контекст не знає про нього.
Правильно: Один контекст для однієї «одиниці роботи» (Unit of Work). У веб-API — один контекст per HTTP-запит.
Що роблять: context.Books.ToList() замість await context.Books.ToListAsync()
Чому це погано: Синхронні операції блокують потік. У ASP.NET Core це призводить до thread starvation при навантаженні.
Правильно: Завжди використовуйте async-версії методів EF Core: ToListAsync, SaveChangesAsync, FindAsync, FirstOrDefaultAsync.
Що роблять: await context.Orders.ToListAsync() — і отримують повні об'єкти з усіма полями, хоча потрібні лише Id і Title.
Чому це погано: При великих таблицях SELECT * передає зайві мегабайти з бази.
Правильно: Завжди проєктуйте через Select() те, що реально потрібно:
var titles = await context.Books
.Select(b => new { b.Id, b.Title })
.ToListAsync();
Рівень 1: Знайомство
Завдання 1.1 — Відтворіть проєкт BookCatalog самостійно. Запустіть програму і перевірте, що створений файл bookcatalog.db. Відкрийте його через будь-який SQLite Browser (наприклад, DB Browser for SQLite) і переконайтесь, що таблиці Authors і Books існують із правильною схемою.
Завдання 1.2 — Додайте до AppDbContext логування SQL через LogTo(Console.WriteLine). Запустіть CRUD-демонстрацію і збережіть у текстовий файл весь SQL, що виводиться. Для кожного SQL-рядка напишіть 1–2 речення пояснення: яка C# операція його спричинила.
Завдання 1.3 — Спробуйте не викликати context.Database.EnsureCreated() перед першим запитом. Що відбудеться? Яке повідомлення про помилку ви отримаєте? Чому?
Рівень 2: Досвід
Завдання 2.1 — Розширте модель: додайте сутність Publisher (видавець) з полями Id, Name, Country, FoundedYear. Додайте зв'язок: одна книга належить одному видавцю (Book.PublisherId → Publisher.Id). Оновіть DbContext. Перевірте що EnsureCreated() створює три таблиці.
Завдання 2.2 — Напишіть метод GetBooksByAuthor(int authorId), що повертає список книг конкретного автора, посортованих за роком (спочатку нові), з проєкцією new { Title, Year, Price }. Перевірте через логування, який SQL генерується.
Завдання 2.3 — Дослідіть поведінку Change Tracker: після FindAsync(id) виведіть стан через context.Entry(book).State. Змініть book.Price. Знову виведіть стан. Потім викличте context.ChangeTracker.Entries() і виведіть список всіх відстежуваних об'єктів зі станами.
Рівень 3: Міні-проєкт
Завдання 3.1 — Міні-бібліотека: Реалізуйте консольний застосунок «Бібліотека» з наступним функціоналом:
Логування SQL має бути увімкнено. Для кожної операції підрахуйте, скільки SQL-запитів виконується.
Завдання 3.2 — Аналіз ефективності: Напишіть два варіанти методу, що повертає список авторів із кількістю їх книг:
Include(a => a.Books), потім a.Books.Count в пам'ятіSelect(a => new { a.Name, BookCount = a.Books.Count })Порівняйте SQL, що генерується. Поясніть, чому Варіант B є кращим.
IQueryable-провайдер запитів. Дані завантажуються лише при матеріалізації (ToListAsync, FirstAsync тощо)EnsureCreated() підходить для навчання і прототипів, але несумісний з міграціями. Для реальних проєктів — MigrationsSaveChanges() — єдина точка збереження. Виконує DetectChanges → SQL Generation → Batch Execution в одній транзакціїLogTo() є обов'язковим під час розробки. Завжди знайте, який SQL генерує ваш LINQAsNoTracking() для read-only запитів — менше overhead Change Tracker'а
::Що таке ORM? Від SQL до об'єктів
Глибокий огляд проблеми Impedance Mismatch, концепції Object-Relational Mapping та ландшафту ORM-фреймворків у .NET — від теорії Мартіна Фаулера до практичного вибору між EF Core та Dapper.
DbContext — Серце EF Core
Глибоке занурення у внутрішню архітектуру DbContext — StateManager, ChangeTracker, QueryCompiler, lifecycle, потокобезпека, DbContextFactory та пул контекстів. Розуміння «під капотом» для написання правильного коду.