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);