From 11e3838f23b10f20e201b39b25ff833e047cf5b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 10:28:49 +0000 Subject: [PATCH 1/6] Initial plan From 622ee0c63d22866507459306bb6cd46f80911953 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 10:34:44 +0000 Subject: [PATCH 2/6] Fix Oracle MERGE syntax - remove AS keyword, wrap ON clause in parentheses Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../OracleDialectBuilder.cs | 51 ++++++++++++------- .../Tests/Merge/MergeTestsBase.cs | 10 ++++ .../Tests/Merge/MergeTestsOracle.cs | 12 +++++ 3 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsOracle.cs diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs index 60d4b3b..b098d5f 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs @@ -35,6 +35,12 @@ public override string BuildMoveDataSql( // Merge handling if (onConflict is OnConflictOptions onConflictTyped) { + // 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."); + } + IEnumerable matchColumns; if (onConflictTyped.Match != null) { @@ -49,27 +55,21 @@ public override string BuildMoveDataSql( throw new InvalidOperationException("Table has no primary key that can be used for conflict detection."); } - q.AppendLine($"MERGE INTO {target.QuotedTableName} AS {PseudoTableInserted}"); + // 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 puts WHEN NOT MATCHED before WHEN MATCHED for insert-first logic q.Append("WHEN NOT MATCHED THEN INSERT ("); q.AppendColumns(insertedColumns); q.AppendLine(")"); @@ -78,10 +78,25 @@ 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}")); + var columns = target.GetColumns(false); + + 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 "); + q.AppendJoin(", ", GetUpdates(context, target, columns, 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..daef1ec 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs @@ -126,6 +126,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 +196,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 +241,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 +286,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 +320,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) +{ +} From 302f90e20794790ccdcf9ebc79fa9c92021770f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 10:44:54 +0000 Subject: [PATCH 3/6] Update comment for Oracle MERGE syntax per code review Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../OracleDialectBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs index b098d5f..5c7f7cb 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs @@ -69,7 +69,7 @@ public override string BuildMoveDataSql( q.AppendJoin(" AND ", matchColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col} = {PseudoTableExcluded}.{col}")); q.AppendLine(")"); - // Oracle MERGE puts WHEN NOT MATCHED before WHEN MATCHED for insert-first logic + // 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(")"); From 0548cf5e3e5a27c34f94e63d9a1652329b048dc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:08:26 +0000 Subject: [PATCH 4/6] Fix Oracle MERGE: validate match columns, exclude match columns from updates Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../OracleDialectBuilder.cs | 33 ++++++++++++++++--- .../Tests/Merge/MergeTestsBase.cs | 2 ++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs index 5c7f7cb..5ea44b0 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs @@ -42,18 +42,33 @@ public override string BuildMoveDataSql( } IEnumerable matchColumns; + IReadOnlyList matchColumnsList; if (onConflictTyped.Match != null) { - matchColumns = GetColumns(target, onConflictTyped.Match); + matchColumnsList = GetColumns(target, onConflictTyped.Match).ToList(); } else if (target.PrimaryKey.Length > 0) { - matchColumns = target.PrimaryKey.Select(x => x.QuotedColumName); + matchColumnsList = target.PrimaryKey.Select(x => x.QuotedColumName).ToList(); } else { throw new InvalidOperationException("Table has no primary key that can be used for conflict detection."); } + matchColumns = matchColumnsList; + + // Validate that all match columns are available in the source subquery + var insertedColumnNames = insertedColumns.Select(c => c.QuotedColumName).ToHashSet(); + var missingMatchColumns = matchColumnsList.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}"); @@ -80,8 +95,6 @@ public override string BuildMoveDataSql( if (onConflictTyped.Update != null) { - var columns = target.GetColumns(false); - q.Append("WHEN MATCHED "); if (onConflictTyped.RawWhere != null || onConflictTyped.Where != null) @@ -96,7 +109,17 @@ public override string BuildMoveDataSql( } q.AppendLine("THEN UPDATE SET "); - q.AppendJoin(", ", GetUpdates(context, target, columns, onConflictTyped.Update)); + // 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 = matchColumnsList.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 daef1ec..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 From fe933902e24fdefeea9da65b22051d24635d5eba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:14:30 +0000 Subject: [PATCH 5/6] Fix Oracle bulk copy destination table name for temp table operations Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../OracleBulkInsertProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From 231f066b36ee2ea048c36b4e0980937dc713f126 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:18:23 +0000 Subject: [PATCH 6/6] Simplify variable naming in OracleDialectBuilder per code review Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../OracleDialectBuilder.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs index 5ea44b0..2002e01 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs @@ -41,25 +41,23 @@ public override string BuildMoveDataSql( throw new NotSupportedException("Oracle MERGE does not support returning entities. Use ExecuteBulkInsertAsync without returning results when using conflict resolution."); } - IEnumerable matchColumns; - IReadOnlyList matchColumnsList; + IReadOnlyList matchColumns; if (onConflictTyped.Match != null) { - matchColumnsList = GetColumns(target, onConflictTyped.Match).ToList(); + matchColumns = GetColumns(target, onConflictTyped.Match).ToList(); } else if (target.PrimaryKey.Length > 0) { - matchColumnsList = target.PrimaryKey.Select(x => x.QuotedColumName).ToList(); + matchColumns = target.PrimaryKey.Select(x => x.QuotedColumName).ToList(); } else { throw new InvalidOperationException("Table has no primary key that can be used for conflict detection."); } - matchColumns = matchColumnsList; // Validate that all match columns are available in the source subquery var insertedColumnNames = insertedColumns.Select(c => c.QuotedColumName).ToHashSet(); - var missingMatchColumns = matchColumnsList.Where(c => !insertedColumnNames.Contains(c)).ToList(); + var missingMatchColumns = matchColumns.Where(c => !insertedColumnNames.Contains(c)).ToList(); if (missingMatchColumns.Count != 0) { throw new InvalidOperationException( @@ -111,7 +109,7 @@ public override string BuildMoveDataSql( 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 = matchColumnsList.ToHashSet(); + var matchColumnSet = matchColumns.ToHashSet(); var updateableColumns = insertedColumns.Where(c => !matchColumnSet.Contains(c.QuotedColumName)).ToList(); if (updateableColumns.Count == 0) {