* fix(efcore-oracle): migrate LOB columns in V3_6 without in-place datatype alteration Both Oracle V3_6 migrations were generated as in-place `ALTER TABLE ... MODIFY` statements that change a column's datatype to or from a LOB type: NCLOB to JSON for `WorkflowDefinitions.StringData`, and NVARCHAR2(450) to NCLOB for `ActivityNodeId` on `WorkflowExecutionLogRecords` and `ActivityExecutionRecords`. Oracle refuses both (ORA-22858 / ORA-22859), so neither migration could ever apply and the reported ORA-22858 was unavoidable. Convert the columns the way the ORA-22858 message prescribes instead: add a temporary column of the target type, copy the values across, drop the original and rename the temporary one. Because Oracle commits DDL implicitly, a run that fails partway leaves its earlier statements applied - the reporter's already committed `OriginalSource` column is exactly that - so every step is guarded against the state a previous attempt can have left behind. The conversion block derives what still needs doing from `ALL_TAB_COLUMNS`, skips a conversion that already completed (including one applied by hand), and raises rather than copy out of and drop a column whose datatype it does not recognize. Add an offline regression test that generates the Oracle Management and Runtime V3_6 scripts through `IMigrator.GenerateScript` without a connection and asserts that no in-place datatype `MODIFY` is emitted for either column, that the add/copy/drop/rename sequence appears in order, and that the re-run guards are present. All 19 cases fail against the previous migrations. Refs #8011 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(efcore-oracle): tighten migration helper visibility and index guard, share the script-generation test harness Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(efcore-oracle): brace the foreach bodies in the V3_6 migration tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(efcore-oracle): refuse to truncate node IDs on downgrade and validate same-named indexes The Runtime V3_6 downgrade converted ActivityNodeId from NCLOB back to NVARCHAR2(450) by copying DBMS_LOB.SUBSTR(..., 450, 1), silently truncating any value the upgraded schema had allowed to grow past 450 characters. EnsureLobLengthAtMost now checks for oversized values while the column is still a LOB and raises before any data is copied. CreateIndexIfMissing also treated any index with a matching name as already done. It now validates the existing index's table, uniqueness and ordered column list against ALL_INDEXES/ALL_IND_COLUMNS so a same-named index left behind by schema drift or manual recovery is not mistaken for the one the migration means to create. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(efcore-oracle): correct the dynamic SQL rationale on the LOB length guard Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(efcore-oracle): preflight both tables before downgrading and escape schema names in migration SQL Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(efcore-oracle): copy every row NULL-preservingly so retries reproduce the current source The filtered copy `WHERE "<column>" IS NOT NULL` skipped rows whose nullable source had since become NULL. If a prior conversion committed the copy and then failed before dropping the source column, an operator clearing a value before retrying would find the predicate skip that row, and the stale converted value would be renamed into place. Replace the filtered UPDATE with an unconditional, NULL- preserving CASE expression so a retry always reproduces the current source exactly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
105 lines
5.1 KiB
C#
105 lines
5.1 KiB
C#
using Elsa.Persistence.EFCore;
|
|
using Elsa.Persistence.EFCore.Extensions;
|
|
using Elsa.Persistence.EFCore.Modules.Runtime;
|
|
using Microsoft.EntityFrameworkCore.Migrations;
|
|
|
|
namespace Elsa.Persistence.EFCore.UnitTests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
// 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 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 MigrationScriptGenerator.Generate<RuntimeElsaDbContext>(
|
|
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 MigrationScriptGenerator.Generate<RuntimeElsaDbContext>(
|
|
builder => builder.UseElsaSqlite(migrationsAssembly, "Data Source=:memory:"),
|
|
fromMigration: "20250530104854_V3_5",
|
|
toMigration: "20251204150006_V3_6",
|
|
options);
|
|
}
|
|
|
|
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 <statement> 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}");
|
|
}
|
|
}
|