Programowanie Aplikacji Lokalnych
Rok akademicki: 2026/27
Instrukcja laboratoryjna: Mapowanie Obiektowo-Relacyjne (ORM) Entity Framework Core (.NET 8/9/10), DDD, Relacje, Migracje i Optymalizacja Zapytań
Prowadzący: Tomasz Goluch
Wersja: 1.0
1. Wprowadzenie: ORM, Domain-Driven Design (DDD), Code-First i Czysta Architektura
Cel: Zapoznanie z mapowaniem obiektowo-relacyjnym (ORM), jego rolą we wspieraniu podejścia Domain-Driven Design (DDD) poprzez realizację podejścia Code-First oraz separacją warstw w Czystej Architekturze (Clean Architecture).
Zgodnie z zasadami Domain-Driven Design (DDD), projektując aplikację o złożonej logice biznesowej, w pierwszej kolejności skupiamy się na modelu domeny – klasach, encjach i obiektach wartości realizujących reguły biznesowe. Dopiero w kolejnym kroku, za pomocą mappera obiektowo-relacyjnego (ORM – Object-Relational Mapper), rozwiązujemy problem trwałego zapisu (persystencji) stanu tych obiektów.
ORM niweluje tzw. niezgodność impedancji obiektowo-relacyjnej (Object-Relational Impedance Mismatch) – fundamentalne różnice między paradygmatem obiektowym (grafy obiektów, dziedziczenie, hermetyzacja, polimorfizm) a modelem relacyjnym (tabele, krotki, klucze obce, brak dziedziczenia). Pozwala to modelowi domeny zachować właściwość Persistence Ignorance (klasy domenowe są zwykłymi klasami C# – POCO, które nie wiedzą, w jaki sposób i w jakiej bazie danych są utrwalane).
- Podejście Code-First: Naturalna konsekwencja DDD – schemat relacyjnej bazy danych (tabele, kolumny, indeksy, więzy integralności) jest generowany i aktualizowany automatycznie na podstawie kodu klas domenowych C# oraz konfiguracji mapowania za pomocą mechanizmu Migracji.
- Podejście Database-First (Reverse Engineering):
Odwrotny kierunek pracy, w którym narzędzia ORM generują klasy C# oraz
kontekst danych na podstawie istniejącej już bazy danych (komenda
dotnet ef dbcontext scaffold).
Podział odpowiedzialności w Czystej Architekturze (Clean Architecture)
W nowoczesnych systemach .NET opartych o EF Core separujemy model domenowy od szczegółów infrastruktury bazodanowej poprzez podział solucji na cztery współpracujące warstwy (projekty):
┌─────────────────────────────────────────────────────────────────────────────┐
│ WARSTWA PREZENTACJI (UI / Web / Console) │
│ Widoki (WPF / Terminal.Gui / ASP.NET Core), Punkt startowy (Program.cs), │
│ Konfiguracja kontenera Dependency Injection (AddDbContext) │
└──────────────────────┬──────────────────────────────────┬───────────────────┘
│ wywołuje │ rejestruje DI
▼ ▼
┌────────────────────────────────────────┐ ┌─────────────────────────────────┐
│ WARSTWA APLIKACJI (Application) │ │ WARSTWA INFRASTRUKTURY │
│ Przypadki użycia (Use Cases), Serwisy │ │ (Infrastructure) │
│ biznesowe, DTO, Generatory danych │◄─┤ Implementacja DbContext, │
│ (Bogus), Interfejsy repozytoriów / │ │ Konfiguracje Fluent API, │
│ kontekstu (np. IUniversityDbContext) │ │ Migracje, Provider SQL │
└──────────────────────┬─────────────────┘ └─────────────┬───────────────────┘
│ zależy od │ zależy od
▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ WARSTWA DOMENY (Domain / Core) │
│ Czyste encje biznesowe (Student, Kurs, Profesor), Obiekty Wartości │
│ (record Adres, Money), Wyjątki domenowe (Brak zależności od bazy SQL!) │
└─────────────────────────────────────────────────────────────────────────────┘
🧠 Złota zasada zależności (Dependency Rule):
ProjektDomainznajduje się w samym centrum architektury i nie posiada referencji do żadnych innych projektów ani dostawców baz danych (takich jakMicrosoft.EntityFrameworkCore.SqlServer). Cała konfiguracja specyficzna dla relacyjnej bazy danych (DbContext,OnModelCreating,Migrations) trafia do projektuInfrastructure.
2. Instalacja biblioteki, dostawców i narzędzi CLI / PMC
Cel: Instalacja pakietów NuGet dla Entity Framework Core, wybór odpowiedniego dostawcy bazy danych (Database Provider) oraz opanowanie narzędzi wiersza poleceń (.NET CLI oraz Package Manager Console).
1. Wersje EF Core i kompatybilność z platformą .NET
Wersja pakietów Microsoft.EntityFrameworkCore.* powinna
odpowiadać docelowej wersji SDK .NET używanej w projekcie.
Aktualne zestawienie wydań publikowane jest w oficjalnej dokumentacji:
What's
New in EF Core.
| Wersja EF Core | Wymagany Target Framework | Typ wydania | Wsparcie techniczne do |
|---|---|---|---|
| EF Core 10.0 | .NET 10.0 | LTS (Long Term Support) | Listopad 2028 |
| EF Core 9.0 | .NET 8.0 / .NET 9.0 | STS (Standard Term Support) | Listopad 2026 |
| EF Core 8.0 | .NET 8.0 | LTS (Long Term Support) | Listopad 2026 |
(Na zajęciach laboratoryjnych wspierane są projekty celujące w .NET 8.0 LTS, .NET 9.0 oraz .NET 10.0 LTS).
2. Wymagane pakiety NuGet
Decydując się na wykorzystanie Entity Framework Core w projekcie, instalujemy co najmniej dwa podstawowe pakiety oraz pakiety narzędziowe czasu projektowania (design-time):
Microsoft.EntityFrameworkCore– rdzeń ORM (śledzenie zmian, budowanie modelu, LINQ translator).- Pakiet dostawcy bazy danych (Provider) – tłumaczy
abstrakcyjne drzewa wyrażeń EF Core na dialekt SQL konkretnego silnika
bazodanowego:
Microsoft.EntityFrameworkCore.SqlServer(Microsoft SQL Server / LocalDB),Microsoft.EntityFrameworkCore.Sqlite(lekka baza plikowa SQLite),Npgsql.EntityFrameworkCore.PostgreSQL(PostgreSQL),Pomelo.EntityFrameworkCore.MySql(MySQL / MariaDB).
- Narzędzia generowania migracji:
Microsoft.EntityFrameworkCore.Tools(dla konsoli Menedżera Pakietów w Visual Studio),Microsoft.EntityFrameworkCore.Design(wymagany w projekcie startowym podczas korzystania z narzędzia.NET CLI–dotnet ef).
3. Instalacja pakietów i narzędzi (Wieloplatformowe .NET CLI vs Visual Studio PMC)
Wariant A: Wieloplatformowy wiersz poleceń (.NET CLI – VS Code, Rider, Terminal)
# Instalacja pakietów w projekcie Infrastructure
dotnet add MyProject.Infrastructure package Microsoft.EntityFrameworkCore
dotnet add MyProject.Infrastructure package Microsoft.EntityFrameworkCore.SqlServer
# Instalacja pakietu Design w projekcie startowym (UI)
dotnet add MyProject.UI package Microsoft.EntityFrameworkCore.Design
# Jednorazowa globalna instalacja narzędzia dotnet-ef (lub jego aktualizacja)
dotnet tool install --global dotnet-ef
# Aktualizacja do najnowszej wersji:
dotnet tool update --global dotnet-ef
Wariant B: Konsola Menedżera Pakietów w Visual Studio (Package Manager Console – PMC)
PM> Install-Package Microsoft.EntityFrameworkCore -Project MyProject.Infrastructure
PM> Install-Package Microsoft.EntityFrameworkCore.SqlServer -Project MyProject.Infrastructure
PM> Install-Package Microsoft.EntityFrameworkCore.Tools -Project MyProject.UI
Wariant C: Bezpośredni wpis w pliku projektu
(.csproj) Zamiast uruchamiać komendy instalacyjne,
odpowiednie referencje można zadeklarować bezpośrednio w sekcji
<ItemGroup> pliku .csproj (zastępując
8.0.* lub 9.0.* / 10.0.* wybraną
wersją SDK):
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
4. Zestawienie komend zarządzania schematem i migracjami
| Operacja | Komenda .NET CLI (Terminal / Bash / PS) | Komenda PMC (Visual Studio) | Opis działania |
|---|---|---|---|
| Dodanie migracji | dotnet ef migrations add <Nazwa> |
Add-Migration <Nazwa> |
Analizuje zmiany w modelu względem
ostatniej migawki i generuje pliki migracji w katalogu
Migrations/. |
| Usunięcie migracji | dotnet ef migrations remove |
Remove-Migration |
Usuwa ostatnią (jeszcze niezaaplikowaną w
bazie) migrację i przywraca stan migawki
ModelSnapshot. |
| Aktualizacja bazy | dotnet ef database update |
Update-Database |
Tworzy bazę (jeśli nie istnieje) i
wykonuje wszystkie oczekujące migracje. Podanie 0 cofa
wszystkie migracje. |
| Usunięcie bazy | dotnet ef database drop |
Drop-Database |
Całkowicie usuwa docelową bazę danych. |
| Podgląd skryptu SQL | dotnet ef migrations script |
Script-Migration |
Generuje czysty skrypt DDL SQL odpowiadający migracjom (przydatne do audytu i wdrożeń produkcyjnych). |
💡 Praca w solucji wieloprojektowej (Clean Architecture): Flagi --project oraz --startup-project (Kliknij, aby rozwinąć/zwinąć)
Gdy klasa DbContext znajduje się w bibliotece
Infrastructure, a plik
Program.cs wraz z appsettings.json i
rejestracją AddDbContext w projekcie
UI, narzędzia EF Core muszą wiedzieć,
gdzie zapisać pliki migracji, a skąd pobrać konfigurację połączenia:
- W terminalu (.NET CLI – z poziomu głównego folderu
solucji):
dotnet ef migrations add InitialCreate --project University.Infrastructure --startup-project University.UI dotnet ef database update --project University.Infrastructure --startup-project University.UI - W konsoli Package Manager Console (Visual Studio):
- Na górnym pasku okna Package Manager Console ustaw listę
rozwijaną Default project na
University.Infrastructure. - W oknie Solution Explorer kliknij prawym przyciskiem myszy
projekt
University.UIi wybierz Set as Startup Project. - Wykonaj polecenie
Add-Migration InitialCreateorazUpdate-Database.
- Na górnym pasku okna Package Manager Console ustaw listę
rozwijaną Default project na
3. Encje (Entities) oraz Obiekty Wartości (Value Objects)
Cel: Zrozumienie fundamentalnej różnicy między
encjami a obiektami wartości w podejściu DDD oraz wykorzystanie rekordów
(record) języka C# do implementacji niemodyfikowalnych
obiektów wartości.
1. Encje vs Obiekty Wartości
- Encje (Entities): Najważniejsze byty z punktu
widzenia modelowanego biznesu (np.
Student,Profesor,Kurs,Zamówienie).- Posiadają ciągłą tożsamość (Identity): Dwie encje o
identycznych wartościach wszystkich właściwości (np. dwóch studentów o
imieniu i nazwisku „Jan Kowalski”) nie są tym samym
obiektem, jeśli posiadają różny identyfikator (
Id/IndeksUczelniany). - Są mutowalne w czasie: Ich stan ewoluuje w cyklu
życia systemu i jest śledzony przez
ChangeTrackerw EF Core.
- Posiadają ciągłą tożsamość (Identity): Dwie encje o
identycznych wartościach wszystkich właściwości (np. dwóch studentów o
imieniu i nazwisku „Jan Kowalski”) nie są tym samym
obiektem, jeśli posiadają różny identyfikator (
- Obiekty Wartości (Value Objects): Obiekty opisujące
cechy, miary, kwoty lub adresy (np.
Money,Adres,ZakresDat,Email).- Brak tożsamości (Równość strukturalna): Nie
posiadają własnego klucza głównego
Id. Dwa obiekty wartości o identycznym stanie (np. dwie instancjeAdres("Gdańsk", "Narutowicza 11/12", "80-233")) są sobie zawsze równe. - Wzorzec Whole Value Pattern (Ward Cunningham):
Zamiast rozpraszać prymitywne typy proste (
string,decimal) po dziesiątkach klas encji (antywzorzec Primitive Obsession) i duplikować ich walidację (co łamie zasadę Single Responsibility Principle – pierwszą regułę SOLID), zamykamy wartość wraz z jej regułami integralności w dedykowanym typie. - Niemodyfikowalność (Immutability): Obiekt wartości
podlega walidacji w konstruktorze w momencie tworzenia i po utworzeniu
nie może zmienić swojego stanu (brak publicznych setterów
set;). Aby zmienić adres studenta, przypisujemy do właściwości zupełnie nową, zwalidowaną instancję klasyAdres.
- Brak tożsamości (Równość strukturalna): Nie
posiadają własnego klucza głównego
2.
Implementacja Obiektów Wartości za pomocą record w C#
Od wersji C# 9 dedykowanym i preferowanym sposobem implementacji
obiektów wartości są rekordy (record /
record class). Deklaracja podstawowego rekordu
może zostać zapisana w jednej linii:
public record Money(decimal Amount, string Currency);
Słowo kluczowe record stanowi instrukcję dla kompilatora
C#, aby automatycznie wygenerował kod pośredni (MSIL /
CIL) realizujący pełną semantykę obiektu wartości (tzw.
boilerplate code), który dawniej należało pisać ręcznie w
klasie bazowej ValueObject:
- Niemodyfikowalność (
init): Kompilator generuje właściwości z akcesoreminit, co gwarantuje, że wartości można nadać wyłącznie podczas konstrukcji obiektu:public class Money : IEquatable<Money> { public decimal Amount { get; init; } public string Currency { get; init; } public Money(decimal Amount, string Currency) { this.Amount = Amount; this.Currency = Currency; } } - Porównywanie przez wartość (
EqualsorazGetHashCode): Dwie osobne referencjeMoneysą równe, jeśli ich polaAmountiCurrencyprzechowują te same wartości. MetodaGetHashCode()bezpiecznie łączy skróty wszystkich składowych:public override bool Equals(object? obj) => this.Equals(obj as Money); public virtual bool Equals(Money? other) => other is not null && EqualityComparer<decimal>.Default.Equals(this.Amount, other.Amount) && EqualityComparer<string>.Default.Equals(this.Currency, other.Currency); public override int GetHashCode() => System.HashCode.Combine(this.Amount, this.Currency); - Przeciążone operatory równości (
==oraz!=): Działają w oparciu o równość wartościową, a nie referencyjną:public static bool operator ==(Money? left, Money? right) => left is null ? right is null : left.Equals(right); public static bool operator !=(Money? left, Money? right) => !(left == right); - Czytelna reprezentacja
ToString(): Zamiast domyślnej nazwy typu w logach, kompilator generuje metodęPrintMembersorazToString()zwracającą sformatowany stan obiektu:"Money { Amount = 100.50, Currency = PLN }". - Niedestrukcyjna mutacja (wyrażenie
with) oraz Dekonstruktor: Umożliwia utworzenie zmodyfikowanej kopii (var m2 = m1 with { Amount = 200 };) przy użyciu wygenerowanego chronionego konstruktora kopiującegoprotected Money(Money original)oraz rozpakowanie krotki (var (amount, currency) = myMoney;).
3. Pełny Obiekt Wartości z walidacją i logiką domenową
Aby rekord nie był jedynie pasywnym kontenerem na dane (DTO), rozbudowujemy go o walidację w konstruktorze, normalizację oraz operacje biznesowe zwracające nowe instancje:
namespace MyProject.Domain.ValueObjects;
public record Money
{
public decimal Amount { get; init; }
public string Currency { get; init; }
private static readonly HashSet<string> ValidCurrencies =
new() { "PLN", "USD", "EUR", "GBP", "CHF" };
// Prywatny konstruktor bezparametrowy dla bezpiecznej materializacji przez ORM (EF Core)
private Money()
{
Currency = "PLN";
}
public Money(decimal amount, string currency)
{
// === 1. WALIDACJA WEJŚCIOWA ===
if (amount < 0)
{
throw new ArgumentOutOfRangeException(nameof(amount),
"Kwota pieniężna nie może być ujemna.");
}
if (string.IsNullOrWhiteSpace(currency))
{
throw new ArgumentException("Kod waluty nie może być pusty.", nameof(currency));
}
// === 2. NORMALIZACJA ===
var normalizedCurrency = currency.Trim().ToUpperInvariant();
// === 3. WALIDACJA REGUŁ DOMENOWYCH ===
if (normalizedCurrency.Length != 3)
{
throw new ArgumentException(
"Kod waluty musi być 3-znakowym kodem ISO 4217.", nameof(currency));
}
if (!ValidCurrencies.Contains(normalizedCurrency))
{
throw new ArgumentException(
$"Waluta '{normalizedCurrency}' nie jest wspierana.", nameof(currency));
}
Amount = amount;
Currency = normalizedCurrency;
}
// === 4. LOGIKA BIZNESOWA (Zwraca NOWĄ instancję - Immutability) ===
public Money Add(Money other)
{
if (this.Currency != other.Currency)
{
throw new InvalidOperationException("Nie można dodawać kwot w różnych walutach.");
}
return new Money(this.Amount + other.Amount, this.Currency);
}
public Money MultiplyBy(decimal factor)
{
if (factor < 0)
{
throw new ArgumentOutOfRangeException(nameof(factor), "Współczynnik nie może być ujemny.");
}
return new Money(Math.Round(this.Amount * factor, 2), this.Currency);
}
// Metody wytwórcze (Factory Methods)
public static Money Zero(string currency) => new(0, currency);
public static Money FromPln(decimal amount) => new(amount, "PLN");
public override string ToString() => $"{Amount:F2} {Currency}";
}
⚠️ Konstruktory w rekordach i klasach a EF Core (Constructor Binding):
EF Core potrafi tworzyć instancje encji i obiektów wartości wywołując konstruktor z parametrami, pod warunkiem, że nazwy parametrów konstruktora dokładnie odpowiadają nazwom właściwości (z dokładnością do wielkości pierwszej litery, np.ulica→Ulica). Dobrą praktyką w złożonych klasach i rekordach domenowych jest dodanie prywatnego lub chronionego konstruktora bezparametrowego (private Adres() { }), z którego EF Core skorzysta podczas materializacji danych z bazy SQL.
4. Konfiguracja Modelu Danych: Adnotacje, Fluent API, Indeksy i Encje Owned
Cel: Opanowanie technik mapowania klas C# na schemat
relacyjnej bazy danych za pomocą atrybutów DataAnnotations
oraz płynnego interfejsu Fluent API.
1. Adnotacje
danych (DataAnnotations Attributes)
Podstawowe reguły mapowania można zadeklarować bezpośrednio nad
właściwościami klas za pomocą atrybutów z przestrzeni
System.ComponentModel.DataAnnotations oraz
System.ComponentModel.DataAnnotations.Schema:
[Key]– jawnie wskazuje klucz główny encji (Primary Key). Domyślnie EF Core stosuje konwencję nazewniczą i automatycznie traktuje jako klucz główny właściwość o nazwieIdlub<NazwaKlasy>Id(np.StudentId).[Required]– wymusza ograniczenieNOT NULLna odpowiadającej kolumnie w bazie danych (przy włączonych Nullable Reference Types w C# właściwości typustringbez znaku?są domyślnie traktowane jakoNOT NULL).[MaxLength(100)]/[StringLength(100, MinimumLength = 3)]– określa maksymalny rozmiar kolumny tekstowej w bazie danych (np.nvarchar(100)zamiast domyślnegonvarchar(max)). Atrybut[StringLength]jest atrybutem hybrydowym, wykorzystywanym również przez warstwę UI (np. ASP.NET Core / WPF) do walidacji długości minimalnej i maksymalnej.[NotMapped]– wyklucza właściwość lub klasę z mapowania na kolumnę/tabelę w bazie danych (przydatne dla właściwości wyliczanych w locie).
2.
Konfiguracja przez Fluent API w
OnModelCreating
Aby nie zanieczyszczać czystego modelu domenowego
(Domain) atrybutami bazodanowymi, w architekturze
warstwowej preferujemy konfigurację poprzez Fluent API
wewnątrz metody OnModelCreating(ModelBuilder modelBuilder)
klasy DbContext (w warstwie Infrastructure).
Fluent API ma zawsze wyższy priorytet niż
adnotacje danych i konwencje domyślne oraz oferuje znacznie bogatsze
możliwości:
- Klucze główne (pojedyncze i złożone – Composite
Keys):
protected override void OnModelCreating(ModelBuilder modelBuilder) { // Klucz główny na niestandardowej właściwości (np. Prefix w LicznikIndeksow) modelBuilder.Entity<LicznikIndeksow>() .HasKey(l => l.Prefix); // Klucz złożony z dwóch kolumn (wymagany wyłącznie przez Fluent API w starszych wersjach) modelBuilder.Entity<Enrollment>() .HasKey(e => new { e.StudentId, e.CourseId }); } - Wymagalność i długość kolumn (
Property,IsRequired,HasMaxLength):modelBuilder.Entity<Student>(builder => { builder.Property(s => s.IndeksUczelniany) .IsRequired() .HasMaxLength(20); }); - Unikalne indeksy bazodanowe (
HasIndex+IsUnique): Indeksy drastycznie przyspieszają wyszukiwanie (operacjeWHEREpo kolumnie) oraz gwarantują na poziomie silnika SQL unikalność wartości biznesowych (np. numeru indeksu uczelnianego):modelBuilder.Entity<Student>() .HasIndex(s => s.IndeksUczelniany) .IsUnique(); - Konwertery wartości (
HasConversion/ValueConverter): Pozwalają na niestandardową transformację typu w locie podczas zapisu i odczytu z bazy danych (np. zapisenumjako czytelnego tekstustringzamiastint, lub serializacja obiektu do JSON):modelBuilder.Entity<Course>() .Property(c => c.Category) .HasConversion<string>(); // Zapisuje nazwę etykiety enum zamiast liczby - Inicjalizacja danych startowych (
HasData– Data Seeding): Pozwala wypełnić tabele danymi słownikowymi lub początkowymi bezpośrednio podczas wykonywania migracji:modelBuilder.Entity<LicznikIndeksow>().HasData( new LicznikIndeksow { Prefix = "S", AktualnaWartosc = 1000 }, new LicznikIndeksow { Prefix = "P", AktualnaWartosc = 100 } );
3.
Separacja konfiguracji:
IEntityTypeConfiguration<TEntity>
W dużych projektach umieszczanie konfiguracji kilkudziesięciu encji w
jednej metodzie OnModelCreating czyni klasę
DbContext nieczytelną. Dobrą praktyką jest wydzielenie
konfiguracji każdej encji do osobnej klasy implementującej interfejs
IEntityTypeConfiguration<TEntity>:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using University.Domain.Entities;
namespace University.Infrastructure.Configurations;
public class StudentConfiguration : IEntityTypeConfiguration<Student>
{
public void Configure(EntityTypeBuilder<Student> builder)
{
builder.HasKey(s => s.Id);
builder.Property(s => s.IndeksUczelniany)
.IsRequired()
.HasMaxLength(15);
builder.HasIndex(s => s.IndeksUczelniany)
.IsUnique();
// Konfiguracja obiektu wartości (Owned Entity)
builder.OwnsOne(s => s.AdresZamieszkania, adresBuilder =>
{
adresBuilder.Property(a => a.Ulica).HasMaxLength(100).HasColumnName("Ulica");
adresBuilder.Property(a => a.Miasto).HasMaxLength(60).HasColumnName("Miasto");
adresBuilder.Property(a => a.KodPocztowy).HasMaxLength(10).HasColumnName("KodPocztowy");
});
}
}
Następnie w DbContext ładujemy wszystkie klasy
konfiguracyjne z bieżącego zestawu (assembly) jedną
linijką:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(UniversityDbContext).Assembly);
}
4.
Mapowanie Obiektów Wartości: Encje Posiadane
(Owned Entity Types)
Najpopularniejszym mechanizmem utrwalania Obiektów Wartości (np.
klasy lub rekordu Adres) w EF Core są Encje
Posiadane (Owned Entities), konfigurowane metodą
.OwnsOne() (lub .OwnsMany() dla kolekcji
wartości):
// W warstwie Domain:
public record Adres(string Ulica, string Miasto, string KodPocztowy);
public class Student
{
public int Id { get; set; }
public string Imie { get; set; } = string.Empty;
public string Nazwisko { get; set; } = string.Empty;
public Adres AdresZamieszkania { get; set; } = null!;
}
Kluczowe właściwości i ograniczenia
Owned Entities:
- Brak własnej tabeli (Table Splitting domyślnie):
Domyślnie właściwości obiektu
Adressą spłaszczane i zapisywane jako dodatkowe kolumny bezpośrednio w tabeli właściciela (Students), o nazwach zgodnych z konwencjąAdresZamieszkania_Ulica,AdresZamieszkania_Miasto,AdresZamieszkania_KodPocztowy(nazwy te można dostosować za pomocą.HasColumnName(...), a za pomocą.ToTable("AdresyStudentow")przenieść je do osobnej tabeli). - Ukryty klucz (Shadow Primary Key): Encja posiadana
nie potrzebuje własnej właściwości
Idw kodzie C# – EF Core tworzy dla niej w tle tzw. shadow property powiązane relacją 1:1 z kluczem głównym właściciela. - Automatyczne ładowanie bez
.Include(): Podczas pobierania właściciela (context.Students.ToList()), jego obiektyOwnedsą dołączane automatycznie bez konieczności wywoływania.Include(s => s.AdresZamieszkania). - Zasady użycia: Dla typu
Ownednie tworzy się właściwościDbSet<Adres>wDbContextani nie wywołujemodelBuilder.Entity<Adres>(). Jedna instancja obiektuOwnedw przypadku klas mutowalnych nie powinna być współdzielona referencyjnie między dwoma śledzonymi encjami właścicieli.
💡 Nowość w EF Core 8/9/10: Typy Złożone (
ComplexProperty)
Począwszy od EF Core 8 wprowadzono alternatywną metodębuilder.ComplexProperty(s => s.AdresZamieszkania), zaprojektowaną specjalnie dla niemodyfikowalnych rekordów (record) i struktur (readonly record struct). W przeciwieństwie doOwnsOne,ComplexPropertynie tworzy ukrytego klucza obcego w modelu EF i pozwala bez przeszkód przypisywać ten sam niemodyfikowalny obiekt wartości do wielu encji. W zadaniach wymagających opcjonalnych obiektów wartości (null) lub mapowania do osobnej tabeli nadal stosujemy.OwnsOne().
5.
Relacje, Nawigacje, Zachowania przy Usuwaniu
(DeleteBehavior) i Ładowanie Danych
Cel: Opanowanie modelowania relacji 1:1, 1:N, N:M (w tym z tabelą pośrednią przechowującą dane oraz relacji samoodwołujących się), konfiguracji kaskadowego usuwania oraz unikania problemu wydajnościowego N+1 zapytań.
1. Właściwości klucza obcego i nawigacje
Relacje w bazie danych definiowane są za pomocą kluczy obcych (Foreign Keys – FK), natomiast w modelu obiektowym reprezentowane są przez właściwości nawigacyjne:
- Odniesienie nawigacyjne (Reference Navigation):
Pojedyncza referencja do powiązanej encji (reprezentuje stronę „jeden” w
relacjach
1:1oraz1:N).- Dla relacji opcjonalnej (klucz obcy
int?dopuszczaNULL) właściwość nawigacyjna również jest nullowalna (np.public Profesor? Promotor { get; set; }). - Dla relacji wymaganej (klucz obcy
intjestNOT NULL) stosujemy zapispublic Wydzial Wydzial { get; set; } = null!;(operatornull!wycisza ostrzeżenie kompilatora NRT przed załadowaniem nawigacji przez EF Core).
- Dla relacji opcjonalnej (klucz obcy
- Kolekcja nawigacyjna (Collection Navigation):
Kolekcja
ICollection<T>lubList<T>/HashSet<T>reprezentująca stronę „wiele” w relacjach1:NorazN:M. Zawsze inicjalizujemy ją w miejscu deklaracji (= new List<T>();), aby uniknąć wyjątkuNullReferenceExceptionprzy dodawaniu elementów.- Uwaga: Nigdy nie używaj właściwości wyliczanej
public ICollection<Kurs> Kursy => new List<Kurs>();(expression-bodied property), ponieważ każde odwołanie do właściwości zwracałoby nową, pustą listę!
- Uwaga: Nigdy nie używaj właściwości wyliczanej
2. Wzorce relacji i ich konfiguracja we Fluent API
A.
Relacja Jeden-do-Jednego (1:1 – One-to-One, np.
Profesor \(\leftrightarrow\)
Gabinet)
W relacji 1:1 jedna strona jest encją główną (Principal –
rodzicem), a druga encją zależną (Dependent –
dzieckiem). To w encji zależnej musi znajdować się właściwość
klucza obcego (ProfesorId), na której EF Core automatycznie
założy indeks unikalny:
// Encja główna (Principal)
public class Profesor
{
public int Id { get; set; }
public string Imie { get; set; } = string.Empty;
public string Nazwisko { get; set; } = string.Empty;
public Gabinet? Gabinet { get; set; } // Nawigacja do encji zależnej
}
// Encja zależna (Dependent - zawiera klucz obcy!)
public class Gabinet
{
public int Id { get; set; }
public string NumerPokoju { get; set; } = string.Empty;
public string Budynek { get; set; } = string.Empty;
public int ProfesorId { get; set; } // Klucz obcy (FK)
public Profesor Profesor { get; set; } = null!;
}
// Konfiguracja Fluent API (w OnModelCreating):
// Uwaga: W relacji 1:1 w HasForeignKey<TDak> należy jawnie podać typ encji zależnej!
modelBuilder.Entity<Profesor>()
.HasOne(p => p.Gabinet)
.WithOne(g => g.Profesor)
.HasForeignKey<Gabinet>(g => g.ProfesorId)
.OnDelete(DeleteBehavior.Cascade);
B.
Relacja Jeden-do-Wielu (1:N – One-to-Many, np.
Wydział \(\leftrightarrow\) Kurs)
public class Wydzial
{
public int Id { get; set; }
public string Nazwa { get; set; } = string.Empty;
public ICollection<Kurs> Kursy { get; set; } = new List<Kurs>();
}
public class Kurs
{
public int Id { get; set; }
public string Nazwa { get; set; } = string.Empty;
public int ECTS { get; set; }
public int WydzialId { get; set; }
public Wydzial Wydzial { get; set; } = null!;
}
// Konfiguracja Fluent API:
modelBuilder.Entity<Wydzial>()
.HasMany(w => w.Kursy)
.WithOne(k => k.Wydzial)
.HasForeignKey(k => k.WydzialId)
.OnDelete(DeleteBehavior.Restrict);
C.
Relacja Wiele-do-Wielu z dodatkowymi danymi (N:M z encją
pośrednią / Payload, np. Enrollment)
Gdy tabela łącząca oprócz pary kluczy obcych przechowuje dodatkowe
dane biznesowe (np. ocenę końcową oraz semestr zapisu na kurs),
definiujemy jawną klasę encji łączącej (Enrollment) i
konfigurujemy ją jako dwie relacje 1:N lub za pomocą
.UsingEntity<Enrollment>():
public class Enrollment
{
public int StudentId { get; set; }
public Student Student { get; set; } = null!;
public int KursId { get; set; }
public Kurs Kurs { get; set; } = null!;
// Dodatkowe dane biznesowe (Payload) w tabeli łączącej:
public double? Ocena { get; set; }
public string Semestr { get; set; } = string.Empty; // np. "2026Z"
}
// Konfiguracja Fluent API:
modelBuilder.Entity<Enrollment>(builder =>
{
// Klucz złożony (para StudentId + KursId) lub potrójny (+ Semestr)
builder.HasKey(e => new { e.StudentId, e.KursId });
builder.HasOne(e => e.Student)
.WithMany(s => s.Zapisy)
.HasForeignKey(e => e.StudentId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.Kurs)
.WithMany(k => k.Zapisy)
.HasForeignKey(e => e.KursId)
.OnDelete(DeleteBehavior.Cascade);
});
D.
Relacja Wiele-do-Wielu samoodwołująca się (Self-Referencing
Many-to-Many, np. Prerequisites na encji
Kurs)
Kurs może wymagać zaliczenia innych kursów (wymagania wstępne –
WymaganiaWstepne), a jednocześnie sam może być wymaganiem
wstępnym dla kolejnych przedmiotów (KursyZalezne):
public class Kurs
{
public int Id { get; set; }
public string Nazwa { get; set; } = string.Empty;
public string KodKursu { get; set; } = string.Empty;
public int PunktyECTS { get; set; }
// Kursy, które trzeba zaliczyć PRZED tym kursem (Prerequisites)
public ICollection<Kurs> WymaganiaWstepne { get; set; } = new HashSet<Kurs>();
// Kursy, dla których TEN kurs jest wymaganiem wstępnym
public ICollection<Kurs> KursyZalezne { get; set; } = new HashSet<Kurs>();
}
// Konfiguracja Fluent API dla Self-Referencing N:M:
modelBuilder.Entity<Kurs>()
.HasMany(k => k.WymaganiaWstepne)
.WithMany(k => k.KursyZalezne)
.UsingEntity(join => join.ToTable("KursWymaganiaWstepne"));
3.
Zachowania przy usuwaniu encji (OnDelete /
DeleteBehavior)
Podczas usuwania encji głównej (rodzica), relacyjna baza danych oraz kontekst EF Core muszą wiedzieć, co zrobić z przypisanymi do niej encjami zależnymi:
Wartość DeleteBehavior |
Wymagalność FK | Działanie w bazie danych i EF Core |
|---|---|---|
Cascade |
Wymagany (NOT NULL) |
Domyślne dla kluczy
wymaganych. Usunięcie rodzica automatycznie usuwa wszystkie
przypisane rekordy zależne (np. usunięcie Profesora usuwa
jego Gabinet). |
SetNull |
Opcjonalny (NULL) |
Usunięcie rodzica nie
usuwa encji zależnej, lecz ustawia jej kolumnę klucza obcego na
NULL (np. usunięcie Profesora-promotora zeruje
pole PromotorId u magistranta). |
Restrict /
NoAction |
Dowolny | Blokuje usunięcie rodzica (rzuca wyjątek naruszenia klucza obcego), dopóki istnieją przypisane do niego encje zależne. |
ClientSetNull |
Opcjonalny (NULL) |
EF Core zeruje klucz obcy w śledzonych
obiektach w pamięci RAM, ale w bazie SQL więzy mają postać
NO ACTION. |
Przykład konfiguracji DeleteBehavior.SetNull dla relacji
Magistrant \(\rightarrow\)
Promotor:
modelBuilder.Entity<StudentStudiowMagisterskich>()
.HasOne(m => m.Promotor)
.WithMany(p => p.Dyplomanci)
.HasForeignKey(m => m.PromotorId)
.IsRequired(false) // Klucz obcy PromotorId (int?) musi dopuszczać NULL!
.OnDelete(DeleteBehavior.SetNull);
4. Strategie ładowania powiązanych danych i Problem N+1 Zapytań
Pułapka opóźnionego ładowania (Lazy Loading) i Problem N+1
Lazy Loading polega na automatycznym wykonywaniu zapytania SQL w tle w momencie pierwszego odwołania się w kodzie C# do właściwości nawigacyjnej. Wymaga on:
- Instalacji pakietu
Microsoft.EntityFrameworkCore.Proxies, - Wywołania
.UseLazyLoadingProxies()przy konfiguracjiDbContext, - Oznaczenia wszystkich właściwości nawigacyjnych słowem kluczowym
virtual(aby EF Core mógł wygenerować w locie klasę potomną Dynamic Proxy przechwytującą wywołanie getterów).
Choć wygodny, Lazy Loading jest główną przyczyną katastrofalnego spadku wydajności znanego jako Problem N+1 zapytań:
// ANTYWZORZEC (Problem N+1 przy włączonym Lazy Loading):
// 1. Wykonujemy 1 zapytanie SQL pobierające N = 100 kursów:
var kursy = context.Kursy.Take(100).ToList();
foreach (var kurs in kursy)
{
// 2. W każdej iteracji pętli odwołanie do kurs.Zapisy uruchamia OSOBNE zapytanie SQL!
// Łącznie baza danych zostaje uderzona 1 + 100 = 101 zapytaniami!
Console.WriteLine($"Kurs: {kurs.Nazwa}, zapisanych: {kurs.Zapisy.Count}");
}
Rozwiązanie:
Ładowanie chciwe (Eager Loading – Include /
ThenInclude)
W profesjonalnych aplikacjach nie używamy
LazyLoadingProxies, lecz jawnie wskazujemy za pomocą metod
.Include() oraz .ThenInclude(), które tabele
mają zostać złączone (JOIN) w pojedynczym zapytaniu SQL,
lub stosujemy bezpośrednią projekcję .Select():
// POPRAWNE ROZWIĄZANIE (Eager Loading - tylko 1 zapytanie SQL z JOIN):
var kursyZZapisami = context.Kursy
.Include(k => k.Wydzial)
.Include(k => k.Zapisy)
.ThenInclude(z => z.Student)
.Take(100)
.ToList();
foreach (var kurs in kursyZZapisami)
{
// Wszystkie dane są już w pamięci RAM - 0 dodatkowych zapytań do bazy!
Console.WriteLine($"[{kurs.Wydzial.Nazwa}] {kurs.Nazwa}: {kurs.Zapisy.Count} studentów");
}
6. Strategie Mapowania Dziedziczenia w Bazie Danych (TPH, TPT, TPC)
Cel: Zaznajomienie z trzema sposobami odwzorowania
obiektowej hierarchii dziedziczenia (np. Student \(\leftarrow\)
StudentStudiowMagisterskich) na struktury tabel
relacyjnych.
Relacyjne bazy danych nie posiadają natywnego mechanizmu dziedziczenia tabel. EF Core udostępnia trzy strategie odwzorowania hierarchii klas:
Hierarchia w C#: class Student { Id, Imie, Indeks } <── class StudentMagisterski : Student { TematPracy, PromotorId }
1. TPH (Table-Per-Hierarchy) [Domyślna]:
┌────────────────────────────────────────────────────────────────────────────┐
│ Tabela: Students │
│ Id | Discriminator | Imie | Indeks | TematPracy (NULL) | Promotor │
└────────────────────────────────────────────────────────────────────────────┘
2. TPT (Table-Per-Type):
┌─────────────────────────────┐ ┌──────────────────────────────────────┐
│ Tabela: Students │ 1───1 │ Tabela: StudenciMagisterscy │
│ Id (PK) | Imie | Indeks │◄──────┤ Id (PK, FK) | TematPracy | PromotorId│
└─────────────────────────────┘ └──────────────────────────────────────┘
3. TPC (Table-Per-Concrete-Type):
┌─────────────────────────────┐ ┌──────────────────────────────────────┐
│ Tabela: Students │ │ Tabela: StudenciMagisterscy │
│ Id | Imie | Indeks │ │ Id | Imie | Indeks | Temat | Promotor│
└─────────────────────────────┘ └──────────────────────────────────────┘
Zestawienie porównawcze strategii dziedziczenia
| Cecha | 1. TPH (Table-Per-Hierarchy) | 2. TPT (Table-Per-Type) | 3. TPC (Table-Per-Concrete-Type) |
|---|---|---|---|
| Struktura tabel | Jedna wspólna tabela dla
całej hierarchii klas + kolumna Discriminator. |
Osobna tabela dla każdej
klasy (bazowej i potomnych) połączona relacją 1:1 po
Id. |
Osobna tabela dla każdej klasy nieabstrakcyjnej (kolumny bazowe są powielone). |
| Wydajność odczytu | Bardzo wysoka (brak
JOIN i UNION, odczyt z jednej tabeli). |
Niska (wymaga kosztownych
złączeń JOIN przy każdym zapytaniu). |
Wysoka przy odpytywaniu
konkretnego podtypu; zapytania polimorficzne używają
UNION ALL. |
Normalizacja i
NULL |
Kolumny klas potomnych muszą
dopuszczać NULL w bazie danych. |
Pełna normalizacja (3NF) – kolumny klas
potomnych mogą być NOT NULL. |
Kolumny klas potomnych mogą być
NOT NULL (brak pustych komórek). |
| Konfiguracja w EF Core | Domyślna (opcjonalnie
.UseTphMappingStrategy() lub
.HasDiscriminator()). |
Wymaga
.UseTptMappingStrategy() lub wywołania
.ToTable(...) dla każdej klasy. |
Wymaga
.UseTpcMappingStrategy() oraz sekwencji dla generowania
unikalnych kluczy Id. |
Przykłady konfiguracji
w OnModelCreating
// Wariant 1: TPH (Domyślny) z dostosowaniem kolumny dyskryminatora
modelBuilder.Entity<Student>()
.HasDiscriminator<string>("TypStudenta")
.HasValue<Student>("Inzynierski")
.HasValue<StudentStudiowMagisterskich>("Magisterski");
// Wariant 2: TPT (Tabela na każdy typ w hierarchii)
modelBuilder.Entity<Student>().UseTptMappingStrategy();
// (alternatywnie: jawne przypisanie .ToTable("Studenci") oraz .ToTable("StudenciMagisterscy"))
// Wariant 3: TPC (Tabela na każdy konkretny typ - od EF Core 7+)
modelBuilder.Entity<Student>().UseTpcMappingStrategy();
7.
Kontekst Danych (DbContext), Wzorce Repository & Unit
of Work oraz Połączenie z Bazą
Cel: Zrozumienie architektury klasy
DbContext, jej cyklu życia, śledzenia zmian
(ChangeTracker) oraz konfiguracji połączenia z użyciem
kontenera Dependency Injection.
1.
Rola DbContext jako wzorców Repository oraz Unit of
Work
Klasa dziedzicząca po DbContext stanowi centralny punkt
komunikacji z bazą danych i łączy w sobie dwa klasyczne wzorce
projektowe:
- Wzorzec Jednostki Pracy (Unit of Work):
Zamiast wysyłać pojedyncze zapytanie
INSERT/UPDATEprzy każdej zmianie właściwości w kodzie C#,DbContextśledzi wszystkie modyfikacje obiektów w pamięci za pomocą mechanizmuChangeTracker. Dopiero wywołanieSaveChanges()/SaveChangesAsync()grupuje wszystkie wykryte zmiany i wysyła je do bazy w ramach jednej atomowej transakcji. - Wzorzec Repozytorium (Repository):
Właściwości typu
DbSet<TEntity>reprezentują kolekcje encji danego typu, na których można wykonywać zapytania LINQ oraz operacje dodawania (Add) i usuwania (Remove).
using Microsoft.EntityFrameworkCore;
using University.Domain.Entities;
namespace University.Infrastructure.Persistence;
public class UniversityDbContext : DbContext
{
public DbSet<Student> Studenci => Set<Student>();
public DbSet<StudentStudiowMagisterskich> StudenciMagisterscy => Set<StudentStudiowMagisterskich>();
public DbSet<Profesor> Profesorowie => Set<Profesor>();
public DbSet<Kurs> Kursy => Set<Kurs>();
public DbSet<Wydzial> Wydzialy => Set<Wydzial>();
public DbSet<Gabinet> Gabinety => Set<Gabinet>();
public DbSet<Enrollment> Zapisy => Set<Enrollment>();
public DbSet<LicznikIndeksow> LicznikiIndeksow => Set<LicznikIndeksow>();
// Konstruktor przyjmujący opcje konfiguracyjne z kontenera Dependency Injection
public UniversityDbContext(DbContextOptions<UniversityDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(UniversityDbContext).Assembly);
}
}
2.
Śledzenie stanu encji (EntityState) i cykl życia
DbContext
Każda encja powiązana z kontekstem posiada wpis
context.Entry(entity), którego właściwość
State przyjmuje jedną z wartości wyliczenia
EntityState:
Detached(0) – obiekt nie jest śledzony przez kontekst (np. utworzony operatoremnewlub pobrany z.AsNoTracking()).Unchanged(1) – obiekt pobrany z bazy, w którym nie zmieniono jeszcze żadnej właściwości.Deleted(2) – obiekt oznaczony metodą.Remove(entity)do usunięcia przy najbliższymSaveChanges()(wygenerujeDELETE).Modified(3) – co najmniej jedna właściwość obiektu uległa zmianie (wygenerujeUPDATEtylko dla zmienionych kolumn).Added(4) – nowy obiekt dodany metodą.Add(entity)(wygenerujeINSERT).
⚠️ Krytyczna zasada współbieżności
DbContext:
InstancjaDbContextNIE jest bezpieczna wątkowo (not thread-safe) i jest projektowana do krótkiego cyklu życia (jedna jednostka pracy / jedno żądanie). Nigdy nie wykonuj równoległych operacji (Task.WhenAll) na tej samej instancjiDbContexti zawsze oczekuj (await) na zakończenie każdej metody asynchronicznej przed wywołaniem kolejnej!
3.
Konfiguracja Connection Stringa i rejestracja w
Program.cs
Ciąg połączeniowy (Connection String) przechowujemy w pliku
konfiguracyjnym appsettings.json (pamiętając o escapowaniu
znaku ukośnika \\ w formacie JSON):
{
"ConnectionStrings": {
"DefaultSqlServer": "Server=(localdb)\\mssqllocaldb;Database=UniversityCleanDb;Trusted_Connection=True;MultipleActiveResultSets=true",
"SqliteConnection": "Data Source=university.db"
}
}
Rejestracja kontekstu w kontenerze Dependency Injection (w pliku
Program.cs):
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using University.Infrastructure.Persistence;
var builder = Host.CreateApplicationBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("DefaultSqlServer");
builder.Services.AddDbContext<UniversityDbContext>(options =>
{
// Wariant 1: Microsoft SQL Server / LocalDB
options.UseSqlServer(connectionString);
// Wariant 2: Baza plikowa SQLite
// options.UseSqlite(builder.Configuration.GetConnectionString("SqliteConnection"));
// Wariant 3: MySQL / MariaDB (pakiet Pomelo.EntityFrameworkCore.MySql)
// options.UseMySql(connectionString, new MariaDbServerVersion(new Version(10, 11, 0)));
});
using IHost host = builder.Build();
💡 Fabryka czasu projektowania (IDesignTimeDbContextFactory) dla aplikacji konsolowych i WPF (Kliknij, aby rozwinąć/zwinąć)
W prostych aplikacjach konsolowych lub WPF, które nie używają
domyślnego Host.CreateApplicationBuilder(args), narzędzie
Add-Migration / dotnet ef migrations add może
zgłosić błąd braku konfiguracji DbContext. Najczystszym
rozwiązaniem w warstwie Infrastructure jest dodanie klasy
implementującej
IDesignTimeDbContextFactory<TContext>, z której
korzystają wyłącznie narzędzia migracji:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace University.Infrastructure.Persistence;
public class UniversityDbContextFactory : IDesignTimeDbContextFactory<UniversityDbContext>
{
public UniversityDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<UniversityDbContext>();
optionsBuilder.UseSqlServer(
"Server=(localdb)\\mssqllocaldb;Database=UniversityCleanDb;Trusted_Connection=True;");
return new UniversityDbContext(optionsBuilder.Options);
}
}
8.
Migracje Schematu Bazy Danych i Fasada DatabaseFacade
Cel: Opanowanie cyklu życia migracji oraz metod
zarządzania bazą danych udostępnianych przez właściwość
context.Database.
1. Anatomia migracji w EF Core
Wykonanie polecenia
dotnet ef migrations add InitialCreate (lub
Add-Migration InitialCreate) nie modyfikuje jeszcze bazy
danych, lecz generuje w katalogu Migrations pliki C#:
<YYYYMMDDHHMMSS>_InitialCreate.cs– zawiera klasę dziedziczącą poMigrationz dwiema metodami:Up(MigrationBuilder migrationBuilder)– instrukcje tworzące lub modyfikujące tabele, kolumny i indeksy przy aplikowaniu migracji,Down(MigrationBuilder migrationBuilder)– instrukcje cofające zmiany w przypadku wycofywania migracji.
<NazwaKontekstu>ModelSnapshot.cs– pełna migawka bieżącego stanu modelu C#. Stanowi „źródło prawdy”, z którym EF Core porównuje kod klas podczas generowania kolejnej migracji.
2.
Wzorzec Fasada (context.Database) i aplikowanie zmian w
czasie uruchomienia
Właściwość context.Database (typu
DatabaseFacade) realizuje wzorzec projektowy
Fasada, wystawiając uproszczony interfejs do
zarządzania fizyczną bazą danych:
context.Database.Migrate()/MigrateAsync()(Zalecane produkcyjnie i w zadaniach): Tworzy bazę danych (jeśli nie istnieje), sprawdza tabelę historii__EFMigrationsHistoryi wykonuje wszystkie oczekujące migracje.using (var scope = host.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService<UniversityDbContext>(); await dbContext.Database.MigrateAsync(); }context.Database.EnsureCreated(): Tworzy bazę i schemat bezpośrednio z modelu, ale nie tworzy tabeli__EFMigrationsHistoryi nie używa migracji. Baza utworzona przezEnsureCreated()nie może być później aktualizowana migracjami (metodyMigrate()orazEnsureCreated()wykluczają się wzajemnie!).context.Database.EnsureDeleted(): Całkowicie usuwa bazę danych. ParaEnsureDeleted()+EnsureCreated()jest przydatna wyłącznie w szybkich testach integracyjnych lub jednorazowych prototypach.
3. Wizualizacja modelu: EF Core Power Tools
W środowisku Visual Studio niezwykle przydatnym rozszerzeniem jest
EF Core Power Tools (dostępne w Extensions → Manage
Extensions). Po kliknięciu prawym przyciskiem myszy na projekt z
klasą DbContext pozwala ono wygenerować wizualny diagram
encji (Add DbContext Diagram – DGML) lub podejrzeć pełny skrypt
DDL SQL (View DbContext DDL SQL) bez konieczności łączenia się
z serwerem bazy danych.
9.
Transakcje, Sekwencje Biznesowe i Generator Danych
(Bogus)
Cel: Realizacja złożonych, wieloetapowych operacji
biznesowych w ramach jawnych transakcji bazodanowych oraz automatyczne
generowanie realistycznych danych testowych za pomocą biblioteki
Bogus.
1.
Transakcje jawne (BeginTransactionAsync) vs automatyczne
(SaveChanges)
Domyślnie każde pojedyncze wywołanie
context.SaveChanges() / SaveChangesAsync()
jest automatycznie opakowywane w transakcję bazodanową – jeśli zapis
choćby jednej z modyfikowanych encji się nie powiedzie, całość zostaje
wycofana (Rollback).
Jawnej transakcji
(context.Database.BeginTransactionAsync()) używamy wtedy,
gdy jedna atomowa operacja biznesowa wymaga:
- Odczytu i blokady stanu (np. pobrania i inkrementacji aktualnego
stanu licznika z tabeli
LicznikiIndeksow), - Wykonania zależnych operacji zapisu (lub kilku wywołań
SaveChangesAsync()/ExecuteSqlInterpolatedAsync()), - Gwarancji, że w przypadku błędu zarówno dodanie studenta, jak i zmiana licznika zostaną w całości cofnięte.
2. Wzorzec atomowego zarządzania licznikiem indeksów (Tworzenie i Cofanie Licznika)
Poniższy przykład pokazuje kompletną realizację logiki przydzielania
numeru indeksu (np. "S1001") oraz warunkowego cofania
licznika przy usuwaniu ostatniego rekordu w warstwie
Application:
using Microsoft.EntityFrameworkCore;
using University.Domain.Entities;
using University.Infrastructure.Persistence;
namespace University.Application.Services;
public class StudentService
{
private readonly UniversityDbContext _context;
public StudentService(UniversityDbContext context)
{
_context = context;
}
public async Task<Student> CreateStudentAsync(string imie, string nazwisko, int rokStudiow, Adres adres, string prefix = "S")
{
// Rozpoczynamy jawną transakcję bazodanową
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// 1. Pobieramy licznik dla danego prefiksu (np. "S")
var licznik = await _context.LicznikiIndeksow
.FirstOrDefaultAsync(l => l.Prefix == prefix);
if (licznik is null)
{
throw new InvalidOperationException($"Brak zdefiniowanego licznika dla prefiksu '{prefix}'.");
}
// 2. Inkrementujemy licznik i przypisujemy nowy numer (np. z 1000 -> 1001 -> "S1001")
licznik.AktualnaWartosc++;
string nadanyIndeks = $"{licznik.Prefix}{licznik.AktualnaWartosc}";
var student = new Student
{
Imie = imie,
Nazwisko = nazwisko,
RokStudiow = rokStudiow,
IndeksUczelniany = nadanyIndeks,
AdresZamieszkania = adres
};
_context.Studenci.Add(student);
// 3. Zapisujemy zarówno zaktualizowany licznik, jak i nowego studenta
await _context.SaveChangesAsync();
// 4. Zatwierdzamy transakcję
await transaction.CommitAsync();
return student;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task DeleteStudentAsync(int studentId)
{
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var student = await _context.Studenci.FirstOrDefaultAsync(s => s.Id == studentId)
?? throw new KeyNotFoundException($"Nie znaleziono studenta o Id = {studentId}.");
// Wyodrębniamy prefiks (litery na początku) oraz numer z IndeksUczelniany (np. "S1002" -> "S" oraz 1002)
string indeks = student.IndeksUczelniany;
string prefix = new string(indeks.TakeWhile(char.IsLetter).ToArray());
string numerStr = new string(indeks.SkipWhile(char.IsLetter).ToArray());
if (int.TryParse(numerStr, out int numerStudenta))
{
var licznik = await _context.LicznikiIndeksow
.FirstOrDefaultAsync(l => l.Prefix == prefix);
// Jeśli usuwany student posiada dokładnie najwyższy aktualny numer dla swojego prefiksu,
// cofamy licznik o 1, aby uniknąć dziury na końcu sekwencji:
if (licznik is not null && licznik.AktualnaWartosc == numerStudenta)
{
licznik.AktualnaWartosc--;
}
}
_context.Studenci.Remove(student);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
3.
Bezpieczne zapytania surowe SQL (ExecuteSqlInterpolated /
FromSqlInterpolated)
Jeśli zachodzi potrzeba wykonania bezpośredniego polecenia SQL,
nigdy nie łącz ciągów znaków przez konkatenację
(podatność SQL Injection). Zawsze korzystaj z metod
interpolowanych, które automatycznie zamieniają zmienne C# na parametry
SQL (@p0):
int minRating = 5;
await _context.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE Courses SET IsFeatured = 1 WHERE Rating >= {minRating}");
4.
Generowanie realistycznych danych testowych z biblioteką
Bogus
Biblioteka Bogus
(dotnet add package Bogus) pozwala generować realistyczne
polskie dane osobowe, adresowe i akademickie
(new Faker("pl")). W czystej architekturze generator nie
powinien wpisywać losowych numerów indeksów bezpośrednio do tabel, lecz
wywoływać logikę biznesową serwisu (lub pobierać kolejne numery z
LicznikIndeksow):
using Bogus;
using University.Domain.Entities;
using University.Domain.ValueObjects;
// Definicja reguł generowania obiektu wartości Adres w polskiej lokalizacji ("pl")
var adresFaker = new Faker<Adres>("pl")
.CustomInstantiator(f => new Adres(
f.Address.StreetAddress(),
f.Address.City(),
f.Address.ZipCode("##-###")
));
// Wykorzystanie wygenerowanych danych w serwisie aplikacyjnym (z zachowaniem sekwencji LicznikIndeksow):
var personFaker = new Faker("pl");
for (int i = 0; i < 20; i++)
{
await studentService.CreateStudentAsync(
imie: personFaker.Name.FirstName(),
nazwisko: personFaker.Name.LastName(),
rokStudiow: personFaker.Random.Int(1, 5),
adres: adresFaker.Generate(),
prefix: "S"
);
}
10. Wydajne Zapytania LINQ to Entities i Optymalizacja Wydajności
Cel: Opanowanie pisania złożonych zapytań analitycznych w LINQ oraz eliminacja czterech najczęstszych wąskich gardeł wydajnościowych w EF Core.
Właściwości DbSet<TEntity> implementują interfejs
IQueryable<TEntity>. Oznacza to, że
zapytania LINQ nie są wykonywane w pamięci C#, lecz budują drzewo
wyrażeń (Expression Tree), które dostawca EF Core tłumaczy na
zapytanie SQL w momencie materializacji (np. przy wywołaniu
.ToListAsync(), .FirstOrDefaultAsync(),
.CountAsync()).
Cztery filary optymalizacji zapytań w EF Core
1. Zapytania tylko do
odczytu: .AsNoTracking()
Domyślnie EF Core tworzy migawkę każdego pobranego obiektu w
ChangeTrackerze, aby móc wykryć w nim zmiany przy
SaveChanges(). W przypadku zapytań raportowych,
analitycznych i widoków list (gdzie tylko wyświetlamy dane) jest to
zbędny narzut pamięciowy i procesorowy. Dodanie
.AsNoTracking() wyłącza śledzenie i przyspiesza
materializację o 20–40%:
var studenci = await _context.Studenci
.AsNoTracking()
.Where(s => s.RokStudiow >= 2)
.ToListAsync();
2. Projekcja
danych do DTO: .Select(x => new WynikDto { ... })
Zamiast pobierać z bazy wszystkie kolumny całych encji
(SELECT *) wraz z ciężkimi złączeniami
.Include(), używaj operatora .Select(), aby
pobrać wyłącznie potrzebne pola lub wyliczyć agregacje bezpośrednio w
silniku SQL:
// Przykład: Średnia ocen (GPA) i liczba ocenionych studentów dla kursów na wybranym wydziale
var statystykiKursow = await _context.Kursy
.AsNoTracking()
.Where(k => k.WydzialId == wybranyWydzialId)
.Select(k => new KursGpaDto
{
KodKursu = k.KodKursu,
NazwaKursu = k.Nazwa,
LiczbaOcenionychStudentow = k.Zapisy.Count(z => z.Ocena != null),
SredniaOcen = k.Zapisy
.Where(z => z.Ocena != null)
.Average(z => (double?)z.Ocena) ?? 0.0
})
.ToListAsync();
(Uwaga: Gdy stosujesz projekcję .Select(...) do
obiektu DTO lub typu anonimowego, nie musisz nawet wywoływać
.Include() – EF Core sam wygeneruje odpowiednie
podzapytania i złączenia LEFT JOIN na podstawie ścieżek
nawigacji użytych wewnątrz .Select()!).
3. Ewaluacja po stronie serwera (Server-Side) vs w pamięci (Client-Side Evaluation)
Wszystkie operacje filtrowania (Where), sortowania
(OrderBy), grupowania (GroupBy) i agregacji
(Sum, Average, Count)
muszą znajdować się PRZED wywołaniem .ToList() /
.ToListAsync().
// BŁĄD KRYTYCZNY (Client-Side Evaluation):
// Pobiera CAŁĄ tabelę (np. 100 000 rekordów) przez sieć do pamięci RAM i dopiero tam filtruje!
var wynikZly = (await _context.Studenci.ToListAsync())
.Where(s => s.Nazwisko.StartsWith("Kow"));
// POPRAWNIE (Server-Side Evaluation - w bazie wykonywane jest WHERE Nazwisko LIKE 'Kow%'):
var wynikDobry = await _context.Studenci
.AsNoTracking()
.Where(s => s.Nazwisko.StartsWith("Kow"))
.OrderBy(s => s.Nazwisko)
.Take(10)
.ToListAsync();
⚠️ PUŁAPKA TRANSLACJI LINQ:
StringComparisonoraz własne metody C# wewnątrz.Where()
Użycie wewnątrz zapytaniaIQueryableprzeciążenias.Nazwisko.StartsWith(filtr, StringComparison.InvariantCultureIgnoreCase)lub własnej metody pomocniczej C# spowoduje rzucenie wyjątku:
System.InvalidOperationException: The LINQ expression ... could not be translated.
Serwer SQL nie zna enumeracjiStringComparisonz platformy .NET! Zawsze używaj podstawowegos.Nazwisko.StartsWith(filtr)lub funkcjiEF.Functions.Like(s.Nazwisko, $"{filtr}%").
4.
Unikanie duplikatów przy złączeniach wielopoziomowych
(SelectMany + Distinct /
Union)
Gdy obliczamy sumę wartości (np. punktów ECTS) po złączeniu dwóch
różnych kolekcji (np. kursów, na które zapisany jest student, oraz
kursów będących ich wymaganiami wstępnymi), ten sam kurs może pojawić
się wielokrotnie (np. Analiza Matematyczna jest prerekwizytem
zarówno dla Fizyki, jak i Algorytmów). Przed
zsumowaniem punktów należy wyeliminować powtórzenia za pomocą
.Union() lub .Distinct() na identyfikatorach
kursów.
11. Diagnostyka i Rozwiązywanie Najczęstszych Błędów (Troubleshooting)
Cel: Szybka identyfikacja i naprawa najczęstszych wyjątków napotykanych podczas pracy z Entity Framework Core.
1.
System.InvalidOperationException: No database provider has been configured for this DbContext
- Przyczyna: Instancja
DbContextnie wie, z jakiego silnika bazy danych ma korzystać. - Rozwiązanie:
- Sprawdź, czy w
Program.cswywołanobuilder.Services.AddDbContext<MojDbContext>(options => options.UseSqlServer(...)). - Upewnij się, że klasa
MojDbContextposiada konstruktor przyjmującyDbContextOptions<MojDbContext>i przekazuje go do konstruktora bazowego: base(options).
- Sprawdź, czy w
2.
System.InvalidOperationException: The entity type '<NazwaKlasy>' requires a primary key to be defined
- Przyczyna: EF Core nie odnalazł klucza głównego dla
encji, albo klasa będąca Obiektem Wartości (
Adres) została omyłkowo zarejestrowana jako zwykła encja. - Rozwiązanie:
- Jeśli to zwykła encja (np.
LicznikIndeksowz kluczemPrefix), skonfiguruj klucz główny we Fluent API:.HasKey(l => l.Prefix)lub atrybutem[Key]. - Jeśli to Obiekt Wartości (
Adres), skonfiguruj go przez.OwnsOne(s => s.AdresZamieszkania)lub oznacz atrybutem[Owned].
- Jeśli to zwykła encja (np.
3.
SqlException: Invalid object name '<NazwaTabeli>'
(lub SQLite Error 1: 'no such table')
- Przyczyna: Aplikacja próbuje wykonać zapytanie do tabeli, która nie została jeszcze utworzona w bazie danych.
- Rozwiązanie: Wygeneruj migrację
(
dotnet ef migrations add .../Add-Migration ...) i zaktualizuj bazę danych (dotnet ef database update/Update-Database) lub wywołajawait context.Database.MigrateAsync()przy starcie aplikacji.
4.
SqlException: Introducing FOREIGN KEY constraint ... may cause cycles or multiple cascade paths
- Przyczyna: Silnik Microsoft SQL Server blokuje
tworzenie relacji, w których do jednej tabeli prowadzą dwie różne
ścieżki kaskadowego usuwania (
ON DELETE CASCADE), np. w hierarchii dziedziczenia lub relacjach samoodwołujących się. - Rozwiązanie: W metodzie
OnModelCreatingzmień zachowanie przy usuwaniu dla jednej ze ścieżek relacji na.OnDelete(DeleteBehavior.Restrict),.OnDelete(DeleteBehavior.NoAction)lub.OnDelete(DeleteBehavior.ClientSetNull).
5.
DbUpdateException: Cannot insert the value NULL into column 'Id'
- Przyczyna: Pliki migracji zostały wygenerowane dla
innego dostawcy bazy danych niż ten, z którym aktualnie łączy się
aplikacja (np. migracje wygenerowano dla SQLite z adnotacją
.Annotation("Sqlite:Autoincrement", true), a aplikację przełączono na SQL Server wymagający.Annotation("SqlServer:Identity", "1, 1")). - Rozwiązanie: Po zmianie dostawcy bazy danych usuń
stary folder
Migrations, wygeneruj nową migrację dla docelowego dostawcy i wykonajUpdate-Database.
6.
SqlException: Cannot attach the file '...mdf' as database '...' because this file is already in use
- Przyczyna: Zewnętrzny proces (np. otwarte
połączenie w oknie SQL Server Object Explorer / Server
Explorer w Visual Studio lub poprzednia zawieszona instancja
aplikacji) blokuje plik bazy danych
.mdf. - Rozwiązanie: Zamknij aktywne połączenie w oknie
eksploratora baz danych (PPM na bazie → Close Connection /
Detach) lub korzystaj ze standardowego połączenia do serwera
LocalDB
(
Server=(localdb)\\mssqllocaldb;Database=NazwaBazy;Trusted_Connection=True;) bez ręcznego wskazywania ścieżkiAttachDbFilename.