From 5763c67f341f3777cd70bd46e685131e433b60a4 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 26 Feb 2026 08:49:11 +0100 Subject: [PATCH] Switch startup DB upgrades to EF migrations --- FAQ.md | 4 + README.md | 2 +- RpgRoller.Tests/HostingCoverageTests.cs | 99 ++++++++++++++++ RpgRoller.Tests/Support/ApiTestBase.cs | 5 +- .../ApplicationInitializationExtensions.cs | 2 +- .../Hosting/ServiceCollectionExtensions.cs | 5 +- RpgRoller/Hosting/SqliteSchemaUpgrader.cs | 104 ++++++++++++++++ .../20260226084000_InitialSchema.cs | 111 ++++++++++++++++++ .../RpgRollerDbContextModelSnapshot.cs | 78 ++++++++++++ 9 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 RpgRoller/Hosting/SqliteSchemaUpgrader.cs create mode 100644 RpgRoller/Migrations/20260226084000_InitialSchema.cs create mode 100644 RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs diff --git a/FAQ.md b/FAQ.md index 54eaa2e..f142819 100644 --- a/FAQ.md +++ b/FAQ.md @@ -26,6 +26,10 @@ Backend state is persisted via EF Core + SQLite. To start with a clean backend state, stop the app and remove the corresponding SQLite file. +## Do I need to run manual DB migrations after updating the app? + +Usually no. Startup now uses EF Core migration-based schema upgrades (`Database.Migrate`) and applies pending migrations automatically. + ## Does the backend read SQLite on every API call? No. The backend loads state from SQLite once during startup into in-memory state and serves requests from memory. Successful state mutations are then written back to SQLite. diff --git a/README.md b/README.md index 327129c..bdfc891 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Backend state persistence: - EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`) - Development DB: `RpgRoller/App_Data/rpgroller.development.db` - Default DB: `RpgRoller/App_Data/rpgroller.db` -- Database schema is created automatically on startup (`EnsureCreated`) +- Database schema is created/upgraded automatically on startup via EF Core migrations (`Database.Migrate`) - Runtime state is loaded once at startup into memory and written back to SQLite on successful state changes ## Prerequisites diff --git a/RpgRoller.Tests/HostingCoverageTests.cs b/RpgRoller.Tests/HostingCoverageTests.cs index ad6a587..f803282 100644 --- a/RpgRoller.Tests/HostingCoverageTests.cs +++ b/RpgRoller.Tests/HostingCoverageTests.cs @@ -1,6 +1,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using RpgRoller.Hosting; +using RpgRoller.Data; namespace RpgRoller.Tests; @@ -27,4 +31,99 @@ public sealed class HostingCoverageTests Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IGameService)); Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IDiceRoller)); } + + [Fact] + public void SqliteSchemaUpgrader_MigratesLegacySkillsSchema() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-legacy-upgrade-{Guid.NewGuid():N}.db"); + var connectionString = $"Data Source={dbPath}"; + + using (var connection = new SqliteConnection(connectionString)) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = + """ + CREATE TABLE "Users" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY, + "Username" TEXT NOT NULL, + "UsernameNormalized" TEXT NOT NULL, + "PasswordHash" TEXT NOT NULL, + "DisplayName" TEXT NOT NULL, + "ActiveCharacterId" TEXT NULL + ); + + CREATE TABLE "Sessions" ( + "Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "CreatedAtUtc" TEXT NOT NULL + ); + + CREATE TABLE "Campaigns" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Campaigns" PRIMARY KEY, + "GmUserId" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Ruleset" TEXT NOT NULL, + "Version" INTEGER NOT NULL + ); + + CREATE TABLE "Characters" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY, + "OwnerUserId" TEXT NOT NULL, + "CampaignId" TEXT NOT NULL, + "Name" TEXT NOT NULL + ); + + CREATE TABLE "Skills" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY, + "CharacterId" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "DiceRollDefinition" TEXT NOT NULL + ); + + CREATE TABLE "RollLogEntries" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_RollLogEntries" PRIMARY KEY, + "CampaignId" TEXT NOT NULL, + "CharacterId" TEXT NOT NULL, + "SkillId" TEXT NOT NULL, + "RollerUserId" TEXT NOT NULL, + "Visibility" TEXT NOT NULL, + "Result" INTEGER NOT NULL, + "Breakdown" TEXT NOT NULL, + "TimestampUtc" TEXT NOT NULL + ); + """; + _ = command.ExecuteNonQuery(); + } + + var options = new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)) + .Options; + + using (var db = new RpgRollerDbContext(options)) + { + SqliteSchemaUpgrader.ApplyPendingChanges(db); + } + + using var verifyConnection = new SqliteConnection(connectionString); + verifyConnection.Open(); + + using var tableInfoCommand = verifyConnection.CreateCommand(); + tableInfoCommand.CommandText = "PRAGMA table_info('Skills');"; + using var tableInfoReader = tableInfoCommand.ExecuteReader(); + var columns = new HashSet(StringComparer.OrdinalIgnoreCase); + while (tableInfoReader.Read()) + { + columns.Add(tableInfoReader.GetString(1)); + } + + Assert.Contains("WildDice", columns); + Assert.Contains("AllowFumble", columns); + + using var historyCommand = verifyConnection.CreateCommand(); + historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';"; + var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar()); + Assert.Equal(1, historyCount); + } } diff --git a/RpgRoller.Tests/Support/ApiTestBase.cs b/RpgRoller.Tests/Support/ApiTestBase.cs index 9ec4293..563b966 100644 --- a/RpgRoller.Tests/Support/ApiTestBase.cs +++ b/RpgRoller.Tests/Support/ApiTestBase.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using RpgRoller.Contracts; @@ -32,7 +33,9 @@ public abstract class ApiTestBase : IClassFixture services.RemoveAll(); var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); - services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); + services.AddDbContextFactory(options => + options.UseSqlite($"Data Source={dbPath}") + .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning))); })); } diff --git a/RpgRoller/Hosting/ApplicationInitializationExtensions.cs b/RpgRoller/Hosting/ApplicationInitializationExtensions.cs index c8e9259..8aa0b31 100644 --- a/RpgRoller/Hosting/ApplicationInitializationExtensions.cs +++ b/RpgRoller/Hosting/ApplicationInitializationExtensions.cs @@ -11,7 +11,7 @@ public static class ApplicationInitializationExtensions using var scope = app.Services.CreateScope(); var dbFactory = scope.ServiceProvider.GetRequiredService>(); using var db = dbFactory.CreateDbContext(); - db.Database.EnsureCreated(); + SqliteSchemaUpgrader.ApplyPendingChanges(db); _ = scope.ServiceProvider.GetRequiredService(); } } diff --git a/RpgRoller/Hosting/ServiceCollectionExtensions.cs b/RpgRoller/Hosting/ServiceCollectionExtensions.cs index 3f7142d..f094bd1 100644 --- a/RpgRoller/Hosting/ServiceCollectionExtensions.cs +++ b/RpgRoller/Hosting/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using RpgRoller.Data; using RpgRoller.Domain; using RpgRoller.Services; @@ -18,7 +19,9 @@ public static class ServiceCollectionExtensions EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath); services.AddSingleton, PasswordHasher>(); - services.AddDbContextFactory(options => options.UseSqlite(sqliteConnectionString)); + services.AddDbContextFactory(options => + options.UseSqlite(sqliteConnectionString) + .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning))); services.AddSingleton(); services.AddSingleton(); diff --git a/RpgRoller/Hosting/SqliteSchemaUpgrader.cs b/RpgRoller/Hosting/SqliteSchemaUpgrader.cs new file mode 100644 index 0000000..5a57757 --- /dev/null +++ b/RpgRoller/Hosting/SqliteSchemaUpgrader.cs @@ -0,0 +1,104 @@ +using Microsoft.EntityFrameworkCore; +using RpgRoller.Data; + +namespace RpgRoller.Hosting; + +public static class SqliteSchemaUpgrader +{ + private const string InitialMigrationId = "20260226084000_InitialSchema"; + private const string ProductVersion = "10.0.2"; + + public static void ApplyPendingChanges(RpgRollerDbContext db) + { + if (db.Database.IsSqlite()) + { + EnsureLegacySchemaHistory(db); + } + + db.Database.Migrate(); + } + + private static void EnsureLegacySchemaHistory(RpgRollerDbContext db) + { + db.Database.OpenConnection(); + try + { + if (TableExists(db, "__EFMigrationsHistory")) + { + return; + } + + if (!TableExists(db, "Skills")) + { + return; + } + + if (!ColumnExists(db, "Skills", "WildDice") || !ColumnExists(db, "Skills", "AllowFumble")) + { + return; + } + + using var createHistoryCommand = db.Database.GetDbConnection().CreateCommand(); + createHistoryCommand.CommandText = + """ + CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( + "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, + "ProductVersion" TEXT NOT NULL + ); + """; + _ = createHistoryCommand.ExecuteNonQuery(); + + using var insertHistoryCommand = db.Database.GetDbConnection().CreateCommand(); + insertHistoryCommand.CommandText = + """ + INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ($migrationId, $productVersion); + """; + + var migrationParameter = insertHistoryCommand.CreateParameter(); + migrationParameter.ParameterName = "$migrationId"; + migrationParameter.Value = InitialMigrationId; + insertHistoryCommand.Parameters.Add(migrationParameter); + + var productVersionParameter = insertHistoryCommand.CreateParameter(); + productVersionParameter.ParameterName = "$productVersion"; + productVersionParameter.Value = ProductVersion; + insertHistoryCommand.Parameters.Add(productVersionParameter); + + _ = insertHistoryCommand.ExecuteNonQuery(); + } + finally + { + db.Database.CloseConnection(); + } + } + + private static bool TableExists(RpgRollerDbContext db, string tableName) + { + using var command = db.Database.GetDbConnection().CreateCommand(); + command.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;"; + var parameter = command.CreateParameter(); + parameter.ParameterName = "$name"; + parameter.Value = tableName; + command.Parameters.Add(parameter); + + return command.ExecuteScalar() is not null; + } + + private static bool ColumnExists(RpgRollerDbContext db, string tableName, string columnName) + { + using var command = db.Database.GetDbConnection().CreateCommand(); + command.CommandText = $"PRAGMA table_info('{tableName}');"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var currentColumnName = reader.GetString(1); + if (string.Equals(currentColumnName, columnName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/RpgRoller/Migrations/20260226084000_InitialSchema.cs b/RpgRoller/Migrations/20260226084000_InitialSchema.cs new file mode 100644 index 0000000..6dce6d0 --- /dev/null +++ b/RpgRoller/Migrations/20260226084000_InitialSchema.cs @@ -0,0 +1,111 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using RpgRoller.Data; + +namespace RpgRoller.Migrations; + +[DbContext(typeof(RpgRollerDbContext))] +[Migration("20260226084000_InitialSchema")] +public sealed class InitialSchema : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE TABLE IF NOT EXISTS "Users" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY, + "Username" TEXT NOT NULL, + "UsernameNormalized" TEXT NOT NULL, + "PasswordHash" TEXT NOT NULL, + "DisplayName" TEXT NOT NULL, + "ActiveCharacterId" TEXT NULL + ); + """); + + migrationBuilder.Sql( + """ + CREATE TABLE IF NOT EXISTS "Sessions" ( + "Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "CreatedAtUtc" TEXT NOT NULL + ); + """); + + migrationBuilder.Sql( + """ + CREATE TABLE IF NOT EXISTS "Campaigns" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Campaigns" PRIMARY KEY, + "GmUserId" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Ruleset" TEXT NOT NULL, + "Version" INTEGER NOT NULL + ); + """); + + migrationBuilder.Sql( + """ + CREATE TABLE IF NOT EXISTS "Characters" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY, + "OwnerUserId" TEXT NOT NULL, + "CampaignId" TEXT NOT NULL, + "Name" TEXT NOT NULL + ); + """); + + migrationBuilder.Sql( + """ + CREATE TABLE IF NOT EXISTS "Skills" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY, + "CharacterId" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "DiceRollDefinition" TEXT NOT NULL + ); + """); + + migrationBuilder.Sql( + """ + ALTER TABLE "Skills" ADD COLUMN "WildDice" INTEGER NOT NULL DEFAULT 1; + """); + + migrationBuilder.Sql( + """ + ALTER TABLE "Skills" ADD COLUMN "AllowFumble" INTEGER NOT NULL DEFAULT 1; + """); + + migrationBuilder.Sql( + """ + CREATE TABLE IF NOT EXISTS "RollLogEntries" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_RollLogEntries" PRIMARY KEY, + "CampaignId" TEXT NOT NULL, + "CharacterId" TEXT NOT NULL, + "SkillId" TEXT NOT NULL, + "RollerUserId" TEXT NOT NULL, + "Visibility" TEXT NOT NULL, + "Result" INTEGER NOT NULL, + "Breakdown" TEXT NOT NULL, + "TimestampUtc" TEXT NOT NULL + ); + """); + + migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_Users_UsernameNormalized" ON "Users" ("UsernameNormalized");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Sessions_UserId" ON "Sessions" ("UserId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Campaigns_GmUserId" ON "Campaigns" ("GmUserId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Characters_OwnerUserId" ON "Characters" ("OwnerUserId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Characters_CampaignId" ON "Characters" ("CampaignId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Skills_CharacterId" ON "Skills" ("CharacterId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_RollLogEntries_CampaignId" ON "RollLogEntries" ("CampaignId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_RollLogEntries_RollerUserId" ON "RollLogEntries" ("RollerUserId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_RollLogEntries_SkillId" ON "RollLogEntries" ("SkillId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_RollLogEntries_CharacterId" ON "RollLogEntries" ("CharacterId");"""); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("""DROP TABLE IF EXISTS "RollLogEntries";"""); + migrationBuilder.Sql("""DROP TABLE IF EXISTS "Skills";"""); + migrationBuilder.Sql("""DROP TABLE IF EXISTS "Characters";"""); + migrationBuilder.Sql("""DROP TABLE IF EXISTS "Campaigns";"""); + migrationBuilder.Sql("""DROP TABLE IF EXISTS "Sessions";"""); + migrationBuilder.Sql("""DROP TABLE IF EXISTS "Users";"""); + } +} diff --git a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs new file mode 100644 index 0000000..427445f --- /dev/null +++ b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using RpgRoller.Data; +using RpgRoller.Domain; + +namespace RpgRoller.Migrations; + +[DbContext(typeof(RpgRollerDbContext))] +internal sealed class RpgRollerDbContextModelSnapshot : ModelSnapshot +{ + protected override void BuildModel(ModelBuilder modelBuilder) + { + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).IsRequired().HasMaxLength(128); + entity.Property(x => x.Ruleset).HasConversion().IsRequired(); + entity.Property(x => x.Version).IsRequired(); + entity.HasIndex(x => x.GmUserId); + entity.ToTable("Campaigns"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).IsRequired().HasMaxLength(128); + entity.HasIndex(x => x.OwnerUserId); + entity.HasIndex(x => x.CampaignId); + entity.ToTable("Characters"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Visibility).HasConversion().IsRequired(); + entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256); + entity.Property(x => x.TimestampUtc).IsRequired(); + entity.HasIndex(x => x.CampaignId); + entity.HasIndex(x => x.RollerUserId); + entity.HasIndex(x => x.SkillId); + entity.HasIndex(x => x.CharacterId); + entity.ToTable("RollLogEntries"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).IsRequired().HasMaxLength(128); + entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128); + entity.Property(x => x.WildDice).IsRequired(); + entity.Property(x => x.AllowFumble).IsRequired(); + entity.HasIndex(x => x.CharacterId); + entity.ToTable("Skills"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Username).IsRequired().HasMaxLength(64); + entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64); + entity.Property(x => x.PasswordHash).IsRequired(); + entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128); + entity.HasIndex(x => x.UsernameNormalized).IsUnique(); + entity.ToTable("Users"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Token); + entity.Property(x => x.Token).HasMaxLength(64); + entity.Property(x => x.CreatedAtUtc).IsRequired(); + entity.HasIndex(x => x.UserId); + entity.ToTable("Sessions"); + }); + } +}