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

View File

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

View File

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