diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs index 24333d6..b97feab 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs @@ -59,7 +59,7 @@ protected override Task BulkInsert( using var bulkCopy = new OracleBulkCopy(connection, options.CopyOptions); - bulkCopy.DestinationTableName = tableInfo.QuotedTableName; + bulkCopy.DestinationTableName = tableName; bulkCopy.BatchSize = options.BatchSize; bulkCopy.BulkCopyTimeout = options.GetCopyTimeoutInSeconds(); diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs index 60d4b3b..2002e01 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs @@ -35,41 +35,54 @@ public override string BuildMoveDataSql( // Merge handling if (onConflict is OnConflictOptions onConflictTyped) { - IEnumerable matchColumns; + // Oracle MERGE doesn't support returning entities + if (returnedColumns.Count != 0) + { + throw new NotSupportedException("Oracle MERGE does not support returning entities. Use ExecuteBulkInsertAsync without returning results when using conflict resolution."); + } + + IReadOnlyList matchColumns; if (onConflictTyped.Match != null) { - matchColumns = GetColumns(target, onConflictTyped.Match); + matchColumns = GetColumns(target, onConflictTyped.Match).ToList(); } else if (target.PrimaryKey.Length > 0) { - matchColumns = target.PrimaryKey.Select(x => x.QuotedColumName); + matchColumns = target.PrimaryKey.Select(x => x.QuotedColumName).ToList(); } else { throw new InvalidOperationException("Table has no primary key that can be used for conflict detection."); } - q.AppendLine($"MERGE INTO {target.QuotedTableName} AS {PseudoTableInserted}"); + // Validate that all match columns are available in the source subquery + var insertedColumnNames = insertedColumns.Select(c => c.QuotedColumName).ToHashSet(); + var missingMatchColumns = matchColumns.Where(c => !insertedColumnNames.Contains(c)).ToList(); + if (missingMatchColumns.Count != 0) + { + throw new InvalidOperationException( + $"Oracle MERGE requires match columns to be present in the source data. " + + $"The following match columns are not available: {string.Join(", ", missingMatchColumns)}. " + + $"This can happen when using auto-generated primary key columns for conflict detection. " + + $"Use the 'Match' option to specify non-generated columns for conflict detection, " + + $"or set 'CopyGeneratedColumns = true' if the generated column values are provided."); + } + + // Oracle MERGE syntax does NOT use AS for table aliases + q.AppendLine($"MERGE INTO {target.QuotedTableName} {PseudoTableInserted}"); q.Append("USING (SELECT "); q.AppendColumns(insertedColumns); - q.Append($" FROM {source}) AS {PseudoTableExcluded} ("); - q.AppendColumns(insertedColumns); - q.AppendLine(")"); - - q.Append("ON "); - q.AppendJoin(" AND ", matchColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col} = {PseudoTableExcluded}.{col}")); + // Oracle MERGE syntax does NOT use AS for subquery aliases + q.Append($" FROM {source}) {PseudoTableExcluded}"); q.AppendLine(); - if (onConflictTyped.Update != null) - { - var columns = target.GetColumns(false); - - q.AppendLine("WHEN MATCHED THEN UPDATE SET "); - q.AppendJoin(", ", GetUpdates(context, target, columns, onConflictTyped.Update)); - q.AppendLine(); - } + // Oracle requires ON clause conditions to be wrapped in parentheses + q.Append("ON ("); + q.AppendJoin(" AND ", matchColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col} = {PseudoTableExcluded}.{col}")); + q.AppendLine(")"); + // Oracle MERGE syntax: WHEN NOT MATCHED clause for inserts, followed by WHEN MATCHED clause for updates q.Append("WHEN NOT MATCHED THEN INSERT ("); q.AppendColumns(insertedColumns); q.AppendLine(")"); @@ -78,10 +91,33 @@ public override string BuildMoveDataSql( q.AppendJoin(", ", insertedColumns, (b, col) => b.Append($"{PseudoTableExcluded}.{col.QuotedColumName}")); q.AppendLine(")"); - if (returnedColumns.Count != 0) + if (onConflictTyped.Update != null) { - q.Append("OUTPUT "); - q.AppendJoin(", ", returnedColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col.QuotedColumName} AS {col.QuotedColumName}")); + q.Append("WHEN MATCHED "); + + if (onConflictTyped.RawWhere != null || onConflictTyped.Where != null) + { + if (onConflictTyped is { RawWhere: not null, Where: not null }) + { + throw new ArgumentException("Cannot specify both RawWhere and Where in OnConflictOptions."); + } + + q.Append("AND "); + AppendConflictCondition(q, target, context, onConflictTyped); + } + + q.AppendLine("THEN UPDATE SET "); + // Oracle MERGE: columns in ON clause cannot be updated, so exclude match columns + // Use insertedColumns instead of all columns because the USING subquery only contains insertedColumns + var matchColumnSet = matchColumns.ToHashSet(); + var updateableColumns = insertedColumns.Where(c => !matchColumnSet.Contains(c.QuotedColumName)).ToList(); + if (updateableColumns.Count == 0) + { + throw new InvalidOperationException( + "Oracle MERGE cannot update any columns because all available columns are used in the ON clause for conflict detection. " + + "Specify different columns in the 'Match' option or use specific columns in the 'Update' expression."); + } + q.AppendJoin(", ", GetUpdates(context, target, updateableColumns, onConflictTyped.Update)); q.AppendLine(); } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs index 45f6900..57076b4 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs @@ -33,6 +33,8 @@ public async Task InsertEntities_MultipleTimes(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.PostgreSql)); Skip.If(_context.IsProvider(ProviderType.SqlServer)); + // Oracle MERGE requires match columns to be in the source data; auto-generated Id is not available + Skip.If(_context.IsProvider(ProviderType.Oracle)); // Arrange var entities = new List @@ -126,6 +128,8 @@ public async Task InsertEntities_MultipleTimes_With_Conflict_On_Id(InsertStrateg public async Task InsertEntities_WithConflict_SingleColumn(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.MySql)); + // Oracle MERGE does not support returning entities + Skip.If(_context.IsProvider(ProviderType.Oracle)); // Arrange _context.TestEntities.Add(new TestEntity { TestRun = _run,Name = $"{_run}_Entity1" }); @@ -194,6 +198,8 @@ public async Task InsertEntities_WithConflict_DoNothing(InsertStrategy strategy) public async Task InsertEntities_WithConflict_RawCondition(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.MySql)); + // Oracle MERGE does not support returning entities + Skip.If(_context.IsProvider(ProviderType.Oracle)); // Arrange _context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 }); @@ -237,6 +243,8 @@ public async Task InsertEntities_WithConflict_RawCondition(InsertStrategy strate public async Task InsertEntities_WithConflict_ExpressionCondition(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.MySql)); + // Oracle MERGE does not support returning entities + Skip.If(_context.IsProvider(ProviderType.Oracle)); // Arrange _context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 }); @@ -280,6 +288,8 @@ public async Task InsertEntities_WithConflict_ExpressionCondition(InsertStrategy public async Task InsertEntities_WithConflict_ComplexExpressionCondition(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.MySql)); + // Oracle MERGE does not support returning entities + Skip.If(_context.IsProvider(ProviderType.Oracle)); // Arrange _context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 }); @@ -312,6 +322,8 @@ public async Task InsertEntities_WithConflict_ComplexExpressionCondition(InsertS public async Task InsertEntities_WithConflict_MultipleColumns(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.MySql)); + // Oracle MERGE does not support returning entities + Skip.If(_context.IsProvider(ProviderType.Oracle)); // Arrange _context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 }); diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsOracle.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsOracle.cs new file mode 100644 index 0000000..86dc10d --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsOracle.cs @@ -0,0 +1,12 @@ +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Merge; + +[Trait("Category", "Oracle")] +[Collection(TestDbContainerOracleCollection.Name)] +public class MergeTestsOracle(TestDbContainerOracle dbContainer) : MergeTestsBase(dbContainer) +{ +}