Jak zacząć z EntityFramework (core)

Jak zacząć z EntityFramework (core)

Wstęp

Witaj podróżniku. Skoro już tu jesteś, prawdopodobnie wiesz czym jest EntityFramework. Jeśli jednak nie, to przeczytaj kolejny akapit. W tym artykule postaram szybko wprowadzić Cię w jego świat, żebyś w ciągu kilku minut był w stanie zacząć z nim wspólną przygodę.

Czym jest Entity Framework? – rozwiń jeśli nie wiesz

EntityFramework Core zwany również EfCore to narzędzie typu ORMObject Relational Mapper. Prostymi słowami to mechanizm, który umie zamienić (mapować) rekordy znajdujące się w bazie danych, na obiekty modelowe. Po co to? To jest zwykły „pomagacz”. Bo oczywiście możesz napisać w starym dobrym ADO.NET kod w stylu:

//pseudokod
var resultList = new List<Employee>();
string sql = "SELECT * from employees";
var reader = _db.OpenQuery(sql);
while(reader.Read())
{
    var employee = new Employee();
    employee.Id = reader.GetGuid("id");
    employee.Name = reader.GetString("name");
    employee.Salary = reader.GetDecimal("salary");
    
    resultList.Add(employee);
    reader.Next();
}

return resultList;

ale ostatecznie większość programistów, pisząc w ADO.NET i tak kończyła z jakąś ubogą formą własnego ORMa.

Dlatego powstały właśnie takie narzędzia, żeby ułatwić pracę. Programista wie jak ma pobrać dane i wie jaki obiekt chce zwrócić. Nie musi się zajmować czarną robotą w stylu mapowania tak jak wyżej.

Jest sporo różnych ORMów na rynku. Myślę, że najpopularniejsze z nich (przynajmniej w mojej subiektywnej ocenie) to Dapper, nHibernate i właśnie EfCore. Przy czym Dapper zalicza się do grupy tzw. micro orm. Dlatego, że nie potrafi tyle, co EfCore, czy nHibernate. Wciąż musisz pisać własnego SQLa, ale za to dostajesz obiekt już elegancko zmapowany. No i Dapper jest zdecydowanie szybszy.

Kwestia nazwy

Pewnie zobaczysz nie raz obok siebie nazwy w stylu „Entity Framework”, „EfCore”, „Entity Framework Core”. Czy to jest ten sam produkt? Nie do końca. Jeśli chodzi o „Entity Framework” jest on używany w .NET Framework (czyli w tej starszej technologii przed .NET Core). Natomiast „EfCore”, czy po prostu „Entity Framework Core” to wersja dla .Net Core i nowszych. Czasem możesz widzieć te nazwy zamiennie. W tym artykule, pisząc „Ef”, „Entity Framework” zawsze będę miał na myśli „EfCore”.

Kwestia prędkości

Oczywiście, że ORMy są wolniejsze niż ręczne mapowanie. Jednak to zwolnienie w większości przypadków jest niezauważalne. Jeśli jednak faktycznie w niektórych miejscach potrzebujesz większej szybkości, możesz tylko te miejsca traktować inaczej – bez ORMa.

Instalacja narzędzi EfCore

Na początek sprawdź, czy masz już zainstalowane narzędzia do EF. Wklep taką komendę do command line’a:

dotnet ef --version

Jeśli Ci się wykrzaczyło, znaczy że nie masz. W takim przypadku zainstaluj:

dotnet tool install --global dotnet-ef

Powyższe polecenie zainstaluje globalnie narzędzia do EF, a poniższe zaktualizuje do najnowszej wersji:

dotnet tool update --global dotnet-ef

Przykładowa aplikacja

Jak zwykle, przygotowałem małą przykładową aplikację do tego artykułu. Możesz ją pobrać z GitHuba.

Aplikacja to prosty programik w stylu ToDoList. W ramach ćwiczeń polecam Ci go wykończyć.

Wymagane NuGety:

EfCore nie jest standardową biblioteką. Musisz pobrać sobie minimum dwa NuGety:

  • Microsoft.EntityFrameworkCore – cały silnik dla EfCore
  • Microsoft.EntityFrameworkCore.Design – to jest zestaw narzędzi potrzebny programiście podczas „projektowania” aplikacji. Są to narzędzia, które np. pomagają utworzyć migracje (o tym później). Nie są jednak dostępne w czasie działania aplikacji (runtime). Więc jeśli nie będziesz robił migracji, nie musisz tego instalować. Ale lepiej mieć, niż nie mieć.
  • Biblioteka do obsługi konkretnego typu bazy danych (DBMS) – inna jest dla MSSQL, inna dla Sqlite, inna dla Oracle itd. Ich nazwy określają do jakiej bazy danych się odnoszą, np: Microsoft.EntityFrameworkCore.SqlServer – do obsługi MSSQL.

Modele bazodanowe

Na początek stwórzmy sobie podstawowy model bazodanowy do aplikacji.

public enum ToDoItemStatus
{
    NotStarted,
    Running,
    Done
}

public class ToDoItem: BaseDbItem
{
    public DateTimeOffset? StartDate { get; set; }
    public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
    public DateTimeOffset? EndDate { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
}

Mamy tutaj zwykły ToDoItem, który może być w 3 stanach:

  • Nierozpoczęty (NotRunning)
  • Rozpoczęty (Running)
  • Zakończony (Done)

Zauważysz też, że mój model dziedziczy po BaseDbItem. To zwykła klasa abstrakcyjna, która zawiera Id:

public abstract class BaseDbItem
{
    public Guid Id { get; set; }
}

Oczywiście nie musisz jej tworzyć i możesz umieścić w każdym modelu pole Id. Ja jednak wolę to zrobić, bo to pomaga i może uprościć wiele rzeczy w późniejszym utrzymaniu takiego systemu. Dlatego też zachęcam Cię, żebyś stosował taką klasę bazową.

Kontekst bazy danych

To jest najważniejszy element EfCore. Technicznie to klasa, która dziedziczy po DbContext. Odzwierciedla całą zawartość bazy danych – wszystkie tabele, które chcesz mieć. Dlatego też musisz napisać taką klasę. W swojej najprostszej i NIEWYSTARCZAJĄCEJ postaci może wyglądać tak:

public class ApplicationDbContext: DbContext
{
    public DbSet<ToDoItem> ToDoItems { get; set; }
}

Właściwie to zbiór właściwości typu DbSet. DbSet odnosi się do tabeli w bazie danych. Dlatego też dla każdej tabeli będziesz miał osobne właściwości DbSet. Stwórzmy zatem drugi model – użytkownik. Dodamy też od razu go do kontekstu:

public class User: BaseDbItem
{
    public string Name { get; set; }
}

//dodajmy go też jako właściciela konkretnego ToDoItem:
public class ToDoItem: BaseDbItem
{
    public User Owner { get; set; }
    public DateTimeOffset? StartDate { get; set; }
    public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
    public DateTimeOffset? EndDate { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
}

//no i do kontekstu:
public class ApplicationDbContext: DbContext
{
    public DbSet<ToDoItem> ToDoItems { get; set; }
    public DbSet<User> Users { get; set; }
}

Połączenie z bazą danych

Mając stworzony DbContext, możemy teraz utworzyć połączenie do bazy danych. Sprowadza się to do trzech kroków:

Przechowywanie ConnectionString

ConnectionString do bazy będzie przechowywany oczywiście w ustawieniach – appsettings. Ja trzymam w głównym pliku: appsettings.json zamiast appsettings.Development.json, ponieważ jest mi prościej (za chwilę dowiesz się dlaczego).

Pamiętaj, że ten plik ląduje w repozytorium na serwerze, więc jeśli umieszczasz tam jakieś hasło, to pamiętaj, że to zła praktyka i powinieneś posłużyć się sekretami. W przeciwnym razie każdy z dostępem do repozytorium zobaczy Twoje hasło.

To teraz jak utworzyć ConnectionString do bazy lokalnej? Bardzo prosto. Najpierw otwórz sobie okienko SQL Server Object Explorer:

To okienko pozwala Ci zarządzać lokalnymi bazami. Teraz odnajdź bazę master na lokalnym serwerze i wejdź w jej właściwości (ważne, żebyś rozwinął bazę master. Jeśli jej nie rozwiniesz, nie zobaczysz connection stringa):

Z właściwości możesz odczytać connection stringa:

Skopiuj go i wklej do ustawień w pliku appsettings.json lub w secrets.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "MainDbConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=master;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
  }
}

Teraz zwróć uwagę, że w connection string jest zaszyta nazwa bazy – master. Dlatego też powinieneś ją zmienić na swoją docelową bazę. Ta baza nie musi istnieć, EfCore sam ją sobie utworzy, ale zmień tą nazwę. Nigdy nie pracuj na bazie master (traktuj ją tylko jako do odczytu):

  "ConnectionStrings": {
    "MainDbConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=todo;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
  }

Tutaj bazę nazwałem todo.

Przekazywanie ConnectionString

Musimy teraz w jakiś sposób powiedzieć EfCore’owi z jaką bazą chcemy się łączyć. Robimy to poprzez kontekst bazy danych. Wystarczy oprogramować konkretną wersję konstruktora w taki sposób:

public class ApplicationDbContext: DbContext
{
    public DbSet<ToDoItem> ToDoItems { get; set; }
    public DbSet<User> Users { get; set; }

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {

    }
}

I to w zasadzie wystarczy. Oczywiście nic Ci nie broni, żebyś w tym konstruktorze posiadał jakiś kod. Ale to w domyślnym mechanizmie wystarczy.

Pamiętaj tylko, że jeśli używasz EfCore z dependency injection, stwórz konstruktor z generyczną wersją DbContextOptions – tak jak wyżej: DbContextOptions<ApplicationDbContext> zamiast samego DbContextOptions.

Dlaczego? Jeśli masz w aplikacji jeden DbContext, to nie ma żadnej różnicy. Jednak, jeśli masz ich kilka, to użycie generycznej wersji DbContextOptions<T> zapewni, że do odpowiednich kontekstów zawsze trafią odpowiednie opcje. W przeciwnym razie dependency injection może coś pochrzanić.

Więc lepiej wyrób sobie taki nawyk.

Teraz dwa słowa wyjaśnienia. EfCore działa w taki sposób, że to kontekst bazy danych musi wiedzieć z jaką bazą się łączy. I ten connection string przekazuje się w konstruktorze. Jednak nie możesz go tam zahardkodować, bo to zła praktyka z punktu widzenia bezpieczeństwa i utrzymania takiego kodu.

Oczywiście mógłbyś posłużyć się mechanizmem konfiguracji i wstrzyknąć tutaj jakieś IOptions. Ale to już jest domyślnie. Ten domyślny sposób korzysta z tego przeciążenia konstruktora – wstrzykuje do kontekstu odpowiednie opcje.

Podczas rejestracji kontekstu, rejestrowane są też jego opcje (DbContextOptions), w którym connection string jest już obecny. Jeśli dobrze zarejestrujemy 🙂

Rejestracja kontekstu bazy danych

Kontekst rejestrujemy tak jak każdy inny serwis, dodatkowo podając connection stringa i mówiąc mu, jakiej bazy chcemy użyć.

var config = builder.Configuration;
builder.Services.AddDbContext<ApplicationDbContext>(o =>
{
    o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
});

Uwaga! GetConnectionString to metoda pomocnicza, która pobiera sekcję z ustawień aplikacji o nazwie „ConnectionStrings”. Więc jeśli Twoja sekcja nazywa się inaczej lub w innym miejscu masz tego connection stringa, wtedy posłuż się standardowym wywołaniem w stylu config[„MojaSekcja:Connection”].

Pamiętaj, że UseSqlServer to extension method pochodzące z NuGet: Microsoft.EntityFrameworkCore.SqlServer. Jeśli zainstalujesz inny NuGet, np. do obsługi SQLite, wtedy będziesz używał metody UseSqlite. Analogicznie z innymi bazami danych.

Inne sposoby

Są też inne prawilne sposoby rejestracji takiego kontekstu. Np. za pomocą DbContextFactory, o którym za chwilę.

Czym są migracje w EfCore?

Nieodłącznym mechanizmem EfCore są migracje. EfCore jest w stanie sam utworzyć bazę danych. Co więcej, jest w stanie pilnować, żeby struktura bazy danych odpowiadała strukturze w Twoich modelach (choć pamiętaj, że świat relacyjny i obiektowy to zupełnie dwa różne miejsca). Co więcej, od jakiegoś czasu EfCore potrafi też stworzyć modele na podstawie tabel istniejących w bazie danych.

Na pewno spotkałeś się z pojęciami Code First i Database First. To właśnie opisuje sposób tworzenia bazy danych. Podejście Code First tworzy bazę danych z istniejących modeli obiektowych. Database First z kolei na odwrót – tworzy modele obiektowe na podstawie struktury bazy danych. W EfCore jeszcze do niedawna było możliwe użycie jedynie podejścia CodeFirst.

EfCore, aby to wszystko było możliwe, posługuje się migracjami. Z technicznego punktu widzenia, migracja to zwykła klasa, która posiada przepis na utworzenie konkretnej wersji bazy danych.

Gdy zmieniasz swój model i uznajesz go za pewną skończoną wersję, powinieneś zrobić migrację. Robi się to za pomocą narzędzi EfCore. Wystarczy, że w konsoli przejdziesz do folderu, w którym jest Twój projekt i wpiszesz tam takie polecenie:

dotnet ef migrations add "InitialDbCreate"

InitialDbCreate to oczywiście nazwa Twojej migracji. Ta jest standardową nazwą na pierwszą migrację. Poza tym przyjęło się, że nazwa migracji powinna mówić o tym, co migracja zawiera, np. „AddedClientName” albo „RemovedOwnerModel” itd.

Wykonanie tego polecenia stworzy folder Migrations i dwa pliki w środku. Pierwszy to migracja (klasa dziedzicząca po Migration), a drugi to snapshot aktualnej wersji – powstaje na podstawie migracji. Jeśli chcesz od nowa zbudować bazę danych, po prostu możesz usunąć cały folder i bazę danych.

Mając taką migrację, możesz teraz uaktualnić / stworzyć bazę danych poleceniem:

dotnet ef database update

To polecenie po pierwsze połączy się z bazą danych, a po drugie utworzy lub odczyta specjalną tabelę, w której są zapisane nazwy migracji, z których powstaje baza danych. Patrzy na ostatni wpis i wykonuje kolejną migrację – aktualizuje strukturę bazy danych.

Oczywiście dokładnie tak samo, jak tworzyłbyś skrypty SQL aktualizujące strukturę – tutaj też to może się nie udać. Przykładowo, jeśli istnieją rekordy i jakaś kolumna ma wartości NULL, a teraz chcesz zrobić, żeby ta kolumna nie mogła być NULLem, to się nie uda. Więc trzeba na takie rzeczy zwracać uwagę.

Jak to działa?

Zarówno tworzenie migracji jak i aktualizacja bazy uruchamia Twoją aplikację. Najpierw apka jest budowana (choć to można pominąć, dodając do polecenia argument --no-build, ale uważaj na to, bo może się okazać że migracja nie zostanie przeprowadzona), potem uruchamiana w specjalny sposób. I tutaj może pojawić się problem z odczytem connection stringa. W .NET6 narzędzia szukają connection stringa na produkcyjnej wersji środowiska (chyba że masz globalnie ustawioną zmienną środowiskową ASPNET_ENVIRONMENT z wartością Development) – dlatego wcześniej pisałem o tym, że dla ułatwienia connection string trzymany jest w głównym appsettings.json (lub secrets.json).

Pewnie można to jakoś zmienić, ale jeszcze nie rozkminiłem jak 🙂

Konfiguracja EfCore

EfCore jest na tyle „mądry”, że dużo potrafi wywnioskować na temat modeli będących w DbContext. I tak naprawdę jeśli w ogóle nie skonfigurujesz swoich modeli, to on i tak rozkimi, że pole o nazwie Id jest kluczem głównym; że pole OwnerId jest jakimś kluczem obcym. Co więcej nawet jak zobaczy właściwość wskazującą na inny obiekt, też stworzy z tego relację.

Jednak nie wszystko jest w stanie wydedukować. Poza tym mamy w naszym przypadku jeden poważny problem. Kolumna Status w tabeli ToDoItems domyślnie została utworzona jako int (EfCore tak domyślnie mapuje enumy). Czy to dobrze? Zdecydowanie nie. W tym momencie mamy możliwe 3 wartości:

public enum ToDoItemStatus
{
    NotStarted,
    Running,
    Done
}

NotStarted zostało zmapowane do 0, Running to 1, a Done to 2. Fajnie. A jak ktoś dołoży kolejną?

public enum ToDoItemStatus
{
    NotStarted,
    Running,
    Paused,
    Done
}

Wtedy w bazie wszystko się pop…rzy. Bo nagle Paused przyjmie wartość 2, a Done 3. Z bazy będzie wynikało, że żadne zadanie nie zostało ukończone. Za to te, co były ukończone będą uważane za wstrzymane. Dlatego mimo wszystko lepiej enumy trzymać jako string.

To wszystko i wiele więcej możemy uzyskać dzięki konfiguracji modeli. Konfigurację można przeprowadzić na dwa sposoby:

  • za pomocą adnotacji
  • używając fluentowej konfiguracji

Osobiście jestem zwolennikiem tej drugiej, bo dzięki temu modele bazodanowe są czyste. Poza tym adnotacje nie pozwolą na wszystko.

Przykład konfiguracji za pomocą adnotacji może wyglądać tak:

public class ToDoItem: BaseDbItem
{
    [Required]
    public Guid OwnerId { get; set; }
    [ForeignKey(nameof(OwnerId)]
    public User Owner { get; set; }
    public DateTimeOffset? StartDate { get; set; }
    public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
    public DateTimeOffset? EndDate { get; set; }
    [MaxLength(30)]
    public string Title { get; set; }
    public string Description { get; set; }
}

Tutaj mówimy do ORMa trzy rzeczy:

  • Pole OwnerId ma być wymagane (not null)
  • Owner to klucz obcy do tabeli Users, odpowiada mu właściwość OwnerId
  • Pole Title ma mieć maksymalnie 30 znaków

Tych adnotacji jest oczywiście więcej, ale moim skromnym zdaniem szkoda na nie czasu. Przejdźmy od razu do Fluent Configuration.

Konfiguracja typu Fluent

Możesz ją przeprowadzić na dwa sposoby. Czysto – tzn. oddzielne klasy odpowiadają za konfigurację konkretnych modeli lub brudno – wszystkie modele w jednej metodzie. Najpierw pokażę Ci tę „brudną” konfigurację.

Przejdźmy do naszego DbContext. Tam jest do przeciążenia taka metoda jak OnModelCreating, w której możemy te modele pokonfigurować. Ale zanim do tego dojdzie, zróbmy małą, ale cholernie istotną zmianę w modelu:

public class ToDoItem: BaseDbItem
{
    public Guid OwnerId { get; set; }
    public User Owner { get; set; }
    public DateTimeOffset? StartDate { get; set; }
    public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
    public DateTimeOffset? EndDate { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
}

Dodałem właściwość OwnerId. Dzięki temu będziemy mogli przeszukiwać tabelę ToDoItems pod kątem konkretnych użytkowników z użyciem Linq. Jednocześnie nie będziemy musieli robić joina z tabelą Users. Skonfigurujmy to teraz:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    var todoItemBuilder = modelBuilder.Entity<ToDoItem>();

    todoItemBuilder.Property(x => x.Title)
        .HasMaxLength(30);

    todoItemBuilder.Property(x => x.Status)
        .HasConversion(new EnumToStringConverter<ToDoItemStatus>());

    todoItemBuilder.Property(x => x.OwnerId)
        .IsRequired();

    todoItemBuilder.HasOne(x => x.Owner)
        .WithMany()
        .HasForeignKey(x => x.OwnerId);
}

Ta konfiguracja fluentowa również sama się świetnie opisuje 🙂 Założę się, że bez tłumaczenia od razu widzisz jakie masz możliwości i co robi powyższy kod.

Generalnie, posługując się metodą Property, możemy ustawiać ograniczenia, konwertery itd. dla konkretnych właściwości.

Zwróć uwagę szczególnie na linijki 10 i 11, gdzie konwertujemy enum do stringa.

Czy warto bawić się w konfigurację, skoro EfCore robi dużo za nas? Moim zdaniem warto. Ja nie za bardzo lubię oddawać kontrolę nad kodem, są rzeczy które wolę jasno określić. Jednak, jeśli Ty jesteś bardziej otwarty lub nie potrzebujesz niektórych ograniczeń, możesz spróbować dać EfCore wolną wolę i zobaczyć co się stanie.

Ignorowanie właściwości

EfCore domyślnie umieszcza w bazie wszystkie właściwości z modelu (w przeciwieństwie do nHibernate). Tutaj też możemy mu powiedzieć jakie właściwości ma ominąć. Np. jeśli bym nie chciał, żeby do bazy trafiał czas zakończenia zadania, mógłbym to skonfigurować tak:

todoItemBuilder.Ignore(x => x.EndDate);

Jak widzisz sam konkretny builder danego modelu ma w sobie też różne ciekawe elementy. Pozwala między innymi na zmianę nazwy tabeli.

Klucze

Możesz ustawić dodatkowy klucz na jednym lub na kilku polach. Przy czym jeśli chodzi o klucz ustawiany na kilku polach, jest to możliwe tylko za pomocą fluent configuration. Nie można tego zrobić adnotacjami:

todoItemBuilder.HasKey(x => x.Status);
todoItemBuilder.HasKey(x => new {x.OwnerId, x.Status});

Tutaj utworzyłem dwa klucze – jeden na pole Status, drugi jest kluczem na dwóch polach – OwnerId i Status. Jak widzisz, jest to banalnie proste.

Relacje w EfCore

Relacje to dość ważny aspekt w bazach relacyjnych 😀 W EfCore konfiguruje się je bardzo prosto, trzeba pamiętać tylko o kilku rzeczach i pojęciach:

  • relację konfigurujemy tylko z jednej strony. Tzn. możemy ustawić, że ToDoItem ma jednego ownera albo User ma wiele ToDoItem'ów. Nie musimy konfigurować dwóch modeli. Tzn. nie musimy w User podawać, że ma wiele ToDoItem'ów i jednocześnie w ToDoItem, że ma jednego usera. Wystarczy, że skonfigiujemy to po jednej stronie
  • encja zależna (dependent entity) – to jest ta encja, która zawiera klucz obcy. Czyli w naszym przypadku encją zależną jest ToDoItem – ponieważ to w tej klasie zdefiniowaliśmy klucz obcy
  • encja główna (principal entity) – to jest ta encja, która jest „rodzicem” encji zależnej. W naszym wypadku będzie to User. No, jeśli usuniemy Usera, jego encje zależne też powinny zostać usunięte (czyli jego wszystkie zadania).
  • właściwość nawigacyjna (navigation property) – to jest właściwość w encji zależnej i/lub głównej, która wskazuje na tę drugą stronę. Bardziej po ludzku, w modelu ToDoItems naszym navigation property jest Owner – to ta właściwość wskazuje na konkretny model encji głównej. Możemy dodać takie navigation property również do modelu User, dodając np. listę todo itemów:
public class User: BaseDbItem
{
    public string Name { get; set; }
    public List<ToDoItem> Items { get; set; }
}

Generalnie navigation property może posiadać kilka typów:

  • kolekcję zależnych encji (jak wyżej właściwość Items)
  • właściwość wskazującą na jedną powiązaną encję (np. w naszym modelu ToDoItems jest to Owner)

Czasem mówimy też o inverse property. To jest zwykłe navigation property tyle, że po drugiej stronie relacji. Jakby to powiedzieć prościej… Jeśli nasza encja User miałaby listę Itemów jak w powyższym przykładzie, wtedy ta lista Itemów byłaby takim „inverse property”. Kiedy to się przydaje i po co?

Generalnie EfCore potrafi sam wykminić wiele rzeczy na podstawie konwencji nazewnictwa, zależności itd. Ale w pewnych sytuacjach nie ogranie. Czasem możesz dostać błąd, że nie może „wydedukować” rodzaju relacji. Wtedy, żeby mu ułatwić możesz zastosować ten inverse property. I konfiguracja takiej relacji wyglądałaby tak:

todoItemBuilder.HasOne(x => x.Owner)
    .WithMany(y => y.Items)
    .HasForeignKey(x => x.OwnerId);

Jeśli nie masz inverse property, możesz po prostu skonfigurować to tak:

todoItemBuilder.HasOne(x => x.Owner)
    .WithMany()
    .HasForeignKey(x => x.OwnerId);

Konfiguracja „czysta”

Mam tu na myśli taką konfigurację, że poszczególne klasy są konfigurowane w osobnych plikach – tak jak ma to miejsce przy nHibernate. Najpierw warto utworzyć sobie konfiguracyjną klasę bazową, np:

public abstract class BaseModelConfig<TModel> : IEntityTypeConfiguration<TModel>
    where TModel : BaseDbItem
{
    public virtual void Configure(EntityTypeBuilder<TModel> builder)
    {
        builder.Property(x => x.Id)
            .IsRequired()
            .ValueGeneratedOnAdd();
    }
}

Zwróć uwagę, że nasza klasa bazowa implementuje interfejs IEntityTypeConfiguration. Tutaj właśnie przyda się też bazowy model, o czym pisałem wcześniej. Naprawdę warto go mieć.

Następnie konfiguracja pozostałych modeli polega na dziedziczeniu po klasie BaseModelConfig:

public class TodoItemConfig : BaseModelConfig<ToDoItem>
{
    public override void Configure(EntityTypeBuilder<ToDoItem> builder)
    {
        base.Configure(builder);

        builder.Property(x => x.Title)
            .HasMaxLength(30);

        builder.Property(x => x.Status)
            .HasConversion(new EnumToStringConverter<ToDoItemStatus>());

        builder.Property(x => x.OwnerId)
            .IsRequired();

        builder.HasOne(x => x.Owner)
            .WithMany()
            .HasForeignKey(x => x.OwnerId);
    }
}

Oczywiście nie musisz tworzyć abstrakcyjnej klasy bazowej. Ważne, żeby konkretne konfiguracje implementowały interfejs IEntityTypeConfiguration. Jednak taka klasa bazowa dużo ułatwia.

Na koniec musisz jeszcze powiedzieć w kontekście, gdzie ma szukać konfiguracji. DbContext będzie szukał implementacji interfejsu IEntityTypeConfiguration w Assembly, które mu podasz. W tym przypadku wykorzystujemy aktualne, główne:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
    modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); 
}

Zapytania

Przede wszystkim pamiętaj, że EfCore pełni rolę repozytorium. Dlatego nie twórz repozytorium (mówię tutaj o wzorcu projektowym), używając EfCore – to jest częsty błąd w tutorialach na YouTubie i zbyt wielu artykułach. Pamiętaj – EfCore to repozytorium.

Jeśli chodzi o programowanie webowe, to najczyściej jest używać EfCore w serwisach. Kontroler odwołuje się do serwisu, serwis ma wstrzyknięty DbContext. A teraz zobaczmy, w jaki sposób można dodać rekord. Spójrz na poniższy serwis:

public class UserService
{
    private readonly ApplicationDbContext _dbContext;

    public UserService(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task AddOrUpdateUser(User data)
    {
        _dbContext.Users.Update(data);
        await _dbContext.SaveChangesAsync();
    }
}

Najpierw wstrzykujemy do konstruktora ApplicationDbContext. Następnie możemy się nim posługiwać tak jakbyśmy używali zwykłego wzorca repozytorium. A nawet lepiej, bo można używać Linq do pobierania danych.

Zwróć tylko uwagę na to, że dodawanie/modyfikacja danych w poszczególnych DbSet'ach jeszcze niczego nie zmienia. Na tym poziomie działa taki sprytny wewnętrzny mechanizm EfCore (change tracker), który oznacza odpowiednie rekordy w odpowiedni sposób (ten rekord będzie zmodyfikowany, ten trzeba dodać, a tu jest jakiś do usunięcia).

Faktyczne operacje do bazy danych pójdą dopiero po wywołaniu SaveChanges. I do tego pójdą w transakcji.

Czas życia DbContext – ważne

DbContext nie jest thread-safe. Nie powinien żyć długo. Domyślnie jest wstrzykiwany jako scope (choć to można zmienić podczas konfiguracji). Zalecam zostawić domyślne zachowanie, chyba że pojawiają się problemy, to wtedy można zmienić na transient:

builder.Services.AddDbContext<ApplicationDbContext>(o =>
{
    o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
}, ServiceLifetime.Transient);

Jeśli używasz zwykłego WebApplication albo WebApi, to takie konstrukcje ze wstrzykiwaniem DbContext są jak najbardziej poprawne. Ale problem pojawia się przy Blazor lub aplikacjach desktopowych.

Inaczej dla Blazor i aplikacji desktopowych

Dlaczego? Jak już mówiłem, DbContext jest standardowo wstrzykiwany jako scoped. Możesz też zmienić na transient. Jednak co to oznacza dla Blazor? Tam, jeśli masz serwis oznaczony jako scope, to właściwie zachowuje się to jak singleton. Dokładnie tak samo jest w aplikacjach desktopowych.

Nie oznacza to teraz, że w Blazor wszystkie serwisy korzystające pośrednio lub bezpośrednio z bazy danych, mają być wstrzykiwane jako transient. O nie. Jest lepszy sposób. Spójrzmy na fabrykę.

Fabryka DbContext

Zamiast rejestrować DbContext, możesz zarejestrować fabrykę:

builder.Services.AddDbContextFactory<ApplicationDbContext>(o =>
{
    o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
});

Przyznasz, że wielkiej różnicy nie ma. Różnica pojawia się w momencie używania tego kontekstu. Teraz wstrzykujemy fabrykę do serwisu:

public class UserService
{
    private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;

    public UserService(IDbContextFactory<ApplicationDbContext> dbContextFactory)
    {
        _dbContextFactory = dbContextFactory;
    }

    public async Task AddOrUpdateUser(User data)
    {
        using var dbContext = _dbContextFactory.CreateDbContext();
        
        dbContext.Users.Update(data);
        await dbContext.SaveChangesAsync();
    }
}

Tutaj do dostępu do bazy danych wykorzystujemy fabrykę. Po prostu ona nam tworzy ten kontekst. Pamiętaj, że musisz tworzyć to przez using. DbContext implementuje interfejs IDisposable i jest cholernie ważne, żeby po sobie posprzątał.

Nie ma nic złego w takim tworzeniu kontekstu. Co więcej – to naturalny sposób w przypadku aplikacji desktopowych no i we wspomnianym Blazor.

Pamiętaj, że jeśli chodzi o WebApplication lub WebApi – gdzie każdy request tworzy i usuwa scope, to możesz posłużyć się tą wersją ze wstrzykiwaniem samego kontekstu.

Zapytania SELECT

Oczywiście jest to tak samo proste jak pobieranie danych ze zwykłej listy. Zwróć tylko uwagę na to, czym jest IQueryable. Spójrz na poniższy kod:

var data = dbContext.Users.Where(x => x.Name.Length > 3);

Tutaj pobieramy użytkowników, których imię ma więcej niż 3 znaki. Czy to zapytanie się wykona?

Nie wykona się. Dlatego, że metoda Where zwraca obiekt IQueryable. A to taki twór, który możesz uznać za zapytanie SQL. Możesz mu zatem dodać inne warunki, np:

var data = dbContext.Users.Where(x => x.Name.Length > 3);
data = data.Where(x => x.Name.Contains("A"));

A kiedy IQueryable zostanie wysłane do bazy danych? We wszystkich tych metodach, które zwracają coś innego niż IQueryable (a konkretnie nasz model lub jego listę), np:

var list = await data.ToListAsync();
var first = await data.FirstAsync();
var fod = await data.FirstOrDefaultAsync();

Itd. Generalnie zawsze spójrz na to, co zwraca konkretna metoda. Tam, gdzie masz IQueryable, to nie ma jeszcze żadnej pracy na bazie danych.

Join

A teraz spójrz na takie zapytanie:

using var ctx = _dbContextFactory.CreateDbContext();

var data = await ctx.ToDoItems.ToListAsync();

Co zostanie pobrane?

Same zadania. Bez ich właścicieli. To super wiadomość. EfCore domyślnie zwróci tylko te dane, o które pytamy. Nie jest to wprawdzie takie „lazy loading” jak w nHibernate (choć to też da się ustawić), jednak do bazy idą tylko najbardziej potrzebne zapytania.

Oczywiście możemy do tego zrobić joina, żeby uzyskać zadania wraz z właścicielami. Ale musimy to jasno powiedzieć. W taki sposób:

using var ctx = _dbContextFactory.CreateDbContext();

var data = await ctx.ToDoItems
    .Include(x => x.Owner)
    .ToListAsync();

Metoda Include powie EfCore’owi, że chcemy pobrać również właściciela zadania. Istnieje również metoda ThenInclude, która pobiera dane z kolejnej encji (w naszym przypadku byłby to User).

Uważaj na podwójny INSERT

Spójrz na tan kod:

public async Task AddOrUpdateTask(ToDoItem item)
{
    User owner;
    using var ctx = _dbContextFactory.CreateDbContext();
    owner = await ctx.Users.FirstAsync();

    item.Owner = owner;
    ctx.ToDoItems.Add(item);
    await ctx.SaveChangesAsync();
}

Tak naprawdę nie jest ważne skąd pochodzi owner, ważne jest że ten owner znajduje się już w bazie danych. Taki kod wybucha błędem o niepoprawnym kluczu. EfCore myśli, że skoro dodajemy nowy rekord (ToDoItem), to dodajemy go z całym inwentarzem, czyli chcemy też dodać jego ownera (technicznie – INSERT owner i INSERT todoItem). Ale User o takim id już istnieje w bazie danych.

Czemu tak się stało?

EfCore ma taki sprytny mechanizm, który nazywa się ChangeTracker. On po prostu sprawdza, co się zmieniło w encji podczas życia kontekstu bazy danych. Akurat ta encja (TodoItem) jest zupełnie nowa. Więc w ChangeTrackerze, który żyje razem z kontekstem bazy danych ma oznaczenie „New”. Wszystko co ma oznaczenie „New” musi zostać dodane do bazy przez INSERT. EfCore uznaje również, że wszystkie encje zależne też są nowe.

Ale to nie koniec niespodzianki. Jeśli stworzymy jeden DbContext i pobierzemy z niego TodoItem, następnie kontekst zostanie ubity, to po utworzeniu drugiego kontekstu ten TodoItem, który został pobrany też będzie widoczny jako nowy – w tym nowym DbContext. To jest pułapka, na którą często sam się łapałem i na którą zwracam Tobie szczególną uwagę.

Więc jak sobie z czymś takim poradzić? Po prostu:

public async Task AddOrUpdateTask(ToDoItem item)
{
    User owner;
    using var ctx = _dbContextFactory.CreateDbContext();
    owner = await ctx.Users.FirstAsync();

    item.OwnerId = owner.Id;
    ctx.ToDoItems.Add(item);
    await ctx.SaveChangesAsync();
}

Czyli zamiast obiektu, przypisuję Id. Czasem nawet będziesz musiał zrobić takie cudo:

item.Owner = null;
ctx.ToDoItems.Update(item);
await ctx.SaveChangesAsync();

Musisz znullować ownera jako obiekt, ale zostawiasz jego Id. To oczywiście dotyczy tej samej sytuacji, czyli jedna encja pochodzi z jednego kontekstu, a druga z drugiego. Może się tak zdarzyć chociażby przy WebApi. Dlatego też pomocne może okazać się przesyłanie między końcówkami modelu DTO, zamiast bazodanowego (i mocno Cię do tego zachęcam, żebyś nie przesyłał modeli bazodanowych).

Różne bazy danych, różne możliwości

Możliwości EfCore zależą trochę od rodzaju bazy danych, na jakiej pracujesz. Niektóre operacje nie będą dozwolone np. na Sqlite. Zazwyczaj pracujemy na jednym typie bazy. Ale nie zawsze. Czasem zdarza się, że potrzebujesz dwóch rodzajów. Dlatego ważne jest, żeby przetestować wszystkie funkcje na wszystkich DBMS, które wykorzystujesz.


Dziękuję Ci za przeczytanie tego artykułu. To oczywiście nie wyczerpuje tematu. To są podstawy podstaw jeśli chodzi o EfCore. Na tym blogu mam jeszcze kilka tekstów na ten temat, jeśli Cię zainteresowało, to polecam:

Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu. No i oczywiście udostępnij artykuł osobom, które powinny wiedzieć jak podejść do EfCore.

Podziel się artykułem na:
Transakcyjność w EfCore

Transakcyjność w EfCore

Wstęp

Transakcje są nieodłącznym elementem baz danych SQL. Z tego artykułu dowiesz się, po co się je stosuje, a także jak je obsłużyć w EfCore.

Ten artykuł ma jedynie w prosty sposób wyjaśnić czym jest transakcja, po co stosować i jak ją ogarniać w EfCore. O transakcyjności jako takiej napisano niejedną książkę, więc ramy prostego artykułu zdecydowanie nie pozwalają na wejście w temat głębiej. Osoby znające zagadnienie nie wyniosą z artykułu raczej niczego nowego (może poza tym, że w EfCore właściwie nie trzeba stosować transakcji – o tym niżej).

Czym jest transakcja w bazie danych?

Przedstawię Ci oklepany przykład przelewu bankowego. Jednak nie myl transakcji bankowej z transakcją bazodanową. To dwa różne pojęcia.

Co system musi zrobić, jeśli przelewam Ci pieniądze? Musi wykonać przynajmniej dwie operacje:

  • odjąć sumę przelewu z mojego konta
  • dodać sumę przelewu do Twojego konta

System przy tym musi cały czas pracować. Co się stanie, jeśli wykona tylko jedną operację, a potem serwer straci zasilanie albo po prostu, po ludzku się spali? Z mojego konta znikną pieniądze, a na Twoim się nie pojawią. Jak się przed czymś takim zabezpieczyć? Transakcją.

Transakcja powoduje to, że wszystkie zmiany (UPDATE, INSERT itd.) w niej zawarte wykonają się w całości albo nie wykonają się w ogóle. Czyli w powyższym przykładzie, jeśli sprzęt straci zasilanie po wykonaniu pierwszej instrukcji, nic złego się nie stanie. Na moim koncie wciąż będą pieniądze – transakcja się nie powiodła, więc została (zostanie) wycofana.

Jak to działa? O tym można napisać książkę, ponadto różne systemy bazodanowe obsługują transakcje na różne sposoby. Najważniejsze jest to, że transakcja zapewnia atomowość operacji – czyli wszystkie operacje wykonują się jako jedna – czyli albo wszystko zostanie zmienione, albo nic.

Rozpoczętą transakcję możesz zakończyć na dwa sposoby. Albo ją zaakceptować (COMMIT) albo wycofać (ROLLBACK).

Transakcja musi być szybka – to NIE jest backup danych.

Transakcje jawne i niejawne

W świecie SQL każde zapytanie INSERT, UPDATE, DELETE itd. wykonuje się w transakcji niejawnej. Jeśli robisz UPDATE, możesz zmienić kilka pól. A co jeśli w pewnym momencie, podczas tej operacji coś się stanie? Zmienisz tylko pół rekordu? No nie. Dlatego, że nad całością czuwa transakcja niejawna.

Gdzieś tam na niskim poziomie, gdy wykonujesz instrukcję SQL, najpierw system uruchamia transakcję i jeśli wszystko przejdzie ok, transakcja jest akceptowana.

Transakcja jawna to taka, którą Ty sam wywołujesz. Jak w powyższym przykładzie z przelewem.

Transakcje przyspieszają

Z powyższego akapitu wyłania się pewien wniosek. Jeśli masz do zrobienia 100 insertów, każdy z nich musi utworzyć nową transakcję, zmienić dane i zakończyć transakcję. Czyli tak naprawdę wykonywane są 3 operacje dla jednego insertu. To znaczy, że przy 100 insertach wykona się na niższym poziomie 300 operacji.

Jeśli jednak te wszystkie 100 insertów wykonasz w transakcji jawnej, wykonają się tylko 102 operacje – rozpoczęcie transakcji jawnej, wykonanie insertów (bez transakcji niejawnych, ponieważ transakcja już istnieje) no i zakończenie Twojej transakcji.

I faktycznie – transakcja tak użyta powoduje, że cała operacja wykona się szybciej.

Kiedy używać transakcji?

W tym momencie artykułu sam powinieneś być w stanie odpowiedzieć na to pytanie. Generalnie, podsumowując, transakcji używamy głównie w dwóch przypadkach:

  1. Gdy wykonujemy operacje w bazie danych, które w jakiś sposób są od siebie zależne. Tzn. jedna operacja bez drugiej nie ma sensu albo spowoduje błędy w danych. Przykład – przelew bankowy. Albo bardziej przyziemny – system fakturowy. Dodajesz rekord faktury do jednej tabeli, a do drugiej jej elementy. Faktura bez zawartości nie ma sensu. Poza tym, gdyby coś poszło nie tak, do bazy trafiło by tylko część zawartości faktury. Widzisz, że te dane muszą być spójne.
  2. Gdy wykonujemy operacje w pętli (nie muszą być ze sobą powiązane), wtedy transakcja może przyspieszyć całość.

Automatyczne transakcje w EfCore – czyli kiedy NIE używać jawnych transakcji

Metoda Save(Async) automatycznie robi wszystkie zmiany w transakcji. Czyli jeśli masz kod w stylu:

foreach(var item in list)
    _dbContext.Items.Add(item);

await _dbContext.SaveAsync();

to wszystkie zapytania wykonają się w transakcji. Metoda Add po prostu dodaje element do listy (i oznacza je jako dodane), ale w żaden sposób nie dotyka bazy danych. To dopiero robi SaveAsync. To tutaj jest tworzona transakcja i są wysyłane zapytania. Czyli nawet w sytuacji, w której dodajesz, usuwasz i modyfikujesz wiele rekordów, ale masz tylko jedno wywołanie SaveAsync – nie potrzebujesz transakcji – tą zapewnia Ci SaveAsync (chyba że masz naprawdę źle zrobioną konfigurację modeli, ale wtedy się raczej wszystko wywali).

No to teraz rodzi się pytanie – po co używać kilku SaveAsync?

Czasem nie ma innej możliwości. Np. masz dwa zależne od siebie rekordy, ale powiązane są jedynie za pomocą Id, np:

public class Parent
{
    public int Id { get; set; }
}

public class Child
{
    public int Id { get; set; }
    public int ParentId { get; set; }
}

W takim wypadku, jeśli chcesz dodać do bazy rekord Parent wraz z kilkoma Childami, musisz najpierw dodać Parenta, zrobić SaveAsync i dopiero w tym momencie dostaniesz jego Id. Po SaveAsync. W drugim zapytaniu możesz dodać Childa z konkretnym Id Parenta.

Tutaj pojawia się mała uwaga dygresyjna – w pewnych przypadkach Id rekordu możesz dostać już podczas dodawania do kolekcji, np: _dbContext.Parents.Add(parent) – jeśli masz własny generator Id lub Id jest tworzone z całkowitym pominięciem bazy danych.

Drugim powodem jest to, że nie zawsze masz wpływ na to, kiedy SaveAsync się wywoła. Spójrz na ten przykład z użyciem Identity:

IdentityUser user = new IdentityUser("test");
user.Email = "test@example.com";
await _userManager.CreateAsync(user);

Invoice inv = new Invoice();
inv.UserId = user.Id;
inv.Items.Add(new InvoiceItem());

_db.Invoices.Add(inv);

await _db.SaveChangesAsync();

Masz tutaj DWA wywołania SaveAsync. Jak to? Pierwsze jest ukryte gdzieś w środku CreateAsync z UserManagera. I pamiętaj, że wszystkie metody z UserManager lub RoleManager, które faktycznie wprowadzają jakąś zmianę, mają w pewnym miejscu wywołanie SaveAsync.

Transakcje w EfCore

Obsługa transakcji w EfCore jest banalnie prosta:

using(var tr = _dbContext.Database.BeginTransaction())
{
    try
    {
        _db.Dummies.Add(new Dummy { Name = "1" });
        await _db.SaveChangesAsync();          

        await tr.CommitAsync();
    }catch (Exception)
    {
        tr.Rollback();
    }
}

Na początku rozpoczynamy nową transakcję (przez właściwość Database z kontekstu EfCore), w której wykonujemy operacje na bazie danych, na koniec ją zatwierdzamy lub wycofujemy.

Musisz zwrócić uwagę na dwie rzeczy:

  • Transakcja implementuje IDisposable, to znaczy, że trzeba ją zniszczyć po użyciu. Najwygodniej używać jej wraz z using – tak jak w przykładzie powyżej. Staraj się tak działać.
  • Jeśli transakcja zostanie zniszczona, a wcześniej nie zostanie zaakceptowana, to automatycznie zostanie wycofana. Tzn., że powyższy kod można by zapisać równie dobrze w taki sposób:
using(var tr = _dbContext.Database.BeginTransaction())
{
    Dummy dummy = new Dummy { Name = "1" };
    _db.Dummies.Add(dummy);
    await _db.SaveChangesAsync();          

    await tr.CommitAsync();
 }

Działanie będzie dokładnie takie samo – transakcja zostanie wycofana, jeśli nie zostanie użyty Commit – czyli jeśli podczas dodawania rekordu do bazy danych wystąpi jakiś błąd.

Punkty kontrolne

EfCore w wersji 5 wprowadziło możliwość utworzenia punktu kontrolnego. Ale zadziała to tylko na połączeniach, które NIE używają Multiple Active Result Sets (popatrz w swoim connection stringu, czy masz ustawioną tą właściwość).

Punkty kontrolne czasem mogą się przydać, jeśli nie chcesz wycofać całej transakcji, np:

using(var tr = _db.Database.BeginTransaction())
{
    try
    {
        IdentityUser user = new IdentityUser("test");
        user.Email = "test@example.com";
        await _userMan.CreateAsync(user);

        tr.CreateSavepoint("BeforeInvoice");

        Invoice inv = new Invoice();
        inv.UserId = user.Id;
        inv.Items.Add(new InvoiceItem());

        _db.Invoices.Add(inv);

        await _db.SaveChangesAsync();

        await tr.CommitAsync();
    }
    catch (Exception)
    {
        tr.RollbackToSavepoint("BeforeInvoice");
    }                
}

Tutaj, jeśli nie powiedzie się dodanie faktury, transakcja zostanie wycofana do zdefiniowanego punktu kontrolnego – BeforeInvoice. Tzn., że użytkownik pozostanie w bazie. W tym momencie możesz spróbować jeszcze raz dodać faktury (lub wycofać całą transakcję). Kiedy to ma sens? Przykładowo, kiedy kilka osób pracuje na jakimś rekordzie i on aktualnie jest zablokowany. Po chwili może zostać odblokowany. To oczywiście może być dużo bardziej skomplikowane. Ale takie użycie save pointów samo się narzuca.

Ograniczenia

Musisz pamiętać o tym, że EfCore to jest ORM na dopalaczach. EfCore jest coraz mniej zależny od bazy danych, a właściwie od źródła danych. Niektóre źródła mogą nie obsługiwać transakcji i wtedy nawet dobry Boże nie pomoże. Wywołanie transakcji w takim przypadku może zakończyć się albo wyjątkiem, albo po prostu nic się nie stanie.

Na szczęście bazy danych SQL (MSSQL, MariaDb, MySQL, Oracle, SQLite itd) wspierają transakcje.


Dobrnęliśmy do końca. Dzięki za przeczytanie tego artykułu, jeśli był dla Ciebie pomocny, podziel się nim w swoich mediach społecznościowych.

Jeśli znalazłeś w artykule błąd lub czegoś nie rozumiesz, koniecznie daj znać 🙂

Obrazek wyróżniający: Biznes zdjęcie utworzone przez rawpixel.com – pl.freepik.com

Podziel się artykułem na:
Soft delete w EfCore

Soft delete w EfCore

Czyli jak oznaczyć rekord jako usunięty?

Wstęp

W prawdziwym świecie raczej nie usuwa się rekordów z bazy na stałe. Często są one tylko oznaczane jako „rekordy usunięte”. Później te rekordy można archiwizować lub faktycznie usunąć po jakimś czasie. Dlaczego tak się robi?

Czasem są to powody biznesowe, a czasem nawet prawne. Wyobraź sobie chociażby sytuację, w której tworzysz forum internetowe. Ktoś napisał bardzo obraźliwy komentarz lub nawet komuś groził, po czym komentarz został przez autora usunięty. Jednak sprawa została zgłoszona na policję. W tym momencie masz możliwość sprawdzenia czy i jaki komentarz został utworzony, a nawet kiedy został „oznaczony do usunięcia”.

To oczywiście prosty przykład, ale w świecie bardzo ważnych danych, które można usunąć jednym kliknięciem, takie usuwanie na stałe nie jest raczej pożądane. W końcu to użytkownik jest najgorszą częścią systemu, a my niejako musimy chronić ten system przed użytkownikami.

Mechanizm oznaczania rekordów jako usuniętych nazywa się Soft Delete (miękkie usuwanie).

Implementacja

Flaga w modelu

Implementacja soft delete w EfCore jest raczej prosta. Przede wszystkim musisz posiadać w swoim modelu bazodanowym jakieś pole, które będzie trzymało informację o tym, czy rekord jest usunięty, czy nie. Ja używam zawsze bazowej klasy do wszystkich modeli bazodanowych, która wygląda mniej więcej tak:

public abstract class DbItem
{
    public Guid Id { get; set; }
    public bool IsDeleted { get; set; }
}

Tutaj rolę tej flagi pełni oczywiście właściwość IsDeleted.

Pobieranie danych nieusuniętych

Następny krok to konfiguracja EfCore w taki sposób, żeby pobierać dane tylko rekordów nieusuniętych. Tak jakbyś do każdego zapytania dodał warunek WHERE isDeleted = 0.

Oczywiście można to zrobić automatycznie. Podczas konfiguracji modelu w metodzie OnModelCreating dodaj filtr, używając HasQueryFilter, np:

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);

    builder.Entity<MyDbModel>()
        .HasQueryFilter(x => x.IsDeleted == false);
}

Ten QueryFilter musisz dodać do każdego modelu.

Ja do konfiguracji modeli używam interfejsu IEntityTypeConfiguration i bazowej klasy, więc jest to łatwiejsze. Opiszę to w innym artykule.

W każdym razie HasQueryFilter na modelu robi tyle, że do każdego zapytania dla tego modelu dodaje właśnie taki warunek WHERE.

Jeśli jednak będziesz chciał w jakimś konkretnym przypadku pobrać dane również oznaczone do usunięcia, możesz zignorować QueryFilters w zapytaniu Linq:

var data = dbContext.MyModels
    .Where(x => x.Name == "blabla")
    .IgnoreQueryFilters(); //to zignoruje wszystkie QueryFilters skonfigurowane na modelu

Modyfikacja danych zamiast usuwania

W ostatnim kroku trzeba troszkę zmienić działanie EfCore. Jeśli ktoś chce usunąć rekord, napisze:

dbContext.MyModels.Remove(data);
dbContext.SaveChanges();

Domyślnie EfCore utworzy zapytanie „DELETE”. Trzeba to zmienić w taki sposób, żeby usuwanie rekordu tak naprawdę modyfikowało flagę IsDeleted. Dlatego dodajemy zdarzenia do DbContext. Kod się sam opisuje:

public AppDbContext(DbContextOptions options)
    :base(options)
{
    SavingChanges += AppDbContext_SavingChanges; //dodajemy handlery do zdarzeń
    SavedChanges += AppDbContext_SavedChanges;
    SaveChangesFailed += AppDbContext_SaveChangesFailed;
}

private void AppDbContext_SaveChangesFailed(object sender, SaveChangesFailedEventArgs e)
{
    ChangeTracker.AutoDetectChangesEnabled = true;
}

private void AppDbContext_SavedChanges(object sender, SavedChangesEventArgs e)
{
    ChangeTracker.AutoDetectChangesEnabled = true;
}

//to się dzieje podczas dbContext.SaveChanges()
private void AppDbContext_SavingChanges(object sender, SavingChangesEventArgs e)
{
    ChangeTracker.DetectChanges(); //wykrywamy zmiany w modelu

    foreach(var entry in ChangeTracker.Entries()) //przechodzimy przez wszystkie zmienione modele
    {
        switch(entry.State)
        {
            case EntityState.Deleted: //jeśli model ma zostać usunięty
                entry.CurrentValues[nameof(DbItem.IsDeleted)] = true; //zmień pole IsDeleted na true
                entry.State = EntityState.Modified; //i oznacz go jako zmodyfikowany, a nie usunięty
                break;
        }
    }

    ChangeTracker.AutoDetectChangesEnabled = false;
}

Dzięki takiej operacji zmodyfikujesz rekord zamiast go usuwać.

Być może w pewnych sytuacjach będziesz chciał usunąć rekord za pomocą zapytania SQL. Tutaj już musisz sam pamiętać o tym, żeby zamiast DELETE zrobić UPDATE odpowiedniego pola.


To tyle. Jeśli czegoś nie zrozumiałeś, potrzebujesz dodatkowych wyjaśnień albo znalazłeś błąd w artykule, koniecznie podziel się w komentarzu 🙂

Obrazek dla artykułu dzięki rawpixel.com – pl.freepik.com

Podziel się artykułem na: