Split SQLite rebuild migration from roles change

This commit is contained in:
2026-04-04 20:44:26 +02:00
parent 8c413a8ded
commit a5f8421aa8
7 changed files with 513 additions and 61 deletions

View File

@@ -121,7 +121,7 @@ For migration authoring, use the local tool command form:
dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj
```
For SQLite migrations, avoid `migrationBuilder.Sql(...)` in the same migration step that rebuilds a table for schema changes; prefer column defaults or a follow-up migration so EF Core does not warn about raw SQL executing while a rebuilt table is still pending.
For SQLite migrations, keep table-rebuild operations isolated from unrelated schema/data changes. If a migration needs both a rebuild-triggering change (for example `AlterColumn`) and another independent operation, split them into separate migrations so EF Core does not emit non-transactional migration warnings.
## Frontend Runtime

View File

@@ -199,6 +199,11 @@ public sealed class HostingCoverageTests
var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolesHistoryCount);
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, authorizationRolesHistoryCount);
using var rolemasterHistoryCommand = verifyConnection.CreateCommand();
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
@@ -206,20 +211,147 @@ public sealed class HostingCoverageTests
}
[Fact]
public void AuthorizationRolesAndCampaignDeletion_MigrationScript_DoesNotMixUsersSqlIntoCharactersRebuild()
public void AuthorizationMigrations_SplitCharactersRebuildFromRolesColumnAddition()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-migration-script-{Guid.NewGuid():N}.db");
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
using var db = new RpgRollerDbContext(options);
var migrator = db.GetService<IMigrator>();
var script = migrator.GenerateScript(
var charactersScript = migrator.GenerateScript(
fromMigration: "20260226131003_AddSkillGroupPrototypes",
toMigration: "20260226160859_AddAuthorizationRolesAndCampaignDeletion");
var rolesScript = migrator.GenerateScript(
fromMigration: "20260226160859_AddAuthorizationRolesAndCampaignDeletion",
toMigration: "20260226170000_AddAuthorizationRoles");
Assert.Contains("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", script);
Assert.Contains("""CREATE TABLE "ef_temp_Characters" (""", script);
Assert.DoesNotContain("UPDATE Users", script);
Assert.Contains("""CREATE TABLE "ef_temp_Characters" (""", charactersScript);
Assert.DoesNotContain("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", charactersScript);
Assert.Contains("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", rolesScript);
Assert.DoesNotContain("""CREATE TABLE "ef_temp_Characters" (""", rolesScript);
}
[Fact]
public void SqliteSchemaUpgrader_BackfillsSplitAuthorizationRolesMigrationHistory()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-split-history-{Guid.NewGuid():N}.db");
using (var connection = new SqliteConnection($"Data Source={dbPath}"))
{
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES
('20260226084000_InitialSchema', '10.0.2'),
('20260226090000_ModelSync', '10.0.2'),
('20260226100000_AddRollLogDice', '10.0.2'),
('20260226124941_AddSkillGroupsAndCharacterOwnerTransfer', '10.0.2'),
('20260226131003_AddSkillGroupPrototypes', '10.0.2'),
('20260226160859_AddAuthorizationRolesAndCampaignDeletion', '10.0.2');
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,
"Roles" TEXT NOT NULL DEFAULT 'admin'
);
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 NULL,
"Name" TEXT NOT NULL
);
CREATE INDEX "IX_Characters_OwnerUserId" ON "Characters" ("OwnerUserId");
CREATE INDEX "IX_Characters_CampaignId" ON "Characters" ("CampaignId");
CREATE TABLE "SkillGroups" (
"Id" TEXT NOT NULL CONSTRAINT "PK_SkillGroups" PRIMARY KEY,
"CharacterId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"AllowFumble" INTEGER NOT NULL,
"DiceRollDefinition" TEXT NOT NULL,
"WildDice" INTEGER NOT NULL
);
CREATE INDEX "IX_SkillGroups_CharacterId" ON "SkillGroups" ("CharacterId");
CREATE TABLE "Skills" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY,
"CharacterId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"DiceRollDefinition" TEXT NOT NULL,
"WildDice" INTEGER NOT NULL,
"AllowFumble" INTEGER NOT NULL,
"SkillGroupId" TEXT NULL
);
CREATE INDEX "IX_Skills_CharacterId" ON "Skills" ("CharacterId");
CREATE INDEX "IX_Skills_SkillGroupId" ON "Skills" ("SkillGroupId");
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,
"Dice" TEXT NOT NULL
);
CREATE INDEX "IX_RollLogEntries_CampaignId" ON "RollLogEntries" ("CampaignId");
CREATE INDEX "IX_RollLogEntries_CharacterId" ON "RollLogEntries" ("CharacterId");
CREATE INDEX "IX_RollLogEntries_RollerUserId" ON "RollLogEntries" ("RollerUserId");
CREATE INDEX "IX_RollLogEntries_SkillId" ON "RollLogEntries" ("SkillId");
""";
_ = command.ExecuteNonQuery();
}
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
using (var db = new RpgRollerDbContext(options))
{
SqliteSchemaUpgrader.ApplyPendingChanges(db);
}
using var verifyConnection = new SqliteConnection($"Data Source={dbPath}");
verifyConnection.Open();
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, authorizationRolesHistoryCount);
using var rolemasterHistoryCommand = verifyConnection.CreateCommand();
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolemasterHistoryCount);
}
[Fact]
@@ -343,5 +475,10 @@ public sealed class HostingCoverageTests
skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1));
Assert.Contains("FumbleRange", skillGroupColumns);
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, authorizationRolesHistoryCount);
}
}

View File

@@ -8,15 +8,23 @@ public static class SqliteSchemaUpgrader
public static void ApplyPendingChanges(RpgRollerDbContext db)
{
if (db.Database.IsSqlite())
{
db.Database.OpenConnection();
try
{
EnsureLegacySchemaHistory(db);
EnsureSplitMigrationHistory(db);
}
finally
{
db.Database.CloseConnection();
}
}
db.Database.Migrate();
}
private static void EnsureLegacySchemaHistory(RpgRollerDbContext db)
{
db.Database.OpenConnection();
try
{
if (TableExists(db, "__EFMigrationsHistory"))
return;
@@ -36,28 +44,24 @@ public static class SqliteSchemaUpgrader
""";
_ = 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();
InsertMigrationHistory(db, InitialMigrationId);
}
finally
private static void EnsureSplitMigrationHistory(RpgRollerDbContext db)
{
db.Database.CloseConnection();
}
if (!TableExists(db, "__EFMigrationsHistory"))
return;
if (!MigrationExists(db, CharactersCampaignDeletionMigrationId))
return;
if (MigrationExists(db, AuthorizationRolesMigrationId))
return;
if (!ColumnExists(db, "Users", "Roles"))
return;
InsertMigrationHistory(db, AuthorizationRolesMigrationId);
}
private static bool TableExists(RpgRollerDbContext db, string tableName)
@@ -87,6 +91,46 @@ public static class SqliteSchemaUpgrader
return false;
}
private static bool MigrationExists(RpgRollerDbContext db, string migrationId)
{
using var command = db.Database.GetDbConnection().CreateCommand();
command.CommandText = """
SELECT 1
FROM "__EFMigrationsHistory"
WHERE "MigrationId" = $migrationId
LIMIT 1;
""";
var migrationParameter = command.CreateParameter();
migrationParameter.ParameterName = "$migrationId";
migrationParameter.Value = migrationId;
command.Parameters.Add(migrationParameter);
return command.ExecuteScalar() is not null;
}
private static void InsertMigrationHistory(RpgRollerDbContext db, string migrationId)
{
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 = migrationId;
insertHistoryCommand.Parameters.Add(migrationParameter);
var productVersionParameter = insertHistoryCommand.CreateParameter();
productVersionParameter.ParameterName = "$productVersion";
productVersionParameter.Value = ProductVersion;
insertHistoryCommand.Parameters.Add(productVersionParameter);
_ = insertHistoryCommand.ExecuteNonQuery();
}
private const string InitialMigrationId = "20260226084000_InitialSchema";
private const string CharactersCampaignDeletionMigrationId = "20260226160859_AddAuthorizationRolesAndCampaignDeletion";
private const string AuthorizationRolesMigrationId = "20260226170000_AddAuthorizationRoles";
private const string ProductVersion = "10.0.2";
}

View File

@@ -211,11 +211,6 @@ namespace RpgRoller.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Roles")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)

View File

@@ -11,14 +11,6 @@ namespace RpgRoller.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Roles",
table: "Users",
type: "TEXT",
maxLength: 256,
nullable: false,
defaultValue: "admin");
migrationBuilder.AlterColumn<Guid>(
name: "CampaignId",
table: "Characters",
@@ -31,10 +23,6 @@ namespace RpgRoller.Migrations
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Roles",
table: "Users");
migrationBuilder.AlterColumn<Guid>(
name: "CampaignId",
table: "Characters",

View File

@@ -0,0 +1,258 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260226170000_AddAuthorizationRoles")]
partial class AddAuthorizationRoles
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Roles")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddAuthorizationRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Roles",
table: "Users",
type: "TEXT",
maxLength: 256,
nullable: false,
defaultValue: "admin");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Roles",
table: "Users");
}
}
}