fix(configdb): make ResyncLdapGroupMappingSeed migration idempotent (guarded insert) (#70)
This commit is contained in:
+42
-4
@@ -7,18 +7,56 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ResyncLdapGroupMappingSeed : Migration
|
public partial class ResyncLdapGroupMappingSeed : Migration
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SQLite idempotent insert for the Id=5 (SCADA-Viewers → Viewer) seed row.
|
||||||
|
/// <c>INSERT OR IGNORE</c> skips the row when the <c>Id</c> primary key already
|
||||||
|
/// exists, so replaying this migration against a DB that already carries Id=5
|
||||||
|
/// (e.g. one seeded by a newer HasData baseline) is a no-op rather than a PK
|
||||||
|
/// violation. Exposed as a constant so the idempotency test can exercise the
|
||||||
|
/// exact SQL the migration emits on the SQLite branch.
|
||||||
|
/// </summary>
|
||||||
|
internal const string SqliteInsertSql =
|
||||||
|
"INSERT OR IGNORE INTO \"LdapGroupMappings\" (\"Id\", \"LdapGroupName\", \"Role\") " +
|
||||||
|
"VALUES (5, 'SCADA-Viewers', 'Viewer');";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.InsertData(
|
// #70: this seed insert must be idempotent. The original unconditional
|
||||||
table: "LdapGroupMappings",
|
// InsertData threw a PK violation (and crash-looped startup, since dev
|
||||||
columns: new[] { "Id", "LdapGroupName", "Role" },
|
// auto-applies migrations) on any pre-existing DB that already had Id=5
|
||||||
values: new object[] { 5, "SCADA-Viewers", "Viewer" });
|
// — e.g. one seeded via the old HasData baseline. Guard the insert so it
|
||||||
|
// is a no-op when the row already exists. Provider-aware because the
|
||||||
|
// production central DB is SQL Server while dev/test run on SQLite, and
|
||||||
|
// the two speak different conditional-insert dialects (mirrors the
|
||||||
|
// NotificationOutboxRepository.InsertIfNotExists pattern, #286).
|
||||||
|
if (migrationBuilder.ActiveProvider == "Microsoft.EntityFrameworkCore.Sqlite")
|
||||||
|
{
|
||||||
|
// SQLite: INSERT OR IGNORE silently skips the row on a PK clash.
|
||||||
|
migrationBuilder.Sql(SqliteInsertSql);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// SQL Server: guard with IF NOT EXISTS. LdapGroupMappings.Id is an
|
||||||
|
// IDENTITY column (InitialSchema declares SqlServer:Identity "1, 1"),
|
||||||
|
// so an explicit Id value requires SET IDENTITY_INSERT ON/OFF around
|
||||||
|
// the INSERT — exactly what EF's InsertData emitted for this row.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM [LdapGroupMappings] WHERE [Id] = 5)
|
||||||
|
BEGIN
|
||||||
|
SET IDENTITY_INSERT [LdapGroupMappings] ON;
|
||||||
|
INSERT INTO [LdapGroupMappings] ([Id], [LdapGroupName], [Role])
|
||||||
|
VALUES (5, N'SCADA-Viewers', N'Viewer');
|
||||||
|
SET IDENTITY_INSERT [LdapGroupMappings] OFF;
|
||||||
|
END");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
|
// Idempotent delete: removes the Id=5 row if present, no-op otherwise.
|
||||||
|
// Unchanged in behaviour from the original DeleteData.
|
||||||
migrationBuilder.DeleteData(
|
migrationBuilder.DeleteData(
|
||||||
table: "LdapGroupMappings",
|
table: "LdapGroupMappings",
|
||||||
keyColumn: "Id",
|
keyColumn: "Id",
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Idempotency tests for the <c>ResyncLdapGroupMappingSeed</c> migration (#70).
|
||||||
|
///
|
||||||
|
/// The original migration did an UNCONDITIONAL <c>InsertData</c> of the
|
||||||
|
/// Id=5 (SCADA-Viewers → Viewer) seed row. On any pre-existing DB that already
|
||||||
|
/// carried Id=5 that threw a PK violation and crash-looped startup (dev
|
||||||
|
/// auto-applies migrations). The fix makes the insert provider-aware and
|
||||||
|
/// idempotent (SQLite <c>INSERT OR IGNORE</c> / SQL Server guarded
|
||||||
|
/// <c>IF NOT EXISTS … SET IDENTITY_INSERT … INSERT</c>).
|
||||||
|
///
|
||||||
|
/// These tests drive the migration's <c>Up()</c> through the EF migrations SQL
|
||||||
|
/// pipeline configured for SQLite, so they exercise the exact
|
||||||
|
/// <c>migrationBuilder.Sql(...)</c> text the migration emits on the SQLite
|
||||||
|
/// branch — the branch a CI box without an MSSQL container can actually run.
|
||||||
|
/// The SQL Server branch is the same <c>migrationBuilder.Sql</c> text shape and
|
||||||
|
/// is exercised by the full-history MSSQL migration fixture
|
||||||
|
/// (<see cref="MsSqlMigrationFixture"/>) used by the other migration tests.
|
||||||
|
/// </summary>
|
||||||
|
public class ResyncLdapGroupMappingSeedMigrationTests
|
||||||
|
{
|
||||||
|
private const string SqliteProvider = "Microsoft.EntityFrameworkCore.Sqlite";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Up_OnSqlite_InsertsRow_WhenIdFiveMissing()
|
||||||
|
{
|
||||||
|
using var connection = OpenSeededConnection(seedIdFive: false);
|
||||||
|
|
||||||
|
ApplyMigrationUp(connection);
|
||||||
|
|
||||||
|
Assert.Equal(1, CountId5(connection));
|
||||||
|
Assert.Equal(("SCADA-Viewers", "Viewer"), ReadId5(connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Up_OnSqlite_IsIdempotent_WhenIdFiveAlreadyExists()
|
||||||
|
{
|
||||||
|
// Pre-existing DB already carries Id=5 — the #70 crash scenario.
|
||||||
|
using var connection = OpenSeededConnection(seedIdFive: true);
|
||||||
|
|
||||||
|
// Applying the migration MUST NOT throw on the PK clash...
|
||||||
|
var ex = Record.Exception(() => ApplyMigrationUp(connection));
|
||||||
|
Assert.Null(ex);
|
||||||
|
|
||||||
|
// ...and MUST NOT duplicate the row.
|
||||||
|
Assert.Equal(1, CountId5(connection));
|
||||||
|
Assert.Equal(("SCADA-Viewers", "Viewer"), ReadId5(connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Up_OnSqlite_ReplayingTwice_IsStillIdempotent()
|
||||||
|
{
|
||||||
|
using var connection = OpenSeededConnection(seedIdFive: false);
|
||||||
|
|
||||||
|
ApplyMigrationUp(connection);
|
||||||
|
var ex = Record.Exception(() => ApplyMigrationUp(connection));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
Assert.Equal(1, CountId5(connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- harness -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a shared in-memory SQLite connection holding only the columns this
|
||||||
|
/// migration touches and (optionally) a pre-existing Id=5 row.
|
||||||
|
/// </summary>
|
||||||
|
private static SqliteConnection OpenSeededConnection(bool seedIdFive)
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
using (var create = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
// Mirror the production PK shape: Id is the primary key. Re-inserting
|
||||||
|
// an existing Id raises a UNIQUE/PK violation unless guarded.
|
||||||
|
create.CommandText =
|
||||||
|
"CREATE TABLE \"LdapGroupMappings\" (" +
|
||||||
|
" \"Id\" INTEGER NOT NULL CONSTRAINT \"PK_LdapGroupMappings\" PRIMARY KEY," +
|
||||||
|
" \"LdapGroupName\" TEXT NOT NULL," +
|
||||||
|
" \"Role\" TEXT NOT NULL);";
|
||||||
|
create.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seedIdFive)
|
||||||
|
{
|
||||||
|
using var seed = connection.CreateCommand();
|
||||||
|
seed.CommandText =
|
||||||
|
"INSERT INTO \"LdapGroupMappings\" (\"Id\", \"LdapGroupName\", \"Role\") " +
|
||||||
|
"VALUES (5, 'SCADA-Viewers', 'Viewer');";
|
||||||
|
seed.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates the real migration's <c>Up()</c> SQL via the EF migrations
|
||||||
|
/// pipeline configured for SQLite (so <c>migrationBuilder.ActiveProvider</c>
|
||||||
|
/// is the SQLite provider and the SQLite branch is taken), then runs every
|
||||||
|
/// emitted command against the supplied connection.
|
||||||
|
/// </summary>
|
||||||
|
private static void ApplyMigrationUp(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
using var context = new ScadaBridgeDbContext(options);
|
||||||
|
|
||||||
|
var migration = new ResyncLdapGroupMappingSeed { ActiveProvider = SqliteProvider };
|
||||||
|
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||||
|
var commands = sqlGenerator.Generate(migration.UpOperations);
|
||||||
|
|
||||||
|
foreach (var command in commands)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = command.CommandText;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountId5(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM \"LdapGroupMappings\" WHERE \"Id\" = 5;";
|
||||||
|
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string LdapGroupName, string Role) ReadId5(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT \"LdapGroupName\", \"Role\" FROM \"LdapGroupMappings\" WHERE \"Id\" = 5;";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
Assert.True(reader.Read());
|
||||||
|
return (reader.GetString(0), reader.GetString(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user