Lekcja 17. Relacje między tabelami w EF Core — klucze obce i Include
Trudny / egzaminacyjnyPo 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 klasieProdukt— odpowiednik kolumnykategoria_idw tabeli SQL; - Właściwość nawigacyjna:
public Kategoria Kategoria { get; set; }w klasieProdukt— 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 klasieKategoria— pozwala pobrać WSZYSTKIE produkty danej kategorii przezkategoria.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
Dodaj do formularza dodawania/edycji produktu rozwijaną listę (
<select asp-for="Produkt.KategoriaId" asp-items="...">) z dostępnymi kategoriami pobranymi z bazy, zamiast wpisywaniaKategoriaIdręcznie jako liczby.Dodaj stronę "Szczegóły kategorii" (
/Kategorie/Szczegoly/{id}) wyświetlającą nazwę kategorii i PEŁNĄ LISTĘ przypisanych do niej produktów — wykorzystaj alboIncludeod stronyKategorie(context.Kategorie.Include(k => k.Produkty)), albo osobne zapytaniecontext.Produkty.Where(p => p.KategoriaId == id).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).