Entity Framework Core - ORM, DbContext i zapytania z LINQ do bazy
Entity Framework Core w pigułce - encje, DbContext jako sesja z bazą, IQueryable kontra IEnumerable, śledzenie zmian, migracje oraz pułapki produkcyjne. Piąta część kursu C#.
W poprzednim wpisie LINQ w C# zobaczyłeś, jak filtrować, sortować i przekształcać kolekcje w pamięci. Teraz pójdziemy o krok dalej: zamiast operować na List<Gra> żyjącej w RAM-ie aplikacji, podłączymy się do realnej bazy danych. I co najlepsze - składnia LINQ pozostaje dokładnie ta sama.
Klejem między światem obiektowym C# a tabelami w bazie relacyjnej jest Entity Framework Core - ORM od Microsoftu. W tej części poznasz, czym ORM jest, jak zaprojektować model encji, czym różni się IQueryable od IEnumerable, jak EF śledzi zmiany i zapisuje je przez SaveChanges, oraz na co uważać w aplikacjach produkcyjnych.
1. Czym jest ORM i dlaczego EF Core istnieje #
Co to jest: ORM (Object-Relational Mapper) to warstwa pośrednicząca między obiektami w pamięci aplikacji a wierszami w tabelach bazy relacyjnej. Pozwala pisać kod operujący na klasach C#, a pod spodem generuje odpowiednie polecenia SQL.
Dokumentacja Microsoftu opisuje EF Core jako “lightweight, extensible, open source and cross-platform version of the popular Entity Framework data access technology” i podsumowuje jego rolę dwoma punktami:
- Enables .NET developers to work with a database using .NET objects.
- Eliminates the need for most of the data-access code that typically needs to be written.
Dlaczego istnieje: bez ORM-a każda interakcja z bazą wymaga pisania ręcznego SQL, otwierania połączeń, mapowania wierszy SqlDataReader na pola obiektu, obsługi wyjątków, transakcji i konwersji typów. Dla aplikacji z setkami zapytań to mnóstwo kodu, który jest prawie identyczny dla każdej encji. ORM eliminuje tę powtarzalność i pozwala skupić się na regułach biznesowych.
Pod spodem trwa rozwiązanie tzw. object-relational impedance mismatch - świat obiektowy (referencje, dziedziczenie, kolekcje) nie pasuje wprost do świata relacyjnego (klucze obce, normalizacja, JOIN). ORM ten dysonans rozbraja.
Wspierane bazy danych #
EF Core obsługuje większość popularnych systemów. Każdy jest podpinany przez osobny pakiet NuGet z metodą Use*:
| System | Konfiguracja | Pakiet NuGet |
|---|---|---|
| SQL Server / Azure SQL | .UseSqlServer(...) | Microsoft.EntityFrameworkCore.SqlServer |
| SQLite | .UseSqlite(...) | Microsoft.EntityFrameworkCore.Sqlite |
| PostgreSQL | .UseNpgsql(...) | Npgsql.EntityFrameworkCore.PostgreSQL |
| MySQL / MariaDB | .UseMySql(...) | Pomelo.EntityFrameworkCore.MySql |
| Oracle | .UseOracle(...) | Oracle.EntityFrameworkCore |
| Azure Cosmos DB | .UseCosmos(...) | Microsoft.EntityFrameworkCore.Cosmos |
| Baza w pamięci (tylko testy) | .UseInMemoryDatabase(...) | Microsoft.EntityFrameworkCore.InMemory |
Kod biznesowy jest dokładnie ten sam niezależnie od wybranej bazy - zmiana z SQL Server na PostgreSQL to podmiana jednej linii konfiguracji i pakietu NuGet.
2. Model: encje + DbContext #
Aplikacja EF Core składa się z trzech elementów: encji, kontekstu i konfiguracji.
Encja - klasa POCO mapowana na tabelę #
Encja to zwykła klasa C# (POCO - Plain Old CLR Object) reprezentująca jeden rodzaj danych w bazie. Bez dziedziczenia po niczym specjalnym, bez atrybutów EF-owych - sama struktura wystarczy.
public class Blog
{
public int BlogId { get; set; } // klucz główny (konwencja)
public string Url { get; set; }
public int Rating { get; set; }
public List<Post> Posts { get; set; } // właściwość nawigacyjna
}
public class Post
{
public int PostId { get; set; }
public string Title { get; set; }
public string Content { get; set; }
public int BlogId { get; set; } // klucz obcy (konwencja)
public Blog Blog { get; set; } // właściwość nawigacyjna
}
EF Core opiera się na konwencjach - rozpoznaje typowe wzorce automatycznie:
- Właściwość
Idlub<NazwaEncji>Id(np.BlogId) → klucz główny. - Właściwość typu innej encji + odpowiadająca jej właściwość kończąca się na
Id→ relacja oparta na kluczu obcym. - Właściwość
List<T>(gdzieTjest encją) → relacja “jeden do wielu”.
Wszystko, co odbiega od konwencji, konfigurujesz atrybutami ([Key], [ForeignKey], [Required]) lub w metodzie OnModelCreating.
DbContext - sesja z bazą #
Co to jest: DbContext to klasa pochodna od Microsoft.EntityFrameworkCore.DbContext. Reprezentuje pojedynczą sesję z bazą - krótkotrwałą jednostkę pracy (unit of work z notatek Martina Fowlera, na które dokumentacja MS wprost się powołuje).
Dlaczego istnieje: kontekst pełni trzy role na raz:
- Mapuje encje na tabele - tu deklarujesz, które klasy są encjami (
DbSet<T>). - Śledzi zmiany - od chwili wczytania encji do
SaveChangesEF zapisuje, które pola się zmieniły. - Generuje i wysyła SQL - wszystkie zapytania i polecenia przechodzą przez niego.
using Microsoft.EntityFrameworkCore;
public class BloggingContext : DbContext
{
public DbSet<Blog> Blogs { get; set; }
public DbSet<Post> Posts { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(
@"Server=(localdb)\mssqllocaldb;Database=Blogging;Trusted_Connection=True;");
}
}
DbSet<T> to uchwyt na pojedynczą tabelę. Na nim wykonujesz zapytania (context.Blogs.Where(...)), dodajesz nowe rekordy (context.Blogs.Add(...)) i usuwasz istniejące (context.Blogs.Remove(...)).
Konfiguracja przez wstrzykiwanie zależności #
W aplikacjach ASP.NET Core kontekst rejestrujemy w kontenerze DI metodą AddDbContext:
builder.Services.AddDbContext<BloggingContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
AddDbContext rejestruje kontekst jako usługę o cyklu życia scoped - jedna instancja na żądanie HTTP. To naturalnie pokrywa się z definicją jednostki pracy: każde żądanie to osobna jednostka.
3. Zapytania: LINQ over IQueryable #
Tu wszystko, czego nauczyłeś się w poprzednim wpisie o LINQ, zwraca z odsetkami. EF Core używa dokładnie tych samych operatorów - Where, OrderBy, Select, Count, First, Sum - z jedną kluczową różnicą.
IQueryable kontra IEnumerable #
Co to jest: IQueryable<T> to interfejs dziedziczący po IEnumerable<T>. Wygląda tak samo - można na nim wywoływać te same metody LINQ - lecz zachowanie jest fundamentalnie inne.
IEnumerable<T> | IQueryable<T> | |
|---|---|---|
| Skąd pochodzi | List<T>, tablica, Dictionary | DbSet<T> z EF Core |
| Co zapisuje operator LINQ | delegację (funkcję) | drzewo wyrażeń (expression tree) |
| Gdzie wykonuje się logika | w pamięci aplikacji | tłumaczone na natywny język bazy (np. SQL) |
| Co jest pobierane | wszystkie dane | tylko to, co naprawdę potrzebne |
Dlaczego to istotne: rozważ takie zapytanie:
var topBlogs = context.Blogs
.Where(b => b.Rating > 3)
.OrderByDescending(b => b.Rating)
.Take(5)
.ToList();
Pod spodem EF Core nie pobiera wszystkich blogów. Buduje drzewo wyrażeń opisujące zapytanie, a provider (np. Microsoft.EntityFrameworkCore.SqlServer) tłumaczy je na SQL podobny do:
SELECT TOP(5) [b].[BlogId], [b].[Url], [b].[Rating]
FROM [Blogs] AS [b]
WHERE [b].[Rating] > 3
ORDER BY [b].[Rating] DESC
Baza wykonuje całą pracę. Z sieci przychodzi tylko pięć rekordów, a nie miliony.
Pułapka: client kontra server evaluation #
Niektóre wyrażenia EF Core nie umie przetłumaczyć na SQL - np. wywołania własnych metod. Wtedy obawia się dwóch ścieżek:
- Server evaluation - wszystko, co rozumie, robi w bazie.
- Client evaluation - resztę wykonuje już po pobraniu danych do pamięci aplikacji.
Od wersji 3.0 EF Core przy domyślnej konfiguracji zakazuje klient evaluation w predykatach Where - rzuca wyjątek przy próbie wywołania nieznanej mu metody. Wyjątek można obejść, jawnie materializując wcześniej dane:
// NIE zadziała - EF Core nie umie przetłumaczyć CustomValidation na SQL
var invalid = context.Blogs.Where(b => CustomValidation(b.Url)).ToList();
// Dobrze - najpierw bierzemy wszystko z bazy, potem filtrujemy w pamięci
var invalid = context.Blogs
.ToList() // tutaj IQueryable → IEnumerable
.Where(b => CustomValidation(b.Url)) // teraz LINQ w pamięci
.ToList();
To świadoma decyzja: lepszy jawny wyjątek niż ukryte pobieranie milionów rekordów z bazy bez ostrzeżenia.
Materializacja: kiedy SQL faktycznie leci #
Tak samo jak w “zwykłym” LINQ, zapytanie EF Core jest leniwe. SQL nie poleci, dopóki nie wywołasz metody materializującej:
.ToList()/.ToListAsync()- lista wszystkich wyników.ToArray()/.ToArrayAsync()- tablica.First()/.FirstAsync()- pierwszy element.Count()/.Sum()/.Average()- wartość skalarnaforeach- iteracja po sekwencji
W aplikacjach asynchronicznych zawsze preferuj warianty ...Async - inaczej blokujesz wątek na czas oczekiwania na bazę.
4. Zapisywanie danych: change tracking + SaveChanges #
EF Core jest inteligentny. Nie musisz mu mówić “wyślij UPDATE z polem Rating = 5” - sam wykryje, że coś się zmieniło.
Cykl jednostki pracy #
using var db = new BloggingContext();
// 1. Wczytujemy encję - EF Core dodaje ją do change trackera
var blog = await db.Blogs.SingleAsync(b => b.BlogId == 1);
// 2. Modyfikujemy - EF Core odnotuje, że pole Rating się zmieniło
blog.Rating = 5;
// 3. Zapisujemy - EF generuje minimalne UPDATE z jednym tylko polem
await db.SaveChangesAsync();
Pod spodem leci coś w stylu:
UPDATE [Blogs] SET [Rating] = 5 WHERE [BlogId] = 1;
Tylko jedna kolumna w SET - bo tylko ona się zmieniła. To duża różnica wydajnościowa przy szerokich tabelach.
Dodawanie i usuwanie #
// INSERT
var newBlog = new Blog { Url = "https://szymonsays.com", Rating = 5 };
db.Blogs.Add(newBlog);
// DELETE
var old = await db.Blogs.SingleAsync(b => b.BlogId == 42);
db.Blogs.Remove(old);
// Oba pójdą jednym SaveChangesAsync (w jednej transakcji)
await db.SaveChangesAsync();
SaveChanges automatycznie owija wszystkie zmiany w jedną transakcję - albo wszystko zostanie zapisane, albo nic, jeśli cokolwiek się nie powiedzie.
AsNoTracking dla zapytań tylko do odczytu #
Domyślnie każda encja zwrócona przez zapytanie ląduje w change trackerze - kosztem pamięci i CPU. Dla widoków, raportów i list “tylko do odczytu” sięgaj po AsNoTracking():
var report = await db.Blogs
.AsNoTracking()
.Where(b => b.Rating > 3)
.ToListAsync();
// report zawiera dane, ale EF Core ich nie śledzi - mniej narzutu
Dokumentacja Microsoftu opisuje to wprost: No-tracking queries are useful when the results are used in a read-only scenario. They’re generally quicker to execute because there’s no need to set up the change tracking information.
5. Relacje i ładowanie powiązanych danych #
Skoro Blog ma List<Post> Posts, to jak EF Core to wczytuje?
Eager loading: Include #
Najprostsze i najczęstsze podejście - mówisz wprost, które relacje załadować razem z encją:
var blogiZPostami = await db.Blogs
.Include(b => b.Posts) // pobierz też posty
.Where(b => b.Rating > 3)
.ToListAsync();
foreach (var blog in blogiZPostami)
{
Console.WriteLine($"{blog.Url}: {blog.Posts.Count} postów");
}
Include generuje JOIN. Możesz iść głębiej przez ThenInclude:
var full = await db.Blogs
.Include(b => b.Posts)
.ThenInclude(p => p.Komentarze)
.ToListAsync();
Pułapka: problem N+1 #
Najsłynniejszy błąd w pracy z ORM. Wyobraź sobie kod bez Include:
var blogi = await db.Blogs.ToListAsync(); // 1 zapytanie - lista blogów
foreach (var blog in blogi)
{
Console.WriteLine($"{blog.Url}: {blog.Posts.Count}"); // N zapytań - jedno na blog!
}
Każdy odczyt blog.Posts to osobne zapytanie do bazy. Jeśli masz 1000 blogów - 1001 zapytań zamiast jednego. Rozwiązaniem jest Include (eager loading) albo świadome projekcjonowanie (Select) z konkretnymi polami.
6. Migracje - ewolucja schematu razem z kodem #
Modele zmieniają się w czasie. Dodajesz nową właściwość do encji - baza musi to odzwierciedlić. Migracje EF Core robią to za ciebie.
# Wygenerowanie migracji z różnicy między modelem a poprzednim snapshotem
dotnet ef migrations add DodanoPoleAutorBloga
# Aplikacja migracji do bazy
dotnet ef database update
Każda migracja to plik C# z metodami Up (jak doprowadzić bazę do nowego stanu) i Down (jak cofnąć). Trzymane są w repo razem z kodem aplikacji - dzięki temu cała historia schematu jest pod kontrolą wersji.
Zasada ogólna: migracje wykonujesz w środowisku deweloperskim. Na produkcji aplikujesz je albo w pipeline deployment, albo przez dotnet ef migrations script (wygenerowanie czystego SQL do ręcznego wykonania) - nie automatycznie przy starcie aplikacji, bo to wymaga zbyt wysokich uprawnień do bazy.
7. Pułapki produkcyjne #
DbContext nie jest bezpieczny dla wielu wątków #
Cytat z dokumentacji Microsoftu, krótki i jednoznaczny: DbContext is Not thread-safe. Don’t share contexts between threads. Każde użycie tej samej instancji równolegle z wielu wątków daje niezdefiniowane zachowanie i potencjalne uszkodzenie danych.
W ASP.NET Core problem rozwiązuje za ciebie DI - AddDbContext rejestruje kontekst jako scoped, czyli osobną instancję na każde żądanie HTTP. Poza tym wzorcem musisz sam tworzyć instancje przez fabrykę (AddDbContextFactory).
Async-first - zawsze await #
// ŹLE - wywołanie bez await może uszkodzić stan kontekstu
var task1 = db.Blogs.ToListAsync();
var task2 = db.Posts.ToListAsync(); // równoległe użycie tej samej instancji
await Task.WhenAll(task1, task2); // wyjątek lub korupcja danych
// DOBRZE - każda operacja jest awaited sekwencyjnie
var blogi = await db.Blogs.ToListAsync();
var posty = await db.Posts.ToListAsync();
Baza in-memory nie służy do produkcji #
UseInMemoryDatabase() wygląda kusząco - zero konfiguracji, działa od ręki. Microsoft jednak wprost ostrzega: The EF Core in-memory database is not designed for production use. In addition, it may not be the best choice even for testing. Nie wymusza constraintów referencyjnych, nie obsługuje pełnych transakcji, nie wykonuje SQL. Do testów integracyjnych dużo lepszą alternatywą jest SQLite w trybie in-memory - prawdziwa baza relacyjna trzymana w pamięci, którą można nawet zrzucić do pliku.
Wartości domyślne typów kolumn #
Bez jawnej konfiguracji EF Core dla string używa nvarchar(max), a dla decimal - decimal(18, 2). To rzadko są optymalne wybory. Przy projektowaniu schematu albo dodawaj atrybuty ([MaxLength], [Column(TypeName = "decimal(10, 4)")]), albo konfiguruj wszystko w OnModelCreating.
Podsumowanie tematu #
W tej części kursu poznałeś:
- ORM - warstwa łącząca obiekty C# z tabelami bazy, eliminująca ręczne pisanie SQL i mapowania
- Encje + DbContext - klasy POCO mapowane przez konwencje na tabele;
DbContextjako jednostka pracy zDbSet<T>dla każdej tabeli - IQueryable kontra IEnumerable - operacje na
IQueryablebudują drzewo wyrażeń tłumaczone na natywny SQL; operacje naIEnumerabledziałają w pamięci aplikacji - Change tracking + SaveChanges - EF wykrywa modyfikacje i generuje minimalne
INSERT/UPDATE/DELETEw jednej transakcji;AsNoTrackingdla zapytań tylko do odczytu - Include + N+1 - eager loading powiązanych encji, najczęstsza pułapka wydajnościowa ORM-ów
- Migracje - ewolucja schematu wraz z modelem, trzymana w repo razem z kodem
- Pułapki produkcyjne - DbContext nie jest thread-safe, zawsze
await, baza in-memory nie do produkcji, kolumny domyślne wymagają tuningu
W następnym wpisie Delegaty i zdarzenia w C# wracamy do podstaw języka i poznajemy mechanizm, na którym opierają się wszystkie interaktywne aplikacje .NET - od WinForms po SignalR. Zobaczysz, jak EventHandler i EventArgs realizują wzorzec wydawca-subskrybent.
Quiz poniżej. Tym razem bez code challenge - uruchomienie EF Core wymaga zestawu narzędzi spoza piaskownicy w przeglądarce (.csproj, NuGet, dotnet ef), więc realne ćwiczenie z bazy lepiej zrobić we własnym IDE.
Podobne wpisy
Więzy integralności w SQL - Primary Key, Foreign Key, CASCADE, UNIQUE, CHECK
Mechanizmy obronne bazy danych - Primary Key (INT IDENTITY kontra GUID), Foreign Key i integralność referencyjna, trzy strategie ON DELETE (CASCADE/SET NULL/NO ACTION), constraints UNIQUE i CHECK jako walidacja na poziomie bazy. Dwudziesta czwarta część kursu C#.
SQL w praktyce - DDL i DML - CREATE, INSERT, SELECT, UPDATE, DELETE, JOIN
Praktyczny SQL dla programisty C# - podział na DDL/DML/DCL/TCL, CREATE TABLE z kluczami głównymi i obcymi, CRUD (Wielka Czwórka DML), INNER JOIN, klasyczne pułapki UPDATE/DELETE bez WHERE, mapowanie SQL na LINQ w C#. Dwudziesta trzecia część kursu C#.
Relacyjne bazy danych w C# - architektura RDBMS, schemat tabel, mapowanie typów SQL
Architektura silnika RDBMS (Query Optimizer, Storage Engine, Transaction Log), projektowanie tabel z PK/FK, mapowanie typów SQL Server na typy C# (INT, NVARCHAR, DECIMAL, DATETIME2, DATE). Dwudziesta druga część kursu C#.
🔗 Linkują tu