Dataniedziela, 9 sierpnia 2026 Czas07:45:42
← Aplikacje webowe

Lekcja 17. Relacje między tabelami w EF Core — klucze obce i Include

Trudny / egzaminacyjny

Po co się tego uczymy?

Prawdziwe aplikacje niemal zawsze mają dane POWIĄZANE ze sobą — produkt należy do kategorii, zamówienie należy do klienta i zawiera wiele produktów. Ta lekcja pokazuje, jak Entity Framework Core reprezentuje takie relacje w klasach C# (bez ręcznego pisania SQL JOIN) — dokładnie ten sam problem, który w dziale MAUI rozwiązywałeś ręcznym zapytaniem JOIN w Microsoft.Data.Sqlite, tutaj EF Core robi to automatycznie.

Teoria

Relacja jeden-do-wielu (one-to-many) to najczęstszy typ powiązania: JEDNA kategoria ma WIELE produktów, ale każdy produkt należy do DOKŁADNIE JEDNEJ kategorii. W EF Core reprezentuje się to przez:

  • Właściwość klucza obcego: public int KategoriaId { get; set; } w klasie Produkt — odpowiednik kolumny kategoria_id w tabeli SQL;
  • Właściwość nawigacyjna: public Kategoria Kategoria { get; set; } w klasie Produkt — pozwala w kodzie C# odwołać się bezpośrednio do CAŁEGO obiektu kategorii (produkt.Kategoria.Nazwa), zamiast tylko jej Id;
  • Właściwość nawigacyjna kolekcji (opcjonalnie, po "drugiej stronie"): public List<Produkt> Produkty { get; set; } w klasie Kategoria — pozwala pobrać WSZYSTKIE produkty danej kategorii przez kategoria.Produkty.

Leniwe ładowanie a Include. Domyślnie, gdy pobierasz produkty (context.Produkty.ToListAsync()), EF Core NIE ładuje automatycznie powiązanej kategorii — właściwość produkt.Kategoria pozostałaby null. Żeby EF Core dołączył powiązane dane w JEDNYM zapytaniu (SQL JOIN pod spodem), trzeba jawnie użyć metody Include: context.Produkty.Include(p => p.Kategoria).ToListAsync() — dopiero wtedy produkt.Kategoria jest faktycznie wypełnione.

Dlaczego EF Core nie ładuje wszystkiego automatycznie? Wydajność — gdyby KAŻDE zapytanie automatycznie dociągało WSZYSTKIE powiązane dane (kategorię, a może kategoria ma powiązanego dostawcę, a dostawca adres...), pojedyncze proste zapytanie mogłoby niepotrzebnie pobierać ogromne ilości danych. Include pozwala programiście świadomie zdecydować, JAKIE powiązane dane są faktycznie potrzebne w danym miejscu kodu.

Konfiguracja relacji w DbContext. W najprostszych przypadkach EF Core SAM rozpoznaje relację po nazwach właściwości (konwencja: KategoriaId + właściwość nawigacyjna Kategoria) — nie trzeba nic dodatkowo konfigurować. W bardziej złożonych przypadkach (np. gdy nazwy nie pasują do konwencji) używa się OnModelCreating w DbContext z metodami modelBuilder.Entity<Produkt>().HasOne(p => p.Kategoria).WithMany(k => k.Produkty).HasForeignKey(p => p.KategoriaId).

Relacja wiele-do-wielu (many-to-many) — rzadsza, ale ważna (np. zamówienie może zawierać wiele produktów, a każdy produkt może być w wielu zamówieniach) — wymaga DODATKOWEJ tabeli pośredniczącej (np. PozycjaZamowienia z ZamowienieId, ProduktId i dodatkowo Ilosc) — dokładnie ta sama koncepcja klucza obcego/tabeli łączącej, którą poznałeś w dziale "Bazy danych i SQL" i w projekcie Produkty+Koszyk z działu MAUI.

Schemat

Tabela Kategorie                    Tabela Produkty
┌──────────────┐                    ┌──────────────────┐
│ Id (PK)       │◄───────────────────┤ KategoriaId (FK)  │
│ Nazwa         │    jeden-do-wielu  │ Id (PK)           │
└──────────────┘                    │ Nazwa             │
                                     │ Cena              │
                                     └──────────────────┘

Klasy C# (właściwości nawigacyjne):
class Kategoria { ... List<Produkt> Produkty { get; set; } }
class Produkt   { ... int KategoriaId; Kategoria Kategoria { get; set; } }

context.Produkty.Include(p => p.Kategoria).ToListAsync()
        │
        ▼
SQL (generowany automatycznie przez EF Core):
SELECT p.*, k.* FROM Produkty p
JOIN Kategorie k ON p.KategoriaId = k.Id
        │
        ▼
produkt.Kategoria.Nazwa  ← teraz dostępne w C#, bez dodatkowego zapytania

Przykład z życia

Sklep internetowy z kategoriami "Elektronika", "Akcesoria", "Odzież" — każdy produkt należy do JEDNEJ kategorii, a strona kategorii pokazuje WSZYSTKIE należące do niej produkty. To dokładnie ta relacja jeden-do-wielu, którą poznajesz w tej lekcji — identyczna koncepcyjnie do relacji Produkty-Koszyk z projektu w dziale MAUI, tylko realizowana przez EF Core zamiast ręcznego SQL JOIN.

Modele/Kategoria.cs + Produkt.cs + Data/MojaBazaContext.cs

// Modele/Kategoria.cs
namespace MojaStronaApp.Modele;

public class Kategoria
{
    public int Id { get; set; }
    public string Nazwa { get; set; } = "";

    // Właściwość nawigacyjna - wszystkie produkty tej kategorii
    public List<Produkt> Produkty { get; set; } = new();
}

// ---------------------------------------------------------------------

// Modele/Produkt.cs (rozszerzone o relację)
namespace MojaStronaApp.Modele;

public class Produkt
{
    public int Id { get; set; }
    public string Nazwa { get; set; } = "";
    public double Cena { get; set; }

    // Klucz obcy
    public int KategoriaId { get; set; }

    // Właściwość nawigacyjna - kategoria TEGO produktu
    public Kategoria? Kategoria { get; set; }
}

// ---------------------------------------------------------------------

// Data/MojaBazaContext.cs
using Microsoft.EntityFrameworkCore;
using MojaStronaApp.Modele;

namespace MojaStronaApp.Data;

public class MojaBazaContext : DbContext
{
    public MojaBazaContext(DbContextOptions<MojaBazaContext> options) : base(options) { }

    public DbSet<Produkt> Produkty { get; set; }
    public DbSet<Kategoria> Kategorie { get; set; }
}

Pages/Produkty/Lista.cshtml.cs (Include) + fragment dodawania z KategoriaId

// Pages/Produkty/Lista.cshtml.cs - odczyt z Include
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MojaStronaApp.Data;
using MojaStronaApp.Modele;

namespace MojaStronaApp.Pages.Produkty;

public class ListaModel : PageModel
{
    private readonly MojaBazaContext _context;
    public ListaModel(MojaBazaContext context) => _context = context;

    public List<Produkt> Produkty { get; set; } = new();

    public async Task OnGetAsync()
    {
        // Include dociąga powiązaną Kategorię w JEDNYM zapytaniu SQL
        Produkty = await _context.Produkty
            .Include(p => p.Kategoria)
            .ToListAsync();
    }
}

// W widoku Lista.cshtml można teraz bezpiecznie napisać:
// @foreach (var p in Model.Produkty)
// {
//     <tr>
//         <td>@p.Nazwa</td>
//         <td>@p.Kategoria?.Nazwa</td>   <-- działa dzięki Include!
//     </tr>
// }

// ---------------------------------------------------------------------

// Dodawanie produktu z przypisaniem kategorii (fragment OnPostAsync)
// var produkt = new Produkt
// {
//     Nazwa = Produkt.Nazwa,
//     Cena = Produkt.Cena,
//     KategoriaId = Produkt.KategoriaId   // przypisujemy tylko Id, nie cały obiekt
// };
// _context.Produkty.Add(produkt);
// await _context.SaveChangesAsync();

Komentarz i wyjaśnienie kodu

Kategoria? Kategoria w klasie Produkt jest oznaczona jako NULLABLE (?) — bo dopóki nie użyjesz Include, ta właściwość faktycznie BĘDZIE null, nawet jeśli w bazie danych relacja istnieje; to świadome przypomnienie w typie, że trzeba pamiętać o Include, gdy dane kategorii są potrzebne.

Przy DODAWANIU nowego produktu ustawiasz TYLKO KategoriaId (liczbę — Id istniejącej kategorii, np. wybranej z rozwijanej listy <select asp-for="Produkt.KategoriaId" asp-items="...">) — NIE trzeba (i nie powinno się) tworzyć całego nowego obiektu Kategoria przy każdym produkcie; EF Core samo powiąże rekordy przez klucz obcy.

.Include(p => p.Kategoria) używa WYRAŻENIA LAMBDA (p => p.Kategoria) zamiast stringa z nazwą właściwości — ta forma jest sprawdzana przez kompilator, więc literówka w nazwie właściwości zostanie wykryta OD RAZU przy kompilacji, a nie dopiero w czasie działania aplikacji.

Ćwiczenie samodzielne

Rozbuduj projekt z poprzednich lekcji o model Kategoria i relację z Produkt jak w przykładzie, wygeneruj i zastosuj migrację, dodaj kilka kategorii i produktów przypisanych do nich, a następnie zmień stronę listy produktów tak, żeby pokazywała też nazwę kategorii każdego produktu (pamiętając o Include).

Zadania do pracy własnej

  1. Dodaj do formularza dodawania/edycji produktu rozwijaną listę (<select asp-for="Produkt.KategoriaId" asp-items="...">) z dostępnymi kategoriami pobranymi z bazy, zamiast wpisywania KategoriaId ręcznie jako liczby.

  2. Dodaj stronę "Szczegóły kategorii" (/Kategorie/Szczegoly/{id}) wyświetlającą nazwę kategorii i PEŁNĄ LISTĘ przypisanych do niej produktów — wykorzystaj albo Include od strony Kategorie (context.Kategorie.Include(k => k.Produkty)), albo osobne zapytanie context.Produkty.Where(p => p.KategoriaId == id).

  3. Zbuduj relację wiele-do-wielu: model Zamowienie (z datą złożenia) i tabelę pośredniczącą PozycjaZamowienia (ZamowienieId, ProduktId, Ilosc) — zaimplementuj stronę pokazującą szczegóły zamówienia z pełną listą zamówionych produktów i ich ilości, wraz z sumą całego zamówienia (analogicznie do projektu Produkty+Koszyk z działu MAUI, tylko teraz jako aplikacja webowa z EF Core zamiast ręcznego SQL).

Typowe błędy

Zapomnienie o Include i próba odczytania produkt.Kategoria.Nazwa — bez Include, produkt.Kategoria jest null, więc próba odczytania .Nazwa rzuci NullReferenceException.

Tworzenie NOWEGO obiektu Kategoria przy każdym dodawanym produkcie, zamiast przypisania istniejącego KategoriaId — prowadzi do duplikowania kategorii w bazie zamiast poprawnego powiązania z już istniejącym rekordem.

Nadużywanie Include "na wszelki wypadek" wszędzie, nawet gdy powiązane dane nie są w ogóle potrzebne w danym widoku — niepotrzebnie obciąża zapytanie i spowalnia aplikację; Include powinien być używany świadomie, tylko tam, gdzie dane rzeczywiście są wyświetlane/wykorzystywane.

Nawiązanie do egzaminu zawodowego

To bezpośrednia realizacja INF.03.4.2 (związki encji) i INF.03.4.4 (SQL, w tym złączenia) w praktycznym kontekście aplikacji webowej z EF Core — mechanizm relacji jest fundamentem projektu "system rezerwacji" z lekcji 27 i projektu końcowego, gdzie dane niemal na pewno będą ze sobą powiązane (użytkownik-rezerwacja, kategoria-produkt).