Switch startup DB upgrades to EF migrations

This commit is contained in:
2026-02-26 08:49:11 +01:00
parent 0ec19bf682
commit 5763c67f34
9 changed files with 406 additions and 4 deletions

4
FAQ.md
View File

@@ -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. 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? ## 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. 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.

View File

@@ -35,7 +35,7 @@ Backend state persistence:
- EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`) - EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`)
- Development DB: `RpgRoller/App_Data/rpgroller.development.db` - Development DB: `RpgRoller/App_Data/rpgroller.development.db`
- Default DB: `RpgRoller/App_Data/rpgroller.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 - Runtime state is loaded once at startup into memory and written back to SQLite on successful state changes
## Prerequisites ## Prerequisites

View File

@@ -1,6 +1,10 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using RpgRoller.Hosting; using RpgRoller.Hosting;
using RpgRoller.Data;
namespace RpgRoller.Tests; 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.IGameService));
Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IDiceRoller)); 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<RpgRollerDbContext>()
.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<string>(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);
}
} }

View File

@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using RpgRoller.Contracts; using RpgRoller.Contracts;
@@ -32,7 +33,9 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
services.RemoveAll<RpgRollerDbContext>(); services.RemoveAll<RpgRollerDbContext>();
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}")); services.AddDbContextFactory<RpgRollerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}")
.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)));
})); }));
} }

View File

@@ -11,7 +11,7 @@ public static class ApplicationInitializationExtensions
using var scope = app.Services.CreateScope(); using var scope = app.Services.CreateScope();
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RpgRollerDbContext>>(); var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RpgRollerDbContext>>();
using var db = dbFactory.CreateDbContext(); using var db = dbFactory.CreateDbContext();
db.Database.EnsureCreated(); SqliteSchemaUpgrader.ApplyPendingChanges(db);
_ = scope.ServiceProvider.GetRequiredService<IGameService>(); _ = scope.ServiceProvider.GetRequiredService<IGameService>();
} }
} }

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Domain; using RpgRoller.Domain;
using RpgRoller.Services; using RpgRoller.Services;
@@ -18,7 +19,9 @@ public static class ServiceCollectionExtensions
EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath); EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath);
services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>(); services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString)); services.AddDbContextFactory<RpgRollerDbContext>(options =>
options.UseSqlite(sqliteConnectionString)
.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)));
services.AddSingleton<IDiceRoller, RandomDiceRoller>(); services.AddSingleton<IDiceRoller, RandomDiceRoller>();
services.AddSingleton<IGameService, GameService>(); services.AddSingleton<IGameService, GameService>();

View File

@@ -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;
}
}

View File

@@ -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";""");
}
}

View File

@@ -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<Campaign>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
entity.Property(x => x.Ruleset).HasConversion<string>().IsRequired();
entity.Property(x => x.Version).IsRequired();
entity.HasIndex(x => x.GmUserId);
entity.ToTable("Campaigns");
});
modelBuilder.Entity<Character>(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<RollLogEntry>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Visibility).HasConversion<string>().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<Skill>(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<UserAccount>(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<UserSession>(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");
});
}
}