diff --git a/Daqifi.Desktop/App.xaml.cs b/Daqifi.Desktop/App.xaml.cs index d478a246..a78f1950 100644 --- a/Daqifi.Desktop/App.xaml.cs +++ b/Daqifi.Desktop/App.xaml.cs @@ -3,8 +3,10 @@ using Daqifi.Desktop.Common.Loggers; using Daqifi.Desktop.DialogService; using Daqifi.Desktop.Logger; +using Daqifi.Desktop.View; using Daqifi.Desktop.WindowViewModelMapping; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using System.IO; using System.Net.Http; @@ -53,6 +55,7 @@ protected override void OnStartup(StartupEventArgs e) var serviceCollection = new ServiceCollection(); serviceCollection.AddDbContextFactory(options => options.UseSqlite($"Data source={DatabasePath}") + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) ); serviceCollection.AddLogging(); @@ -71,6 +74,25 @@ protected override void OnStartup(StartupEventArgs e) ServiceLocator.RegisterSingleton(); ServiceProvider = serviceCollection.BuildServiceProvider(); + + // Apply database migrations before any DB access. + // Temporarily switch to OnExplicitShutdown so closing the migration + // status window does not terminate the application. + var contextFactory = ServiceProvider.GetRequiredService>(); + if (DatabaseMigrator.PrepareMigration(contextFactory, DatabasePath)) + { + ShutdownMode = ShutdownMode.OnExplicitShutdown; + + var statusWindow = new MigrationStatusWindow(); + statusWindow.Show(); + + DatabaseMigrator.ApplyMigrations(contextFactory, DatabasePath); + + statusWindow.Close(); + + ShutdownMode = ShutdownMode.OnLastWindowClose; + } + // Create and show main window var view = new MainWindow(); view.Show(); diff --git a/Daqifi.Desktop/Loggers/DatabaseMigrator.cs b/Daqifi.Desktop/Loggers/DatabaseMigrator.cs new file mode 100644 index 00000000..8ab3a5dd --- /dev/null +++ b/Daqifi.Desktop/Loggers/DatabaseMigrator.cs @@ -0,0 +1,228 @@ +using System.IO; +using Daqifi.Desktop.Common.Loggers; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Daqifi.Desktop.Logger; + +/// +/// Handles database migration at application startup, including upgrade path +/// for existing databases created by EnsureCreated(). +/// +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 + /// + /// Seeds migration history for existing databases and checks whether + /// there are pending migrations. Call this before + /// to determine if a status UI should be shown. + /// + /// The DbContext factory registered in DI. + /// Full path to the SQLite database file. + /// true if there are pending migrations to apply. + public static bool PrepareMigration(IDbContextFactory contextFactory, string databasePath) + { + SeedMigrationHistoryIfNeeded(databasePath); + SqliteConnection.ClearAllPools(); + return HasPendingMigrations(contextFactory); + } + + /// + /// Applies pending EF Core migrations with backup and rollback support. + /// Call first to seed history and check + /// whether migrations are needed. + /// + /// The DbContext factory registered in DI. + /// Full path to the SQLite database file. + public static void ApplyMigrations(IDbContextFactory 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 + /// + /// Checks whether there are any pending migrations to apply. + /// + private static bool HasPendingMigrations(IDbContextFactory contextFactory) + { + using var context = contextFactory.CreateDbContext(); + var pending = context.Database.GetPendingMigrations(); + return pending.Any(); + } + + /// + /// Creates a backup copy of the SQLite database file before applying migrations. + /// + /// The backup file path, or null if no backup was created. + 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; + } + } + + /// + /// Removes the backup file after a successful migration. + /// + 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"); + } + } + + /// + /// 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. + /// + 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"); + } + } + + /// + /// Restores the database from backup after a failed migration. + /// + 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"); + } + } + + /// + /// For databases created by EnsureCreated(), the __EFMigrationsHistory + /// table does not exist. This method creates it and seeds the initial migration entry + /// so that Migrate() only applies subsequent migrations. + /// Uses a raw to avoid EF connection pooling locks. + /// + 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}');"; + command.ExecuteNonQuery(); + + connection.Close(); + } + + /// + /// Checks whether the __EFMigrationsHistory table exists in the database. + /// + 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; + } + + /// + /// Checks whether the database has existing application tables (created by EnsureCreated()). + /// + 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 +} diff --git a/Daqifi.Desktop/Loggers/LoggingContext.cs b/Daqifi.Desktop/Loggers/LoggingContext.cs index 77636cea..3ed7045f 100644 --- a/Daqifi.Desktop/Loggers/LoggingContext.cs +++ b/Daqifi.Desktop/Loggers/LoggingContext.cs @@ -7,14 +7,25 @@ public class LoggingContext : DbContext { public LoggingContext(DbContextOptions options) : base(options) { - Database.EnsureCreated(); } protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity().HasMany(c => c.DataSamples).WithOne(p => p.LoggingSession).IsRequired().OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity().Property(ls => ls.Name).IsRequired(); - modelBuilder.Entity().Ignore(c => c.ChannelColorBrush); + modelBuilder.Entity(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().ToTable("Samples"); + + modelBuilder.Entity(entity => + { + entity.ToTable("Channel"); + entity.Ignore(c => c.ChannelColorBrush); + }); + base.OnModelCreating(modelBuilder); } diff --git a/Daqifi.Desktop/Loggers/LoggingContextDesignTimeFactory.cs b/Daqifi.Desktop/Loggers/LoggingContextDesignTimeFactory.cs new file mode 100644 index 00000000..9de6ee4b --- /dev/null +++ b/Daqifi.Desktop/Loggers/LoggingContextDesignTimeFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Daqifi.Desktop.Logger; + +/// +/// Factory used by EF Core tooling (dotnet ef migrations) to create +/// a LoggingContext at design time without starting the WPF application. +/// +public class LoggingContextDesignTimeFactory : IDesignTimeDbContextFactory +{ + public LoggingContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite("Data source=design_time.db"); + return new LoggingContext(optionsBuilder.Options); + } +} diff --git a/Daqifi.Desktop/Migrations/20250812090000_InitialSQLiteMigration.Designer.cs b/Daqifi.Desktop/Migrations/20250812090000_InitialSQLiteMigration.Designer.cs new file mode 100644 index 00000000..53bf98ee --- /dev/null +++ b/Daqifi.Desktop/Migrations/20250812090000_InitialSQLiteMigration.Designer.cs @@ -0,0 +1,198 @@ +// +using System; +using Daqifi.Desktop.Logger; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Daqifi.Desktop.Migrations; + +[DbContext(typeof(LoggingContext))] +[Migration("20250812090000_InitialSQLiteMigration")] +partial class InitialSQLiteMigration +{ + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + + modelBuilder.Entity("Daqifi.Desktop.Channel.Channel", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActiveSampleID") + .HasColumnType("INTEGER"); + + b.Property("DeviceName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceSerialNo") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("HasAdc") + .HasColumnType("INTEGER"); + + b.Property("HasValidExpression") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsAnalog") + .HasColumnType("INTEGER"); + + b.Property("IsBidirectional") + .HasColumnType("INTEGER"); + + b.Property("IsDigital") + .HasColumnType("INTEGER"); + + b.Property("IsDigitalOn") + .HasColumnType("INTEGER"); + + b.Property("IsOutput") + .HasColumnType("INTEGER"); + + b.Property("IsScalingActive") + .HasColumnType("INTEGER"); + + b.Property("IsVisible") + .HasColumnType("INTEGER"); + + b.Property("LoggingSessionID") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputValue") + .HasColumnType("REAL"); + + b.Property("ScaleExpression") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("TypeString") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ID"); + + b.HasIndex("ActiveSampleID"); + + b.HasIndex("LoggingSessionID"); + + b.ToTable("Channel"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Channel.DataSample", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceSerialNo") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LoggingSessionID") + .HasColumnType("INTEGER"); + + b.Property("TimestampTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("REAL"); + + b.HasKey("ID"); + + b.HasIndex("LoggingSessionID"); + + b.ToTable("Samples"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Logger.LoggingSession", b => + { + b.Property("ID") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SessionStart") + .HasColumnType("TEXT"); + + b.HasKey("ID"); + + b.ToTable("Sessions"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Channel.Channel", b => + { + b.HasOne("Daqifi.Desktop.Channel.DataSample", "ActiveSample") + .WithMany() + .HasForeignKey("ActiveSampleID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Daqifi.Desktop.Logger.LoggingSession", null) + .WithMany("Channels") + .HasForeignKey("LoggingSessionID"); + + b.Navigation("ActiveSample"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Channel.DataSample", b => + { + b.HasOne("Daqifi.Desktop.Logger.LoggingSession", "LoggingSession") + .WithMany("DataSamples") + .HasForeignKey("LoggingSessionID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LoggingSession"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Logger.LoggingSession", b => + { + b.Navigation("Channels"); + + b.Navigation("DataSamples"); + }); +#pragma warning restore 612, 618 + } +} diff --git a/Daqifi.Desktop/Migrations/20250812090000_InitialSQLiteMigration.cs b/Daqifi.Desktop/Migrations/20250812090000_InitialSQLiteMigration.cs index 03dd85a0..db693aea 100644 --- a/Daqifi.Desktop/Migrations/20250812090000_InitialSQLiteMigration.cs +++ b/Daqifi.Desktop/Migrations/20250812090000_InitialSQLiteMigration.cs @@ -11,49 +11,101 @@ public partial class InitialSQLiteMigration : Migration protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( - name: "LoggingSessions", + name: "Sessions", columns: table => new { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - DeviceName = table.Column(type: "TEXT", nullable: false), - SessionName = table.Column(type: "TEXT", nullable: false), - StartTime = table.Column(type: "TEXT", nullable: false), - EndTime = table.Column(type: "TEXT", nullable: true) + ID = table.Column(type: "INTEGER", nullable: false), + SessionStart = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false) }, constraints: table => { - table.PrimaryKey("PK_LoggingSessions", x => x.ID); + table.PrimaryKey("PK_Sessions", x => x.ID); }); migrationBuilder.CreateTable( - name: "DataSamples", + name: "Samples", columns: table => new { ID = table.Column(type: "INTEGER", nullable: false) .Annotation("Sqlite:Autoincrement", true), + LoggingSessionID = table.Column(type: "INTEGER", nullable: false), + Value = table.Column(type: "REAL", nullable: false), + TimestampTicks = table.Column(type: "INTEGER", nullable: false), DeviceName = table.Column(type: "TEXT", nullable: false), ChannelName = table.Column(type: "TEXT", nullable: false), DeviceSerialNo = table.Column(type: "TEXT", nullable: false), - Timestamp = table.Column(type: "TEXT", nullable: false), - Value = table.Column(type: "REAL", nullable: false), Color = table.Column(type: "TEXT", nullable: false), - LoggingSessionID = table.Column(type: "INTEGER", nullable: false) + Type = table.Column(type: "INTEGER", nullable: false) }, constraints: table => { - table.PrimaryKey("PK_DataSamples", x => x.ID); + table.PrimaryKey("PK_Samples", x => x.ID); table.ForeignKey( - name: "FK_DataSamples_LoggingSessions_LoggingSessionID", + name: "FK_Samples_Sessions_LoggingSessionID", column: x => x.LoggingSessionID, - principalTable: "LoggingSessions", + principalTable: "Sessions", principalColumn: "ID", onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "Channel", + columns: table => new + { + ID = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + Index = table.Column(type: "INTEGER", nullable: false), + OutputValue = table.Column(type: "REAL", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Direction = table.Column(type: "INTEGER", nullable: false), + TypeString = table.Column(type: "TEXT", nullable: false), + ScaleExpression = table.Column(type: "TEXT", nullable: false), + IsBidirectional = table.Column(type: "INTEGER", nullable: false), + IsOutput = table.Column(type: "INTEGER", nullable: false), + HasAdc = table.Column(type: "INTEGER", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + IsDigital = table.Column(type: "INTEGER", nullable: false), + IsAnalog = table.Column(type: "INTEGER", nullable: false), + IsDigitalOn = table.Column(type: "INTEGER", nullable: false), + IsScalingActive = table.Column(type: "INTEGER", nullable: false), + HasValidExpression = table.Column(type: "INTEGER", nullable: false), + ActiveSampleID = table.Column(type: "INTEGER", nullable: false), + IsVisible = table.Column(type: "INTEGER", nullable: false), + DeviceName = table.Column(type: "TEXT", nullable: false), + DeviceSerialNo = table.Column(type: "TEXT", nullable: false), + LoggingSessionID = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Channel", x => x.ID); + table.ForeignKey( + name: "FK_Channel_Samples_ActiveSampleID", + column: x => x.ActiveSampleID, + principalTable: "Samples", + principalColumn: "ID", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Channel_Sessions_LoggingSessionID", + column: x => x.LoggingSessionID, + principalTable: "Sessions", + principalColumn: "ID"); + }); + migrationBuilder.CreateIndex( - name: "IX_DataSamples_LoggingSessionID", - table: "DataSamples", + name: "IX_Channel_ActiveSampleID", + table: "Channel", + column: "ActiveSampleID"); + + migrationBuilder.CreateIndex( + name: "IX_Channel_LoggingSessionID", + table: "Channel", + column: "LoggingSessionID"); + + migrationBuilder.CreateIndex( + name: "IX_Samples_LoggingSessionID", + table: "Samples", column: "LoggingSessionID"); } @@ -61,9 +113,12 @@ protected override void Up(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( - name: "DataSamples"); + name: "Channel"); + + migrationBuilder.DropTable( + name: "Samples"); migrationBuilder.DropTable( - name: "LoggingSessions"); + name: "Sessions"); } -} \ No newline at end of file +} diff --git a/Daqifi.Desktop/Migrations/20250812100000_AddSamplesSessionTimeIndex.Designer.cs b/Daqifi.Desktop/Migrations/20250812100000_AddSamplesSessionTimeIndex.Designer.cs new file mode 100644 index 00000000..764c6b65 --- /dev/null +++ b/Daqifi.Desktop/Migrations/20250812100000_AddSamplesSessionTimeIndex.Designer.cs @@ -0,0 +1,201 @@ +// +using System; +using Daqifi.Desktop.Logger; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Daqifi.Desktop.Migrations; + +[DbContext(typeof(LoggingContext))] +[Migration("20250812100000_AddSamplesSessionTimeIndex")] +partial class AddSamplesSessionTimeIndex +{ + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + + modelBuilder.Entity("Daqifi.Desktop.Channel.Channel", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActiveSampleID") + .HasColumnType("INTEGER"); + + b.Property("DeviceName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceSerialNo") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("HasAdc") + .HasColumnType("INTEGER"); + + b.Property("HasValidExpression") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsAnalog") + .HasColumnType("INTEGER"); + + b.Property("IsBidirectional") + .HasColumnType("INTEGER"); + + b.Property("IsDigital") + .HasColumnType("INTEGER"); + + b.Property("IsDigitalOn") + .HasColumnType("INTEGER"); + + b.Property("IsOutput") + .HasColumnType("INTEGER"); + + b.Property("IsScalingActive") + .HasColumnType("INTEGER"); + + b.Property("IsVisible") + .HasColumnType("INTEGER"); + + b.Property("LoggingSessionID") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputValue") + .HasColumnType("REAL"); + + b.Property("ScaleExpression") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("TypeString") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ID"); + + b.HasIndex("ActiveSampleID"); + + b.HasIndex("LoggingSessionID"); + + b.ToTable("Channel"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Channel.DataSample", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceSerialNo") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LoggingSessionID") + .HasColumnType("INTEGER"); + + b.Property("TimestampTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("REAL"); + + b.HasKey("ID"); + + b.HasIndex("LoggingSessionID"); + + b.HasIndex("LoggingSessionID", "TimestampTicks") + .HasDatabaseName("IX_Samples_LoggingSessionID_TimestampTicks"); + + b.ToTable("Samples"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Logger.LoggingSession", b => + { + b.Property("ID") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SessionStart") + .HasColumnType("TEXT"); + + b.HasKey("ID"); + + b.ToTable("Sessions"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Channel.Channel", b => + { + b.HasOne("Daqifi.Desktop.Channel.DataSample", "ActiveSample") + .WithMany() + .HasForeignKey("ActiveSampleID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Daqifi.Desktop.Logger.LoggingSession", null) + .WithMany("Channels") + .HasForeignKey("LoggingSessionID"); + + b.Navigation("ActiveSample"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Channel.DataSample", b => + { + b.HasOne("Daqifi.Desktop.Logger.LoggingSession", "LoggingSession") + .WithMany("DataSamples") + .HasForeignKey("LoggingSessionID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LoggingSession"); + }); + + modelBuilder.Entity("Daqifi.Desktop.Logger.LoggingSession", b => + { + b.Navigation("Channels"); + + b.Navigation("DataSamples"); + }); +#pragma warning restore 612, 618 + } +} diff --git a/Daqifi.Desktop/Migrations/20250812100000_AddSamplesSessionTimeIndex.cs b/Daqifi.Desktop/Migrations/20250812100000_AddSamplesSessionTimeIndex.cs new file mode 100644 index 00000000..d4e0163a --- /dev/null +++ b/Daqifi.Desktop/Migrations/20250812100000_AddSamplesSessionTimeIndex.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Daqifi.Desktop.Migrations; + +/// +public partial class AddSamplesSessionTimeIndex : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Samples_LoggingSessionID_TimestampTicks", + table: "Samples", + columns: new[] { "LoggingSessionID", "TimestampTicks" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Samples_LoggingSessionID_TimestampTicks", + table: "Samples"); + } +} diff --git a/Daqifi.Desktop/Migrations/LoggingContextModelSnapshot.cs b/Daqifi.Desktop/Migrations/LoggingContextModelSnapshot.cs index 6eb68469..efa1253b 100644 --- a/Daqifi.Desktop/Migrations/LoggingContextModelSnapshot.cs +++ b/Daqifi.Desktop/Migrations/LoggingContextModelSnapshot.cs @@ -15,7 +15,90 @@ partial class LoggingContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + + modelBuilder.Entity("Daqifi.Desktop.Channel.Channel", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActiveSampleID") + .HasColumnType("INTEGER"); + + b.Property("DeviceName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceSerialNo") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("HasAdc") + .HasColumnType("INTEGER"); + + b.Property("HasValidExpression") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsAnalog") + .HasColumnType("INTEGER"); + + b.Property("IsBidirectional") + .HasColumnType("INTEGER"); + + b.Property("IsDigital") + .HasColumnType("INTEGER"); + + b.Property("IsDigitalOn") + .HasColumnType("INTEGER"); + + b.Property("IsOutput") + .HasColumnType("INTEGER"); + + b.Property("IsScalingActive") + .HasColumnType("INTEGER"); + + b.Property("IsVisible") + .HasColumnType("INTEGER"); + + b.Property("LoggingSessionID") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputValue") + .HasColumnType("REAL"); + + b.Property("ScaleExpression") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("TypeString") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ID"); + + b.HasIndex("ActiveSampleID"); + + b.HasIndex("LoggingSessionID"); + + b.ToTable("Channel"); + }); modelBuilder.Entity("Daqifi.Desktop.Channel.DataSample", b => { @@ -42,8 +125,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LoggingSessionID") .HasColumnType("INTEGER"); - b.Property("Timestamp") - .HasColumnType("TEXT"); + b.Property("TimestampTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); b.Property("Value") .HasColumnType("REAL"); @@ -52,44 +138,61 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("LoggingSessionID"); - b.ToTable("DataSamples"); + b.HasIndex("LoggingSessionID", "TimestampTicks") + .HasDatabaseName("IX_Samples_LoggingSessionID_TimestampTicks"); + + b.ToTable("Samples"); }); - modelBuilder.Entity("Daqifi.Desktop.Loggers.LoggingSession", b => + modelBuilder.Entity("Daqifi.Desktop.Logger.LoggingSession", b => { b.Property("ID") - .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("DeviceName") + b.Property("Name") .IsRequired() .HasColumnType("TEXT"); - b.Property("EndTime") + b.Property("SessionStart") .HasColumnType("TEXT"); - b.Property("SessionName") - .IsRequired() - .HasColumnType("TEXT"); + b.HasKey("ID"); - b.Property("StartTime") - .HasColumnType("TEXT"); + b.ToTable("Sessions"); + }); - b.HasKey("ID"); + modelBuilder.Entity("Daqifi.Desktop.Channel.Channel", b => + { + b.HasOne("Daqifi.Desktop.Channel.DataSample", "ActiveSample") + .WithMany() + .HasForeignKey("ActiveSampleID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Daqifi.Desktop.Logger.LoggingSession", null) + .WithMany("Channels") + .HasForeignKey("LoggingSessionID"); - b.ToTable("LoggingSessions"); + b.Navigation("ActiveSample"); }); modelBuilder.Entity("Daqifi.Desktop.Channel.DataSample", b => { - b.HasOne("Daqifi.Desktop.Loggers.LoggingSession", "LoggingSession") - .WithMany() + b.HasOne("Daqifi.Desktop.Logger.LoggingSession", "LoggingSession") + .WithMany("DataSamples") .HasForeignKey("LoggingSessionID") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("LoggingSession"); }); + + modelBuilder.Entity("Daqifi.Desktop.Logger.LoggingSession", b => + { + b.Navigation("Channels"); + + b.Navigation("DataSamples"); + }); #pragma warning restore 612, 618 } -} \ No newline at end of file +} diff --git a/Daqifi.Desktop/View/MigrationStatusWindow.xaml b/Daqifi.Desktop/View/MigrationStatusWindow.xaml new file mode 100644 index 00000000..5ae665cb --- /dev/null +++ b/Daqifi.Desktop/View/MigrationStatusWindow.xaml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/Daqifi.Desktop/View/MigrationStatusWindow.xaml.cs b/Daqifi.Desktop/View/MigrationStatusWindow.xaml.cs new file mode 100644 index 00000000..c8b3460f --- /dev/null +++ b/Daqifi.Desktop/View/MigrationStatusWindow.xaml.cs @@ -0,0 +1,15 @@ +using System.Windows; + +namespace Daqifi.Desktop.View; + +/// +/// A small status window shown during database migration to inform the user +/// that an upgrade is in progress. Only displayed when there are pending migrations. +/// +public partial class MigrationStatusWindow : Window +{ + public MigrationStatusWindow() + { + InitializeComponent(); + } +}