diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/MigrationHelper.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/MigrationHelper.cs
new file mode 100644
index 000000000..4d9d4566b
--- /dev/null
+++ b/src/modules/Elsa.Persistence.EFCore.Oracle/MigrationHelper.cs
@@ -0,0 +1,361 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Elsa.Persistence.EFCore.Oracle;
+
+///
+/// Emits Oracle-specific migration statements that work around DDL restrictions Oracle imposes and that survive being
+/// re-run after a partially applied attempt.
+///
+///
+/// Oracle commits implicitly before and after every DDL statement, so a migration that fails halfway leaves the
+/// statements it already executed committed - EF Core's migration transaction cannot roll them back. Every operation
+/// emitted by this helper therefore first inspects ALL_TAB_COLUMNS, or traps the Oracle error code that means
+/// "this was already done", and skips the work it finds already applied. Re-running a failed migration then converges
+/// on the intended schema instead of failing with, for example, ORA-01430 ("column being added already exists").
+///
+internal static class MigrationHelper
+{
+ ///
+ /// The suffix of the temporary column builds the converted values in. The same
+ /// suffix is used in both directions on purpose: a temporary column left behind by a failed run is then always
+ /// noticed by the next run instead of being orphaned under a direction-specific name.
+ ///
+ private const string TempColumnSuffix = "_New";
+
+ ///
+ /// Changes the datatype of by adding a temporary column of the target type, copying the
+ /// values across, dropping the original column and renaming the temporary one.
+ ///
+ ///
+ ///
+ /// Oracle refuses to change a column's datatype to or from a LOB type in place: ALTER TABLE ... MODIFY
+ /// fails with ORA-22858 ("invalid alteration of datatype") when the target type is a LOB, and with ORA-22859
+ /// ("invalid modification of columns") when the source type is. The add/copy/drop/rename sequence emitted here is
+ /// the workaround the ORA-22858 message itself prescribes.
+ ///
+ ///
+ /// The emitted PL/SQL block is re-runnable. It derives what still needs doing from the datatypes currently
+ /// recorded in ALL_TAB_COLUMNS for the original and the temporary column, so it converges from every state
+ /// a previously failed run can leave behind: nothing done yet, the temporary column added, the values copied, or
+ /// the original column already dropped. When the conversion has already completed it does nothing at all, which is
+ /// what lets an operator who applied the conversion by hand replay the migration. When it finds a datatype it does
+ /// not recognize it raises, rather than risk converting an already-converted column and losing the data.
+ ///
+ ///
+ /// The copy itself is unconditional and NULL-preserving: it runs for every row, not only the ones with a non-NULL
+ /// source, and writes NULL into the temporary column whenever the source is NULL. That way a retry
+ /// after a copy that already committed - but then failed before the original column was dropped - reproduces the
+ /// current source exactly instead of leaving a stale converted value behind for a row whose source has since been
+ /// cleared.
+ ///
+ ///
+ /// The migration builder to emit into.
+ /// The Elsa schema the table lives in.
+ /// The unquoted table name.
+ /// The unquoted column name.
+ /// The current datatype, as ALL_TAB_COLUMNS.DATA_TYPE reports it (for example NCLOB or NVARCHAR2).
+ /// The target datatype, as ALL_TAB_COLUMNS.DATA_TYPE reports it. This is what marks the conversion as already done, so it has to be the dictionary spelling and not the DDL spelling.
+ /// The target datatype as written in DDL, including any length (for example NVARCHAR2(450)).
+ /// Builds the SQL expression that converts a value; it is handed the quoted name of the original column.
+ /// Whether the converted column must carry a NOT NULL constraint. The constraint is applied by a separate, equally re-runnable statement once the conversion has landed.
+ public static void ConvertColumnType(
+ MigrationBuilder migrationBuilder,
+ IElsaDbContextSchema schema,
+ string table,
+ string column,
+ string fromDataType,
+ string toDataType,
+ string toColumnDefinition,
+ Func copyExpression,
+ bool notNull)
+ {
+ var qualifiedTable = QualifyTable(schema, table);
+ var tempColumn = $"{column}{TempColumnSuffix}";
+ var quotedColumn = Identifier(column);
+ var quotedTempColumn = Identifier(tempColumn);
+ var copy = copyExpression(quotedColumn);
+
+ // The DDL/DML text and the messages below are built first and only escaped once, at the point where they are
+ // embedded inside the enclosing PL/SQL string literal (an EXECUTE IMMEDIATE body, or a RAISE_APPLICATION_ERROR
+ // message). That keeps a schema, table or column name containing an apostrophe - for example a quoted Oracle
+ // schema like "O'Brien" - from producing invalid SQL, the same way SqlIgnoringOracleError already escapes the
+ // statement it wraps.
+ var addTempColumn = $"ALTER TABLE {qualifiedTable} ADD ({quotedTempColumn} {toColumnDefinition})".Replace("'", "''");
+ var copyValues = $"UPDATE {qualifiedTable} SET {quotedTempColumn} = CASE WHEN {quotedColumn} IS NULL THEN NULL ELSE {copy} END".Replace("'", "''");
+ var dropOriginal = $"ALTER TABLE {qualifiedTable} DROP COLUMN {quotedColumn}".Replace("'", "''");
+ var renameTemp = $"ALTER TABLE {qualifiedTable} RENAME COLUMN {quotedTempColumn} TO {quotedColumn}".Replace("'", "''");
+ var missingBothMessage = $"Neither {quotedColumn} nor {quotedTempColumn} exists on {qualifiedTable}.".Replace("'", "''");
+ var unexpectedSourceMessage = $"Expected {quotedColumn} on {qualifiedTable} to be {fromDataType} or {toDataType}, but found ".Replace("'", "''");
+ var unexpectedTempMessage = $"Expected the leftover column {quotedTempColumn} on {qualifiedTable} to be {toDataType}, but found ".Replace("'", "''");
+
+ migrationBuilder.Sql($"""
+ DECLARE
+ l_source_type ALL_TAB_COLUMNS.DATA_TYPE%TYPE;
+ l_temp_type ALL_TAB_COLUMNS.DATA_TYPE%TYPE;
+ l_already_converted BOOLEAN := FALSE;
+
+ FUNCTION data_type_of(p_column IN VARCHAR2) RETURN VARCHAR2 IS
+ l_data_type ALL_TAB_COLUMNS.DATA_TYPE%TYPE;
+ BEGIN
+ SELECT DATA_TYPE INTO l_data_type FROM ALL_TAB_COLUMNS WHERE OWNER = {Literal(schema.Schema)} AND TABLE_NAME = {Literal(table)} AND COLUMN_NAME = p_column;
+ RETURN l_data_type;
+ EXCEPTION
+ WHEN NO_DATA_FOUND THEN
+ RETURN NULL;
+ END;
+ BEGIN
+ l_source_type := data_type_of({Literal(column)});
+ l_temp_type := data_type_of({Literal(tempColumn)});
+
+ -- The conversion already completed, either on an earlier run of this migration or by hand.
+ IF l_temp_type IS NULL AND l_source_type = '{toDataType}' THEN
+ l_already_converted := TRUE;
+ END IF;
+
+ IF NOT l_already_converted THEN
+ IF l_source_type IS NULL AND l_temp_type IS NULL THEN
+ RAISE_APPLICATION_ERROR(-20001, '{missingBothMessage}');
+ END IF;
+
+ -- Refuse to convert a column whose datatype is not one this migration knows about: converting
+ -- blindly would copy out of, and then drop, a column that may already hold the converted data.
+ IF l_source_type IS NOT NULL AND l_source_type != '{fromDataType}' THEN
+ RAISE_APPLICATION_ERROR(-20002, '{unexpectedSourceMessage}' || l_source_type || '.');
+ END IF;
+
+ IF l_temp_type IS NOT NULL AND l_temp_type != '{toDataType}' THEN
+ RAISE_APPLICATION_ERROR(-20003, '{unexpectedTempMessage}' || l_temp_type || '.');
+ END IF;
+
+ IF l_temp_type IS NULL THEN
+ EXECUTE IMMEDIATE '{addTempColumn}';
+ END IF;
+
+ IF l_source_type IS NOT NULL THEN
+ EXECUTE IMMEDIATE '{copyValues}';
+ EXECUTE IMMEDIATE '{dropOriginal}';
+ END IF;
+
+ EXECUTE IMMEDIATE '{renameTemp}';
+ END IF;
+ END;
+ """);
+
+ if (notNull)
+ {
+ SetColumnNotNull(migrationBuilder, schema, table, column);
+ }
+ }
+
+ ///
+ /// Raises an error if any value stored in is longer than
+ /// characters, so that converting a LOB column down to a bounded
+ /// NVARCHAR2 fails before it copies - and so silently truncates - a value that does not fit.
+ ///
+ ///
+ /// The check only runs while is still recorded as NCLOB or CLOB in
+ /// ALL_TAB_COLUMNS: once the conversion has landed the datatype itself already enforces the length, and
+ /// the length query itself is issued through EXECUTE IMMEDIATE so the block takes no compile-time
+ /// dependency on the column's current datatype, keeping the migration re-runnable regardless of which datatype
+ /// the column has when the block is parsed. That also makes a re-run after the conversion has completed a
+ /// no-op, consistent with every other helper in this class.
+ ///
+ /// The migration builder to emit into.
+ /// The Elsa schema the table lives in.
+ /// The unquoted table name.
+ /// The unquoted column name.
+ /// The maximum number of characters the target NVARCHAR2 column can hold.
+ public static void EnsureLobLengthAtMost(MigrationBuilder migrationBuilder, IElsaDbContextSchema schema, string table, string column, int maxLength)
+ {
+ var qualifiedTable = QualifyTable(schema, table);
+ var quotedColumn = Identifier(column);
+ var lengthQuery = $"SELECT COUNT(*) FROM {qualifiedTable} WHERE DBMS_LOB.GETLENGTH({quotedColumn}) > {maxLength}".Replace("'", "''");
+ var tooLongMessage = $" row(s) in {qualifiedTable} have {quotedColumn} values longer than {maxLength} characters; they cannot be stored in NVARCHAR2({maxLength}). Shorten or remove them before downgrading.".Replace("'", "''");
+
+ migrationBuilder.Sql($"""
+ DECLARE
+ l_data_type ALL_TAB_COLUMNS.DATA_TYPE%TYPE;
+ l_count INTEGER;
+ BEGIN
+ SELECT DATA_TYPE INTO l_data_type FROM ALL_TAB_COLUMNS WHERE OWNER = {Literal(schema.Schema)} AND TABLE_NAME = {Literal(table)} AND COLUMN_NAME = {Literal(column)};
+
+ IF l_data_type IN ('NCLOB', 'CLOB') THEN
+ EXECUTE IMMEDIATE '{lengthQuery}' INTO l_count;
+
+ IF l_count > 0 THEN
+ RAISE_APPLICATION_ERROR(-20004, l_count || '{tooLongMessage}');
+ END IF;
+ END IF;
+ EXCEPTION
+ WHEN NO_DATA_FOUND THEN
+ NULL;
+ END;
+ """);
+ }
+
+ ///
+ /// Adds a column unless it is already there, so that a run following a partially applied one does not fail with
+ /// ORA-01430 ("column being added already exists in table").
+ ///
+ /// The migration builder to emit into.
+ /// The Elsa schema the table lives in.
+ /// The unquoted table name.
+ /// The unquoted column name.
+ /// The column definition as written in DDL, for example NCLOB.
+ public static void AddColumnIfMissing(MigrationBuilder migrationBuilder, IElsaDbContextSchema schema, string table, string column, string columnDefinition)
+ {
+ var qualifiedTable = QualifyTable(schema, table);
+ var quotedColumn = Identifier(column);
+ var addColumn = $"ALTER TABLE {qualifiedTable} ADD ({quotedColumn} {columnDefinition})".Replace("'", "''");
+
+ migrationBuilder.Sql($"""
+ DECLARE
+ l_count INTEGER;
+ BEGIN
+ SELECT COUNT(*) INTO l_count FROM ALL_TAB_COLUMNS WHERE OWNER = {Literal(schema.Schema)} AND TABLE_NAME = {Literal(table)} AND COLUMN_NAME = {Literal(column)};
+
+ IF l_count = 0 THEN
+ EXECUTE IMMEDIATE '{addColumn}';
+ END IF;
+ END;
+ """);
+ }
+
+ ///
+ /// Drops a column if it is there, so that a run following a partially applied one does not fail with ORA-00904
+ /// ("invalid identifier").
+ ///
+ /// The migration builder to emit into.
+ /// The Elsa schema the table lives in.
+ /// The unquoted table name.
+ /// The unquoted column name.
+ public static void DropColumnIfPresent(MigrationBuilder migrationBuilder, IElsaDbContextSchema schema, string table, string column)
+ {
+ var qualifiedTable = QualifyTable(schema, table);
+ var quotedColumn = Identifier(column);
+ var dropColumn = $"ALTER TABLE {qualifiedTable} DROP COLUMN {quotedColumn}".Replace("'", "''");
+
+ migrationBuilder.Sql($"""
+ DECLARE
+ l_count INTEGER;
+ BEGIN
+ SELECT COUNT(*) INTO l_count FROM ALL_TAB_COLUMNS WHERE OWNER = {Literal(schema.Schema)} AND TABLE_NAME = {Literal(table)} AND COLUMN_NAME = {Literal(column)};
+
+ IF l_count > 0 THEN
+ EXECUTE IMMEDIATE '{dropColumn}';
+ END IF;
+ END;
+ """);
+ }
+
+ ///
+ /// Creates an index unless it is already there, so that a run following a partially applied one does not fail with
+ /// ORA-00955 ("name is already used by an existing object"). When an index with that name already exists, its
+ /// table, uniqueness and ordered column list are validated against what is being requested, so that an index
+ /// schema drift or manual recovery left behind under the same name is not mistaken for the one this migration
+ /// means to create.
+ ///
+ /// The migration builder to emit into.
+ /// The Elsa schema the table lives in.
+ /// The unquoted index name.
+ /// The unquoted table name.
+ /// The unquoted column names to index.
+ /// Whether the index is unique.
+ public static void CreateIndexIfMissing(MigrationBuilder migrationBuilder, IElsaDbContextSchema schema, string name, string table, string[] columns, bool unique = false)
+ {
+ var columnList = string.Join(", ", columns.Select(Identifier));
+ var quotedIndexName = $"{Identifier(schema.Schema)}.{Identifier(name)}";
+ var createIndex = $"CREATE {(unique ? "UNIQUE " : "")}INDEX {quotedIndexName} ON {QualifyTable(schema, table)} ({columnList})".Replace("'", "''");
+ var expectedUniqueness = unique ? "UNIQUE" : "NONUNIQUE";
+ var expectedColumns = string.Join(",", columns);
+ var mismatchMessage = $"Index {quotedIndexName} already exists but does not match the expected definition (table {table}, {expectedUniqueness}, columns {expectedColumns}); found table ".Replace("'", "''");
+
+ migrationBuilder.Sql($"""
+ DECLARE
+ l_count INTEGER;
+ l_table_name ALL_INDEXES.TABLE_NAME%TYPE;
+ l_uniqueness ALL_INDEXES.UNIQUENESS%TYPE;
+ l_columns VARCHAR2(4000);
+ BEGIN
+ SELECT COUNT(*) INTO l_count FROM ALL_INDEXES WHERE OWNER = {Literal(schema.Schema)} AND INDEX_NAME = {Literal(name)};
+
+ IF l_count = 0 THEN
+ EXECUTE IMMEDIATE '{createIndex}';
+ ELSE
+ SELECT TABLE_NAME, UNIQUENESS INTO l_table_name, l_uniqueness FROM ALL_INDEXES WHERE OWNER = {Literal(schema.Schema)} AND INDEX_NAME = {Literal(name)};
+ SELECT LISTAGG(COLUMN_NAME, ',') WITHIN GROUP (ORDER BY COLUMN_POSITION) INTO l_columns FROM ALL_IND_COLUMNS WHERE INDEX_OWNER = {Literal(schema.Schema)} AND INDEX_NAME = {Literal(name)};
+
+ IF l_table_name != {Literal(table)} OR l_uniqueness != '{expectedUniqueness}' OR l_columns != {Literal(expectedColumns)} THEN
+ RAISE_APPLICATION_ERROR(-20005, '{mismatchMessage}' || l_table_name || ', ' || l_uniqueness || ', columns ' || l_columns || '. Drop or fix the existing index before running this migration.');
+ END IF;
+ END IF;
+ END;
+ """);
+ }
+
+ ///
+ /// Drops an index if it is there, so that a run following a partially applied one does not fail with ORA-01418
+ /// ("specified index does not exist").
+ ///
+ /// The migration builder to emit into.
+ /// The Elsa schema the index lives in.
+ /// The unquoted index name.
+ public static void DropIndexIfPresent(MigrationBuilder migrationBuilder, IElsaDbContextSchema schema, string name)
+ {
+ SqlIgnoringOracleError(migrationBuilder, $"DROP INDEX {Identifier(schema.Schema)}.{Identifier(name)}", -1418, "ORA-01418: the index is already gone, so an earlier run got at least this far.");
+ }
+
+ private static string QualifyTable(IElsaDbContextSchema schema, string table) => $"{Identifier(schema.Schema)}.{Identifier(table)}";
+
+ ///
+ /// Renders as a single-quoted PL/SQL string literal, doubling any apostrophe it contains
+ /// so that a schema, table or column name holding one - for example a quoted Oracle schema like O'Brien -
+ /// cannot break out of the literal and produce invalid, or worse injectable, SQL.
+ ///
+ private static string Literal(string value) => "'" + value.Replace("'", "''") + "'";
+
+ ///
+ /// Renders as a double-quoted Oracle identifier, doubling any embedded double quote so
+ /// that the identifier delimiters cannot be broken out of.
+ ///
+ private static string Identifier(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
+
+ ///
+ /// Constrains a column to NOT NULL unless it already is, so that re-running does not fail with ORA-01442 ("column
+ /// to be modified to NOT NULL is already NOT NULL"). Adding the constraint is not a datatype alteration, so unlike
+ /// the conversion itself Oracle does allow it in place - including on a LOB column.
+ ///
+ private static void SetColumnNotNull(MigrationBuilder migrationBuilder, IElsaDbContextSchema schema, string table, string column)
+ {
+ var qualifiedTable = QualifyTable(schema, table);
+ var quotedColumn = Identifier(column);
+ var setNotNull = $"ALTER TABLE {qualifiedTable} MODIFY ({quotedColumn} NOT NULL)".Replace("'", "''");
+
+ migrationBuilder.Sql($"""
+ DECLARE
+ l_nullable ALL_TAB_COLUMNS.NULLABLE%TYPE;
+ BEGIN
+ SELECT NULLABLE INTO l_nullable FROM ALL_TAB_COLUMNS WHERE OWNER = {Literal(schema.Schema)} AND TABLE_NAME = {Literal(table)} AND COLUMN_NAME = {Literal(column)};
+
+ IF l_nullable = 'Y' THEN
+ EXECUTE IMMEDIATE '{setNotNull}';
+ END IF;
+ END;
+ """);
+ }
+
+ private static void SqlIgnoringOracleError(MigrationBuilder migrationBuilder, string sql, int sqlCode, string comment)
+ {
+ var literal = sql.Replace("'", "''");
+
+ migrationBuilder.Sql($"""
+ BEGIN
+ EXECUTE IMMEDIATE '{literal}';
+ EXCEPTION
+ WHEN OTHERS THEN
+ -- {comment}
+ IF SQLCODE != {sqlCode} THEN RAISE; END IF;
+ END;
+ """);
+ }
+}
diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs
index 11bbc4357..23b9ec4f7 100644
--- a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs
+++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs
@@ -5,6 +5,13 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management
{
///
+ ///
+ /// Oracle rejects an in-place ALTER TABLE ... MODIFY that changes a column's datatype to or from a LOB type
+ /// (ORA-22858 / ORA-22859), so StringData is converted between NCLOB and JSON with the add/copy/drop/rename
+ /// sequence emits. Because Oracle commits DDL implicitly, a run
+ /// that fails partway leaves its earlier statements applied; every step here is therefore guarded so that a re-run
+ /// picks up where the failed one stopped instead of failing on an already-added column.
+ ///
public partial class V3_6 : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
@@ -18,41 +25,35 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management
///
protected override void Up(MigrationBuilder migrationBuilder)
{
- migrationBuilder.AddColumn(
- name: "OriginalSource",
- schema: _schema.Schema,
- table: "WorkflowDefinitions",
- type: "NCLOB",
- nullable: true);
+ MigrationHelper.AddColumnIfMissing(migrationBuilder, _schema, "WorkflowDefinitions", "OriginalSource", "NCLOB");
- migrationBuilder.AlterColumn(
- name: "StringData",
- schema: _schema.Schema,
+ MigrationHelper.ConvertColumnType(
+ migrationBuilder,
+ _schema,
table: "WorkflowDefinitions",
- type: "JSON",
- nullable: true,
- oldClrType: typeof(string),
- oldType: "NCLOB",
- oldNullable: true);
+ column: "StringData",
+ fromDataType: "NCLOB",
+ toDataType: "JSON",
+ toColumnDefinition: "JSON",
+ copyExpression: source => $"JSON(TO_CLOB({source}))",
+ notNull: false);
}
///
protected override void Down(MigrationBuilder migrationBuilder)
{
- migrationBuilder.AlterColumn(
- name: "StringData",
- schema: _schema.Schema,
+ MigrationHelper.ConvertColumnType(
+ migrationBuilder,
+ _schema,
table: "WorkflowDefinitions",
- type: "NCLOB",
- nullable: true,
- oldClrType: typeof(string),
- oldType: "JSON",
- oldNullable: true);
+ column: "StringData",
+ fromDataType: "JSON",
+ toDataType: "NCLOB",
+ toColumnDefinition: "NCLOB",
+ copyExpression: source => $"TO_NCLOB(JSON_SERIALIZE({source} RETURNING CLOB))",
+ notNull: false);
- migrationBuilder.DropColumn(
- name: "OriginalSource",
- schema: _schema.Schema,
- table: "WorkflowDefinitions");
+ MigrationHelper.DropColumnIfPresent(migrationBuilder, _schema, "WorkflowDefinitions", "OriginalSource");
}
}
}
diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Runtime/20251204150355_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Runtime/20251204150355_V3_6.cs
index beaec32bc..781532015 100644
--- a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Runtime/20251204150355_V3_6.cs
+++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Runtime/20251204150355_V3_6.cs
@@ -5,6 +5,14 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Elsa.Persistence.EFCore.Oracle.Migrations.Runtime
{
///
+ ///
+ /// Oracle rejects an in-place ALTER TABLE ... MODIFY that changes a column's datatype to or from a LOB type
+ /// (ORA-22858 / ORA-22859), so ActivityNodeId is converted between NVARCHAR2(450) and NCLOB with the
+ /// add/copy/drop/rename sequence emits. Because Oracle commits DDL
+ /// implicitly, a run that fails partway leaves its earlier statements applied; every step here is therefore
+ /// guarded so that a re-run picks up where the failed one stopped instead of failing on an already-created index
+ /// or an already-added column.
+ ///
public partial class V3_6 : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
@@ -18,89 +26,75 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Runtime
///
protected override void Up(MigrationBuilder migrationBuilder)
{
- migrationBuilder.CreateIndex(
+ // The original CreateIndex call passed filter: "\"Hash\" IS NOT NULL", but the Oracle EF provider never
+ // emitted it - Oracle has no partial indexes - so this unfiltered statement reproduces what the provider
+ // was actually generating (verified against the pre-fix generated script).
+ MigrationHelper.CreateIndexIfMissing(
+ migrationBuilder,
+ _schema,
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
- schema: _schema.Schema,
table: "Triggers",
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
- unique: true,
- filter: "\"Hash\" IS NOT NULL");
+ unique: true);
- // ORA-01418: specified index does not exist
- migrationBuilder.Sql(@"
- BEGIN
- EXECUTE IMMEDIATE 'DROP INDEX ""IX_WorkflowExecutionLogRecord_ActivityNodeId""';
- EXCEPTION
- WHEN OTHERS THEN
- IF SQLCODE != -1418 THEN RAISE; END IF;
- END;
- ");
+ // Dropping the column below takes its indexes with it, but they are still dropped up front: that is what the
+ // migration originally intended, and it also clears an index left behind on a database where the column was
+ // already converted by hand.
+ MigrationHelper.DropIndexIfPresent(migrationBuilder, _schema, "IX_WorkflowExecutionLogRecord_ActivityNodeId");
+ MigrationHelper.DropIndexIfPresent(migrationBuilder, _schema, "IX_ActivityExecutionRecord_ActivityNodeId");
- migrationBuilder.Sql(@"
- BEGIN
- EXECUTE IMMEDIATE 'DROP INDEX ""IX_ActivityExecutionRecord_ActivityNodeId""';
- EXCEPTION
- WHEN OTHERS THEN
- IF SQLCODE != -1418 THEN RAISE; END IF;
- END;
- ");
-
- migrationBuilder.AlterColumn(
- name: "ActivityNodeId",
- schema: _schema.Schema,
- table: "WorkflowExecutionLogRecords",
- type: "NCLOB",
- nullable: false,
- oldClrType: typeof(string),
- oldType: "NVARCHAR2(450)");
-
- migrationBuilder.AlterColumn(
- name: "ActivityNodeId",
- schema: _schema.Schema,
- table: "ActivityExecutionRecords",
- type: "NCLOB",
- nullable: false,
- oldClrType: typeof(string),
- oldType: "NVARCHAR2(450)");
+ ConvertActivityNodeIdToNclob(migrationBuilder, "WorkflowExecutionLogRecords");
+ ConvertActivityNodeIdToNclob(migrationBuilder, "ActivityExecutionRecords");
}
///
protected override void Down(MigrationBuilder migrationBuilder)
{
- migrationBuilder.DropIndex(
- name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
- schema: _schema.Schema,
- table: "Triggers");
+ // 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 before any statement of the downgrade runs - including the trigger-index drop below - so that
+ // an oversized value in either table is caught before Oracle has committed any DDL.
+ MigrationHelper.EnsureLobLengthAtMost(migrationBuilder, _schema, "WorkflowExecutionLogRecords", "ActivityNodeId", 450);
+ MigrationHelper.EnsureLobLengthAtMost(migrationBuilder, _schema, "ActivityExecutionRecords", "ActivityNodeId", 450);
- migrationBuilder.AlterColumn(
- name: "ActivityNodeId",
- schema: _schema.Schema,
- table: "WorkflowExecutionLogRecords",
- type: "NVARCHAR2(450)",
- nullable: false,
- oldClrType: typeof(string),
- oldType: "NCLOB");
+ MigrationHelper.DropIndexIfPresent(migrationBuilder, _schema, "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId");
- migrationBuilder.AlterColumn(
- name: "ActivityNodeId",
- schema: _schema.Schema,
- table: "ActivityExecutionRecords",
- type: "NVARCHAR2(450)",
- nullable: false,
- oldClrType: typeof(string),
- oldType: "NCLOB");
+ ConvertActivityNodeIdToNVarchar2(migrationBuilder, "WorkflowExecutionLogRecords");
+ ConvertActivityNodeIdToNVarchar2(migrationBuilder, "ActivityExecutionRecords");
- migrationBuilder.CreateIndex(
- name: "IX_WorkflowExecutionLogRecord_ActivityNodeId",
- schema: _schema.Schema,
- table: "WorkflowExecutionLogRecords",
- column: "ActivityNodeId");
+ MigrationHelper.CreateIndexIfMissing(migrationBuilder, _schema, "IX_WorkflowExecutionLogRecord_ActivityNodeId", "WorkflowExecutionLogRecords", new[] { "ActivityNodeId" });
+ MigrationHelper.CreateIndexIfMissing(migrationBuilder, _schema, "IX_ActivityExecutionRecord_ActivityNodeId", "ActivityExecutionRecords", new[] { "ActivityNodeId" });
+ }
- migrationBuilder.CreateIndex(
- name: "IX_ActivityExecutionRecord_ActivityNodeId",
- schema: _schema.Schema,
- table: "ActivityExecutionRecords",
- column: "ActivityNodeId");
+ private void ConvertActivityNodeIdToNclob(MigrationBuilder migrationBuilder, string table)
+ {
+ MigrationHelper.ConvertColumnType(
+ migrationBuilder,
+ _schema,
+ table: table,
+ column: "ActivityNodeId",
+ fromDataType: "NVARCHAR2",
+ toDataType: "NCLOB",
+ toColumnDefinition: "NCLOB",
+ copyExpression: source => $"TO_NCLOB({source})",
+ notNull: true);
+ }
+
+ private void ConvertActivityNodeIdToNVarchar2(MigrationBuilder migrationBuilder, string table)
+ {
+ MigrationHelper.ConvertColumnType(
+ migrationBuilder,
+ _schema,
+ table: table,
+ column: "ActivityNodeId",
+ fromDataType: "NCLOB",
+ toDataType: "NVARCHAR2",
+ toColumnDefinition: "NVARCHAR2(450)",
+ // Activity node IDs are far shorter than 450 characters; DBMS_LOB.SUBSTR counts characters for an
+ // NCLOB and returns NVARCHAR2, so it neither truncates real data nor goes through the database
+ // character set the way TO_CHAR would.
+ copyExpression: source => $"DBMS_LOB.SUBSTR({source}, 450, 1)",
+ notNull: true);
}
}
}
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 777107d4b..58dae16eb 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,7 @@
+
diff --git a/test/unit/Elsa.Persistence.EFCore.UnitTests/MigrationScriptGenerator.cs b/test/unit/Elsa.Persistence.EFCore.UnitTests/MigrationScriptGenerator.cs
new file mode 100644
index 000000000..209e77205
--- /dev/null
+++ b/test/unit/Elsa.Persistence.EFCore.UnitTests/MigrationScriptGenerator.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Elsa.Persistence.EFCore.UnitTests;
+
+///
+/// Generates a migration script offline, through , so tests can assert on the
+/// SQL a migration produces without opening a database connection.
+///
+internal static class MigrationScriptGenerator
+{
+ ///
+ /// Builds a configured by and generates the
+ /// migration script between and .
+ ///
+ /// Configures the provider (and, through it, the migrations assembly and schema) on the options builder.
+ /// The migration to generate the script from, or null for the initial database state.
+ /// The migration to generate the script up to.
+ /// The SQL generation options, for example whether the script is idempotent.
+ public static string Generate(
+ Action> configure,
+ string? fromMigration,
+ string toMigration,
+ MigrationsSqlGenerationOptions options)
+ where TDbContext : DbContext
+ {
+ var optionsBuilder = new DbContextOptionsBuilder();
+ configure(optionsBuilder);
+
+ var serviceProvider = new ServiceCollection().BuildServiceProvider();
+
+ using var dbContext = (TDbContext)Activator.CreateInstance(typeof(TDbContext), optionsBuilder.Options, serviceProvider)!;
+
+ return dbContext.GetService().GenerateScript(fromMigration: fromMigration, toMigration: toMigration, options: options);
+ }
+}
diff --git a/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6OracleMigrationTests.cs b/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6OracleMigrationTests.cs
new file mode 100644
index 000000000..f0b6f9e9a
--- /dev/null
+++ b/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6OracleMigrationTests.cs
@@ -0,0 +1,286 @@
+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;
+
+///
+/// Regression tests for the Oracle V3_6 migrations. Oracle refuses to change a column's datatype to or from a LOB type
+/// in place: ALTER TABLE ... MODIFY 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
+/// WorkflowDefinitions.StringData, NVARCHAR2(450) to NCLOB for ActivityNodeId - so neither could ever
+/// apply. They must instead add a new column, copy the values across, drop the original and rename the new one.
+///
+///
+/// The scripts are generated offline through ; no Oracle connection is opened.
+///
+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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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(
+ 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(
+ builder => builder.UseElsaOracle(typeof(Elsa.Persistence.EFCore.Oracle.Migrations.Runtime.V3_6).Assembly, "Data Source=unused", new ElsaDbContextOptions { SchemaName = schema }),
+ fromMigration,
+ toMigration,
+ options);
+}
diff --git a/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6RuntimeMigrationTests.cs b/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6RuntimeMigrationTests.cs
index 89f30d0a9..b82c67ded 100644
--- a/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6RuntimeMigrationTests.cs
+++ b/test/unit/Elsa.Persistence.EFCore.UnitTests/V3_6RuntimeMigrationTests.cs
@@ -1,10 +1,7 @@
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;
@@ -51,12 +48,16 @@ public class V3_6RuntimeMigrationTests
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 GenerateScript(
+ return MigrationScriptGenerator.Generate(
builder => builder.UseElsaPostgreSql(migrationsAssembly, "Host=unused", contextOptions),
fromMigration: "20250530104953_V3_5",
toMigration: "20251204150341_V3_6",
@@ -67,31 +68,13 @@ public class V3_6RuntimeMigrationTests
{
var migrationsAssembly = typeof(Elsa.Persistence.EFCore.Sqlite.Migrations.Runtime.V3_6).Assembly;
- return GenerateScript(
+ return MigrationScriptGenerator.Generate(
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);