-
Notifications
You must be signed in to change notification settings - Fork 0
feat: switch from EnsureCreated() to EF Migrations for schema updates #469
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
88d2f72
feat: switch from EnsureCreated() to EF Migrations for schema updates
tylerkron 2f968e5
fix: suppress PendingModelChangesWarning for hand-written migrations
tylerkron adadfb2
chore: add design-time DbContext factory for EF tooling
tylerkron 090958d
fix: correct migration table names and nullability to match actual DB
tylerkron e14e741
fix: check correct table name 'Samples' in migration seeding logic
tylerkron 95f1e13
fix: prevent SQLite lock by separating backup and seeding from migrate
tylerkron 3c37116
fix: use raw SqliteConnection for seeding to avoid pooling deadlock
tylerkron 27036cf
fix: clear connection pools and WAL files, add debug logging
tylerkron 91446a8
chore: remove debug logging, auto-delete backup after successful migr…
tylerkron 32a433f
perf: skip backup when no pending migrations
tylerkron b2fbe39
fix: add migration failure recovery and safe WAL checkpoint
tylerkron dca689d
feat: show status window during database migration
tylerkron 1aad6fc
fix: prevent app shutdown when migration status window closes
tylerkron File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| using System.IO; | ||
| using Daqifi.Desktop.Common.Loggers; | ||
| using Microsoft.Data.Sqlite; | ||
| using Microsoft.EntityFrameworkCore; | ||
|
|
||
| namespace Daqifi.Desktop.Logger; | ||
|
|
||
| /// <summary> | ||
| /// Handles database migration at application startup, including upgrade path | ||
| /// for existing databases created by <c>EnsureCreated()</c>. | ||
| /// </summary> | ||
| public static class DatabaseMigrator | ||
| { | ||
| #region Constants | ||
| private const string INITIAL_MIGRATION_ID = "20250812090000_InitialSQLiteMigration"; | ||
| private const string EF_PRODUCT_VERSION = "9.0.14"; | ||
| #endregion | ||
|
|
||
| #region Public Methods | ||
| /// <summary> | ||
| /// Seeds migration history for existing databases and checks whether | ||
| /// there are pending migrations. Call this before <see cref="ApplyMigrations"/> | ||
| /// to determine if a status UI should be shown. | ||
| /// </summary> | ||
| /// <param name="contextFactory">The DbContext factory registered in DI.</param> | ||
| /// <param name="databasePath">Full path to the SQLite database file.</param> | ||
| /// <returns><c>true</c> if there are pending migrations to apply.</returns> | ||
| public static bool PrepareMigration(IDbContextFactory<LoggingContext> contextFactory, string databasePath) | ||
| { | ||
| SeedMigrationHistoryIfNeeded(databasePath); | ||
| SqliteConnection.ClearAllPools(); | ||
| return HasPendingMigrations(contextFactory); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Applies pending EF Core migrations with backup and rollback support. | ||
| /// Call <see cref="PrepareMigration"/> first to seed history and check | ||
| /// whether migrations are needed. | ||
| /// </summary> | ||
| /// <param name="contextFactory">The DbContext factory registered in DI.</param> | ||
| /// <param name="databasePath">Full path to the SQLite database file.</param> | ||
| public static void ApplyMigrations(IDbContextFactory<LoggingContext> contextFactory, string databasePath) | ||
| { | ||
| var backupPath = BackupDatabase(databasePath); | ||
| CheckpointWal(databasePath); | ||
|
|
||
| try | ||
| { | ||
| using var context = contextFactory.CreateDbContext(); | ||
| context.Database.Migrate(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| AppLogger.Instance.Error(ex, "Database migration failed"); | ||
| RestoreBackup(backupPath, databasePath); | ||
| throw; | ||
| } | ||
|
|
||
| CleanupBackup(backupPath); | ||
| AppLogger.Instance.AddBreadcrumb("database", "Database migration completed successfully"); | ||
| } | ||
| #endregion | ||
|
|
||
| #region Private Methods | ||
| /// <summary> | ||
| /// Checks whether there are any pending migrations to apply. | ||
| /// </summary> | ||
| private static bool HasPendingMigrations(IDbContextFactory<LoggingContext> contextFactory) | ||
| { | ||
| using var context = contextFactory.CreateDbContext(); | ||
| var pending = context.Database.GetPendingMigrations(); | ||
| return pending.Any(); | ||
|
tylerkron marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// <summary> | ||
| /// Creates a backup copy of the SQLite database file before applying migrations. | ||
| /// </summary> | ||
| /// <returns>The backup file path, or null if no backup was created.</returns> | ||
| private static string BackupDatabase(string databasePath) | ||
| { | ||
| try | ||
| { | ||
| if (!File.Exists(databasePath)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| var backupPath = databasePath + ".migration-backup"; | ||
| File.Copy(databasePath, backupPath, overwrite: true); | ||
| return backupPath; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| AppLogger.Instance.Error(ex, "Failed to back up database before migration"); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Removes the backup file after a successful migration. | ||
| /// </summary> | ||
| private static void CleanupBackup(string backupPath) | ||
| { | ||
| try | ||
| { | ||
| if (!string.IsNullOrEmpty(backupPath) && File.Exists(backupPath)) | ||
| { | ||
| File.Delete(backupPath); | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| AppLogger.Instance.Error(ex, "Failed to delete migration backup file"); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Flushes the WAL journal into the main database file using PRAGMA wal_checkpoint. | ||
| /// This is safer than deleting WAL/SHM files directly, which could cause data loss | ||
| /// if uncommitted transactions exist. | ||
| /// </summary> | ||
| private static void CheckpointWal(string databasePath) | ||
| { | ||
| try | ||
| { | ||
| var connectionString = $"Data source={databasePath}"; | ||
| using var connection = new SqliteConnection(connectionString); | ||
| connection.Open(); | ||
|
|
||
| using var command = connection.CreateCommand(); | ||
| command.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);"; | ||
| command.ExecuteNonQuery(); | ||
|
|
||
| connection.Close(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| AppLogger.Instance.Error(ex, "Failed to checkpoint WAL — migration will proceed anyway"); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Restores the database from backup after a failed migration. | ||
| /// </summary> | ||
| private static void RestoreBackup(string backupPath, string databasePath) | ||
| { | ||
| try | ||
| { | ||
| if (!string.IsNullOrEmpty(backupPath) && File.Exists(backupPath)) | ||
| { | ||
| SqliteConnection.ClearAllPools(); | ||
| File.Copy(backupPath, databasePath, overwrite: true); | ||
| AppLogger.Instance.Error(null, "Database restored from backup after migration failure"); | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| AppLogger.Instance.Error(ex, "Failed to restore database from backup — manual recovery may be needed"); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// For databases created by <c>EnsureCreated()</c>, the <c>__EFMigrationsHistory</c> | ||
| /// table does not exist. This method creates it and seeds the initial migration entry | ||
| /// so that <c>Migrate()</c> only applies subsequent migrations. | ||
| /// Uses a raw <see cref="SqliteConnection"/> to avoid EF connection pooling locks. | ||
| /// </summary> | ||
| private static void SeedMigrationHistoryIfNeeded(string databasePath) | ||
| { | ||
| if (!File.Exists(databasePath)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var connectionString = $"Data source={databasePath}"; | ||
| using var connection = new SqliteConnection(connectionString); | ||
| connection.Open(); | ||
|
|
||
| if (HasMigrationHistoryTable(connection)) | ||
| { | ||
| connection.Close(); | ||
| return; | ||
| } | ||
|
|
||
| if (!HasExistingTables(connection)) | ||
| { | ||
| connection.Close(); | ||
| return; | ||
| } | ||
|
|
||
| using var command = connection.CreateCommand(); | ||
| command.CommandText = | ||
| "CREATE TABLE IF NOT EXISTS \"__EFMigrationsHistory\" " + | ||
| "(\"MigrationId\" TEXT NOT NULL PRIMARY KEY, \"ProductVersion\" TEXT NOT NULL); " + | ||
| "INSERT OR IGNORE INTO \"__EFMigrationsHistory\" " + | ||
| $"VALUES ('{INITIAL_MIGRATION_ID}', '{EF_PRODUCT_VERSION}');"; | ||
|
tylerkron marked this conversation as resolved.
|
||
| command.ExecuteNonQuery(); | ||
|
|
||
| connection.Close(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Checks whether the <c>__EFMigrationsHistory</c> table exists in the database. | ||
| /// </summary> | ||
| private static bool HasMigrationHistoryTable(SqliteConnection connection) | ||
| { | ||
| using var command = connection.CreateCommand(); | ||
| command.CommandText = | ||
| "SELECT COUNT(*) FROM sqlite_master " + | ||
| "WHERE type='table' AND name='__EFMigrationsHistory'"; | ||
| var result = command.ExecuteScalar(); | ||
| return result is long count && count > 0; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Checks whether the database has existing application tables (created by <c>EnsureCreated()</c>). | ||
| /// </summary> | ||
| private static bool HasExistingTables(SqliteConnection connection) | ||
| { | ||
| using var command = connection.CreateCommand(); | ||
| command.CommandText = | ||
| "SELECT COUNT(*) FROM sqlite_master " + | ||
| "WHERE type='table' AND name='Samples'"; | ||
| var result = command.ExecuteScalar(); | ||
| return result is long count && count > 0; | ||
| } | ||
| #endregion | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Microsoft.EntityFrameworkCore.Design; | ||
|
|
||
| namespace Daqifi.Desktop.Logger; | ||
|
|
||
| /// <summary> | ||
| /// Factory used by EF Core tooling (dotnet ef migrations) to create | ||
| /// a LoggingContext at design time without starting the WPF application. | ||
| /// </summary> | ||
| public class LoggingContextDesignTimeFactory : IDesignTimeDbContextFactory<LoggingContext> | ||
| { | ||
| public LoggingContext CreateDbContext(string[] args) | ||
| { | ||
| var optionsBuilder = new DbContextOptionsBuilder<LoggingContext>(); | ||
| optionsBuilder.UseSqlite("Data source=design_time.db"); | ||
| return new LoggingContext(optionsBuilder.Options); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.