Neuerungen in EF Core 8

EF Core 8.0 (EF8) wurde im November 2023 freigegeben.

Tipp

Sie können alle Beispiele ausführen und debuggen, indem Sie den Beispielcode von GitHub herunterladen. Jeder Abschnitt verweist auf den Quellcode, der für diesen Abschnitt spezifisch ist.

EF8 erfordert das .NET 8 SDK zum Erstellen sowie die .NET 8-Laufzeit für die Ausführung. EF8 wird nicht in früheren .NET-Versionen und nicht unter .NET Framework ausgeführt.

Komplexe Typen verwendende Wertobjekte

In der Datenbank gespeicherte Objekte können in drei allgemeine Kategorien unterteilt werden:

  • Objekte, die unstrukturiert sind und einen einzelnen Wert enthalten. Beispiel: int, Guid, string, IPAddress. Diese werden (etwas leger) als „primitive Typen“ bezeichnet.
  • Objekte, die so strukturiert sind, dass sie mehrere Werte enthalten, und in denen die Identität des Objekts durch einen Schlüsselwert definiert wird. Beispiel: Blog, Post, Customer. Diese werden als „Entitätstypen“ bezeichnet.
  • Objekte, die so strukturiert sind, dass sie mehrere Werte enthalten, das Objekt hat jedoch keine Schlüsseldefinitionsidentität. Platzhalter in einer derartigen Schreibweise sind z.B. Address und Coordinate.

Vor EF8 gab es keine gute Möglichkeit, den dritten Objekttyp zuzuordnen. Eigene Typen können verwendet werden, aber da eigene Typen tatsächlich Entitätstypen sind, basiert ihre Semantik auch dann auf einem Schlüsselwert, wenn dieser Schlüsselwert versteckt ist.

EF8 unterstützt jetzt „komplexe Typen“, um diesen dritten Objekttyp abzudecken. Objekte komplexen Typs:

  • Werden nicht anhand des Schlüsselwerts identifiziert oder nachverfolgt.
  • Müssen als Teil eines Entitätstyps definiert werden. (Mit anderen Worten, Sie können kein DbSet eines komplexen Typs haben.)
  • Können entweder .NET-Werttypen oder Verweistypen sein.
  • Instanzen können von mehreren Eigenschaften gemeinsam genutzt werden.

Ein einfaches Beispiel

Betrachten Sie beispielsweise einen 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 wird dann an drei Stellen in einem einfachen Kunden/Auftrags-Modell verwendet:

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!;
}

Nun erstellen und speichern wir einen Kunden mit seiner Adresse:

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();

Dies führt dazu, dass die folgende Zeile in die Datenbank eingefügt wird:

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

Beachten Sie, dass die komplexen Typen keine eigenen Tabellen erhalten. Stattdessen werden sie in Spalten der Customers-Tabelle inline gespeichert. Dies entspricht dem Tabellenfreigabeverhalten eigener Typen.

Hinweis

Wir planen nicht, zuzulassen, dass komplexe Typen ihrer eigenen Tabelle zugeordnet werden. In einer zukünftigen Version planen wir jedoch, zuzulassen, dass der komplexe Typ in einer einzelnen Spalte als JSON-Dokument gespeichert werden kann. Stimmen Sie für Issue 31252, wenn dies für Sie wichtig ist.

Angenommen, wir möchten eine Bestellung an einen Kunden senden, und die Adresse des Kunden sowohl als Standardabrechnung als auch Versandadresse verwenden. Normalerweise wird hierzu das Address-Objekt aus Customer in Order kopiert. Beispiel:

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

await context.SaveChangesAsync();

Bei komplexen Typen funktioniert dies wie erwartet, und die Adresse wird in die Orders-Tabelle eingefügt:

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

Bisher sagen Sie vielleicht: „Aber ich könnte dies mit eigenen Typen tun!“ Die Semantik des „Entitätstyps“ eigener Typen steht jedoch schnell im Weg. Beispielsweise führt das Ausführen des obigen Codes mit eigenen Typen zu einer Reihe von Warnungen und dann zu einem Fehler:

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

Dies liegt daran, dass eine einzelne Instanz des Address-Entitätstyps (mit demselben ausgeblendeten Schlüsselwert) für drei verschiedene Entitätsinstanzen verwendet wird. Andererseits ist die gemeinsame Nutzung derselben Instanz durch komplexe Eigenschaften zulässig, sodass der Code bei Verwendung komplexer Typen wie erwartet funktioniert.

Konfiguration komplexer Typen

Komplexe Typen müssen im Modell entweder mit Zuordnungsattributen oder durch Aufrufen der ComplexProperty-API in OnModelCreating konfiguriert werden. Komplexe Typen werden nicht nach Konventionen ermittelt.

Beispielsweise kann der Address-Typ mit dem ComplexTypeAttribute konfiguriert werden:

[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; }
}

Oder in 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);
    });
}

Veränderlichkeit

Im obigen Beispiel haben wir dieselbe Address-Instanz verwendet, die an drei Stellen verwendet wurde. Dies ist zulässig und verursacht bei Verwendung komplexer Typen EF Core keine Probleme. Das Freigeben von Instanzen desselben Verweistyps bedeutet jedoch: Wenn ein Eigenschaftswert für die Instanz geändert wird, spiegelt diese Änderung sich in allen drei Verwendungen wider. Ändern Sie also z. B. Line1 der Kundenadresse, und speichern Sie die Änderungen:

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

Dies führt zu der folgenden Aktualisierung der Datenbank, wenn Sie SQL Server verwenden:

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;

Beachten Sie, dass sich alle drei Line1-Spalten geändert haben, da sie alle dieselbe Instanz verwenden. Dies wollen wir in der Regel nicht.

Tipp

Wenn die Bestelladressen automatisch geändert werden sollen, wenn sich die Kundenadresse ändert, sollten Sie die Zuordnung der Adresse als Entitätstyp in Betracht ziehen. Order und Customer können dann über eine Navigationseigenschaft sicher auf dieselbe (jetzt durch einen Schlüssel identifizierte) Adressinstanz verweisen.

Solchen Problemen können Sie einfach begegnen, indem Sie den Typ unveränderlich machen. Tatsächlich ist diese Unveränderlichkeit oft natürlich, wenn ein Typ ein guter Kandidat für einen komplexe Typ ist. So ist es in der Regel sinnvoll, ein komplexes neues Address-Objekt bereitzustellen, und den Rest unverändert zu lassen, anstatt alles umzukrempeln.

Sowohl Verweis- als auch Werttypen können unveränderlich gemacht werden. In den folgenden Abschnitten betrachten wir einige Beispiele.

Verweistypen als komplexe Typen

Unveränderliche Klasse

Im obigen Beispiel haben wir eine einfache, änderbare class verwendet. Um die oben beschriebenen Probleme mit versehentlicher Mutation zu verhindern, können wir die Klasse unveränderlich machen. Beispiel:

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; }
}

Tipp

Mit C# 12 oder höher kann diese Klassendefinition mit einem primären Konstruktor vereinfacht werden:

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;
}

Es ist jetzt nicht möglich, den Line1-Wert für eine vorhandene Adresse zu ändern. Stattdessen müssen wir eine neue Instanz mit dem geänderten Wert erstellen. Beispiel:

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

await context.SaveChangesAsync();

Dieses Mal aktualisiert der Aufruf von SaveChangesAsync nur die Kundenadresse:

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

Beachten Sie: Obwohl das Address-Objekt unveränderlich ist und das gesamte Objekt geändert wurde, verfolgt EF weiterhin Änderungen an den einzelnen Eigenschaften nach, sodass nur die Spalten mit geänderten Werten aktualisiert werden.

Unveränderlicher Datensatz

Mit C# 9 wurden Datensatztypen eingeführt, die das Erstellen und Verwenden unveränderlicher Objekte erleichtern. Beispielsweise kann das Address-Objekt zu einem Datensatztyp gemacht werden:

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; }
}

Tipp

Diese Klassendefinition kann mit einem primären Konstruktor vereinfacht werden:

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

Das Ersetzen des veränderlichen Objekts und das Aufrufen von SaveChanges erfordert jetzt weniger Code:

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

await context.SaveChangesAsync();

Werttypen als komplexe Typen

Veränderliche Struktur

Ein einfacher veränderlicher Werttyp kann als komplexer Typ verwendet werden. Beispielsweise kann Address in C# als struct definiert werden:

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; }
}

Das Kunden-Address-Objekt den Versand- und Abrechnungs-Address-Eigenschaften zuzuweisen, führt dazu, dass jede Eigenschaft eine Kopie der Address erhält, da dies die Funktionsweise von Werttypen ist. Dies bedeutet, dass das Ändern der Address für den Kunden nicht die Versand- oder Abrechnungs-Address-Instanzen ändert, sodass für veränderliche Strukturen nicht dieselben Probleme beim Freigeben von Instanzen auftreten, wie bei veränderlichen Klassen.

Allerdings wird von veränderlichen Strukturen in C# in der Regel abgeraten, also überlegen Sie sich die Verwendung sehr sorgfältig.

Unveränderliche Struktur

Unveränderliche Strukturen funktionieren ebenso gut wie komplexe Typen, genau wie unveränderliche Klassen. Beispielsweise kann Address so definiert werden, dass sie nicht geändert werden kann:

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;
}

Der Code zum Ändern der Adresse sieht jetzt genauso aus wie bei Verwendung einer unveränderlichen Klasse:

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

await context.SaveChangesAsync();

Unveränderlicher Strukturdatensatz

In C# 10 wurden struct record-Typen eingeführt, die das Erstellen und Arbeiten mit unveränderlichen Strukturdatensätzen so leicht machen wie mit unveränderlichen Klassendatensätzen. Beispielsweise können wir Address als unveränderlichen Strukturdatensatz definieren:

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

Der Code zum Ändern der Adresse sieht jetzt genauso aus wie bei Verwendung eines unveränderlichen Klassendatensatzes:

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

await context.SaveChangesAsync();

Geschachtelte komplexe Typen

Ein komplexer Typ kann Eigenschaften anderer komplexer Typen enthalten. Verwenden wir beispielsweise unseren komplexen Typ Address von oben zusammen mit einem komplexen Typ PhoneNumber und schachteln sie beide in einem anderen komplexen Typ:

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; }
}

Wir verwenden hier unveränderliche Datensätze, da dies gut zu den Semantiken unserer komplexen Typen passt, aber die Schachtelung komplexer Typen kann mit jedem beliebigen .NET-Typ erfolgen.

Hinweis

Wir verwenden keinen primären Konstruktor für den Contact-Typ, da EF Core die Constructor Injection der Werte komplexer Typen noch nicht unterstützt. Stimmen Sie für Issue 31621, wenn dies für Sie wichtig ist.

Wir fügen Contact als Eigenschaft von Customer hinzu:

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();
}

Und PhoneNumber als Eigenschaften von 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!;
}

Die Konfiguration geschachtelter komplexer Typen kann mit ComplexTypeAttribute erneut erreicht werden:

[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; }
}

Oder in 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);
        });
}

Abfragen

Eigenschaften komplexer Typen für Entitätstypen werden wie jede andere Nichtnavigationseigenschaft des Entitätstyps behandelt. Dies bedeutet, dass sie immer geladen werden, wenn der Entitätstyp geladen wird. Dies gilt auch für Eigenschaften aller geschachtelten komplexen Typen. Beispiel: Abfragen für einen Kunden:

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

Sie wird jetzt in folgenden SQL-Code übersetzt, wenn Sie SQL Server verwenden:

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

Beachten Sie zwei Dinge bei diesem SQL-Code:

  • Alles wird zurückgegeben, um den Kunden und alle geschachtelten komplexen Contact-, Address- und PhoneNumber-Typen aufzufüllen.
  • Alle Werte komplexer Typen werden als Spalten in der Tabelle für den Entitätstyp gespeichert. Komplexe Typen werden niemals separaten Tabellen zugeordnet.

Projektionen

Komplexe Typen können aus einer Abfrage projiziert werden. Wählen Sie z. B. nur die Versandadresse aus einer Bestellung aus:

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

Wenn Sie SQL Server verwenden, wird dieser Code folgendermaßen übersetzt:

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

Beachten Sie, dass Projektionen komplexer Typen nicht nachverfolgt werden können, da Objekte komplexer Typ keine für die Nachverfolgung verwendbare Identität haben.

Verwendung in Prädikaten

Member komplexer Typen können in Prädikaten verwendet werden. So finden Sie beispielsweise alle Bestellungen, die in eine bestimmte Stadt gehen:

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

Auf SQL Server wird dies in folgenden SQL-Code übersetzt:

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

Eine vollständige Instanz eines komplexen Typs kann auch in Prädikaten verwendet werden. Suchen Sie beispielsweise alle Kunden mit einer bestimmten Telefonnummer:

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();

Wenn Sie SQL Server verwenden, wird dieser Code in den folgenden SQL-Code übersetzt:

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)

Beachten Sie, dass die Gleichheit erfolgt, indem jeder Member des komplexen Typs erweitert wird. Dies entspricht der Tatsache, dass komplexe Typen keinen Schlüssel für Identität haben, und daher ist eine Instanz eines komplexen Typs gleich einer anderen Instanz eines komplexen Typs, wenn und nur wenn alle ihre Member gleich sind. Dies entspricht auch der Gleichheit, die von .NET für Datensatztypen definiert wird.

Manipulation der Werte komplexer Typen

EF8 bietet Zugriff auf die Nachverfolgung von Informationen, z. B. die aktuellen und ursprünglichen Werte komplexer Typen, und ob ein Eigenschaftswert geändert wurde oder nicht. Die komplexen API-Typen sind eine Erweiterung der bereits für Entitätstypen verwendeten Änderungsnachverfolgungs-API.

Die ComplexProperty-Methoden von EntityEntry geben einen Eintrag für ein gesamtes komplexes Objekt zurück. So rufen Sie beispielsweise den aktuellen Wert von Order.BillingAddress ab:

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

Ein Aufruf von Property kann hinzugefügt werden, um auf eine Eigenschaft des komplexen Typs zuzugreifen. So rufen Sie beispielsweise nur den aktuellen Wert der Abrechnungs-PLZ ab:

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

Auf geschachtelte komplexe Typen wird mithilfe geschachtelter Aufrufe von ComplexProperty zugegriffen. So rufen Sie beispielsweise die Stadt aus der geschachtelten Address von Contact von Customer ab:

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

Andere Methoden stehen zum Lesen und Ändern des Zustands zur Verfügung. Beispielsweise kann mit PropertyEntry.IsModified eine Eigenschaft eines komplexen Typs als geändert festgelegt werden:

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

Aktuelle Einschränkungen

Komplexe Typen stellen eine erhebliche Investition im EF-Stapel dar. Wir waren nicht in der Lage, in diesem Release alles zum Laufen zu bringen, aber wir planen, einige der Lücken in einem zukünftigen Release zu schließen. Stimmen Sie unbedingt über die entsprechenden GitHub-Probleme ab (👍), wenn eine dieser Einschränkungen für Sie wichtig ist.

Zu den Einschränkungen komplexer Typen in EF8 gehören:

  • Unterstützung der Sammlungen komplexer Typen. (Issue 31237)
  • Zulassen, dass Eigenschaften komplexer Typen NULL sein dürfen. (Issue 31376)
  • Zuordnen von Eigenschaften komplexer Typen zu JSON-Spalten. (Issue 31252)
  • Constructor Injection für komplexe Typen. (Issue 31621)
  • Hinzufügen der Seed-Datenunterstützung für komplexe Typen. (Issue 31254)
  • Zuordnen der Eigenschaften komplexer Typen für den Cosmos-Anbieter. (Issue 31253)
  • Implementieren komplexer Typen für die Datenbank im Arbeitsspeicher. (Issue 31464)

Primitive Sammlungen

Eine Frage, die bei der Verwendung relationaler Datenbanken immer auftaucht, ist, was mit Sammlungen primitiver Typen zu tun ist. Das heißt, Listen oder Arrays von ganzen Zahlen, Datum/Uhrzeit, Zeichenfolgen usw. Wenn Sie PostgreSQL verwenden, ist es einfach, diese Dinge mithilfe des integrierten Arraytyps von PostgreSQL zu speichern. Für andere Datenbanken gibt es zwei gängige Ansätze:

  • Erstellen Sie eine Tabelle mit einer Spalte für den primitiven Typwert und einer weiteren Spalte, die als Fremdschlüssel fungiert, der jeden Wert mit dem Besitzer der Sammlung verknüpft.
  • Serialisieren Sie die Sammlung primitiver Typen in einen Spaltentyp, der von der Datenbank behandelt wird, z. B. serialisieren Sie in und aus einer Zeichenfolge.

Die erste Option hat in vielen Situationen Vorteile.Diese werden am Ende dieses Abschnitts kurz behandelt. Es handelt sich jedoch nicht um eine natürliche Darstellung der Daten im Modell, und wenn Sie wirklich eine Sammlung eines primitiven Typs haben, kann die zweite Option effektiver sein.

Ab Preview 4 bietet EF8 jetzt eine integrierte Unterstützung für die zweite Option, die JSON als Serialisierungsformat verwendet. JSON eignet sich gut dafür, da moderne relationale Datenbanken integrierte Mechanismen zum Abfragen und Bearbeiten von JSON enthalten, sodass die JSON-Spalte bei Bedarf effektiv als Tabelle behandelt werden kann, ohne dass der Aufwand für das Erstellen einer solchen Tabelle erforderlich ist. Diese gleichen Mechanismen ermöglichen es, JSON in Parametern zu übergeben und dann auf ähnliche Weise wie Tabellenwertparameter in Abfragen zu verwenden. Mehr dazu später.

Tipp

Der hier gezeigte Code stammt aus PrimitiveCollectionsSample.cs.

Eigenschaften primitiver Sammlungen

EF Core kann jede IEnumerable<T>-Eigenschaft , wobei T ein primitiver Typ ist, einer JSON-Spalte in der Datenbank zuordnen. Dies erfolgt durch Konvention für öffentliche Eigenschaften, die sowohl einen Getter als auch einen Setter haben. Beispielsweise werden alle Eigenschaften im folgenden Entitätstyp gemäß Konvention JSON-Spalten zugeordnet:

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; }
}

Hinweis

Was verstehen wir in diesem Zusammenhang unter „primitiver Typ“? Im Wesentlichen etwas, das der Datenbankanbieter zuordnen kann, wobei bei Bedarf eine Wertkonvertierung verwendet wird. Beispielsweise werden im obigen Entitätstyp die Typen int, string, DateTime, DateOnly und bool alle ohne Konvertierung vom Datenbankanbieter verarbeitet. SQL Server verfügt nicht über eine native Unterstützung für ganze Zahlen ohne Vorzeichen oder URIs, aber uint und Uri werden weiterhin als primitive Typen behandelt, da für diese Typen integrierte Wertkonverter vorhanden sind.

Standardmäßig verwendet EF Core einen nicht eingeschränkten Unicode-Zeichenfolgen-Spaltentyp, um den JSON-Code aufzubewahren, da dies bei großen Sammlungen vor Datenverlusten schützt. Bei einigen Datenbanksystemen, z. B. SQL Server, kann die Angabe einer maximalen Länge für die Zeichenfolge jedoch die Leistung verbessern. Dies kann zusammen mit anderen Spaltenkonfigurationen auf normale Weise erfolgen. Beispiel:

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

Oder durch Verwendung von Zuordnungsattributen:

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

Eine Standardspaltenkonfiguration kann für alle Eigenschaften eines bestimmten Typs mithilfe der Konfiguration des Präkonventionenmodells verwendet werden. Beispiel:

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

Abfragen mit primitiven Sammlungen

Sehen wir uns einige der Abfragen an, die Sammlungen primitiver Typen verwenden. Hierfür benötigen wir ein einfaches Modell mit zwei Entitätstypen. Der erste stellt eine öffentliche britische Einrichtung bzw. einen „Pub“ dar:

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();
}

Der Pub-Typ enthält zwei primitive Sammlungen:

  • Beers ist ein Array von Zeichenfolgen, die die in der Kneipe (Pub) verfügbaren Biermarken darstellen.
  • DaysVisited ist eine Liste der Daten, an denen die Kneipe besucht wurde.

Tipp

In einer echten Anwendung wäre es wahrscheinlich sinnvoller, einen Entitätstyp für Bier zu erstellen und eine Biertabelle bereitzustellen. Wir zeigen hier eine primitive Sammlung, um zu veranschaulichen, wie sie funktionieren. Aber denken Sie daran, nur weil Sie etwas als primitive Sammlung modellieren können, bedeutet dies nicht, dass Sie dies unbedingt sollten.

Der zweite Entitätstyp stellt einen Hundespaziergang in britischer Landschaft dar:

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,
}

Wie Pubenthält DogWalk auch eine Sammlung der Besuchsdaten und einen Link zur nächstgelegenen Kneipe, da, wie Sie wissen, der Hund manchmal nach einem langen Spaziergang einen Schluck Bier braucht.

Bei Verwendung dieses Modells ist die erste Abfrage, die wir ausführen, eine einfache Contains-Abfrage, um alle Spaziergänge in einem von mehreren verschiedenen Gebieten (Terrains) zu finden:

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();

Dies wird bereits von den aktuellen Versionen von EF Core per Inlining der zu suchenden Werte übersetzt. Ein Beispiel sehen Sie nachfolgend bei der Verwendung von SQL Server:

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

Diese Strategie funktioniert jedoch nicht gut hinsichtlich der Zwischenspeicherung von Datenbankabfragen. Eine Erörterung dieses Problems finden Sie unter Ankündigung von EF8 Preview 4 im .NET-Blog.

Wichtig

Das Inlining von Werten erfolgt hier so, dass keine Chance auf einen Angriff durch Einschleusung von SQL-Befehlen besteht. Bei der unten beschriebenen Änderung zur Verwendung von JSON geht es um Leistung und nichts mit Sicherheit.

Für EF Core 8 besteht die Standardeinstellung nun darin, die Liste der Gebiete (Terrains) als einzelnen Parameter zu übergeben, der eine JSON-Sammlung enthält. Beispiel:

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

Die Abfrage verwendet OpenJson dann für 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])

Oder json_each für SQLite:

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

Hinweis

OpenJson ist nur ab SQL Server 2016 (Kompatibilitätsgrad 130) und höher verfügbar. Sie können SQL Server mitteilen, dass Sie eine ältere Version verwenden, indem Sie die Kompatibilitätsebene als Teil von UseSqlServer konfigurieren. Beispiel:

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

Versuchen wir es mit einer anderen Art von Contains-Abfrage. In diesem Fall suchen wir in der Spalte nach einem Wert der Parametersammlung. Zum Beispiel, jede Kneipe, die Heineken verkauft:

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

Die vorhandene Dokumentation von Neuerungen in EF7 enthält ausführliche Informationen zu JSON-Zuordnungen, -Abfragen und -Updates. Diese Dokumentation gilt jetzt auch für SQLite.

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

OpenJson wird jetzt verwendet, um Werte aus der JSON-Spalte zu extrahieren, sodass jeder Wert mit dem übergebenen Parameter abgeglichen werden kann.

Wir können die Verwendung von OpenJson für den Parameter mit OpenJson für die Spalte kombinieren. Um beispielsweise Kneipen (Pubs) zu finden, die eines der verschiedenen Lager-Biere anbietet:

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();

Auf SQL Server wird dies wie folgt übersetzt:

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

Der @__beers_0-Parameterwert hier ist ["Carling","Heineken","Stella Artois","Carlsberg"].

Sehen wir uns eine Abfrage an, die die Spalte verwendet, die eine Sammlung von Datumsangaben enthält. So finden Sie z. B. die in diesem Jahr besuchten Kneipen:

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

Auf SQL Server wird dies wie folgt übersetzt:

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)

Beachten Sie, dass die Abfrage hier die datumsspezifische Funktion DATEPART verwendet, da EF weiß, dass die primitive Sammlung Datumsangaben enthält. Es scheint nicht so zu sein, aber dies ist wirklich wichtig. Da EF weiß, was sich in der Sammlung befindet, kann es geeignete SQL generieren, um die typisierten Werte mit Parametern, Funktionen, anderen Spalten usw. zu verwenden.

Wir sollten die Datumssammlung erneut verwenden, um die aus der Sammlung extrahierten Typ- und Projektwerte entsprechend zu sortieren. Lassen Sie uns beispielsweise Kneipen in der Reihenfolge auflisten, in der sie zuerst besucht wurden, und mit dem ersten und letzten Datum, an dem jede Kneipe besucht wurde:

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();

Auf SQL Server wird dies wie folgt übersetzt:

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

Und schließlich, wie oft besuchen wir die nächstgelegene Kneipe, wenn wir mit dem Hund spazieren gehen? Lassen Sie es uns herausfinden:

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();

Auf SQL Server wird dies wie folgt übersetzt:

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]

Folgende Daten werden angezeigt:

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.

Es sieht so aus, als seien Bier und mit dem Hund spazieren gehen eine gewinnbringende Kombination!

Primitive Sammlungen in JSON-Dokumenten

In allen obigen Beispielen enthält die Spalte für die primitive Sammlung JSON. Dies ist jedoch nicht dasselbe wie das Zuordnen eines eigenen Entitätstyps zu einer Spalte, die ein JSON-Dokument enthält, das in EF7 eingeführt wurde. Was aber, wenn dieses JSON-Dokument selbst eine primitive Sammlung enthält? Nun, alle obigen Abfragen funktionieren immer noch auf die gleiche Weise! Stellen Sie sich beispielsweise vor, wir verschieben die Besuchstage in einen eigenen Typ Visits , der einem JSON-Dokument zugeordnet ist:

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!;
}

Tipp

Der hier gezeigte Code stammt aus PrimitiveCollectionsSample.cs.

Wir können nun eine Variante unserer endgültigen Abfrage ausführen, die diesmal Daten aus dem JSON-Dokument extrahiert, einschließlich Abfragen in die primitiven Sammlungen, die im Dokument enthalten sind:

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();

Auf SQL Server wird dies wie folgt übersetzt:

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]

Und in eine ähnliche Abfrage bei Verwendung von 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"

Tipp

Beachten Sie, dass EF Core in SQLite jetzt den Operator ->> verwendet, was zu Abfragen führt, die sowohl einfacher zu lesen als auch häufig leistungsstärker sind.

Zuordnen von primitiven Sammlungen zu einer Tabelle

Wie zuvor bereits erwähnt, besteht eine weitere Option für primitive Sammlungen darin, sie einer anderen Tabelle zuzuordnen. Die erstklassige Unterstützung hierfür wird durch das Problem #25163 nachverfolgt. Achten Sie darauf, für dieses Problem zu stimmen, wenn es für Sie wichtig ist. Bis dies implementiert ist, besteht der beste Ansatz darin, einen Umschließungstyp für das primitive Element zu erstellen. Erstellen wir beispielsweise einen Typ für Beer:

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

    public string Name { get; private set; }
}

Beachten Sie, dass der Typ einfach den primitiven Wert umschließt. Es wurde kein Primärschlüssel oder Fremdschlüssel definiert. Dieser Typ kann dann in der Pub-Klasse verwendet werden:

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();
}

EF erstellt nun eine Beer-Tabelle, wobei Primärschlüssel- und Fremdschlüsselspalten zurück in die Pubs-Tabelle synthetisiert werden. Beispielsweise unter 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

Verbesserungen der JSON-Spaltenzuordnung

EF8 enthält Verbesserungen an der Unterstützung für die JSON-Spaltenzuordnung, die in EF7 eingeführt wurde.

Tipp

Der hier gezeigte Code stammt aus JsonColumnsSample.cs.

Übersetzen des Elementzugriffs in JSON-Arrays

EF8 unterstützt die Indizierung in JSON-Arrays beim Ausführen von Abfragen. Die folgende Abfrage überprüft beispielsweise, ob die ersten beiden Updates vor einem bestimmten Datum vorgenommen wurden.

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();

Wenn Sie SQL Server verwenden, wird dieser Code in den folgenden SQL-Code übersetzt:

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

Hinweis

Diese Abfrage wird auch dann erfolgreich ausgeführt, wenn ein bestimmter Beitrag keine Updates enthält oder nur über ein einzelnes Update verfügt. In diesem Fall gibt JSON_VALUENULL zurück, und das Prädikat stimmt nicht überein.

Die Indizierung in JSON-Arrays kann auch verwendet werden, um Elemente aus einem Array in die endgültigen Ergebnisse zu projizieren. Die folgende Abfrage projiziert beispielsweise das UpdatedOn-Datum für das erste und zweite Update der einzelnen Beiträge.

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();

Wenn Sie SQL Server verwenden, wird dieser Code in den folgenden SQL-Code übersetzt:

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]

Wie oben erwähnt, gibt JSON_VALUE NULL zurück, wenn das Element des Arrays nicht vorhanden ist. Dies wird in der Abfrage behandelt, indem der projizierte Wert in eine Nullwerte zulassende DateOnly umgewandelt wird. Eine Alternative zum Umwandeln des Werts besteht darin, die Abfrageergebnisse zu filtern, sodass JSON_VALUE niemals NULL zurückgibt. Zum Beispiel:

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();

Wenn Sie SQL Server verwenden, wird dieser Code in den folgenden SQL-Code übersetzt:

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)

Übersetzen Sie Abfragen in eingebettete Sammlungen

EF8 unterstützt Abfragen für Auflistungen von primitiven (oben erläuterten) und nicht-primitiven Typen, die in das JSON-Dokument eingebettet sind. Die folgende Abfrage gibt zum Beispiel alle Beiträge mit einem beliebigen Suchbegriff aus einer Liste wieder:

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();

Wenn Sie SQL Server verwenden, wird dieser Code in den folgenden SQL-Code übersetzt:

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

JSON-Spalten für SQLite

In EF7 wurde die Unterstützung für die Zuordnung zu JSON-Spalten bei Verwendung von Azure SQL / SQL Server eingeführt. EF8 erweitert diese Unterstützung auf SQLite-Datenbanken. Was den SQL Server-Support angeht, so umfasst dies Folgendes:

  • Zuordnung von Aggregaten aus .NET-Typen zu JSON-Dokumenten, die in SQLite-Spalten gespeichert sind
  • Abfragen in JSON-Spalten, z. B. Filtern und Sortieren nach den Elementen der Dokumente
  • Abfragen, die Elemente aus dem JSON-Dokument in Ergebnisse projizieren
  • Aktualisieren und Speichern von Änderungen an JSON-Dokumenten

Die vorhandene Dokumentation von Neuerungen in EF7 enthält ausführliche Informationen zu JSON-Zuordnungen, -Abfragen und -Updates. Diese Dokumentation gilt jetzt auch für SQLite.

Tipp

Der in der EF7-Dokumentation gezeigte Code wurde so aktualisiert, dass er auch auf SQLite ausgeführt werden kann. Sie finden ihn in JsonColumnsSample.cs.

Abfragen in JSON-Spalten

Abfragen in JSON-Spalten in SQLite verwenden die json_extract-Funktion. Beispielsweise wird die Abfrage zu „Autoren in Chigley“ aus der Dokumentation, auf die oben verwiesen wird:

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

Jetzt in folgenden SQL-Code übersetzt, wenn Sie SQLite verwenden:

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

Aktualisieren von JSON-Spalten

Für Updates verwendet EF die json_set-Funktion in SQLite. Wenn Sie beispielsweise eine einzelne Eigenschaft in einem Dokument aktualisieren:

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

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

await context.SaveChangesAsync();

Generiert EF die folgenden Parameter:

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']

Die die json_setFunktion in SQLite verwenden:

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

HierarchyId in .NET und EF Core

Azure SQL und SQL Server verfügen über einen speziellen Datentyp namenshierarchyid, der zum Speichern hierarchischer Daten verwendet wird. In diesem Fall sind „hierarchische Daten“ im Wesentlichen Daten, die eine Struktur bilden, in der jedes Element ein übergeordnetes Element und/oder untergeordnete Elemente aufweisen kann. Beispiele für solche Daten sind:

  • Eine Organisationsstruktur
  • Ein Dateisystem
  • Eine Gruppe von Aufgaben in einem Projekt.
  • Eine Taxonomie sprachlicher Termini
  • Ein Diagramm der Links zwischen Webseiten

Die Datenbank kann dann Abfragen für diese Daten mithilfe ihrer hierarchischen Struktur ausführen. Beispielsweise kann eine Abfrage über- und untergeordnete Elemente von bestimmten Elementen oder alle Elemente in einer bestimmten Tiefe in der Hierarchie finden.

Unterstützung in .NET und EF Core

Die offizielle Unterstützung für den SQL Server-Typ hierarchyid wurde erst seit Kurzem für moderne .NET-Plattformen (z. B. für „.NET Core“) bereitgestellt. Diese Unterstützung erfolgt in Form des NuGet-Pakets Microsoft.SqlServer.Types, das SQL Server-spezifische Low-Level-Typen einbringt. In diesem Fall wird der Low-Level-Typ als SqlHierarchyId bezeichnet.

Auf der nächsten Ebene wurde ein neues Microsoft.EntityFrameworkCore.SqlServer.Abstractions-Paket eingeführt, das einen HierarchyId-Typ höherer Ebene enthält, der für die Verwendung in Entitätstypen vorgesehen ist.

Tipp

Der HierarchyId-Typ ist für die Normen von .NET idiomatischer als SqlHierarchyId. Dieser Typ wird stattdessen danach modelliert, wie .NET Framework-Typen innerhalb der Datenbank-Engine von SQL Server gehostet sind. HierarchyId ist für die Verwendung mit EF Core konzipiert, kann aber auch außerhalb von EF Core in anderen Anwendungen verwendet werden. Das Microsoft.EntityFrameworkCore.SqlServer.Abstractions-Paket verweist nicht auf andere Pakete und hat daher minimale Auswirkungen auf die Größe und Abhängigkeiten der bereitgestellten Anwendungen.

Für die Verwendung von HierarchyId für EF Core-Funktionen wie Abfragen und Updates ist das Paket Microsoft.EntityFrameworkCore.SqlServer.HierarchyId-Paket erforderlich. Dieses Paket enthält Microsoft.EntityFrameworkCore.SqlServer.Abstractions und Microsoft.SqlServer.Types als transitive Abhängigkeiten und ist daher oft das einzige Paket, das benötigt wird. Nachdem das Paket installiert wurde, wird die Verwendung von HierarchyId aktiviert, indem UseHierarchyId als Teil des Aufrufs der Anwendung von UseSqlServer aufgerufen wird. Beispiel:

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

Hinweis

Inoffizielle Unterstützung für hierarchyid in EF Core ist seit vielen Jahren über das Paket EntityFrameworkCore.SqlServer.HierarchyId verfügbar. Dieses Paket wurde als Zusammenarbeit zwischen der Community und dem EF-Team beibehalten. Da es nun offizielle Unterstützung für hierarchyid in .NET gibt, bildet der Code aus diesem Community-Paket mit der Berechtigung der ursprünglichen Mitwirkenden die Grundlage für das hier beschriebene offizielle Paket. Vielen Dank an alle über die Jahre Beteiligten, darunter @aljones, @cutig3r, @huan086, @kmataru, @mehdihaghshenas und @vyrotek.

Modellieren von Hierarchien

Der HierarchyId-Typ kann für Eigenschaften eines Entitätstyps verwendet werden. Angenommen, wir möchten den väterlichen Stammbaum einiger fiktiver Halblinge modellieren. Im Entitätstyp für Halflingkann eine HierarchyId-Eigenschaft verwendet werden, um jeden Halbling im Stammbaum zu finden.

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; }
}

Tipp

Der hier und in den folgenden Beispielen gezeigte Code stammt von HierarchyIdSample.cs.

Tipp

Falls gewünscht, eignet sich HierarchyId für die Verwendung als Schlüsseleigenschaftstyp.

In diesem Fall ist der Stammbaum mit dem Patriarchen der Familie verwurzelt. Jeder Halbling kann vom Patriarchen über den Stammbaum mithilfe seiner PathFromPatriarch-Eigenschaft nachverfolgt werden. SQL Server verwendet für diese Pfade ein kompaktes Binärformat. Es ist jedoch üblich, beim Arbeiten mit Code in und aus einer für Menschen lesbaren Zeichenfolgendarstellung zu analysieren. In dieser Darstellung wird die Position auf jeder Ebene durch ein /-Zeichen getrennt. Betrachten Sie beispielsweise den Stammbaum im folgenden Diagramm:

Halbling-Stammbaum

In dieser Struktur:

  • Balbo befindet sich ganz oben in der Struktur, dargestellt durch /.
  • Balbo hat fünf untergeordnete Elemente: /1/, /2/, /3/, /4/und /5/.
  • Mungo, Balbos erstes untergeordnetes Element, hat auch fünf untergeordnete Elemente: /1/1/, /1/2/, /1/3/, /1/4/und /1/5/. Beachten Sie, dass die HierarchyId für Balbo (/1/) das Präfix für alle untergeordneten Elemente ist.
  • Ebenso hat Ponto, Balbos drittes untergeordnetes Element, zwei untergeordnete Elemente: /3/1/ und /3/2/. Auch hier wird jedem dieser untergeordneten Elemente die HierarchyId für Ponto vorangestellt, dargestellt als /3/.
  • Und immer so weiter in der Struktur...

Der folgende Code fügt diesen Stammbaum mithilfe von EF Core in eine Datenbank ein:

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();

Tipp

Bei Bedarf können Dezimalwerte verwendet werden, um neue Knoten zwischen zwei vorhandenen Knoten zu erstellen. Beispielsweise würde /3/2.5/2/ zwischen /3/2/2/ und /3/3/2/ positioniert.

Abfragen von Hierarchien

HierarchyId stellt mehrere Methoden zur Verfügung, die in LINQ-Abfragen verwendet werden können.

Methode BESCHREIBUNG
GetAncestor(int n) Ruft die -Ebenen des n-Knotens aufsteigend in der hierarchischen Struktur ab.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Ruft den Wert eines -Nachfolgerknotens ab, der größer als child1 und kleiner als child2 ist.
GetLevel() Ruft die Ebene dieses Knotens in der hierarchischen Struktur ab.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Ruft einen Wert ab, der die Position eines neuen Knotens darstellt, der über einen Pfad von newRoot verfügt, der dem Pfad von oldRoot bis dahin entspricht. Dabei wird effektiv an die neue Position verschoben.
IsDescendantOf(HierarchyId? parent) Ruft einen Wert ab, der angibt, ob dieser Knoten ein Nachfolger von parent ist.

Darüber hinaus können die Operatoren ==, !=, <, <=, > und >= verwendet werden.

Im Folgenden sind Beispiele für die Verwendung dieser Methoden in LINQ-Abfragen aufgeführt.

Abrufen von Entitäten auf einer bestimmten Ebene in der Struktur

Die folgende Abfrage verwendet GetLevel, um alle Halblinge auf einer bestimmten Ebene in der Struktur zurückzugeben:

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

Dies entspricht der folgenden SQL-Syntax:

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

Wenn Sie dies in einer Schleife ausführen, können Sie die Halblinge für jede Generation abrufen:

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

Abrufen des direkten Vorgängers einer Entität

Die folgende Abfrage verwendet GetAncestor, um den direkten Vorgänger eines Halblings unter Berücksichtigung des Namens dieses Halblings zu finden:

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

Dies entspricht der folgenden SQL-Syntax:

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)

Die Ausführung dieser Abfrage für den Halbling „Bilbo“ gibt „Bungo“ zurück.

Abrufen der direkten Nachfolger einer Entität

Die folgende Abfrage verwendet auch GetAncestor, aber dieses Mal, um die direkten Nachfolger eines Halblings zu finden, wenn der Name dieses Halblings gegeben ist:

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

Dies entspricht der folgenden SQL-Syntax:

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)

Die Ausführung dieser Abfrage für den Halbling „Mungo“ gibt „Bungo“, „Belba“, „Longo“ und „Linda“ zurück.

Abrufen aller Vorgänger einer Entität

GetAncestor ist nützlich, um eine einzelne Ebene oder eine bestimmte Anzahl von Ebenen nach oben oder unten zu durchsuchen. Auf der anderen Seite ist IsDescendantOf nützlich, um alle Vorfahren oder Nachfolger zu finden. Die folgende Abfrage verwendet zum Beispiel IsDescendantOf, um alle Vorfahren eines Halblings zu finden, wobei der Name des Halblings angegeben wird:

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());

Wichtig

IsDescendantOf gibt true für sich selbst zurück, weshalb es in der obigen Abfrage herausgefiltert wird.

Dies entspricht der folgenden SQL-Syntax:

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

Die Ausführung dieser Abfrage für den Halbling „Bilbo“ gibt „Bungo“, „Mungo“ und „Balbo“ zurück.

Abrufen aller Nachfolger einer Entität

Die folgende Abfrage verwendet auch IsDescendantOf, aber dieses Mal, um alle Nachfolger eines Halblings zu finden, wobei der Name des Halblings angegeben wird:

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());

Dies entspricht der folgenden SQL-Syntax:

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

Die Ausführung dieser Abfrage für den Halbling „Mungo“ gibt „Bungo“, „Belba“, „Longo“, „Linda“, „Bingo“, „Bilbo“, „Otho“, „Falco“, „Lotho“ und „Poppy“ zurück.

Suchen nach einem gemeinsamen Vorfahren

Eine der häufigsten Fragen zu diesem speziellen Stammbaum ist: „Wer ist der gemeinsame Vorfahre von Frodo und Bilbo?“ Wir können IsDescendantOf verwenden, um eine solche Abfrage zu erstellen:

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();

Dies entspricht der folgenden SQL-Syntax:

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

Die Ausführung dieser Abfrage mit „Bilbo“ und „Frodo“ teilt uns mit, dass deren gemeinsamer Vorfahre „Balbo“ ist.

Aktualisieren von Hierarchien

Die normalen Änderungsnachverfolgungs- und SaveChanges-Mechanismen können verwendet werden, um hierarchyid-Spalten zu aktualisieren.

Neueinordnung einer Unterhierarchie

Zum Beispiel erinnern wir uns alle an den Skandal von SR 1752 (auch als „LongoGate“ bekannt), als DNA-Tests ergaben, dass Longo nicht der Sohn von Mungo, sondern tatsächlich der Sohn von Ponto ist! Eine Folge dieses Skandals war, dass der Stammbaum neu geschrieben werden musste. Insbesondere mussten Longo und alle seine Nachkommen von Mungo nach Ponto neu eingeordnet werden. GetReparentedValue kann dazu verwendet werden. Beispielsweise werden zuerst „Longo“ und alle seine Nachfolger abgefragt:

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

Anschließend wird GetReparentedValue verwendet, um die HierarchyId für Longo und jeden Nachfolger zu aktualisieren, gefolgt von einem Aufruf von SaveChangesAsync:

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

await context.SaveChangesAsync();

Dies führt zu der folgenden Aktualisierung der Datenbank:

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;

Unter Verwendung dieser Parameter:

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

Hinweis

Die Parameterwerte für die HierarchyId-Eigenschaften werden in ihrem kompakten Binärformat an die Datenbank gesendet.

Nach dem Update gibt die Abfrage nach den Nachfolgern von „Mungo“ die Namen „Bungo“, „Belba“, „Linda“, „Bingo“, „Bilbo“, „Falco“ und „Poppy“ zurück, während die Abfrage nach den Nachfolgern von "Ponto" die Namen „Longo“, „Rosa“, „Polo“, „Otho“, „Posco“, „Prisca“, „Lotho“, „Ponto“, „Porto“, „Peony“ und „Angelica“ zurückgibt.

Unformatierte SQL-Abfragen für nicht zugeordnete Typen

EF7 hat unformatierte SQL-Abfragen eingeführt, die skalare Typen zurückgeben. Dies wird in EF8 optimiert, um unformatierte SQL-Abfragen einzuschließen, die einen zugeordneten CLR-Typ zurückgeben, ohne diesen Typ in das EF-Modell einzuschließen.

Tipp

Der hier gezeigte Code stammt aus RawSqlSample.cs.

Abfragen mit nicht zugeordneten Typen werden mit den Methoden SqlQuery oder SqlQueryRaw ausgeführt. Erstere verwendet die Zeichenfolgeninterpolation, um die Abfrage zu parametrisieren, wodurch sichergestellt wird, dass alle nicht konstanten Werte parametrisiert werden. Betrachten Sie z. B. die folgende Datenbanktabelle:

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 kann verwendet werden, um diese Tabelle abzufragen und Instanzen eines BlogPost-Typs mit Eigenschaften zurückzugeben, die den Spalten in dieser Tabelle entsprechen:

Zum Beispiel:

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; }
}

Beispiel:

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();

Diese Abfrage wird parametrisiert und ausgeführt als:

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

Der Typ, der für Abfrageergebnisse verwendet wird, kann allgemeine Zuordnungskonstrukte enthalten, die von EF Core unterstützt werden, z. B. parametrisierte Konstruktoren und Zuordnungsattribute. Zum Beispiel:

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; }
}

Hinweis

Die auf diese Weise verwendeten Typen verfügen über keine definierten Schlüssel und können keine Beziehungen zu anderen Typen haben. Typen mit Beziehungen müssen im Modell zugeordnet werden.

Der verwendete Typ muss über eine Eigenschaft für jeden Wert im Resultset verfügen, muss aber nicht mit einer Tabelle in der Datenbank übereinstimmen. Der folgende Typ stellt beispielsweise nur eine Teilmenge von Informationen für jeden Beitrag dar und enthält den Blognamen, der aus der Tabelle Blogs stammt:

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

Dieser kann auch mit SqlQuery auf die gleiche Weise wie zuvor abgefragt werden:


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();

Ein tolles Feature von SqlQuery ist, dass die Schnittstelle IQueryable zurückgegeben wird, die mithilfe von LINQ erstellt werden kann. Beispielsweise kann der obigen Abfrage eine „Where“-Klausel hinzugefügt werden:

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();

Diese wird wie folgt ausgeführt:

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

Ah diesem Punkt ist erwähnenswert, dass alle oben genannten Schritte vollständig in LINQ ausgeführt werden können, ohne dass SQL-Code geschrieben werden muss. Dies umfasst das Zurückgeben von Instanzen eines nicht zugeordneten Typs wie PostSummary. Die vorherige Abfrage kann beispielsweise in LINQ wie folgt geschrieben werden:

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();

Dies bedeutet viel sauberere 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

Tipp

EF kann ein saubereres SQL generieren, wenn es für die gesamte Abfrage verantwortlich ist, anders als beim Verfassen über vom Benutzer bereitgestelltes SQL, da im früheren Fall die vollständige Semantik der Abfrage für EF verfügbar war.

Bisher wurden alle Abfragen direkt für Tabellen ausgeführt. SqlQuery kann auch verwendet werden, um Ergebnisse aus einer Ansicht zurückzugeben, ohne den Ansichtstyp im EF-Modell zuzuordnen. Zum Beispiel:

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

Ebenso kann SqlQuery für die Ergebnisse einer Funktion verwendet werden:

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

Die zurückgegebene IQueryable kann zusammengesetzt werden, wenn sie das Ergebnis einer Ansicht oder Funktion ist, genauso wie für das Ergebnis einer Abfragetabelle. Gespeicherte Prozeduren können auch mithilfe von SqlQuery ausgeführt werden, aber die meisten Datenbanken unterstützen das Zusammensetzen darüber nicht. Beispiel:

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

Verbesserungen beim Lazy Loading

Verzögertes Laden für Abfragen ohne Nachverfolgung

EF8 bietet Unterstützung für das verzögerte Laden von Navigationen für Entitäten, die nicht von DbContextnachverfolgt werden. Dies bedeutet, dass auf eine Abfrage ohne Nachverfolgung das verzögerte Laden von Navigationen auf die Entitäten folgen kann, die von der Abfrage ohne Nachverfolgung zurückgegeben werden.

Tipp

Der Code für die unten gezeigten Beispiele für verzögertes Laden stammt aus LazyLoadingSample.cs.

Betrachten Sie z. B. eine Abfrage ohne Nachverfolgung für Blogs:

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

Wenn Blog.Posts für das verzögerte Laden konfiguriert ist, z. B. mithilfe von Proxys mit verzögertem Ladevorgang, führt der Zugriff auf Posts dazu, dass sie aus der Datenbank geladen werden.

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}");
    }
}

EF8 meldet auch, ob eine bestimmte Navigation für Entitäten geladen wird, die nicht vom Kontext nachverfolgt werden. Zum Beispiel:

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

Es gibt ein paar wichtige Überlegungen bei der Verwendung des verzögerten Ladens auf diese Art:

  • Das verzögerte Laden ist nur erfolgreich, bis der für die Abfrage der Entität verwendete DbContext freigegeben wird.
  • Entitäten, die auf diese Weise abgefragt werden, behalten einen Verweis auf ihr DbContext, auch wenn sie nicht von ihr nachverfolgt werden. Speicherverluste sollten unbedingt vermieden werden, wenn die Entitätsinstanzen eine lange Lebensdauer haben.
  • Durch das explizite Trennen der Entität durch Festlegen des Zustands auf EntityState.Detached wird der Verweis auf die DbContext unterbrochen, und das verzögerte Laden funktioniert nicht mehr.
  • Denken Sie daran, dass bei alle verzögerten Ladevorgänge synchrone E/A-Vorgänge verwenden, da es keine Möglichkeit gibt, auf asynchrone Weise auf eine Eigenschaft zuzugreifen.

Verzögertes Laden von nicht nachverfolgten Entitäten funktioniert sowohl für Proxys mit verzögertem Laden als auch für verzögertes Laden ohne Proxys.

Explizites Laden aus nicht nachverfolgten Entitäten

EF8 unterstützt das Laden von Navigationen auf nicht nachverfolgten Entitäten, auch wenn die Entität oder Navigation nicht für das verzögerte Laden konfiguriert ist. Im Gegensatz zum verzögerten Laden kann das explizite Laden asynchron ausgeführt werden. Zum Beispiel:

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

Deaktivieren des verzögerten Ladens für bestimmte Navigationen

EF8 ermöglicht die Konfiguration bestimmter Navigationen, sodass diese nicht verzögert geladen werden, auch wenn alles andere dafür eingerichtet ist. Gehen Sie wie folgt vor, um die Post.Author-Navigation so zu konfigurieren, dass sie nicht verzögert geladen wird:

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

Das Deaktivieren des verzögerten Ladens funktioniert wie folgt für Proxys mit verzögertem Laden als auch verzögertes Laden ohne Proxys.

Proxys mit verzögertem Laden funktionieren, indem virtuelle Navigationseigenschaften außer Kraft gesetzt werden. In klassischen EF6-Anwendungen wird aufgrund einer klassischen Fehlerquelle verhindert, dass die Navigation virtuell gemacht wird, da diese dann im Hintergrund nicht verzögert lädt. Daher werden EF Core-Proxys standardmäßig ausgelöst, wenn eine Navigation nicht virtuell ist.

Dieses Verhalten kann in EF8 in das klassische EF6-Verhalten geändert werden, damit für eine Navigation eingestellt werden kann, nicht verzögert zu laden, indem sie ganz einfach nicht virtuell gemacht wird. Diese Aktivierung wird als Teil des Aufrufs von UseLazyLoadingProxies konfiguriert. Beispiel:

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

Zugriff auf nachverfolgte Entitäten

Suche nachverfolgter Entitäten anhand eines primären oder alternativen Schlüssels oder Fremdschlüssels

Intern verwaltet EF Datenstrukturen zum Auffinden nachverfolgter Entitäten anhand eines primären oder alternativen Schlüssels oder Fremdschlüssels. Diese Datenstrukturen werden zur effizienten Fehlerbehebung zwischen verwandten Entitäten verwendet, wenn neue Entitäten nachverfolgt oder Beziehungen geändert werden.

EF8 enthält neue öffentliche APIs, sodass Anwendungen diese Datenstrukturen jetzt verwenden können, um nachverfolgte Entitäten effizient zu suchen. Auf diese APIs wird über die LocalView<TEntity> des Entitätstyps zugegriffen. So können Sie beispielsweise eine nachverfolgte Entität anhand des Primärschlüssels auffinden:

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

Tipp

Der hier gezeigte Code stammt aus LookupByKeySample.cs.

Die FindEntry-Methode gibt entweder die EntityEntry<TEntity> für die nachverfolgte Entität oder null zurück, wenn keine Entität mit dem angegebenen Schlüssel nachverfolgt wird. Wie alle Methoden für LocalView wird die Datenbank nie abgefragt, auch wenn die Entität nicht gefunden wird. Der zurückgegebene Eintrag enthält die Entität selbst sowie Nachverfolgungsinformationen. Zum Beispiel:

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

Das Nachschlagen einer Entität durch etwas anderes als einen Primärschlüssel erfordert, dass der Eigenschaftsname angegeben wird. So suchen Sie beispielsweise anhand eines alternativen Schlüssels:

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

Alternativ können Sie auch anhand eines Fremdschlüssels suchen:

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

Bisher haben die Nachschlagevorgänge immer einen einzelnen Eintrag oder null zurückgegeben. Einige Nachschlagevorgänge können jedoch mehr als einen Eintrag zurückgeben, z. B. beim Suchen anhand eines nicht eindeutigen Fremdschlüssels. Die Methode GetEntries sollte für diese Nachschlagevorgänge verwendet werden. Zum Beispiel:

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

In all diesen Fällen ist der Wert, der für die Suche verwendet wird, entweder ein Primärschlüssel, ein alternativer Schlüssel oder ein Fremdschlüsselwert. EF verwendet seine internen Datenstrukturen für diese Nachschlagevorgänge. Nachschlagevorgänge nach Wert können jedoch auch für den Wert einer beliebigen Eigenschaft oder einer Kombination von Eigenschaften verwendet werden. So suchen Sie beispielsweise alle archivierten Beiträge:

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

Für diesen Nachschlagevorgang ist eine Überprüfung aller nachverfolgten Post-Instanzen erforderlich, dieser ist daher weniger effizient als die Schlüsselsuche. Er ist jedoch in der Regel immer noch schneller als naive Abfragen mit ChangeTracker.Entries<TEntity>().

Schließlich ist es auch möglich, Nachschlagevorgänge für zusammengesetzte Schlüssel oder andere Kombinationen mehrerer Eigenschaften durchzuführen, oder wenn der Eigenschaftstyp zur Kompilierungszeit nicht bekannt ist. Beispiel:

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

Modellerstellung

Diskriminatorspalten besitzen maximale Länge

In EF8 werden Zeichenfolgendiskriminatorspalten, die für die TPH-Vererbungszuordnung verwendet werden, jetzt mit einer maximalen Länge konfiguriert. Diese Länge wird als kleinste Fibonacci-Zahl berechnet, die alle definierten Diskriminatorwerte abdeckt. Nehmen wir beispielsweise die folgende Hierarchie:

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; }
}

Mit der Konvention der Verwendung der Klassennamen für Diskriminatorwerte sind die möglichen Werte hier „PaperbackEdition“, „HardbackEdition“ und „Magazine“, und damit ist die Diskriminatorspalte für eine maximale Länge von 21 konfiguriert. Ein Beispiel sehen Sie nachfolgend bei der Verwendung von 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]),

Tipp

Fibonacci-Zahlen werden verwendet, um zu begrenzen, wie oft eine Migration generiert wird, um die Spaltenlänge zu ändern, wenn der Hierarchie neue Typen hinzugefügt werden.

DateOnly/TimeOnly wird auf SQL Server unterstützt

Die Typen DateOnly und TimeOnly wurden in .NET 6 eingeführt und seit ihrer Einführung für mehrere Datenbankanbieter (z. B. SQLite, MySQL und PostgreSQL) unterstützt. Für SQL Server hat die kürzliche Version eines Microsoft.Data.SqlClient-Pakets für .NET 6 ErikEJ ermöglicht, Unterstützung für diese Typen auf ADO.NET Ebene hinzuzufügen. Dies wiederum ebnete den Weg für die Unterstützung in EF8 für DateOnly und TimeOnly als Eigenschaften in Entitätstypen.

Tipp

DateOnly und TimeOnly können in EF Core 6 und 7 mit dem Communitypaket ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly aus @ErikEJ verwendet werden.

Betrachten Sie beispielsweise das folgende EF-Modell für britische Schulen:

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; }
}

Tipp

Der hier gezeigte Code stammt aus DateOnlyTimeOnlySample.cs.

Hinweis

Dieses Modell stellt nur britische Schulen dar und speichert Zeiten als lokale Zeiten (GMT). Die Behandlung verschiedener Zeitzonen würde diesen Code erheblich erschweren. Beachten Sie, dass die Verwendung von DateTimeOffset hier nicht hilfreich wäre, da Öffnungs- und Schließzeiten unterschiedliche Offsets haben, je nachdem, ob die Sommerzeit aktiv ist oder nicht.

Diese Entitätstypen werden bei Verwendung von SQL Server den folgenden Tabellen zugeordnet. Beachten Sie, dass die DateOnly-Eigenschaften date-Spalten und die TimeOnly-Eigenschaften time-Spalten zugeordnet sind.

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

Abfragen mit DateOnly und TimeOnly funktionieren auf die erwartete Weise. Die folgende LINQ-Abfrage sucht z. B. Schulen, die derzeit geöffnet sind:

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();

Diese Abfrage wird in die folgende SQL-Instanz übersetzt, wie in ToQueryString gezeigt:

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 und TimeOnly können auch in JSON-Spalten verwendet werden. Beispielsweise kann OpeningHours als JSON-Dokument gespeichert werden; die daraus resultierenden Daten sehen wie folgt aus:

Spalte Wert
Id 2
Name Farr High School
Gegründet 01.05.1964
OpeningHours
[
{ "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 }
]

Durch die Kombination von zwei Features von EF8 können wir jetzt die Öffnungszeiten abfragen, indem wir in die JSON-Sammlung indiziert werden. Beispiel:

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();

Diese Abfrage wird in die folgende SQL-Instanz übersetzt, wie in ToQueryString gezeigt:

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

Schließlich können Updates und Löschvorgänge mit Nachverfolgung und SaveChanges oder mithilfe von ExecuteUpdate/ExecuteDelete erfolgen. Beispiel:

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

Dies entspricht der folgenden SQL-Syntax:

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)

Reverse Engineering von Synapse und Dynamics 365 TDS

Das Reverse Engineering für EF8 (auch als Gerüstbau aus einer vorhandenen Datenbank bekannt) unterstützt jetzt serverlose Synapse SQL-Pools und Datenbanken von Dynamics 365 TDS Endpoint.

Warnung

Diese Datenbanksysteme weisen Unterschiede von normalen SQL Server- und Azure SQL-Datenbanken auf. Diese Unterschiede bedeuten, dass nicht alle EF Core-Funktionen unterstützt werden, wenn Abfragen für diese Datenbanksysteme geschrieben oder andere Vorgänge mit diesen ausgeführt werden.

Verbesserungen bei mathematischen Übersetzungen

Generische mathematische Schnittstellen wurden in .NET 7 eingeführt. Konkrete Typen wie double und float implementierten diese Schnittstellen durch Hinzufügen neuer APIs, die die vorhandene Funktionalität von Math und MathF spiegeln.

EF Core 8 übersetzt Aufrufe dieser generischen mathematischen APIs in LINQ mithilfe der vorhandenen SQL-Übersetzungen von Anbietern für Math und MathF. Dies bedeutet, dass Sie jetzt zwischen Aufrufen wie Math.Sin oder double.Sin in Ihren EF-Abfragen wählen können.

Wir haben in Zusammenarbeit mit dem .NET-Team zwei neue generische mathematische Methoden in .NET 8 hinzugefügt, die für double und float implementiert sind. Diese werden auch in SQL in EF Core 8 übersetzt.

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

Schließlich haben wir in Zusammenarbeit mit Eric Sink im SQLitePCLRaw-Projekt die mathematischen SQLite-Funktionen in ihren Builds der nativen SQLite-Bibliothek ermöglicht. Dies schließt die native Bibliothek ein, die Sie standardmäßig erhalten, wenn Sie den SQLite-Anbieter von EF Core installieren. Dies ermöglicht mehrere neue SQL-Übersetzungen in LINQ, darunter: Acos, Acosh, Asin, Asinh, Atan, Atanh, Ceiling, Cos, Cosh, DegreesToRadians, Exp, Floor, Log, Log2, Log10, Pow, RadiansToDegrees, Sign, Sin, Sin, Sin, Sqrt, Tan, Tan, Tan und Truncate.

Überprüfen auf ausstehende Modelländerungen

Wir haben einen neuen dotnet ef-Befehl hinzugefügt, um zu überprüfen, ob seit der letzten Migration Modelländerungen vorgenommen wurden. Dies kann in CI/CD-Szenarien hilfreich sein, um sicherzustellen, dass Sie oder Teamkollegen nicht vergessen haben, eine Migration hinzuzufügen.

dotnet ef migrations has-pending-model-changes

Sie können diese Überprüfung auch programmgesteuert in Ihrer Anwendung oder Tests mithilfe der neuen dbContext.Database.HasPendingModelChanges()-Methode durchführen.

Verbesserungen an SQLite-Gerüsten

SQLite unterstützt nur vier primitive Datentypen – INTEGER, REAL, TEXT und BLOB. Früher bedeutete dies, dass beim Reverse Engineering einer SQLite-Datenbank zu einem Gerüst für ein EF Core-Modell die resultierenden Entitätstypen nur Eigenschaften vom Typ long, double, string, and byte[] enthielten. Zusätzliche .NET-Typen werden vom EF Core SQLite-Anbietenden unterstützt, indem sie zwischen ihnen und einem der vier primitiven SQLite-Typen konvertiert werden.

In EF Core 8 verwenden wir nun zusätzlich zum SQLite-Typ den Datenformat- und Spaltentypnamen, um einen passenderen .NET-Typ zu ermitteln, der im Modell verwendet werden soll. Die folgenden Tabellen zeigen einige Fälle, in denen die zusätzlichen Informationen zu besseren Eigenschaftentypen im Modell führen.

Spaltentypname .NET-Typ
BOOLEAN byte[]bool
SMALLINT longshort
INT longint
BIGINT lang
STRING byte[]string
Datenformat .NET-Typ
'0.0' stringdecimal
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' stringDateTime
'00:00:00' stringTimeSpan
'00000000-0000-0000-0000-000000000000' stringGuid

Sentinel-Werte und Datenbankstandardwerte

Datenbanken ermöglichen die Konfiguration von Spalten zum Generieren eines Standardwerts, wenn beim Einfügen einer Zeile kein Wert angegeben wird. Dies kann in EF mithilfe von HasDefaultValue-Konstanten dargestellt werden:

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

Oder HasDefaultValueSql für beliebige SQL-Klauseln:

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

Tipp

Der unten gezeigte Code stammt von DefaultConstraintSample.cs.

Damit EF davon Gebrauch machen kann, muss es bestimmen, wann es einen Wert für die Spalte senden soll und wann nicht. Standardmäßig verwendet EF den CLR-Standard als Sentinel für diese Funktion. Das heißt, wenn der Wert von Status oder LeaseDate in den obigen Beispielen die CLR-Standardwerte für diese Typen sind, interpretiert EF , dass die Eigenschaft nicht festgelegtwurde und sendet daher keinen Wert an die Datenbank. Dies eignet sich gut für Referenztypen, z. B. wenn die string-Eigenschaft Statusnull lautet, dann sendet EF nicht null an die Datenbank, sondern enthält keinen Wert, sodass der Datenbankstandard ("Hidden") verwendet wird. Ebenso fügt EF für die DateTime-Eigenschaft LeaseDate nicht den CLR-Standardwert von 1/1/0001 12:00:00 AM ein. Stattdessen wird dieser Wert weggelassen, sodass der Datenbankstandard verwendet wird.

In einigen Fällen ist der CLR-Standardwert jedoch ein gültiger Wert, der eingefügt werden soll. In EF8 wird das so gehandhabt, dass sich der Sentinel-Wert für eine Spalte ändern kann. Betrachten Sie beispielsweise eine ganzzahlige Spalte, die mit einem Datenbankstandard konfiguriert ist:

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

In diesem Fall soll die neue Entität mit der angegebenen Anzahl von Gutschriften eingefügt werden, es sei denn, dies ist nicht angegeben, in diesem Fall werden 10 Gutschriften zugewiesen. Dies bedeutet jedoch, dass das Einfügen eines Datensatzes mit Nullguthaben nicht möglich ist, da Null der CLR-Standardwert ist und daher bewirkt, dass EF keinen Wert sendet. In EF8 kann dies behoben werden, indem der Sentinel für die Eigenschaft von Null zu -1 geändert wird:

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

EF verwendet jetzt nur noch den Datenbankstandardwert, wenn Credits auf -1 festgelegt ist. Ein Wert von Null wird wie jeder andere Betrag eingefügt.

Es kann oft sinnvoll sein, dies sowohl im Entitätstyp als auch in der EF-Konfiguration zu berücksichtigen. Beispiel:

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

Das bedeutet, dass der Sentinel-Wert von -1 automatisch festgelegt wird, wenn die Instanz erstellt wird, was bedeutet, dass die Eigenschaft im Zustand „nicht festgelegt“ beginnt.

Tipp

Wenn Sie die Standardeinschränkung der Datenbank für die Verwendung bei der Migrations Erstellung der Spalte konfigurieren möchten, Sie aber möchten, dass EF immer einen Wert einfügt, dann konfigurieren Sie die Eigenschaft als nicht generiert. Beispiel: b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();.

Datenbankstandardwerte für Boolesche Werte

Boolesche Eigenschaften stellen eine extreme Form dieses Problems dar, da der CLR-Standardwert (false) einer von nur zwei gültigen Werten ist. Dies bedeutet, dass eine bool-Eigenschaft mit einer Datenbankstandardeinschränkung nur einen Wert eingefügt hat, wenn dieser Wert true lautet. Wenn der Standardwert der Datenbank false ist, bedeutet dies, wenn der Eigenschaftswert false ist, dann wird der Datenbankstandard verwendet, welcher false ist. Andernfalls, wenn der Eigenschaftswert true ist, dann wird true eingefügt. Wenn die Datenbankstandardeinstellung false lautet, endet die Datenbankspalte also mit dem richtigen Wert.

Wenn der Standardwert der Datenbank hingegen true ist, bedeutet dies, dass, wenn der Eigenschaftswert false ist, der Standardwert der Datenbank verwendet wird, welcher true lautet! Und wenn der Eigenschaftswert true ist, dann wird true eingefügt. Der Wert in der Spalte endet also immer mit true in der Datenbank, unabhängig davon, was der Eigenschaftswert ist.

EF8 behebt dieses Problem, indem es den Sentinel für Bool-Eigenschaften auf denselben Wert wie den Standardwert der Datenbank festlegt. Beide Fälle oben führen dann dazu, dass der richtige Wert eingefügt wird, unabhängig davon, ob die Datenbankstandardeinstellung true oder false lautet.

Tipp

Wenn Sie eine bestehende Datenbank als Gerüst verwenden, analysiert EF8 einfache Standardwerte und fügt sie in die HasDefaultValue-Aufrufe ein. (Zuvor waren alle Standardwerte als undurchsichtige HasDefaultValueSql-Aufrufe gerüstet.) Das bedeutet, dass Non-Nullable-boolsche Spalten mit einer true oder false konstanten Datenbankvorgabe nicht mehr als Nullwerte zulassend gerüstet sind.

Datenbankstandardwerte für Enumerationen

Bei Enumerationseigenschaften können ähnliche Probleme auftreten wie bei bool-Eigenschaften, da Enumerationen in der Regel nur eine sehr kleine Menge gültiger Werte haben und der CLR-Standardwert einer dieser Werte sein kann. Betrachten Sie zum Beispiel diesen Entitätstyp und diese Enumeration:

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

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

Die Level-Eigenschaft wird dann mit einem Datenbankstandard konfiguriert:

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

Mit dieser Konfiguration schließt EF das Senden des Werts an die Datenbank aus, wenn dieser auf Level.Beginner festgelegt ist und stattdessen wird Level.Intermediate von der Datenbank zugewiesen. Das ist nicht beabsichtigt!

Das Problem wäre nicht aufgetreten, wenn die Enumeration mit dem Wert „unbekannt“ oder „nicht angegeben“ als Datenbankstandard definiert worden wäre:

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

Es ist jedoch nicht immer möglich, eine bestehende Enumeration zu ändern, daher kann in EF8 der Sentinel erneut angegeben werden. Beispiel: Zurück zur ursprünglichen Enumeration:

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

Jetzt wird Level.Beginner als normal eingefügt und der Datenbankstandard wird nur verwendet, wenn der Eigenschaftswert Level.Unspecified lautet. Auch hier kann es sinnvoll sein, dies im Entitätstyp selbst zu berücksichtigen. Beispiel:

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

Ein Nullwerte zulassendes Hintergrundfeld verwenden

Eine allgemeinere Möglichkeit, das oben beschriebene Problem zu lösen, besteht darin, ein Nullwerte zulassendes Hintergrundfeld für die Non-Nullablen-Eigenschaft zu erstellen. Betrachten Sie beispielsweise den folgenden Entitätstyp mit einer bool-Eigenschaft:

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

Die Eigenschaft kann ein Nullwerte zulassendes Unterstützungsfeldfeld erhalten:

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

    private bool? _isActive;

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

Das Unterstützungsfeld bleibt erhalten null, es sei denn, der Eigenschaftensatzer wird tatsächlich aufgerufen. Das heißt, der Wert des Unterstützungsfelds ist ein besserer Hinweis darauf, ob die Eigenschaft festgelegt wurde oder nicht als die CLR-Standardeinstellung der Eigenschaft. Dies funktioniert out-of-the-box with EF, da EF das Unterstützungsfeld verwendet, um die Eigenschaft standardmäßig zu lesen und zu schreiben.

Besseres ExecuteUpdate und ExecuteDelete

SQL-Befehle, die Aktualisierungen und Löschvorgänge ausführen, wie beispielsweise solcher, die von ExecuteUpdate und ExecuteDelete-Methoden generiert wurden, müssen auf eine einzelne Datenbanktabelle abzielen. In EF7 unterstützte ExecuteUpdate und ExecuteDelete jedoch keine Aktualisierungen, die auf mehrere Entitätstypen zugreifen, selbst wenn die Abfrage letztendlich eine einzige Tabelle betraf. EF8 entfernt diese Einschränkung. Betrachten Sie zum Beispiel einen Customer-Entitätstyp mit CustomerInfo-eigenem Typ:

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; }
}

Beide Entitätstypen sind der Customers-Tabelle zugeordnet. Die folgende Massenaktualisierung schlägt jedoch bei EF7 fehl, da beide Entitätstypen verwendet werden:

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"));

In EF8 wird dies nun in die folgende SQL-Datei übersetzt, wenn Azure SQL verwendet wird:

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

Ebenso können die von einer Union-Abfrage zurückgegebenen Instanzen aktualisiert werden, solange die Aktualisierungen alle auf dieselbe Tabelle abzielen. So können wir z. B. jeden Customer mit einer Region von France und gleichzeitig jeden Customer, der einen Store mit der Region France besucht hat, aktualisieren:

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"));

In EF8 erzeugt diese Abfrage bei Verwendung von Azure SQL Folgendes:

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]

Als letztes Beispiel kann ExecuteUpdate in EF8 zum Aktualisieren von Entitäten in einer TPT-Hierarchie verwendet werden, solange alle aktualisierten Eigenschaften derselben Tabelle zugeordnet sind. Betrachten Sie beispielsweise diese Entitätstypen, die mit TPT zugeordnet sind:

[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; }
}

Mit EF8 kann die Note-Eigenschaft aktualisiert werden:

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

Oder die Name-Eigenschaft kann aktualisiert werden:

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

EF8 schlägt jedoch fehl, wenn Sie versuchen, sowohl die Name als auch die Note-Eigenschaften zu aktualisieren, da sie unterschiedlichen Tabellen zugeordnet sind. Beispiel:

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

Löst die folgende Ausnahme aus:

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.

Bessere Verwendung von IN-Abfragen

Wenn der Contains-LINQ-Operator mit einer Unterabfrage verwendet wird, generiert EF Core jetzt bessere Abfragen mit SQL IN anstelle von EXISTS. Abgesehen davon, dass SQL besser lesbar ist, kann dies in einigen Fällen zu erheblich schnelleren Abfragen führen. Betrachten Sie zum Beispiel die folgende LINQ-Abfrage:

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

EF7 generiert Folgendes für PostgreSQL:

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

Da die Unterabfrage auf die externe Blogs-Tabelle (via b."Id") verweist, handelt es sich um eine -korrelierte Unterabfrage, was bedeutet, dass die Posts-Unterabfrage für jede Zeile in der Blogs-Tabelle ausgeführt werden muss. In EF8 wird stattdessen die folgende SQL-Datei generiert:

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

Da die Unterabfrage nicht mehr auf Blogs verweist, kann sie einmal ausgewertet werden, was auf den meisten Datenbanksystemen zu massiven Leistungsverbesserungen führt. Bei einigen Datenbanksystemen, vor allem bei SQL Server, ist die Datenbank jedoch in der Lage, die erste Abfrage für die zweite Abfrage zu optimieren, sodass die Leistung gleich bleibt.

Numerische Zeilenversionen für SQL Azure/SQL Server

Die automatische optimistische Nebenläufigkeit von SQL Server wird mithilfe von rowversion-Spalten behandelt. Ein rowversion ist ein undurchsichtiger 8-Byte-Wert, der zwischen Datenbank, Client und Server ausgetauscht wird. Standardmäßig stellt SqlClient rowversion-Typen als byte[] dar, obwohl veränderliche Verweistypen rowversion semantisch schlecht geeignet sind. In EF8 ist es einfach, rowversion-Spalten long- oder ulong-Eigenschaften zuzuordnen. Zum Beispiel:

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

Eliminierung von Klammern

Das Generieren lesbarer SQL ist ein wichtiges Ziel für EF Core. In EF8 ist die generierte SQL durch die automatische Beseitigung nicht benötigter Klammern besser lesbar. Beispiel: Die folgende LINQ-Abfrage:

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

Übersetzt in den folgenden Azure SQL-Code bei Verwendung von 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)

Diese wurde bei Verwendung von EF8 wie folgt verbessert:

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

Spezifisches Deaktivieren für DIE RETURNING/OUTPUT-Klausel

EF7 hat die SQL-Standardaktualisierung so geändert, dass sie zum Abrufen von datenbankgenerierten Spalten verwendet RETURNING/OUTPUT wird. In einigen Fällen wurde festgestellt, dass dies nicht funktioniert, und deshalb führt EF8 explizite Opt-outs (Deaktivierungen) für dieses Verhalten ein.

Zum Beispiel, um OUTPUT bei Verwendung des SQL Server/Azure SQL-Anbieters zu deaktivieren:

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

Oder um RETURNING zu deaktivieren bei Verwendung des SQLite-Anbieters:

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

Weitere kleinere Änderungen

Zusätzlich zu den oben beschriebenen Verbesserungen wurden viele kleinere Änderungen an EF8 vorgenommen. Dies schließt Folgendes ein: