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:
parent
fda3987c0b
commit
faf9d57b8b
361
src/modules/Elsa.Persistence.EFCore.Oracle/MigrationHelper.cs
Normal file
361
src/modules/Elsa.Persistence.EFCore.Oracle/MigrationHelper.cs
Normal 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;
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue