fix(efcore-oracle): migrate LOB columns in V3_6 without in-place datatype alteration (#8040)

* fix(efcore-oracle): migrate LOB columns in V3_6 without in-place datatype alteration

Both Oracle V3_6 migrations were generated as in-place `ALTER TABLE ... MODIFY`
statements that change a column's datatype to or from a LOB type: NCLOB to JSON
for `WorkflowDefinitions.StringData`, and NVARCHAR2(450) to NCLOB for
`ActivityNodeId` on `WorkflowExecutionLogRecords` and `ActivityExecutionRecords`.
Oracle refuses both (ORA-22858 / ORA-22859), so neither migration could ever
apply and the reported ORA-22858 was unavoidable.

Convert the columns the way the ORA-22858 message prescribes instead: add a
temporary column of the target type, copy the values across, drop the original
and rename the temporary one. Because Oracle commits DDL implicitly, a run that
fails partway leaves its earlier statements applied - the reporter's already
committed `OriginalSource` column is exactly that - so every step is guarded
against the state a previous attempt can have left behind. The conversion block
derives what still needs doing from `ALL_TAB_COLUMNS`, skips a conversion that
already completed (including one applied by hand), and raises rather than copy
out of and drop a column whose datatype it does not recognize.

Add an offline regression test that generates the Oracle Management and Runtime
V3_6 scripts through `IMigrator.GenerateScript` without a connection and asserts
that no in-place datatype `MODIFY` is emitted for either column, that the
add/copy/drop/rename sequence appears in order, and that the re-run guards are
present. All 19 cases fail against the previous migrations.

Refs #8011

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(efcore-oracle): tighten migration helper visibility and index guard, share the script-generation test harness

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(efcore-oracle): brace the foreach bodies in the V3_6 migration tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(efcore-oracle): refuse to truncate node IDs on downgrade and validate same-named indexes

The Runtime V3_6 downgrade converted ActivityNodeId from NCLOB back to
NVARCHAR2(450) by copying DBMS_LOB.SUBSTR(..., 450, 1), silently
truncating any value the upgraded schema had allowed to grow past 450
characters. EnsureLobLengthAtMost now checks for oversized values while
the column is still a LOB and raises before any data is copied.

CreateIndexIfMissing also treated any index with a matching name as
already done. It now validates the existing index's table, uniqueness
and ordered column list against ALL_INDEXES/ALL_IND_COLUMNS so a
same-named index left behind by schema drift or manual recovery is not
mistaken for the one the migration means to create.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(efcore-oracle): correct the dynamic SQL rationale on the LOB length guard

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(efcore-oracle): preflight both tables before downgrading and escape schema names in migration SQL

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(efcore-oracle): copy every row NULL-preservingly so retries reproduce the current source

The filtered copy `WHERE "<column>" IS NOT NULL` skipped rows whose
nullable source had since become NULL. If a prior conversion committed
the copy and then failed before dropping the source column, an
operator clearing a value before retrying would find the predicate
skip that row, and the stale converted value would be renamed into
place. Replace the filtered UPDATE with an unconditional, NULL-
preserving CASE expression so a retry always reproduces the current
source exactly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sipke Schoorstra 2026-09-07 02:09:08 -07:00 committed by GitHub
parent fda3987c0b
commit faf9d57b8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 782 additions and 118 deletions

View file

@ -0,0 +1,361 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace Elsa.Persistence.EFCore.Oracle;
/// <summary>
/// Emits Oracle-specific migration statements that work around DDL restrictions Oracle imposes and that survive being
/// re-run after a partially applied attempt.
/// </summary>
/// <remarks>
/// 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 <c>ALL_TAB_COLUMNS</c>, 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").
/// </remarks>
internal static class MigrationHelper
{
/// <summary>
/// The suffix of the temporary column <see cref="ConvertColumnType"/> 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.
/// </summary>
private const string TempColumnSuffix = "_New";
/// <summary>
/// Changes the datatype of <paramref name="column"/> by adding a temporary column of the target type, copying the
/// values across, dropping the original column and renaming the temporary one.
/// </summary>
/// <remarks>
/// <para>
/// Oracle refuses to change a column's datatype to or from a LOB type in place: <c>ALTER TABLE ... MODIFY</c>
/// fails with ORA-22858 ("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.
/// </para>
/// <para>
/// The emitted PL/SQL block is re-runnable. It derives what still needs doing from the datatypes currently
/// recorded in <c>ALL_TAB_COLUMNS</c> 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.
/// </para>
/// <para>
/// The copy itself is unconditional and NULL-preserving: it runs for every row, not only the ones with a non-NULL
/// source, and writes <c>NULL</c> into the temporary column whenever the source is <c>NULL</c>. 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.
/// </para>
/// </remarks>
/// <param name="migrationBuilder">The migration builder to emit into.</param>
/// <param name="schema">The Elsa schema the table lives in.</param>
/// <param name="table">The unquoted table name.</param>
/// <param name="column">The unquoted column name.</param>
/// <param name="fromDataType">The current datatype, as <c>ALL_TAB_COLUMNS.DATA_TYPE</c> reports it (for example <c>NCLOB</c> or <c>NVARCHAR2</c>).</param>
/// <param name="toDataType">The target datatype, as <c>ALL_TAB_COLUMNS.DATA_TYPE</c> reports it. This is what marks the conversion as already done, so it has to be the dictionary spelling and not the DDL spelling.</param>
/// <param name="toColumnDefinition">The target datatype as written in DDL, including any length (for example <c>NVARCHAR2(450)</c>).</param>
/// <param name="copyExpression">Builds the SQL expression that converts a value; it is handed the quoted name of the original column.</param>
/// <param name="notNull">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.</param>
public static void ConvertColumnType(
MigrationBuilder migrationBuilder,
IElsaDbContextSchema schema,
string table,
string column,
string fromDataType,
string toDataType,
string toColumnDefinition,
Func<string, string> 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);
}
}
/// <summary>
/// Raises an error if any value stored in <paramref name="column"/> is longer than <paramref name="maxLength"/>
/// characters, so that <see cref="ConvertColumnType"/> converting a LOB column down to a bounded
/// <c>NVARCHAR2</c> fails before it copies - and so silently truncates - a value that does not fit.
/// </summary>
/// <remarks>
/// The check only runs while <paramref name="column"/> is still recorded as <c>NCLOB</c> or <c>CLOB</c> in
/// <c>ALL_TAB_COLUMNS</c>: once the conversion has landed the datatype itself already enforces the length, and
/// the length query itself is issued through <c>EXECUTE IMMEDIATE</c> 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.
/// </remarks>
/// <param name="migrationBuilder">The migration builder to emit into.</param>
/// <param name="schema">The Elsa schema the table lives in.</param>
/// <param name="table">The unquoted table name.</param>
/// <param name="column">The unquoted column name.</param>
/// <param name="maxLength">The maximum number of characters the target <c>NVARCHAR2</c> column can hold.</param>
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;
""");
}
/// <summary>
/// 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").
/// </summary>
/// <param name="migrationBuilder">The migration builder to emit into.</param>
/// <param name="schema">The Elsa schema the table lives in.</param>
/// <param name="table">The unquoted table name.</param>
/// <param name="column">The unquoted column name.</param>
/// <param name="columnDefinition">The column definition as written in DDL, for example <c>NCLOB</c>.</param>
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;
""");
}
/// <summary>
/// Drops a column if it is there, so that a run following a partially applied one does not fail with ORA-00904
/// ("invalid identifier").
/// </summary>
/// <param name="migrationBuilder">The migration builder to emit into.</param>
/// <param name="schema">The Elsa schema the table lives in.</param>
/// <param name="table">The unquoted table name.</param>
/// <param name="column">The unquoted column name.</param>
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;
""");
}
/// <summary>
/// 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.
/// </summary>
/// <param name="migrationBuilder">The migration builder to emit into.</param>
/// <param name="schema">The Elsa schema the table lives in.</param>
/// <param name="name">The unquoted index name.</param>
/// <param name="table">The unquoted table name.</param>
/// <param name="columns">The unquoted column names to index.</param>
/// <param name="unique">Whether the index is unique.</param>
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;
""");
}
/// <summary>
/// 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").
/// </summary>
/// <param name="migrationBuilder">The migration builder to emit into.</param>
/// <param name="schema">The Elsa schema the index lives in.</param>
/// <param name="name">The unquoted index name.</param>
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)}";
/// <summary>
/// Renders <paramref name="value"/> 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 <c>O'Brien</c> -
/// cannot break out of the literal and produce invalid, or worse injectable, SQL.
/// </summary>
private static string Literal(string value) => "'" + value.Replace("'", "''") + "'";
/// <summary>
/// Renders <paramref name="name"/> as a double-quoted Oracle identifier, doubling any embedded double quote so
/// that the identifier delimiters cannot be broken out of.
/// </summary>
private static string Identifier(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
/// <summary>
/// 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.
/// </summary>
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;
""");
}
}

View file

@ -5,6 +5,13 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management
{
/// <inheritdoc />
/// <remarks>
/// Oracle rejects an in-place <c>ALTER TABLE ... MODIFY</c> that changes a column's datatype to or from a LOB type
/// (ORA-22858 / ORA-22859), so <c>StringData</c> is converted between NCLOB and JSON with the add/copy/drop/rename
/// sequence <see cref="MigrationHelper.ConvertColumnType"/> 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.
/// </remarks>
public partial class V3_6 : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
@ -18,41 +25,35 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OriginalSource",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NCLOB",
nullable: true);
MigrationHelper.AddColumnIfMissing(migrationBuilder, _schema, "WorkflowDefinitions", "OriginalSource", "NCLOB");
migrationBuilder.AlterColumn<string>(
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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
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");
}
}
}

View file

@ -5,6 +5,14 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Elsa.Persistence.EFCore.Oracle.Migrations.Runtime
{
/// <inheritdoc />
/// <remarks>
/// Oracle rejects an in-place <c>ALTER TABLE ... MODIFY</c> that changes a column's datatype to or from a LOB type
/// (ORA-22858 / ORA-22859), so <c>ActivityNodeId</c> is converted between NVARCHAR2(450) and NCLOB with the
/// add/copy/drop/rename sequence <see cref="MigrationHelper.ConvertColumnType"/> 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.
/// </remarks>
public partial class V3_6 : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
@ -18,89 +26,75 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Runtime
/// <inheritdoc />
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<string>(
name: "ActivityNodeId",
schema: _schema.Schema,
table: "WorkflowExecutionLogRecords",
type: "NCLOB",
nullable: false,
oldClrType: typeof(string),
oldType: "NVARCHAR2(450)");
migrationBuilder.AlterColumn<string>(
name: "ActivityNodeId",
schema: _schema.Schema,
table: "ActivityExecutionRecords",
type: "NCLOB",
nullable: false,
oldClrType: typeof(string),
oldType: "NVARCHAR2(450)");
ConvertActivityNodeIdToNclob(migrationBuilder, "WorkflowExecutionLogRecords");
ConvertActivityNodeIdToNclob(migrationBuilder, "ActivityExecutionRecords");
}
/// <inheritdoc />
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<string>(
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<string>(
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);
}
}
}

View file

@ -11,6 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\modules\Elsa.Persistence.EFCore.Common\Elsa.Persistence.EFCore.Common.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Persistence.EFCore.Oracle\Elsa.Persistence.EFCore.Oracle.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Persistence.EFCore.PostgreSql\Elsa.Persistence.EFCore.PostgreSql.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Persistence.EFCore.Sqlite\Elsa.Persistence.EFCore.Sqlite.csproj" />
</ItemGroup>

View file

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Persistence.EFCore.UnitTests;
/// <summary>
/// Generates a migration script offline, through <see cref="IMigrator.GenerateScript"/>, so tests can assert on the
/// SQL a migration produces without opening a database connection.
/// </summary>
internal static class MigrationScriptGenerator
{
/// <summary>
/// Builds a <typeparamref name="TDbContext"/> configured by <paramref name="configure"/> and generates the
/// migration script between <paramref name="fromMigration"/> and <paramref name="toMigration"/>.
/// </summary>
/// <param name="configure">Configures the provider (and, through it, the migrations assembly and schema) on the options builder.</param>
/// <param name="fromMigration">The migration to generate the script from, or <c>null</c> for the initial database state.</param>
/// <param name="toMigration">The migration to generate the script up to.</param>
/// <param name="options">The SQL generation options, for example whether the script is idempotent.</param>
public static string Generate<TDbContext>(
Action<DbContextOptionsBuilder<TDbContext>> configure,
string? fromMigration,
string toMigration,
MigrationsSqlGenerationOptions options)
where TDbContext : DbContext
{
var optionsBuilder = new DbContextOptionsBuilder<TDbContext>();
configure(optionsBuilder);
var serviceProvider = new ServiceCollection().BuildServiceProvider();
using var dbContext = (TDbContext)Activator.CreateInstance(typeof(TDbContext), optionsBuilder.Options, serviceProvider)!;
return dbContext.GetService<IMigrator>().GenerateScript(fromMigration: fromMigration, toMigration: toMigration, options: options);
}
}

View file

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

View file

@ -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<RuntimeElsaDbContext>(
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<RuntimeElsaDbContext>(
builder => builder.UseElsaSqlite(migrationsAssembly, "Data Source=:memory:"),
fromMigration: "20250530104854_V3_5",
toMigration: "20251204150006_V3_6",
options);
}
private static string GenerateScript(Action<DbContextOptionsBuilder<RuntimeElsaDbContext>> configureProvider, string fromMigration, string toMigration, MigrationsSqlGenerationOptions options)
{
var optionsBuilder = new DbContextOptionsBuilder<RuntimeElsaDbContext>();
configureProvider(optionsBuilder);
var dbContextOptions = optionsBuilder.Options;
using var dbContext = new RuntimeElsaDbContext(dbContextOptions, CreateServiceProvider());
var migrator = dbContext.GetService<IMigrator>();
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);