-
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 10 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,198 @@ | ||
| 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> | ||
| /// Applies pending EF Core migrations. For existing databases created by | ||
| /// <c>EnsureCreated()</c> (which lack a <c>__EFMigrationsHistory</c> table), | ||
| /// seeds the migration history first so <c>Migrate()</c> does not attempt | ||
| /// to recreate existing tables. | ||
| /// </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 MigrateDatabase(IDbContextFactory<LoggingContext> contextFactory, string databasePath) | ||
| { | ||
| // Seed migration history before EF checks for pending migrations. | ||
| // Must happen first so EF can accurately determine what's pending. | ||
| SeedMigrationHistoryIfNeeded(databasePath); | ||
| SqliteConnection.ClearAllPools(); | ||
|
|
||
| // Check if there are actually pending migrations before doing | ||
| // the expensive backup + migrate cycle. | ||
| if (!HasPendingMigrations(contextFactory)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var backupPath = BackupDatabase(databasePath); | ||
| CleanupWalFiles(databasePath); | ||
|
|
||
| using var context = contextFactory.CreateDbContext(); | ||
| context.Database.Migrate(); | ||
|
|
||
| CleanupBackup(backupPath); | ||
| AppLogger.Instance.AddBreadcrumb("database", "Database migration completed successfully"); | ||
|
qodo-code-review[bot] marked this conversation as resolved.
|
||
| } | ||
| #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> | ||
| /// Removes WAL and SHM files that can hold stale locks from previous sessions. | ||
| /// </summary> | ||
| private static void CleanupWalFiles(string databasePath) | ||
| { | ||
| try | ||
| { | ||
| var walPath = databasePath + "-wal"; | ||
| var shmPath = databasePath + "-shm"; | ||
|
|
||
| if (File.Exists(walPath)) | ||
| { | ||
| File.Delete(walPath); | ||
| } | ||
|
|
||
| if (File.Exists(shmPath)) | ||
| { | ||
| File.Delete(shmPath); | ||
| } | ||
| } | ||
|
qodo-code-review[bot] marked this conversation as resolved.
Outdated
|
||
| catch | ||
| { | ||
| // Non-critical — WAL files will be recreated by SQLite | ||
| } | ||
| } | ||
|
|
||
| /// <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.