Wprowadzenie

Zapytania w EF Core zostały nieznacznie zaktualizowane w porównaniu z poprzednimi wersjami. W przypadku LINQ-to-Entities pojawiła się możliwość dołączenia metod do wykonywanych zapytań. Dodatkowo możemy liczyć na lepszą optymalizację tworzonych zapytań SQL.

Sprawdźmy jak w praktyce wygląda dołączanie metod do tworzonych zapytań:

public static string SpecificBrandName()
{
	return "Audi";
}

static void Main(string[] args)
{
	var context = new ApplicationDbContext();
	var carBrand = context.CarCompanies
		.Where(a => a.Brand == SpecificBrandName())
		.ToList();

	Console.ReadLine();
}

Może nie jest to najlepszy przykład (nasze tabele są naprawdę proste) ale możecie zobaczyć, że do zapytania dodaliśmy metodę SpecificBrandName(). W realnym scenariuszu moglibyśmy przygotować metodę wyliczającą np. odchylenie standardowe miesięcznej sprzedaży danych samochodów i zwrócić posortowaną listę marek, które osiągają najlepszą (zrównoważoną) sprzedaż.

Zanim przejdziemy dalej spóbujemy podejrzeć zapytanie SQL wygenerowane przez powyższy kod Entity Framework Core. W tym celu możemy posłużyć się:

// Wymagana wersja EF Core 5.0
// Wymagana paczka: using Microsoft.EntityFrameworkCore;
var query = context.CarCompanies.Where(w => w.Brand == SpecificBrandName());
var sql = query.ToQueryString();
Rezultat przedstawia się następująco:
SELECT [c].[Id], [c].[Brand], [c].[Model]
FROM [CarCompanies] AS [c]
WHERE [c].[Brand] = N'Audi'

Eager Loading

Ładowanie chciwe (Eager Loading) pobiera wszystkie powiązane dane z konkretnych tabel. Działa podobnie jak w Entity Framework 6 przy wykorzystaniu metody rozszerzającej Include(). W nowej wersji frameworka otrzymujemy również do dyspozycji kolejną metodę rozszerzającą ThenInclude(), która pozwala na pobranie wielu poziomów powiązanych danych.

W przeciwieństwie do EF 6 możemy określić wyrażenie lambda jako parametr w metodzie Include() w celu zdefiniowania referencyjnej właściwości nawigacyjnej. Zanim jednak przejdziemy do przykładu wykorzystamy wiedzę z poprzednich wpisów – przygotujemy kolejną prostą bazę danych w oparciu o poniższy model:

public abstract class Entity
{
	public int Id { get; set; }
}

public class CarBrand : Entity
{
	public string Brand { get; set; }
}

public class FuelType : Entity
{
	public string Type { get; set; }
}

public class Sales : Entity
{
	public int Quantity { get; set; }
}

public class CarDefintion : Entity
{
	public string Model { get; set; }

	// Referencyjna właściwość nawigacyjna
	public CarBrand Brand { get; set; }

	// Referencyjna właściwość nawigacyjna
	public FuelType Fuel { get; set; }

	// Referencyjna właściwość nawigacyjna
	public Sales Sales { get; set; }
}
Dzięki takiej konwencji będziemy mogli wykorzystać Include() oraz chwilę później konstrukcję wielokrotnego dołącznia połączonych danych.

Spójrzmy jak wygląda diagram tak przygotowanej bazy danych: EF Core: schemat bazy danych Na tym etapie nie potrzebujemy danych – model bazy danych przygotowaliśmy w celu przetesowania metody Include():

using(var context = new ApplicationDbContext())
{
	var salesSummary = context.CarDefintion
		.Where(c => c.Model == "RS6")
		.Include(c => c.Sales);
}
W powyższym przykładzie użyliśmy metody Include(c=>c.Sales) przekazując właściwość referencyjną do encji Sales. Efektem wykonania takiego zapytania jest dołączenie do wyników danych z tabeli Sales. Spójrzmy jeszcze jak wygląda wygenerowany SQL:
SELECT [c].[Id], [c].[BrandId], [c].[FuelId], [c].[Model], [c].[SalesId], [s].[Id], [s].[Quantity]
FROM [CarDefintion] AS [c]
LEFT JOIN [Sales] AS [s] ON [c].[SalesId] = [s].[Id]
WHERE [c].[Model] = N'RS6'

Powyższe zapytanie może również przyjać formę zgodną z zapisem dla EF 6:

using(var context = new ApplicationDbContext())
{
	var salesSummary = context.CarDefintion
		.Where(c => c.Model == "RS6")
		.Include("Sales");
}
Podejście to jednak nie jest zalecane ponieważ może dojść do rzucenia wyjątku w trakcie wykonywania programu jeżeli nazwa referencyjnej właściwości nawigacyjnej jest niepoprawna lub nie istnieje. W pierwszym przypadku, użycie wyrażenie lambda, pozwala na wykrycie błędu już w trakcie kompilacji.

Metoda rozszerzająca Include() może być również wykorzystana po metodzie FromSqlRaw(), która pozwala nam na wykonywanie zapytań SQL w postaci nieprzetworzonej:

using(var context = new ApplicationDbContext())
{
	var salesSummary = context.CarDefintion
		.FromSqlRaw("SELECT * FROM CarDefintion WHERE Model = 'RS6'")
		.Include(c => c.Sales);
}

Wykorzystajmy w pełni schemat bazy danych, który przygotowaliśmy i pobierzmy wszystkie powiązane dane. W tym celu wykonamy wielokrotnie Include():

using(var context = new ApplicationDbContext())
{
	var salesSummary = context.CarDefintion
		.Where(c => c.Model == "RS6")
		.Include(c=>c.Fuel)
		.Include(c=>c.Brand)
		.Include(c => c.Sales);

	string queryString = context.CarDefintion
		.Where(c => c.Model == "RS6")
		.Include(c => c.Fuel)
		.Include(c => c.Brand)
		.Include(c => c.Sales).ToQueryString();
}
Zapytanie SQL wygenerowane przez EF Core przyjmuje poniższą postać:
SELECT [c].[Id], [c].[BrandId], [c].[FuelId], [c].[Model], [c].[SalesId], [f].[Id], [f].[StandardId], [f].[Type], [c0].[Id], [c0].[Brand], [s].[Id], [s].[Quantity]
FROM [CarDefintion] AS [c]
LEFT JOIN [FuelType] AS [f] ON [c].[FuelId] = [f].[Id]
LEFT JOIN [CarBrand] AS [c0] ON [c].[BrandId] = [c0].[Id]
LEFT JOIN [Sales] AS [s] ON [c].[SalesId] = [s].[Id]
WHERE [c].[Model] = N'RS6'

ThenInclude()

EF Core wprowadził nową metodę rozszerzającą ThenInclude(), która pozwala na ładowanie wielu poziomów powiązanych ze sobą encji. Żeby lepiej zrozumieć działanie dodamy pewną modyfikację do naszego modelu:

// Encję Fuel rozszerzmy o informację dotyczącą norm spalania
public class FuelType : Entity
{
	public string Type { get; set; }

	public CombustionStandard Standard { get; set; }
}

public class CombustionStandard : Entity
{
	public string Standard { get; set; }
}
Pod spodem dodałem nową migrację oraz dokonałem aktualizacji bazy danych tak, aby zachować pełną synchronizację pomiędzy kodem a schematem bazy danych: EF Core: schemat bazy danych

Na powyższym diagramie możecie doskonale zobaczyć kolejny poziom powiązanych ze sobą encji. W celu pobrania szczegółów dotyczących norm spalania spełnianych przez poszczególny rodzaj paliwa (w realnym świecie posługiwalibyśmy się poszczególnymi konstrukcjami silników) musimy wykonać poniższe zapytanie:

using (var context = new ApplicationDbContext())
{
	var salesSummary = context.CarDefintion
		.Where(c => c.Model == "RS6")
		.Include(c => c.Fuel)
			.ThenInclude(c => c.Standard)
		.Include(c => c.Brand)
		.Include(c => c.Sales);

	string queryString = context.CarDefintion
		.Where(c => c.Model == "RS6")
		.Include(c => c.Fuel)
			.ThenInclude(c => c.Standard)
		.Include(c => c.Brand)
		.Include(c => c.Sales).ToQueryString();
}
Wygenerowane zapytanie SQL przyjmuje poniższą postać:
SELECT [c].[Id], [c].[BrandId], [c].[FuelId], [c].[Model], [c].[SalesId], [f].[Id], [f].[StandardId], [f].[Type], [c0].[Id], [c0].[Standard], [c1].[Id], [c1].[Brand], [s].[Id], [s].[Quantity]
FROM [CarDefintion] AS [c]
LEFT JOIN [FuelType] AS [f] ON [c].[FuelId] = [f].[Id]
LEFT JOIN [CombustionStandard] AS [c0] ON [f].[StandardId] = [c0].[Id]
LEFT JOIN [CarBrand] AS [c1] ON [c].[BrandId] = [c1].[Id]
LEFT JOIN [Sales] AS [s] ON [c].[SalesId] = [s].[Id]
WHERE [c].[Model] = N'RS6'

Zapytania projekcyjne

Include() oraz ThenInclude() to nie jedyny sposób na ładowanie połączonych encji. Możemy również skorzystać z tzw. zapytań projekcyjnych, których konstrukcja pozwala na ładowanie połączonych encji. Spójrzcie na poniższy przykład:

var projectionQuery = context.CarDefintion.Where(a => a.Model == "RS6")
.Select(s => new
{
	CarDefintion = s,
	Fuel = s.Fuel,
	CombustionStandard = s.Fuel.Standard,
	Sales = s.Sales
});
W powyższym przypadku używamy metody rozszerzającej Select(), która pozwala nam na dołączenie powiązanych wyników. Zwrócie uwagę na zapis s.Fuel.Standard, który symuluje zachowanie metody ThenInclude().

Podsumowanie

Początkowe założenia dotyczące krótkiego wpisu legły w gruzach. Uważam jednak, że niezbędne jest dokładne wyjaśnienie omawianych pojęć. W tym wpisie chciałem jeszcze poruszyć zagadnienie Lazy Loading jako przeciwieństwa dla omówionego wcześniej Eager Loading.

Zagadnienie Lazy Loading jest niezwykle ciekawe, może być zrealizowane na dwa sposoby oraz wymaga odpowiedniego zdefiniowania właściwości nawigacyjnych – zdecydowałem się poświęcić osobny wpis (kolejny) na omówienie wszelkich szczegółów dotyczących implementacji.