Neuerungen in EF Core 7.0

EF Core 7.0 (EF7) wurde im November 2022veröffentlicht.

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.

EF7 zielt auf .NET 6 ab und kann daher entweder mit .NET 6 (LTS) oder .NET 7verwendet werden.

Beispielmodell

Viele der folgenden Beispiele verwenden ein einfaches Modell mit Blogs, Beiträgen, Tags und Autoren:

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

    public int Id { get; private set; }
    public string Name { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Post
{
    public Post(string title, string content, DateTime publishedOn)
    {
        Title = title;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishedOn { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
    public Author? Author { get; set; }
    public PostMetadata? Metadata { get; set; }
}

public class FeaturedPost : Post
{
    public FeaturedPost(string title, string content, DateTime publishedOn, string promoText)
        : base(title, content, publishedOn)
    {
        PromoText = promoText;
    }

    public string PromoText { get; set; }
}

public class Tag
{
    public Tag(string id, string text)
    {
        Id = id;
        Text = text;
    }

    public string Id { get; private set; }
    public string Text { get; set; }
    public List<Post> Posts { get; } = new();
}

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

    public int Id { get; private set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; } = null!;
    public List<Post> Posts { get; } = new();
}

In einigen Beispielen werden auch Aggregattypen verwendet, die in verschiedenen Beispielen auf unterschiedliche Weise zugeordnet werden. Es gibt einen Aggregattyp für Kontakte:

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

Ein zweiter Aggregattyp steht für Postmetadaten:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Tipp

Das Beispielmodell befindet sich in BlogsContext.cs.

JSON-Spalten

Die meisten relationalen Datenbanken unterstützen Spalten, die JSON-Dokumente enthalten. Der JSON-Code in diesen Spalten ermöglicht den Drilldown mit Abfragen. Dies ermöglicht z. B. das Filtern und Sortieren nach den Elementen der Dokumente sowie die Projektion von Elementen aus den Dokumenten in Ergebnisse. JSON-Spalten ermöglichen es relationalen Datenbanken, einige der Merkmale von Dokumentdatenbanken zu übernehmen und eine nützliche Hybridlösung zwischen den beiden zu erstellen.

EF7 enthält anbieterunabhängige Unterstützung für JSON-Spalten mit einer Implementierung für SQL Server. Diese Unterstützung ermöglicht die Zuordnung von Aggregaten, die aus .NET-Typen zu JSON-Dokumenten erstellt wurden. Normale LINQ-Abfragen können für die Aggregate verwendet werden, und diese werden in die entsprechenden Abfragekonstrukte übersetzt, die zum Drilldown in den JSON-Code erforderlich sind. EF7 unterstützt außerdem das Aktualisieren und Speichern von Änderungen an JSON-Dokumenten.

Hinweis

Die SQLite-Unterstützung für JSON ist für post EF7geplant. Die PostgreSQL- und Pomelo MySQL-Anbieter enthalten bereits einige Unterstützung für JSON-Spalten. Wir arbeiten mit den Autoren dieser Anbieter zusammen, um die JSON-Unterstützung auf alle Anbieter auszurichten.

Zuordnung zu JSON-Spalten

In EF Core werden Aggregattypen mithilfe von OwnsOne und OwnsManydefiniert. Betrachten Sie beispielsweise den Aggregattyp aus unserem Beispielmodell, das zum Speichern von Kontaktinformationen verwendet wird:

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

Dies kann dann in einem Entitätstyp "Besitzer" verwendet werden, z. B. zum Speichern der Kontaktdetails eines Autors:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; }
}

Der Aggregattyp wird in OnModelCreating unter Verwendung von OwnsOne konfiguriert:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

Tipp

Der hier gezeigte Code stammt aus JsonColumnsSample.cs.

Standardmäßig ordnen relationale Datenbankanbieter Aggregattypen wie diesen der gleichen Tabelle wie die besitzereigenen Entitätstypen zu. Dies heißt, jede Eigenschaft der Klassen ContactDetails und Address wird einer Spalte in der Tabelle Authors zugeordnet.

Einige der gespeicherten Autoren mit Kontaktdetails sehen wie folgt aus:

Autoren

Id Name Kontakt_Addresse_Straße Kontakt_Addresse_Stadt Kontakt_Addresse_Postleitzahl Kontakt_Addresse__Land Kontakt_Telefon
1 Maddy Montaquila 1 Main St. Camberwick Green CW1 5ZH UK 01632 12345
2 Jeremy Likness 2 Main St. Chigley CW1 5ZH UK 01632 12346
3 Daniel Roth 3 Main St. Camberwick Green CW1 5ZH UK 01632 12347
4 Arthur Vickers 15a Main St Chigley CW1 5ZH Vereinigtes Königreich 01632 22345
5 Brice Lambson 4 Main St. Chigley CW1 5ZH UK 01632 12349

Bei Bedarf kann jeder Entitätstyp, der das Aggregat bildet, stattdessen einer eigenen Tabelle zugeordnet werden:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToTable("Contacts");
            ownedNavigationBuilder.OwnsOne(
                contactDetails => contactDetails.Address, ownedOwnedNavigationBuilder =>
                {
                    ownedOwnedNavigationBuilder.ToTable("Addresses");
                });
        });
}

Die gleichen Daten werden dann in drei Tabellen gespeichert:

Autoren

Id Name
1 Maddy Montaquila
2 Jeremy Likness
3 Daniel Roth
4 Arthur Vickers
5 Brice Lambson

Kontakte

AuthorId Telefonnummer
1 01632 12345
2 01632 12346
3 01632 12347
4 01632 22345
5 01632 12349

Adressen

KontaktDetailsAuthorId Straße Stadt Postleitzahl Land / Region
1 1 Main St. Camberwick Green CW1 5ZH UK
2 2 Main St. Chigley CW1 5ZH Vereinigtes Königreich
3 3 Main St. Camberwick Green CW1 5ZH Vereinigtes Königreich
4 15a Main St Chigley CW1 5ZH Vereinigtes Königreich
5 4 Main St. Chigley CW1 5ZH UK

Jetzt, kommt der interessante Teil. In EF7 kann der ContactDetails Aggregattyp einer JSON-Spalte zugeordnet werden. Dies erfordert nur einen Aufruf von ToJson() beim Konfigurieren des Aggregattyps:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToJson();
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

Die Authors Tabelle enthält nun eine JSON-Spalte für ContactDetails die Auffüllung mit einem JSON-Dokument für jeden Autor:

Autoren

Id Name Contact
1 Maddy Montaquila {
  „Phone“:„01632 12345“,
  „Address“: {
    „City“:„Camberwick Green“,
    „Country“:„UK“,
    „Postcode“:„CW1 5ZH“,
    „Straße“:„1 Main St“
  }
}
2 Jeremy Likness {
  „Phone“:„01632 12346“,
  „Address“: {
    „City“:„Chigley“,
    „Country“:„UK“,
    "Postcode“:„CH1 5ZH“,
    "Straße“:„2 Main St“
  }
}
3 Daniel Roth {
  „Phone“:„01632 12347“,
  „Address“: {
    „City“:„Camberwick Green“,
    „Country“:„UK“,
    „Postcode“:„CW1 5ZH“,
    „Straße“:„3 Main St“
  }
}
4 Arthur Vickers {
  „Phone“:„01632 12348“,
  „Address“: {
    „City“:„Chigley“,
    „Country“:„UK“,
    "Postcode“:„CH1 5ZH“,
    „Straße“:„15a Main St“
  }
}
5 Brice Lambson {
  „Phone“:„01632 12349“,
  „Address“: {
    „City“:„Chigley“,
    „Country“:„UK“,
    "Postcode“:„CH1 5ZH“,
    „Straße“:„4 Main St“
  }
}

Tipp

Diese Verwendung von Aggregaten ähnelt sehr der Art und Weise, wie JSON-Dokumente bei Verwendung des EF Core-Anbieters für Azure Cosmos DB zugeordnet werden. JSON-Spalten bringen die Funktionen der Verwendung von EF Core für Dokumentdatenbanken in Dokumente, die in eine relationale Datenbank eingebettet sind.

Die oben gezeigten JSON-Dokumente sind sehr einfach, aber diese Zuordnungsfunktion kann auch mit komplexeren Dokumentstrukturen verwendet werden. Betrachten Sie z. B. einen anderen Aggregattyp aus unserem Beispielmodell, der verwendet wird, um Metadaten zu einem Beitrag darzustellen:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Dieser Aggregattyp enthält mehrere geschachtelte Typen und Auflistungen. Aufrufe an OwnsOne und OwnsMany werden verwendet, um diesen Aggregattyp zuzuordnen:

modelBuilder.Entity<Post>().OwnsOne(
    post => post.Metadata, ownedNavigationBuilder =>
    {
        ownedNavigationBuilder.ToJson();
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopSearches);
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopGeographies);
        ownedNavigationBuilder.OwnsMany(
            metadata => metadata.Updates,
            ownedOwnedNavigationBuilder => ownedOwnedNavigationBuilder.OwnsMany(update => update.Commits));
    });

Tipp

ToJson wird nur für den Aggregatstamm benötigt, um das gesamte Aggregat in einem JSON-Dokument zuzuordnen.

Mit dieser Zuordnung kann EF7 ein komplexes JSON-Dokument wie folgt erstellen und abfragen:

{
  "Views": 5085,
  "TopGeographies": [
    {
      "Browsers": "Firefox, Netscape",
      "Count": 924,
      "Latitude": 110.793,
      "Longitude": 39.2431
    },
    {
      "Browsers": "Firefox, Netscape",
      "Count": 885,
      "Latitude": 133.793,
      "Longitude": 45.2431
    }
  ],
  "TopSearches": [
    {
      "Count": 9359,
      "Term": "Search #1"
    }
  ],
  "Updates": [
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1996-02-17T19:24:29.5429092Z",
      "Commits": []
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "2019-11-24T19:24:29.5429093Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1997-05-28T19:24:29.5429097Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        },
        {
          "Comment": "Commit #2",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    }
  ]
}

Hinweis

Das direkte Zuordnen von räumlichen Typen zu JSON wird noch nicht unterstützt. Im obigen Dokument werden double Werte als Problemumgehung verwendet. Stimmen Sie für die Unterstützung räumlicher Typen in JSON-Spalten ab, wenn dies etwas ist, an dem Sie interessiert sind.

Hinweis

Die Zuordnung von Auflistungen primitiver Typen zu JSON wird noch nicht unterstützt. Im obigen Dokument wird ein Wertkonverter verwendet, um die Auflistung in eine durch Trennzeichen getrennte Zeichenfolge zu transformieren. Stimmen Sie für Json: Hinzufügen von Unterstützung für die Sammlung von primitiven Typen, wenn dies etwas ist, an dem Sie interessiert sind.

Hinweis

Die Zuordnung von besitzereigenen Typen zu JSON wird in Verbindung mit der TPT- oder TPC-Vererbung noch nicht unterstützt. Stimmen Sie für die Unterstützung von JSON-Eigenschaften mit TPT/TPC-Vererbungszuordnung, wenn dies für Sie interessant ist.

Abfragen in JSON-Spalten

Abfragen in JSON-Spalten funktionieren genauso wie abfragen in einem anderen Aggregattyp in EF Core. Das heißt, verwenden Sie einfach LINQ! Nachfolgend finden Sie einige Beispiele.

Eine Abfrage für alle Autoren, die in Chigley leben:

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

Die Abfrage generiert den folgenden SQL-Code, wenn SQL-Server verwendet wird:

SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$')
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Beachten Sie die Verwendung von JSON_VALUE, um City aus dem Address inneren des JSON-Dokument zu erhalten.

Select kann verwendet werden, um Elemente aus dem JSON-Dokument zu extrahieren und zu projizieren:

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

Diese Abfrage generiert den folgenden SQL-Code:

SELECT CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Hier ist ein Beispiel, das im Filter und in der Projektion etwas mehr erledigt und auch nach der Telefonnummer im JSON-Dokument sortiert:

var orderedAddresses = await context.Authors
    .Where(
        author => (author.Contact.Address.City == "Chigley"
                   && author.Contact.Phone != null)
                  || author.Name.StartsWith("D"))
    .OrderBy(author => author.Contact.Phone)
    .Select(
        author => author.Name + " (" + author.Contact.Address.Street
                  + ", " + author.Contact.Address.City
                  + " " + author.Contact.Address.Postcode + ")")
    .ToListAsync();

Diese Abfrage generiert den folgenden SQL-Code:

SELECT (((((([a].[Name] + N' (') + CAST(JSON_VALUE([a].[Contact],'$.Address.Street') AS nvarchar(max))) + N', ') + CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max))) + N' ') + CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))) + N')'
FROM [Authors] AS [a]
WHERE (CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' AND CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) IS NOT NULL) OR ([a].[Name] LIKE N'D%')
ORDER BY CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max))

Und wenn das JSON-Dokument Sammlungen enthält, können diese in den Ergebnissen projiziert werden:

var postsWithViews = await context.Posts.Where(post => post.Metadata!.Views > 3000)
    .AsNoTracking()
    .Select(
        post => new
        {
            post.Author!.Name, post.Metadata!.Views, Searches = post.Metadata.TopSearches, Commits = post.Metadata.Updates
        })
    .ToListAsync();

Diese Abfrage generiert den folgenden SQL-Code:

SELECT [a].[Name], CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int), JSON_QUERY([p].[Metadata],'$.TopSearches'), [p].[Id], JSON_QUERY([p].[Metadata],'$.Updates')
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int) > 3000

Hinweis

Komplexere Abfragen mit JSON-Sammlungen erfordern jsonpath Unterstützung. Stimmen Sie für die Unterstützung von Jsonpath-Abfragen, wenn dies etwas ist, an dem Sie interessiert sind.

Tipp

Erwägen Sie das Erstellen von Indizes, um die Abfrageleistung in JSON-Dokumenten zu verbessern. Siehe z. B. Index JSON-Daten bei Verwendung von SQL Servern.

Aktualisieren von JSON-Spalten

SaveChanges und SaveChangesAsync funktionieren normal, um Aktualisierungen an einer JSON-Spalte vorzunehmen. Für umfangreiche Änderungen wird das gesamte Dokument aktualisiert. Ersetzen Sie beispielsweise den größten Teil des Contact Dokuments für einen Autor:

var jeremy = await context.Authors.SingleAsync(author => author.Name.StartsWith("Jeremy"));

jeremy.Contact = new() { Address = new("2 Riverside", "Trimbridge", "TB1 5ZS", "UK"), Phone = "01632 88346" };

await context.SaveChangesAsync();

In diesem Fall wird das gesamte neue Dokument als Parameter übergeben:

info: 8/30/2022 20:21:24.392 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"Phone":"01632 88346","Address":{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"2 Riverside"}}' (Nullable = false) (Size = 114), @p1='2'], CommandType='Text', CommandTimeout='30']

Das wird dann in UPDATE SQL verwendet:

UPDATE [Authors] SET [Contact] = @p0
OUTPUT 1
WHERE [Id] = @p1;

Wenn jedoch nur ein Unterdokument geändert wird, verwendet EF Core einen JSON_MODIFY Befehl, um nur das Unterdokument zu aktualisieren. Ändern Sie z. B. das Address Innere eines Contact Dokuments:

var brice = await context.Authors.SingleAsync(author => author.Name.StartsWith("Brice"));

brice.Contact.Address = new("4 Riverside", "Trimbridge", "TB1 5ZS", "UK");

await context.SaveChangesAsync();

Es werden die folgenden Parameter generiert:

info: 10/2/2022 15:51:15.895 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"4 Riverside"}' (Nullable = false) (Size = 80), @p1='5'], CommandType='Text', CommandTimeout='30']

Die in UPDATE über einen Aufruf JSON_MODIFY verwendet werden:

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address', JSON_QUERY(@p0))
OUTPUT 1
WHERE [Id] = @p1;

Wenn nur eine einzelne Eigenschaft geändert wird, verwendet EF Core schließlich erneut den Befehl „JSON_MODIFY“, um nur den geänderten Eigenschaftswert zu patchen. Beispiel:

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

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

await context.SaveChangesAsync();

Es werden die folgenden Parameter generiert:

info: 10/2/2022 15:54:05.112 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

Die wieder mit einer JSON_MODIFY verwendet werden:

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address.Country', JSON_VALUE(@p0, '$[0]'))
OUTPUT 1
WHERE [Id] = @p1;

AktualisierungAusführen und LöschenAusführen (Massenupdates)

Standardmäßig verfolgt EF Core Änderungen an Entitäten und sendet dann Aktualisierungen an die Datenbank, wenn eine der SaveChanges Methoden aufgerufen wird. Änderungen werden nur für Eigenschaften und Beziehungen gesendet, die sich tatsächlich geändert haben. Außerdem bleiben die nachverfolgten Entitäten mit den an die Datenbank gesendeten Änderungen synchronisiert. Dieser Mechanismus ist eine effiziente und bequeme Möglichkeit, allgemeine Einfügungen, Aktualisierungen und Löschungen an die Datenbank zu senden. Diese Änderungen werden auch stapelweise verarbeitet, um die Anzahl der Datenbank-Roundtrips zu reduzieren.

Es ist jedoch manchmal nützlich, Aktualisierungs- oder Löschbefehle in der Datenbank auszuführen, ohne die Änderungsverfolgung einzubeziehen. EF7 ermöglicht dies mit den neuen Methoden ExecuteUpdate und ExecuteDelete. Diese Methoden werden auf eine LINQ-Abfrage angewendet und aktualisieren oder löschen Entitäten in der Datenbank basierend auf den Ergebnissen dieser Abfrage. Viele Entitäten können mit einem einzigen Befehl aktualisiert werden, und die Entitäten werden nicht in den Arbeitsspeicher geladen, was bedeutet, dass dies zu effizienteren Aktualisierungen und Löschungen führen kann.

Beachten Sie jedoch Folgendes:

  • Die spezifischen Änderungen, die vorgenommen werden sollen, müssen explizit angegeben werden; sie werden nicht automatisch von EF Core erkannt.
  • Alle nachverfolgten Entitäten werden nicht synchronisiert.
  • Möglicherweise müssen zusätzliche Befehle in der richtigen Reihenfolge gesendet werden, damit keine Datenbankeinschränkungen verletzt werden. Beispielsweise das Löschen von Nachfolgern, bevor ein Prinzipal gelöscht werden darf.

All das bedeutet, dass die ExecuteUpdate und die ExecuteDelete Methoden den bestehenden Mechanismus SaveChanges ergänzen und nicht ersetzen.

Grundlegende ExecuteDelete Beispiele

Tipp

Der hier gezeigte Code stammt aus ExecuteDeleteSample.cs.

Durch das Aktivieren von ExecuteDelete oder ExecuteDeleteAsync auf einer DbSet werden alle Entitäten dieser DbSet aus der Datenbank gelöscht. So löschen Sie beispielsweise alle Tag-Entitäten:

await context.Tags.ExecuteDeleteAsync();

Der folgende SQL-Code wird ausgeführt, wenn der SQL Server verwendet wird:

DELETE FROM [t]
FROM [Tags] AS [t]

Interessanter ist, dass die Abfrage Filter enthalten kann. Beispiel:

await context.Tags.Where(t => t.Text.Contains(".NET")).ExecuteDeleteAsync();

Dadurch wird der folgende SQL-Code ausgeführt:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE [t].[Text] LIKE N'%.NET%'

Die Abfrage kann auch komplexere Filter verwenden, einschließlich Navigationen zu anderen Typen. Wenn Sie beispielsweise Tags nur aus alten Blogbeiträgen löschen möchten:

await context.Tags.Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022)).ExecuteDeleteAsync();

Was ausgeführt wird:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Grundlegende ExecuteUpdate Beispiele

Tipp

Der hier gezeigte Code stammt aus ExecuteUpdateSample.cs.

ExecuteUpdate und ExecuteUpdateAsync verhalten sich auf eine sehr ähnliche Weise bezogen auf die ExecuteDelete Methoden. Der Hauptunterschied besteht darin, dass ein Update das Wissen erfordern, welche Eigenschaften und wie sie aktualisiert werden sollen. Dies wird mithilfe eines oder mehrerer Aufrufe an SetProperty erreicht. So aktualisieren Sie z. B. die Name jedes einzelnen Blogs:

await context.Blogs.ExecuteUpdateAsync(
    s => s.SetProperty(b => b.Name, b => b.Name + " *Featured!*"));

Der erste Parameter von SetProperty legt fest, welche Eigenschaft aktualisiert werden soll; in diesem Fall Blog.Name. Der zweite Parameter gibt an, wie der neue Wert berechnet werden soll; in diesem Fall durch das Verwenden des vorhandenen Werts und Anfügen von "*Featured!*". Die resultierende SQL lautet:

UPDATE [b]
SET [b].[Name] = [b].[Name] + N' *Featured!*'
FROM [Blogs] AS [b]

Wie bei ExecuteDelete kann die Abfrage verwendet werden, um zu filtern, welche Entitäten aktualisiert werden. Darüber hinaus können mehrere Aufrufe von SetProperty verwendet werden, um mehr als eine Eigenschaft der Zielentität zu aktualisieren. So aktualisieren Sie z. B. Title und Content aller Beiträge, die vor 2022 veröffentlicht wurden:

await context.Posts
    .Where(p => p.PublishedOn.Year < 2022)
    .ExecuteUpdateAsync(s => s
        .SetProperty(b => b.Title, b => b.Title + " (" + b.PublishedOn.Year + ")")
        .SetProperty(b => b.Content, b => b.Content + " ( This content was published in " + b.PublishedOn.Year + ")"));

In diesem Fall ist die generierte SQL etwas komplizierter:

UPDATE [p]
SET [p].[Content] = (([p].[Content] + N' ( This content was published in ') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')',
    [p].[Title] = (([p].[Title] + N' (') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')'
FROM [Posts] AS [p]
WHERE DATEPART(year, [p].[PublishedOn]) < 2022

Schließlich kann der Filter, wie bei ExecuteDelete, auf andere Tabellen verweisen. So aktualisieren Sie beispielsweise alle Tags aus alten Beiträgen:

await context.Tags
    .Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022))
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Text, t => t.Text + " (old)"));

Was generiert wird:

UPDATE [t]
SET [t].[Text] = [t].[Text] + N' (old)'
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Weitere Informationen und Codebeispiele zu ExecuteUpdate und ExecuteDelete finden Sie unter UpdateDurchführen und LöschenDurchführen.

Vererbung und mehrere Tabellen

ExecuteUpdate und ExecuteDelete können nur auf eine einzelne Tabelle reagieren. Dies hat Auswirkungen beim Arbeiten mit verschiedenen Vererbungszuordnungsstrategien. Im Allgemeinen gibt es keine Probleme bei der Verwendung der TPH-Zuordnungsstrategie, da nur eine Tabelle geändert werden kann. Beispiel: Löschen aller FeaturedPost-Entitäten:

await context.Set<FeaturedPost>().ExecuteDeleteAsync();

Generiert den folgenden SQL-Code bei Verwendung der TPH-Zuordnung:

DELETE FROM [p]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'FeaturedPost'

Es gibt für diesen Fall auch keine Probleme bei der Verwendung der TPC-Zuordnungsstrategie, da wieder nur Änderungen an einer einzelnen Tabelle erforderlich sind:

DELETE FROM [f]
FROM [FeaturedPosts] AS [f]

Wenn Sie jedoch versuchen, die TPT-Zuordnungsstrategie zu verwenden, tritt ein Fehler auf, da das Löschen von Zeilen aus zwei verschiedenen Tabellen erforderlich wäre.

Das Hinzufügen eines Filters zur Abfrage bedeutet häufig, dass der Vorgang mit den TPC- und TPT-Strategien fehlschlägt. Dies liegt daran, dass die Zeilen möglicherweise aus mehreren Tabellen gelöscht werden müssen. Diese Abfrage zum Beispiel:

await context.Posts.Where(p => p.Author!.Name.StartsWith("Arthur")).ExecuteDeleteAsync();

Generiert bei Verwendung von TPH den folgenden SQL-Code:

DELETE FROM [p]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE [a].[Name] IS NOT NULL AND ([a].[Name] LIKE N'Arthur%')

Schlägt jedoch fehl, wenn TPC oder TPT verwendet wird.

Tipp

Problem Nr. 10879 verfolgt das Hinzufügen von Unterstützung für das automatische Senden mehrerer Befehle in diesen Szenarien. Stimmen Sie für dieses Problem ab, wenn es sich um etwas handelt, das Sie implementiert haben möchten.

ExecuteDelete und Beziehungen

Wie bereits erwähnt, kann es erforderlich sein, abhängige Entitäten zu löschen oder zu aktualisieren, bevor der Prinzipal einer Beziehung gelöscht werden kann. Zum Beispiel ist jede Post abhängig von der zugeordneten Author. Dies bedeutet, dass ein Autor nicht gelöscht werden kann, wenn ein Beitrag weiterhin auf ihn verweist; dadurch würde die Fremdschlüsseleinschränkung in der Datenbank verletzt. Zum Beispiel würde der Versuch:

await context.Authors.ExecuteDeleteAsync();

Zu der folgenden Ausnahme auf dem SQL Server führen:

Microsoft.Data.SqlClient.SqlException (0x80131904): Die LÖSCHEN-Anweisung ist mit der REFERENZ-Einschränkung "FK_Posts_Authors_AuthorId" in Konflikt geraten. Der Konflikt ist in der Datenbank „TphBlogsContext“, Tabelle „dbo.Posts“ aufgetreten, Spalte ‚AutorID‘. Die Anweisung wurde beendet.

Um dies zu beheben, müssen wir zuerst die Beiträge löschen oder die Beziehung zwischen jedem Beitrag und seinem Autor abtrennen, indem die Fremdschlüsseleigenschaft auf NULL festgelegt AuthorId wird. Verwenden Sie z. B. die Option Löschen:

await context.Posts.TagWith("Deleting posts...").ExecuteDeleteAsync();
await context.Authors.TagWith("Deleting authors...").ExecuteDeleteAsync();

Tipp

TagWith kann verwendet werden, um normale Abfragen zu markieren ExecuteDelete oder ExecuteUpdate auf die gleiche Weise zu kennzeichnen wie normale Abfragen.

Dies führt zu zwei separaten Befehlen; dem ersten, der die Abhängigen löscht:

-- Deleting posts...

DELETE FROM [p]
FROM [Posts] AS [p]

Und dem zweiten zum Löschen der Prinzipale:

-- Deleting authors...

DELETE FROM [a]
FROM [Authors] AS [a]

Wichtig

Mehrere ExecuteDelete und ExecuteUpdate Befehle sind standardmäßig nicht in einer einzelnen Transaktion enthalten. Die DbContext-Transaktions-APIs können jedoch in der normalen Weise verwendet werden, um diese Befehle in einer Transaktion umzuschließen.

Tipp

Das Senden dieser Befehle in einem einzelnen Roundtrip hängt von Problem #10879 ab. Stimmen Sie für dieses Problem ab, wenn es sich um etwas handelt, das Sie implementiert haben möchten.

Das Konfigurieren von Löschweitergaben in der Datenbank kann hier sehr nützlich sein. In unserem Modell ist die Beziehung zwischen Blog und Post erforderlich, was bewirkt, dass EF Core eine Löschweitergabe nach Konvention konfiguriert. Das bedeutet, wenn ein Blog aus der Datenbank gelöscht wird, werden auch alle abhängigen Beiträge gelöscht. Daraus folgt, dass wir zum Löschen aller Blogs und Beiträge, nur die Blogs löschen müssen:

await context.Blogs.ExecuteDeleteAsync();

Daraus ergibt sich folgender SQL-Code:

DELETE FROM [b]
FROM [Blogs] AS [b]

Der beim Löschen eines Blogs auch dazu führt, dass alle zugehörigen Beiträge durch die konfigurierte Löschweitergabe gelöscht werden.

Schnelleres ÄnderungenSpeichern

In EF7 wurde die Leistung von SaveChanges und SaveChangesAsync erheblich verbessert. In einigen Szenarien ist das Speichern von Änderungen jetzt bis zu viermal schneller als bei EF Core 6.0!

Die meisten dieser Verbesserungen ergeben sich aus:

  • Der Durchführung weniger Roundtrips zur Datenbank
  • Schnellerem Generieren von SQL-Code

Nachfolgend finden Sie einige Beispiele für diese Verbesserungen.

Hinweis

Eine ausführliche Erläuterung dieser Änderungen finden Sie unter Ankündigung von Entity Framework Core 7 Preview 6: Performance Edition im .NET-Blog.

Tipp

Der hier gezeigte Code stammt aus SaveChangesPerformanceSample.cs.

Nicht benötigte Transaktionen werden eliminiert

Alle modernen relationalen Datenbanken garantieren Transaktionsalität für (die meisten) einzelnen SQL-Anweisungen. Das heißt, die Anweisung niemals nur teilweise abgeschlossen wird, auch wenn ein Fehler auftritt. EF7 vermeidet in diesen Fällen das Starten einer expliziten Transaktion.

Sehen Sie sich beispielsweise die Protokollierung für den folgenden Aufruf an SaveChanges:

await context.AddAsync(new Blog { Name = "MyBlog" });
await context.SaveChangesAsync();

Es zeigt, dass in EF Core 6.0 der INSERT-Befehl von Befehlen umschlossen wird, um zu beginnen und dann eine Transaktion zu übernehmen:

dbug: 9/29/2022 11:43:09.196 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/29/2022 11:43:09.265 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 9/29/2022 11:43:09.297 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

EF7 erkennt, dass die Transaktion hier nicht benötigt wird und entfernt daher diese Aufrufe:

info: 9/29/2022 11:42:34.776 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

Dadurch werden zwei Datenbank-Roundtrips entfernt, die einen großen Unterschied bei der Gesamtleistung machen können, insbesondere, wenn die Latenz von Aufrufen an die Datenbank hoch ist. In typischen Produktionssystemen befindet sich die Datenbank nicht auf demselben Computer wie die Anwendung. Das bedeutet, dass die Latenz oft relativ hoch ist und diese Optimierung in realen Produktionssystemen besonders effektiv ist.

Verbesserter SQL-Code für einfache Identitätseinfügung

Der obige Fall fügt eine einzelne Zeile mit einer IDENTITY Schlüsselspalte und keinen anderen datenbankgenerierten Werten ein. EF7 vereinfacht den SQL-Code in diesem Fall mithilfe von OUTPUT INSERTED. Obwohl diese Vereinfachung in vielen anderen Fällen nicht anwendbar ist, ist es trotzdem wichtig diese Art von Einzeileneinfügung zu verbessern, da sie in vielen Anwendungen sehr häufig vorkommt.

Einfügen von mehreren Zeilen

In EF Core 6.0 wurde der Standardansatz zum Einfügen mehrerer Zeilen durch Einschränkungen in der SQL Server-Unterstützung für Tabellen mit Triggern gesteuert. Wir wollten sicherstellen, dass die Standardoberfläche auch für die Minderheit der Benutzer mit Triggern in ihren Tabellen funktioniert hat. Dies bedeutete, dass wir keine einfache OUTPUT-Klausel verwenden konnten, da dies in SQL Server nicht mit Triggern funktioniert. Stattdessen generierte EF Core 6.0 beim Einfügen mehrerer Entitäten ziemlich konvolutierten SQL-Code. Zum Beispiel, dieser Aufruf von SaveChanges:

for (var i = 0; i < 4; i++)
{
    await context.AddAsync(new Blog { Name = "Foo" + i });
}

await context.SaveChangesAsync();

Führt zu den folgenden Aktionen, wenn sie für SQL Server mit EF Core 6.0 ausgeführt werden:

dbug: 9/30/2022 17:19:51.919 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/30/2022 17:19:51.993 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 9/30/2022 17:19:52.023 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Wichtig

Obwohl es kompliziert ist, ist die stapelweise Verarbeitung mehrerer Einfügungen wie dieser immer noch wesentlich schneller als das Senden eines einzelnen Befehls für jeden Einfügebefehl.

In EF7 können Sie diesen SQL-Code weiterhin abrufen, wenn Ihre Tabellen Trigger enthalten, aber für den gängigen Fall generieren wir jetzt viel effizientere, wenn auch immer noch etwas komplexe, Befehle:

info: 9/30/2022 17:40:37.612 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (4ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

Die Transaktion ist nicht mehr vorhanden, wie im Fall eines einzelnen Einfügevorgangs, da MERGE eine einzelne Anweisung ist, die durch eine implizite Transaktion geschützt ist. Außerdem ist die temporäre Tabelle nicht mehr vorhanden, und die OUTPUT-Klausel sendet nun die generierten IDs direkt an den Client zurück. Dies kann je nach Umgebungsfaktoren wie Latenz zwischen Anwendung und Datenbank viermal schneller sein als bei EF Core 6.0.

Trigger

Wenn die Tabelle Trigger hat, löst der Aufruf von SaveChanges im obigen Code eine Ausnahme aus:

Ausnahmefehler. Microsoft.EntityFrameworkCore.DbUpdateException:
Änderungen konnten nicht gespeichert werden, da die Zieltabelle Datenbanktrigger enthält. Bitte konfigurieren Sie ihren Entitätstyp entsprechend, weitere Informationen finden Sie unter https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers.
---> Microsoft.Data.SqlClient.SqlException (0x80131904):
Die Zieltabelle "BlogsWithTriggers" der DML-Anweisung darf keine aktivierten Trigger aufweisen, wenn die Anweisung eine OUTPUT-Klausel ohne INTO-Klausel enthält.

Der folgende Code kann verwendet werden, um EF Core darüber zu informieren, dass die Tabelle über einen Trigger verfügt:

modelBuilder
    .Entity<BlogWithTrigger>()
    .ToTable(tb => tb.HasTrigger("TRG_InsertUpdateBlog"));

EF7 wird dann zum EF Core 6.0 SQL zurückgesetzt, wenn Einfüge- und Aktualisierungsbefehle für diese Tabelle gesendet werden.

Weitere Informationen, einschließlich einer Konvention zum automatischen Konfigurieren aller zugeordneten Tabellen mit Triggern, finden Sie in den SQL Server-Tabellen mit Triggern, die jetzt eine spezielle EF Core-Konfiguration in der Dokumentation zu grundlegenden Änderungen von EF7 erfordern.

Weniger Roundtrips zum Einfügen von Diagrammen

Erwägen Sie das Einfügen eines Diagramms von Entitäten, die eine neue Prinzipalentität enthalten, sowie neue abhängige Entitäten mit Fremdschlüsseln, die auf den neuen Prinzipal verweisen. Beispiel:

await context.AddAsync(
    new Blog { Name = "MyBlog", Posts = { new() { Title = "My first post" }, new() { Title = "My second post" } } });
await context.SaveChangesAsync();

Wenn der Primärschlüssel des Prinzipals von der Datenbank generiert wird, wird der Wert, der für den Fremdschlüssel im abhängigen Schlüssel festgelegt wird, erst bekannt, wenn der Prinzipal eingefügt wurde. EF Core generiert hiuerfür zwei Roundtrips–-einen, um den Prinzipal einzufügen und den neuen Primärschlüssel zurückzuholen, und einen zweiten, um die Abhängigen mit dem Fremdschlüsselwertsatz einzufügen. Und da es dafür zwei Aussagen gibt, ist eine Transaktion erforderlich, was bedeutet, dass es insgesamt vier Roundtrips gibt:

dbug: 10/1/2022 13:12:02.517 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 13:12:02.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);
info: 10/1/2022 13:12:02.529 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[@p1='6', @p2='My first post' (Nullable = false) (Size = 4000), @p3='6', @p4='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Post] USING (
      VALUES (@p1, @p2, 0),
      (@p3, @p4, 1)) AS i ([BlogId], [Title], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([BlogId], [Title])
      VALUES (i.[BlogId], i.[Title])
      OUTPUT INSERTED.[Id], i._Position;
dbug: 10/1/2022 13:12:02.531 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

In einigen Fällen ist der Primärschlüsselwert jedoch bekannt, bevor der Prinzipal eingefügt wird. Dies umfasst u. a.:

  • Schlüsselwerte, die nicht automatisch generiert werden
  • Schlüsselwerte, die auf dem Client generiert werden, z. B. Schlüssel Guid
  • Schlüsselwerte, die auf dem Server in Batches generiert werden, z. B. bei Verwendung eines Hi-Lo-Wertgenerators

In EF7 sind diese Fälle nun auf einen einzelnen Roundtrip optimiert. Im obigen Fall auf dem SQL Server kann beispielsweise der Primärschlüssel Blog.Id für die Verwendung der Hi-Lo-Generationsstrategie konfiguriert werden:

modelBuilder.Entity<Blog>().Property(e => e.Id).UseHiLo();
modelBuilder.Entity<Post>().Property(e => e.Id).UseHiLo();

Der SaveChanges Aufruf von oben ist jetzt auf einen einzelnen Roundtrip für das Einfügen optimiert.

dbug: 10/1/2022 21:51:55.805 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 21:51:55.806 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='9', @p1='MyBlog' (Nullable = false) (Size = 4000), @p2='10', @p3='9', @p4='My first post' (Nullable = false) (Size = 4000), @p5='11', @p6='9', @p7='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Posts] ([Id], [BlogId], [Title])
      VALUES (@p2, @p3, @p4),
      (@p5, @p6, @p7);
dbug: 10/1/2022 21:51:55.807 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Beachten Sie, dass hier noch eine Transaktion erforderlich ist. Dies liegt daran, dass Einfügungen in zwei separate Tabellen erstellt werden.

EF7 verwendet auch einen einzigen Batch in anderen Fällen, in denen EF Core 6.0 mehrere erstellen würde. Beispielsweise beim Löschen und Einfügen von Zeilen in dieselbe Tabelle.

Der Wert von ÄnderungenSpeichern

Wie einige der hier gezeigten Beispiele zeigen, kann das Speichern von Ergebnissen in der Datenbank ein komplexes Geschäft sein. Hier zeigt die Verwendung von EF Core ihren wirklichen Wert. EF Core:

  • Die Befehle mehrere Batches zusammen einzufügen, zu aktualisieren und zu löschen reduziert Roundtrips
  • Gibt an, ob eine explizite Transaktion erforderlich ist oder nicht
  • Bestimmt, in welcher Reihenfolge Entitäten eingefügt, aktualisiert und gelöscht werden sollen, damit Datenbankeinschränkungen nicht verletzt werden
  • Stellt sicher, dass datenbankgenerierte Werte effizient zurückgegeben und wieder in die Entitäten verteilt werden
  • Legt Fremdschlüsselwerte automatisch mithilfe der für Primärschlüssel generierten Werte fest
  • Testen auf Parallelitätskonflikte

Darüber hinaus erfordern unterschiedliche Datenbanksysteme für viele dieser Fälle unterschiedlichen SQL-Code. Der EF Core-Datenbankanbieter arbeitet mit EF Core zusammen, um sicherzustellen, dass für jeden Fall korrekte und effiziente Befehle gesendet werden.

TPC-Vererbungszuordnung (Table-per-concrete-type, TPC)

EF Core ordnet standardmäßig eine Vererbungshierarchie von .NET-Typen einer einzelnen Datenbanktabelle zu. Sie wird als TPH-Zuordnungsstrategie (Table-per-Hierarchy) bezeichnet. EF Core 5.0 hat die TPT-Strategie (Table-per-Type) eingeführt, die Zuordnung jedes .NET-Typs zu einer anderen Datenbanktabelle unterstützt. EF7 führt die TPC-Strategie (Table-per-Concrete-Type) ein. TPC ordnet .NET-Typen auch verschiedenen Tabellen zu, aber in einer Weise, die sich mit einigen häufigen Leistungsproblemen mit der TPT-Strategie befasst.

Tipp

Der hier gezeigte Code stammt aus TpcInheritanceSample.cs.

Tipp

Das EF-Team demonstrierte und sprach ausführlich über die TPC-Zuordnung in einer Folge des .NET Data Community Standup. Wie bei allen Community-Standup-Episodenkönnen Sie die TPC-Episode jetzt auf YouTube ansehen.

Das TPC-Datenbankschema

Die TPC-Strategie ähnelt der TPT-Strategie, mit der Ausnahme, dass für jeden konkreten Typ in der Hierarchie eine andere Tabelle erstellt wird, aber Tabellen nicht für abstrakte Typen erstellt werden - daher der Name "Table-per-concrete-type". Wie bei TPT gibt die Tabelle selbst den Typ des gespeicherten Objekts an. Im Gegensatz zur TPT-Zuordnung enthält jede Tabelle jedoch Spalten für jede Eigenschaft im konkreten Typ und ihre Basistypen. TPC-Datenbankschematas werden denormalisiert.

Sehen Sie sich beispielsweise die Zuordnung dieses Hierarchie an:

public abstract class Animal
{
    protected Animal(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public abstract string Species { get; }

    public Food? Food { get; set; }
}

public abstract class Pet : Animal
{
    protected Pet(string name)
        : base(name)
    {
    }

    public string? Vet { get; set; }

    public ICollection<Human> Humans { get; } = new List<Human>();
}

public class FarmAnimal : Animal
{
    public FarmAnimal(string name, string species)
        : base(name)
    {
        Species = species;
    }

    public override string Species { get; }

    [Precision(18, 2)]
    public decimal Value { get; set; }

    public override string ToString()
        => $"Farm animal '{Name}' ({Species}/{Id}) worth {Value:C} eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Cat : Pet
{
    public Cat(string name, string educationLevel)
        : base(name)
    {
        EducationLevel = educationLevel;
    }

    public string EducationLevel { get; set; }
    public override string Species => "Felis catus";

    public override string ToString()
        => $"Cat '{Name}' ({Species}/{Id}) with education '{EducationLevel}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Dog : Pet
{
    public Dog(string name, string favoriteToy)
        : base(name)
    {
        FavoriteToy = favoriteToy;
    }

    public string FavoriteToy { get; set; }
    public override string Species => "Canis familiaris";

    public override string ToString()
        => $"Dog '{Name}' ({Species}/{Id}) with favorite toy '{FavoriteToy}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Human : Animal
{
    public Human(string name)
        : base(name)
    {
    }

    public override string Species => "Homo sapiens";

    public Animal? FavoriteAnimal { get; set; }
    public ICollection<Pet> Pets { get; } = new List<Pet>();

    public override string ToString()
        => $"Human '{Name}' ({Species}/{Id}) with favorite animal '{FavoriteAnimal?.Name ?? "<Unknown>"}'" +
           $" eats {Food?.ToString() ?? "<Unknown>"}";
}

Bei Verwendung von SQL Server sind die für diese Hierarchie erstellten Tabellen:

CREATE TABLE [Cats] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [EducationLevel] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([Id]));

CREATE TABLE [Dogs] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [FavoriteToy] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([Id]));

CREATE TABLE [FarmAnimals] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Value] decimal(18,2) NOT NULL,
    [Species] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_FarmAnimals] PRIMARY KEY ([Id]));

CREATE TABLE [Humans] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [FavoriteAnimalId] int NULL,
    CONSTRAINT [PK_Humans] PRIMARY KEY ([Id]));

Beachten Sie Folgendes:

  • Es gibt keine Tabellen für die Animal- oder Pet-Typen, da sich diese im Objektmodell befinden abstract. Denken Sie daran, dass C# keine Instanzen abstrakter Typen zulässt, und es daher keine Situation gibt, in der eine abstrakte Typinstanz in der Datenbank gespeichert wird.

  • Die Zuordnung von Eigenschaften in Basistypen wird für jeden konkreten Typ wiederholt. Beispielsweise verfügt jede Tabelle über eine Name-Spalte, und sowohl Katzen als auch Hunde haben eine Vet-Spalte.

  • Das Speichern einiger Daten in dieser Datenbank führt zu folgenden Ergebnissen:

Katzentabelle

Id Name FoodID Tierarzt Bildungsgrad
1 Alina 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly Vorschule
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Haustierkrankenhaus Bothell BSc

Hundetabelle

Id Name FoodID Tierarzt Lieblingsspielzeug
3 Toast 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly Mr. Squirrel

Bauernhoftiere-Tabelle

Id Name FoodID Wert Art
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100,00 Equus africanus asinus

Menschentabelle

Id Name FoodID LieblingstierID
5 Wendy 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Arthur 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Katie NULL 8

Beachten Sie, dass im Gegensatz zur TPT-Zuordnung alle Informationen für ein einzelnes Objekt in einer einzelnen Tabelle enthalten sind. Anders als bei der TPH-Zuordnung gibt es keine Kombination aus Spalte und Zeile in einer Tabelle, in der das Modell nie verwendet wird. Wir werden unten sehen, wie diese Merkmale für Abfragen und Speicher wichtig sein können.

Konfigurieren der TPC-Vererbung

Alle Typen in einer Vererbungshierarchie müssen explizit in das Modell einbezogen werden, wenn die Hierarchie mit EF Core zugeordnet wird. Dies kann durch Erstellen von DbSet Eigenschaften auf Ihren DbContext für jeden Typ erfolgen:

public DbSet<Animal> Animals => Set<Animal>();
public DbSet<Pet> Pets => Set<Pet>();
public DbSet<FarmAnimal> FarmAnimals => Set<FarmAnimal>();
public DbSet<Cat> Cats => Set<Cat>();
public DbSet<Dog> Dogs => Set<Dog>();
public DbSet<Human> Humans => Set<Human>();

Oder mithilfe der Entity-Methode in OnModelCreating:

modelBuilder.Entity<Animal>();
modelBuilder.Entity<Pet>();
modelBuilder.Entity<Cat>();
modelBuilder.Entity<Dog>();
modelBuilder.Entity<FarmAnimal>();
modelBuilder.Entity<Human>();

Wichtig

Dies unterscheidet sich vom älteren EF6-Verhalten, bei dem abgeleitete Typen von zugeordneten Basistypen automatisch ermittelt werden, wenn sie in derselben Assembly enthalten waren.

Es muss nichts anderes getan werden, um die Hierarchie als TPH zuzuordnen, da es sich um die Standardstrategie handelt. Ab EF7 kann TPH jedoch explizit gemacht werden, indem UseTphMappingStrategy auf den Basistyp der Hierarchie aufgerufen wird:

modelBuilder.Entity<Animal>().UseTphMappingStrategy();

Wenn Sie stattdessen TPT verwenden möchten, ändern Sie folgendes zu UseTptMappingStrategy:

modelBuilder.Entity<Animal>().UseTptMappingStrategy();

Ebenso wird UseTpcMappingStrategy zum Konfigurieren von TPC verwendet:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

In jedem Fall wird der für jeden Typ verwendete Tabellenname aus dem DbSet Eigenschaftsnamen ihres DbContextTyps entnommen oder kann mithilfe derToTable Generatormethode oder des [Table] Attributs konfiguriert werden.

TPC-Abfrageleistung

Bei Abfragen ist die TPC-Strategie eine Verbesserung gegenüber TPT, da sichergestellt wird, dass die Informationen für eine bestimmte Entitätsinstanz immer in einer einzelnen Tabelle gespeichert werden. Dies bedeutet, dass die TPC-Strategie nützlich sein kann, wenn die zugeordnete Hierarchie groß ist und viele konkrete Typen (in der Regel blattweise) mit einer großen Anzahl von Eigenschaften aufweist und in den meisten Abfragen nur eine kleine Teilmenge von Typen verwendet wird.

Der für drei einfache LINQ-Abfragen generierte SQL-Code kann verwendet werden, um zu beobachten, wo TPC im Vergleich zu TPH und TPT gut funktioniert. Diese Abfragen sind:

  1. Eine Abfrage, die Entitäten aller Typen in der Hierarchie zurückgibt:

    context.Animals.ToList();
    
  2. Eine Abfrage, die Entitäten aus einer Teilmenge von Typen in der Hierarchie zurückgibt:

    context.Pets.ToList();
    
  3. Eine Abfrage, die nur Entitäten aus einem einzelnen Blatttyp in der Hierarchie zurückgibt:

    context.Cats.ToList();
    

TPH-Abfragen

Bei Verwendung von TPH fragen alle drei Abfragen nur eine einzelne Tabelle ab, aber mit unterschiedlichen Filtern in der Diskriminatorspalte:

  1. TPH SQL gibt Entitäten aller Typen in der Hierarchie zurück:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Species], [a].[Value], [a].[FavoriteAnimalId], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    
  2. TPH SQL gibt Entitäten aus einer Teilmenge von Typen in der Hierarchie zurück:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] IN (N'Cat', N'Dog')
    
  3. TPH SQL gibt nur Entitäten aus einem einzelnen Blatttyp in der Hierarchie zurück:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] = N'Cat'
    

Alle diese Abfragen sollten gut ausgeführt werden, insbesondere bei einem geeigneten Datenbankindex in der Diskriminatorspalte.

TPT-Abfragen

Bei der Verwendung von TPT erfordern alle diese Abfragen das Verknüpfen mehrerer Tabellen, da die Daten für einen bestimmten konkreten Typ in viele Tabellen aufgeteilt werden:

  1. TPT SQL gibt Entitäten aller Typen in der Hierarchie zurück:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [f].[Species], [f].[Value], [h].[FavoriteAnimalId], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
        WHEN [h].[Id] IS NOT NULL THEN N'Human'
        WHEN [f].[Id] IS NOT NULL THEN N'FarmAnimal'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    LEFT JOIN [FarmAnimals] AS [f] ON [a].[Id] = [f].[Id]
    LEFT JOIN [Humans] AS [h] ON [a].[Id] = [h].[Id]
    LEFT JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  2. TPT SQL gibt Entitäten aus einer Teilmenge von Typen in der Hierarchie zurück:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  3. TPT SQL gibt nur Entitäten aus einem einzelnen Blatttyp in der Hierarchie zurück:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    INNER JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    

Hinweis

EF Core verwendet „Diskriminatorsynthese“, um zu bestimmen, aus welcher Tabelle die Daten stammen, und damit den richtigen Typ zu verwenden. Dies funktioniert, da die LEFT JOIN NULL-Werte für die abhängige ID-Spalte (die „Untertabellen“) zurückgibt, die nicht der richtige Typ sind. Bei einem Hund ist [d].[Id] also nicht NULL, aber alle anderen (konkreten) IDs werden NULL sein.

Alle diese Abfragen können aufgrund der Tabellenbeitritte unter Leistungsproblemen leiden. Deshalb ist TPT nie eine gute Wahl für die Abfrageleistung.

TPC-Abfragen

TPC verbessert TPT für alle diese Abfragen, da die Anzahl der Tabellen, die abgefragt werden müssen, reduziert wird. Darüber hinaus werden die Ergebnisse aus jeder Tabelle mit UNION ALL kombiniert, was wesentlich schneller als eine Tabellenverknüpfung sein kann, da keine Übereinstimmung zwischen Zeilen oder Deduplizierungen von Zeilen ausgeführt werden muss.

  1. TPC SQL gibt Entitäten aller Typen in der Hierarchie zurück:

    SELECT [f].[Id], [f].[FoodId], [f].[Name], [f].[Species], [f].[Value], NULL AS [FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'FarmAnimal' AS [Discriminator]
    FROM [FarmAnimals] AS [f]
    UNION ALL
    SELECT [h].[Id], [h].[FoodId], [h].[Name], NULL AS [Species], NULL AS [Value], [h].[FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'Human' AS [Discriminator]
    FROM [Humans] AS [h]
    UNION ALL
    SELECT [c].[Id], [c].[FoodId], [c].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  2. TPC SQL gibt Entitäten aus einer Teilmenge von Typen in der Hierarchie zurück:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  3. TPC SQL gibt nur Entitäten aus einem einzelnen Blatttyp in der Hierarchie zurück:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel]
    FROM [Cats] AS [c]
    

Obwohl TPC für alle diese Abfragen besser als TPT ist, sind die TPH-Abfragen beim Zurückgeben von Instanzen mehrerer Typen immer noch besser. Dies ist einer der Gründe, warum TPH die Standardstrategie ist, die von EF Core verwendet wird.

Wie der SQL-Code für Abfrage Nr. 3 zeigt, zeichnet TPC das Abfragen von Entitäten eines einzelnen Blatttyps wirklich aus. Die Abfrage verwendet nur eine einzelne Tabelle und benötigt keine Filterung.

Einfügungen und Aktualisierungen mit TPC

TPC hat auch beim Speichern einer neuen Entität eine gute Leistung, weil dadurch nur eine einzelne Zeile in eine einzelne Tabelle eingefügt werden muss. Dies gilt auch für TPH. Bei TPT müssen Zeilen in viele Tabellen eingefügt werden, was weniger gut funktioniert.

Das gleiche gilt häufig für Updates, obwohl in diesem Fall, wenn sich alle zu aktualisierenden Spalten in derselben Tabelle befinden, auch für TPT, der Unterschied möglicherweise nicht signifikant ist.

Überlegungen zum Speicherplatz

Sowohl TPT als auch TPC können weniger Speicher als TPH verwenden, wenn viele Untertypen mit vielen Eigenschaften vorhanden sind, die häufig nicht verwendet werden. Dies liegt daran, dass jede Zeile in der TPH-Tabelle eine NULL für jede dieser nicht verwendeten Eigenschaften speichern muss. In der Praxis ist dies selten ein Problem, aber es kann sinnvoll sein, große Datenmengen mit diesen Merkmalen zu speichern.

Tipp

Wenn Ihr Datenbanksystem es unterstützt (z. B. SQL Server), sollten Sie für TPH-Spalten, die selten aufgefüllt werden, "sparse columns" verwenden.

Schlüsselgenerierung

Die gewählte Vererbungszuordnungsstrategie hat Konsequenzen für die Erstellung und Verwaltung von Primärschlüsselwerten. Schlüssel in TPH sind einfach, da jede Entitätsinstanz durch eine einzelne Zeile in einer einzelnen Tabelle dargestellt wird. Jede Art von Schlüsselwertgenerierung kann verwendet werden, und es sind keine zusätzlichen Einschränkungen erforderlich.

Für die TPT-Strategie gibt es immer eine Zeile in der Tabelle, die dem Basistyp der Hierarchie zugeordnet ist. Jede Art von Schlüsselgenerierung kann in dieser Zeile verwendet werden, und die Schlüssel für andere Tabellen werden mithilfe von Fremdschlüsseleinschränkungen mit dieser Tabelle verknüpft.

Bei TPC werden die Dinge etwas komplizierter. Zunächst ist es wichtig zu verstehen, dass EF Core erfordert, dass alle Entitäten in einer Hierarchie über einen eindeutigen Schlüsselwert verfügen, auch wenn die Entitäten unterschiedliche Typen aufweisen. Mit unserem Beispielmodell kann ein Hund also nicht den gleichen ID-Schlüsselwert wie eine Katze haben. Im Gegensatz zu TPT gibt es keine gemeinsame Tabelle, die als einzelne Stelle fungieren kann, an der sich Schlüsselwerte befinden und generiert werden können. Dies bedeutet, dass eine einfache Identity-Spalte nicht verwendet werden kann.

Für Datenbanken, die Sequenzen unterstützen, können Schlüsselwerte mithilfe einer einzelnen Sequenz generiert werden, auf die in der Standardeinschränkung für jede Tabelle verwiesen wird. Dies ist die Strategie, die in den oben gezeigten TPC-Tabellen verwendet wird, wobei jede Tabelle folgendes hat:

[Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence])

AnimalSequence ist eine Datenbanksequenz, die von EF Core erstellt wird. Diese Strategie wird bei Verwendung des EF Core-Datenbankanbieters für SQL Server standardmäßig für TPC-Hierarchien verwendet. Datenbankanbieter für andere Datenbanken, die Sequenzen unterstützen, sollten einen ähnlichen Standardwert aufweisen. Andere Schlüsselgenerierungsstrategien, die Sequenzen wie Hi-Lo-Muster verwenden, können auch mit TPC verwendet werden.

Obwohl Standardidentitätsspalten nicht mit TPC funktionieren, ist es dennoch möglich, Identitätsspalten zu verwenden, wenn jede Tabelle mit einem geeigneten Seed konfiguriert und erhöht wird, sodass die für jede Tabelle generierten Werte niemals in Konflikt stehen. Beispiel:

modelBuilder.Entity<Cat>().ToTable("Cats", tb => tb.Property(e => e.Id).UseIdentityColumn(1, 4));
modelBuilder.Entity<Dog>().ToTable("Dogs", tb => tb.Property(e => e.Id).UseIdentityColumn(2, 4));
modelBuilder.Entity<FarmAnimal>().ToTable("FarmAnimals", tb => tb.Property(e => e.Id).UseIdentityColumn(3, 4));
modelBuilder.Entity<Human>().ToTable("Humans", tb => tb.Property(e => e.Id).UseIdentityColumn(4, 4));

SQLite unterstützt keine Sequenzen oder Identity Seeds/Increments, und daher wird die Wertgenerierung ganzzahliger Schlüssel nicht unterstützt, wenn SQLite mit der TPC-Strategie verwendet wird. Clientseitige Generierung oder global eindeutige Schlüssel – z. B. GUID-Schlüssel – werden für jede Datenbank unterstützt, einschließlich SQLite.

Fremdschlüsseleinschränkungen

Die TPC-Zuordnungsstrategie erstellt ein denormalisiertes SQL-Schema – dies ist ein Grund, warum einige Datenbank-Puristen dagegen sind. Betrachten Sie z. B. die Fremdschlüsselspalte FavoriteAnimalId. Der Wert in dieser Spalte muss mit dem Primärschlüsselwert einiger Tiere übereinstimmen. Dies kann in der Datenbank mit einer einfachen FK-Einschränkung erzwungen werden, wenn TPH oder TPT verwendet wird. Beispiel:

CONSTRAINT [FK_Animals_Animals_FavoriteAnimalId] FOREIGN KEY ([FavoriteAnimalId]) REFERENCES [Animals] ([Id])

Aber bei Verwendung von TPC wird der Primärschlüssel für ein Tier in der Tabelle für den konkreten Typ dieses Tieres gespeichert. Beispielsweise wird der Primärschlüssel einer Katze in der Cats.Id-Spalte gespeichert, während der Primärschlüssel eines Hundes in der Dogs.Id-Spalte gespeichert ist usw. Dies bedeutet, dass für diese Beziehung keine FK-Einschränkung erstellt werden kann.

In der Praxis ist dies kein Problem, solange die Anwendung nicht versucht, ungültige Daten einzufügen. Wenn beispielsweise alle Daten von EF Core eingefügt und Navigationen zum Verknüpfen von Entitäten verwendet werden, wird sichergestellt, dass die FK-Spalte jederzeit einen gültigen PK-Wert enthält.

Zusammenfassung und Orientierungshilfe

Zusammenfassend ist TPC eine gute Zuordnungsstrategie, die verwendet werden kann, wenn Ihr Code hauptsächlich Entitäten eines einzelnen Blatttyps abfragt. Dies liegt daran, dass die Speicheranforderungen kleiner sind und es keine Diskriminatorspalte gibt, die möglicherweise einen Index benötigt. Einfügungen und Aktualisierungen sind ebenfalls effizient.

Das heißt, TPH ist in der Regel für die meisten Anwendungen in Ordnung und ist ein guter Standardwert für eine Vielzahl von Szenarien, sodass Sie die Komplexität von TPC nicht unnötigerweise hinzufügen müssen. Wenn Ihr Code hauptsächlich Entitäten vieler Typen abfragt, z. B. Abfragen für den Basistyp, dann sollten Sie TPH gegenüber TPC bevorzugen.

Verwenden Sie TPT nur, wenn dies durch externe Faktoren vorgegeben wird.

Benutzerdefinierte Reverse-Engineering-Vorlagen

Sie können den Gerüstcode jetzt anpassen, wenn Sie Reverse-Engineering mit einem EF-Modell aus einer Datenbank betreiben. Erste Schritte durch Hinzufügen der Standardvorlagen zu Ihrem Projekt:

dotnet new install Microsoft.EntityFrameworkCore.Templates
dotnet new ef-templates

Die Vorlagen können dann angepasst werden und werden automatisch von dotnet ef dbcontext scaffold und Scaffold-DbContextverwendet.

Weitere Details finden Sie unter Benutzerdefinierte Reverse Engineering-Vorlagen.

Tipp

Das EF-Team hat in einer Folge des .NET Data Community Standups ausführliche Informationen zu Reverse-Engineering-Vorlagen gezeigt und besprochen. Wie bei allen Community-Standup-Episodenkönnen Sie die T4-Vorlagen-Episode jetzt auf YouTubeansehen.

Konventionen für die Modellerstellung

EF Core verwendet ein „Metadatenmodell“, um zu beschreiben, wie die Entitätstypen der Anwendung der zugrunde liegenden Datenbank zugeordnet werden. Dieses Modell wird mit einer Reihe von rund 60 „Konventionen“ erstellt. Das von Konventionen erstellte Modell kann dann mithilfe von Zuordnungsattributen (auch „Datenanmerkungen“ genannt) und/oder Aufrufen der DbModelBuilder API OnModelCreatingangepasst werden.

Ab EF7 können Anwendungen jetzt eine dieser Konventionen entfernen oder ersetzen sowie neue Konventionen hinzufügen. Modellbaukonventionen sind eine leistungsfähige Möglichkeit, die Modellkonfiguration zu steuern, aber sie können komplex und schwer zu erreichen sein. In vielen Fällen kann stattdessen die vorhandene Konfiguration des Vorkonventionsmodells verwendet werden, um einfach eine gemeinsame Konfiguration für Eigenschaften und Typen anzugeben.

Änderungen an der Konvention durch DbContext werden durch Außerkraftsetzung der DbContext.ConfigureConventions Methode vorgenommen. Beispiel:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

Tipp

Um alle integrierten Modellbaukonventionen zu finden, suchen Sie nach jeder Klasse, welche die IConvention-Schnittstelle implementiert.

Tipp

Der unten gezeigte Code stammt aus ModelBuildingConventionsSample.cs.

Entfernen einer vorhandenen Konvention

Manchmal ist eine der integrierten Konventionen für Ihre Anwendung möglicherweise nicht geeignet, in diesem Fall kann sie entfernt werden.

Beispiel: Erstellen Sie keine Indizes für Fremdschlüsselspalten.

Normalerweise ist es sinnvoll, Indizes für FK-Spalten (Fremdschlüsselspalten) zu erstellen. Daher gibt es hierfür eine integrierte Konvention: ForeignKeyIndexConvention. Wenn wir das Modell debug view (Debugansicht) für einen Post-Entitätstyp mit Beziehungen zu Blog und Author betrachten, erkennen wir, dass zwei Indizes erstellt werden: einer für den Fremdschlüssel BlogId und der andere für den Fremdschlüssel AuthorId.

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK Index
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      AuthorId
      BlogId

Aber Indizes haben einen Mehraufwand und, wie hier gefragt, ist es vielleicht nicht immer sinnvoll, sie für alle FK-Spalten zu erstellen. Hierzu kann die ForeignKeyIndexConvention-Klasse beim Erstellen des Modells entfernt werden:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

In der Debugansicht des Modells für Post sehen Sie jetzt, dass die Indizes für die FKs nicht erstellt wurden:

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade

Bei Bedarf können Indizes weiterhin explizit für Fremdschlüsselspalten erstellt werden, entweder mithilfe der IndexAttribute:

[Index("BlogId")]
public class Post
{
    // ...
}

Oder mit der Konfiguration in OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>(entityTypeBuilder => entityTypeBuilder.HasIndex("BlogId"));
}

Wenn Sie den Entitätstyp Post erneut betrachten, enthält er nun den Index BlogId, aber nicht den Index AuthorId:

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      BlogId

Tipp

Wenn Ihr Modell keine Zuordnungsattribute (auch Datenanmerkungen genannt) für die Konfiguration verwendet, können alle Konventionen mit dem Namen, der auf AttributeConvention endet, sicher entfernt werden, um die Modellerstellung zu beschleunigen.

Hinzufügen einer neuen Konvention

Das Entfernen vorhandener Konventionen ist ein Anfang, aber was ist mit dem Hinzufügen völlig neuer Modellbaukonventionen? EF7 unterstützt auch das!

Beispiel: Einschränken der Länge von Diskriminatoreigenschaften

Für die TPH-Vererbungszuordnungsstrategie pro Hierarchie ist eine Diskriminatorspalte erforderlich, um anzugeben, welcher Typ in einer Zeile dargestellt wird. Standardmäßig verwendet EF eine ungebundene Zeichenfolgenspalte für den Diskriminator, wodurch sichergestellt wird, dass sie für jede Diskriminatorlänge funktioniert. Das Einschränken der maximalen Länge von Diskriminatorzeichenfolgen kann jedoch eine effizientere Speicherung und Abfragen ermöglichen. Lassen Sie uns eine neue Konvention erstellen, die dies tut.

EF Core-Modellbaukonventionen werden basierend auf Änderungen am Modell während der Erstellung ausgelöst. Dadurch bleibt das Modell auf dem neuesten Stand, da eine explizite Konfiguration vorgenommen wird, Zuordnungsattribute angewendet und andere Konventionen ausgeführt werden. Um daran teilzunehmen, implementiert jede Konvention eine oder mehrere Schnittstellen, die bestimmen, wann die Konvention ausgelöst wird. Eine Konvention, die implementiert wird, wird beispielsweise ausgelöst, wenn dem Modell ein neuer Entitätstyp IEntityTypeAddedConvention hinzugefügt wird. Ebenso wird eine Konvention, die sowohl IForeignKeyAddedConvention also auch IKeyAddedConvention implementiert, ausgelöst, wenn dem Modell entweder ein Schlüssel oder ein Fremdschlüssel hinzugefügt wird.

Das Wissen, welche Schnittstellen implementiert werden sollen, kann schwierig sein, da die Konfiguration, die an einem Punkt an dem Modell vorgenommen wurde, zu einem späteren Zeitpunkt geändert oder entfernt werden kann. Beispielsweise kann ein Schlüssel durch Konvention erstellt, aber später ersetzt werden, wenn ein anderer Schlüssel explizit konfiguriert wird.

Lassen Sie uns dies etwas konkreter machen, indem wir zunächst versuchen, die Diskriminatorlängen-Konvention zu implementieren:

public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
    public void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
        if (discriminatorProperty != null
            && discriminatorProperty.ClrType == typeof(string))
        {
            discriminatorProperty.Builder.HasMaxLength(24);
        }
    }
}

Diese Konvention implementiert IEntityTypeBaseTypeChangedConvention, was bedeutet, dass sie ausgelöst wird, wenn die zugeordnete Vererbungshierarchie für einen Entitätstyp geändert wird. Die Konvention sucht und konfiguriert dann die Zeichenfolgendiskriminatoreigenschaft für die Hierarchie.

Diese Konvention wird dann durch Aufrufen von Add in ConfigureConventions verwendet:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ =>  new DiscriminatorLengthConvention1());
}

Tipp

Anstatt eine Instanz der Konvention direkt hinzuzufügen, akzeptiert die Methode Add eine Factory zum Erstellen von Instanzen der Konvention. Dadurch kann die Konvention Abhängigkeiten vom internen EF Core-Dienstanbieter verwenden. Da diese Konvention keine Abhängigkeiten aufweist, wird der Dienstanbieterparameter _ genannt, um anzugeben, dass er nie verwendet wird.

Wenn Sie das Modell erstellen und den Entitätstyp Post betrachten, wird gezeigt, dass dies funktioniert hat – die Diskriminatoreigenschaft ist jetzt mit einer maximalen Länge von 24 konfiguriert:

 Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

Aber was geschieht, wenn wir jetzt explizit eine andere Diskriminatoreigenschaft konfigurieren? Beispiel:

modelBuilder.Entity<Post>()
    .HasDiscriminator<string>("PostTypeDiscriminator")
    .HasValue<Post>("Post")
    .HasValue<FeaturedPost>("Featured");

In der Debugansicht des Modells stellen wir fest, dass die Diskriminatorlänge nicht mehr konfiguriert ist!

 PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw

Dies liegt daran, dass die Diskriminatoreigenschaft, die wir in unserer Konvention konfiguriert haben, später entfernt wurde, als der benutzerdefinierte Diskriminator hinzugefügt wurde. Wir könnten versuchen, dies zu beheben, indem wir eine andere Schnittstelle in unserer Konvention implementieren, um auf die Diskriminatoränderungen zu reagieren, aber herauszufinden, welche Schnittstelle implementiert werden soll, ist nicht einfach.

Glücklicherweise gibt es eine andere Möglichkeit, das zu erreichen, was die Dinge viel einfacher macht. Oft spielt es keine Rolle, wie das Modell aussieht, während es erstellt wird, solange das endgültige Modell korrekt ist. Darüber hinaus muss die Konfiguration, die wir anwenden möchten, häufig keine anderen Konventionen auslösen, um zu reagieren. Daher kann unsere Konvention IModelFinalizingConvention umsetzen. Die Abschlusskonventionen des Modells werden ausgeführt, nachdem alle anderen Modellerstellungskonventionen abgeschlossen sind und somit Zugriff auf den endgültigen Zustand des Modells haben. Eine Abschlusskonvention für ein Modell durchläuft in der Regel das gesamte Modell und konfiguriert währenddessen Modellelemente. In diesem Fall finden wir also jeden Diskriminator im Modell und konfigurieren ihn:

public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                discriminatorProperty.Builder.HasMaxLength(24);
            }
        }
    }
}

Nach dem Erstellen des Modells mit dieser neuen Konvention stellen wir fest, dass die Diskriminatorlänge jetzt richtig konfiguriert ist, obwohl sie angepasst wurde:

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

Lassen Sie uns einfach einen Schritt weitergehen und die maximale Länge so konfigurieren, dass sie die Länge des längsten Diskriminatorwerts ist.

public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                var maxDiscriminatorValueLength =
                    entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

                discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
            }
        }
    }
}

Jetzt beträgt die maximale Länge der Diskriminatorspalte 8, also der Länge von "Featured", dem längsten verwendeten Diskriminatorwert.

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(8)

Tipp

Vielleicht fragen Sie sich, ob die Konvention auch einen Index für die Diskriminatorspalte erstellen soll. Es gibt eine Diskussion darüber auf GitHub. Die kurze Antwort ist, dass manchmal ein Index nützlich sein könnte, er es aber die meiste Zeit wahrscheinlich nicht sein wird. Daher ist es am besten, bei Bedarf geeignete Indizes zu erstellen, anstatt eine Konvention dafür zu haben. Aber wenn Sie damit nicht einverstanden sind, kann die oben genannte Konvention problemlos geändert werden, um auch einen Index zu erstellen.

Beispiel: Standardlänge für alle Zeichenfolgeneigenschaften

Sehen wir uns ein weiteres Beispiel an, in dem eine abschließende Konvention verwendet werden kann – dieses Mal wird eine Standardlänge für eine beliebige Zeichenfolgeneigenschaft festgelegt, wie sie auf GitHub angefordert wird. Die Konvention sieht dem vorherigen Beispiel ziemlich ähnlich:

public class MaxStringLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            property.Builder.HasMaxLength(512);
        }
    }
}

Diese Konvention ist ziemlich einfach. Es findet jede Zeichenfolgeneigenschaft im Modell und legt die maximale Länge auf 512 fest. Wenn Sie in der Debugansicht nach den Eigenschaften von Post suchen, sehen wir, dass alle Zeichenfolgeneigenschaften jetzt eine maximale Länge von 512 haben.

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(512)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Aber die Eigenschaft Content sollte wahrscheinlich mehr als 512 Zeichen zulassen, oder alle unsere Beiträge werden ziemlich kurz! Dies kann ohne Änderung unserer Konvention erfolgen, indem die maximale Länge nur für diese Eigenschaft explizit konfiguriert wird, entweder mithilfe eines Zuordnungsattributs:

[MaxLength(4000)]
public string Content { get; set; }

Oder mit Code in OnModelCreating:

modelBuilder.Entity<Post>()
    .Property(post => post.Content)
    .HasMaxLength(4000);

Jetzt haben alle Eigenschaften eine maximale Länge von 512, mit der Ausnahme von Content, das explizit auf 4000 konfiguriert wurde:

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(4000)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Warum hat unsere Konvention die explizit konfigurierte maximale Länge nicht außer Kraft setzen? Die Antwort lautet, dass EF Core nachverfolgt, wie jede Konfiguration vorgenommen wird. Dies wird durch die ConfigurationSource Enumeration dargestellt. Die verschiedenen Konfigurationsarten sind:

  • Explicit: Das Modellelement wurde explizit in OnModelCreating konfiguriert
  • DataAnnotation: Das Modellelement wurde mithilfe eines Zuordnungsattributs (auch Datenanmerkung genannt) für den CLR-Typ konfiguriert
  • Convention: Das Modellelement wurde durch eine Modellbaukonvention konfiguriert

Konventionen überschreiben niemals die Konfiguration, die als DataAnnotation oder Explicit markiert ist. Dies wird mithilfe eines „Konventionsgenerators“ erreicht, z. B. der IConventionPropertyBuilder, der aus der Builder Eigenschaft abgerufen wird. Beispiel:

property.Builder.HasMaxLength(512);

Durch Aufrufen HasMaxLength des Konventionsgenerators wird nur die maximale Länge festgelegt, wenn sie noch nicht durch ein Zuordnungsattribut oder in OnModelCreating konfiguriert wurde.

Generatormethoden wie diese haben auch einen zweiten Parameter: fromDataAnnotation. Legen Sie diese Einstellung auf true fest, wenn die Konvention die Konfiguration im Auftrag eines Zuordnungsattributs vornimmt. Beispiel:

property.Builder.HasMaxLength(512, fromDataAnnotation: true);

Dadurch wird ConfigurationSource auf DataAnnotation festgelegt, was bedeutet, dass der Wert jetzt durch explizite Zuordnungen auf OnModelCreating überschrieben werden kann, aber nicht durch Nichtzuordnungsattributekonventionen.

Was geschieht schließlich, bevor wir dieses Beispiel verlassen, wenn wir sowohl die MaxStringLengthConvention als auch DiscriminatorLengthConvention3 gleichzeitig verwenden? Die Antwort lautet, dass es von der Reihenfolge abhängt, in der sie hinzugefügt werden, da modellbasierte Konventionen in der Reihenfolge ausgeführt werden, in der sie hinzugefügt werden. Wenn MaxStringLengthConvention also zuletzt hinzugefügt wird, wird es zuletzt ausgeführt, und legt die maximale Länge der Diskriminatoreigenschaft auf 512 fest. Daher ist es in diesem Fall besser, DiscriminatorLengthConvention3 als letztes hinzuzufügen, damit sie die Standardlänge für nur Diskriminatoreigenschaften außer Kraft setzen kann, während alle anderen Zeichenfolgeneigenschaften als 512 verbleiben.

Entfernen einer vorhandenen Konvention

Manchmal wollen wir anstatt eine vorhandene Konvention vollständig zu entfernen sie stattdessen durch eine Konvention ersetzen, die im Grunde dasselbe tut, aber mit einem geänderten Verhalten. Dies ist nützlich, da die vorhandene Konvention bereits die benötigten Schnittstellen implementiert, um entsprechend ausgelöst zu werden.

Beispiel: Opt-In-Eigenschaftszuordnung

EF Core ordnet alle öffentlichen Lese-/Schreibeigenschaften nach Konvention zu. Dies eignet sich möglicherweise nicht für die Art und Weise, wie die Entitätstypen definiert sind. Um dies zu ändern, können wir die PropertyDiscoveryConvention mit unserer eigenen Implementierung ersetzen, die keine Eigenschaft zuordnen, es sei denn, sie wird explizit in OnModelCreating einem neuen Attribut zugeordnet oder mit einem neuen Attribut namens Persist gekennzeichnet:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class PersistAttribute : Attribute
{
}

Hier ist die neue Konvention:

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

Tipp

Beim Ersetzen einer integrierten Konvention sollte die neue Konventionsimplementierung von der vorhandenen Konventionsklasse erben. Beachten Sie, dass einige Konventionen relationale oder anbieterspezifische Implementierungen aufweisen. In diesem Fall sollte die neue Konventionsimplementierung von der spezifischen vorhandenen Konventionsklasse des verwendeten Datenbankanbieters erben.

Die Konvention wird dann mit der Replace-Methode in ConfigureConventions verwendet:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Replace<PropertyDiscoveryConvention>(
        serviceProvider => new AttributeBasedPropertyDiscoveryConvention(
            serviceProvider.GetRequiredService<ProviderConventionSetBuilderDependencies>()));
}

Tipp

Dies ist ein Fall, in dem die vorhandene Konvention Abhängigkeiten aufweist, die durch das Abhängigkeitsobjekt ProviderConventionSetBuilderDependencies dargestellt werden. Diese werden vom internen Dienstanbieter abgerufen GetRequiredService und an den Konventionskonstruktor übergeben.

Diese Konvention funktioniert, indem alle lesbaren Eigenschaften und Felder aus dem angegebenen Entitätstyp abgerufen werden. Wenn das Element [Persist] zugeordnet ist, wird es durch Aufrufen zugeordnet:

entityTypeBuilder.Property(memberInfo);

Wenn es sich bei dem Element um eine Eigenschaft handelt, die andernfalls zugeordnet worden wäre, wird es vom Modell ausgeschlossen, indem Folgendes verwendet wird:

entityTypeBuilder.Ignore(propertyInfo.Name);

Beachten Sie, dass diese Konvention die Zuordnung von Feldern (zusätzlich zu Eigenschaften) ermöglicht, solange sie mit [Persist]gekennzeichnet sind. Dies bedeutet, dass wir private Felder als ausgeblendete Schlüssel im Modell verwenden können.

Berücksichtigen Sie beispielsweise folgende Entitätstypen:

public class LaundryBasket
{
    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    public bool IsClean { get; set; }

    public List<Garment> Garments { get; } = new();
}

public class Garment
{
    public Garment(string name, string color)
    {
        Name = name;
        Color = color;
    }

    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    [Persist]
    public string Name { get; }

    [Persist]
    public string Color { get; }

    public bool IsClean { get; set; }

    public LaundryBasket? Basket { get; set; }
}

Das Modell, das aus diesen Entitätstypen erstellt wurde, ist:

Model:
  EntityType: Garment
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Basket_id (no field, int?) Shadow FK Index
      Color (string) Required
      Name (string) Required
      TenantId (int) Required
    Navigations:
      Basket (LaundryBasket) ToPrincipal LaundryBasket Inverse: Garments
    Keys:
      _id PK
    Foreign keys:
      Garment {'Basket_id'} -> LaundryBasket {'_id'} ToDependent: Garments ToPrincipal: Basket ClientSetNull
    Indexes:
      Basket_id
  EntityType: LaundryBasket
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      TenantId (int) Required
    Navigations:
      Garments (List<Garment>) Collection ToDependent Garment Inverse: Basket
    Keys:
      _id PK

Beachten Sie, dass normalerweise IsClean zugeordnet worden wäre, aber da es nicht mit [Perist] gekennzeichnet ist (vermutlich weil Sauberkeit keine persistentes Eigenschaft von Wäsche ist), wird sie jetzt als nicht zugeordnete Eigenschaft behandelt.

Tipp

Diese Konvention könnte nicht als Abschlusskonvention für ein Modell implementiert werden, da die Zuordnung einer Eigenschaft viele andere Konventionen auslöst, um die zugeordnete Eigenschaft weiter zu konfigurieren.

Mapping der gespeicherten Prozedur

Standardmäßig generiert EF Core Einfüge-, Aktualisierungs- und Löschbefehle, die direkt mit Tabellen oder aktualisierbaren Ansichten funktionieren. EF7 bietet Unterstützung für die Zuordnung dieser Befehle zu gespeicherten Prozeduren.

Tipp

EF Core hat schon immer die Abfrage über gespeicherte Prozeduren unterstützt. Die neue Unterstützung in EF7 unterstützt explizit gespeicherte Prozeduren für Einfügungen, Aktualisierungen und Löschvorgänge.

Wichtig

Die Unterstützung für die Zuordnung gespeicherter Prozeduren bedeutet nicht, dass gespeicherte Prozeduren empfohlen werden.

Gespeicherte Prozeduren werden in OnModelCreating mithilfe von InsertUsingStoredProcedure, UpdateUsingStoredProcedure und DeleteUsingStoredProcedure zugeordnet. So ordnen Sie beispielsweise gespeicherte Prozeduren für einen Entitätstyp Person zu:

modelBuilder.Entity<Person>()
    .InsertUsingStoredProcedure(
        "People_Insert",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(a => a.Name);
            storedProcedureBuilder.HasResultColumn(a => a.Id);
        })
    .UpdateUsingStoredProcedure(
        "People_Update",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        })
    .DeleteUsingStoredProcedure(
        "People_Delete",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        });

Diese Konfiguration ordnet die folgenden gespeicherten Prozeduren bei Verwendung von SQL Servern zu:

Für Einfügungen

CREATE PROCEDURE [dbo].[People_Insert]
    @Name [nvarchar](max)
AS
BEGIN
      INSERT INTO [People] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@Name);
END

Für Aktualisierungen

CREATE PROCEDURE [dbo].[People_Update]
    @Id [int],
    @Name_Original [nvarchar](max),
    @Name [nvarchar](max)
AS
BEGIN
    UPDATE [People] SET [Name] = @Name
    WHERE [Id] = @Id AND [Name] = @Name_Original
    SELECT @@ROWCOUNT
END

Für Löschungen

CREATE PROCEDURE [dbo].[People_Delete]
    @Id [int],
    @Name_Original [nvarchar](max)
AS
BEGIN
    DELETE FROM [People]
    OUTPUT 1
    WHERE [Id] = @Id AND [Name] = @Name_Original;
END

Tipp

Gespeicherte Prozeduren müssen nicht für jeden Typ im Modell oder für alle Vorgänge eines bestimmten Typs verwendet werden. Wenn beispielsweise nur DeleteUsingStoredProcedure für einen bestimmten Typ angegeben wird, generiert EF Core SQL normal für Einfüge- und Aktualisierungsvorgänge und verwendet nur die gespeicherte Prozedur für Löschvorgänge.

Das erste Argument, das an jede Methode übergeben wird, ist der Name der gespeicherten Prozedur. Dies kann weggelassen werden und in diesem Fall verwendet EF Core den Tabellennamen, der mit „_Einfügen“, „_Aktualisieren“ oder „_Loschen“ angefügt wird. Da die Tabelle im obigen Beispiel "Personen" genannt wird, können die Namen der gespeicherten Prozeduren ohne Änderung der Funktionalität entfernt werden.

Das zweite Argument ist ein Generator zum Konfigurieren der Eingabe und Ausgabe der gespeicherten Prozedur, einschließlich Parametern, Rückgabewerten und Ergebnisspalten.

Parameter

Parameter müssen dem Generator in derselben Reihenfolge wie in der Definition der gespeicherten Prozedur hinzugefügt werden.

Hinweis

Parameter können benannt werden, aber EF Core ruft gespeicherte Prozeduren immer mithilfe von Positionsargumenten anstelle von benannten Argumenten auf. Stimmen Sie für Konfiguration der Sproc-Zuordnung, um Parameternamen für Aufrufe zu verwenden, wenn das Aufrufen mit dem Namen etwas ist, an dem Sie interessiert sind.

Das erste Argument für jede Parameter-Generator-Methode gibt die Eigenschaft im Modell an, an das der Parameter gebunden ist. Dies kann ein Lambda-Ausdruck sein:

storedProcedureBuilder.HasParameter(a => a.Name);

Oder eine Zeichenfolge, die besonders nützlich ist, wenn Schatteneigenschaften zugeordnet werden:

storedProcedureBuilder.HasParameter("Name");

Parameter sind standardmäßig für „Eingabe“ konfiguriert. Parameter „Ausgabe“ oder „Eingabe/Ausgabe“ können mit einem geschachtelten Generator konfiguriert werden. Beispiel:

storedProcedureBuilder.HasParameter(
    document => document.RetrievedOn, 
    parameterBuilder => parameterBuilder.IsOutput());

Es gibt drei verschiedene Generatormethoden für verschiedene Varianten von Parametern:

  • HasParameter gibt einen normalen Parameter an, der an den aktuellen Wert der angegebenen Eigenschaft gebunden ist.
  • HasOriginalValueParameter gibt einen Parameter an, der an den ursprünglichen Wert der angegebenen Eigenschaft gebunden ist. Der ursprüngliche Wert ist der Wert, den die Eigenschaft hatte, als sie aus der Datenbank abgefragt wurde, sofern er bekannt ist. Wenn dieser Wert nicht bekannt ist, wird stattdessen der aktuelle Wert verwendet. Ursprüngliche Wertparameter sind nützlich für Parallelitätstoken.
  • HasRowsAffectedParameter gibt einen Parameter an, der verwendet wird, um die Anzahl der Zeilen zurückzugeben, die von der gespeicherten Prozedur betroffen sind.

Tipp

Ursprüngliche Wertparameter müssen für Schlüsselwerte in gespeicherten Prozeduren „Aktualisieren“ und „Löschen“ verwendet werden. Dadurch wird sichergestellt, dass die richtige Zeile in zukünftigen Versionen von EF Core aktualisiert wird, die veränderbare Schlüsselwerte unterstützt.

Zurückgeben von Werten

EF Core unterstützt drei Mechanismen zum Zurückgeben von Werten aus gespeicherten Prozeduren:

  • Ausgabeparameter, wie oben dargestellt.
  • Ergebnisspalten, die mit der Generatormethode HasResultColumn angegeben werden.
  • Der Rückgabewert, der auf die Rückgabe der Anzahl betroffener Zeilen beschränkt ist und mithilfe der Generatormethode HasRowsAffectedReturnValue angegeben wird.

Von gespeicherten Prozeduren zurückgegebene Werte werden häufig als generierte, Standard- oder berechnete Werte verwendet, z. B. aus einem Identity-Schlüssel oder einer berechneten Spalte. Die folgende Konfiguration gibt beispielsweise vier Ergebnisspalten an:

entityTypeBuilder.InsertUsingStoredProcedure(
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(document => document.Title);
            storedProcedureBuilder.HasResultColumn(document => document.Id);
            storedProcedureBuilder.HasResultColumn(document => document.FirstRecordedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RetrievedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RowVersion);
        });

Diese werden verwendet, um Folgendes zurückzugeben:

  • Der generierte Schlüsselwert für die Eigenschaft Id.
  • Der von der Datenbank für die Eigenschaft FirstRecordedOn generierte Standardwert.
  • Der berechnete Wert, der von der Datenbank für die Eigenschaft RetrievedOn generiert wird.
  • Das automatisch generierte Parallelitätstoken rowversion für die Eigenschaft RowVersion.

Diese Konfiguration wird der folgenden gespeicherten Prozedur bei Verwendung von SQL Server zugeordnet:

CREATE PROCEDURE [dbo].[Documents_Insert]
    @Title [nvarchar](max)
AS
BEGIN
    INSERT INTO [Documents] ([Title])
    OUTPUT INSERTED.[Id], INSERTED.[FirstRecordedOn], INSERTED.[RetrievedOn], INSERTED.[RowVersion]
    VALUES (@Title);
END

Optimistische Nebenläufigkeit

Optimistische Parallelität funktioniert auf die gleiche Weise mit gespeicherten Prozeduren wie ohne. Die gespeicherte Prozedur sollte:

  • Ein Parallelitätstoken in einer WHERE-Klausel verwenden, um sicherzustellen, dass die Zeile nur aktualisiert wird, wenn sie über ein gültiges Token verfügt. Der für das Parallelitätstoken verwendete Wert ist in der Regel, aber nicht zwingend, der ursprüngliche Wert der Parallelitätstokeneigenschaft.
  • Die Anzahl der betroffenen Zeilen zurückgeben, damit EF Core dies mit der erwarteten Anzahl betroffener Zeilen vergleichen kann, und DbUpdateConcurrencyException auslösen, wenn die Werte nicht übereinstimmen.

Die folgende gespeicherte SQL Server-Prozedur verwendet beispielsweise ein rowversion automatisches Parallelitätstoken:

CREATE PROCEDURE [dbo].[Documents_Update]
    @Id [int],
    @RowVersion_Original [rowversion],
    @Title [nvarchar](max),
    @RowVersion [rowversion] OUT
AS
BEGIN
    DECLARE @TempTable table ([RowVersion] varbinary(8));
    UPDATE [Documents] SET
        [Title] = @Title
    OUTPUT INSERTED.[RowVersion] INTO @TempTable
    WHERE [Id] = @Id AND [RowVersion] = @RowVersion_Original
    SELECT @@ROWCOUNT;
    SELECT @RowVersion = [RowVersion] FROM @TempTable;
END

Dies ist in EF Core konfiguriert mithilfe von:

.UpdateUsingStoredProcedure(
    storedProcedureBuilder =>
    {
        storedProcedureBuilder.HasOriginalValueParameter(document => document.Id);
        storedProcedureBuilder.HasOriginalValueParameter(document => document.RowVersion);
        storedProcedureBuilder.HasParameter(document => document.Title);
        storedProcedureBuilder.HasParameter(document => document.RowVersion, parameterBuilder => parameterBuilder.IsOutput());
        storedProcedureBuilder.HasRowsAffectedResultColumn();
    });

Beachten Sie Folgendes:

  • Der ursprüngliche Wert des Parallelitätstokens RowVersion wird verwendet.
  • Die gespeicherte Prozedur verwendet eine WHERE-Klausel, um sicherzustellen, dass die Zeile nur aktualisiert wird, wenn der ursprüngliche Wert RowVersion übereinstimmt.
  • Der neue generierte Wert für RowVersion wird in eine temporäre Tabelle eingefügt.
  • Die Anzahl der betroffenen Zeilen (@@ROWCOUNT) und der generierte RowVersion Wert werden zurückgegeben.

Zuordnen von Vererbungshierarchien zu gespeicherten Prozeduren

EF Core erfordert, dass gespeicherte Prozeduren dem Tabellenlayout für Typen in einer Hierarchie folgen. Dies bedeutet Folgendes:

  • Eine mithilfe von TPH zugeordnete Hierarchie muss eine einzelne Einfüge-, Aktualisierungs- und/oder Löschprozedur aufweisen, die auf die einzelne zugeordnete Tabelle ausgerichtet ist. Die eingefügten und aktualisierten gespeicherten Prozeduren müssen über einen Parameter für den Diskriminatorwert verfügen.
  • Eine mithilfe von TPT zugeordnete Hierarchie muss eine gespeicherte Einfüge-, Aktualisierungs- und/oder Löschprozedur für jeden Typ aufweisen, einschließlich abstrakter Typen. EF Core führt bei Bedarf mehrere Aufrufe aus, um Zeilen in allen Tabellen zu aktualisieren, einzufügen und zu löschen.
  • Eine mithilfe von TPC zugeordnete Hierarchie muss eine gespeicherte Einfüge-, Aktualisierungs- und/oder Löschprozedur für jeden konkreten Typ haben, aber nicht über abstrakte Typen verfügen.

Hinweis

Wenn Sie unabhängig von der Zuordnungsstrategie eine einzelne gespeicherte Prozedur pro konkretem Typ verwenden, stimmen Sie unabhängig von der Vererbungsstrategie für die Unterstützung mit einem einzelnen Sproc pro konkretem Typ.

Zuordnen von besitzereigenen Typen zu gespeicherten Prozeduren

Die Konfiguration gespeicherter Prozeduren für besitzereigene Typen erfolgt im Generator für geschachtelte besitzereigene Typen. Beispiel:

modelBuilder.Entity<Person>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.OwnsOne(
            author => author.Contact,
            ownedNavigationBuilder =>
            {
                ownedNavigationBuilder.ToTable("Contacts");
                ownedNavigationBuilder
                    .InsertUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                        })
                    .UpdateUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
                        })
                    .DeleteUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
            });
    });

Hinweis

Derzeit gespeicherte Prozeduren zum Einfügen, Aktualisieren und Löschen unterstützen nur besitzereigene Typen und müssen separaten Tabellen zugeordnet werden. Das heißt, der besitzereigene Typ kann nicht durch Spalten in der Besitzertabelle dargestellt werden. Stimmen Sie für Hinzufügen von "Tabellen"-Splittingunterstützung zur CUD-Sproc-Zuordnung, wenn dies eine Einschränkung ist, die Sie entfernen möchten.

Zuordnen von n:n-Verknüpfungsentitäten zu gespeicherten Prozeduren

Die Konfiguration gespeicherter Prozeduren mit n:n-Verknüpfungsentitäten kann als Teil der n:n-Konfiguration ausgeführt werden. Beispiel:

modelBuilder.Entity<Book>(
    entityTypeBuilder =>
    {
        entityTypeBuilder
            .HasMany(document => document.Authors)
            .WithMany(author => author.PublishedWorks)
            .UsingEntity<Dictionary<string, object>>(
                "BookPerson",
                builder => builder.HasOne<Person>().WithMany().OnDelete(DeleteBehavior.Cascade),
                builder => builder.HasOne<Book>().WithMany().OnDelete(DeleteBehavior.ClientCascade),
                joinTypeBuilder =>
                {
                    joinTypeBuilder
                        .InsertUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasParameter("AuthorsId");
                                storedProcedureBuilder.HasParameter("PublishedWorksId");
                            })
                        .DeleteUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasOriginalValueParameter("AuthorsId");
                                storedProcedureBuilder.HasOriginalValueParameter("PublishedWorksId");
                                storedProcedureBuilder.HasRowsAffectedResultColumn();
                            });
                });
    });

Neue und verbesserte Abfangfunktionen und Ereignisse

EF Core-Abfangfunktionen ermöglichen das Abfangen, Ändern und/oder Unterdrücken von EF Core-Vorgängen. EF Core umfasst auch herkömmliche .NET-Ereignisse und Protokollierung.

EF7 enthält die folgenden Verbesserungen für Abfangfunktionen:

Darüber hinaus umfasst EF7 neue herkömmliche .NET-Ereignisse für:

In den folgenden Abschnitten werden einige Beispiele für die Verwendung dieser neuen Abfangfunktionen gezeigt.

Einfache Aktionen zur Entitätserstellung

Tipp

Der hier gezeigte Code stammt aus SimpleMaterializationSample.cs.

Das neue IMaterializationInterceptor unterstützt das Abfangen vor und nach der Erstellung einer Entitätsinstanz und vor und nach der Initialisierung der Eigenschaften dieser Instanz. Der Interceptor kann die Entitätsinstanz an jedem Punkt ändern oder ersetzen. Dadurch können:

  • Festlegen nicht zugeordneter Eigenschaften oder Aufrufmethoden, die für Validierung, berechnete Werte oder Flags erforderlich sind.
  • Verwenden einer Factory zum Erstellen von Instanzen.
  • Das Erstellen einer anderen Entitätsinstanz als EF würde normalerweise z. B. eine Instanz aus einem Cache oder einen Proxytyp erstellen.
  • Einfügen von Diensten in eine Entitätsinstanz.

Stellen Sie sich beispielsweise vor, dass wir die Zeit nachverfolgen möchten, zu der eine Entität aus der Datenbank abgerufen wurde, vielleicht so dass sie einem Benutzer angezeigt werden kann, der die Daten bearbeitet. Dazu definieren wir zunächst eine Schnittstelle:

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

Die Verwendung einer Schnittstelle ist bei Abfangfunktionen üblich, da es derselben Abfangfunktionen ermöglicht, mit vielen verschiedenen Entitätstypen zu arbeiten. Beispiel:

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

Beachten Sie, dass das Attribut [NotMapped] verwendet wird, um anzugeben, dass diese Eigenschaft nur beim Arbeiten mit der Entität verwendet wird und nicht in der Datenbank beibehalten werden sollte.

Der Interceptor muss dann die entsprechende Methode von IMaterializationInterceptor implementieren und die abgerufene Zeit festlegen:

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

Eine Instanz dieser Abfangfunktionen wird beim Konfigurieren von DbContext registriert:

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers
        => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

Tipp

Dieser Abfangfunktionen ist zustandslos, was häufig ist, sodass eine einzelne Instanz erstellt und von allen DbContext-Instanzen gemeinsam genutzt wird.

Sobald eine Customer-Abfrage aus der Datenbank erfolgt, wird die Retrieved-Eigenschaft automatisch festgelegt. Beispiel:

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

Erzeugt Ausgabe:

Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'

Einfügen von Diensten in Entitäten

Tipp

Der hier gezeigte Code stammt aus InjectLoggerSample.cs.

EF Core verfügt bereits über integrierte Unterstützung für das Einfügen einiger spezieller Dienste in Kontextinstanzen; zum Beispiel in Verzögertem Laden ohne Proxys, was durch Einfügen des Dienstes ILazyLoader funktioniert.

Eine IMaterializationInterceptor kann verwendet werden, um dies auf jeden Dienst zu generalisieren. Das folgende Beispiel zeigt, wie Sie ILogger in Entitäten einfügen, sodass sie ihre eigene Protokollierung durchführen können.

Hinweis

Durch das Einfügen von Diensten in Entitäten werden diese Entitätstypen mit den injizierten Diensten gekoppelt, die einige Personen als Antimuster betrachten.

Wie zuvor wird eine Schnittstelle verwendet, um zu definieren, was getan werden kann.

public interface IHasLogger
{
    ILogger? Logger { get; set; }
}

Und Entitätstypen, die protokolliert werden, müssen diese Schnittstelle implementieren. Beispiel:

public class Customer : IHasLogger
{
    private string? _phoneNumber;

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

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

Dieses Mal muss die Abfangfunktion IMaterializationInterceptor.InitializedInstance implementieren, der aufgerufen wird, nachdem jede Entitätsinstanz erstellt wurde und seine Eigenschaftswerte initialisiert wurden. Die Abfangfunktion ruft einen ILogger aus dem Kontext ab und initialisiert IHasLogger.Logger mit ihm:

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

Dieses Mal wird für jede DbContext-Instanz eine neue Instanz der Abfangfunktion verwendet, da sich die ILogger abgerufenen Instanzen pro DbContext ändern können und die ILogger in der Abfangfunktion zwischengespeichert wird:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());

Wenn Customer.PhoneNumber geändert wird, wird diese Änderung nun im Protokoll der Anwendung protokolliert. Beispiel:

info: CustomersLogger[1]
      Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.

LINQ-Ausdrucksstruktur-Abfangen

Tipp

Der hier gezeigte Code stammt aus QueryInterceptionSample.cs.

EF Core verwendet .NET LINQ-Abfragen. Dies umfasst in der Regel die Verwendung des C#-, VB- oder F#-Compilers zum Erstellen einer Ausdrucksstruktur, die dann von EF Core in den entsprechenden SQL-Code übersetzt wird. Betrachten Sie beispielsweise eine Methode, die eine Seite von Kunden zurückgibt:

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

Tipp

Diese Abfrage verwendet die EF.Property-Methode, um die Eigenschaft anzugeben, nach der sortiert werden soll. Auf diese Weise kann die Anwendung dynamisch den Eigenschaftsnamen übergeben, sodass die Sortierung nach einer beliebigen Eigenschaft des Entitätstyps möglich ist. Beachten Sie, dass das Sortieren nach nicht indizierten Spalten langsam sein kann.

Dies funktioniert ohne Probleme, solange die zum Sortieren verwendete Eigenschaft immer eine stabile Sortierung zurückgibt. Aber das ist vielleicht nicht immer der Fall. Die oben genannte LINQ-Abfrage generiert z. B. folgendes bei SQLite bei der Sortierung nach Customer.City:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

Wenn es mehrere Kunden mit demselben City gibt, ist die Sortierung dieser Abfrage nicht stabil. Dies kann zu fehlenden oder doppelten Ergebnissen führen, wenn der Benutzer die Daten durchsucht.

Eine häufige Möglichkeit, dieses Problem zu beheben, besteht darin, eine sekundäre Sortierung nach Primärschlüssel durchzuführen. Anstatt dies jedoch manuell zu jeder Abfrage hinzuzufügen, ermöglicht EF7 das Abfangen der Abfrageausdrucksstruktur, in der die sekundäre Sortierung dynamisch hinzugefügt werden kann. Um dies zu erleichtern, verwenden wir erneut eine Schnittstelle, diesmal für jede Entität mit einem ganzzahligen Primärschlüssel:

public interface IHasIntKey
{
    int Id { get; }
}

Diese Schnittstelle wird von den Entitätstypen implementiert:

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

Anschließend benötigen wir eine Abfangfunktion, die IQueryExpressionInterceptor implementiert

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        base.VisitMethodCall(methodCallExpression),
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

Dies sieht wahrscheinlich ziemlich kompliziert aus - und das ist es auch! Das Arbeiten mit Ausdrucksstrukturen ist in der Regel nicht einfach. Sehen wir uns an, was passiert:

  • Grundsätzlich kapselt die Abfangfunktion ExpressionVisitor ein. Der Besucher setzt VisitMethodCall, der aufgerufen wird, wenn ein Aufruf einer Methode in der Abfrageausdrucksstruktur vorhanden ist, außer Kraft.

  • Der Besucher prüft, ob dies ein Aufruf der OrderBy-Methode ist, an der wir interessiert sind.

  • Wenn dies der Fall ist, überprüft der Besucher weiter, ob der generische Methodenaufruf einen Typ enthält, der unsere IHasIntKey-Schnittstelle implementiert.

  • An diesem Punkt wissen wir, dass der Methodenaufruf dem Formular OrderBy(e => ...)entspricht. Wir extrahieren den Lambda-Ausdruck aus diesem Aufruf und rufen den in diesem Ausdruck verwendeten Parameter, d. h. e, ab.

  • Wir erstellen nun eine neue MethodCallExpression mit der Generatormethode Expression.Call. In diesem Fall wird die Methode ThenBy(e => e.Id) aufgerufen. Wir erstellen dies mit dem oben extrahierten Parameter und einem Eigenschaftszugriff auf die Id Eigenschaft der IHasIntKey Schnittstelle.

  • Die Eingabe in diesen Aufruf ist das Original OrderBy(e => ...), und das Endergebnis ist also ein Ausdruck für OrderBy(e => ...).ThenBy(e => e.Id).

  • Dieser geänderte Ausdruck wird vom Besucher zurückgegeben, was bedeutet, dass die LINQ-Abfrage jetzt entsprechend geändert wurde, um einen ThenBy Aufruf einzuschließen.

  • EF Core wird fortgesetzt und kompiliert diesen Abfrageausdruck in den entsprechenden SQL-Code für die verwendete Datenbank.

Diese Abfangfunktion wird auf die gleiche Weise registriert wie für das erste Beispiel. Durch Ausführen von GetPageOfCustomers wird jetzt die folgende SQL-Datei generiert:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

Es wird nun immer eine stabile Bestellung produzieren, auch wenn es mehrere Kunden mit demselben City gibt.

Puh! Das ist viel Code, um eine einfache Änderung an einer Abfrage vorzunehmen. Und noch schlimmer, es funktioniert möglicherweise nicht einmal für alle Abfragen. Es ist berüchtigt wie schwierig es ist, einen Ausdrucksbesucher zu schreiben, der alle Abfrageformen erkennt, die er sollte, und keine, die er nicht sollte. Das funktioniert beispielsweise wahrscheinlich nicht, wenn die Sortierung in einer Unterabfrage erfolgt.

Dies bringt uns zu einem kritischen Punkt bei Abfangfunktionen - fragen Sie sich nicht auch immer, ob es eine einfachere Möglichkeit gibt, das Gewünschte zu tun. Abfangfunktionen sind leistungsfähig, aber es ist einfach bei ihnen Fehler zu machen. Sie sind sprichwortgemäß eine einfache Möglichkeit, sich selbst in den Fuß zu schießen.

Stellen Sie sich beispielsweise vor, wenn wir stattdessen unsere Methode GetPageOfCustomers wie folgt ändern:

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

In diesem Fall wird ThenBy einfach der Abfrage hinzugefügt. Ja, es muss separat für jede Abfrage ausgeführt werden, aber es ist einfach, leicht zu verstehen und funktioniert immer.

Optimistische Parallelitäts-Abfangfunktion

Tipp

Der hier gezeigte Code stammt aus "OptimisticConcurrencyInterceptionSample.cs".

EF Core unterstützt das optimistische Parallelitätsmuster, indem überprüft wird, ob die Anzahl der Zeilen, die tatsächlich von einer Aktualisierung oder Löschung betroffen ist, mit der Anzahl der zu erwartenden Zeilen übereinstimmt. Dies ist häufig mit einem Parallelitätstoken gekoppelt; d. h. ein Spaltenwert, der nur mit dem erwarteten Wert übereinstimmt, wenn die Zeile seit dem Lesen des erwarteten Werts nicht aktualisiert wurde.

EF signalisiert eine Verletzung optimistischer Parallelität durch Auslösen eines DbUpdateConcurrencyException. In EF7 hat ISaveChangesInterceptor die neue Methoden ThrowingConcurrencyException und ThrowingConcurrencyExceptionAsync, die vor dem DbUpdateConcurrencyException Auslösen aufgerufen werden. Diese Abfangpunkte ermöglichen es, die Ausnahme zu unterdrücken, möglicherweise gekoppelt mit asynchronen Datenbankänderungen, um die Verletzung zu beheben.

Wenn beispielsweise zwei Anforderungen versuchen, dieselbe Entität gleichzeitig zu löschen, schlägt der zweite Löschvorgang möglicherweise fehl, da die Zeile in der Datenbank nicht mehr vorhanden ist. Dies kann in Ordnung sein – das Endergebnis ist, dass die Entität trotzdem gelöscht wurde. Die folgende Abfangfunktion veranschaulicht, wie das möglich ist:

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

Es gibt mehrere Dinge, die es wert sind, diese Abfangfunktion zu notieren:

  • Sowohl die synchronen als auch die asynchronen Abfangmethoden werden implementiert. Dies ist wichtig, wenn die Anwendung entweder SaveChanges oder SaveChangesAsync aufruft. Wenn jedoch der gesamte Anwendungscode asynchron ist, muss nur ThrowingConcurrencyExceptionAsync implementiert werden. Ebenso muss, wenn die Anwendung niemals asynchrone Datenbankmethoden verwendet, nur ThrowingConcurrencyException implementiert werden. Dies gilt im Allgemeinen für alle Abfangfunktionen mit synchronen und asynchronen Methoden. (Es kann sinnvoll sein, die Methode zu implementieren, die Ihre Anwendung nicht zum Auslösen verwendet, für den Fall das sich synchroner/asynchroner Code einschleicht.)
  • Die Abfangfunktion hat Zugriff auf EntityEntry-Objekte für die Entitäten, die gespeichert werden. In diesem Fall wird es verwendet, um zu überprüfen, ob die Parallelitätsverletzung bei einem Löschvorgang stattfindet.
  • Wenn die Anwendung einen relationalen Datenbankanbieter verwendet, kann das Objekt ConcurrencyExceptionEventData in ein Objekt RelationalConcurrencyExceptionEventData umgewandelt werden. Dadurch werden zusätzliche relationale Informationen zu dem Datenbankvorgang bereitgestellt, der ausgeführt wird. In diesem Fall wird der relationale Befehlstext in die Konsole gedruckt.
  • Durch Zurückgeben von InterceptionResult.Suppress() wird EF Core aufgefordert, die Aktion zu unterdrücken, die es in diesem Fall zu ergreifen wollte, und DbUpdateConcurrencyException auszulösen. Diese Fähigkeit von das Verhalten von EF Core zu ändern, anstatt nur zu beobachten, was EF Core tut, ist eines der leistungsstärksten Features von Abfangfunktion.

Verzögerte Initialisierung einer Verbindungszeichenfolge

Tipp

Der hier gezeigte Code stammt aus LazyConnectionStringSample.cs.

Verbindungszeichenfolgen sind häufig statische Objekte, die aus einer Konfigurationsdatei gelesen werden. Diese können einfach beim Konfigurieren einer DbContext an UseSqlServer oder Ähnliche weitergegeben werden. Manchmal kann sich die Verbindungszeichenfolge jedoch für jede Kontextinstanz ändern. Beispielsweise kann jeder Mandant in einem Mehrinstanzensystem eine andere Verbindungszeichenfolge aufweisen.

EF7 erleichtert die Behandlung dynamischer Verbindungen und Verbindungszeichenfolgen durch Verbesserungen an der IDbConnectionInterceptor. Dies beginnt mit der Möglichkeit, die DbContext ohne Verbindungszeichenfolge zu konfigurieren. Beispiel:

services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer());

Eine der IDbConnectionInterceptor Methoden kann dann implementiert werden, um die Verbindung zu konfigurieren, bevor sie verwendet wird. ConnectionOpeningAsync ist eine gute Wahl, da sie einen asynchronen Vorgang ausführen kann, um die Verbindungszeichenfolge abzurufen, ein Zugriffstoken zu suchen kann usw. Stellen Sie sich beispielsweise einen Dienst für die aktuelle Anforderung vor, der den aktuellen Mandanten versteht:

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

Warnung

Das Ausführen einer asynchronen Suche nach einer Verbindungszeichenfolge, einem Zugriffstoken oder bei Bedarf nach ähnlichen Vorgängen kann sehr langsam sein. Sie sollten diese Dinge zwischenspeichern und nur die zwischengespeicherte Zeichenfolge oder das zwischengespeicherte Token regelmäßig aktualisieren. Beispielsweise können Zugriffstoken häufig für einen erheblichen Zeitraum verwendet werden, bevor sie aktualisiert werden müssen.

Dies kann in jede DbContext-Instanz mithilfe der Konstruktoreinfügung eingefügt werden:

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public CustomerContext(
        DbContextOptions<CustomerContext> options,
        ITenantConnectionStringFactory connectionStringFactory)
        : base(options)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    // ...
}

Dieser Dienst wird dann beim Erstellen der Abfangfunktionsimplementierung für den Kontext verwendet:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(
        new ConnectionStringInitializationInterceptor(_connectionStringFactory));

Schließlich verwendet die Abfangfunktion diesen Dienst, um die Verbindungszeichenfolge asynchron abzurufen und festzulegen, dass die Verbindung zum ersten Mal verwendet wird:

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly IClientConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(IClientConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

Hinweis

Die Verbindungszeichenfolge wird nur beim ersten Verwenden einer Verbindung abgerufen. Danach wird die in der DbConnection gespeicherte Verbindungszeichenfolge verwendet, ohne eine neue Verbindungszeichenfolge nachschlagen zu müssen.

Tipp

Diese Abfangfunktion überschreibt die nicht asynchrone Methode ConnectionOpening, die ausgelöst werden soll, da der Dienst zum Abrufen der Verbindungszeichenfolge aus einem asynchronen Codepfad aufgerufen werden muss.

Protokollierung von SQL Server-Abfragestatistiken

Tipp

Der hier gezeigte Code stammt aus QueryStatisticsLoggerSample.cs.

Abschließend erstellen wir zwei Abfangfunktionen, die zusammenarbeiten, um SQL Server-Abfragestatistiken an das Anwendungsprotokoll zu senden. Um die Statistiken zu generieren, muss IDbCommandInterceptor zwei Dinge tun.

Zunächst stellt die Abfangfunktion Befehlen SET STATISTICS IO ON voran, woran die SQL Server erkennen, Statistiken an den Client zu senden, nachdem ein Resultset verbraucht wurde:

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

Zweitens implementiert die Abfangfunktion die neue Methode DataReaderClosingAsync, die aufgerufen wird, nachdem DbDataReader aufgehört hat Ergebnisse zu verbrauchen, aber bevor sie geschlossen wurde. Wenn der SQL Server Statistiken sendet, fügt er sie in ein zweites Ergebnis für den Leser ein. An diesem Punkt liest die Abfangfunktion dieses Ergebnis durch Aufrufen von NextResultAsync, was Statistiken auf die Verbindung auffüllt.

public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
    DbCommand command,
    DataReaderClosingEventData eventData,
    InterceptionResult result)
{
    await eventData.DataReader.NextResultAsync();

    return result;
}

Die zweite Abfangfunktion wird benötigt, um die Statistiken aus der Verbindung abzurufen und sie in den Logger der Anwendung zu schreiben. Dazu verwenden wir eine IDbConnectionInterceptor, um die neue Methode ConnectionCreated zu implementieren. ConnectionCreated wird unmittelbar aufgerufen, nachdem EF Core eine Verbindung erstellt hat, und kann daher verwendet werden, um zusätzliche Konfigurationen dieser Verbindung auszuführen. In diesem Fall ruft die Abfangfunktion eine ILogger ab und hakt sich dann in das Ereignis SqlConnection.InfoMessage ein, um die Nachrichten zu protokollieren.

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

Wichtig

Die Methoden ConnectionCreating und ConnectionCreated werden nur aufgerufen, wenn EF Core eine DbConnection erstellt. Sie werden nicht aufgerufen, wenn die Anwendung DbConnection erstellt und an EF Core übergibt.

Beim Ausführen von manchem Code, der diese Abfangfunktionen verwendet, werden SQL Server-Abfragestatistiken im Protokoll angezeigt:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Customers] USING (
      VALUES (@p0, @p1, 0),
      (@p2, @p3, 1)) AS i ([Name], [PhoneNumber], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name], [PhoneNumber])
      VALUES (i.[Name], i.[PhoneNumber])
      OUTPUT INSERTED.[Id], i._Position;
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 0, logical reads 5, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SELECT TOP(2) [c].[Id], [c].[Name], [c].[PhoneNumber]
      FROM [Customers] AS [c]
      WHERE [c].[Name] = N'Alice'
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 1, logical reads 2, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

Abfrageerweiterungen

EF7 enthält viele Verbesserungen bei der Übersetzung von LINQ-Abfragen.

GroupBy als endgültiger Operator

Tipp

Der hier gezeigte Code stammt aus GroupByFinalOperatorSample.cs.

EF7 unterstützt die Verwendung von GroupBy als endgültigen Operator in einer Abfrage. Nehmen Sie diese LINQ-Abfrage als Beispiel:

var query = context.Books.GroupBy(s => s.Price);

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

SELECT [b].[Price], [b].[Id], [b].[AuthorId]
FROM [Books] AS [b]
ORDER BY [b].[Price]

Hinweis

Dieser Typ von GroupBy kann nicht direkt in SQL übersetzt werden, sodass EF Core die Gruppierung für die zurückgegebenen Ergebnisse durchführt. Dies führt jedoch nicht dazu, dass zusätzliche Daten vom Server übertragen werden.

GroupJoin als abschließender Operator

Tipp

Der hier gezeigte Code stammt aus GroupJoinFinalOperatorSample.cs.

EF7 unterstützt die Verwendung von GroupJoin als abschließenden Operator in einer Abfrage. Nehmen Sie diese LINQ-Abfrage als Beispiel:

var query = context.Customers.GroupJoin(
    context.Orders, c => c.Id, o => o.CustomerId, (c, os) => new { Customer = c, Orders = os });

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

SELECT [c].[Id], [c].[Name], [t].[Id], [t].[Amount], [t].[CustomerId]
FROM [Customers] AS [c]
OUTER APPLY (
    SELECT [o].[Id], [o].[Amount], [o].[CustomerId]
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId]
) AS [t]
ORDER BY [c].[Id]

GroupBy-Entitätstyp

Tipp

Der hier gezeigte Code stammt aus GroupByEntityTypeSample.cs.

EF7 unterstützt die Gruppierung nach einem Entitätstyp. Nehmen Sie diese LINQ-Abfrage als Beispiel:

var query = context.Books
    .GroupBy(s => s.Author)
    .Select(s => new { Author = s.Key, MaxPrice = s.Max(p => p.Price) });

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

SELECT [a].[Id], [a].[Name], MAX([b].[Price]) AS [MaxPrice]
FROM [Books] AS [b]
INNER JOIN [Author] AS [a] ON [b].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

Beachten Sie, dass die Gruppierung nach einer eindeutigen Eigenschaft, z. B. dem Primärschlüssel, immer effizienter ist als das Gruppieren nach einem Entitätstyp. Die Gruppierung nach Entitätstypen kann jedoch sowohl für Schlüssel- als auch für schlüssellose Entitätstypen verwendet werden.

Außerdem führt die Gruppierung nach einem Entitätstyp mit einem Primärschlüssel immer zu einer Gruppe pro Entitätsinstanz, da jede Entität einen eindeutigen Schlüsselwert aufweisen muss. Es lohnt sich manchmal, die Quelle der Abfrage so zu wechseln, dass die Gruppierung nicht erforderlich ist. Die folgende Abfrage gibt beispielsweise die gleichen Ergebnisse wie die vorherige Abfrage zurück:

var query = context.Authors
    .Select(a => new { Author = a, MaxPrice = a.Books.Max(b => b.Price) });

Diese Abfrage wird bei Verwendung von SQLite in den folgenden SQL-Code übersetzt:

SELECT [a].[Id], [a].[Name], (
    SELECT MAX([b].[Price])
    FROM [Books] AS [b]
    WHERE [a].[Id] = [b].[AuthorId]) AS [MaxPrice]
FROM [Authors] AS [a]

Unterabfragen verweisen nicht auf nicht-gruppierte Spalten aus der äußeren Abfrage

Tipp

Der hier gezeigte Code stammt aus UngroupedColumnsQuerySample.cs.

In EF Core 6.0 würde eine GROUP BY-Klausel auf Spalten in der äußeren Abfrage verweisen, die bei einigen Datenbanken fehlschlägt würde und in anderen ineffizient wäre. Betrachten Sie beispielsweise die folgende Abfrage:

var query = from s in (from i in context.Invoices
                       group i by i.History.Month
                       into g
                       select new { Month = g.Key, Total = g.Sum(p => p.Amount), })
            select new
            {
                s.Month, s.Total, Payment = context.Payments.Where(p => p.History.Month == s.Month).Sum(p => p.Amount)
            };

In EF Core 6.0 auf SQL Server wurde dies in Folgendes übersetzt:

SELECT DATEPART(month, [i].[History]) AS [Month], COALESCE(SUM([i].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = DATEPART(month, [i].[History])) AS [Payment]
FROM [Invoices] AS [i]
GROUP BY DATEPART(month, [i].[History])

Auf EF7 lautet die Übersetzung:

SELECT [t].[Key] AS [Month], COALESCE(SUM([t].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = [t].[Key]) AS [Payment]
FROM (
    SELECT [i].[Amount], DATEPART(month, [i].[History]) AS [Key]
    FROM [Invoices] AS [i]
) AS [t]
GROUP BY [t].[Key]

Schreibgeschützte Auflistungen können für Contains verwendet werden

Tipp

Der hier gezeigte Code stammt aus ReadOnlySetQuerySample.cs.

EF7 unterstützt die Verwendung von Contains, wenn die zu suchenden Elemente in IReadOnlySet, IReadOnlyCollection oder IReadOnlyList enthalten sind. Nehmen Sie diese LINQ-Abfrage als Beispiel:

IReadOnlySet<int> searchIds = new HashSet<int> { 1, 3, 5 };
var query = context.Customers.Where(p => p.Orders.Any(l => searchIds.Contains(l.Id)));

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

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[Customer1Id] AND [o].[Id] IN (1, 3, 5))

Übersetzungen für Aggregatfunktionen

EF7 führt eine bessere Erweiterbarkeit für Anbieter zum Übersetzen von Aggregatfunktionen ein. Dies und andere Arbeiten in diesem Bereich haben zu mehreren neuen Übersetzungen in allen Anbietern geführt, darunter:

Hinweis

Aggregatfunktionen, die auf Argument IEnumerable reagieren, werden in der Regel nur in GroupBy-Abfragen übersetzt. Stimmen Sie für die Unterstützung räumlicher Typen in JSON-Spalten, wenn Sie daran interessiert sind, diese Einschränkung zu entfernen.

Zeichenfolgenaggregatfunktionen

Tipp

Der hier gezeigte Code stammt aus StringAggregateFunctionsSample.cs.

Abfragen, die Join und Concat verwenden werden jetzt bei Bedarf übersetzt. Beispiel:

var query = context.Posts
    .GroupBy(post => post.Author)
    .Select(grouping => new { Author = grouping.Key, Books = string.Join("|", grouping.Select(post => post.Title)) });

Diese Abfrage wird in Folgendes übersetzt, wenn der SQL Server verwendet wird:

SELECT [a].[Id], [a].[Name], COALESCE(STRING_AGG([p].[Title], N'|'), N'') AS [Books]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

In Kombination mit anderen Zeichenfolgenfunktionen ermöglichen diese Übersetzungen eine komplexe Zeichenfolgenbearbeitung auf dem Server. Beispiel:

var query = context.Posts
    .GroupBy(post => post.Author!.Name)
    .Select(
        grouping =>
            new
            {
                PostAuthor = grouping.Key,
                Blogs = string.Concat(
                    grouping
                        .Select(post => post.Blog.Name)
                        .Distinct()
                        .Select(postName => "'" + postName + "' ")),
                ContentSummaries = string.Join(
                    " | ",
                    grouping
                        .Where(post => post.Content.Length >= 10)
                        .Select(post => "'" + post.Content.Substring(0, 10) + "' "))
            });

Diese Abfrage wird in Folgendes übersetzt, wenn der SQL Server verwendet wird:

SELECT [t].[Name], (N'''' + [t0].[Name]) + N''' ', [t0].[Name], [t].[c]
FROM (
    SELECT [a].[Name], COALESCE(STRING_AGG(CASE
        WHEN CAST(LEN([p].[Content]) AS int) >= 10 THEN COALESCE((N'''' + COALESCE(SUBSTRING([p].[Content], 0 + 1, 10), N'')) + N''' ', N'')
    END, N' | '), N'') AS [c]
    FROM [Posts] AS [p]
    LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
    GROUP BY [a].[Name]
) AS [t]
OUTER APPLY (
    SELECT DISTINCT [b].[Name]
    FROM [Posts] AS [p0]
    LEFT JOIN [Authors] AS [a0] ON [p0].[AuthorId] = [a0].[Id]
    INNER JOIN [Blogs] AS [b] ON [p0].[BlogId] = [b].[Id]
    WHERE [t].[Name] = [a0].[Name] OR ([t].[Name] IS NULL AND [a0].[Name] IS NULL)
) AS [t0]
ORDER BY [t].[Name]

Räumliche Aggregatfunktionen

Tipp

Der hier gezeigte Code stammt aus SpatialAggregateFunctionsSample.cs.

Es ist jetzt für Datenbankanbieter möglich, die NetTopologySuite unterstützen, die folgenden räumlichen Aggregatfunktionen zu übersetzen:

Tipp

Diese Übersetzungen wurden vom Team für SQL Server und SQLite implementiert. Wenden Sie sich für andere Anbieter an den Anbieterbetreuer, um Support hinzuzufügen, falls er für diesen Anbieter implementiert wurde.

Beispiel:

var query = context.Caches
    .Where(cache => cache.Location.X < -90)
    .GroupBy(cache => cache.Owner)
    .Select(
        grouping => new { Id = grouping.Key, Combined = GeometryCombiner.Combine(grouping.Select(cache => cache.Location)) });

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

SELECT [c].[Owner] AS [Id], geography::CollectionAggregate([c].[Location]) AS [Combined]
FROM [Caches] AS [c]
WHERE [c].[Location].Long < -90.0E0
GROUP BY [c].[Owner]

Statistische Aggregatfunktionen

Tipp

Der hier gezeigte Code stammt aus StatisticalAggregateFunctionsSample.cs.

SQL Server-Übersetzungen wurden für die folgenden statistischen Funktionen implementiert:

Tipp

Diese Übersetzungen wurden vom Team für SQL Server implementiert. Wenden Sie sich für andere Anbieter an den Anbieterbetreuer, um Support hinzuzufügen, falls er für diesen Anbieter implementiert wurde.

Beispiel:

var query = context.Downloads
    .GroupBy(download => download.Uploader.Id)
    .Select(
        grouping => new
        {
            Author = grouping.Key,
            TotalCost = grouping.Sum(d => d.DownloadCount),
            AverageViews = grouping.Average(d => d.DownloadCount),
            VariancePopulation = EF.Functions.VariancePopulation(grouping.Select(d => d.DownloadCount)),
            VarianceSample = EF.Functions.VarianceSample(grouping.Select(d => d.DownloadCount)),
            StandardDeviationPopulation = EF.Functions.StandardDeviationPopulation(grouping.Select(d => d.DownloadCount)),
            StandardDeviationSample = EF.Functions.StandardDeviationSample(grouping.Select(d => d.DownloadCount))
        });

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

SELECT [u].[Id] AS [Author], COALESCE(SUM([d].[DownloadCount]), 0) AS [TotalCost], AVG(CAST([d].[DownloadCount] AS float)) AS [AverageViews], VARP([d].[DownloadCount]) AS [VariancePopulation], VAR([d].[DownloadCount]) AS [VarianceSample], STDEVP([d].[DownloadCount]) AS [StandardDeviationPopulation], STDEV([d].[DownloadCount]) AS [StandardDeviationSample]
FROM [Downloads] AS [d]
INNER JOIN [Uploader] AS [u] ON [d].[UploaderId] = [u].[Id]
GROUP BY [u].[Id]

Übersetzung von string.IndexOf

Tipp

Der hier gezeigte Code stammt aus MiscellaneousTranslationsSample.cs.

EF7 übersetzt String.IndexOf jetzt in LINQ-Abfragen. Beispiel:

var query = context.Posts
    .Select(post => new { post.Title, IndexOfEntity = post.Content.IndexOf("Entity") })
    .Where(post => post.IndexOfEntity > 0);

Diese Abfrage wird bei Verwendung von SQL Server in den folgenden SQL-Code übersetzt:

SELECT [p].[Title], CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1 AS [IndexOfEntity]
FROM [Posts] AS [p]
WHERE (CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1) > 0

Übersetzung von GetType für Entitätstypen

Tipp

Der hier gezeigte Code stammt aus MiscellaneousTranslationsSample.cs.

EF7 übersetzt Object.GetType() jetzt in LINQ-Abfragen. Beispiel:

var query = context.Posts.Where(post => post.GetType() == typeof(Post));

Diese Abfrage wird bei Verwendung von SQL Server mit TPH-Vererbung in den folgenden SQL-Code übersetzt:

SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'Post'

Beachten Sie, dass diese Abfrage nur Post-Instanzen zurückgibt, die tatsächlich vom Typ Post sind, und nicht die von abgeleiteten Typen. Dies unterscheidet sich von einer Abfrage, die is verwendet oder OfType, die auch Instanzen von abgeleiteten Typen zurückgibt. Betrachten Sie beispielsweise diese Abfrage:

var query = context.Posts.OfType<Post>();

Die in unterschiedlichen SQL-Code übersetzt wird:

      SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
      FROM [Posts] AS [p]

Und sowohl die Post- als auch die FeaturedPost-Entitäten zurückgibt.

Unterstützung für AT TIME ZONE

Tipp

Der hier gezeigte Code stammt aus MiscellaneousTranslationsSample.cs.

EF7 führt neue AtTimeZone Funktionen für DateTime und DateTimeOffset ein. Diese Funktionen werden in AT TIME ZONE-Klauseln in der generierten SQL-Datei übersetzt. Beispiel:

var query = context.Posts
    .Select(
        post => new
        {
            post.Title,
            PacificTime = EF.Functions.AtTimeZone(post.PublishedOn, "Pacific Standard Time"),
            UkTime = EF.Functions.AtTimeZone(post.PublishedOn, "GMT Standard Time"),
        });

Diese Abfrage wird bei Verwendung von SQL Server in den folgenden SQL-Code übersetzt:

SELECT [p].[Title], [p].[PublishedOn] AT TIME ZONE 'Pacific Standard Time' AS [PacificTime], [p].[PublishedOn] AT TIME ZONE 'GMT Standard Time' AS [UkTime]
FROM [Posts] AS [p]

Tipp

Diese Übersetzungen wurden vom Team für SQL Server implementiert. Wenden Sie sich für andere Anbieter an den Anbieterbetreuer, um Support hinzuzufügen, falls er für diesen Anbieter implementiert wurde.

Include gefiltert für ausgeblendete Navigationen

Tipp

Der hier gezeigte Code stammt aus MiscellaneousTranslationsSample.cs.

Die Include-Methoden können jetzt mit EF.Property verwendet werden. Dies ermöglicht das Filtern und Sortieren sogar für private Navigationseigenschaften oder private Navigationen, die durch Felder dargestellt werden. Beispiel:

var query = context.Blogs.Include(
    blog => EF.Property<ICollection<Post>>(blog, "Posts")
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

Dieser entspricht:

var query = context.Blogs.Include(
    blog => Posts
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

Es ist jedoch nicht erforderlich, dass Blog.Posts öffentlich zugänglich ist.

Bei Verwendung von SQL Servern werden beide obigen Abfragen übersetzt in:

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[AuthorId], [t].[BlogId], [t].[Content], [t].[Discriminator], [t].[PublishedOn], [t].[Title], [t].[PromoText]
FROM [Blogs] AS [b]
LEFT JOIN (
    SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
    FROM [Posts] AS [p]
    WHERE [p].[Content] LIKE N'%.NET%'
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Title]

Cosmos-Übersetzung für Regex.IsMatch

Tipp

Der hier gezeigte Code stammt aus CosmosQueriesSample.cs.

EF7 unterstützt die Verwendung von Regex.IsMatch in LINQ-Abfragen für Azure Cosmos DB. Beispiel:

var containsInnerT = await context.Triangles
    .Where(o => Regex.IsMatch(o.Name, "[a-z]t[a-z]", RegexOptions.IgnoreCase))
    .ToListAsync();

Dies entspricht dem folgenden SQL-Code:

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND RegexMatch(c["Name"], "[a-z]t[a-z]", "i"))

DbContext-API und Verhaltensverbesserungen

EF7 enthält eine Vielzahl kleiner Verbesserungen von DbContext und verwandten Klassen.

Tipp

Der Code für Beispiele in diesem Abschnitt stammt aus DbContextApiSample.cs.

Suppressor für nicht initialisierte DbSet-Eigenschaften

Öffentliche, settable-Eigenschaften von DbSet auf DbContext werden automatisch von EF Core initialisiert, wenn DbContext erstellt wird. Nehmen Sie z. B. die folgende Definition von DbContext:

public class SomeDbContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
}

Die Eigenschaft Blogs wird als Teil der Erstellung der Instanz DbContext auf eine Instanz DbSet<Blog> festgelegt. Dadurch kann der Kontext ohne zusätzliche Schritte für Abfragen verwendet werden.

Nach der Einführung von C#-Null-Referenztypenwarnt der Compiler nun jedoch, dass die nicht nullfähige Eigenschaft Blogs nicht initialisiert wird:

[CS8618] Non-nullable property 'Blogs' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

Dies ist eine falsche Warnung; die Eigenschaft wird von EF Core auf einen Wert ungleich NULL festgelegt. Außerdem führt das Deklarieren der Eigenschaft als NULL-Werte dazu, dass die Warnung weggeht, aber dies ist keine gute Idee, da die Eigenschaft konzeptionell nicht nullfähig ist und niemals null sein wird.

EF7 enthält einen DiagnosticSuppressor für DbSet Eigenschaften auf DbContext, die den Compiler daran hindern, diese Warnung zu generieren.

Tipp

Dieses Muster entstand in den Tagen, in denen C#-Autoeigenschaften sehr begrenzt waren. Bei modernem C# sollten Sie die automatischen Eigenschaften schreibgeschützt machen und sie dann entweder explizit im Konstruktor DbContext initialisieren oder die zwischengespeicherte Instanz DbSet bei Bedarf aus dem Kontext abrufen. Beispiel: public DbSet<Blog> Blogs => Set<Blog>().

Unterscheiden des Abbruchs von Fehlern in Protokollen

Manchmal bricht eine Anwendung eine Abfrage oder einen anderen Datenbankvorgang explizit ab. Dies erfolgt in der Regel mithilfe einer CancellationToken, die an eine Methode, die den Vorgang ausführt, übergebenen wird.

In EF Core 6 sind die Ereignisse, die protokolliert werden, wenn ein Vorgang abgebrochen wird, identisch mit denen, die protokolliert werden, wenn der Vorgang aus einem anderen Grund fehlschlägt. EF7 führt neue Protokollereignisse speziell für abgebrochene Datenbankvorgänge ein. Diese neuen Ereignisse werden standardmäßig auf der Ebene Debug protokolliert. Die folgende Tabelle zeigt die relevanten Ereignisse und deren Standardprotokollebenen:

Ereignis BESCHREIBUNG Standardprotokolliergrad
CoreEventId.QueryIterationFailed Fehler beim Verarbeiten der Ergebnisse einer Abfrage. LogLevel.Error
CoreEventId.SaveChangesFailed Fehler beim Speichern von Änderungen an der Datenbank. LogLevel.Error
RelationalEventId.CommandError Fehler beim Ausführen eines Datenbankbefehls. LogLevel.Error
CoreEventId.QueryCanceled Die Abfrage wurde abgebrochen. LogLevel.Debug
CoreEventId.SaveChangesCanceled Der Datenbankbefehl wurde beim Speichern von Änderungen abgebrochen. LogLevel.Debug
RelationalEventId.CommandCanceled Die Ausführung von DbCommand wurde abgebrochen. LogLevel.Debug

Hinweis

Der Abbruch wird erkannt, indem die Ausnahme betrachtet wird, anstatt das Abbruchtoken zu überprüfen. Das bedeutet, dass Abbrüche, die nicht über das Abbruchtoken ausgelöst werden, weiterhin erkannt und auf diese Weise protokolliert werden.

Neue IProperty- und INavigation- Überladungen für EntityEntry-Methoden

Code, der mit dem EF-Modell arbeitet, verfügt häufig über IProperty oder INavigation, die Eigenschaften oder Navigationsmetadaten repräsentieren. Ein Entitätseintrag wird verwendet, um den Eigenschafts-/Navigationswert abzurufen oder den Status abzufragen. Vor EF7 erforderte dies jedoch die Übergabe des Namens der Eigenschaft oder Navigation an Methoden von EntityEntry, die dann erneut IProperty oder INavigation nachschlagen würden. In EF7 können IProperty oder INavigation stattdessen direkt übergeben werden und vermeiden die zusätzliche Suche.

Ziehen Sie beispielsweise eine Methode in Betracht, um alle gleichgeordneten Elemente einer bestimmten Entität zu finden:

public static IEnumerable<TEntity> FindSiblings<TEntity>(
    this DbContext context, TEntity entity, string navigationToParent)
    where TEntity : class
{
    var parentEntry = context.Entry(entity).Reference(navigationToParent);

    return context.Entry(parentEntry.CurrentValue!)
        .Collection(parentEntry.Metadata.Inverse!)
        .CurrentValue!
        .OfType<TEntity>()
        .Where(e => !ReferenceEquals(e, entity));
}

Diese Methode findet das übergeordnete Element einer bestimmten Entität und übergibt dann die Umkehrung von INavigation an die Collection-Methode des übergeordneten Eintrags. Diese Metadaten werden dann verwendet, um alle gleichgeordneten Elemente des angegebenen übergeordneten Elements zurückzugeben. Hier ist ein Beispiel für die Verwendung:


Console.WriteLine($"Siblings to {post.Id}: '{post.Title}' are...");
foreach (var sibling in context.FindSiblings(post, nameof(post.Blog)))
{
    Console.WriteLine($"    {sibling.Id}: '{sibling.Title}'");
}

Und die Ausgabe:

Siblings to 1: 'Announcing Entity Framework 7 Preview 7: Interceptors!' are...
    5: 'Productivity comes to .NET MAUI in Visual Studio 2022'
    6: 'Announcing .NET 7 Preview 7'
    7: 'ASP.NET Core updates in .NET 7 Preview 7'

EntityEntry für gemeinsame Entitätstypen

EF Core kann denselben CLR-Typ für mehrere verschiedene Entitätstypen verwenden. Diese werden als "Typen der freigegebenen Entität" bezeichnet und häufig verwendet, um einen Wörterbuchtyp mit Schlüssel-Wert-Paaren zuzuordnen, die für die Eigenschaften des Entitätstyps verwendet werden. Beispielsweise kann ein BuildMetadata Entitätstyp definiert werden, ohne einen dedizierten CLR-Typ zu definieren:

modelBuilder.SharedTypeEntity<Dictionary<string, object>>(
    "BuildMetadata", b =>
    {
        b.IndexerProperty<int>("Id");
        b.IndexerProperty<string>("Tag");
        b.IndexerProperty<Version>("Version");
        b.IndexerProperty<string>("Hash");
        b.IndexerProperty<bool>("Prerelease");
    });

Beachten Sie, dass der Entitätstyp Geteilt benannt werden muss. In diesem Fall lautet der Name BuildMetadata. Auf diese Entitätstypen wird dann mithilfe von DbSet für den Entitätstyp zugegriffen, der mit dem Namen abgerufen wird. Beispiel:

public DbSet<Dictionary<string, object>> BuildMetadata
    => Set<Dictionary<string, object>>("BuildMetadata");

Dies DbSet kann zum Nachverfolgen von Entitätsinstanzen verwendet werden:

await context.BuildMetadata.AddAsync(
    new Dictionary<string, object>
    {
        { "Tag", "v7.0.0-rc.1.22426.7" },
        { "Version", new Version(7, 0, 0) },
        { "Prerelease", true },
        { "Hash", "dc0f3e8ef10eb1464b27f0fd4704f53c01226036" }
    });

Und führen Sie Abfragen aus:

var builds = await context.BuildMetadata
    .Where(metadata => !EF.Property<bool>(metadata, "Prerelease"))
    .OrderBy(metadata => EF.Property<string>(metadata, "Tag"))
    .ToListAsync();

In EF7 gibt es nun auch eine Entry Methode, mit DbSet welcher der Status einer Instanz erhalten werden kann, auch wenn sie noch nicht nachverfolgtwird. Beispiel:

var state = context.BuildMetadata.Entry(build).State;

ContextInitialized ist jetzt protokolliert als Debug

In EF7 wird das ContextInitialized Ereignis auf der Debug Ebene protokolliert. Beispiel:

dbug: 10/7/2022 12:27:52.379 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

In früheren Versionen wurde sie auf der Information Ebene protokolliert. Beispiel:

info: 10/7/2022 12:30:34.757 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

Bei Bedarf kann die Protokollebene zurück zu Information geändert werden:

optionsBuilder.ConfigureWarnings(
    builder =>
    {
        builder.Log((CoreEventId.ContextInitialized, LogLevel.Information));
    });

IEntityEntryGraphIterator öffentlich nutzbar

In EF7 kann der Dienst IEntityEntryGraphIterator von Anwendungen verwendet werden. Dies ist der Dienst, der intern beim Ermitteln eines Diagramms von Entitäten und auch von TrackGraph zum Nachverfolgen verwendet wird. Hier ist ein Beispiel, das alle Entitäten durchläuft, die von einer Startentität aus erreichbar sind:

var blogEntry = context.ChangeTracker.Entries<Blog>().First();
var found = new HashSet<object>();
var iterator = context.GetService<IEntityEntryGraphIterator>();
iterator.TraverseGraph(new EntityEntryGraphNode<HashSet<object>>(blogEntry, found, null, null), node =>
{
    if (node.NodeState.Contains(node.Entry.Entity))
    {
        return false;
    }

    Console.Write($"Found with '{node.Entry.Entity.GetType().Name}'");

    if (node.InboundNavigation != null)
    {
        Console.Write($" by traversing '{node.InboundNavigation.Name}' from '{node.SourceEntry!.Entity.GetType().Name}'");
    }

    Console.WriteLine();

    node.NodeState.Add(node.Entry.Entity);

    return true;
});

Console.WriteLine();
Console.WriteLine($"Finished iterating. Found {found.Count} entities.");
Console.WriteLine();

Beachten Sie:

  • Der Iterator beendet das Durchlaufen von einem bestimmten Knoten, wenn der Rückrufdelegat zurückgegeben wird false. In diesem Beispiel werden besuchte Entitäten nachverfolgt und false zurückgegeben, wenn die Entität bereits besucht wurde. Dadurch werden endlose Schleifen verhindert, die sich aus Zyklen im Diagramm ergeben.
  • Das Objekt EntityEntryGraphNode<TState> ermöglicht die Übergabe des Zustands, ohne ihn in die Stellvertretung aufzunehmen.
  • Für jeden anderen als den ersten besuchten Knoten wird der Knoten, von dem er ermittelt wurde, und die Navigation, über die er ermittelt wurde, an den Rückruf übergeben.

Modellbauverbesserungen

EF7 enthält eine Vielzahl kleiner Verbesserungen beim Modellbau.

Tipp

Der Code für Beispiele in diesem Abschnitt stammt aus ModelBuildingSample.cs.

Indizes können aufsteigend oder absteigend sein

Standardmäßig erstellt EF Core aufsteigende Indizes. EF7 unterstützt auch die Erstellung von absteigenden Indizes. Beispiel:

modelBuilder
    .Entity<Post>()
    .HasIndex(post => post.Title)
    .IsDescending();

Oder die Verwendung des Zuordnungsattributs Index:

[Index(nameof(Title), AllDescending = true)]
public class Post
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Title { get; set; }
}

Dies ist selten für Indizes über eine einzelne Spalte nützlich, da die Datenbank denselben Index für die Sortierung in beide Richtungen verwenden kann. Dies ist jedoch nicht der Fall für zusammengesetzte Indizes über mehrere Spalten, bei denen die Reihenfolge für jede Spalte wichtig sein kann. EF Core unterstützt dies, indem bei mehreren Spalten für jede Spalte eine unterschiedliche Sortierung definiert werden kann. Beispiel:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner })
    .IsDescending(false, true);

Oder verwenden Sie ein Zuordnungsattribut:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true })]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

Der folgende SQL-Code wird generiert, wenn SQL Server verwendet werden:

CREATE INDEX [IX_Blogs_Name_Owner] ON [Blogs] ([Name], [Owner] DESC);

Schließlich können mehrere Indizes über denselben sortierten Satz von Spalten erstellt werden, indem den Indizes Namen gegeben werden. Beispiel:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_1")
    .IsDescending(false, true);

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_2")
    .IsDescending(true, true);

Oder durch Verwendung von Zuordnungsattributen:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true }, Name = "IX_Blogs_Name_Owner_1")]
[Index(nameof(Name), nameof(Owner), IsDescending = new[] { true, true }, Name = "IX_Blogs_Name_Owner_2")]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

Dadurch wird der folgende SQL-Code auf dem SQL Server generiert:

CREATE INDEX [IX_Blogs_Name_Owner_1] ON [Blogs] ([Name], [Owner] DESC);
CREATE INDEX [IX_Blogs_Name_Owner_2] ON [Blogs] ([Name] DESC, [Owner] DESC);

Zuordnungsattribut für zusammengesetzte Schlüssel

EF7 führt ein neues Zuordnungsattribut (auch „Datenanmerkung“ genannt) ein, um die Primärschlüsseleigenschaft oder -eigenschaften eines beliebigen Entitätstyps anzugeben. Im Gegensatz zu System.ComponentModel.DataAnnotations.KeyAttribute wird PrimaryKeyAttribute auf der Entitätstypklasse anstelle der Schlüsseleigenschaft platziert. Beispiel:

[PrimaryKey(nameof(PostKey))]
public class Post
{
    public int PostKey { get; set; }
}

Dadurch ist es eine natürliche Anpassung für die Definition zusammengesetzter Schlüssel:

[PrimaryKey(nameof(PostId), nameof(CommentId))]
public class Comment
{
    public int PostId { get; set; }
    public int CommentId { get; set; }
    public string CommentText { get; set; } = null!;
}

Das Definieren des Indexes für die Klasse bedeutet auch, dass er verwendet werden kann, um private Eigenschaften oder Felder als Schlüssel anzugeben, obwohl diese beim Erstellen des EF-Modells in der Regel ignoriert werden. Beispiel:

[PrimaryKey(nameof(_id))]
public class Tag
{
    private readonly int _id;
}

Zuordnungsattribut DeleteBehavior

EF7 führt ein Zuordnungsattribut (auch „Datenanmerkung“ genannt) ein, um DeleteBehavior für eine Beziehung anzugeben. Beispielsweise werden erforderliche Beziehungen standardmäßig mit DeleteBehavior.Cascade erstellt. Dies kann standardmäßig mithilfe von DeleteBehaviorAttribute zu DeleteBehavior.NoAction geändert werden:

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }

    [DeleteBehavior(DeleteBehavior.NoAction)]
    public Blog Blog { get; set; } = null!;
}

Dadurch werden Löschweitergaben für die Blog-Beiträge-Beziehung deaktiviert.

Eigenschaften, die verschiedenen Spaltennamen zugeordnet sind

Einige Zuordnungsmuster führen dazu, dass dieselbe CLR-Eigenschaft einer Spalte in jeder der verschiedenen Tabellen zugeordnet wird. EF7 ermöglicht, dass diese Spalten, unterschiedliche Namen haben. Betrachten Sie beispielsweise eine einfache Vererbungshierarchie:

public abstract class Animal
{
    public int Id { get; set; }
    public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
    public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
    public string? FavoriteToy { get; set; }
}

Mit der TPT-Vererbungsstrategiewerden diese Typen drei Tabellen zugeordnet. Die Primärschlüsselspalte in jeder Tabelle hat jedoch möglicherweise einen anderen Namen. Beispiel:

CREATE TABLE [Animals] (
    [Id] int NOT NULL IDENTITY,
    [Breed] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
    [CatId] int NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
    CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
    CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

EF7 ermöglicht die Konfiguration dieser Zuordnung mithilfe eines verschachtelten Tabellen-Generators:

modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

Mit der TPC-Vererbungszuordnung kann die Breed-Eigenschaft auch verschiedenen Spaltennamen in verschiedenen Tabellen zugeordnet werden. Sehen Sie sich beispielsweise die folgenden TPC-Tabellen an:

CREATE TABLE [Cats] (
    [CatId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [CatBreed] nvarchar(max) NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId])
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [DogBreed] nvarchar(max) NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId])
);

EF7 unterstützt diese Tabellenzuordnung:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        builder =>
        {
            builder.Property(cat => cat.Id).HasColumnName("CatId");
            builder.Property(cat => cat.Breed).HasColumnName("CatBreed");
        });

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        builder =>
        {
            builder.Property(dog => dog.Id).HasColumnName("DogId");
            builder.Property(dog => dog.Breed).HasColumnName("DogBreed");
        });

Unidirektionale n:n-Beziehungen

EF7 unterstützt m:n-Beziehungen, bei denen eine Seite oder die andere keine Navigationseigenschaft aufweist. Betrachten Sie beispielsweise die Typen Post und Tag:

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
}
public class Tag
{
    public int Id { get; set; }
    public string TagName { get; set; } = null!;
}

Beachten Sie, dass der Typ Post über eine Navigationseigenschaft für eine Liste von Tags verfügt, der Typ Tag jedoch keine Navigationseigenschaft für Beiträge hat. In EF7 kann dies weiterhin als m:n-Beziehung konfiguriert werden, sodass dasselbe Objekt Tag für viele verschiedene Beiträge verwendet werden kann. Beispiel:

modelBuilder
    .Entity<Post>()
    .HasMany(post => post.Tags)
    .WithMany();

Dies führt zu einer Zuordnung zur entsprechenden Verknüpfungstabelle:

CREATE TABLE [Tags] (
    [Id] int NOT NULL IDENTITY,
    [TagName] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Tags] PRIMARY KEY ([Id])
);

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(64) NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id])
);

CREATE TABLE [PostTag] (
    [PostId] int NOT NULL,
    [TagsId] int NOT NULL,
    CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostId], [TagsId]),
    CONSTRAINT [FK_PostTag_Posts_PostId] FOREIGN KEY ([PostId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
);

Und die Beziehung kann in normaler Weise als n:n verwendet werden. Fügen Sie z. B. einige Beiträge ein, die verschiedene Tags aus einem gemeinsamen Satz teilen:

var tags = new Tag[] { new() { TagName = "Tag1" }, new() { TagName = "Tag2" }, new() { TagName = "Tag2" }, };

await context.AddRangeAsync(new Blog { Posts =
{
    new Post { Tags = { tags[0], tags[1] } },
    new Post { Tags = { tags[1], tags[0], tags[2] } },
    new Post()
} });

await context.SaveChangesAsync();

Entitätsaufteilung

Die Entitätsaufteilung ordnet einem einzelnen Entitätstyp mehrere Tabellen zu. Betrachten Sie beispielsweise eine Datenbank mit drei Tabellen, die Kundendaten enthalten:

  • Eine Customers Tabelle für Kundeninformationen
  • Eine PhoneNumbers Tabelle für die Telefonnummern der Kunden
  • Eine Addresses Tabelle für die Adressen der Kunden

Hier sind Definitionen für diese Tabellen im SQL Server:

CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
    
CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

Jede dieser Tabellen wird in der Regel ihrem eigenen Entitätstyp mit Beziehungen zwischen den Typen zugeordnet. Wenn jedoch alle drei Tabellen immer zusammen verwendet werden, kann es praktischer sein, sie einem einzelnen Entitätstyp zuzuordnen. Beispiel:

public class Customer
{
    public Customer(string name, string street, string city, string? postCode, string country)
    {
        Name = name;
        Street = street;
        City = city;
        PostCode = postCode;
        Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
}

Dies wird in EF7 erreicht, indem für jede Aufteilung im Entitätstyp SplitToTable aufgerufen wird. Mit dem folgenden Code wird beispielsweise der Entitätstyp Customer auf die oben gezeigten Tabellen Customers, PhoneNumbers und Addresses aufgeteilt:

modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
        entityBuilder
            .ToTable("Customers")
            .SplitToTable(
                "PhoneNumbers",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.PhoneNumber);
                })
            .SplitToTable(
                "Addresses",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.Street);
                    tableBuilder.Property(customer => customer.City);
                    tableBuilder.Property(customer => customer.PostCode);
                    tableBuilder.Property(customer => customer.Country);
                });
    });

Beachten Sie auch, dass bei Bedarf unterschiedliche Primärschlüsselspaltennamen für jede der Tabellen angegeben werden können.

SQL Server UTF-8-Zeichenfolgen

SQL Server-Unicode-Zeichenfolgen, die durch die Datentypennchar und nvarchar dargestellt werden, werden als UTF-16 gespeichert. Darüber hinaus werden die Datentypen char und varchar verwendet, um Nicht-Unicode-Zeichenfolgen mit Unterstützung für verschiedene Zeichensätze zu speichern.

Ab SQL Server 2019 können statt dem Speichern mit Unicode-Zeichenfolgen mit UTF-8-Codierung die Datentypen char und varchar gespeichert werden. Dies wird durch Festlegen einer der UTF-8-Sortierungen erreicht. Der folgende Code konfiguriert z. B. eine SQL Server UTF-8-Zeichenfolge für die Variable für die Spalte CommentText:

modelBuilder
    .Entity<Comment>()
    .Property(comment => comment.CommentText)
    .HasColumnType("varchar(max)")
    .UseCollation("LATIN1_GENERAL_100_CI_AS_SC_UTF8");

Diese Konfiguration generiert die folgende SQL Server-Spaltendefinition:

CREATE TABLE [Comment] (
    [PostId] int NOT NULL,
    [CommentId] int NOT NULL,
    [CommentText] varchar(max) COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8 NOT NULL,
    CONSTRAINT [PK_Comment] PRIMARY KEY ([PostId], [CommentId])
);

Zeitliche Tabellen unterstützen besitzereigene Entitäten

Die Zuordnung zeitlicher SQL Server-Tabellen von EF Core wurde in EF7 verbessert, um die Tabellenfreigabe zu unterstützen. Die Standardzuordnung für einzelne besitzereigene Entitäten verwendet vor allem die Tabellenfreigabe.

Ziehen Sie z. B. einen Besitzerentitätstyp Employee und seinen eigenen Entitätstyp EmployeeInfo in Betracht:

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; } = null!;

    public EmployeeInfo Info { get; set; } = null!;
}

public class EmployeeInfo
{
    public string Position { get; set; } = null!;
    public string Department { get; set; } = null!;
    public string? Address { get; set; }
    public decimal? AnnualSalary { get; set; }
}

Wenn diese Typen derselben Tabelle zugeordnet sind, kann diese Tabelle in EF7 zu einer zeitlichen Tabelle gemacht werden:

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        tableBuilder =>
        {
            tableBuilder.IsTemporal();
            tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
            tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
        })
    .OwnsOne(
        employee => employee.Info,
        ownedBuilder => ownedBuilder.ToTable(
            "Employees",
            tableBuilder =>
            {
                tableBuilder.IsTemporal();
                tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
                tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
            }));

Hinweis

Die Vereinfachung dieser Konfiguration wird von Problem Nr. 29303nachverfolgt. Stimmen Sie für dieses Problem ab, wenn es sich um etwas handelt, das Sie implementiert haben möchten.

Verbesserte Wertgenerierung

EF7 enthält zwei wesentliche Verbesserungen bei der automatischen Generierung von Werten für Schlüsseleigenschaften.

Tipp

Der Code für Beispiele in diesem Abschnitt stammt aus ValueGenerationSample.cs.

Wertgenerierung für DDD-geschützte Typen

Im domänengesteuerten Design (DDD) können „geschützte Schlüssel“ die Typsicherheit von Schlüsseleigenschaften verbessern. Dies wird erreicht, indem der Schlüsseltyp in einen anderen Typ eingeschlossen wird, der für die Verwendung des Schlüssels spezifisch ist. Der folgende Code definiert beispielsweise einen Typ ProductId für Produktschlüssel und einen Typ CategoryId für Kategorieschlüssel.

public readonly struct ProductId
{
    public ProductId(int value) => Value = value;
    public int Value { get; }
}

public readonly struct CategoryId
{
    public CategoryId(int value) => Value = value;
    public int Value { get; }
}

Diese werden dann in den Entitätstypen Product und Category verwendet:

public class Product
{
    public Product(string name) => Name = name;
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public CategoryId CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public Category(string name) => Name = name;
    public CategoryId Id { get; set; }
    public string Name { get; set; }
    public List<Product> Products { get; } = new();
}

Dies macht es unmöglich, versehentlich die ID für eine Kategorie einem Produkt zuzuweisen oder umgekehrt.

Warnung

Wie bei vielen DDD-Konzepten kommt diese verbesserte Typsicherheit auf Kosten zusätzlicher Codekomplexität. Es lohnt sich zu überlegen, ob beispielsweise das Zuweisen einer Produkt-ID zu einer Kategorie etwas ist, das jemals passieren wird. Das Vereinfachen kann insgesamt für die Codebasis vorteilhafter sein.

Die hier gezeigten geschützten Schlüsseltypen enthalten beide Umbruchschlüsselwerte int, was bedeutet, dass ganzzahlige Werte in den zugeordneten Datenbanktabellen verwendet werden. Dies wird durch Definieren von Wertkonvertern für die Typen erreicht:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<ProductId>().HaveConversion<ProductIdConverter>();
    configurationBuilder.Properties<CategoryId>().HaveConversion<CategoryIdConverter>();
}

private class ProductIdConverter : ValueConverter<ProductId, int>
{
    public ProductIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

private class CategoryIdConverter : ValueConverter<CategoryId, int>
{
    public CategoryIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

Hinweis

Der hier aufgeführte Code verwendet Typen struct. Das bedeutet, dass sie über die entsprechende Werttypsemantik verfügen, die als Schlüssel verwendet werden soll. Wenn class stattdessen Typen verwendet werden, müssen sie entweder die Gleichheitssemantik außer Kraft setzen oder auch einen Wertvergleich angeben.

In EF7 können Schlüsseltypen, die auf Wertkonvertern basieren, automatisch generierte Schlüsselwerte verwenden, solange der zugrunde liegende Typ dies unterstützt. Dies ist auf normale Weise mit ValueGeneratedOnAdd konfiguriert:

modelBuilder.Entity<Product>().Property(product => product.Id).ValueGeneratedOnAdd();
modelBuilder.Entity<Category>().Property(category => category.Id).ValueGeneratedOnAdd();

Standardmäßig führt dies zu IDENTITY Spalten, wenn sie mit SQL Server verwendet werden:

CREATE TABLE [Categories] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Categories] PRIMARY KEY ([Id]));

CREATE TABLE [Products] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Die beim Einfügen von Entitäten üblichen Schlüsselwerte generieren:

MERGE [Categories] USING (
VALUES (@p0, 0),
(@p1, 1)) AS i ([Name], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Name])
VALUES (i.[Name])
OUTPUT INSERTED.[Id], i._Position;

Sequenzbasierte Schlüsselgenerierung für SQL Server

EF Core unterstützt die Schlüsselwertgenerierung mithilfe von SQL ServerIDENTITY-Spalten oder einem Hi-Lo-Muster basierend auf Schlüsselblöcken, die von einer Datenbanksequenz generiert werden. EF7 bietet Unterstützung für eine Datenbanksequenz, die an die Standardeinschränkung der Spalte des Schlüssels angefügt ist. In seiner einfachsten Form muss EF Core lediglich aufgefordert werden, eine Sequenz für die Schlüsseleigenschaft zu verwenden:

modelBuilder.Entity<Product>().Property(product => product.Id).UseSequence();

Dies führt dazu, dass eine Sequenz in der Datenbank definiert wird:

CREATE SEQUENCE [ProductSequence] START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE NO CYCLE;

Dies wird dann in der Standardeinschränkung der Schlüsselspalte verwendet:

CREATE TABLE [Products] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [ProductSequence]),
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Hinweis

Diese Form der Schlüsselgenerierung wird standardmäßig für generierte Schlüssel in Entitätstyphierarchien mithilfe der TPC-Zuordnungsstrategie verwendet.

Bei Bedarf kann die Sequenz einen anderen Namen und ein anderes Schema erhalten. Beispiel:

modelBuilder
    .Entity<Product>()
    .Property(product => product.Id)
    .UseSequence("ProductsSequence", "northwind");

Die weitere Konfiguration der Sequenz wird durch die explizite Konfiguration im Modell gebildet. Beispiel:

modelBuilder
    .HasSequence<int>("ProductsSequence", "northwind")
    .StartsAt(1000)
    .IncrementsBy(2);

Verbesserungen der Migrationstools

EF7 enthält zwei wesentliche Verbesserungen bei der Verwendung der Befehlszeilentools für EF Core-Migrationen.

UseSqlServer etc. akzeptieren NULL

Es ist sehr üblich, eine Verbindungszeichenfolge aus einer Konfigurationsdatei zu lesen und diese Verbindungszeichenfolge dann an UseSqlServer, UseSqlite oder die entsprechende Methode für einen anderen Anbieter zu übergeben. Beispiel:

services.AddDbContext<BloggingContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("BloggingDatabase")));

Es ist auch üblich, beim Anwenden von Migrationen eine Verbindungszeichenfolge zu übergeben. Beispiel:

dotnet ef database update --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

Oder wenn Sie ein Migrationsbundle verwenden.

./bundle.exe --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

In diesem Fall versucht der Anwendungsstartcode, die Verbindungszeichenfolge aus der Konfiguration zu lesen und an sie zu UseSqlServerübergeben. Wenn die Konfiguration nicht verfügbar ist, führt dies zum Übergeben von NULL an UseSqlServer. In EF7 ist dies zulässig, solange die Verbindungszeichenfolge später festgelegt wird, z. B. durch Übergeben von --connection an das Befehlszeilentool.

Hinweis

Diese Änderung wurde für UseSqlServer und UseSqlite vorgenommen. Wenden Sie sich für andere Anbieter an den Anbieterbetreuer, um eine entsprechende Änderung vorzunehmen, wenn sie für diesen Anbieter noch nicht geschehen ist.

Erkennen, wann Tools ausgeführt werden

EF Core führt Anwendungscode aus, wenn dotnet-ef oder PowerShell-Befehle verwendet werden. Manchmal kann es notwendig sein, diese Situation zu erkennen, um zu verhindern, dass unangemessener Code zur Entwurfszeit ausgeführt wird. Code, der beim Start automatisch Migrationen anwendet, sollte dies wahrscheinlich zur Entwurfszeit nicht tun. In EF7 kann dies mithilfe der Flag EF.IsDesignTime erkannt werden:

if (!EF.IsDesignTime)
{
    await context.Database.MigrateAsync();
}

EF Core setzt IsDesignTime auf true, wenn Anwendungscode im Auftrag von Tools ausgeführt wird.

Leistungsverbesserungen für Proxys

EF Core unterstützt dynamisch generierte Proxys für verzögertes Laden und Änderungsnachverfolgung. EF7 enthält zwei Leistungsverbesserungen bei der Verwendung dieser Proxys:

  • Die Proxytypen werden jetzt verzögert erstellt. Dies bedeutet, dass die erste Modellerstellungszeit bei der Verwendung von Proxys mit EF7 erheblich schneller sein kann als bei EF Core 6.0.
  • Proxys können jetzt mit kompilierten Modellen verwendet werden.

Hier sind einige Leistungsergebnisse für ein Modell mit 449 Entitätstypen, 6390 Eigenschaften und 720 Beziehungen.

Szenario Methode Mittelwert Fehler StdDev
EF Core 6.0 ohne Proxys ZeitBisZurErstenAbfrage 1,085 s 0,0083 s 0,0167 s
EF Core 6.0 mit Proxys zur Änderungsnachverfolgung ZeitBisZurErstenAbfrage 13,01 s 0,2040 s 0,4110 s
EF Core 7.0 ohne Proxys ZeitBisZurErstenAbfrage 1,442 s 0,0134 s 0,0272 s
EF Core 7.0 mit Proxys zur Änderungsnachverfolgung ZeitBisZurErstenAbfrage 1,446 s 0,0160 s 0,0323 s
EF Core 7.0 mit Proxys zur Änderungsnachverfolgung und kompiliertem Modell ZeitBisZurErstenAbfrage 0,162 s 0,0062 s 0,0125 s

In diesem Fall kann ein Modell mit Proxys zur Änderungsnachverfolgung also bereit sein, die erste Abfrage 80 mal schneller in EF7 auszuführen als es mit EF Core 6.0 möglich ist.

Erstklassige Windows Forms-Datenbindung

Das Windows Forms-Team hat einige großartige Verbesserungen an der Visual Studio Designer-Oberflächevorgenommen. Dies umfasst neue Oberflächen für die Datenbindung, die gut in den EF Core integriert ist.

Kurz gesagt bietet die neue Oberfläche Visual Studio U.I. zum Erstellen einer ObjectDataSource:

Choose Category data source type

Dies kann dann mit einem einfachen Code an einen EF Core DbSet gebunden werden:

public partial class MainForm : Form
{
    private ProductsContext? dbContext;

    public MainForm()
    {
        InitializeComponent();
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        this.dbContext = new ProductsContext();

        this.dbContext.Categories.Load();
        this.categoryBindingSource.DataSource = dbContext.Categories.Local.ToBindingList();
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        base.OnClosing(e);

        this.dbContext?.Dispose();
        this.dbContext = null;
    }
}

Eine vollständige exemplarische Vorgehensweise und eine herunterladbare WinForms-Beispielanwendung finden Sie unter Erste Schritte mit Windows Forms.