Entity Framework Core 8

26. Juli 2024

Entity Framework Core (kurz EF Core) ist ein ORM-Framework (Object-Relational Mapping) von Microsoft. Es ermöglicht .NET-Entwicklern, Datenbankoperationen über .NET-Objekte durchzuführen, ohne direkt SQL-Abfragen zu schreiben.

Es werden unterschiedlichste Datenbankanbieter unterstützt (Microsoft SQL Server, Oracle, SQLite, PostgreSQL, …) und es bietet Unterstützung für LINQ-Abfragen, Change-Tracking, Migration und vieles mehr.

Installation .NET EF Tools

Mit Hilfe der .NET EF Tools können Datenbankmigrationen erstellt und ausgeführt werden. Die Installation wird über die Kommandozeile aufgerufen:

dotnet tool install --global dotnet-ef

Anschließend können die Befehle mit

dotnet ef ...

ausgeführt werden. Mehr dazu später.

Vorbereitung .NET Projekt

Im zu verwendenden .NET-Projekt muss das NuGet-Paket „Microsoft.EntityFrameworkCore“ bzw. ein datenbankspezifisches wie bspw. „Microsoft.EntityFrameworkCore.SqlServer“ eingebunden werden. Außerdem wird für die Migrations das Paket „Microsoft.EntityFrameworkCore.Design“ benötigt.

Erstellen von Klassen

Nun können POCO-Klassen mit Eigenschaften erstellt werden, die mit speziellen EF Core Attributen markiert werden können, um ein Mapping mit dem Datenbankschema herzustellen. Alternativ zu den Attributen kann auch die Fluent API verwendet werden.

[Table("PEOPLE", Schema = "ef")]
public class Person
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("FIRST_NAME")]
    [StringLength(50)]
    [Required]
    public string? FirstName { get; set; }

    [Column("LAST_NAME")]
    [StringLength(50)]
    [Required]
    public string? LastName { get; set; }
    
    [Timestamp]
    [Column("ROW_VERSION")]
    public byte[]? RowVersion { get; set; }
}

Nachfolgend die wichtigsten Attribute.

auf Klassen

  • [Table(„TableName“)]: Gibt den Namen der Datenbanktabelle an, die mit der Klasse verknüpft ist.
  • [Key]: Markiert eine Eigenschaft als Primärschlüssel.
  • [NotMapped]: Gibt an, dass die Klasse nicht in der Datenbank gespeichert werden soll. Dieses Attribut kann auch auf Eigenschaften gesetzt werden.
  • [DatabaseGenerated(option)]: Gibt in Kombination mit dem [Key]-Attribut an, wie der Primärschlüssel definiert wird (None, Identity oder Computed).

auf Eigenschaften

  • [Required]: Gibt an, dass die Eigenschaft einen Wert haben muss (nicht null).
  • [MaxLength(n)]: Legt die maximale Länge einer Zeichenfolgen-Eigenschaft fest.
  • [MinLength(n)]: Legt die minimale Länge einer Zeichenfolgen-Eigenschaft fest.
  • [StringLength(min, max)]: Legt die minimale und maximale Länge einer Zeichenfolgen-Eigenschaft fest.
  • [Range(min, max)]: Gibt den gültigen Wertebereich für numerische Eigenschaften an.
  • [Column(„ColumnName“)]: Gibt den Namen der Datenbankspalte an, die mit der Eigenschaft verknüpft ist.
  • [ForeignKey(„NavigationProperty“)]: Gibt an, dass eine Eigenschaft einen Fremdschlüssel darstellt.
  • [ConcurrencyCheck]: Markiert eine Eigenschaft für die Optimistic Concurrency Control.
  • [Timestamp]: Gibt an, dass die Eigenschaft für die Optimistic Concurrency Control verwendet wird und automatisch aktualisiert wird.

DbContext

Der DbContext ist die zentrale Klasse, über die die Anwendung mit der Datenbank kommunizieren und CRUD-Operationen (Create, Read, Update, Delete) ausführen kann.

Unter anderen hat der DbContext folgende Aufgaben:

  • Datenbankverbindung: Der DbContext verwaltet die Verbindung zur Datenbank. Er stellt sicher, dass die Anwendung mit der Datenbank verbunden ist, und verwaltet die Lebensdauer der Verbindung.
  • Modelle und Entitäten: Der DbContext wird verwendet, um Entitäten zu definieren, die die Tabellen in der Datenbank repräsentieren. Jede Entität wird als DbSet<T> im DbContext definiert, wobei T der Typ der Entität ist.
  • Abfragen und Manipulation von Daten: Der DbContext ermöglicht es, Daten über LINQ-Abfragen zu lesen und zu manipulieren. Er bietet Methoden wie Add, Update, Remove und SaveChanges, um Änderungen an den Entitäten in der Datenbank vorzunehmen.
  • Nachverfolgung von Änderungen: Der DbContext verfolgt Änderungen an den Entitäten, die in ihm geladen werden. Wenn SaveChanges aufgerufen wird, werden diese Änderungen in der Datenbank gespeichert.
  • Konfiguration: Der DbContext ermöglicht die Konfiguration von Entitäten und deren Beziehungen, z. B. durch Fluent API oder Data Annotations (Attribute). Dies umfasst die Definition von Primär- und Fremdschlüsseln, Indizes und andere Einschränkungen.
  • Migrationen: Der DbContext kann auch verwendet werden, um Migrationen zu erstellen und zu verwalten, die es ermöglichen, die Datenbankstruktur im Laufe der Entwicklung zu ändern, ohne Daten zu verlieren.

Ein DbContext ist eine Klasse, die von DbContext erbt und pro verwaltete Entität eine Eigenschaft vom Typ „DbSet<T>“ enthält:

public class MyDbContext : DbContext
{
    public DbSet<Person> People { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("connectionString");
    }
}

Bei der Verwendung mit Microsoft.Extensions.Hosting kann der DbContext als Service registriert und so konsumiert werden:

builder.ConfigureServices((context, services) =>
{
    services.AddDbContext<MyDbContext>(options =>
    {
        options.UseSqlServer(context.Configuration.GetValue<string>("ConnectionStrings:Default"));
    });
});

Hierfür wird allerdings der Konstruktor, der „DbContextOptions“ empfängt, benötigt:

public class MyDbContext : DbContext
{
    public MyDbContext(DbContextOptions options) 
        : base(options)
    {
    }

    ...
}

Standardmäßig wird dieser als „Scoped“ registriert. Falls anders gewünscht, kann der zu verwendende Scope bei der Registrierung angegeben werden.

Migrations

Mit Hilfe von Migrations werden für Änderungen in den Entitäten Update-Skripte erstellt, die auf die Datenbankstruktur angewendet werden können.

Um eine Migration zu erstellen, muss in der Kommandozeile in das Projekt gewechselt werden. Anschließend wird der folgende Befehl aufgerufen. Das letzte Argument definiert den zu verwendenden Namen der Migration.

dotnet ef migrations add NameDerMigration

Durch den Aufruf des Befehls werden im .NET Projekt ein bzw. zwei Dateien erstellt.

Eine Datei hat den Namen bestehend aus dem aktuellen Datum und Uhrzeit sowie dem verwendeten Namen. In diesem sind die Datenbank-Skripte für das Schema-Update definiert (erstellen und rückgängig machen).

Die zweite Datei hat den Namen des DbContexts plus „ModelSnapshot“ und definiert den letzten Status des Schemas. Bei einer neuerlichen Migration wird geprüft, wie sich das Schema im Vergleich zum Snapshot geändert hat und erzeugt dadurch die neue Migration.

Um die Migration anzuwenden kann entweder im Code der folgende Befehl

var ctx = new MyDbContext();
ctx.Database.Migrate();

oder über die .NET EF Tools der Befehl

dotnet ef database update

ausgeführt werden.

Eine angewendete Migration kann auch wieder rückgängig gemacht werden. Hierfür muss als zusätzlicher Parameter der Name der Migration angegeben werden, auf dessen Status die Datenbank zurückgesetzt werden soll. Angenommen, unser Beispiel hätte bereits weitere Migrations und wir möchten den Status auf die Initial-Migration zurücksetzen, dann kann folgender Befehl aufgerufen werden.

dotnet ef database update Initial

Es ist auch möglich, das SQL-Skript für die Migration ausgeben zu lassen:

dotnet ef migrations script
dotnet ef migrations script --idempotent
dotnet ef migrations script FromMigration ToMigration

Um die letzte Migration aus dem Code zu entfernen, wird folgender Befehl verwendet:

dotnet ef migrations remove

CRUD-Operationen

Nun können wir mit Hilfe des DbContext Daten erstellen, lesen, ändern und löschen.

//Erstellen
ctx.People.Add(new Person
{
    FirstName = "John",
    LastName = "Doe"
});
ctx.SaveChanges();

//Ändern
var p = ctx.People.First();
p.FirstName = "Jane";
ctx.SaveChanges();

//Löschen
ctx.People.Remove(p);
ctx.SaveChanges();

Log-Ausgabe von SQL-Statements

Die von EF Core generierten SQL-Statements können für Testzwecke einfach auf die Console ausgegeben werden.

public class MyDbContext : DbContext
{
    public DbSet<Person> People { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer("connectionString")
            .EnableSensitiveDataLogging()
            .LogTo(Console.WriteLine, LogLevel.Information);
    }
}

Beziehungen

One-to-One Relationship

1..1 Beziehungen können in EF Core sehr einfach abgebildet werden. Das Beispiel von vorhin wird um Address erweitert, wobei jede Person eine Adresse haben kann und eine Adresse immer einer Person zugeordnet ist.

[Table("PEOPLE", Schema = "ef")]
public class Person
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("FIRST_NAME")]
    [StringLength(50)]
    [Required]
    public string? FirstName { get; set; }

    [Column("LAST_NAME")]
    [StringLength(50)]
    [Required]
    public string? LastName { get; set; }

    [Column("BIRTH_DATE")]
    public DateTime? BirthDate { get; set; }

    public Address? Address { get; set; }
    
    [Timestamp]
    [Column("ROW_VERSION")]
    public byte[]? RowVersion { get; set; }
}

[Table("ADDRESSES", Schema = "ef")]
public class Address
{
    [Key]
    [ForeignKey(nameof(Person))]
    [Column("ID")]
    public int Id { get; set; }
    
    public Person? Person { get; set; }

    [Column("CITY")]
    [StringLength(50)]
    [Required]
    public string? City { get; set; }
}

Die Entität-Person hat eine neue Eigenschaft „Address“ und die Entität-Address eine Eigenschaft „Person“ erhalten. Zusätzlich wurde die „Id“-Eigenschaft in Address mit dem ForeignKey-Attribut und Verweis auf das Navigation-Property „Person“ versehen.

Wichtig bei der Verwendung ist, dass, wenn Lazy-Loading nicht aktiv ist (siehe weiter unten), dass die Navigation-Properties standardmäßig nicht geladen werden und somit leer sind. Mit Hilfe von „Include“ kann ein Eager-Loading gemacht werden:

var p = ctx
    .People
    .Include(c => c.Address)
    .First();

p.FirstName = "Jane";
p.Address!.City = "Los Angeles";

Alternativ kann das Laden auch nachgelagert durchgeführt werden:

var p = ctx.People.First();
p.FirstName = "Jane";
    
ctx
    .Entry(p)
    .Reference(c => c.Address)
    .Load();

p.Address!.City = "Los Angeles";

One-to-Many Relationship

Als Nächstes wird das Beispiel um eine 1..n Beziehung ergänzt. Eine Person kann mehrere Haustiere haben.

[Table("PEOPLE", Schema = "ef")]
public class Person
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    ...
    
    public ICollection<Pet>? Pets { get; set; }
}

[Table("PETS", Schema = "ef")]
public class Pet
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("NAME")]
    [StringLength(50)]
    [Required]
    public string? Name { get; set; }

    [Column("OWNER_ID")]
    public int OwnerId { get; set; }

    public Person? Owner { get; set; }
}

In der Person-Entität wurde eine neue Eigenschaft „Pets“ vom Typ „ICollection<Pet>“ hinzugefügt. Die Pet-Entität hat einen Owner, der auf die Person referenziert.

Auch hier gilt die gleiche Thematik bzgl. Lazy-Loading wie bereits zuvor bei One-to-One Relationship erwähnt. Damit die Collection geladen wird, muss wieder „Include“ verwendet werden oder die Liste zu einem späteren Zeitpunkt geladen werden.

var p = ctx
    .People
    .Include(c => c.Pets)
    .First();

bzw.

ctx
    .Entry(p)
    .Collection(c => c.Pets!)
    .Load();

Das Ändern des Owners (in diesem Fall im Pet) führt nicht automatisch dazu, dass das Element aus der Pets-Liste des alten Owners verschwindet und in der Liste des neuen Owners eingefügt wird. Dies wird erst im Zuge von „SaveChanges“ gemacht.

Include vs. AsSplitQuery

Ein „Include“, wie im vorherigen Beispiel hat mitunter negative Auswirkungen auf die Performance, da EF Core versucht alle Daten mit so wenig SQL-Statements wie möglich aus der Datenbank zu laden. D. h. es werden teilweise recht komplexe Statements erstellt, die nicht mehr performant sind.

Um EF Core zu instruieren, dass dies nicht gemacht werden soll, kann die Extension Methode „AsSplitQuery“ verwendet werden.

var p = ctx
    .People
    .Include(c => c.Pets)
    .AsSplitQuery()
    .First();

Vererbung

EF Core kennt drei Arten von Vererbung, die nachfolgend in den Grundzügen beschrieben werden.

Table per Hierarchy (TPH)

Hierbei wird die komplette Vererbungshierarchie in einer Datenbanktabelle gespeichert. Spalten, die in einer Entität nicht vorkommen, bleiben null.

[Table("ANIMALS", Schema = "ef")]
public abstract class Animal
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("NAME")]
    [StringLength(50)]
    [Required]
    public string? Name { get; set; }
}

public class Dog : Animal
{
    [Column("BREED")]
    [StringLength(50)]
    [Required]
    public string? Breed { get; set; }
}

public class Cat : Animal
{
    [Column("COLOR")]
    [StringLength(50)]
    [Required]
    public string? Color { get; set; }
}

Entscheidend ist, dass nur die Basisklasse das Table-Attribut hat.

Hierbei erzeugt EF Core im Hintergrund automatisch eine „Discriminator“-Spalte, der der jeweilige Type-Name gespeichert ist (in diesem Fall „Cat“ oder „Dog“). Diese kann mit Hilfe der Fluent API geändert werden:

public class MyDbContext : DbContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder
            .Entity<Animal>()
            .HasDiscriminator<string>("TYPE");
            
        base.OnModelCreating(modelBuilder);
    }
}

Table per Type (TPT)

Jede Entität hat eine eigene Tabelle. Die Tabellen sind untereinander mit Foreign-Keys verbunden. Je nach Verwendung kann dies zu Problemen mit der Performance führen, da ggf. einige Joins notwendig sind.

[Table("ANIMAL", Schema = "ef")]
public abstract class Animal
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("NAME")]
    [StringLength(50)]
    [Required]
    public string? Name { get; set; }
}

[Table("CATS", Schema = "ef")]
public class Cat : Animal
{
    [Column("COLOR")]
    [StringLength(50)]
    [Required]
    public string? Color { get; set; }
}

[Table("DOGS", Schema = "ef")]
public class Dog : Animal
{
    [Column("BREED")]
    [StringLength(50)]
    [Required]
    public string? Breed { get; set; }
}

Entscheidend ist, dass jede Klasse ein Table-Attribut hat.

Eine Discriminator-Spalte ist hierbei nicht notwendig und auch nicht erlaubt, da die Trennung auf Datenbanktabellen-Ebene stattfindet.

Table per Concrete Class (TPC)

In diesem Fall wird für jede konkrete Entität eine eigene Datenbanktabelle erstellt. Der Unterschied zu TPT ist, dass es keine gemeinsame Datenbanktabelle gibt, sondern jede Entität für sich lebt und einfach nur die Eigenschaften der Basisklasse erbt. Sinnvollerweise ist die Basisklasse abstrakt, da diese für sich alleine nicht verwendet werden kann.

public abstract class Animal
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("NAME")]
    [StringLength(50)]
    [Required]
    public string? Name { get; set; }
}

[Table("CATS", Schema = "ef")]
public class Cat : Animal
{
    [Column("COLOR")]
    [StringLength(50)]
    [Required]
    public string? Color { get; set; }
}

[Table("DOGS", Schema = "ef")]
public class Dog : Animal
{
    [Column("BREED")]
    [StringLength(50)]
    [Required]
    public string? Breed { get; set; }
}

Entscheidend ist, dass die Basisklasse kein Table-Attribut hat.

Index

Der Primärschlüssel und alle Fremdschlüssel einer Tabelle erhalten automatisch von EF Core einen Index. Mit Hilfe des Index-Attributes können auf der Klasse noch zusätzliche Indizes definiert werden.

Table("PEOPLE", Schema = "ef")]
[Index(nameof(FirstName), nameof(LastName), IsUnique = true)]
[Index(nameof(BirthDate), Name = "IX_P_BD")]
public class Person
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("FIRST_NAME")]
    [StringLength(50)]
    [Required]
    public string? FirstName { get; set; }

    [Column("LAST_NAME")]
    [StringLength(50)]
    [Required]
    public string? LastName { get; set; }

    [Column("BIRTH_DATE")]
    public DateTime? BirthDate { get; set; }
}

„IsUnique“ definiert, dass es ein eindeutiger Index ist. Der Name des Index wird aus dem Namen der Datenbanktabelle und dem Namen der enthaltenen Spalten abgeleitet. Alternativ kann dieser auch in der Eigenschaft Name angegeben werden.

Query Filter

Query Filter erlauben es, einer Entität einen auf den DbContext bezogenen globalen Filter zu setzen. Dies ist bspw. bei der Verwendung von Soft-Delete oder Tenants eine coole Sache. Nachfolgend ein Beispiel für Soft-Delete:

public class Base
{
    [Column("IS_DELETED")]
    public bool IsDeleted { get; set; }
}

[Table("PEOPLE", Schema = "ef")]
[Index(nameof(FirstName), nameof(LastName), IsUnique = true)]
[Index(nameof(BirthDate), Name = "IX_P_BD")]
public class Person : Base
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("FIRST_NAME")]
    [StringLength(50)]
    [Required]
    public string? FirstName { get; set; }

    public virtual ICollection<Pet>? Pets { get; set; }
}

[Table("PETS", Schema = "ef")]
public class Pet : Base
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("NAME")]
    [StringLength(50)]
    [Required]
    public string? Name { get; set; }

    [Column("OWNER_ID")]
    public int OwnerId { get; set; }

    public virtual Person? Owner { get; set; }
}

Es gibt eine Basisklasse „Base“ und zwei Klassen, die von dieser Erben. Die Basisklasse hat eine Eigenschaft „IsDeleted“.

Im DbContext in der Methode „OnModelCreating“ können nun die Query Filter definiert werden.

public class MyDbContext : DbContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        foreach (var type in modelBuilder.Model.GetEntityTypes())
        {
            if (!type.ClrType.IsSubclassOf(typeof(Base)))
                continue;

            var param = Expression.Parameter(type.ClrType, "x");
            var prop = Expression.Property(param, nameof(Base.IsDeleted));
            var not = Expression.Not(prop);
            
            type.SetQueryFilter(Expression.Lambda(not, param));
        }

        base.OnModelCreating(modelBuilder);
    }
}

Hiermit wird der Filter in jede Abfrage auf die entsprechende ergänzt. Soll der QueryFilter nicht verwendet werden, so muss beim IQueryable<T> die Extension-Methode „IgnoreQueryFilters()“ aufgerufen werden.

Erwähnenswert ist, dass nur ein Query Filter pro Entität definiert werden kann. Wird Methode für die gleiche Entität nochmals aufgerufen, so überschreibt diese den zuvor gesetzten Filter.

Weiter unten geht es noch um das Thema Interceptors, womit die Logik für Soft-Delete noch verbessert werden kann.

Shadow Properties

Shadow Properties sind Eigenschaften, die in einer Entität definiert sind, aber in der Entitätsklasse selbst vorhanden vorhanden sind. Sie ermöglichen es, zusätzliche Daten in der Datenbank zu speichern, ohne die Entitätsklasse zu ändern. Dadurch ist es möglich, dynamisch Entitäten/Datenbanktabellen zu erweitern.

Die Erstellung ist recht einfach und wird über die „OnModelCreating“ des DbContext gemacht:

public class MyDbContext : DbContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder
            .Entity<Person>()
            .Property<DateTime>("CreationDate")
            .HasColumnName("CREATION_DATE");
        
        base.OnModelCreating(modelBuilder);
    }
}

Das Setzen eines Shadow-Property funktioniert wie folgt:

var p = ctx.People.Add(new Person
{
    FirstName = "John",
    LastName = "Doe"
});
p.Property("CreationDate").CurrentValue = DateTime.Now;

ctx.SaveChanges();

Interceptors

Interceptors sind ein leistungsfähiges Konzept, das es Entwicklern ermöglicht, das Verhalten von Datenbankoperationen zu ändern oder zu erweitern, ohne den zugrunde liegenden Code direkt zu ändern. Damit können Aktionen manipuliert werden, bevor sie an die Datenbank gesendet werden oder nachdem die Ergebnisse zurückgegeben wurden.

Es gibt drei Arten von Interceptors, von denen zwei nachfolgend beschrieben werden. Der fehlende ist der DbTransactionInterceptor, der für die Behandlung von Transaktionen verwendet werden kann.

Die Registrierung eines Interceptors passiert im DbContext:

public class MyDbContext : DbContext
{
    ...

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .AddInterceptors(new MyCommandInterceptor());
    }
}

Command Interceptors

Hiermit können alle möglichen Aktionen, die mit Datenbankoperationen zu tun haben, manipuliert und protokolliert werden. Die Basisklasse für diesen Interceptor ist „DbCommandInterceptor“.

public class MyCommandInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, 
        CommandEventData eventData, 
        InterceptionResult<DbDataReader> result)
    {
        Console.WriteLine($"CMD: {command.CommandText}");
        return base.ReaderExecuting(command, eventData, result);
    }
}

Dieser Interceptor macht nichts anderes, als protokolliert als lesenden Abfragen auf die Datenbank.

Daneben gibt es noch jede Menge weiterer Methoden, die überschrieben werden können.

SaveChanges Interceptors

Diese werden im Zuge von SaveChanges/SaveChangesAsync aufgerufen und erlauben es vor dem Speichern von Daten noch weitere Anpassungen/Logiken durchzuführen. Die Basisklasse für diesen Interceptor ist „SaveChangesInterceptor“.

public class MyCommandInterceptor : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, 
        InterceptionResult<int> result)
    {
        var context = eventData.Context;
        
        foreach (var entityEntry in context!.ChangeTracker.Entries<Person>())
        {
            if (entityEntry.State == EntityState.Added)
                entityEntry.Property<DateTime>("CreationDate").CurrentValue = DateTime.Now;
        }
        
        return base.SavingChanges(eventData, result);
    }
}

Kleine Randbemerkung: SaveChanges wird nur aufgerufen, wenn auch auf dem DbContext.SaveChanges() aufgerufen wurde. Wurde dort stattdessen SaveChangesAsync() aufgerufen, wird auch im Interceptor die SaveChangesAsync() aufgerufen.

Hier die Methode, die der SaveChangesInterceptor zur Verfügung stellt.

Computed Columns

EF Core unterstützt Computed Columns, also berechnete Spalten. Die Definition dieser funktioniert zum jetzigen Zeitpunkt nur über die Fluent API. Eine weitere Einschränkung ist, dass diese nur beim Laden und Speichern des Datensatzes berechnet werden.

Die Definition wird wie folgt gemacht:

public class MyDbContext : DbContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder
            .Entity<Person>()
            .Property<string>(nameof(Person.Name))
            .HasComputedColumnSql("Concat(FIRST_NAME, ' ', LAST_NAME)");
        
        base.OnModelCreating(modelBuilder);
    }
}

Bulk Operations

EF Core unterstützt die Bulk-Operationen für Update und Delete. Diese Befehle werden direkt in SQL-Statements umgewandelt, wodurch die dazugehörigen Objekte nicht separat geladen werden müssen.

ctx
    .People
    .Where(c => c.FirstName!.StartsWith("X"))
    .ExecuteDelete();

ctx
    .People
    .Where(c => c.FirstName!.StartsWith("X"))
    .ExecuteUpdate(c => c
        .SetProperty(x => x.FirstName, x => "None")
        .SetProperty(x => x.LastName, x => "None"));

Temporal Tables

Temporal Tables ermöglichen es in Microsoft SQL Servern, die Historie von Daten zu verfolgen, indem automatisch zeitlich abgegrenzte Versionen von Datensätzen gespeichert werden, wenn diese geändert werden. Dies ist u. a. nützlich für Auditing und historische Datenanalysen. 

Die Aktivierung hierfür kann einfach mittels Fluent API gemacht werden.

modelBuilder
    .Entity<Person>()
    .ToTable(c => c.IsTemporal());

Dadurch können auch Datensätze von einem früheren Zeitraum abgefragt werden.

var people = ctx
    .People
    .TemporalAsOf(DateTime.UtcNow.AddDays(-1))
    .ToList();

Abfragen mittels eigenen SQL-Statements

Zur Abfrage von Entitäten mittels SQL stehen mehrere Funktionen zur Verfügung.

var x = ctx
    .People
    .FromSql($"select * from ef.people where id > {id}")
    .Where(c => c.FirstName!.StartsWith("Stefan"))
    .ToList();

Obwohl hierbei eine Interpolation verwendet wird, wird die „id“ nicht direkt übernommen, sondern sie wird als Parameter verwendet. Dies geschieht durch den internen Einsatz von FormattableString.

Alternativ kann, wenn bspw. ein manuelles Zusammensetzen des SQL-Statements notwendig ist, die Funktion „FromSqlRaw“ verwendet werden. Dort können die Parameter als Array übergeben werden.

Ähnlich wie in Dapper, können auch SQL-Statements, für die es keine Entität gibt, abgefragt werden. Wichtig hierbei ist, dass die Namen der Spalten aus dem SQL-Statement mit den Namen der Eigenschaften übereinstimmen.

public record PersonCompact(
    int Id, 
    string FirstName, 
    string LastName);

var y = ctx
    .Database
    .SqlQuery<PersonCompact>($@"
        select
           ID as Id,
           FIRST_NAME as FirstName,
           LAST_NAME as LastName,
           BIRTH_DATE as BirthDate
         from ef.people
         where id > {id}")
    .Where(c => !string.IsNullOrEmpty(c.FirstName))
    .ToList();

Wichtig ist, dass für alle Eigenschaften, die in der Klasse definiert wurden, eine Spalte im Ergebnis des SQL vorhanden ist. Zusätzliche Spalten, wie in diesem Fall BirthDate, werden einfach ignoriert.

Owned Entities vs. Complex Types

Auf den ersten Blick sind Owned Entities und Complex Types recht ähnlich. Beide haben keine eigene Datenbanktabelle, sondern werden in der Tabelle des „Besitzers“ gespeichert.

Eine Instanz einer Owned Entity kann jeweils nur einer Besitzer-Instanz zugewiesen werden. D. h. mehrere Instanzen können sich nicht die gleiche Instanz der Owned Entity teilen.

Anders verhält sich dies bei Complex Types. Hier kann eine Instanz mehreren Entitäten zugewiesen werden.

Zur Markierung einer Klasse als Owned Entity, muss diese das Owned-Attribut haben. Zur Markierung einer Klasse als Complex Type, muss diese das ComplexType-Attribut haben.

JSON Columns

JSON Columns ermöglichen es, komplexe Datentypen in einer einzelnen Spalte (in SQL-Server als nvarchar(max), in PostgreSQL als json/jsonb) zu speichern und dennoch einfach per LINQ Abfragen darauf ausführen zu können.

Als erstes ein Beispiel für eine implizite Variante.

[Table("PEOPLE", Schema = "ef")]
public class Person : Base
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("FIRST_NAME")]
    [StringLength(50)]
    [Required]
    public string? FirstName { get; set; }

    [Column("LAST_NAME")]
    [StringLength(50)]
    [Required]
    public string? LastName { get; set; }

    [Column("TAGS")]
    public List<string>? Tags { get; set; }
}

Bei Tags handelt es sich um eine Liste von String. Bei Listen von einfachen Typen macht EF Core alles im Hintergrund.

Bei komplexeren Typen muss mittels Fluent API definiert werden, dass die Daten als JSON gespeichert werden sollen.

[Table("PEOPLE", Schema = "ef")]
public class Person : Base
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("FIRST_NAME")]
    [StringLength(50)]
    [Required]
    public string? FirstName { get; set; }

    [Column("LAST_NAME")]
    [StringLength(50)]
    [Required]
    public string? LastName { get; set; }

    [Column("SKILLS")]
    public List<Skill>? Skills { get; set; }
}

public class MyDbContext : DbContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder
            .Entity<Person>()
            .OwnsMany<Skill>(
                p => p.Skills,
                b => b.ToJson());

        base.OnModelCreating(modelBuilder);
    }

Abfragen können weiterhin wie gewohnt gemacht werden, ohne dass auf die Art und Weise, wie die Daten gespeichert werden, Rücksicht genommen werden muss.

var x = ctx
    .People
    .Where(c => 
        c.Tags!.Contains("Employee")
        && c.Skills!.Any(d => d.Name == "Smart"))
    .ToList();

Concurrency

EF Core unterstützt zwei Varianten von Optimistic Concurrency Control. Hierbei wird davon ausgegangen, dass es nicht oft zur gleichzeitigen Änderung desselben Datensatzes von zwei Benutzern/Prozessen kommt.

Eine Variante ist die Verwendung des Timestamp-Attributes.

[Table("PEOPLE", Schema = "ef")]
public class Person
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }
    
    [Timestamp]
    [Column("ROW_VERSION")]
    public byte[]? RowVersion { get; set; }
}

Bei jedem Speichervorgang prüft EF Core, ob die RowVersion in der Datenbank noch gleich ist, wie zum Zeitpunkt, als der Datensatz aus der Datenbank geladen wurde. Ist dies nicht der Fall, dann wird eine DbUpdateConcurrencyException geworfen. Ist der Speichervorgang erfolgreich, dann wird die RowVersion vom SQL Server angepasst. Da die RowVersion direkt vom SQL Server verwaltet wird, ändert sich diese auch, wenn die Zeilen bspw. über ein Update-Statement direkt über die Datenbank geändert werden.

Eine weitere Variante ist die Verwendung des ConcurrencyCheck-Attributes. Hierbei können ein oder mehrere Eigenschaften einer Klasse mit dem ConcurrencyCheck-Attribut markiert werden. Beim Speichern wird geprüft, ob die Werte dieser Eigenschaften zum Zeitpunkt des Ladens des Datensatzes noch so unverändert in der Datenbank sind. Falls nicht, wird auch hier eine DbUpdateConcurrencyException geworfen.

[Table("PEOPLE", Schema = "ef")]
public class Person
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("LAST_CHANGE")]    
    [ConcurrencyCheck]
    public DateTime LastChange { get; set; }
}

Validierungen

Neben den bereits erwähnten Attributen können auch eigene Validierungen vor dem Speichern gemacht werden. Eine Variante ist, dass die Entität das IValidatableObject-Interface implementiert. EF Core führt die Validierung hierbei allerdings nicht mehr automatisch durch. Dies muss vom Benutzer manuell gemacht werden.

[Table("PEOPLE", Schema = "ef")]
public class Person : IValidatableObject
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    ...

    [Column("BIRTH_DATE")]
    public DateTime? BirthDate { get; set; }

    public IEnumerable<ValidationResult> Validate(
        ValidationContext validationContext)
    {
        if (BirthDate == null)
            yield return new ValidationResult("Geburtstag fehlt", [nameof(BirthDate)]);
        if (BirthDate < new DateTime(1900, 1, 1))
            yield return new ValidationResult("Geburtstag ungültig", [nameof(BirthDate)]);
    }
}

Die Validierung kann dann bspw. in der SaveChanges des DbContext gemacht werden.

public class MyDbContext : DbContext
{
    ...

    public override int SaveChanges()
    {
        foreach (var entityEntry in ChangeTracker.Entries())
        {
            if (!(entityEntry.Entity is IValidatableObject))
                continue;
            
            Validator.ValidateObject(
                entityEntry.Entity, 
                new ValidationContext(entityEntry.Entity));
        }

        return base.SaveChanges();
    }
}

Value Converter

Value Converter sind eine nützliche Funktion, die es erlaubt, den Typ von Eigenschaften in einer Entität zu ändern, wenn sie in die Datenbank gespeichert oder aus der Datenbank abgerufen werden.

[Table("KEYBOARDS", Schema = "ef")]
public class Keyboard
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("ID")]
    public int Id { get; set; }

    [Column("NAME")]
    [StringLength(50)]
    [Required]
    public string? Name { get; set; }

    [Column("IS_IN_STOCK")]
    public bool IsInStock { get; set; }
}

Die IsInStock-Eigenschaft ist vom Typ bool. In der Datenbank soll diese allerdings als String gespeichert werden. Hierfür kann mit Hilfe der Fluent API ein Value-Converter gesetzt werden.

public class MyDbContext : DbContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var boolToStrValueConverter = new ValueConverter<bool, string>(
            v => v ? "Y" : "N",
            v => v == "Y");

        modelBuilder
            .Entity<Keyboard>()
            .Property(nameof(Keyboard.IsInStock))
            .HasMaxLength(1)
            .HasConversion(boolToStrValueConverter);
    }
}

Transaktionen

EF Core führt alle Operationen innerhalb von SaveChanges automatisch in einer Datenbanktransaktion durch. Falls nur ein Datensatz geändert wird, wird auf das Erstellen der Transaktion verzichtet, da nicht notwendig und dies nur unnötig Performance kostet.

Falls mehrere SaveChanges nacheinander in einer Transaktion verarbeitet werden sollen, so kann manuell eine Transaktion gestartet werden, die auch manuell bestätigt werden muss.

using var transaction = ctx.Database.BeginTransaction();

try
{
    var p = ctx.People.Add(new Person
    {
        FirstName = "John",
        LastName = "Doe"
    });
    context.SaveChanges();

    var p = ctx.People.Add(new Person
    {
        FirstName = "Jane",
        LastName = "Doe"
    });
    context.SaveChanges();

    transaction.Commit();
}
catch (Exception)
{
    //optional, da dies automatisch gemacht wird, 
    //wenn die Transaktion disposed wird und kein Commit erfolgt ist
    transaction.Rollback();
}

Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert