diff --git a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Runtime/20251204150341_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Runtime/20251204150341_V3_6.cs index efb1be610..67b39e4c0 100644 --- a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Runtime/20251204150341_V3_6.cs +++ b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Runtime/20251204150341_V3_6.cs @@ -26,8 +26,8 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Runtime unique: true); var schemaPrefix = _schema.Schema != null ? $"\"{_schema.Schema}\"." : ""; - migrationBuilder.Sql($"DROP INDEX IF EXISTS {schemaPrefix}\"IX_WorkflowExecutionLogRecord_ActivityNodeId\""); - migrationBuilder.Sql($"DROP INDEX IF EXISTS {schemaPrefix}\"IX_ActivityExecutionRecord_ActivityNodeId\""); + migrationBuilder.Sql($"DROP INDEX IF EXISTS {schemaPrefix}\"IX_WorkflowExecutionLogRecord_ActivityNodeId\";"); + migrationBuilder.Sql($"DROP INDEX IF EXISTS {schemaPrefix}\"IX_ActivityExecutionRecord_ActivityNodeId\";"); } /// diff --git a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Runtime/20251204150006_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Runtime/20251204150006_V3_6.cs index 3d46f9498..f3b3c2702 100644 --- a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Runtime/20251204150006_V3_6.cs +++ b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Runtime/20251204150006_V3_6.cs @@ -25,8 +25,8 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Runtime columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" }, unique: true); - migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_WorkflowExecutionLogRecord_ActivityNodeId\""); - migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_ActivityExecutionRecord_ActivityNodeId\""); + migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_WorkflowExecutionLogRecord_ActivityNodeId\";"); + migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_ActivityExecutionRecord_ActivityNodeId\";"); } /// diff --git a/test/unit/Elsa.Persistence.EFCore.UnitTests/Elsa.Persistence.EFCore.UnitTests.csproj b/test/unit/Elsa.Persistence.EFCore.UnitTests/Elsa.Persistence.EFCore.UnitTests.csproj index 50b51c57e..777107d4b 100644 --- a/test/unit/Elsa.Persistence.EFCore.UnitTests/Elsa.Persistence.EFCore.UnitTests.csproj +++ b/test/unit/Elsa.Persistence.EFCore.UnitTests/Elsa.Persistence.EFCore.UnitTests.csproj @@ -11,6 +11,8 @@ + + diff --git a/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6RuntimeMigrationTests.cs b/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6RuntimeMigrationTests.cs new file mode 100644 index 000000000..89f30d0a9 --- /dev/null +++ b/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6RuntimeMigrationTests.cs @@ -0,0 +1,121 @@ +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.Persistence.EFCore.Modules.Runtime; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Persistence.EFCore.UnitTests; + +/// +/// Regression tests for the V3_6 Runtime migration's raw SQL statements. `dotnet ef migrations script` +/// previously produced invalid SQL because the `DROP INDEX` statements were not terminated with a +/// semicolon, which corrupts both the idempotent PL/pgSQL block and the plain script. +/// +public class V3_6RuntimeMigrationTests +{ + private const string DropIndexPrefix = "DROP INDEX IF EXISTS"; + private const string WorkflowExecutionLogRecordIndexName = "IX_WorkflowExecutionLogRecord_ActivityNodeId"; + private const string ActivityExecutionRecordIndexName = "IX_ActivityExecutionRecord_ActivityNodeId"; + + [Theory] + [InlineData("Elsa")] + [InlineData("custom_schema")] + public void GenerateScript_PostgreSql_Idempotent_TerminatesDropIndexStatements(string schema) + { + var script = GeneratePostgreSqlScript(MigrationsSqlGenerationOptions.Idempotent, schema); + + AssertDropIndexStatementsAreTerminated(script, schema, requireTrailingEndIf: true); + } + + [Theory] + [InlineData("Elsa")] + [InlineData("custom_schema")] + public void GenerateScript_PostgreSql_Plain_TerminatesDropIndexStatements(string schema) + { + var script = GeneratePostgreSqlScript(MigrationsSqlGenerationOptions.Default, schema); + + AssertDropIndexStatementsAreTerminated(script, schema); + } + + // Note: EF Core's SQLite provider does not support generating idempotent migration scripts + // (SqliteHistoryRepository.GetEndIfScript throws NotSupportedException), so only the plain + // script form is exercised here. The SQLite migration also never schema-qualifies its DROP + // INDEX statements, so there is no schema-prefixed variant to cover. + [Fact] + public void GenerateScript_Sqlite_Plain_TerminatesDropIndexStatements() + { + var script = GenerateSqliteScript(MigrationsSqlGenerationOptions.Default); + + AssertDropIndexStatementsAreTerminated(script, schema: null); + } + + private static string GeneratePostgreSqlScript(MigrationsSqlGenerationOptions options, string schema) + { + var migrationsAssembly = typeof(Elsa.Persistence.EFCore.PostgreSql.Migrations.Runtime.V3_6).Assembly; + var contextOptions = new ElsaDbContextOptions { SchemaName = schema }; + + return GenerateScript( + builder => builder.UseElsaPostgreSql(migrationsAssembly, "Host=unused", contextOptions), + fromMigration: "20250530104953_V3_5", + toMigration: "20251204150341_V3_6", + options); + } + + private static string GenerateSqliteScript(MigrationsSqlGenerationOptions options) + { + var migrationsAssembly = typeof(Elsa.Persistence.EFCore.Sqlite.Migrations.Runtime.V3_6).Assembly; + + return GenerateScript( + builder => builder.UseElsaSqlite(migrationsAssembly, "Data Source=:memory:"), + fromMigration: "20250530104854_V3_5", + toMigration: "20251204150006_V3_6", + options); + } + + private static string GenerateScript(Action> configureProvider, string fromMigration, string toMigration, MigrationsSqlGenerationOptions options) + { + var optionsBuilder = new DbContextOptionsBuilder(); + configureProvider(optionsBuilder); + var dbContextOptions = optionsBuilder.Options; + + using var dbContext = new RuntimeElsaDbContext(dbContextOptions, CreateServiceProvider()); + var migrator = dbContext.GetService(); + + return migrator.GenerateScript(fromMigration: fromMigration, toMigration: toMigration, options: options); + } + + // ElsaDbContextOptions.SchemaName always falls back to ElsaDbContextBase.ElsaSchema ("Elsa") when it + // isn't set, so the branch the V3_6 migration guards against with + // `_schema.Schema != null ? "\"{schema}\"." : ""` - a null schema - is unreachable through the + // public options and is therefore not covered here. + private static IServiceProvider CreateServiceProvider() => new ServiceCollection().BuildServiceProvider(); + + private static void AssertDropIndexStatementsAreTerminated(string script, string? schema, bool requireTrailingEndIf = false) + { + AssertDropIndexStatementIsTerminated(script, WorkflowExecutionLogRecordIndexName, schema, requireTrailingEndIf); + AssertDropIndexStatementIsTerminated(script, ActivityExecutionRecordIndexName, schema, requireTrailingEndIf); + } + + private static void AssertDropIndexStatementIsTerminated(string script, string indexName, string? schema, bool requireTrailingEndIf) + { + var expectedStatement = schema != null + ? $"{DropIndexPrefix} \"{schema}\".\"{indexName}\";" + : $"{DropIndexPrefix} \"{indexName}\";"; + + var lines = script.Split('\n').Select(l => l.Trim()).ToList(); + var dropLineIndex = lines.FindIndex(l => l.StartsWith(DropIndexPrefix, StringComparison.Ordinal) && l.Contains(indexName, StringComparison.Ordinal)); + + Assert.True(dropLineIndex >= 0, $"Expected to find a 'DROP INDEX IF EXISTS' statement for \"{indexName}\" in the generated script:\n{script}"); + Assert.Equal(expectedStatement, lines[dropLineIndex]); + + if (!requireTrailingEndIf) + return; + + // The statement inside the DO $EF$ ... IF NOT EXISTS(...) THEN END IF; block must be + // terminated before the following END IF; line, otherwise the PL/pgSQL block fails to parse. + var endIfLineIndex = lines.FindIndex(dropLineIndex, l => l.Equals("END IF;", StringComparison.Ordinal)); + Assert.True(endIfLineIndex >= 0, $"Expected an 'END IF;' line after the DROP INDEX statement for \"{indexName}\":\n{script}"); + } +}