Ef Core

Перший проєкт — від нуля до CRUD

Покрокове створення першого EF Core проєкту з нуля — встановлення пакетів, опис сутностей, конфігурація DbContext, рядок підключення, EnsureCreated та повний CRUD з анатомією кожної операції.

Перший проєкт: від нуля до CRUD

З чого все починається

У попередній статті ми з'ясували, чому існують ORM-фреймворки та яке місце EF Core займає в архітектурі .NET-застосунку. Тепер — практика. Найкращий спосіб зрозуміти нову технологію — створити щось реальне і спостерігати за кожним кроком.

Ми побудуємо просту систему каталогу книг: сутності Author і Book, один DbContext, і повний набір операцій — Create, Read, Update, Delete. Але головне не сам CRUD — головне розібрати кожен рядок цього коду: чому він написаний саме так, що відбувається під час виконання та яких помилок уникнути на самому початку.

До кінця статті у вас буде:

  • Повністю налаштований EF Core проєкт з реальною базою даних
  • Розуміння того, що робить кожен компонент: DbContext, DbSet, SaveChanges
  • Вміння читати SQL-запити, що генерує EF Core
  • Чітке розуміння різниці між EnsureCreated() та міграціями
Для цього розділу ми використовуємо SQLite — базу даних у вигляді одного файлу, яка не вимагає встановлення сервера. Це ідеальний вибір для навчання. У реальних проєктах ви будете використовувати PostgreSQL або SQL Server — перехід займе зміну двох рядків коду.

Встановлення: три пакети, які вам потрібні

Проєкт EF Core складається з кількох NuGet-пакетів. Кожен відповідає за свою частину:

Microsoft.EntityFrameworkCore

Ядро фреймворку. Містить DbContext, DbSet<T>, LINQ-провайдер, Change Tracker, систему моделювання. Не залежить від конкретної СУБД.

Microsoft.EntityFrameworkCore.Sqlite

Провайдер для SQLite. Транслює LINQ у SQL-діалект SQLite та реалізує з'єднання. Для PostgreSQL — Npgsql.EntityFrameworkCore.PostgreSQL, для SQL Server — Microsoft.EntityFrameworkCore.SqlServer.

Microsoft.EntityFrameworkCore.Tools

Інструменти командного рядка. Потрібні для команд dotnet ef migrations add, dotnet ef database update тощо. Встановлюється лише для розробки.

Створимо консольний проєкт і додамо пакети:

dotnet CLI
$ dotnet new console -n BookCatalog
Template "Console App" was created successfully.
$ cd BookCatalog
$ dotnet add package Microsoft.EntityFrameworkCore.Sqlite
PackageReference for Microsoft.EntityFrameworkCore.Sqlite added.
$ dotnet add package Microsoft.EntityFrameworkCore.Tools
PackageReference for Microsoft.EntityFrameworkCore.Tools added.

Зверніть увагу: Microsoft.EntityFrameworkCore не потрібно додавати окремо — він є транзитивною залежністю провайдера Sqlite і підтягнеться автоматично.


Сутності: C#-класи, що відображають таблиці

Перший крок у будь-якому EF Core проєкті — описати сутності (entities). Сутність — це звичайний C#-клас, що представляє таблицю в базі даних. Кожна властивість класу відповідає стовпцю.

Models/Author.cs
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>();
}
Models/Book.cs
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 NameNOT NULL (рядкові типи без ? вважаються обов'язковими)
  • string? BiographyNULL (nullable string — стовпець може бути порожнім)
  • int YearNOT NULL (value types завжди обов'язкові)
  • int? SomeValueNULL (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: єдиний вхід до бази даних

Якщо сутності — це опис що зберігається, то DbContext — це як це зберігається і через що ви взаємодієте з базою.

Data/AppDbContext.cs
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?

DbContext — базовий клас з Microsoft.EntityFrameworkCore, що реалізує патерни Unit of Work і Repository. Він містить:

  • ChangeTracker — механізм відстеження змін у відстежуваних сутностях
  • Database — API для роботи зі з'єднанням, транзакціями, міграціями
  • Model — скомпільована модель (схема сутностей, їх конфігурація, зв'язки)
  • SaveChanges / SaveChangesAsync — генерація та виконання SQL для всіх змін

Ваш клас AppDbContext наслідує всю цю функціональність і додає лише специфічне для вашої бази: які таблиці (DbSet) і яке налаштування (OnConfiguring або OnModelCreating).

DbSet<T>: що це насправді?

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: де налаштовується з'єднання

Метод 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 не потребує хосту, порту чи облікових даних — лише шлях до файлу.

Де зберігати рядок підключення?

Безпосередньо в коді — лише для навчання. У реальних проєктах рядок підключення ніколи не має бути в коді, особливо в публічних репозиторіях.

Тисячі реальних security incidents сталися через те, що розробник закомітив рядок підключення з паролем у публічний GitHub-репозиторій. Секрети в коді — критична вразливість.

Правильні підходи для зберігання:

У консольному застосунку / навчанні — змінні середовища або appsettings.json (не комітити в git):

appsettings.json
{
    "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 vs Dependency Injection

Підхід з OnConfiguring, який ми використовуємо зараз, — найпростіший, але не рекомендований для реальних проєктів. Розберемо чому.

Проблема OnConfiguring: DbContext жорстко прив'язаний до конкретної конфігурації. Щоб змінити рядок підключення (наприклад, для тестів), потрібно або змінювати код, або наслідувати клас і перевизначати метод.

Правильний підхід — через конструктор: DbContext приймає DbContextOptions<T> через конструктор. Конфігурація підготовлюється зовні та передається при створенні:

Data/AppDbContext.cs
public class AppDbContext : DbContext
{
    // Конструктор приймає готові опції ззовні
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Author> Authors => Set<Author>();
    public DbSet<Book> Books => Set<Book>();
    // Метод OnConfiguring відсутній — конфігурація передається зовні
}

Тепер створення контексту виглядає так:

Program.cs
var options = new DbContextOptionsBuilder<AppDbContext>()
    .UseSqlite("Data Source=bookcatalog.db")
    .Options;

using var context = new AppDbContext(options);

У ASP.NET Core цей шаблон ще простіший — ви реєструєте DbContext у DI-контейнері одним рядком, і фреймворк сам керує його lifecycle:

Program.cs (ASP.NET Core)
// Одна строчка — і DbContext доступний у всьому застосунку через DI
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
Ремарка: Dependency Injection (DI) — фундаментальна концепція ASP.NET Core, яка детально вивчатиметься у відповідному розділі. Тут важливо лише розуміти: підхід через конструктор є кращим, бо відокремлює конфігурацію від реалізації — це робить код тестованим і гнучким.

Для цієї статті ми продовжуємо з OnConfiguring для простоти. Починаючи зі статті про ASP.NET Core ви побачите DI-підхід у дії.


Створення схеми бази: EnsureCreated vs Migrations

Ми маємо сутності і DbContext. Але таблиці в базі ще не існують. Як їх створити?

EF Core пропонує два підходи, і важливо розуміти різницю між ними з самого початку.

Database.EnsureCreated()

using var context = new AppDbContext();
context.Database.EnsureCreated();

Цей метод перевіряє, чи існує база даних (файл для SQLite, або схема для SQL Server/PostgreSQL). Якщо не існує — створює базу і всі таблиці відповідно до поточної моделі. Якщо вже існує — нічого не робить.

Це зручно для навчальних проєктів і прототипів. Але у нього є критичне обмеження:

EnsureCreated()не сумісний з міграціями. Якщо ви використаєте EnsureCreated(), а потім додасте міграцію, вона може не застосуватись або спричинить конфлікт. Ці два підходи взаємовиключні.

Migrations (рекомендований підхід)

Міграції — це керована еволюція схеми бази даних. Кожна зміна в моделі C# → окремий файл міграції (Up/Down) → застосовується через dotnet ef database update. Це дозволяє відстежувати зміни у Git, відкочувати їх, і безпечно оновлювати продакшн-базу.

Повністю міграції розглядаються у Блоці 5 цього курсу. Для навчальних цілей поки що використаємо EnsureCreated().

EnsureCreated()Migrations
СкладністьНульПотребує розуміння
КонтрольАвтоматичноПовний контроль
Оновлення схемиНе підтримуєПідтримує
Продакшн❌ Не рекомендовано✅ Yes
Навчання/прототип✅ PerfectЗайвий overhead

Структура проєкту

Перш ніж писати CRUD, подивимося на структуру файлів, яку ми будуємо:

А ось як ця модель виглядає у вигляді ER-діаграми:

Loading diagram...
@startuml
skinparam style plain

entity Author {
    * Id : int <<PK>>
    --
    * Name : string
    Biography : string?
    * BornAt : DateTime
}

entity Book {
    * Id : int <<PK>>
    --
    * Title : string
    * Isbn : string
    * Year : int
    * Price : decimal
    * AuthorId : int <<FK>>
}

Author ||--o{ Book : "writes"

@enduml

CREATE: додавання даних

Тепер — повний CRUD. Починаємо зі створення нових записів.

Program.cs
// Крок 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}");

Поглянемо на кожен крок детально.

Anatomy: Add()

context.Authors.Add(author) не виконує жодного SQL. Він лише реєструє об'єкт author у Change Tracker зі станом EntityState.Added. Це все — жодного звернення до бази.

Ви можете додати 100 об'єктів через Add() підряд — і лише один SaveChanges() виконає всі 100 INSERT-ів однією транзакцією та, де можливо, одним батч-запитом.

Anatomy: SaveChangesAsync()

Це центральний метод, де відбувається вся "магія". Ось покроково:

DetectChanges()

EF Core обходить всі відстежувані об'єкти і порівнює їх поточний стан зі збереженим знімком. Знаходить всі Added, Modified, Deleted.

SQL Generation

Для кожної зміни генерує відповідний SQL. Для EntityState.AddedINSERT INTO Authors (...) VALUES (...).

Batch Execution

SQL-команди об'єднуються у батчі і виконуються у неявній транзакції. Якщо будь-яка команда провалиться — вся транзакція відкотиться.

Identity Update

Після виконання INSERT база повертає згенерований Id. EF Core автоматично заповнює author.Id — тому після SaveChangesAsync() у об'єкта вже є реальний Id з бази.

State Reset

Всі об'єкти переходять зі стану Added у стан Unchanged. Change Tracker оновлює знімки до актуальних значень.

Ось чому після SaveChangesAsync() ми можемо прочитати author.Id — EF Core вже заповнив його значенням з бази.

Генерований SQL

Що насправді виконує EF Core? Вмикаємо логування і дивимось:

INSERT INTO "Authors" ("Biography", "BornAt", "Name")
VALUES ('Видатний українс...', '1856-08-27 00:00:00', 'Іван Франко')
RETURNING "Id";

Зверніть увагу: Id не передається в INSERT — він генерується базою (AUTOINCREMENT у SQLite). RETURNING "Id" — SQLite-специфічна інструкція, що повертає згенероване значення.


READ: читання даних

EF Core надає кілька способів читати дані — від простого пошуку за Id до складних LINQ-запитів.

FindAsync — читання за первинним ключем

Program.cs
// Пошук за 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. Якщо є — повертає його без запиту до БД. Це корисна оптимізація для сценаріїв, де ви вже завантажили об'єкт раніше.

LINQ-запити: Where, Select, OrderBy

Program.cs
// Всі книги дорожче 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 *. При великих сутностях (з великими текстовими або бінарними полями) це суттєво знижує обсяг переданих даних.

Include: завантаження пов'язаних даних

Program.cs
// Завантажити автора разом зі всіма його книгами (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.


UPDATE: оновлення даних

Оновлення в EF Core засноване на механізмі Change Tracking. Ви не пишете UPDATE SQL — ви просто змінюєте властивості об'єкта, і EF Core сам визначає, що змінилось.

Program.cs
// 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}");

Як EF Core знає, що змінилось?

Коли 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 * — лише те, що реально змінилось. Це зменшує обсяг переданих даних і захищає від конфліктів при паралельних змінах.

Не плутайте "об'єкт існує в пам'яті" з "об'єкт відстежується EF Core". Якщо ви створили new Book() і заповнили його поля, але не передали через context.Books.Add() або не завантажили через запит — EF Core нічого про нього не знає і не збереже зміни.

DELETE: видалення даних

Видалення, як і оновлення, вимагає, щоб сутність була відстежувана контекстом.

Program.cs
// Спосіб 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;

Видалення без завантаження (EF Core 7+)

Якщо вам не потрібен сам об'єкт — лише виконати DELETE за умовою — EF Core 7 додав ExecuteDeleteAsync(). Це значно ефективніше: без завантаження об'єкта, без Change Tracking:

Program.cs
// Видалити всі книги старіші за 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).


Логування SQL: бачимо все, що відбувається

EF Core генерує SQL-запити за лаштунками. Щоб бачити кожен з них — вмикаємо логування. Це критично важливо при розробці: ви повинні знати, що саме виконується.

Спосіб 1: LogTo (найпростіший)

Data/AppDbContext.cs
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlite("Data Source=bookcatalog.db")
        .LogTo(Console.WriteLine, LogLevel.Information) // виводити в консоль
        .EnableSensitiveDataLogging();                  // показувати значення параметрів
}

Тепер кожен SQL буде виводитись у консоль:

dotnet run
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (2ms) [Parameters=[@p0='Іван Франко', @p1='1856-08-27', @p2='...'], CommandType='Text']
INSERT INTO "Authors" ("Biography", "BornAt", "Name")
VALUES (@p0, @p1, @p2)
RETURNING "Id";

EnableSensitiveDataLogging()

За замовчуванням EF Core не логує фактичні значення параметрів (тільки @p0, @p1). Це зроблено з міркувань безпеки — значення можуть містити паролі, персональні дані. EnableSensitiveDataLogging() вмикає показ значень.

EnableSensitiveDataLogging()тільки для розробки. Ніколи не вмикайте його у production. Використовуйте умовне увімкнення:
if (environment.IsDevelopment())
    optionsBuilder.EnableSensitiveDataLogging();

Спосіб 2: LogTo з фільтром

Якщо логів забагато і вони заважають — можна фільтрувати лише SQL-команди:

optionsBuilder.LogTo(
    Console.WriteLine,
    new[] { DbLoggerCategory.Database.Command.Name }, // тільки SQL
    LogLevel.Information
);

Повна демонстрація: все разом

Зберемо все написане в єдину програму і запустимо:

Program.cs
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}");
dotnet run
=== CREATE ===
Автор створений: Id=1
Книги створені: Id=1, Id=2
=== READ ===
Автор: Іван Франко
— Захар Беркут (1883), ₴150,00
— Борислав сміється (1881), ₴120,00
=== UPDATE ===
Ціна 'Захар Беркут' оновлена до ₴200,00
=== DELETE ===
Книга 'Борислав сміється' видалена
=== FINAL STATE ===
Залишилось книг: 1

Lifecycle запиту: від LINQ до SQL

Узагальнимо, що відбувається при кожному запиті:

Loading diagram...
graph LR
    A["📝 LINQ-вираз<br/>context.Books.Where(...)"]
    B["🌳 Expression Tree<br/>(C#-дерево виразу)"]
    C["🔄 Query Compiler<br/>(EF Core внутрішній механізм)"]
    D["💬 SQL-рядок<br/>SELECT ... WHERE ..."]
    E["🔌 Database Provider<br/>SQLite / PostgreSQL / ..."]
    F["📊 DbDataReader<br/>(сирі рядки результату)"]
    G["✨ Матеріалізація<br/>(C#-об'єкти)"]
    H["👁️ Change Tracker<br/>(реєстрація об'єктів)"]
    I["📦 Результат<br/>List<Book>"]

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    G --> H
    H --> I

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#f59e0b,stroke:#b45309,color:#ffffff
    style G fill:#10b981,stroke:#059669,color:#ffffff
    style I fill:#64748b,stroke:#334155,color:#ffffff

Зверніть увагу на два ключових моменти:

Матеріалізація відбувається лише при виклику термінального оператора (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();

Типові помилки початківця

Ось кілька помилок, з якими стикається більшість розробників на старті:


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

Рівень 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.PublisherIdPublisher.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 — Міні-бібліотека: Реалізуйте консольний застосунок «Бібліотека» з наступним функціоналом:

  • Додавання автора
  • Додавання книги до автора
  • Пошук книг за мінімальною ціною (параметр)
  • Оновлення ціни книги за ISBN
  • Видалення автора разом зі всіма його книгами (і перевірка, що Cascade Delete спрацьовує)

Логування SQL має бути увімкнено. Для кожної операції підрахуйте, скільки SQL-запитів виконується.

Завдання 3.2 — Аналіз ефективності: Напишіть два варіанти методу, що повертає список авторів із кількістю їх книг:

  • Варіант A: завантажити всіх авторів через Include(a => a.Books), потім a.Books.Count в пам'яті
  • Варіант B: використати Select(a => new { a.Name, BookCount = a.Books.Count })

Порівняйте SQL, що генерується. Поясніть, чому Варіант B є кращим.


Підсумок

Ключові думки цієї статті:
  • EF Core складається з трьох основних пакетів: ядро, провайдер БД, інструменти CLI
  • Сутності — це звичайні C#-класи (POCO). EF Core використовує конвенції для автоматичного визначення первинних ключів, зовнішніх ключів та обов'язковості полів через Nullable Reference Types
  • DbContext — серце EF Core, що реалізує Unit of Work і Data Mapper. Містить ChangeTracker, Database API і скомпільовану модель
  • DbSet<T> — не колекція в пам'яті, а IQueryable-провайдер запитів. Дані завантажуються лише при матеріалізації (ToListAsync, FirstAsync тощо)
  • EnsureCreated() підходить для навчання і прототипів, але несумісний з міграціями. Для реальних проєктів — Migrations
  • SaveChanges() — єдина точка збереження. Виконує DetectChanges → SQL Generation → Batch Execution в одній транзакції
  • Логування SQL через LogTo() є обов'язковим під час розробки. Завжди знайте, який SQL генерує ваш LINQ
  • AsNoTracking() для read-only запитів — менше overhead Change Tracker'а ::
Наступна стаття — DbContext: Серце EF Core — детально розбирає внутрішню архітектуру DbContext: StateManager, ChangeTracker, QueryCompiler, lifecycle, потокобезпека та DbContextFactory.