Relacje, Include i transakcje w EF Core — zaawansowane operacje na bazie

Relacje OneToMany i ManyToMany, Include vs ThenInclude, AsNoTracking dla wydajności i transakcje w EF Core — wszystko, co robi Twoją bazę przewidywalną.

JAJarosław Armatys 9 min czytania
Wersja wideo tego wpisu — obejrzyj na YouTube

W poprzednich odcinkach WP importera uderzaliśmy w Google Places API i zgarnialiśmy dane firm. Teraz czas zrobić krok głębiej — wyciągnąć z bazy formę prawną, województwo i branżę, a potem spiąć to z obiektem Company tak, żeby Entity Framework sam dociągnął wszystko, czego potrzebujesz do opisu posta na WordPressie. W tym wpisie pokażę Ci, jak zamodelować relacje, kiedy sięgać po Include, dlaczego AsNoTracking potrafi uratować Ci wydajność i po co w ogóle istnieją transakcje.

To nie jest kurs EF Core od zera — zakładam, że wiesz, czym jest DbContext i DbSet. Dzisiaj rozmawiamy o rzeczach, które w realnym projekcie decydują, czy aplikacja chodzi płynnie, czy leje się na niej baza.

Relacje między modelami

Zanim w ogóle pomyślisz o Include, musisz mieć poprawnie zamodelowane relacje. W moim projekcie mam tabelę Company i osobne słowniki: LegalForm, Voivodship i Classification. Każdej firmie chcę przypisać jeden region i jedną formę prawną, ale jedna forma prawna może występować w wielu firmach — to klasyczny OneToMany.

EF Core zrobi za Ciebie sporo robot, jeśli trzymasz się konwencji: property typu navigation (np. Category) plus CategoryId (foreign key). Wygląda to tak:

public class Place
{
    public int Id { get; set; }
    public string Name { get; set; } = default!;
    public int CategoryId { get; set; }
    public PlaceCategory Category { get; set; } = default!;
    public List<PlaceTag> Tags { get; set; } = new();
}

public class PlaceCategory
{
    public int Id { get; set; }
    public string Name { get; set; } = default!;
    public List<Place> Places { get; set; } = new();
}

Patrząc na to jako .NET developer od razu widzisz dwie rzeczy. Po pierwsze CategoryId to klucz obcy — EF sam go podepnie, bo konwencja nazewnicza jest zachowana. Po drugie Category to obiekt nawigacyjny, po kropce którego wyciągniesz wszystkie właściwości kategorii bez dodatkowego query (o ile zrobisz to mądrze, o czym za chwilę).

ManyToMany to z kolei sytuacja, w której jedna firma może mieć wiele tagów, a jeden tag wiele firm. Od EF Core 5 nie musisz już ręcznie tworzyć encji pośredniej — wystarczy, że po obu stronach zdefiniujesz List<>, a EF sam stworzy tabelę join. W moim imporcie używam tego między innymi do mapowania firm na kody PKD — firma może mieć wiele, a jeden kod wraca w setkach firm.

Dodajemy referencję do modelu, żeby później w kodzie odwoływać się do konkretnych wartości po prostu po kropce. To jest ta cała magia EF Core — myślisz o obiektach, a baza robi się sama.

Jedna rzecz, która potrafi zaskoczyć: jeżeli zapomnisz zdefiniować CategoryId, EF Core i tak sobie poradzi, ale automatycznie wygenerowany shadow property będzie utrudniał Ci debug. Zawsze wpisuj FK jawnie.

Include i ThenInclude

Tu się zaczyna prawdziwa zabawa. Domyślnie EF Core nie ładuje powiązanych encji. Jeżeli pobierzesz Company bez Include, to w Category dostaniesz null — i gdzieś za pół godziny w produkcji dostaniesz NullReferenceException, kiedy Twój helper do WordPressa spróbuje zrobić company.LegalForm.Display.

Właśnie dlatego mówię jawnie EF Core: „dociągnij mi formę prawną, województwo i branżę razem z firmą”:

var places = await _db.Places
    .AsNoTracking()
    .Include(p => p.Category)
    .Include(p => p.Tags)
    .Where(p => p.Rating >= 4.0)
    .ToListAsync();

Include ładuje encję bezpośrednio powiązaną z główną tabelą. Ale co, jeśli Twoja relacja ma dwa poziomy? W moim przypadku firma ma branżę (Classification), a branża trzyma listę kodów PKD — żeby dotrzeć do kodów, muszę zejść dwa poziomy w dół:

var companies = await _db.Companies
    .Include(c => c.Classification)
        .ThenInclude(cl => cl.PkdCodes)
    .Include(c => c.LegalForm)
    .Include(c => c.Voivodship)
    .ToListAsync();

ThenInclude kontynuuje łańcuch z ostatnio dołączonej tabeli. Możesz tak schodzić w głąb, jak długo masz relacje. Ale uważaj — każdy Include to dodatkowy JOIN albo dodatkowe zapytanie (zależnie od konfiguracji), więc ładowanie pięciu zagnieżdżonych relacji „na wszelki wypadek” to najszybsza droga do wąskiego gardła.

Zasada: ładuj tylko to, z czego realnie skorzystasz w tym konkretnym flow. Jeśli w tej metodzie używasz tylko LegalForm, nie dociągaj klasyfikacji. Pod spodem EF to jest SQL, a SQL lubi być ascetyczny.

AsNoTracking dla wydajności

Domyślnie EF Core śledzi każdą encję, którą pobierze z bazy. Robi to po to, żeby jak zmienisz właściwość i wywołasz SaveChanges, to wiedział, co zmienić. Change tracker trzyma kopię każdego pobranego obiektu w pamięci — i przy każdej zmianie porównuje stary stan z nowym.

To jest świetne, kiedy edytujesz dane. I kosztowne, kiedy tylko czytasz.

W moim WP importerze 90% operacji to odczyt: wyciągam firmę z bazy, składam opis, pcham go do WordPressa, idę dalej. Nic nie modyfikuję, nic nie zapisuję. Trzymanie 300 obiektów Company w change trackerze po to, żeby po minucie je wyrzucić, to marnotrawstwo.

Rozwiązanie jest jedną linijką:

var companies = await _db.Companies
    .AsNoTracking()
    .Include(c => c.LegalForm)
    .Include(c => c.Voivodship)
    .ToListAsync();

AsNoTracking mówi EF Core: „weź te dane, zrób z nich obiekty, ale nie śledź ich”. Efekt? Mniejsze zużycie pamięci, szybsze query, mniej overhead'u przy deserializacji. Na setkach rekordów to nie jest coś, co poczujesz. Na dziesiątkach tysięcy — różnica potrafi być dziesięciokrotna.

Jeżeli w danej ścieżce tylko czytasz dane i nie zamierzasz ich modyfikować, AsNoTracking powinien być Twoim domyślnym wyborem. Tracking to narzut, za który płacisz tylko wtedy, kiedy go realnie używasz.

Kiedy nie używać AsNoTracking? Kiedy planujesz zmodyfikować pobraną encję i zapisać ją z powrotem. Wtedy tracking Ci pomaga — bez niego musiałbyś ręcznie robić Attach i EntityState.Modified.

Update vs Attach — kiedy co

To temat, który u juniorów często się miesza. Skoro już mówimy o trackingu, pokażę różnicę.

Update mówi EF Core: „ten obiekt istnieje w bazie, cały go zaktualizuj”. Nawet jeśli zmieniłeś jedno pole, EF zrobi UPDATE na wszystkich kolumnach. Jest wygodne, gdy masz encję odłączoną (np. przyszła z API) i chcesz ją z marszu zapisać.

Attach z kolei mówi: „weź ten obiekt, ustaw go jako Unchanged w trackerze, a ja sam powiem, co zmieniłem”. Dopiero po Attach ustawiasz EntityState.Modified na konkretnych polach — i wtedy UPDATE idzie tylko na te kolumny.

_db.Companies.Attach(company);
_db.Entry(company).Property(c => c.Nip).IsModified = true;
await _db.SaveChangesAsync();

Różnica w praktyce: Update jest szybszy do napisania, Attach generuje czystszy SQL i lepiej gra z concurrency tokenami. W realnym kodzie 95% razy Update wystarczy — chyba że optymalizujesz pod bardzo duży ruch.

Transakcje — kiedy są potrzebne

EF Core każdą operację SaveChangesAsync zamyka w transakcji automatycznie. Jeżeli robisz kilka zmian i jeden SaveChangesAsync — nie musisz się niczym martwić, albo wszystko się zapisze, albo nic.

Problem pojawia się, kiedy w jednym logicznym „kawałku pracy” masz kilka wywołań SaveChangesAsync albo mieszasz EF Core z surowym SQL-em, wysyłką do zewnętrznego API albo operacjami na plikach. Wtedy między jednym save'em a drugim wszystko może paść — i masz niespójną bazę.

Tu wchodzi jawna transakcja:

await using var tx = await _db.Database.BeginTransactionAsync();
try
{
    _db.Places.Add(newPlace);
    await _db.SaveChangesAsync();

    _db.PlaceCategories.Add(newCategory);
    await _db.SaveChangesAsync();

    await tx.CommitAsync();
}
catch
{
    await tx.RollbackAsync();
    throw;
}

Dopóki nie wywołasz CommitAsync, żadne zmiany nie są widoczne dla innych połączeń. Jeżeli gdziekolwiek poleci wyjątek — rollback cofa całość. Rzucenie wyjątku dalej (throw) jest ważne, żeby wyżej w stosie ktoś wiedział, że coś poszło nie tak.

W WP importerze używam tego, gdy zapisuję firmę, jej tagi PKD i log importu w jednym flow. Jeśli WordPress zwróci błąd między jednym a drugim zapisem, chcę, żeby baza wróciła do stanu sprzed operacji. Bez transakcji miałbym firmę w bazie bez tagów albo log bez firmy.

Transakcja to nie jest narzędzie, po które sięgasz zawsze. Sięgasz po nią wtedy, kiedy naruszenie spójności między kilkoma operacjami jest dla Ciebie problemem biznesowym, nie technicznym.

Pamiętaj też o izolacji transakcji — domyślnie ReadCommitted wystarczy w większości przypadków, ale jeżeli robisz raportowanie na żywo i nie chcesz widzieć niezatwierdzonych zmian innych sesji, rozważ Snapshot.

Co dalej?

W tym odcinku dotknęliśmy czterech rzeczy, które w każdym projekcie z EF Core decydują o jego jakości: relacje, eager loading, tracking i transakcje. Bez nich masz aplikację, która działa — dopóki nie wejdziesz na 10 tysięcy rekordów albo pierwszy crash nie zostawi Ci niespójnej bazy.

Jeżeli pracujesz z kodem WP importera, spróbuj teraz:

  • przerobić wszystkie zapytania czytające na AsNoTracking
  • zobaczyć w logach SQL (włącz EnableSensitiveDataLogging i LogTo), co realnie idzie do bazy, jak dodajesz kolejny Include
  • opakować import pojedynczej firmy w transakcję i sprawdzić, co się dzieje, kiedy w środku rzucisz wyjątek

W następnym odcinku bierzemy się za try/catch i obsługę błędów w .NET — bo skoro już wiemy, że transakcje potrafią lecieć, warto wiedzieć, jak to ogarnąć wyżej w aplikacji.

Masz pytania co do relacji, include'ów albo konkretnego przypadku z Twojego projektu? Wrzucaj w komentarzach pod filmem — chętnie rozbiorę to na części.

Tagi:#course-dotnet-wordpress#dotnet#entity-framework#database

Masz pytanie do tego wpisu?

Napisz do mnie — chętnie porozmawiam o szczegółach technicznych, albo o tym jak zastosować to u Ciebie w projekcie.