Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Daqifi.Desktop/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Daqifi.Desktop.Logger;
using Daqifi.Desktop.WindowViewModelMapping;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using System.IO;
using System.Net.Http;
Expand Down Expand Up @@ -53,6 +54,7 @@ protected override void OnStartup(StartupEventArgs e)
var serviceCollection = new ServiceCollection();
serviceCollection.AddDbContextFactory<LoggingContext>(options =>
options.UseSqlite($"Data source={DatabasePath}")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
);

serviceCollection.AddLogging();
Expand All @@ -71,6 +73,11 @@ protected override void OnStartup(StartupEventArgs e)
ServiceLocator.RegisterSingleton<IWindowViewModelMappings, WindowViewModelMappings>();

ServiceProvider = serviceCollection.BuildServiceProvider();

// Apply database migrations before any DB access
var contextFactory = ServiceProvider.GetRequiredService<IDbContextFactory<LoggingContext>>();
DatabaseMigrator.MigrateDatabase(contextFactory, DatabasePath);

// Create and show main window
var view = new MainWindow();
view.Show();
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Expand Down
198 changes: 198 additions & 0 deletions Daqifi.Desktop/Loggers/DatabaseMigrator.cs
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");
Comment thread
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();
Comment thread
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);
}
}
Comment thread
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}');";
Comment thread
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
}
19 changes: 15 additions & 4 deletions Daqifi.Desktop/Loggers/LoggingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,25 @@ public class LoggingContext : DbContext
{
public LoggingContext(DbContextOptions<LoggingContext> options) : base(options)
{
Database.EnsureCreated();
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LoggingSession>().HasMany(c => c.DataSamples).WithOne(p => p.LoggingSession).IsRequired().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<LoggingSession>().Property(ls => ls.Name).IsRequired();
modelBuilder.Entity<Channel.Channel>().Ignore(c => c.ChannelColorBrush);
modelBuilder.Entity<LoggingSession>(entity =>
{
entity.ToTable("Sessions");
entity.HasMany(c => c.DataSamples).WithOne(p => p.LoggingSession).IsRequired().OnDelete(DeleteBehavior.Cascade);
entity.Property(ls => ls.Name).IsRequired();
});

modelBuilder.Entity<DataSample>().ToTable("Samples");

modelBuilder.Entity<Channel.Channel>(entity =>
{
entity.ToTable("Channel");
entity.Ignore(c => c.ChannelColorBrush);
});

base.OnModelCreating(modelBuilder);
}

Expand Down
18 changes: 18 additions & 0 deletions Daqifi.Desktop/Loggers/LoggingContextDesignTimeFactory.cs
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);
}
}
Loading
Loading