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: