Wszystkie wpisy

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*:

SystemKonfiguracjaPakiet 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ść Id lub <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> (gdzie T jest 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:

  1. Mapuje encje na tabele - tu deklarujesz, które klasy są encjami (DbSet<T>).
  2. Śledzi zmiany - od chwili wczytania encji do SaveChanges EF zapisuje, które pola się zmieniły.
  3. 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 pochodziList<T>, tablica, DictionaryDbSet<T> z EF Core
Co zapisuje operator LINQdelegację (funkcję)drzewo wyrażeń (expression tree)
Gdzie wykonuje się logikaw pamięci aplikacjitłumaczone na natywny język bazy (np. SQL)
Co jest pobieranewszystkie danetylko 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ść skalarna
  • foreach - 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; DbContext jako jednostka pracy z DbSet<T> dla każdej tabeli
  • IQueryable kontra IEnumerable - operacje na IQueryable budują drzewo wyrażeń tłumaczone na natywny SQL; operacje na IEnumerable działają w pamięci aplikacji
  • Change tracking + SaveChanges - EF wykrywa modyfikacje i generuje minimalne INSERT/UPDATE/DELETE w jednej transakcji; AsNoTracking dla 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

🔗 Linkują tu