Co nowego w programie EF Core 8

Program EF Core 8.0 (EF8) został wydany w listopadzie 2023 r.

Napiwek

Przykładowe przykłady można uruchamiać i debugować , pobierając przykładowy kod z usługi GitHub. Każda sekcja łączy się z kodem źródłowym specyficznym dla tej sekcji.

Program EF8 wymaga skompilowania zestawu .NET 8 SDK i wymaga uruchomienia środowiska uruchomieniowego platformy .NET 8. Program EF8 nie będzie działać we wcześniejszych wersjach platformy .NET i nie będzie działać w programie .NET Framework.

Obiekty wartości używające typów złożonych

Obiekty zapisane w bazie danych można podzielić na trzy szerokie kategorie:

  • Obiekty, które nie mają struktury i przechowują pojedynczą wartość. Na przykład , int, Guid, string, IPAddress. Są to (nieco luźno) nazywane "typami pierwotnymi".
  • Obiekty, które mają strukturę przechowywania wielu wartości i gdzie tożsamość obiektu jest definiowana przez wartość klucza. Na przykład , Blog, Post, Customer. Są one nazywane "typami jednostek".
  • Obiekty, które mają strukturę przechowywania wielu wartości, ale obiekt nie ma klucza definiującego tożsamość. Na przykład , Address. Coordinate

Przed EF8 nie było dobrego sposobu mapowania trzeciego typu obiektu. Typy własności mogą być używane, ale ponieważ typy należące do nich są rzeczywiście typami jednostek, mają semantyka na podstawie wartości klucza, nawet jeśli ta wartość klucza jest ukryta.

Program EF8 obsługuje teraz "Typy złożone", aby pokryć ten trzeci typ obiektu. Obiekty typu złożonego:

  • Nie są identyfikowane ani śledzone przez wartość klucza.
  • Należy zdefiniować jako część typu jednostki. (Innymi słowy, nie można mieć DbSet typu złożonego).
  • Może to być typy wartości platformy .NET lub typy referencyjne.
  • Wystąpienia mogą być współużytkowane przez wiele właściwości.

Prosty przykład

Rozważmy na przykład Address typ:

public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Address następnie jest używany w trzech miejscach w prostym modelu klientów/zamówień:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Address Address { get; set; }
    public List<Order> Orders { get; } = new();
}

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

Utwórzmy i zapiszmy klienta przy użyciu adresu:

var customer = new Customer
{
    Name = "Willow",
    Address = new() { Line1 = "Barking Gate", City = "Walpole St Peter", Country = "UK", PostCode = "PE14 7AV" }
};

context.Add(customer);
await context.SaveChangesAsync();

Spowoduje to wstawienie następującego wiersza do bazy danych:

INSERT INTO [Customers] ([Name], [Address_City], [Address_Country], [Address_Line1], [Address_Line2], [Address_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);

Zwróć uwagę, że typy złożone nie otrzymują własnych tabel. Zamiast tego są zapisywane w tekście w kolumnach Customers tabeli. Jest to zgodne z zachowaniem udostępniania tabel należących do typów.

Uwaga

Nie planujemy zezwalać na mapowanie typów złożonych na własną tabelę. Jednak w przyszłej wersji planujemy zezwolić na zapisywanie typu złożonego jako dokument JSON w jednej kolumnie. Zagłosuj na problem nr 31252 , jeśli jest to dla Ciebie ważne.

Teraz załóżmy, że chcemy wysłać zamówienie do klienta i użyć adresu klienta jako domyślnego rozliczenia adresu wysyłkowego. Naturalnym sposobem wykonania tej czynności jest skopiowanie Address obiektu z obiektu Customer do obiektu Order. Na przykład:

customer.Orders.Add(
    new Order { Contents = "Tesco Tasty Treats", BillingAddress = customer.Address, ShippingAddress = customer.Address, });

await context.SaveChangesAsync();

W przypadku typów złożonych działa to zgodnie z oczekiwaniami, a adres jest wstawiany do Orders tabeli:

INSERT INTO [Orders] ([Contents], [CustomerId],
    [BillingAddress_City], [BillingAddress_Country], [BillingAddress_Line1], [BillingAddress_Line2], [BillingAddress_PostCode],
    [ShippingAddress_City], [ShippingAddress_Country], [ShippingAddress_Line1], [ShippingAddress_Line2], [ShippingAddress_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11);

Do tej pory można powiedzieć: "ale mógłbym to zrobić z typami własności!" Jednak semantyka "typu jednostki" typów własności szybko się w ten sposób. Na przykład uruchomienie powyższego kodu z typami własności powoduje wyświetlenie wysuwu ostrzeżeń, a następnie wyświetlenie błędu:

warn: 8/20/2023 12:48:01.678 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.BillingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update)
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Order.BillingAddress#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
fail: 8/20/2023 12:48:01.709 CoreEventId.SaveChangesFailed[10000] (Microsoft.EntityFrameworkCore.Update) 
      An exception occurred in the database while saving changes for context type 'NewInEfCore8.ComplexTypesSample+CustomerContext'.
      System.InvalidOperationException: Cannot save instance of 'Order.ShippingAddress#Address' because it is an owned entity without any reference to its owner. Owned entities can only be saved as part of an aggregate also including the owner entity.
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.PrepareToSave()

Jest to spowodowane tym, że pojedyncze wystąpienie typu jednostki (z tą samą wartością klucza ukrytego Address ) jest używane dla trzech różnych wystąpień jednostek. Z drugiej strony współużytkowanie tego samego wystąpienia między złożonymi właściwościami jest dozwolone, a więc kod działa zgodnie z oczekiwaniami podczas korzystania z typów złożonych.

Konfiguracja typów złożonych

Typy złożone muszą być konfigurowane w modelu przy użyciu atrybutów mapowania lub przez wywołanie ComplexProperty interfejsu API w programie OnModelCreating. Typy złożone nie są odnajdywane zgodnie z konwencją.

Na przykład Address typ można skonfigurować przy użyciu polecenia ComplexTypeAttribute:

[ComplexType]
public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Lub w pliku OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .ComplexProperty(e => e.Address);

    modelBuilder.Entity<Order>(b =>
    {
        b.ComplexProperty(e => e.BillingAddress);
        b.ComplexProperty(e => e.ShippingAddress);
    });
}

Możliwość mutowania

W powyższym przykładzie utworzyliśmy to samo Address wystąpienie używane w trzech miejscach. Jest to dozwolone i nie powoduje żadnych problemów z programem EF Core podczas korzystania z typów złożonych. Jednak udostępnianie wystąpień tego samego typu odwołania oznacza, że jeśli wartość właściwości w wystąpieniu zostanie zmodyfikowana, ta zmiana zostanie odzwierciedlona we wszystkich trzech użyciach. Na przykład po wykonaniu powyższych czynności zmieńmy Line1 adres klienta i zapiszemy zmiany:

customer.Address.Line1 = "Peacock Lodge";
await context.SaveChangesAsync();

Spowoduje to następującą aktualizację bazy danych podczas korzystania z programu SQL Server:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Orders] SET [BillingAddress_Line1] = @p2, [ShippingAddress_Line1] = @p3
OUTPUT 1
WHERE [Id] = @p4;

Zwróć uwagę, że wszystkie trzy Line1 kolumny uległy zmianie, ponieważ wszystkie współużytkują to samo wystąpienie. Zwykle nie jest to, czego chcemy.

Napiwek

Jeśli adresy zamówień powinny ulec zmianie automatycznie po zmianie adresu klienta, rozważ mapowanie adresu jako typu jednostki. Order następnie Customer można bezpiecznie odwoływać się do tego samego wystąpienia adresu (które jest teraz identyfikowane przez klucz) za pomocą właściwości nawigacji.

Dobrym sposobem radzenia sobie z takimi problemami jest uczynienie typu niezmiennym. Rzeczywiście, ta niezmienność jest często naturalna, gdy typ jest dobrym kandydatem do bycia typem złożonym. Na przykład zwykle warto dostarczyć złożony nowy Address obiekt, a nie tylko wyciszyć, powiedzmy, kraj, pozostawiając resztę tak samo.

Zarówno typy odwołań, jak i wartości mogą być niezmienne. Przyjrzymy się kilku przykładom w poniższych sekcjach.

Typy referencyjne jako typy złożone

Niezmienna klasa

Użyliśmy prostego, modyfikowalnego class w powyższym przykładzie. Aby zapobiec problemom z przypadkową mutacją opisaną powyżej, możemy uczynić klasę niezmienną. Na przykład:

public class Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; }
    public string? Line2 { get; }
    public string City { get; }
    public string Country { get; }
    public string PostCode { get; }
}

Napiwek

W przypadku języka C# 12 lub nowszego można uprościć tę definicję klasy przy użyciu konstruktora podstawowego:

public class Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

Teraz nie można zmienić Line1 wartości na istniejącym adresie. Zamiast tego musimy utworzyć nowe wystąpienie ze zmienioną wartością. Na przykład:

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

Tym razem wywołanie , aby zaktualizować SaveChangesAsync tylko adres klienta:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;

Należy pamiętać, że mimo że obiekt Address jest niezmienny, a cały obiekt został zmieniony, program EF nadal śledzi zmiany poszczególnych właściwości, więc tylko kolumny ze zmienionymi wartościami są aktualizowane.

Niezmienny rekord

Język C# 9 wprowadził typy rekordów, co ułatwia tworzenie i używanie niezmiennych obiektów. Na przykład Address obiekt może być typu rekordu:

public record Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; init; }
    public string? Line2 { get; init; }
    public string City { get; init; }
    public string Country { get; init; }
    public string PostCode { get; init; }
}

Napiwek

Tę definicję rekordu można uprościć przy użyciu konstruktora podstawowego:

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

Zamiana obiektu modyfikowalnego i wywoływanie metody SaveChanges wymaga teraz mniejszej ilości kodu:

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

Typy wartości jako typy złożone

Modyfikowalna struktura

Prosty typ wartości modyfikowalnej może być używany jako typ złożony. Na przykład Address można zdefiniować jako element struct w języku C#:

public struct Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Przypisanie obiektu klienta Address do właściwości wysyłki i rozliczeń Address powoduje pobranie kopii Addressobiektu , ponieważ jest to sposób działania typów wartości. Oznacza to, że modyfikowanie Address elementu na kliencie nie spowoduje zmiany wystąpień wysyłki ani rozliczeń Address , więc modyfikowalne struktury nie mają tych samych problemów z udostępnianiem wystąpień, które występują z klasami modyfikowalnymi.

Jednak modyfikowalne struktury są zwykle zniechęcane w języku C#, więc należy bardzo ostrożnie myśleć przed ich użyciem.

Niezmienna struktura

Niezmienne struktury działają dobrze jak złożone typy, podobnie jak w przypadku niezmiennych klas. Można na przykład zdefiniować taki sposób, Address że nie można go modyfikować:

public readonly struct Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

Kod zmiany adresu wygląda teraz tak samo jak w przypadku używania niezmiennej klasy:

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

Niezmienny rekord struktury

Wprowadzono struct record typy języka C# 10, co ułatwia tworzenie i pracę z niezmiennymi rekordami struktury, takimi jak w przypadku niezmiennych rekordów klas. Na przykład możemy zdefiniować Address jako niezmienny rekord struktury:

public readonly record struct Address(string Line1, string? Line2, string City, string Country, string PostCode);

Kod zmiany adresu wygląda teraz tak samo jak w przypadku używania niezmiennego rekordu klasy:

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

Zagnieżdżone typy złożone

Typ złożony może zawierać właściwości innych typów złożonych. Na przykład użyjemy typu Address złożonego z powyższego razem z typem złożonym i zagnieżdżmy je zarówno wewnątrz innego typu złożonego PhoneNumber :

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

public record PhoneNumber(int CountryCode, long Number);

public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

W tym miejscu używamy niezmiennych rekordów, ponieważ są one dobrym dopasowaniem do semantyki typów złożonych, ale zagnieżdżanie typów złożonych można wykonać z dowolną odmianą typu .NET.

Uwaga

Nie używamy podstawowego konstruktora dla Contact typu, ponieważ program EF Core nie obsługuje jeszcze wstrzykiwania konstruktora złożonych wartości typów. Zagłosuj na problem nr 31621 , jeśli jest to dla Ciebie ważne.

Contact Dodamy jako właściwość elementu Customer:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Contact Contact { get; set; }
    public List<Order> Orders { get; } = new();
}

I PhoneNumber jako właściwości elementu Order:

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required PhoneNumber ContactPhone { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

Konfigurację zagnieżdżonych typów złożonych można ponownie osiągnąć przy użyciu polecenia ComplexTypeAttribute:

[ComplexType]
public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

[ComplexType]
public record PhoneNumber(int CountryCode, long Number);

[ComplexType]
public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

Lub w pliku OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>(
        b =>
        {
            b.ComplexProperty(
                e => e.Contact,
                b =>
                {
                    b.ComplexProperty(e => e.Address);
                    b.ComplexProperty(e => e.HomePhone);
                    b.ComplexProperty(e => e.WorkPhone);
                    b.ComplexProperty(e => e.MobilePhone);
                });
        });

    modelBuilder.Entity<Order>(
        b =>
        {
            b.ComplexProperty(e => e.ContactPhone);
            b.ComplexProperty(e => e.BillingAddress);
            b.ComplexProperty(e => e.ShippingAddress);
        });
}

Zapytania

Właściwości typów złożonych w typach jednostek są traktowane jak każda inna właściwość nienawigacyjna typu jednostki. Oznacza to, że są one zawsze ładowane po załadowaniu typu jednostki. Dotyczy to również wszelkich zagnieżdżonych właściwości typu złożonego. Na przykład wykonywanie zapytań dotyczących klienta:

var customer = await context.Customers.FirstAsync(e => e.Id == customerId);

Podczas korzystania z programu SQL Server jest tłumaczony na następujący kod SQL:

SELECT TOP(1) [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country],
    [c].[Contact_Address_Line1], [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode],
    [c].[Contact_HomePhone_CountryCode], [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode],
    [c].[Contact_MobilePhone_Number], [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE [c].[Id] = @__customerId_0

Zwróć uwagę na dwie rzeczy z tego kodu SQL:

  • Wszystko jest zwracane, aby wypełnić klienta i wszystkie zagnieżdżone Contacttypy , Addressi PhoneNumber złożone.
  • Wszystkie złożone wartości typu są przechowywane jako kolumny w tabeli dla typu jednostki. Typy złożone nigdy nie są mapowane na oddzielne tabele.

Projekcje

Typy złożone mogą być projektowane na podstawie zapytania. Na przykład wybranie tylko adresu wysyłkowego z zamówienia:

var shippingAddress = await context.Orders
    .Where(e => e.Id == orderId)
    .Select(e => e.ShippingAddress)
    .SingleAsync();

Przekłada się to na następujące kwestie podczas korzystania z programu SQL Server:

SELECT TOP(2) [o].[ShippingAddress_City], [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1],
    [o].[ShippingAddress_Line2], [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[Id] = @__orderId_0

Należy pamiętać, że projekcje typów złożonych nie mogą być śledzone, ponieważ obiekty typu złożonego nie mają tożsamości do użycia do śledzenia.

Używanie w predykatach

Składowe typów złożonych mogą być używane w predykatach. Na przykład znalezienie wszystkich zamówień przechodzących do określonego miasta:

var city = "Walpole St Peter";
var walpoleOrders = await context.Orders.Where(e => e.ShippingAddress.City == city).ToListAsync();

Co przekłada się na następujący kod SQL w programie SQL Server:

SELECT [o].[Id], [o].[Contents], [o].[CustomerId], [o].[BillingAddress_City], [o].[BillingAddress_Country],
    [o].[BillingAddress_Line1], [o].[BillingAddress_Line2], [o].[BillingAddress_PostCode],
    [o].[ContactPhone_CountryCode], [o].[ContactPhone_Number], [o].[ShippingAddress_City],
    [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1], [o].[ShippingAddress_Line2],
    [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[ShippingAddress_City] = @__city_0

W predykatach można również używać pełnego wystąpienia typu złożonego. Na przykład znalezienie wszystkich klientów z danym numerem telefonu:

var phoneNumber = new PhoneNumber(44, 7777555777);
var customersWithNumber = await context.Customers
    .Where(
        e => e.Contact.MobilePhone == phoneNumber
             || e.Contact.WorkPhone == phoneNumber
             || e.Contact.HomePhone == phoneNumber)
    .ToListAsync();

Przekłada się to na następujący kod SQL podczas korzystania z programu SQL Server:

SELECT [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country], [c].[Contact_Address_Line1],
     [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode], [c].[Contact_HomePhone_CountryCode],
     [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode], [c].[Contact_MobilePhone_Number],
     [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE ([c].[Contact_MobilePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_MobilePhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_WorkPhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_WorkPhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_HomePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_HomePhone_Number] = @__entity_equality_phoneNumber_0_Number)

Zwróć uwagę, że równość jest wykonywana przez rozszerzenie każdego elementu członkowskiego typu złożonego. Jest to zgodne z typami złożonymi bez klucza tożsamości, dlatego wystąpienie typu złożonego jest równe innemu wystąpieniu typu złożonego, jeśli i tylko wtedy, gdy wszystkie ich elementy członkowskie są równe. Jest to również zgodne z równością zdefiniowaną przez platformę .NET dla typów rekordów.

Manipulowanie złożonymi wartościami typów

EF8 zapewnia dostęp do informacji śledzenia, takich jak bieżące i oryginalne wartości typów złożonych oraz czy wartość właściwości została zmodyfikowana. Typy złożone interfejsu API to rozszerzenie interfejsu API śledzenia zmian, które jest już używane dla typów jednostek.

ComplexProperty Metody zwracania EntityEntry wpisu dla całego złożonego obiektu. Aby na przykład uzyskać bieżącą wartość elementu Order.BillingAddress:

var billingAddress = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .CurrentValue;

Wywołanie metody Property można dodać w celu uzyskania dostępu do właściwości typu złożonego. Aby na przykład uzyskać bieżącą wartość tylko kodu pocztowego rozliczeń:

var postCode = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .Property(e => e.PostCode)
    .CurrentValue;

Dostęp do zagnieżdżonych typów złożonych uzyskuje się przy użyciu zagnieżdżonych wywołań metody ComplexProperty. Na przykład, aby pobrać miasto z zagnieżdżonego AddressContact obiektu na :Customer

var currentCity = context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.City)
    .CurrentValue;

Inne metody są dostępne do odczytywania i zmieniania stanu. Na przykład PropertyEntry.IsModified można użyć do ustawienia właściwości typu złożonego zgodnie z modyfikacją:

context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.PostCode)
    .IsModified = true;

Bieżące ograniczenia

Typy złożone reprezentują znaczną inwestycję w stos EF. Nie byliśmy w stanie wykonać wszystkich prac w tej wersji, ale planujemy zamknąć niektóre luki w przyszłej wersji. Pamiętaj, aby głosować (👍) na odpowiednie problemy z usługą GitHub, jeśli rozwiązanie któregokolwiek z tych ograniczeń jest dla Ciebie ważne.

Ograniczenia typów złożonych w programie EF8 obejmują:

Kolekcje pierwotne

Trwałe pytanie podczas korzystania z relacyjnych baz danych to, co należy zrobić z kolekcjami typów pierwotnych; oznacza to, listy lub tablice liczb całkowitych, daty/godziny, ciągów itd. Jeśli używasz bazy danych PostgreSQL, możesz łatwo przechowywać te elementy przy użyciu wbudowanego typu tablicy PostgreSQL. W przypadku innych baz danych istnieją dwa typowe podejścia:

  • Utwórz tabelę z kolumną dla wartości typu pierwotnego i inną kolumną, która będzie pełnić rolę klucza obcego łączącego każdą wartość z właścicielem kolekcji.
  • Serializowanie kolekcji pierwotnej do typu kolumny obsługiwanego przez bazę danych — na przykład serializowanie do i z ciągu.

Pierwsza opcja ma zalety w wielu sytuacjach — przyjrzymy się jej na końcu tej sekcji. Jednak nie jest to naturalna reprezentacja danych w modelu, a jeśli to, co naprawdę masz, to kolekcja typu pierwotnego, druga opcja może być bardziej skuteczna.

Począwszy od wersji zapoznawczej 4, program EF8 zawiera teraz wbudowaną obsługę drugiej opcji, używając formatu JSON jako formatu serializacji. Kod JSON działa dobrze, ponieważ nowoczesne relacyjne bazy danych zawierają wbudowane mechanizmy wykonywania zapytań i manipulowania formatem JSON, dzięki czemu kolumna JSON może w razie potrzeby być traktowana jako tabela bez konieczności faktycznego tworzenia tej tabeli. Te same mechanizmy umożliwiają przekazywanie danych JSON w parametrach, a następnie ich używanie w podobny sposób do parametrów wartości tabeli w zapytaniach — więcej na ten temat później.

Napiwek

Pokazany tutaj kod pochodzi z PrimitiveCollectionsSample.cs.

Właściwości kolekcji pierwotnej

Program EF Core może mapować dowolną IEnumerable<T> właściwość, gdzie T jest typem pierwotnym, do kolumny JSON w bazie danych. Odbywa się to zgodnie z konwencją dla właściwości publicznych, które mają zarówno parametr getter, jak i setter. Na przykład wszystkie właściwości w następującym typie jednostki są mapowane na kolumny JSON według konwencji:

public class PrimitiveCollections
{
    public IEnumerable<int> Ints { get; set; }
    public ICollection<string> Strings { get; set; }
    public IList<DateOnly> Dates { get; set; }
    public uint[] UnsignedInts { get; set; }
    public List<bool> Booleans { get; set; }
    public List<Uri> Urls { get; set; }
}

Uwaga

Co oznacza "typ pierwotny" w tym kontekście? Zasadniczo coś, co dostawca bazy danych wie, jak mapować, używając pewnego rodzaju konwersji wartości w razie potrzeby. Na przykład w typie jednostki powyżej typy int, string, DateOnlyDateTimei bool są obsługiwane bez konwersji przez dostawcę bazy danych. Program SQL Server nie ma natywnej obsługi niepodpisanych ints ani identyfikatorów URI, ale uintUri nadal jest traktowany jako typy pierwotne, ponieważ istnieją wbudowane konwertery wartości dla tych typów.

Domyślnie program EF Core używa nieprzeciętnego typu kolumny ciągu Unicode do przechowywania kodu JSON, ponieważ chroni to przed utratą danych przy użyciu dużych kolekcji. Jednak w niektórych systemach baz danych, takich jak SQL Server, określenie maksymalnej długości ciągu może poprawić wydajność. Można to zrobić razem z inną konfiguracją kolumn w normalny sposób. Na przykład:

modelBuilder
    .Entity<PrimitiveCollections>()
    .Property(e => e.Booleans)
    .HasMaxLength(1024)
    .IsUnicode(false);

Możesz też użyć atrybutów mapowania:

[MaxLength(2500)]
[Unicode(false)]
public uint[] UnsignedInts { get; set; }

Domyślna konfiguracja kolumny może być używana dla wszystkich właściwości określonego typu przy użyciu konfiguracji modelu przed konwencją. Na przykład:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder
        .Properties<List<DateOnly>>()
        .AreUnicode(false)
        .HaveMaxLength(4000);
}

Zapytania z kolekcjami pierwotnymi

Przyjrzyjmy się niektórym zapytaniom korzystającym z kolekcji typów pierwotnych. W tym celu będziemy potrzebować prostego modelu z dwoma typami jednostek. Pierwszy reprezentuje brytyjski dom publiczny lub "pub":

public class Pub
{
    public Pub(string name, string[] beers)
    {
        Name = name;
        Beers = beers;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string[] Beers { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

Typ Pub zawiera dwie kolekcje pierwotne:

  • Beers to tablica ciągów reprezentujących marki piwa dostępne w pubie.
  • DaysVisited jest listą dat, w których odwiedzono pub.

Napiwek

W prawdziwej aplikacji prawdopodobnie bardziej sensowne byłoby utworzenie typu jednostki dla piwa i posiadanie tabeli dla piw. Przedstawiamy tutaj kolekcję pierwotną, aby zilustrować sposób ich działania. Pamiętaj jednak, że tylko dlatego, że można modelować coś jako kolekcję pierwotną, nie oznacza, że musisz.

Drugi typ jednostki reprezentuje pies spacer na brytyjskiej wsi:

public class DogWalk
{
    public DogWalk(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public Terrain Terrain { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
    public Pub ClosestPub { get; set; } = null!;
}

public enum Terrain
{
    Forest,
    River,
    Hills,
    Village,
    Park,
    Beach,
}

Podobnie jak Pub, DogWalk zawiera również kolekcję odwiedzonych dat i link do najbliższego pubu, ponieważ, wiesz, czasami pies potrzebuje spodka piwa po długim spacerze.

Korzystając z tego modelu, pierwszym zapytaniem, które wykonamy, jest proste Contains zapytanie, które umożliwia znalezienie wszystkich spacerów z jednym z kilku różnych terenu:

var terrains = new[] { Terrain.River, Terrain.Beach, Terrain.Park };
var walksWithTerrain = await context.Walks
    .Where(e => terrains.Contains(e.Terrain))
    .Select(e => e.Name)
    .ToListAsync();

Jest to już tłumaczone przez bieżące wersje platformy EF Core przez podkreślenie wartości do wyszukania. Na przykład w przypadku korzystania z programu SQL Server:

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE [w].[Terrain] IN (1, 5, 4)

Jednak ta strategia nie działa dobrze w przypadku buforowania zapytań bazy danych; Zobacz Ogłoszenie programu EF8 (wersja zapoznawcza 4 ) na blogu platformy .NET, aby zapoznać się z omówieniem problemu.

Ważne

Podkreślenie wartości odbywa się w taki sposób, że nie ma szans na atak polegający na wstrzyknięciu kodu SQL. Zmiana użycia kodu JSON opisanego poniżej dotyczy wydajności i nic wspólnego z zabezpieczeniami.

W przypadku platformy EF Core 8 domyślną wartością jest teraz przekazanie listy terenu jako pojedynczego parametru zawierającego kolekcję JSON. Na przykład:

@__terrains_0='[1,5,4]'

Następnie zapytanie jest używane OpenJson w programie SQL Server:

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__terrains_0) AS [t]
    WHERE CAST([t].[value] AS int) = [w].[Terrain])

Lub json_each na SQLite:

SELECT "w"."Name"
FROM "Walks" AS "w"
WHERE EXISTS (
    SELECT 1
    FROM json_each(@__terrains_0) AS "t"
    WHERE "t"."value" = "w"."Terrain")

Uwaga

OpenJson jest dostępna tylko w programie SQL Server 2016 (poziom zgodności 130) i nowszych wersjach. Możesz poinformować program SQL Server, że używasz starszej wersji, konfigurując poziom zgodności w ramach programu UseSqlServer. Na przykład:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseSqlServer(
            @"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow",
            sqlServerOptionsBuilder => sqlServerOptionsBuilder.UseCompatibilityLevel(120));

Wypróbujmy inny rodzaj Contains zapytania. W tym przypadku wyszukamy wartość kolekcji parametrów w kolumnie. Na przykład każdy pub, który zapasy Heineken:

var beer = "Heineken";
var pubsWithHeineken = await context.Pubs
    .Where(e => e.Beers.Contains(beer))
    .Select(e => e.Name)
    .ToListAsync();

Istniejąca dokumentacja z nowości w programie EF7 zawiera szczegółowe informacje na temat mapowania, zapytań i aktualizacji JSON. Ta dokumentacja dotyczy teraz również programu SQLite.

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[Beers]) AS [b]
    WHERE [b].[value] = @__beer_0)

OpenJson Element jest teraz używany do wyodrębniania wartości z kolumny JSON, aby można było dopasować każdą wartość do przekazanego parametru.

Możemy połączyć użycie parametru OpenJson z parametrem OpenJson w kolumnie . Aby na przykład znaleźć puby, które zaopatrzyją się w jedną z różnych lagerów:

var beers = new[] { "Carling", "Heineken", "Stella Artois", "Carlsberg" };
var pubsWithLager = await context.Pubs
    .Where(e => beers.Any(b => e.Beers.Contains(b)))
    .Select(e => e.Name)
    .ToListAsync();

Przekłada się to na następujące elementy w programie SQL Server:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__beers_0) AS [b]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[Beers]) AS [b0]
        WHERE [b0].[value] = [b].[value] OR ([b0].[value] IS NULL AND [b].[value] IS NULL)))

Wartość parametru @__beers_0 to ["Carling","Heineken","Stella Artois","Carlsberg"].

Przyjrzyjmy się zapytaniu, które korzysta z kolumny zawierającej kolekcję dat. Na przykład aby znaleźć puby odwiedzone w tym roku:

var thisYear = DateTime.Now.Year;
var pubsVisitedThisYear = await context.Pubs
    .Where(e => e.DaysVisited.Any(v => v.Year == thisYear))
    .Select(e => e.Name)
    .ToListAsync();

Przekłada się to na następujące elementy w programie SQL Server:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[DaysVisited]) AS [d]
    WHERE DATEPART(year, CAST([d].[value] AS date)) = @__thisYear_0)

Zwróć uwagę, że zapytanie korzysta z funkcji DATEPART specyficznej dla daty, ponieważ program EF wie, że kolekcja pierwotna zawiera daty. To może nie wydawać się tak, ale jest to naprawdę ważne. Ponieważ program EF wie, co znajduje się w kolekcji, może wygenerować odpowiedni język SQL, aby używać typowanych wartości z parametrami, funkcjami, innymi kolumnami itp.

Użyjmy ponownie kolekcji dat, tym razem, aby odpowiednio zamówić wartości typu i projektu wyodrębnione z kolekcji. Na przykład wyświetlmy listę pubów w kolejności, w której zostały one po raz pierwszy odwiedzone, i z pierwszą i ostatnią datą, w której odwiedzono każdy pub:

var pubsVisitedInOrder = await context.Pubs
    .Select(e => new
    {
        e.Name,
        FirstVisited = e.DaysVisited.OrderBy(v => v).First(),
        LastVisited = e.DaysVisited.OrderByDescending(v => v).First(),
    })
    .OrderBy(p => p.FirstVisited)
    .ToListAsync();

Przekłada się to na następujące elementy w programie SQL Server:

SELECT [p].[Name], (
    SELECT TOP(1) CAST([d0].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d0]
    ORDER BY CAST([d0].[value] AS date)) AS [FirstVisited], (
    SELECT TOP(1) CAST([d1].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d1]
    ORDER BY CAST([d1].[value] AS date) DESC) AS [LastVisited]
FROM [Pubs] AS [p]
ORDER BY (
    SELECT TOP(1) CAST([d].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d]
    ORDER BY CAST([d].[value] AS date))

I wreszcie, jak często odwiedzamy najbliższy pub, biorąc psa na spacer? Dowiedzmy się:

var walksWithADrink = await context.Walks.Select(
    w => new
    {
        WalkName = w.Name,
        PubName = w.ClosestPub.Name,
        Count = w.DaysVisited.Count(v => w.ClosestPub.DaysVisited.Contains(v)),
        TotalCount = w.DaysVisited.Count
    }).ToListAsync();

Przekłada się to na następujące elementy w programie SQL Server:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[DaysVisited]) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

I ujawnia następujące dane:

The Prince of Wales Feathers was visited 5 times in 8 "Ailsworth to Nene" walks.
The Prince of Wales Feathers was visited 6 times in 9 "Caster Hanglands" walks.
The Royal Oak was visited 6 times in 8 "Ferry Meadows" walks.
The White Swan was visited 7 times in 9 "Woodnewton" walks.
The Eltisley was visited 6 times in 8 "Eltisley" walks.
Farr Bay Inn was visited 7 times in 11 "Farr Beach" walks.
Farr Bay Inn was visited 7 times in 9 "Newlands" walks.

Wygląda na to, że piwo i pies chodzenie są zwycięską kombinacją!

Kolekcje pierwotne w dokumentach JSON

We wszystkich powyższych przykładach kolumna kolekcji pierwotnej zawiera kod JSON. Nie jest to jednak takie samo, jak mapowanie typu jednostki należącej do kolumny zawierającej dokument JSON, który został wprowadzony w programie EF7. Ale co zrobić, jeśli sam dokument JSON zawiera kolekcję pierwotną? Cóż, wszystkie powyższe zapytania nadal działają w ten sam sposób! Załóżmy na przykład, że przenosimy dni odwiedzone dane do typu Visits należącego do dokumentu JSON:

public class Pub
{
    public Pub(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public BeerData Beers { get; set; } = null!;
    public Visits Visits { get; set; } = null!;
}

public class Visits
{
    public string? LocationTag { get; set; }
    public List<DateOnly> DaysVisited { get; set; } = null!;
}

Napiwek

Pokazany tutaj kod pochodzi z PrimitiveCollectionsInJsonSample.cs.

Teraz możemy uruchomić odmianę naszego końcowego zapytania, które tym razem wyodrębnia dane z dokumentu JSON, w tym zapytania do kolekcji pierwotnych zawartych w dokumencie:

var walksWithADrink = await context.Walks.Select(
    w => new
    {
        WalkName = w.Name,
        PubName = w.ClosestPub.Name,
        WalkLocationTag = w.Visits.LocationTag,
        PubLocationTag = w.ClosestPub.Visits.LocationTag,
        Count = w.Visits.DaysVisited.Count(v => w.ClosestPub.Visits.DaysVisited.Contains(v)),
        TotalCount = w.Visits.DaysVisited.Count
    }).ToListAsync();

Przekłada się to na następujące elementy w programie SQL Server:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], JSON_VALUE([w].[Visits], '$.LocationTag') AS [WalkLocationTag], JSON_VALUE([p].[Visits], '$.LocationTag') AS [PubLocationTag], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson(JSON_VALUE([p].[Visits], '$.DaysVisited')) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

Podobne zapytanie podczas korzystania z biblioteki SQLite:

SELECT "w"."Name" AS "WalkName", "p"."Name" AS "PubName", "w"."Visits" ->> 'LocationTag' AS "WalkLocationTag", "p"."Visits" ->> 'LocationTag' AS "PubLocationTag", (
    SELECT COUNT(*)
    FROM json_each("w"."Visits" ->> 'DaysVisited') AS "d"
    WHERE EXISTS (
        SELECT 1
        FROM json_each("p"."Visits" ->> 'DaysVisited') AS "d0"
        WHERE "d0"."value" = "d"."value")) AS "Count", json_array_length("w"."Visits" ->> 'DaysVisited') AS "TotalCount"
FROM "Walks" AS "w"
INNER JOIN "Pubs" AS "p" ON "w"."ClosestPubId" = "p"."Id"

Napiwek

Zwróć uwagę, że w środowisku SQLite EF Core jest teraz używany ->> operator, co powoduje, że zapytania, które są łatwiejsze do odczytania i często bardziej wydajne.

Mapowanie kolekcji pierwotnych na tabelę

Wspomnieliśmy powyżej, że inną opcją kolekcji pierwotnych jest mapowania ich na inną tabelę. Obsługa pierwszej klasy dla tego problemu jest śledzona przez problem nr 25163; pamiętaj, aby głosować na ten problem, jeśli jest dla Ciebie ważne. Dopóki nie zostanie to zaimplementowane, najlepszym rozwiązaniem jest utworzenie typu opakowującego dla elementu pierwotnego. Na przykład utwórzmy typ dla elementu Beer:

[Owned]
public class Beer
{
    public Beer(string name)
    {
        Name = name;
    }

    public string Name { get; private set; }
}

Zwróć uwagę, że typ po prostu opakowuje wartość pierwotną — nie ma klucza podstawowego ani żadnych kluczy obcych zdefiniowanych. Ten typ może być następnie używany w Pub klasie:

public class Pub
{
    public Pub(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public List<Beer> Beers { get; set; } = new();
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

Program EF utworzy teraz tabelę Beer , synthesizing klucz podstawowy i kolumny klucza obcego Pubs z powrotem do tabeli. Na przykład w programie SQL Server:

CREATE TABLE [Beer] (
    [PubId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Beer] PRIMARY KEY ([PubId], [Id]),
    CONSTRAINT [FK_Beer_Pubs_PubId] FOREIGN KEY ([PubId]) REFERENCES [Pubs] ([Id]) ON DELETE CASCADE

Ulepszenia mapowania kolumn JSON

Program EF8 zawiera ulepszenia obsługi mapowania kolumn JSON wprowadzone w programie EF7.

Napiwek

Pokazany tutaj kod pochodzi z JsonColumnsSample.cs.

Tłumaczenie dostępu do elementów na tablice JSON

Program EF8 obsługuje indeksowanie w tablicach JSON podczas wykonywania zapytań. Na przykład następujące zapytanie sprawdza, czy pierwsze dwie aktualizacje zostały wprowadzone przed daną datą.

var cutoff = DateOnly.FromDateTime(DateTime.UtcNow - TimeSpan.FromDays(365));
var updatedPosts = await context.Posts
    .Where(
        p => p.Metadata!.Updates[0].UpdatedOn < cutoff
             && p.Metadata!.Updates[1].UpdatedOn < cutoff)
    .ToListAsync();

Przekłada się to na następujący kod SQL podczas korzystania z programu SQL Server:

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) < @__cutoff_0
  AND CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) < @__cutoff_0

Uwaga

To zapytanie powiedzie się, nawet jeśli dany wpis nie ma żadnych aktualizacji lub ma tylko jedną aktualizację. W takim przypadku JSON_VALUE zwraca wartość , NULL a predykat nie jest zgodny.

Indeksowanie do tablic JSON może być również używane do projekcji elementów z tablicy do wyników końcowych. Na przykład następujące zapytanie projektuje UpdatedOn datę pierwszej i drugiej aktualizacji każdego wpisu.

var postsAndRecentUpdatesNullable = await context.Posts
    .Select(p => new
    {
        p.Title,
        LatestUpdate = (DateOnly?)p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = (DateOnly?)p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

Przekłada się to na następujący kod SQL podczas korzystania z programu SQL Server:

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]

Jak wspomniano powyżej, zwraca wartość null, JSON_VALUE jeśli element tablicy nie istnieje. Jest to obsługiwane w zapytaniu przez rzutowanie przewidywanej wartości na wartość null DateOnly. Alternatywą do rzutowania wartości jest filtrowanie wyników zapytania tak, aby JSON_VALUE nigdy nie zwracało wartości null. Na przykład:

var postsAndRecentUpdates = await context.Posts
    .Where(p => p.Metadata!.Updates[0].UpdatedOn != null
                && p.Metadata!.Updates[1].UpdatedOn != null)
    .Select(p => new
    {
        p.Title,
        LatestUpdate = p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

Przekłada się to na następujący kod SQL podczas korzystania z programu SQL Server:

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]
      WHERE (CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) IS NOT NULL)
        AND (CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) IS NOT NULL)

Tłumaczenie zapytań na kolekcje osadzone

Program EF8 obsługuje zapytania dotyczące kolekcji zarówno typów pierwotnych (omówionych powyżej) jak i nietypowych osadzonych w dokumencie JSON. Na przykład następujące zapytanie zwraca wszystkie wpisy z dowolną listą terminów wyszukiwania:

var searchTerms = new[] { "Search #2", "Search #3", "Search #5", "Search #8", "Search #13", "Search #21", "Search #34" };

var postsWithSearchTerms = await context.Posts
    .Where(post => post.Metadata!.TopSearches.Any(s => searchTerms.Contains(s.Term)))
    .ToListAsync();

Przekłada się to na następujący kod SQL podczas korzystania z programu SQL Server:

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OPENJSON([p].[Metadata], '$.TopSearches') WITH (
        [Count] int '$.Count',
        [Term] nvarchar(max) '$.Term'
    ) AS [t]
    WHERE EXISTS (
        SELECT 1
        FROM OPENJSON(@__searchTerms_0) WITH ([value] nvarchar(max) '$') AS [s]
        WHERE [s].[value] = [t].[Term]))

Kolumny JSON dla SQLite

Program EF7 wprowadził obsługę mapowania do kolumn JSON podczas korzystania z programu Azure SQL/SQL Server. Program EF8 rozszerza tę obsługę baz danych SQLite. Jeśli chodzi o obsługę programu SQL Server, obejmuje to:

  • Mapowanie agregacji utworzonych na podstawie typów platformy .NET na dokumenty JSON przechowywane w kolumnach SQLite
  • Zapytania dotyczące kolumn JSON, takich jak filtrowanie i sortowanie według elementów dokumentów
  • Zapytania dotyczące elementów projektu z dokumentu JSON do wyników
  • Aktualizowanie i zapisywanie zmian w dokumentach JSON

Istniejąca dokumentacja z nowości w programie EF7 zawiera szczegółowe informacje na temat mapowania, zapytań i aktualizacji JSON. Ta dokumentacja dotyczy teraz również programu SQLite.

Napiwek

Kod przedstawiony w dokumentacji platformy EF7 został zaktualizowany, aby można było go również uruchomić na platformie SQLite, można znaleźć w JsonColumnsSample.cs.

Zapytania do kolumn JSON

Zapytania w kolumnach JSON w bibliotece SQLite używają json_extract funkcji . Na przykład zapytanie "autorzy w chigley" z dokumentacji, do których odwołuje się powyżej:

var authorsInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .ToListAsync();

W przypadku korzystania z biblioteki SQLite jest tłumaczona na następujący kod SQL:

SELECT "a"."Id", "a"."Name", "a"."Contact"
FROM "Authors" AS "a"
WHERE json_extract("a"."Contact", '$.Address.City') = 'Chigley'

Aktualizowanie kolumn JSON

W przypadku aktualizacji program EF używa json_set funkcji w bibliotece SQLite. Na przykład podczas aktualizowania pojedynczej właściwości w dokumencie:

var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur"));

arthur.Contact.Address.Country = "United Kingdom";

await context.SaveChangesAsync();

Program EF generuje następujące parametry:

info: 3/10/2023 10:51:33.127 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

Które używają json_set funkcji w sqlite:

UPDATE "Authors" SET "Contact" = json_set("Contact", '$.Address.Country', json_extract(@p0, '$[0]'))
WHERE "Id" = @p1
RETURNING 1;

HierarchyId na platformie .NET i platformie EF Core

Usługi Azure SQL i SQL Server mają specjalny typ danych, hierarchyid który jest używany do przechowywania danych hierarchicznych. W takim przypadku "dane hierarchiczne" zasadniczo oznaczają dane, które stanowią strukturę drzewa, gdzie każdy element może mieć element nadrzędny i/lub podrzędny. Przykłady takich danych to:

  • Struktura organizacyjna
  • System plików
  • Zestaw zadań w projekcie
  • Taksonomia terminów językowych
  • Wykres łączy między stronami sieci Web

Baza danych może następnie uruchamiać zapytania względem tych danych przy użyciu jego struktury hierarchicznej. Na przykład zapytanie może znaleźć elementy nadrzędne i zależne od danych elementów lub znaleźć wszystkie elementy w określonej głębokości w hierarchii.

Obsługa platform .NET i EF Core

Oficjalna obsługa typu programu SQL Server hierarchyid jest ostatnio dostępna tylko na nowoczesnych platformach .NET (tj. ".NET Core"). Ta obsługa jest w postaci pakietu NuGet Microsoft.SqlServer.Types , który oferuje typy specyficzne dla programu SQL Server niskiego poziomu. W tym przypadku typ niskiego poziomu nosi nazwę SqlHierarchyId.

Na następnym poziomie wprowadzono nowy pakiet Microsoft.EntityFrameworkCore.SqlServer.Abstractions , który zawiera typ wyższego poziomu HierarchyId przeznaczony do użycia w typach jednostek.

Napiwek

Typ HierarchyId jest bardziej idiotyczny do norm platformy .NET niż SqlHierarchyId, który zamiast tego jest modelowany po tym, jak typy programu .NET Framework są hostowane wewnątrz aparatu bazy danych programu SQL Server. HierarchyId jest przeznaczony do pracy z programem EF Core, ale może być również używany poza programem EF Core w innych aplikacjach. Pakiet Microsoft.EntityFrameworkCore.SqlServer.Abstractions nie odwołuje się do żadnych innych pakietów, dlatego ma minimalny wpływ na rozmiar i zależności wdrożonej aplikacji.

Użycie funkcji HierarchyId programu EF Core, takich jak zapytania i aktualizacje, wymaga pakietu Microsoft.EntityFrameworkCore.SqlServer.HierarchyId . Ten pakiet wprowadza zależności Microsoft.EntityFrameworkCore.SqlServer.AbstractionsMicrosoft.SqlServer.Types przechodnie i tak często jest jedynym wymaganym pakietem. Po zainstalowaniu pakietu użycie polecenia HierarchyId jest włączone przez wywołanie w ramach wywołania UseHierarchyId aplikacji do UseSqlServermetody . Na przykład:

options.UseSqlServer(
    connectionString,
    x => x.UseHierarchyId());

Uwaga

Nieoficjalna obsługa hierarchyid programu EF Core jest dostępna od wielu lat za pośrednictwem pakietu EntityFrameworkCore.SqlServer.HierarchyId . Ten pakiet został zachowany jako współpraca między społecznością a zespołem EF. Teraz, gdy istnieje oficjalna obsługa platformy hierarchyid .NET, kod z tych formularzy pakietów społeczności, z uprawnieniem oryginalnych współautorów, podstawy oficjalnego pakietu opisanego tutaj. Wiele dzięki wszystkim zaangażowanym osobom z lat, w tym @aljones, @cutig3r, @huan086, @kmataru, @mehdihaghshenas i @vyrotek

Hierarchie modelowania

Typ HierarchyId może służyć do właściwości typu jednostki. Załóżmy na przykład, że chcemy modelować drzewo rodzinne ojcowskie niektórych fikcyjnych półlingów. W typie jednostki dla HalflingHierarchyId elementu właściwość może służyć do lokalizowania każdego półlinga w drzewie rodzinnym.

public class Halfling
{
    public Halfling(HierarchyId pathFromPatriarch, string name, int? yearOfBirth = null)
    {
        PathFromPatriarch = pathFromPatriarch;
        Name = name;
        YearOfBirth = yearOfBirth;
    }

    public int Id { get; private set; }
    public HierarchyId PathFromPatriarch { get; set; }
    public string Name { get; set; }
    public int? YearOfBirth { get; set; }
}

Napiwek

Kod przedstawiony tutaj i w poniższych przykładach pochodzi z HierarchyIdSample.cs.

Napiwek

W razie potrzeby HierarchyId nadaje się do użycia jako typ właściwości klucza.

W tym przypadku drzewo rodzinne jest zakorzenione patriarchą rodziny. Każdy półling można prześledzić od patriarchy w dół drzewa przy użyciu jego PathFromPatriarch właściwości. Program SQL Server używa kompaktowego formatu binarnego dla tych ścieżek, ale często analizowanie do i z reprezentacji ciągu czytelnego dla człowieka podczas pracy z kodem. W tej reprezentacji pozycja na każdym poziomie jest oddzielona znakiem / . Rozważmy na przykład drzewo rodzinne na poniższym diagramie:

Drzewo rodzinne półlinga

W tym drzewie:

  • Balbo znajduje się u korzenia drzewa reprezentowanego przez /element .
  • Balbo ma pięcioro dzieci reprezentowane przez /1/, , /3//2/, /4/i /5/.
  • Pierwsze dziecko Balbo, Mungo, ma również pięcioro dzieci reprezentowane przez /1/1/, , /1/2//1/3/, /1/4/i /1/5/. Zwróć uwagę, że dla HierarchyId Balbo (/1/) jest prefiksem dla wszystkich jego dzieci.
  • Podobnie trzecie dziecko Balbo, Ponto, ma dwoje dzieci, reprezentowane przez /3/1/ i /3/2/. Ponownie każdy z tych elementów podrzędnych jest poprzedzony prefiksem HierarchyId dla Ponto, który jest reprezentowany jako /3/.
  • I tak dalej w dół drzewa...

Poniższy kod wstawia to drzewo rodziny do bazy danych przy użyciu programu EF Core:

await AddRangeAsync(
    new Halfling(HierarchyId.Parse("/"), "Balbo", 1167),
    new Halfling(HierarchyId.Parse("/1/"), "Mungo", 1207),
    new Halfling(HierarchyId.Parse("/2/"), "Pansy", 1212),
    new Halfling(HierarchyId.Parse("/3/"), "Ponto", 1216),
    new Halfling(HierarchyId.Parse("/4/"), "Largo", 1220),
    new Halfling(HierarchyId.Parse("/5/"), "Lily", 1222),
    new Halfling(HierarchyId.Parse("/1/1/"), "Bungo", 1246),
    new Halfling(HierarchyId.Parse("/1/2/"), "Belba", 1256),
    new Halfling(HierarchyId.Parse("/1/3/"), "Longo", 1260),
    new Halfling(HierarchyId.Parse("/1/4/"), "Linda", 1262),
    new Halfling(HierarchyId.Parse("/1/5/"), "Bingo", 1264),
    new Halfling(HierarchyId.Parse("/3/1/"), "Rosa", 1256),
    new Halfling(HierarchyId.Parse("/3/2/"), "Polo"),
    new Halfling(HierarchyId.Parse("/4/1/"), "Fosco", 1264),
    new Halfling(HierarchyId.Parse("/1/1/1/"), "Bilbo", 1290),
    new Halfling(HierarchyId.Parse("/1/3/1/"), "Otho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/"), "Falco", 1303),
    new Halfling(HierarchyId.Parse("/3/2/1/"), "Posco", 1302),
    new Halfling(HierarchyId.Parse("/3/2/2/"), "Prisca", 1306),
    new Halfling(HierarchyId.Parse("/4/1/1/"), "Dora", 1302),
    new Halfling(HierarchyId.Parse("/4/1/2/"), "Drogo", 1308),
    new Halfling(HierarchyId.Parse("/4/1/3/"), "Dudo", 1311),
    new Halfling(HierarchyId.Parse("/1/3/1/1/"), "Lotho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/1/"), "Poppy", 1344),
    new Halfling(HierarchyId.Parse("/3/2/1/1/"), "Ponto", 1346),
    new Halfling(HierarchyId.Parse("/3/2/1/2/"), "Porto", 1348),
    new Halfling(HierarchyId.Parse("/3/2/1/3/"), "Peony", 1350),
    new Halfling(HierarchyId.Parse("/4/1/2/1/"), "Frodo", 1368),
    new Halfling(HierarchyId.Parse("/4/1/3/1/"), "Daisy", 1350),
    new Halfling(HierarchyId.Parse("/3/2/1/1/1/"), "Angelica", 1381));

await SaveChangesAsync();

Napiwek

W razie potrzeby wartości dziesiętne mogą służyć do tworzenia nowych węzłów między dwoma istniejącymi węzłami. Na przykład /3/2.5/2/ przechodzi między /3/2/2/ i /3/3/2/.

Wykonywanie zapytań dotyczących hierarchii

HierarchyId Uwidacznia kilka metod, które mogą być używane w zapytaniach LINQ.

Metoda opis
GetAncestor(int n) Pobiera poziomy węzłów n w górę drzewa hierarchicznego.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Pobiera wartość węzła podrzędnego, który jest większy niż child1 i mniejszy niż child2.
GetLevel() Pobiera poziom tego węzła w drzewie hierarchicznym.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Pobiera wartość reprezentującą lokalizację nowego węzła, który ma ścieżkę z równej ścieżce od newRootoldRoot do tej, skutecznie przenosząc tę wartość do nowej lokalizacji.
IsDescendantOf(HierarchyId? parent) Pobiera wartość wskazującą, czy ten węzeł jest elementem podrzędnym parent.

Ponadto można użyć operatorów ==, !=, <<=, > i >= .

Poniżej przedstawiono przykłady użycia tych metod w zapytaniach LINQ.

Pobieranie jednostek na danym poziomie w drzewie

Następujące zapytanie używa GetLevel metody , aby zwrócić wszystkie półlingi na danym poziomie w drzewie rodziny:

var generation = await context.Halflings.Where(halfling => halfling.PathFromPatriarch.GetLevel() == level).ToListAsync();

Przekłada się to na następujący kod SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetLevel() = @__level_0

Uruchomienie tego w pętli pozwala uzyskać półlingi dla każdej generacji:

Generation 0: Balbo
Generation 1: Mungo, Pansy, Ponto, Largo, Lily
Generation 2: Bungo, Belba, Longo, Linda, Bingo, Rosa, Polo, Fosco
Generation 3: Bilbo, Otho, Falco, Posco, Prisca, Dora, Drogo, Dudo
Generation 4: Lotho, Poppy, Ponto, Porto, Peony, Frodo, Daisy
Generation 5: Angelica

Pobieranie bezpośredniego przodka jednostki

Następujące zapytanie używa GetAncestor metody do znalezienia bezpośredniego przodka półlinga, biorąc pod uwagę nazwę halflinga:

async Task<Halfling?> FindDirectAncestor(string name)
    => await context.Halflings
        .SingleOrDefaultAsync(
            ancestor => ancestor.PathFromPatriarch == context.Halflings
                .Single(descendent => descendent.Name == name).PathFromPatriarch
                .GetAncestor(1));

Przekłada się to na następujący kod SQL:

SELECT TOP(2) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch] = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0).GetAncestor(1)

Uruchomienie tego zapytania dla półlingu "Bilbo" zwraca wartość "Bungo".

Pobieranie bezpośrednich malejących jednostki

Następujące zapytanie używa również elementu GetAncestor, ale tym razem w celu znalezienia bezpośrednich malejących półlinga, biorąc pod uwagę nazwę halflinga:

IQueryable<Halfling> FindDirectDescendents(string name)
    => context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.GetAncestor(1) == context.Halflings
            .Single(ancestor => ancestor.Name == name).PathFromPatriarch);

Przekłada się to na następujący kod SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetAncestor(1) = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0)

Uruchomienie tego zapytania dla półlingu "Mungo" zwraca wartości "Bungo", "Belba", "Longo" i "Linda".

Pobieranie wszystkich elementów podrzędnych jednostki

GetAncestor jest przydatna do wyszukiwania w górę lub w dół pojedynczego poziomu, a nawet określonej liczby poziomów. Z drugiej strony jest IsDescendantOf przydatna do znajdowania wszystkich przodków lub zależności. Na przykład następujące zapytanie używa IsDescendantOf metody , aby znaleźć wszystkie elementy podrzędne półlinga, biorąc pod uwagę nazwę halflinga:

IQueryable<Halfling> FindAllAncestors(string name)
    => context.Halflings.Where(
            ancestor => context.Halflings
                .Single(
                    descendent =>
                        descendent.Name == name
                        && ancestor.Id != descendent.Id)
                .PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel());

Ważne

IsDescendantOf zwraca wartość true dla siebie, dlatego jest on filtrowany w powyższym zapytaniu.

Przekłada się to na następujący kod SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id]).IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

Uruchomienie tego zapytania dla półlingu "Bilbo" zwraca wartości "Bungo", "Mungo" i "Balbo".

Pobieranie wszystkich malejących jednostek

Następujące zapytanie używa również elementu IsDescendantOf, ale tym razem do wszystkich malejących półlinga, biorąc pod uwagę nazwę halflinga:

IQueryable<Halfling> FindAllDescendents(string name)
    => context.Halflings.Where(
            descendent => descendent.PathFromPatriarch.IsDescendantOf(
                context.Halflings
                    .Single(
                        ancestor =>
                            ancestor.Name == name
                            && descendent.Id != ancestor.Id)
                    .PathFromPatriarch))
        .OrderBy(descendent => descendent.PathFromPatriarch.GetLevel());

Przekłada się to na następujący kod SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].IsDescendantOf((
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id])) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel()

Uruchomienie tego zapytania dla półlingu "Mungo" zwraca "Bungo", "Belba", "Longo", "Linda", "Bingo", "Bilbo", "Otho", "Falco", "Lotho" i "Poppy".

Znajdowanie wspólnego przodka

Jednym z najczęstszych pytań zadawanych na temat tego konkretnego drzewa rodzinnego jest "kto jest wspólnym przodkiem Frodo i Bilbo?" Możemy użyć IsDescendantOf polecenia , aby napisać takie zapytanie:

async Task<Halfling?> FindCommonAncestor(Halfling first, Halfling second)
    => await context.Halflings
        .Where(
            ancestor => first.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch)
                        && second.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel())
        .FirstOrDefaultAsync();

Przekłada się to na następujący kod SQL:

SELECT TOP(1) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE @__first_PathFromPatriarch_0.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
  AND @__second_PathFromPatriarch_1.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

Uruchomienie tego zapytania z "Bilbo" i "Frodo" informuje nas, że ich wspólny przodk jest "Balbo".

Aktualizowanie hierarchii

Do aktualizowania hierarchyid kolumn można używać mechanizmów śledzenia normalnych zmian i funkcji SaveChanges.

Ponowne nadrzędne pod hierarchię

Na przykład, jestem pewien, że wszyscy pamiętamy skandal SR 1752 (np. "LongoGate"), gdy badania DNA wykazały, że Longo nie był w rzeczywistości synem Mungo, ale rzeczywiście synem Ponto! Jednym z opadów z tego skandalu było to, że drzewo rodzinne musi być ponownie napisane. W szczególności Longo i wszystkie jego zstępstwa musiały zostać ponownie wychowane z Mungo do Ponto. GetReparentedValue może służyć do tego celu. Na przykład najpierw "Longo" i wszystkie jego zstępne są odpytywane:

var longoAndDescendents = await context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.IsDescendantOf(
            context.Halflings.Single(ancestor => ancestor.Name == "Longo").PathFromPatriarch))
    .ToListAsync();

Następnie GetReparentedValue służy do aktualizowania HierarchyId elementu dla longo i każdego malejącej, a następnie wywołania metody :SaveChangesAsync

foreach (var descendent in longoAndDescendents)
{
    descendent.PathFromPatriarch
        = descendent.PathFromPatriarch.GetReparentedValue(
            mungo.PathFromPatriarch, ponto.PathFromPatriarch)!;
}

await context.SaveChangesAsync();

Spowoduje to następującą aktualizację bazy danych:

SET NOCOUNT ON;
UPDATE [Halflings] SET [PathFromPatriarch] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Halflings] SET [PathFromPatriarch] = @p2
OUTPUT 1
WHERE [Id] = @p3;
UPDATE [Halflings] SET [PathFromPatriarch] = @p4
OUTPUT 1
WHERE [Id] = @p5;

Przy użyciu następujących parametrów:

 @p1='9',
 @p0='0x7BC0' (Nullable = false) (Size = 2) (DbType = Object),
 @p3='16',
 @p2='0x7BD6' (Nullable = false) (Size = 2) (DbType = Object),
 @p5='23',
 @p4='0x7BD6B0' (Nullable = false) (Size = 3) (DbType = Object)

Uwaga

Wartości parametrów właściwości HierarchyId są wysyłane do bazy danych w kompaktowym formacie binarnym.

Po aktualizacji zapytanie dotyczące malejących wartości "Mungo" zwraca wartość "Bungo", "Belba", "Linda", "Bingo", "Bilbo", "Falco" i "Poppy", podczas wykonywania zapytań o malejące wartości "Ponto", "Longo", "Rosa", "Polo", "Otho", "Posco", "Prisca", "Lotho", "Ponto", "Porto", "Peony" i "Angelica".

Nieprzetworzone zapytania SQL dla niemapowanych typów

Program EF7 wprowadził nieprzetworzone zapytania SQL zwracające typy skalarne. Jest to ulepszone w programie EF8 w celu uwzględnienia nieprzetworzonych zapytań SQL zwracających dowolny typ mapowalnego środowiska CLR bez uwzględniania tego typu w modelu EF.

Napiwek

Pokazany tutaj kod pochodzi z RawSqlSample.cs.

Zapytania korzystające z niemapowanych typów są wykonywane przy użyciu polecenia SqlQuery lub SqlQueryRaw. Pierwszy używa interpolacji ciągów do sparametryzowania zapytania, co pomaga upewnić się, że wszystkie wartości inne niż stałe są sparametryzowane. Rozważmy na przykład następującą tabelę bazy danych:

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Content] nvarchar(max) NOT NULL,
    [PublishedOn] date NOT NULL,
    [BlogId] int NOT NULL,
);

SqlQuery Może służyć do wykonywania zapytań względem tej tabeli i zwracania wystąpień BlogPost typu z właściwościami odpowiadającymi kolumnom w tabeli:

Na przykład:

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

Na przykład:

var start = new DateOnly(2022, 1, 1);
var end = new DateOnly(2023, 1, 1);
var postsIn2022 =
    await context.Database
        .SqlQuery<BlogPost>($"SELECT * FROM Posts as p WHERE p.PublishedOn >= {start} AND p.PublishedOn < {end}")
        .ToListAsync();

To zapytanie jest sparametryzowane i wykonywane jako:

SELECT * FROM Posts as p WHERE p.PublishedOn >= @p0 AND p.PublishedOn < @p1

Typ używany dla wyników zapytania może zawierać typowe konstrukcje mapowania obsługiwane przez program EF Core, takie jak konstruktory sparametryzowane i atrybuty mapowania. Na przykład:

public class BlogPost
{
    public BlogPost(string blogTitle, string content, DateOnly publishedOn)
    {
        BlogTitle = blogTitle;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }

    [Column("Title")]
    public string BlogTitle { get; set; }

    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

Uwaga

Typy używane w ten sposób nie mają zdefiniowanych kluczy i nie mogą mieć relacji z innymi typami. Typy z relacjami muszą być mapowane w modelu.

Użyty typ musi mieć właściwość dla każdej wartości w zestawie wyników, ale nie musi odpowiadać żadnej tabeli w bazie danych. Na przykład następujący typ reprezentuje tylko podzbiór informacji dla każdego wpisu i zawiera nazwę bloga, która pochodzi z Blogs tabeli:

public class PostSummary
{
    public string BlogName { get; set; } = null!;
    public string PostTitle { get; set; } = null!;
    public DateOnly? PublishedOn { get; set; }
}

Zapytania można wykonywać przy użyciu SqlQuery w taki sam sposób jak poprzednio:


var cutoffDate = new DateOnly(2022, 1, 1);
var summaries =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
               FROM Posts AS p
               INNER JOIN Blogs AS b ON p.BlogId = b.Id
               WHERE p.PublishedOn >= {cutoffDate}")
        .ToListAsync();

Jedną z miłych SqlQuery cech jest to, że zwraca element IQueryable , który można skomponować przy użyciu LINQ. Na przykład do powyższego zapytania można dodać klauzulę "Where":

var summariesIn2022 =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
               FROM Posts AS p
               INNER JOIN Blogs AS b ON p.BlogId = b.Id")
        .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
        .ToListAsync();

Jest to wykonywane jako:

SELECT [n].[BlogName], [n].[PostTitle], [n].[PublishedOn]
FROM (
         SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
         FROM Posts AS p
                  INNER JOIN Blogs AS b ON p.BlogId = b.Id
     ) AS [n]
WHERE [n].[PublishedOn] >= @__cutoffDate_1 AND [n].[PublishedOn] < @__end_2

W tym momencie warto pamiętać, że wszystkie powyższe czynności można wykonać całkowicie w LINQ bez konieczności pisania jakiegokolwiek kodu SQL. Obejmuje to zwracanie wystąpień niezamapowanego typu, takiego jak PostSummary. Na przykład powyższe zapytanie można napisać w linQ jako:

var summaries =
    await context.Posts.Select(
            p => new PostSummary
            {
                BlogName = p.Blog.Name,
                PostTitle = p.Title,
                PublishedOn = p.PublishedOn,
            })
        .Where(p => p.PublishedOn >= start && p.PublishedOn < end)
        .ToListAsync();

Co przekłada się na znacznie bardziej czystszy sql:

SELECT [b].[Name] AS [BlogName], [p].[Title] AS [PostTitle], [p].[PublishedOn]
FROM [Posts] AS [p]
INNER JOIN [Blogs] AS [b] ON [p].[BlogId] = [b].[Id]
WHERE [p].[PublishedOn] >= @__start_0 AND [p].[PublishedOn] < @__end_1

Napiwek

Program EF jest w stanie wygenerować czystszy program SQL, gdy jest odpowiedzialny za całe zapytanie, niż podczas komponowania w języku SQL dostarczonym przez użytkownika, ponieważ w poprzednim przypadku pełna semantyka zapytania jest dostępna dla platformy EF.

Do tej pory wszystkie zapytania zostały wykonane bezpośrednio względem tabel. SqlQuery Może również służyć do zwracania wyników z widoku bez mapowania typu widoku w modelu EF. Na przykład:

var summariesFromView =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM PostAndBlogSummariesView")
        .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
        .ToListAsync();

SqlQuery Podobnie można użyć do wyników funkcji:

var summariesFromFunc =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM GetPostsPublishedAfter({cutoffDate})")
        .Where(p => p.PublishedOn < end)
        .ToListAsync();

Zwrócony IQueryable element może składać się, gdy jest wynikiem widoku lub funkcji, podobnie jak w przypadku wyniku zapytania tabeli. Procedury składowane można również wykonywać przy użyciu metody SqlQuery, ale większość baz danych nie obsługuje ich tworzenia. Na przykład:

var summariesFromStoredProc =
    await context.Database.SqlQuery<PostSummary>(
            @$"exec GetRecentPostSummariesProc")
        .ToListAsync();

Ulepszenia ładowania z opóźnieniem

Ładowanie z opóźnieniem dla zapytań bez śledzenia

Program EF8 dodaje obsługę opóźnionego ładowania nawigacji na jednostkach , które nie są śledzone przez element DbContext. Oznacza to, że zapytanie bez śledzenia może być wykonywane przez leniwe ładowanie nawigacji na jednostkach zwracanych przez zapytanie bez śledzenia.

Napiwek

Kod przykładów ładowania z opóźnieniem pokazanych poniżej pochodzi z LazyLoadingSample.cs.

Rozważmy na przykład zapytanie bez śledzenia dla blogów:

var blogs = await context.Blogs.AsNoTracking().ToListAsync();

Jeśli Blog.Posts skonfigurowano ładowanie z opóźnieniem, na przykład przy użyciu serwerów proxy ładujących z opóźnieniem, uzyskanie dostępu spowoduje załadowanie Posts z bazy danych:

Console.WriteLine();
Console.Write("Choose a blog: ");
if (int.TryParse(ReadLine(), out var blogId))
{
    Console.WriteLine("Posts:");
    foreach (var post in blogs[blogId - 1].Posts)
    {
        Console.WriteLine($"  {post.Title}");
    }
}

Program EF8 zgłasza również, czy dana nawigacja jest ładowana dla jednostek, które nie są śledzone przez kontekst. Na przykład:

foreach (var blog in blogs)
{
    if (context.Entry(blog).Collection(e => e.Posts).IsLoaded)
    {
        Console.WriteLine($" Posts for blog '{blog.Name}' are loaded.");
    }
}

Podczas korzystania z ładowania leniwego w ten sposób należy wziąć pod uwagę kilka ważnych kwestii:

  • Ładowanie z opóźnieniem powiedzie się tylko do momentu DbContext usunięcia jednostki użytej do wykonywania zapytań.
  • Jednostki, do których są odpytywane w ten sposób, zachowują odwołanie do elementu DbContext, mimo że nie są śledzone przez nie. Należy zachować ostrożność, aby uniknąć przecieków pamięci, jeśli wystąpienia jednostek będą miały długie okresy istnienia.
  • Jawne odłączenie jednostki przez ustawienie jej stanu na EntityState.Detached zerwanie odwołania do DbContext i leniwe ładowanie nie będzie już działać.
  • Należy pamiętać, że wszystkie leniwe operacje ładowania używają synchronicznych operacji we/wy, ponieważ nie ma możliwości uzyskania dostępu do właściwości w sposób asynchroniczny.

Ładowanie z nieśledzonych jednostek działa zarówno dla serwerów proxy z opóźnieniem ładowania, jak i ładowania z opóźnieniem bez serwerów proxy.

Jawne ładowanie z nieśledzonych jednostek

Program EF8 obsługuje ładowanie nawigacji na nieśledzonych jednostkach nawet wtedy, gdy jednostka lub nawigacja nie jest skonfigurowana do ładowania z opóźnieniem. W przeciwieństwie do ładowania leniwego, to jawne ładowanie można wykonać asynchronicznie. Na przykład:

await context.Entry(blog).Collection(e => e.Posts).LoadAsync();

Rezygnacja z opóźnionego ładowania dla określonych nawigacji

Program EF8 umożliwia konfigurację określonych nawigacji, które nie są ładowane z opóźnieniem, nawet jeśli wszystko inne jest skonfigurowane w taki sposób. Na przykład aby skonfigurować nawigację Post.Author tak, aby nie ładowała się z opóźnieniem, wykonaj następujące czynności:

modelBuilder
    .Entity<Post>()
    .Navigation(p => p.Author)
    .EnableLazyLoading(false);

Wyłączenie ładowania z opóźnieniem, tak jak to działa w przypadku serwerów proxy ładowanych z opóźnieniem i ładowania z opóźnieniem bez serwerów proxy.

Ładowanie serwerów proxy z opóźnieniem działa przez zastąpienie właściwości nawigacji wirtualnej. W klasycznych aplikacjach EF6 typowe źródło usterek zapomina o utworzeniu nawigacji wirtualnej, ponieważ nawigacja w trybie dyskretnym nie będzie ładowana z opóźnieniem. W związku z tym serwery proxy ef Core są domyślnie zgłaszane, gdy nawigacja nie jest wirtualna.

Można to zmienić w programie EF8, aby wyrazić zgodę na klasyczne zachowanie ef6, tak aby nawigacja nie ładowała się z opóźnieniem, przez co nawigacja nie jest wirtualna. Ta zgoda jest konfigurowana jako część wywołania metody UseLazyLoadingProxies. Na przykład:

optionsBuilder.UseLazyLoadingProxies(b => b.IgnoreNonVirtualNavigations());

Dostęp do śledzonych jednostek

Wyszukiwanie śledzonych jednostek według klucza podstawowego, alternatywnego lub obcego

Wewnętrznie platforma EF obsługuje struktury danych do znajdowania śledzonych jednostek według klucza podstawowego, alternatywnego lub obcego. Te struktury danych są używane do wydajnego naprawiania między powiązanymi jednostkami, gdy nowe jednostki są śledzone lub zmieniają się relacje.

Program EF8 zawiera nowe publiczne interfejsy API, dzięki czemu aplikacje mogą teraz używać tych struktur danych do wydajnego wyszukiwania śledzonych jednostek. Te interfejsy API są dostępne za pośrednictwem LocalView<TEntity> typu jednostki. Aby na przykład wyszukać śledzonej jednostki według klucza podstawowego:

var blogEntry = context.Blogs.Local.FindEntry(2)!;

Napiwek

Pokazany tutaj kod pochodzi z LookupByKeySample.cs.

Metoda FindEntry zwraca wartość EntityEntry<TEntity> dla śledzonej jednostki lub null jeśli nie jest śledzona żadna jednostka z danym kluczem. Podobnie jak wszystkie metody w systemie LocalView, baza danych nigdy nie jest odpytywane, nawet jeśli jednostka nie zostanie znaleziona. Zwrócony wpis zawiera samą jednostkę, a także informacje o śledzeniu. Na przykład:

Console.WriteLine($"Blog '{blogEntry.Entity.Name}' with key {blogEntry.Entity.Id} is tracked in the '{blogEntry.State}' state.");

Wyszukiwanie jednostki za pomocą dowolnego elementu innego niż klucz podstawowy wymaga określenia nazwy właściwości. Aby na przykład wyszukać klucz alternatywny:

var siteEntry = context.Websites.Local.FindEntry(nameof(Website.Uri), new Uri("https://www.bricelam.net/"))!;

Możesz też wyszukać unikatowy klucz obcy:

var blogAtSiteEntry = context.Blogs.Local.FindEntry(nameof(Blog.SiteUri), new Uri("https://www.bricelam.net/"))!;

Do tej pory wyszukiwania zawsze zwracały pojedynczy wpis lub null. Jednak niektóre wyszukiwania mogą zwracać więcej niż jeden wpis, na przykład podczas wyszukiwania za pomocą klucza obcego, który nie jest unikatowy. Metoda GetEntries powinna być używana dla tych odnośników. Na przykład:

var postEntries = context.Posts.Local.GetEntries(nameof(Post.BlogId), 2);

We wszystkich tych przypadkach wartość używana do wyszukiwania jest kluczem podstawowym, kluczem alternatywnym lub wartością klucza obcego. Program EF używa wewnętrznych struktur danych dla tych odnośników. Jednak wyszukiwania według wartości mogą być również używane dla wartości dowolnej właściwości lub kombinacji właściwości. Aby na przykład znaleźć wszystkie zarchiwizowane wpisy:

var archivedPostEntries = context.Posts.Local.GetEntries(nameof(Post.Archived), true);

To wyszukiwanie wymaga skanowania wszystkich śledzonych Post wystąpień i dlatego będzie mniej wydajne niż wyszukiwanie kluczy. Jednak zwykle jest to nadal szybsze niż naiwne zapytania przy użyciu polecenia ChangeTracker.Entries<TEntity>().

Na koniec można również wykonywać wyszukiwania względem kluczy złożonych, innych kombinacji wielu właściwości lub gdy typ właściwości nie jest znany w czasie kompilacji. Na przykład:

var postTagEntry = context.Set<PostTag>().Local.FindEntryUntyped(new object[] { 4, "TagEF" });

Kompilowanie modelu

Kolumny dyskryminujące mają maksymalną długość

W programie EF8 kolumny dyskryminujące ciągów używane do mapowania dziedziczenia TPH są teraz skonfigurowane z maksymalną długością. Ta długość jest obliczana jako najmniejsza liczba Fibonacciego, która obejmuje wszystkie zdefiniowane wartości dyskryminujące. Rozważmy na przykład następującą hierarchię:

public abstract class Document
{
    public int Id { get; set; }
    public string Title { get; set; }
}

public abstract class Book : Document
{
    public string? Isbn { get; set; }
}

public class PaperbackEdition : Book
{
}

public class HardbackEdition : Book
{
}

public class Magazine : Document
{
    public int IssueNumber { get; set; }
}

Zgodnie z konwencją używania nazw klas dla wartości dyskryminujących możliwe wartości to "PaperbackEdition", "HardbackEdition" i "Magazine", a tym samym kolumna dyskryminująca jest skonfigurowana dla maksymalnej długości 21. Na przykład w przypadku korzystania z programu SQL Server:

CREATE TABLE [Documents] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Discriminator] nvarchar(21) NOT NULL,
    [Isbn] nvarchar(max) NULL,
    [IssueNumber] int NULL,
    CONSTRAINT [PK_Documents] PRIMARY KEY ([Id]),

Napiwek

Liczby Fibonacciego służą do ograniczania liczby wygenerowania migracji w celu zmiany długości kolumny w miarę dodawania nowych typów do hierarchii.

Funkcja DateOnly/TimeOnly obsługiwana w programie SQL Server

Typy DateOnly i TimeOnly zostały wprowadzone na platformie .NET 6 i są obsługiwane dla kilku dostawców baz danych (np. SQLite, MySQL i PostgreSQL) od czasu ich wprowadzenia. W przypadku programu SQL Server najnowsza wersja pakietu Microsoft.Data.SqlClient przeznaczonego dla platformy .NET 6 umożliwiła ErikEJ dodanie obsługi tych typów na poziomie ADO.NET. To z kolei utorowało drogę do obsługi w programie EF8 dla DateOnly i TimeOnly jako właściwości w typach jednostek.

Napiwek

DateOnly można ich TimeOnly używać w programach EF Core 6 i 7 przy użyciu pakietu społecznościowego ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnlyOnly z @ErikEJ.

Rozważmy na przykład następujący model EF dla szkół brytyjskich:

public class School
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly Founded { get; set; }
    public List<Term> Terms { get; } = new();
    public List<OpeningHours> OpeningHours { get; } = new();
}

public class Term
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly FirstDay { get; set; }
    public DateOnly LastDay { get; set; }
    public School School { get; set; } = null!;
}

[Owned]
public class OpeningHours
{
    public OpeningHours(DayOfWeek dayOfWeek, TimeOnly? opensAt, TimeOnly? closesAt)
    {
        DayOfWeek = dayOfWeek;
        OpensAt = opensAt;
        ClosesAt = closesAt;
    }

    public DayOfWeek DayOfWeek { get; private set; }
    public TimeOnly? OpensAt { get; set; }
    public TimeOnly? ClosesAt { get; set; }
}

Napiwek

Pokazany tutaj kod pochodzi z DateOnlyTimeOnlySample.cs.

Uwaga

Ten model reprezentuje tylko brytyjskie szkoły i przechowuje czasy w czasie lokalnym (GMT). Obsługa różnych stref czasowych znacznie komplikuje ten kod. Należy pamiętać, że użycie DateTimeOffset metody nie pomogłoby w tym miejscu, ponieważ czas otwierania i zamykania ma różne przesunięcia w zależności od tego, czy czas letni jest aktywny, czy nie.

Te typy jednostek są mapowe na poniższe tabele podczas korzystania z programu SQL Server. Zwróć uwagę, że DateOnly właściwości są mapowania na date kolumny i TimeOnly właściwości mapowania na time kolumny.

CREATE TABLE [Schools] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [Founded] date NOT NULL,
    CONSTRAINT [PK_Schools] PRIMARY KEY ([Id]));

CREATE TABLE [OpeningHours] (
    [SchoolId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [DayOfWeek] int NOT NULL,
    [OpensAt] time NULL,
    [ClosesAt] time NULL,
    CONSTRAINT [PK_OpeningHours] PRIMARY KEY ([SchoolId], [Id]),
    CONSTRAINT [FK_OpeningHours_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

CREATE TABLE [Term] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [FirstDay] date NOT NULL,
    [LastDay] date NOT NULL,
    [SchoolId] int NOT NULL,
    CONSTRAINT [PK_Term] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Term_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

Zapytania korzystające z funkcji DateOnly i TimeOnly działają w oczekiwany sposób. Na przykład następujące zapytanie LINQ znajduje szkoły, które są obecnie otwarte:

openSchools = await context.Schools
    .Where(
        s => s.Terms.Any(
                 t => t.FirstDay <= today
                      && t.LastDay >= today)
             && s.OpeningHours.Any(
                 o => o.DayOfWeek == dayOfWeek
                      && o.OpensAt < time && o.ClosesAt >= time))
    .ToListAsync();

To zapytanie przekłada się na następujący kod SQL, jak pokazano w ToQueryStringpliku :

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '19:53:40.4798052';

SELECT [s].[Id], [s].[Founded], [s].[Name], [o0].[SchoolId], [o0].[Id], [o0].[ClosesAt], [o0].[DayOfWeek], [o0].[OpensAt]
FROM [Schools] AS [s]
LEFT JOIN [OpeningHours] AS [o0] ON [s].[Id] = [o0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0 AND [t].[LastDay] >= @__today_0) AND EXISTS (
    SELECT 1
    FROM [OpeningHours] AS [o]
    WHERE [s].[Id] = [o].[SchoolId] AND [o].[DayOfWeek] = @__dayOfWeek_1 AND [o].[OpensAt] < @__time_2 AND [o].[ClosesAt] >= @__time_2)
ORDER BY [s].[Id], [o0].[SchoolId]

DateOnly można TimeOnly również używać w kolumnach JSON. Na przykład OpeningHours można zapisać jako dokument JSON, co spowoduje, że dane wyglądają następująco:

Kolumna Wartość
Id 2
Nazwisko Farr High School
Założona 1964-05-01
OtwieranieHours
[
{ "DayOfWeek": "Sunday", "ClosesAt": null, "OpensAt": null },
{ "DayOfWeek": "Monday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Tuesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Wednesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Thursday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Friday", "ClosesAt": "12:50:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Saturday", "ClosesAt": null, "OpensAt": null }
]

Połączenie dwóch funkcji z platformy EF8 umożliwia teraz wykonywanie zapytań o godziny otwarcia przez indeksowanie w kolekcji JSON. Na przykład:

openSchools = await context.Schools
    .Where(
        s => s.Terms.Any(
                 t => t.FirstDay <= today
                      && t.LastDay >= today)
             && s.OpeningHours[(int)dayOfWeek].OpensAt < time
             && s.OpeningHours[(int)dayOfWeek].ClosesAt >= time)
    .ToListAsync();

To zapytanie przekłada się na następujący kod SQL, jak pokazano w ToQueryStringpliku :

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '20:14:34.7795877';

SELECT [s].[Id], [s].[Founded], [s].[Name], [s].[OpeningHours]
FROM [Schools] AS [s]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0
      AND [t].[LastDay] >= @__today_0)
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].OpensAt') AS time) < @__time_2
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].ClosesAt') AS time) >= @__time_2

Na koniec aktualizacje i usunięcia można wykonać za pomocą funkcji śledzenia i zapisywania zmian lub za pomocą polecenia ExecuteUpdate/ExecuteDelete. Na przykład:

await context.Schools
    .Where(e => e.Terms.Any(t => t.LastDay.Year == 2022))
    .SelectMany(e => e.Terms)
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastDay, t => t.LastDay.AddDays(1)));

Ta aktualizacja przekłada się na następujący kod SQL:

UPDATE [t0]
SET [t0].[LastDay] = DATEADD(day, CAST(1 AS int), [t0].[LastDay])
FROM [Schools] AS [s]
INNER JOIN [Term] AS [t0] ON [s].[Id] = [t0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND DATEPART(year, [t].[LastDay]) = 2022)

Inżynier odwrotny synapse i usługa Dynamics 365 TDS

Inżynieria odwrotna EF8 (czyli tworzenie szkieletów z istniejącej bazy danych) obsługuje teraz bezserwerową pulę SQL usługi Synapse i bazy danych punktów końcowych TDS usługi Dynamics 365.

Ostrzeżenie

Te systemy baz danych różnią się od normalnych baz danych SQL Server i Azure SQL Database. Te różnice oznaczają, że nie wszystkie funkcje platformy EF Core są obsługiwane podczas pisania zapytań względem tych systemów baz danych lub wykonywania innych operacji.

Ulepszenia tłumaczeń matematycznych

Ogólne interfejsy matematyczne zostały wprowadzone na platformie .NET 7. Konkretne typy, takie jak double i zaimplementowane, dodają nowe interfejsy API dublujące istniejące funkcje matematykii matematykifloat.

Program EF Core 8 tłumaczy wywołania tych ogólnych interfejsów API matematycznych w LINQ przy użyciu istniejących tłumaczeń SQL dostawców dla i MathMathF. Oznacza to, że możesz wybrać między wywołaniami takimi jak Math.Sin lub double.Sin w zapytaniach EF.

We współpracy z zespołem platformy .NET dodaliśmy dwie nowe ogólne metody matematyczne na platformie .NET 8, które są implementowane na platformie double i float. Są one również tłumaczone na język SQL w programie EF Core 8.

.NET SQL
DegreesToRadians RADIANS
RadianyToDegrees DEGREES

Na koniec pracowaliśmy z Eric Sink w projekcie SQLitePCLRaw, aby umożliwić funkcji matematycznych SQLite w swoich kompilacjach natywnej biblioteki SQLite. Obejmuje to bibliotekę natywną uzyskaną domyślnie podczas instalowania dostawcy EF Core SQLite. Umożliwia to kilka nowych tłumaczeń SQL w LINQ, w tym: Acos, Acosh, Asin, Asinh, Atan, Atan, Atan2, Atanh, Ceiling, Cos, Cosh, DegreesToRadians, Exp, Floor, Log2, Log10, Pow, RadiansToDegrees, Sign, Sinh, Sqrt, Tanh i Truncate.

Sprawdzanie oczekujących zmian modelu

Dodaliśmy nowe dotnet ef polecenie, aby sprawdzić, czy jakiekolwiek zmiany modelu zostały wprowadzone od ostatniej migracji. Może to być przydatne w scenariuszach ciągłej integracji/ciągłego wdrażania, aby upewnić się, że użytkownik lub kolega z zespołu nie zapomnieli dodać migracji.

dotnet ef migrations has-pending-model-changes

Możesz również wykonać to sprawdzanie programowo w aplikacji lub testach przy użyciu nowej dbContext.Database.HasPendingModelChanges() metody.

Ulepszenia tworzenia szkieletów SQLite

SqLite obsługuje tylko cztery typy danych pierwotnych — INTEGER, REAL, TEXT i BLOB. Wcześniej oznaczało to, że w przypadku odwrotnego utworzenia bazy danych SQLite w celu utworzenia szkieletu modelu EF Core wynikowe typy jednostek obejmowały tylko właściwości typu long, , doublestringi byte[]. Dodatkowe typy platformy .NET są obsługiwane przez dostawcę EF Core SQLite przez konwertowanie między nimi a jednym z czterech pierwotnych typów SQLite.

W programie EF Core 8 używamy teraz nazwy formatu danych i typu kolumny oprócz typu SQLite w celu określenia bardziej odpowiedniego typu platformy .NET do użycia w modelu. W poniższych tabelach przedstawiono niektóre przypadki, w których dodatkowe informacje prowadzą do lepszych typów właściwości w modelu.

Nazwa typu kolumny Typ platformy .NET
BOOLEAN byte[]bool
SMALLINT długikrótki
INT długaint
BIGINT długi
CIĄG byte[]string
Format danych Typ platformy .NET
'0.0' ciągdziesiętny
'1970-01-01' ciągDateOnly
'1970-01-01 00:00:00' ciągDateTime
'00:00:00' ciągTimeSpan
'00000000-0000-0000-0000-000000000000' identyfikator GUID ciągu

Wartości usługi Sentinel i wartości domyślne bazy danych

Bazy danych umożliwiają skonfigurowanie kolumn w celu wygenerowania wartości domyślnej, jeśli podczas wstawiania wiersza nie podano żadnej wartości. Można to przedstawić w programie EF przy użyciu HasDefaultValue dla stałych:

b.Property(e => e.Status).HasDefaultValue("Hidden");

Lub HasDefaultValueSql w przypadku dowolnych klauzul SQL:

b.Property(e => e.LeaseDate).HasDefaultValueSql("getutcdate()");

Napiwek

Poniższy kod pochodzi z DefaultConstraintSample.cs.

Aby program EF korzystał z tej funkcji, musi określić, kiedy i kiedy nie wysyłać wartości dla kolumny. Domyślnie program EF używa domyślnego ustawienia CLR jako sentinel. Oznacza to, że gdy wartość Status lub LeaseDate w powyższych przykładach są wartościami domyślnymi CLR dla tych typów, program EF interpretuje, że oznacza to, że właściwość nie została ustawiona, a więc nie wysyła wartości do bazy danych. Działa to dobrze w przypadku typów referencyjnych — na przykład jeśli właściwość to , program EF nie wysyła null do bazy danych, ale raczej nie zawiera żadnej wartości, tak aby używana była domyślna baza danych ("Hidden").nullStatusstring Podobnie dla DateTime właściwości LeaseDateprogram EF nie wstawi wartości domyślnej 1/1/0001 12:00:00 AMCLR , ale zamiast tego pominą tę wartość, tak aby używana była wartość domyślna bazy danych.

Jednak w niektórych przypadkach wartość domyślna CLR jest prawidłową wartością do wstawienia. Program EF8 obsługuje to, zezwalając na zmianę wartości sentinel dla kolumny. Rozważmy na przykład kolumnę całkowitą skonfigurowaną z wartością domyślną bazy danych:

b.Property(e => e.Credits).HasDefaultValueSql(10);

W takim przypadku chcemy, aby nowa jednostka została wstawiona z daną liczbą środków, chyba że nie zostanie określona, w takim przypadku zostanie przypisanych 10 kredytów. Oznacza to jednak, że wstawianie rekordu z zerowymi środkami nie jest możliwe, ponieważ zero jest wartością domyślną CLR, co spowoduje, że program EF nie wyśle żadnej wartości. W programie EF8 można to naprawić, zmieniając wartość sentinel dla właściwości z zera na -1:

b.Property(e => e.Credits).HasDefaultValueSql(10).HasSentinel(-1);

Program EF będzie teraz używać wartości domyślnej bazy danych tylko wtedy, gdy Credits jest ustawiona na -1wartość ; wartość zero zostanie wstawiona jak każda inna kwota.

Często warto to odzwierciedlić w typie jednostki, a także w konfiguracji ef. Na przykład:

public class Person
{
    public int Id { get; set; }
    public int Credits { get; set; } = -1;
}

Oznacza to, że wartość sentinel -1 jest ustawiana automatycznie po utworzeniu wystąpienia, co oznacza, że właściwość rozpoczyna się w stanie "not-set".

Napiwek

Jeśli chcesz skonfigurować domyślne ograniczenie bazy danych do użycia podczas Migrations tworzenia kolumny, ale chcesz, aby program EF zawsze wstawił wartość, skonfiguruj właściwość jako niegenerowaną. Na przykład b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();.

Wartości domyślne bazy danych dla wartości logicznych

Właściwości logiczne stanowią skrajną formę tego problemu, ponieważ wartość domyślna CLR (false) jest jedną z dwóch prawidłowych wartości. Oznacza to, że bool właściwość z domyślnym ograniczeniem bazy danych będzie miała wstawioną wartość tylko wtedy, gdy ta wartość to true. Gdy wartość domyślna bazy danych to false, oznacza to, że gdy wartość właściwości to false, zostanie użyta wartość domyślna bazy danych, czyli false. W przeciwnym razie, jeśli wartość właściwości to true, true zostanie wstawiona. Dlatego gdy wartość domyślna bazy danych to false, kolumna bazy danych kończy się poprawną wartością.

Z drugiej strony, jeśli wartość domyślna bazy danych to true, oznacza to, że gdy wartość właściwości to false, zostanie użyta wartość domyślna bazy danych, czyli true! A gdy wartość właściwości to true, true zostanie wstawiona. Dlatego wartość w kolumnie zawsze kończy się true w bazie danych, niezależnie od tego, jaka jest wartość właściwości.

Program EF8 rozwiązuje ten problem, ustawiając sentinel dla właściwości logicznych na taką samą wartość jak wartość domyślna bazy danych. Oba powyższe przypadki powodują wstawienie poprawnej wartości niezależnie od tego, czy domyślna true jest baza danych, czy false.

Napiwek

Podczas tworzenia szkieletu z istniejącej bazy danych program EF8 analizuje i dołącza proste wartości domyślne do HasDefaultValue wywołań. (Wcześniej wszystkie wartości domyślne były szkieletowe jako nieprzezroczyste HasDefaultValueSql wywołania). Oznacza to, że kolumny logiczne bez wartości null z wartością domyślną lub false stałą bazą true danych nie są już szkieletowe jako dopuszczające wartość null.

Wartości domyślne bazy danych dla wyliczenia

Właściwości wyliczenia mogą mieć podobne problemy z bool właściwościami, ponieważ wyliczenia zwykle mają bardzo mały zestaw prawidłowych wartości, a wartość domyślna CLR może być jedną z tych wartości. Rozważmy na przykład ten typ jednostki i wyliczenie:

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; }
}

public enum Level
{
    Beginner,
    Intermediate,
    Advanced,
    Unspecified
}

Właściwość Level jest następnie konfigurowana z wartością domyślną bazy danych:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate);

W przypadku tej konfiguracji program EF wykluczy wysyłanie wartości do bazy danych, gdy jest ona ustawiona na Level.Beginner, a zamiast tego Level.Intermediate jest przypisywana przez bazę danych. To nie jest to, co było zamierzone!

Problem nie miałby miejsca, gdyby wyliczenie zostało zdefiniowane z wartością "nieznaną" lub "nieokreśloną" jako domyślną bazą danych:

public enum Level
{
    Unspecified,
    Beginner,
    Intermediate,
    Advanced
}

Jednak nie zawsze można zmienić istniejące wyliczenie, więc w ef8 można ponownie określić sentinel. Na przykład powrót do oryginalnego wyliczenia:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate)
    .HasSentinel(Level.Unspecified);

Teraz Level.Beginner zostanie wstawiona normalnie, a wartość domyślna bazy danych będzie używana tylko wtedy, gdy wartość właściwości to Level.Unspecified. Może to być przydatne, aby odzwierciedlić to w samym typie jednostki. Na przykład:

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; } = Level.Unspecified;
}

Używanie pola kopii zapasowej dopuszczającej wartość null

Bardziej ogólnym sposobem obsługi opisanego powyżej problemu jest utworzenie pola kopii zapasowej dopuszczającej wartość null dla właściwości bez wartości null. Rozważmy na przykład następujący typ jednostki z właściwością bool :

public class Account
{
    public int Id { get; set; }
    public bool IsActive { get; set; }
}

Właściwość może mieć pole kopii zapasowej dopuszczającej wartość null:

public class Account
{
    public int Id { get; set; }

    private bool? _isActive;

    public bool IsActive
    {
        get => _isActive ?? false;
        set => _isActive = value;
    }
}

Pole zapasowe pozostanie w tym miejscu null, chyba że zestaw właściwości zostanie rzeczywiście wywołany. Oznacza to, że wartość pola zapasowego jest lepszym wskazaniem, czy właściwość została ustawiona, czy nie niż wartość domyślna CLR właściwości. To działa poza polem z ef, ponieważ ef będzie używać pola zapasowego do odczytywania i zapisywania właściwości domyślnie.

Better ExecuteUpdate i ExecuteDelete

Polecenia SQL, które wykonują aktualizacje i usuwanie, takie jak te generowane przez ExecuteUpdate metody i ExecuteDelete , muszą być przeznaczone dla pojedynczej tabeli bazy danych. Jednak w programie EF7 ExecuteUpdate i ExecuteDelete nie obsługiwał aktualizacji uzyskiwania dostępu do wielu typów jednostek nawet wtedy, gdy zapytanie ostatecznie wpłynęło na jedną tabelę. Program EF8 usuwa to ograniczenie. Rozważmy na przykład typ jednostki z CustomerInfo typem Customer własności:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required CustomerInfo CustomerInfo { get; set; }
}

[Owned]
public class CustomerInfo
{
    public string? Tag { get; set; }
}

Oba typy jednostek są mapowania na tabelę Customers . Jednak następująca aktualizacja zbiorcza kończy się niepowodzeniem w programie EF7, ponieważ używa obu typów jednostek:

await context.Customers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(
        s => s.SetProperty(b => b.CustomerInfo.Tag, "Tagged")
            .SetProperty(b => b.Name, b => b.Name + "_Tagged"));

W programie EF8 ta funkcja przekłada się teraz na następujący kod SQL podczas korzystania z usługi Azure SQL:

UPDATE [c]
SET [c].[Name] = [c].[Name] + N'_Tagged',
    [c].[CustomerInfo_Tag] = N'Tagged'
FROM [Customers] AS [c]
WHERE [c].[Name] = @__name_0

Podobnie wystąpienia zwracane z Union zapytania mogą być aktualizowane tak długo, jak wszystkie aktualizacje są przeznaczone dla tej samej tabeli. Na przykład możemy zaktualizować dowolny Customer element z regionem France, a jednocześnie dowolnym Customer , który odwiedził sklep z regionem France:

await context.CustomersWithStores
    .Where(e => e.Region == "France")
    .Union(context.Stores.Where(e => e.Region == "France").SelectMany(e => e.Customers))
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Tag, "The French Connection"));

W programie EF8 to zapytanie generuje następujące informacje podczas korzystania z usługi Azure SQL:

UPDATE [c]
SET [c].[Tag] = N'The French Connection'
FROM [CustomersWithStores] AS [c]
INNER JOIN (
    SELECT [c0].[Id], [c0].[Name], [c0].[Region], [c0].[StoreId], [c0].[Tag]
    FROM [CustomersWithStores] AS [c0]
    WHERE [c0].[Region] = N'France'
    UNION
    SELECT [c1].[Id], [c1].[Name], [c1].[Region], [c1].[StoreId], [c1].[Tag]
    FROM [Stores] AS [s]
    INNER JOIN [CustomersWithStores] AS [c1] ON [s].[Id] = [c1].[StoreId]
    WHERE [s].[Region] = N'France'
) AS [t] ON [c].[Id] = [t].[Id]

W ostatnim przykładzie w programie EF8 ExecuteUpdate można użyć do aktualizowania jednostek w hierarchii TPT, o ile wszystkie zaktualizowane właściwości są mapowane na tę samą tabelę. Rozważmy na przykład te typy jednostek mapowane przy użyciu TPT:

[Table("TptSpecialCustomers")]
public class SpecialCustomerTpt : CustomerTpt
{
    public string? Note { get; set; }
}

[Table("TptCustomers")]
public class CustomerTpt
{
    public int Id { get; set; }
    public required string Name { get; set; }
}

Za pomocą programu EF8 Note można zaktualizować właściwość:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted"));

Name Można również zaktualizować właściwość:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Name, b => b.Name + " (Noted)"));

Jednak program EF8 nie próbuje zaktualizować właściwości Name i Note , ponieważ są one mapowane na różne tabele. Na przykład:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted")
        .SetProperty(b => b.Name, b => b.Name + " (Noted)"));

Zgłasza następujący wyjątek:

The LINQ expression 'DbSet<SpecialCustomerTpt>()
    .Where(s => s.Name == __name_0)
    .ExecuteUpdate(s => s.SetProperty<string>(
        propertyExpression: b => b.Note,
        valueExpression: "Noted").SetProperty<string>(
        propertyExpression: b => b.Name,
        valueExpression: b => b.Name + " (Noted)"))' could not be translated. Additional information: Multiple 'SetProperty' invocations refer to different tables ('b => b.Note' and 'b => b.Name'). A single 'ExecuteUpdate' call can only update the columns of a single table. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

Lepsze wykorzystanie zapytań IN

Contains Gdy operator LINQ jest używany z podzapytaniem, platforma EF Core generuje teraz lepsze zapytania przy użyciu języka SQL IN zamiast EXISTS; oprócz tworzenia bardziej czytelnego kodu SQL, w niektórych przypadkach może to spowodować znacznie szybsze wykonywanie zapytań. Rozważmy na przykład następujące zapytanie LINQ:

var blogsWithPosts = await context.Blogs
    .Where(b => context.Posts.Select(p => p.BlogId).Contains(b.Id))
    .ToListAsync();

Program EF7 generuje następujące elementy dla bazy danych PostgreSQL:

SELECT b."Id", b."Name"
      FROM "Blogs" AS b
      WHERE EXISTS (
          SELECT 1
          FROM "Posts" AS p
          WHERE p."BlogId" = b."Id")

Ponieważ podzapytywanie odwołuje się do tabeli zewnętrznej Blogs (za pośrednictwem b."Id"), jest to skorelowana podzapytywanie, co oznacza, że Posts podzapytywanie musi być wykonywane dla każdego wiersza w Blogs tabeli. W programie EF8 zamiast tego jest generowany następujący kod SQL:

SELECT b."Id", b."Name"
      FROM "Blogs" AS b
      WHERE b."Id" IN (
          SELECT p."BlogId"
          FROM "Posts" AS p
      )

Ponieważ podzapytywanie Blogsnie odwołuje się już do elementu , można je ocenić raz, co daje ogromne ulepszenia wydajności w większości systemów baz danych. Jednak niektóre systemy baz danych, zwłaszcza sql Server, baza danych jest w stanie zoptymalizować pierwsze zapytanie do drugiego zapytania, aby wydajność była taka sama.

Numeryczne konwersje wierszy dla Usługi SQL Azure/programu SQL Server

Automatyczna optymistyczna współbieżność programu SQL Server jest obsługiwana przy użyciu rowversion kolumn. A rowversion to nieprzezroczysta wartość 8-bajtowa przekazywana między bazą danych, klientem i serwerem. Domyślnie program SqlClient uwidacznia rowversion typy jako byte[], mimo że modyfikowalne typy odwołań są złym dopasowaniem semantyki rowversion . W programie EF8 można łatwo mapować rowversion kolumny na long lub ulong właściwości. Na przykład:

modelBuilder.Entity<Blog>()
    .Property(e => e.RowVersion)
    .HasConversion<byte[]>()
    .IsRowVersion();

Eliminacja nawiasów

Generowanie czytelnego kodu SQL jest ważnym celem dla platformy EF Core. W programie EF8 wygenerowany język SQL jest bardziej czytelny dzięki automatycznej eliminacji niepotrzebnych nawiasów. Na przykład następujące zapytanie LINQ:

await ctx.Customers  
    .Where(c => c.Id * 3 + 2 > 0 && c.FirstName != null || c.LastName != null)  
    .ToListAsync();  

Przekłada się na następującą usługę Azure SQL podczas korzystania z programu EF7:

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ((([c].[Id] * 3) + 2) > 0 AND ([c].[FirstName] IS NOT NULL)) OR ([c].[LastName] IS NOT NULL)

Co zostało ulepszone do następujących w przypadku korzystania z programu EF8:

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ([c].[Id] * 3 + 2 > 0 AND [c].[FirstName] IS NOT NULL) OR [c].[LastName] IS NOT NULL

Konkretna rezygnacja z klauzuli RETURNING/OUTPUT

Program EF7 zmienił domyślną aktualizację SQL, która ma być używana RETURNING/OUTPUT do pobierania kolumn wygenerowanych przez bazę danych. Niektóre przypadki, w których określono, gdzie to nie działa, a więc EF8 wprowadza jawne rezygnacje z tego zachowania.

Na przykład, aby zrezygnować z OUTPUT korzystania z programu SQL Server/dostawcy usługi Azure SQL:

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlOutputClause(false));

Lub zrezygnować z RETURNING korzystania z dostawcy SQLite:

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlReturningClause(false));

Inne drobne zmiany

Oprócz opisanych powyżej ulepszeń wprowadzono wiele mniejszych zmian wprowadzonych w programie EF8. Obejmuje on: