Entity Framework Core mit SQLite: DbContext, Migrations und Query Filter (Teil 4)
Eine Anwendung braucht irgendwo einen Ort, an dem Daten dauerhaft gespeichert werden — auch wenn der Rechner ausgeschaltet wird. EchoPlay nutzt dafür SQLite, eine kleine, dateibasierte Datenbank, die ohne Serverinstallation auskommt. Die Verbindung zwischen C#-Objekten und dieser Datenbank stellt Entity Framework Core her — ein sogenannter Object-Relational-Mapper (ORM), der C#-Objekte in Datenbankzeilen übersetzt und umgekehrt.
Was ist Entity Framework Core?
Entity Framework Core — kurz EF Core — übersetzt C#-Objekte (die sogenannten Entitäten) in SQL-Befehle. Man arbeitet mit normalen C#-Klassen, und EF Core kümmert sich darum, dass die passenden SQL-Statements ausgeführt werden. Das bedeutet: Man schreibt kein INSERT INTO Series ... von Hand. Man ruft stattdessen _context.Series.Add(series) auf, und EF Core generiert das SQL-Statement.
Der DbContext: Herzstück der Datenbankverbindung
Das zentrale Element in EF Core ist der DbContext. Er stellt die Verbindung zur Datenbank dar und enthält für jede Tabelle eine DbSet<T>-Property. EchoPlay nutzt dafür den EchoPlayDbContext:
public class EchoPlayDbContext(DbContextOptions<EchoPlayDbContext> options) : DbContext(options)
{
public DbSet<Series> Series => Set<Series>();
public DbSet<Episode> Episodes => Set<Episode>();
public DbSet<PlaybackState> PlaybackStates => Set<PlaybackState>();
public DbSet<LocalTrack> LocalTracks => Set<LocalTrack>();
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
public DbSet<CoverImage> CoverImages => Set<CoverImage>();
}
Die CoverImages-Tabelle speichert Cover-Binärdaten getrennt von den Entitäten. Statt byte[]-Properties direkt auf Series und Episode abzulegen, enthält CoverImage die Felder EntityType, EntityId, ImageData, SourceUrl und LastChecked. Das entkoppelt die Cover-Verwaltung von den fachlichen Entitäten und vermeidet, dass große Binärdaten bei jeder Abfrage mitgeladen werden.
Zwei Details fallen hier auf. Der Primary Constructor in der ersten Zeile: Statt einem ausgeschriebenen Konstruktor mit this.options = options;-Zuweisung nutzt EchoPlay den Primary Constructor von C# 12. Die options-Variable wird direkt an DbContext(options) weitergereicht. Das ist kürzer und enthält keine Logik, die versteckt sein könnte. Dann die Expression-Body-Properties: public DbSet<Series> Series => Set<Series>(); — Das => bedeutet, dass die Property bei jedem Zugriff Set<Series>() aufruft, das intern immer dieselbe DbSet-Instanz zurückgibt. Es gibt keinen separaten Backing-Store. Das ist idiomatischer EF-Core-Code.
Was nicht funktioniert
Fehler: Kein DbSet, keine Tabelle. Wenn man AppSettings zur Datenbank hinzufügt, aber vergisst, ein DbSet<AppSettings> im Kontext anzulegen, behandelt EF Core die Klasse nicht als Tabelle. Abfragen mit _context.AppSettings.FirstAsync() scheitern mit einem Laufzeitfehler, weil die Property schlicht fehlt.
Fehler: Keine Konfiguration. Ohne Mapping-Konfiguration rät EF Core aus den Property-Namen auf Spaltennamen. Das kann zu Problemen führen, wenn Spaltennamen kollidieren oder Pflichtfelder fehlen. In EchoPlay sind alle Entitäten explizit konfiguriert.
Konfiguration: Eine Klasse pro Tabelle
Statt alles im OnModelCreating zu schreiben, hat EchoPlay für jede Entität eine eigene Konfigurationsklasse. Diese Klassen implementieren IEntityTypeConfiguration<T> und werden automatisch gefunden:
// Alle IEntityTypeConfiguration-Klassen aus diesem Assembly laden
modelBuilder.ApplyConfigurationsFromAssembly(typeof(EchoPlayDbContext).Assembly);
Das ApplyConfigurationsFromAssembly sucht automatisch alle Klassen, die IEntityTypeConfiguration<T> implementieren, und wendet sie an. So bleibt OnModelCreating übersichtlich, und jede Tabellenkonfiguration liegt in ihrer eigenen Datei.
Query Filter: Soft-Delete unsichtbar machen
EchoPlay löscht keine Datensätze wirklich. Stattdessen wird ein IsDeleted-Flag gesetzt — das ist das Soft-Delete-Muster. Damit gelöschte Einträge bei Abfragen nicht manuell herausgefiltert werden müssen, setzt EchoPlay einen globalen Query Filter:
modelBuilder.Entity<Series>().HasQueryFilter(entity => !entity.IsDeleted);
modelBuilder.Entity<Episode>().HasQueryFilter(entity => !entity.IsDeleted);
modelBuilder.Entity<PlaybackState>().HasQueryFilter(entity => !entity.IsDeleted);
modelBuilder.Entity<LocalTrack>().HasQueryFilter(entity => !entity.IsDeleted);
modelBuilder.Entity<AppSettings>().HasQueryFilter(entity => !entity.IsDeleted);
Jede Where-Abfrage, jedes Find, jedes ToListAsync bekommt automatisch AND IsDeleted = 0 angehängt — unsichtbar für den Aufrufer. Man muss nie daran denken, es gibt keine Chance, es zu vergessen. Wer IgnoreQueryFilters() einsetzt, um explizit gelöschte Einträge zu sehen, muss das sehr bewusst tun. EchoPlay tut das ausschließlich in spezifischen Test-Szenarien, nie im produktiven Code.
Migrations: Schemaänderungen versionieren
Wenn man eine neue Property zu einer Entität hinzufügt — zum Beispiel ein Feld IsSubscribed auf Series — muss die Datenbanktabelle entsprechend angepasst werden. EF Core nennt diesen Schritt eine Migration:
dotnet ef migrations add AddIsSubscribedToSeries
dotnet ef database update
Der erste Befehl erstellt eine C#-Klasse mit den nötigen ALTER TABLE-Befehlen. Der zweite Befehl wendet sie auf die aktuelle Datenbank an. EchoPlay führt Migrationen nicht manuell aus, sondern beim App-Start automatisch:
using IServiceScope dbScope = Services.CreateScope();
DatabaseInitializer dbInit = dbScope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
await dbInit.InitializeAsync();
DatabaseInitializer ruft intern _context.Database.MigrateAsync() auf. Damit werden alle noch nicht angewendeten Migrationen automatisch beim ersten Start einer neuen Version ausgeführt. Ein häufiger Fehler: Migrationen vergessen. Man fügt eine Property hinzu, vergisst dotnet ef migrations add ..., und die App startet mit einem Fehler, weil die Spalte in der Datenbank noch nicht existiert. EF Core erkennt das und wirft eine SqliteException.
AsNoTracking: Lesen ohne Overhead
EF Core hat einen Change Tracker: Er merkt sich alle geladenen Entitäten und prüft beim SaveChanges, ob sich etwas verändert hat. Das ist nützlich für Schreiboperationen — aber für reine Lesezugriffe ist es Overhead. In EchoPlay wird deshalb bei reinen Abfragen AsNoTracking() verwendet:
List<Series> result = await _context.Series
.AsNoTracking()
.OrderBy(series => series.Title)
.ToListAsync();
Der Unterschied: Mit AsNoTracking werden die Objekte nicht in den Change Tracker aufgenommen. Das spart Speicher und ist schneller — besonders wenn viele Datensätze geladen werden. Beim Speichern oder Aktualisieren hingegen wird AsNoTracking nicht verwendet, da EF Core die Änderungen dann nicht erkennen würde.
FindAsync vs. FirstOrDefaultAsync
EF Core bietet zwei ähnliche Methoden für das Laden einzelner Datensätze. FindAsync(id) sucht über den Primary Key und nutzt den Change-Tracker-Cache, wenn die Entität bereits geladen ist. FirstOrDefaultAsync(x => x.Property == value) sucht über eine beliebige Bedingung und macht immer einen Datenbank-Roundtrip. EchoPlay nutzt FindAsync für Primary-Key-Lookups:
Series? series = await _context.Series.FindAsync(id);
Und FirstOrDefaultAsync für fachliche Suchschlüssel:
return await _context.Series
.FirstOrDefaultAsync(series => series.SpotifyArtistId == spotifyArtistId);
Der Unterschied ist subtil, aber wichtig: FindAsync ist für den häufigsten Fall optimiert, FirstOrDefaultAsync für beliebige Bedingungen.
Praxisbeispiel: Eine neue Entität hinzufügen — DashboardPosition
Wenn eine Anwendung wächst, kommen neue Tabellen dazu. In EchoPlay wurde zum Beispiel die Möglichkeit eingebaut, Dashboard-Kacheln per Drag & Drop umzusortieren. Dafür musste die benutzerdefinierte Reihenfolge irgendwo dauerhaft gespeichert werden — also brauchte es eine neue Entität. Dieses Beispiel zeigt die vier Schritte, die in EF Core dafür nötig sind.
Schritt 1: Die Entity-Klasse anlegen. Eine Entity-Klasse ist eine normale C#-Klasse, deren Properties den Spalten einer Datenbanktabelle entsprechen. In EchoPlay erben alle Entitäten von BaseEntity, das gemeinsame Felder wie Id, CreatedAt und IsDeleted mitbringt. Die eigentliche Fachlogik der neuen Klasse ist minimal — sie speichert nur, welche Serie in welchem Dashboard-Bereich an welcher Position steht:
public class DashboardPosition : BaseEntity
{
public Guid SeriesId { get; set; }
public string Section { get; set; } = string.Empty;
public int Position { get; set; }
}
SeriesId verweist auf die zugehörige Serie, Section enthält den Namen des Dashboard-Bereichs (zum Beispiel „Neuerscheinungen“) und Position ist eine 0-basierte Zahl, die die Sortierung bestimmt. Kleinere Werte stehen weiter oben.
Schritt 2: Die Konfigurationsklasse erstellen. EF Core muss wissen, wie die Klasse auf eine Tabelle abgebildet wird. In EchoPlay hat jede Entität eine eigene Konfigurationsklasse, die IEntityTypeConfiguration<T> implementiert. Dort wird der Tabellenname festgelegt, Pflichtfelder werden markiert und Indizes definiert:
internal sealed class DashboardPositionConfiguration : IEntityTypeConfiguration<DashboardPosition>
{
public void Configure(EntityTypeBuilder<DashboardPosition> builder)
{
builder.ToTable("DashboardPositions");
builder.Property(dp => dp.Section)
.IsRequired()
.HasMaxLength(64);
// Pro Serie und Bereich darf nur eine Position existieren
builder.HasIndex(dp => new { dp.SeriesId, dp.Section })
.IsUnique();
}
}
Der zusammengesetzte eindeutige Index auf SeriesId und Section stellt auf Datenbankebene sicher, dass eine Serie in einem Bereich nicht doppelt vorkommen kann. Ohne diesen Index würde ein Programmierfehler im Code zu inkonsistenten Daten führen — die Datenbank fängt das ab.
Schritt 3: Das DbSet im DbContext registrieren. Damit EF Core die neue Tabelle kennt, braucht der EchoPlayDbContext eine DbSet-Property. Außerdem muss der globale Soft-Delete-Filter für die neue Entität eingetragen werden:
public DbSet<DashboardPosition> DashboardPositions => Set<DashboardPosition>();
Ohne diese Zeile wüsste EF Core zwar von der Klasse (über die Konfiguration), aber man könnte nicht per _context.DashboardPositions darauf zugreifen. In der OnModelCreating-Methode kommt dazu der Query-Filter, damit soft-gelöschte Einträge automatisch ausgeblendet werden.
Schritt 4: Die Migration erzeugen. Der letzte Schritt ist ein Kommandozeilenaufruf, der EF Core anweist, die Differenz zwischen dem C#-Modell und dem aktuellen Datenbankschema zu berechnen und als Migration-Klasse abzulegen:
dotnet ef migrations add AddDashboardPositions
EF Core erzeugt daraus eine Klasse mit einer Up-Methode (erstellt die Tabelle und den Index) und einer Down-Methode (macht alles rückgängig). Beim nächsten App-Start ruft der DatabaseInitializer automatisch MigrateAsync() auf und wendet die neue Migration an — der Nutzer bekommt davon nichts mit.
Diese vier Schritte wiederholen sich bei jeder neuen Entität. Wichtig ist die Reihenfolge: Zuerst die Klasse, dann die Konfiguration, dann das DbSet, dann die Migration. Vergisst man einen Schritt, gibt es entweder Compile-Fehler (fehlendes DbSet) oder Laufzeitfehler (fehlende Migration).
Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu