Change Tracking mit Entity Framework Core – deine Datensätze automatisch zeitstempeln

Jan-Hendrik Precht

Jan-Hendrik Precht begeistert sich für die Themen "lebenslanges Lernen" und "systemische Unterstützung in Personalentwicklung und Vertrieb". Zum Einen, weil Ihn sowohl die Vielfalt an möglichen Themen als auch innovative Steuerungsmöglichkeiten faszinieren. Zum Anderen, weil letztendlich alle Beteiligten etwas davon haben.
timestamp

Werden Daten in einer Datenbank gespeichert, dann besteht häufig die Anforderung die jeweiligen Datensätze mit Zeitstempeln zu versehen. Oft sind die Zeitpunkte der Anlage und Änderung eines Datensatzes von Interesse. Bequem ist es für dich als Entwickler, wenn diese Arbeit automatisch verrichtet wird.

Dieser Blogbeitrag zeigt dir, wie Entity Framework Core mit Hilfe des Change Trackers und einigen einfachen Erweiterungen dieser Anforderung automatisch nachkommt. Microsofts Entity Framework Core (im Folgenden EF Core) ist das aktuell populärste ORM Framework für .NET.

Vorteile

Folgende Vorteile bieten sich dir:

  • Deine Datensätze werden auf Änderungen überwacht und automatisch mit Zeitstempeln versehen.
  • Dein Business-Code wird vereinfacht, denn er ist frei von Logik zum Setzen der Zeitstempel.
  • Du verwendest eine einheitliche und universell einsetzbare Vorgehensweise für das Setzen der Zeitstempel.
  • Dein Testaufwand sinkt, denn nur die zuständige EF Core Erweiterung muss einmalig getestet werden.

Referenzimplementierung

Das relationale Datenbanksystem MySQL bieten für das Setzen automatischer Zeitstempel bei der Anlage und Änderung eines Datensatzes beispielsweise den folgenden Lösungsansatz. Siehe hier die Attribute created_at und changed_at unter Verwendung von current_timestamp.

-- create and use database
drop database if exists db_efcore_reference;
create database db_efcore_reference
    character set utf8mb4
    collate utf8mb4_unicode_ci;

use db_efcore_reference;

-- create table
drop table if exists notes;
create table notes
(
    id            int unsigned not null auto_increment,
    message       varchar(256) not null,
    created_at    timestamp default current_timestamp,
    changed_at    timestamp null on update current_timestamp,

    constraint pk_notes primary key (id)
) engine = innodb;

-- insert table data
insert into notes (message) values ('Note A');
insert into notes (message) values ('Note B');

-- update table data
update notes set message = 'Note A - modified' where id = 1;

Das hier gezeigte MySQL Skript soll als Referenzimplementierung für die folgende Erweiterung deiner EF Core Anwendung dienen.

Einstieg

Im folgenden Anwendungsbeispiel implementierst du eine Datenbank zur Verwaltung von Notizen, siehe MySQL Referenzimplementierung. Dafür erzeugst du eine Tabelle Notes, welche Notizen über das Property Message speichert. Über die Properties CreatedAt und ChangedAt speichert die Tabelle zudem die Zeitpunkte der Anlage und Änderung einer Notiz. Diese sollen von EF Core automatisch gesetzt werden. Um die Datenbank zu erstellen, verwendest du im Folgenden den Code-First Approach.

Schritt eins – Interface ICurrentTimestamps erstellen

Zu Beginn erstellst du das Interface ICurrentTimestamps mit den Properties CreatedAt und ChangedAt. So erzeugst du die Standard-Zeitstempel, welche EF Core nach der Erweiterung kennt und bearbeitet.

namespace Core.Database.Abstractions;

public interface ICurrentTimestamps
{
    DateTime CreatedAt { get; set; }
    DateTime? ChangedAt { get; set; }
}

Der Änderungszeitpunkt ChangedAt ist nullable, da er zur Anlage eines Datensatzes unbekannt ist.

Schritt zwei – Klasse Note erstellen

Als Nächstes erstellst du die Klasse Note, welche EF Core als Entität für die Tabelle Notes verwenden wird. Implementiere das Interface ICurrentTimestamps, damit die Standard-Zeitstempel verfügbar werden.

namespace Core.Models;

public class Note : ICurrentTimestamps
{
    public Guid Id { get; }
    public string Message { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? ChangedAt { get; set; }

    private Note(Guid id, string message)
    {
        Id = id;
        Message = message;
    }

    public static Note Create(string message)
    {
        return new Note(Guid.NewGuid(), message);
    }
}

Das Property Id dient als Primärschlüssel und wird zur Instanziierung über die Fabrikmethode Create() automatisch erzeugt, vgl. auto_increment in der MySQL Referenzimplementierung. Das Property Message muss über die Fabrikmethode Create() initialisiert werden und kann später direkt gesetzt werden.

Schritt drei – Klasse DatabaseContext erstellen und Methode OnModelCreating() überschreiben

Erstelle nun die Klasse DatabaseContext und leite von der EF Core Basisklasse DbContext ab. Um über den Code-First Approach eine Datenbank erzeugen zu können, überschreibst du die Methode OnModelCreating().

namespace Core.Database;

public class DatabaseContext : DbContext
{
#nullable disable
    public DbSet<Note> Notes { get; set; }
#nullable enable

    public DatabaseContext(DbContextOptions options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Note>(entity =>
        {
            entity.ToTable("Notes");
            entity.HasKey(note => note.Id);
            entity.Property(note => note.Message).HasMaxLength(256);
        });

        base.OnModelCreating(modelBuilder);
    }
}

Mit DbSet wird die Entität Note dem zugehörigen DbContext bekannt gemacht. EF Core kann jetzt über das Property Notes Lese- und Schreibzugriffe in der Datenbank ausführen.

Über den Methodenaufruf ToTable() erzeugst du die Tabelle Notes. Die Methode HasKey() definiert Id als Primärschlüssel der Tabelle. Die Methode HasMaxLength() limitiert die Länge der Notiz Message auf maximal 256 Zeichen. Alle übrigen Einstellungen ergeben sich implizit aus der Implementierung der Entität Note.

Schritt vier – Initiale Migration erstellen (Code-First Approach)

Bevor jetzt die initiale Migration zur Erzeugung der Datenbank erstellt werden kann, muss die Klasse DatabaseContext noch als Dienst registriert und MySQL Server als Datenbanksystem konfiguriert werden. In der Datei appsettings.json kannst du den ConnectionString für die Verbindung zum Datenbankserver hinterlegen.

var connectionString = configuration.GetConnectionString("Default");

services.AddDbContext<DatabaseContext>(opt =>
    opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))
);

Mit Hilfe der Shell Skripte efcore_migration_add.sh und efcore_migration_remove.sh kannst du jetzt die zugehörigen Migrationen erstellen und entfernen.

Schritt fünf – Klasse DatabaseContext erweitern und SaveChangesAsync() überschreiben

Implementiere jetzt die Kernfunktion der EF Core Erweiterung. Überschreibe in der zuvor erstellten Klasse DatabaseContext die Methode SaveChangesAsync(), welche alle im DbContext vorgenommenen Änderungen in der zugrunde liegenden Datenbank speichert.

public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    var dateTime = DateTime.UtcNow;

    var entriesAdded = ChangeTracker.Entries()
        .Where(entry => entry.State == EntityState.Added)
        .ToList();

    entriesAdded.ForEach(entry =>
        entry.Property(nameof(ICurrentTimestamps.CreatedAt)).CurrentValue = dateTime);

    var entriesModified = ChangeTracker.Entries()
        .Where(entry => entry.State == EntityState.Modified)
        .ToList();

    entriesModified.ForEach(entry =>
        entry.Property(nameof(ICurrentTimestamps.ChangedAt)).CurrentValue = dateTime);

    return base.SaveChangesAsync(cancellationToken);
}

Über die LINQ Filter Where() auf ChangeTracker.Entries() ermittelst du alle neu hinzugefügten und geänderten Datensätze im zugehörigen DbContext. Über die ForEach() Aufrufe setzt du dann die Standard-Zeitstempel, die du über das Interface ICurrentTimestamps in beliebige Entitäten von DatabaseContext integrieren kannst. Zuletzt rufst du die Methode SaveChangesAsync() der Basisklasse DbContext auf und speicherst die Änderungen in der Datenbank.

Schritt sechs – Datensätze einfügen und modifizieren

Die folgende Methode InsertNoteAsync() zeigt dir, wie du einen neuen Datensatz note in den DatabaseContext einfügst. Mit Aufruf von SaveChangesAsync() wird der Zeitstempel CreatedAt automatisch gesetzt.

public async Task InsertNoteAsync(
    string message,
    DatabaseContext context,
    CancellationToken cancellationToken = default)
{
    var note = Note.Create(message);

    await context.Notes.AddAsync(note, cancellationToken);
    await context.SaveChangesAsync(cancellationToken);
}

Die folgende Methode UpdateNoteAsync() zeigt dir, wie du einen bestehenden Datensatz modifizierst. Hier wird mit dem Aufruf von SaveChangesAsync() der Zeitstempel ChangedAt automatisch gesetzt.

public async Task UpdateNoteAsync(
    Guid noteId,
    string message,
    DatabaseContext context,
    CancellationToken cancellationToken = default)
{
    var note = await context.Notes.FirstOrDefaultAsync(n => n.Id == noteId, cancellationToken);

    if (note is null)
    {
        return;
    }

    note.Message = message;

    await context.SaveChangesAsync(cancellationToken);
}

Schritt sieben – Datenbank Updater erstellen

Abschließend implementierst du noch die Klasse DatabaseUpdater, welche Sample-Daten in den DbContext einfügt und modifiziert. So kannst du die Funktionsweise der EF Core Erweiterung in der Methode SaveChangesAsync() unmittelbar validieren. Weiterhin führt der DatabaseUpdater mit Start der Anwendung automatisch die Migration zur Erstellung der Datenbank aus.

Integrationstest

Das besprochene Anwendungsbeispiel kannst du über eine Web-API testen. Sofern du auf deinem Rechner Docker installiert hast, musst du hierzu keinen lokalen MySQL Server installieren und einrichten. Starte einfach den Docker Engine und führe die Shell Skripte run_mysql_server.sh und run_efcore_webapi.sh nacheinander aus. Achte darauf, dass der Docker Container mit dem MySQL Server vollständig gestartet ist, bevor du anschließend die Web-API startest.

Die Web-API verfügt über ein Swagger UI, welches du im Browser über die URL https://localhost:5001/swagger/ öffnest. Die entsprechenden curl Befehle für den Test über ein Terminal findest du hier.

Der folgende HTTP POST Request fügt einen neuen Datensatz in die Datenbank ein.

#!/bin/sh

# Request
curl -X 'POST' \
  'https://localhost:5001/api/notes' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "message": "Arthur Dent"
}'

# Response body
# {
#   "id":"86e4871d-c39c-481f-ba55-6cc095e3f5ec",
#   "message":"Arthur Dent",
#   "createdAt":"2021-12-27T16:35:00.143135Z",
#   "changedAt":null
# }

Das Property CreatedAt wurde mit Erstellung des neuen Datensatzes durch die EF Core Erweiterung automatisch gesetzt und zeigt den Zeitpunkt der Erstellung an. Das Property ChangedAt ist null (also unbearbeitet), da der Datensatz bisher nur erstellt und noch nicht modifiziert wurde.

Der folgende HTTP PUT Request modifiziert den zuvor erstellten Datensatz in der Datenbank.

#!/bin/sh

# Request
curl -X 'PUT' \
  'https://localhost:5001/api/notes' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "id": "86e4871d-c39c-481f-ba55-6cc095e3f5ec",
  "message": "The answer to the ultimate question of life, the universe, and everything is 42."
}'

# Response body
# {
#   "id":"86e4871d-c39c-481f-ba55-6cc095e3f5ec",
#   "message":"The answer to the ultimate question of life, the universe, and everything is 42.",
#   "createdAt":"2021-12-27T16:35:00.143135Z",
#   "changedAt":"2021-12-27T16:52:19.940327Z"
# }

Das Property CreatedAt bleibt unverändert. Das Property ChangedAt ist nun durch die EF Core Erweiterung automatisch gesetzt worden und zeigt den Zeitpunkt der letzten Modifikation an. Durch eine erneute Modifikation des Datensatzes wird ChangedAt ebenfalls erneut gesetzt.

Fazit

Das besprochene Beispiel zeigt dir einen möglichen Lösungsansatz, wie du das Setzen von Zeitstempeln in deiner EF Core Anwendung automatisieren kannst. Der Hauptaufwand liegt in der Implementierung des Interfaces ICurrentTimestamps in den gewünschten Entitäten und der Überschreibung der Methode SaveChangesAsync() aus der EF Core Basisklasse DbContext.

Durch die Verwendung der Klasse ChangeTracker lassen sich ggf. weitere nützliche EF Core Erweiterungen implementieren, welche deine Anwendung weiter automatisieren. Beispielsweise könnten Datenbankeinträge gezielt durch den ChangeTracker überwacht werden und bei deren Modifikation ein gewünschtes Event erzeugt werden.

Den Code zum Blog findest du auch auf GitHub.

Happy Coding!