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).

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):
Projekt Domain znajduje się w samym centrum architektury i nie posiada referencji do żadnych innych projektów ani dostawców baz danych (takich jak Microsoft.EntityFrameworkCore.SqlServer). Cała konfiguracja specyficzna dla relacyjnej bazy danych (DbContext, OnModelCreating, Migrations) trafia do projektu Infrastructure.


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):

  1. Microsoft.EntityFrameworkCore – rdzeń ORM (śledzenie zmian, budowanie modelu, LINQ translator).
  2. 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).
  3. 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:


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

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:

  1. Niemodyfikowalność (init): Kompilator generuje właściwości z akcesorem init, 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;
        }
    }
  2. Porównywanie przez wartość (Equals oraz GetHashCode): Dwie osobne referencje Money są równe, jeśli ich pola Amount i Currency przechowują te same wartości. Metoda GetHashCode() 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);
  3. 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);
  4. Czytelna reprezentacja ToString(): Zamiast domyślnej nazwy typu w logach, kompilator generuje metodę PrintMembers oraz ToString() zwracającą sformatowany stan obiektu: "Money { Amount = 100.50, Currency = PLN }".
  5. 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ącego protected 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:

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:

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:

  1. Brak własnej tabeli (Table Splitting domyślnie): Domyślnie właściwości obiektu Adres są 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).
  2. Ukryty klucz (Shadow Primary Key): Encja posiadana nie potrzebuje własnej właściwości Id w kodzie C# – EF Core tworzy dla niej w tle tzw. shadow property powiązane relacją 1:1 z kluczem głównym właściciela.
  3. Automatyczne ładowanie bez .Include(): Podczas pobierania właściciela (context.Students.ToList()), jego obiekty Owned są dołączane automatycznie bez konieczności wywoływania .Include(s => s.AdresZamieszkania).
  4. Zasady użycia: Dla typu Owned nie tworzy się właściwości DbSet<Adres> w DbContext ani nie wywołuje modelBuilder.Entity<Adres>(). Jedna instancja obiektu Owned w 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 do OwnsOne, ComplexProperty nie 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:

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:

  1. Instalacji pakietu Microsoft.EntityFrameworkCore.Proxies,
  2. Wywołania .UseLazyLoadingProxies() przy konfiguracji DbContext,
  3. 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:

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:

⚠️ Krytyczna zasada współbieżności DbContext:
Instancja DbContext NIE 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 instancji DbContext i 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#:

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:

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:

  1. Odczytu i blokady stanu (np. pobrania i inkrementacji aktualnego stanu licznika z tabeli LicznikiIndeksow),
  2. Wykonania zależnych operacji zapisu (lub kilku wywołań SaveChangesAsync() / ExecuteSqlInterpolatedAsync()),
  3. 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: StringComparison oraz własne metody C# wewnątrz .Where()
Użycie wewnątrz zapytania IQueryable przeciążenia s.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 enumeracji StringComparison z platformy .NET! Zawsze używaj podstawowego s.Nazwisko.StartsWith(filtr) lub funkcji EF.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

2. System.InvalidOperationException: The entity type '<NazwaKlasy>' requires a primary key to be defined

3. SqlException: Invalid object name '<NazwaTabeli>' (lub SQLite Error 1: 'no such table')

4. SqlException: Introducing FOREIGN KEY constraint ... may cause cycles or multiple cascade paths

5. DbUpdateException: Cannot insert the value NULL into column 'Id'

6. SqlException: Cannot attach the file '...mdf' as database '...' because this file is already in use