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:
Entity Framework w osobnym projekcie

Entity Framework w osobnym projekcie

Jeśli szukasz szybkiego rozwiązania, kliknij tu. Jeśli chcesz się nieco więcej dowiedzieć, przeczytaj cały post.

Wstęp

Gdy tworzymy nową aplikację z identyfikacją użytkowników (Identity) w VisualStudio, domyślny kreator tworzy jeden projekt, do którego pcha wszystkie klasy. Do malutkich rzeczy, czy nauki to w zupełności wystarczy. Jednak w świecie rzeczywistym chcielibyśmy mieć osobny projekt do modeli i osobny projekt dla warstwy danych (Data Access Layer).

Niby nie jest to trudne, wystarczy przenieść nasz DbContext do innego projektu i już. A co z migracjami? Migracje nadal będą się tworzyć w projekcie głównym. Nie o to chodzi. Chcemy migracje też w projekcie z danymi.

Dlaczego to nie jest oczywiste?

Musisz zdać sobie sprawę z tego, jak działają migracje w Entity Framework (czy też EfCore), a także jak działa aktualizacja bazy danych.

Gdy uruchamiasz polecenie Add-Migration lub dotnet ef migrations add, narzędzie uruchamia Twoją główną aplikację. Uruchomienie aplikacji następuje w sposób normalny. Czyli przy aplikacji konsolowej, odpalona zostanie metoda Main. Przy aplikacji webowej, pójdzie cała konfiguracja.

Jednym z kroków jest inicjalizacja Entity Framework, np:

services.AddDbContext<ApplicationDbContext>(options =>
	options.UseSqlServer(
		Configuration.GetConnectionString("DefaultConnection")));

W tym momencie tworzymy połączenie z bazą danych i migracje mogą zostać utworzone. Pamiętaj, że do utworzenia migracji konieczne jest połączenie z bazą danych. Narzędzie musi sprawdzić, jak wygląda baza i jak wygląda model – musi mieć możliwość porównania tego.

Teraz jeśli uruchomisz migrację z parametrem -p, wskazując na konkretny projekt, np:

Add-Migration InitialDbCreate -p DataAccessLayer

Entity Framework będzie próbowało uruchomić projekt DataAccessLayer. Jeśli jest to zwykła biblioteka klas (class library), no to co się uruchomi? Nic. Dlatego też migracja nie będzie mogła się odbyć.

Ale można to nieco obejść. Narzędzie poszuka jeszcze klasy, która implementuje pewien interfejs. Jeśli znajdzie taką, utworzy jej obiekt i za jej pomocą skonfiguruje połączenie z bazą danych.

Rozwiązanie

  1. W swoim projekcie z danymi (tam, gdzie masz DbContext i chcesz mieć migracje) musisz utworzyć klasę implementującą specjalny interfejs IDesignTimeDbContextFactory. Ef właśnie tego poszuka (jeśli używasz Sql Servera, dodaj pakiet nuget: Microsoft.EntityFrameworkCore.SqlServer):
public class DbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
	public XMoneyDbContext CreateDbContext(string[] args)
	{
		DbContextOptionsBuilder<ApplicationDbContext> optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();

        optionsBuilder.UseSqlServer("tutaj Twój connection string")

        return new ApplicationDbContext(optionsBuilder.Options);
	}
}

Przeanalizujmy go:

  • deklarujesz fabrykę kontekstu bazy danych (Ef poszuka właśnie klasy implementującej ten interfejs), parametrem generycznym jest oczywiście Twój kontekst bazy danych.
  • najpierw tworzysz buildera do opcji kontekstu
  • ustawiasz opcje (np. UseSqlServer) i connection string
  • tworzysz swój kontekst i zwracasz go

I to właściwie tyle. Możesz już teraz uruchomić migrację z przełącznikiem -p:

Add-Migration NazwaMigracji -p NazwaTwojegoProjektu

lub

dotnet ef migrations add NazwaMigracji -p NazwaTwojegoProjektu

Podziel się artykułem na: