* 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>
287 lines
17 KiB
C#
287 lines
17 KiB
C#
using Elsa.Persistence.EFCore.Extensions;
|
|
using Elsa.Persistence.EFCore.Modules.Management;
|
|
using Elsa.Persistence.EFCore.Modules.Runtime;
|
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore.Migrations;
|
|
|
|
namespace Elsa.Persistence.EFCore.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Regression tests for the Oracle V3_6 migrations. Oracle refuses to change a column's datatype to or from a LOB type
|
|
/// in place: <c>ALTER TABLE ... MODIFY</c> fails with ORA-22858 when the target type is a LOB and with ORA-22859 when
|
|
/// the source type is. Both V3_6 migrations used to be generated as such an in-place alteration - NCLOB to JSON for
|
|
/// <c>WorkflowDefinitions.StringData</c>, NVARCHAR2(450) to NCLOB for <c>ActivityNodeId</c> - so neither could ever
|
|
/// apply. They must instead add a new column, copy the values across, drop the original and rename the new one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The scripts are generated offline through <see cref="IMigrator.GenerateScript"/>; no Oracle connection is opened.
|
|
/// </remarks>
|
|
public class V3_6OracleMigrationTests
|
|
{
|
|
private const string ManagementV3_4 = "20250222190910_V3_4";
|
|
private const string ManagementV3_6 = "20251116182825_V3_6";
|
|
private const string RuntimeV3_5 = "20250530105102_V3_5";
|
|
private const string RuntimeV3_6 = "20251204150355_V3_6";
|
|
|
|
[Theory]
|
|
[InlineData("Elsa", MigrationsSqlGenerationOptions.Default)]
|
|
[InlineData("Elsa", MigrationsSqlGenerationOptions.Idempotent)]
|
|
[InlineData("custom_schema", MigrationsSqlGenerationOptions.Default)]
|
|
public void ManagementUp_ConvertsStringDataToJsonWithoutInPlaceAlter(string schema, MigrationsSqlGenerationOptions options)
|
|
{
|
|
var script = GenerateManagementScript(ManagementV3_4, ManagementV3_6, schema, options);
|
|
|
|
AssertColumnConverted(script, schema, "WorkflowDefinitions", "StringData", "JSON", "JSON(TO_CLOB(\"StringData\"))", notNull: false);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Elsa", MigrationsSqlGenerationOptions.Default)]
|
|
[InlineData("custom_schema", MigrationsSqlGenerationOptions.Default)]
|
|
public void ManagementDown_ConvertsStringDataBackToNclobWithoutInPlaceAlter(string schema, MigrationsSqlGenerationOptions options)
|
|
{
|
|
var script = GenerateManagementScript(ManagementV3_6, ManagementV3_4, schema, options);
|
|
|
|
AssertColumnConverted(script, schema, "WorkflowDefinitions", "StringData", "NCLOB", "TO_NCLOB(JSON_SERIALIZE(\"StringData\" RETURNING CLOB))", notNull: false);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Elsa", MigrationsSqlGenerationOptions.Default)]
|
|
[InlineData("Elsa", MigrationsSqlGenerationOptions.Idempotent)]
|
|
[InlineData("custom_schema", MigrationsSqlGenerationOptions.Default)]
|
|
public void RuntimeUp_ConvertsActivityNodeIdToNclobWithoutInPlaceAlter(string schema, MigrationsSqlGenerationOptions options)
|
|
{
|
|
var script = GenerateRuntimeScript(RuntimeV3_5, RuntimeV3_6, schema, options);
|
|
|
|
foreach (var table in new[] { "WorkflowExecutionLogRecords", "ActivityExecutionRecords" })
|
|
{
|
|
AssertColumnConverted(script, schema, table, "ActivityNodeId", "NCLOB", "TO_NCLOB(\"ActivityNodeId\")", notNull: true);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Elsa", MigrationsSqlGenerationOptions.Default)]
|
|
[InlineData("custom_schema", MigrationsSqlGenerationOptions.Default)]
|
|
public void RuntimeDown_ConvertsActivityNodeIdBackToNvarchar2WithoutInPlaceAlter(string schema, MigrationsSqlGenerationOptions options)
|
|
{
|
|
var script = GenerateRuntimeScript(RuntimeV3_6, RuntimeV3_5, schema, options);
|
|
|
|
foreach (var table in new[] { "WorkflowExecutionLogRecords", "ActivityExecutionRecords" })
|
|
{
|
|
AssertColumnConverted(script, schema, table, "ActivityNodeId", "NVARCHAR2(450)", "DBMS_LOB.SUBSTR(\"ActivityNodeId\", 450, 1)", notNull: true);
|
|
}
|
|
}
|
|
|
|
// Oracle commits DDL implicitly, so the OriginalSource column the migration adds right before the alteration that
|
|
// used to fail stays behind. Re-running has to skip it instead of failing with ORA-01430.
|
|
[Theory]
|
|
[InlineData("Elsa")]
|
|
[InlineData("custom_schema")]
|
|
public void ManagementUp_AddsOriginalSourceOnlyWhenItIsMissing(string schema)
|
|
{
|
|
var script = GenerateManagementScript(ManagementV3_4, ManagementV3_6, schema, MigrationsSqlGenerationOptions.Default);
|
|
|
|
AssertColumnLookup(script, schema, "WorkflowDefinitions", "COLUMN_NAME = 'OriginalSource'");
|
|
AssertStatementAt(script, $"ALTER TABLE {Qualify(schema, "WorkflowDefinitions")} ADD (\"OriginalSource\" NCLOB)");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Elsa")]
|
|
[InlineData("custom_schema")]
|
|
public void ManagementDown_DropsOriginalSourceOnlyWhenItIsPresent(string schema)
|
|
{
|
|
var script = GenerateManagementScript(ManagementV3_6, ManagementV3_4, schema, MigrationsSqlGenerationOptions.Default);
|
|
|
|
AssertColumnLookup(script, schema, "WorkflowDefinitions", "COLUMN_NAME = 'OriginalSource'");
|
|
AssertStatementAt(script, $"ALTER TABLE {Qualify(schema, "WorkflowDefinitions")} DROP COLUMN \"OriginalSource\"");
|
|
}
|
|
|
|
// The unique trigger index is created before the alteration that used to fail, so a re-run would find it already
|
|
// there. A left-behind index is guarded against by an ALL_INDEXES lookup rather than by tolerating ORA-00955,
|
|
// because swallowing that error would also mask a different object already holding the name. The dropped
|
|
// indexes are guarded by tolerating ORA-01418 instead, since that error is specific to a missing index.
|
|
[Theory]
|
|
[InlineData("Elsa")]
|
|
[InlineData("custom_schema")]
|
|
public void RuntimeUp_ToleratesIndexesLeftBehindByAPartiallyAppliedRun(string schema)
|
|
{
|
|
var script = GenerateRuntimeScript(RuntimeV3_5, RuntimeV3_6, schema, MigrationsSqlGenerationOptions.Default);
|
|
|
|
AssertIndexCreationGuardedByExistenceCheck(script, schema, "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId", "Triggers", new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" }, unique: true);
|
|
AssertToleratesOracleError(script, $"DROP INDEX \"{schema}\".\"IX_WorkflowExecutionLogRecord_ActivityNodeId\"", -1418);
|
|
AssertToleratesOracleError(script, $"DROP INDEX \"{schema}\".\"IX_ActivityExecutionRecord_ActivityNodeId\"", -1418);
|
|
}
|
|
|
|
// A valid quoted Oracle schema can contain an apostrophe (for example "O'Brien"). OWNER = '...' comparisons must
|
|
// double it to stay inside their single-quoted literal, and "..." identifiers embedded inside an EXECUTE
|
|
// IMMEDIATE body - itself a single-quoted literal - must have their apostrophe doubled a second time.
|
|
[Fact]
|
|
public void RuntimeUp_EscapesSchemaNameContainingAnApostrophe()
|
|
{
|
|
const string schema = "O'Brien";
|
|
|
|
var script = GenerateRuntimeScript(RuntimeV3_5, RuntimeV3_6, schema, MigrationsSqlGenerationOptions.Default);
|
|
|
|
Assert.Contains("OWNER = 'O''Brien'", script, StringComparison.Ordinal);
|
|
Assert.Contains("ON \"O''Brien\".\"Triggers\"", script, StringComparison.Ordinal);
|
|
|
|
// The invalid, unescaped form must never appear.
|
|
Assert.DoesNotContain("'O'Brien'", script, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Elsa")]
|
|
[InlineData("custom_schema")]
|
|
public void RuntimeDown_ToleratesIndexesLeftBehindByAPartiallyAppliedRun(string schema)
|
|
{
|
|
var script = GenerateRuntimeScript(RuntimeV3_6, RuntimeV3_5, schema, MigrationsSqlGenerationOptions.Default);
|
|
|
|
AssertToleratesOracleError(script, $"DROP INDEX \"{schema}\".\"IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId\"", -1418);
|
|
AssertIndexCreationGuardedByExistenceCheck(script, schema, "IX_WorkflowExecutionLogRecord_ActivityNodeId", "WorkflowExecutionLogRecords", new[] { "ActivityNodeId" }, unique: false);
|
|
AssertIndexCreationGuardedByExistenceCheck(script, schema, "IX_ActivityExecutionRecord_ActivityNodeId", "ActivityExecutionRecords", new[] { "ActivityNodeId" }, unique: false);
|
|
}
|
|
|
|
// The upgraded NCLOB column permits values longer than the NVARCHAR2(450) the downgrade converts back to.
|
|
// Copying such a value would silently truncate it, so both tables are preflighted for oversized values -
|
|
// while their columns are still LOBs - before any statement of the downgrade runs, and the downgrade is
|
|
// refused before any data is copied or any DDL is committed.
|
|
[Theory]
|
|
[InlineData("Elsa")]
|
|
[InlineData("custom_schema")]
|
|
public void RuntimeDown_RefusesToTruncateOversizedActivityNodeIdBeforeConverting(string schema)
|
|
{
|
|
var script = GenerateRuntimeScript(RuntimeV3_6, RuntimeV3_5, schema, MigrationsSqlGenerationOptions.Default);
|
|
|
|
var firstDowngradeStatement = AssertStatementAt(script, $"DROP INDEX \"{schema}\".\"IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId\"");
|
|
|
|
foreach (var qualifiedTable in new[] { "WorkflowExecutionLogRecords", "ActivityExecutionRecords" }.Select(table => Qualify(schema, table)))
|
|
{
|
|
var lengthCheck = AssertStatementAt(script, $"SELECT COUNT(*) FROM {qualifiedTable} WHERE DBMS_LOB.GETLENGTH(\"ActivityNodeId\") > 450");
|
|
|
|
Assert.True(lengthCheck < firstDowngradeStatement, $"Expected the length check for \"ActivityNodeId\" on {qualifiedTable} to run before the first statement of the downgrade, but found the check at {lengthCheck} and the first downgrade statement at {firstDowngradeStatement}:\n{script}");
|
|
}
|
|
|
|
Assert.Contains("RAISE_APPLICATION_ERROR(-20004,", script, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the direction that would silently look fine: a re-run against an already-converted column must not copy
|
|
/// out of and then drop that column, so the conversion is skipped when the datatype is already the target one and
|
|
/// refused outright when it is neither the source nor the target type.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ConversionIsSkippedWhenAlreadyApplied()
|
|
{
|
|
var script = GenerateManagementScript(ManagementV3_4, ManagementV3_6, "Elsa", MigrationsSqlGenerationOptions.Default);
|
|
|
|
Assert.Contains("IF l_temp_type IS NULL AND l_source_type = 'JSON' THEN", script, StringComparison.Ordinal);
|
|
Assert.Contains("l_already_converted := TRUE;", script, StringComparison.Ordinal);
|
|
Assert.Contains("IF l_source_type IS NOT NULL AND l_source_type != 'NCLOB' THEN", script, StringComparison.Ordinal);
|
|
Assert.Contains("RAISE_APPLICATION_ERROR(-20002,", script, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static void AssertColumnConverted(string script, string schema, string table, string column, string toColumnDefinition, string expectedCopyExpression, bool notNull)
|
|
{
|
|
var qualifiedTable = Qualify(schema, table);
|
|
var tempColumn = $"{column}_New";
|
|
|
|
// This is the statement Oracle rejects with ORA-22858 / ORA-22859 and the reason the migration could not apply.
|
|
Assert.DoesNotContain($"MODIFY \"{column}\"", script, StringComparison.Ordinal);
|
|
|
|
var add = AssertStatementAt(script, $"ALTER TABLE {qualifiedTable} ADD (\"{tempColumn}\" {toColumnDefinition})");
|
|
|
|
// The copy is unconditional and NULL-preserving, so a retry after a partially committed copy reproduces the
|
|
// current source exactly instead of leaving a stale converted value behind for a row whose source has since
|
|
// become NULL.
|
|
var copyStatement = $"UPDATE {qualifiedTable} SET \"{tempColumn}\" = CASE WHEN \"{column}\" IS NULL THEN NULL ELSE {expectedCopyExpression} END";
|
|
var copy = AssertStatementAt(script, copyStatement);
|
|
Assert.DoesNotContain($"{copyStatement} WHERE", script, StringComparison.Ordinal);
|
|
|
|
var drop = AssertStatementAt(script, $"ALTER TABLE {qualifiedTable} DROP COLUMN \"{column}\"");
|
|
var rename = AssertStatementAt(script, $"ALTER TABLE {qualifiedTable} RENAME COLUMN \"{tempColumn}\" TO \"{column}\"");
|
|
|
|
Assert.True(add < copy && copy < drop && drop < rename, $"Expected the add/copy/drop/rename sequence for \"{column}\" on {qualifiedTable} in that order, but found them at {add}, {copy}, {drop} and {rename}:\n{script}");
|
|
|
|
// Both columns are looked up through the block's local function, which takes the column name as a parameter,
|
|
// so the guard that makes the block re-runnable reads COLUMN_NAME = p_column rather than a literal.
|
|
AssertColumnLookup(script, schema, table, "COLUMN_NAME = p_column");
|
|
|
|
var setNotNull = $"ALTER TABLE {qualifiedTable} MODIFY (\"{column}\" NOT NULL)";
|
|
|
|
if (!notNull)
|
|
{
|
|
Assert.DoesNotContain(setNotNull, script, StringComparison.Ordinal);
|
|
return;
|
|
}
|
|
|
|
// The converted column starts out nullable because the copy needs it to; the constraint therefore has to land
|
|
// after the rename, and only when the column is not already constrained.
|
|
var constrain = AssertStatementAt(script, setNotNull);
|
|
Assert.True(rename < constrain, $"Expected \"{column}\" on {qualifiedTable} to be constrained to NOT NULL after the rename, but the constraint appears at {constrain} and the rename at {rename}:\n{script}");
|
|
Assert.Contains("IF l_nullable = 'Y' THEN", script, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static void AssertColumnLookup(string script, string schema, string table, string columnPredicate)
|
|
{
|
|
Assert.Contains($"FROM ALL_TAB_COLUMNS WHERE OWNER = '{schema}' AND TABLE_NAME = '{table}' AND {columnPredicate}", script, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static void AssertToleratesOracleError(string script, string statementPrefix, int sqlCode)
|
|
{
|
|
var statement = AssertStatementAt(script, statementPrefix);
|
|
var guard = script.IndexOf($"IF SQLCODE != {sqlCode} THEN RAISE; END IF;", statement, StringComparison.Ordinal);
|
|
|
|
Assert.True(guard >= 0, $"Expected '{statementPrefix}' to be followed by a handler that swallows ORA{sqlCode}:\n{script}");
|
|
}
|
|
|
|
// Index creation is guarded by an ALL_INDEXES lookup rather than by tolerating ORA-00955, because swallowing
|
|
// that error would also mask a different object already holding the name. When an index with that name already
|
|
// exists, its table, uniqueness and ordered column list must also be validated against ALL_IND_COLUMNS via
|
|
// LISTAGG, so that a same-named index left behind by schema drift or manual recovery is not mistaken for a
|
|
// completed run.
|
|
private static void AssertIndexCreationGuardedByExistenceCheck(string script, string schema, string indexName, string table, string[] columns, bool unique)
|
|
{
|
|
Assert.Contains($"SELECT COUNT(*) INTO l_count FROM ALL_INDEXES WHERE OWNER = '{schema}' AND INDEX_NAME = '{indexName}'", script, StringComparison.Ordinal);
|
|
|
|
var lookup = AssertStatementAt(script, $"FROM ALL_INDEXES WHERE OWNER = '{schema}' AND INDEX_NAME = '{indexName}'");
|
|
var guard = script.IndexOf("IF l_count = 0 THEN", lookup, StringComparison.Ordinal);
|
|
|
|
Assert.True(guard >= 0, $"Expected the lookup for \"{indexName}\" to be followed by an 'IF l_count = 0 THEN' guard:\n{script}");
|
|
|
|
var createStatementPrefix = $"CREATE {(unique ? "UNIQUE " : "")}INDEX \"{schema}\".\"{indexName}\"";
|
|
var create = AssertStatementAt(script, createStatementPrefix);
|
|
|
|
Assert.True(create > guard, $"Expected '{createStatementPrefix}' to appear inside the 'IF l_count = 0 THEN' guard for \"{indexName}\":\n{script}");
|
|
|
|
var expectedColumns = string.Join(",", columns);
|
|
var expectedUniqueness = unique ? "UNIQUE" : "NONUNIQUE";
|
|
|
|
Assert.Contains($"SELECT LISTAGG(COLUMN_NAME, ',') WITHIN GROUP (ORDER BY COLUMN_POSITION) INTO l_columns FROM ALL_IND_COLUMNS WHERE INDEX_OWNER = '{schema}' AND INDEX_NAME = '{indexName}'", script, StringComparison.Ordinal);
|
|
Assert.Contains($"l_table_name != '{table}' OR l_uniqueness != '{expectedUniqueness}' OR l_columns != '{expectedColumns}'", script, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static int AssertStatementAt(string script, string statement)
|
|
{
|
|
var index = script.IndexOf(statement, StringComparison.Ordinal);
|
|
|
|
Assert.True(index >= 0, $"Expected the generated script to contain '{statement}':\n{script}");
|
|
|
|
return index;
|
|
}
|
|
|
|
private static string Qualify(string schema, string table) => $"\"{schema}\".\"{table}\"";
|
|
|
|
private static string GenerateManagementScript(string fromMigration, string toMigration, string schema, MigrationsSqlGenerationOptions options) =>
|
|
MigrationScriptGenerator.Generate<ManagementElsaDbContext>(
|
|
builder => builder.UseElsaOracle(typeof(Elsa.Persistence.EFCore.Oracle.Migrations.Management.V3_6).Assembly, "Data Source=unused", new ElsaDbContextOptions { SchemaName = schema }),
|
|
fromMigration,
|
|
toMigration,
|
|
options);
|
|
|
|
private static string GenerateRuntimeScript(string fromMigration, string toMigration, string schema, MigrationsSqlGenerationOptions options) =>
|
|
MigrationScriptGenerator.Generate<RuntimeElsaDbContext>(
|
|
builder => builder.UseElsaOracle(typeof(Elsa.Persistence.EFCore.Oracle.Migrations.Runtime.V3_6).Assembly, "Data Source=unused", new ElsaDbContextOptions { SchemaName = schema }),
|
|
fromMigration,
|
|
toMigration,
|
|
options);
|
|
}
|