Skip to content

Commit 86c0766

Browse files
CopilotPhenX
andauthored
Fix Oracle MERGE ORA-02012 error - invalid syntax - Fixes #79
* Initial plan * Fix Oracle MERGE syntax - remove AS keyword, wrap ON clause in parentheses Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> * Update comment for Oracle MERGE syntax per code review Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> * Fix Oracle MERGE: validate match columns, exclude match columns from updates Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> * Fix Oracle bulk copy destination table name for temp table operations Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> * Simplify variable naming in OracleDialectBuilder per code review Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com>
1 parent d9546ce commit 86c0766

4 files changed

Lines changed: 82 additions & 22 deletions

File tree

src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ protected override Task BulkInsert<T>(
5959

6060
using var bulkCopy = new OracleBulkCopy(connection, options.CopyOptions);
6161

62-
bulkCopy.DestinationTableName = tableInfo.QuotedTableName;
62+
bulkCopy.DestinationTableName = tableName;
6363
bulkCopy.BatchSize = options.BatchSize;
6464
bulkCopy.BulkCopyTimeout = options.GetCopyTimeoutInSeconds();
6565

src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,41 +35,54 @@ public override string BuildMoveDataSql<T>(
3535
// Merge handling
3636
if (onConflict is OnConflictOptions<T> onConflictTyped)
3737
{
38-
IEnumerable<string> matchColumns;
38+
// Oracle MERGE doesn't support returning entities
39+
if (returnedColumns.Count != 0)
40+
{
41+
throw new NotSupportedException("Oracle MERGE does not support returning entities. Use ExecuteBulkInsertAsync without returning results when using conflict resolution.");
42+
}
43+
44+
IReadOnlyList<string> matchColumns;
3945
if (onConflictTyped.Match != null)
4046
{
41-
matchColumns = GetColumns(target, onConflictTyped.Match);
47+
matchColumns = GetColumns(target, onConflictTyped.Match).ToList();
4248
}
4349
else if (target.PrimaryKey.Length > 0)
4450
{
45-
matchColumns = target.PrimaryKey.Select(x => x.QuotedColumName);
51+
matchColumns = target.PrimaryKey.Select(x => x.QuotedColumName).ToList();
4652
}
4753
else
4854
{
4955
throw new InvalidOperationException("Table has no primary key that can be used for conflict detection.");
5056
}
5157

52-
q.AppendLine($"MERGE INTO {target.QuotedTableName} AS {PseudoTableInserted}");
58+
// Validate that all match columns are available in the source subquery
59+
var insertedColumnNames = insertedColumns.Select(c => c.QuotedColumName).ToHashSet();
60+
var missingMatchColumns = matchColumns.Where(c => !insertedColumnNames.Contains(c)).ToList();
61+
if (missingMatchColumns.Count != 0)
62+
{
63+
throw new InvalidOperationException(
64+
$"Oracle MERGE requires match columns to be present in the source data. " +
65+
$"The following match columns are not available: {string.Join(", ", missingMatchColumns)}. " +
66+
$"This can happen when using auto-generated primary key columns for conflict detection. " +
67+
$"Use the 'Match' option to specify non-generated columns for conflict detection, " +
68+
$"or set 'CopyGeneratedColumns = true' if the generated column values are provided.");
69+
}
70+
71+
// Oracle MERGE syntax does NOT use AS for table aliases
72+
q.AppendLine($"MERGE INTO {target.QuotedTableName} {PseudoTableInserted}");
5373

5474
q.Append("USING (SELECT ");
5575
q.AppendColumns(insertedColumns);
56-
q.Append($" FROM {source}) AS {PseudoTableExcluded} (");
57-
q.AppendColumns(insertedColumns);
58-
q.AppendLine(")");
59-
60-
q.Append("ON ");
61-
q.AppendJoin(" AND ", matchColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col} = {PseudoTableExcluded}.{col}"));
76+
// Oracle MERGE syntax does NOT use AS for subquery aliases
77+
q.Append($" FROM {source}) {PseudoTableExcluded}");
6278
q.AppendLine();
6379

64-
if (onConflictTyped.Update != null)
65-
{
66-
var columns = target.GetColumns(false);
67-
68-
q.AppendLine("WHEN MATCHED THEN UPDATE SET ");
69-
q.AppendJoin(", ", GetUpdates(context, target, columns, onConflictTyped.Update));
70-
q.AppendLine();
71-
}
80+
// Oracle requires ON clause conditions to be wrapped in parentheses
81+
q.Append("ON (");
82+
q.AppendJoin(" AND ", matchColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col} = {PseudoTableExcluded}.{col}"));
83+
q.AppendLine(")");
7284

85+
// Oracle MERGE syntax: WHEN NOT MATCHED clause for inserts, followed by WHEN MATCHED clause for updates
7386
q.Append("WHEN NOT MATCHED THEN INSERT (");
7487
q.AppendColumns(insertedColumns);
7588
q.AppendLine(")");
@@ -78,10 +91,33 @@ public override string BuildMoveDataSql<T>(
7891
q.AppendJoin(", ", insertedColumns, (b, col) => b.Append($"{PseudoTableExcluded}.{col.QuotedColumName}"));
7992
q.AppendLine(")");
8093

81-
if (returnedColumns.Count != 0)
94+
if (onConflictTyped.Update != null)
8295
{
83-
q.Append("OUTPUT ");
84-
q.AppendJoin(", ", returnedColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col.QuotedColumName} AS {col.QuotedColumName}"));
96+
q.Append("WHEN MATCHED ");
97+
98+
if (onConflictTyped.RawWhere != null || onConflictTyped.Where != null)
99+
{
100+
if (onConflictTyped is { RawWhere: not null, Where: not null })
101+
{
102+
throw new ArgumentException("Cannot specify both RawWhere and Where in OnConflictOptions.");
103+
}
104+
105+
q.Append("AND ");
106+
AppendConflictCondition(q, target, context, onConflictTyped);
107+
}
108+
109+
q.AppendLine("THEN UPDATE SET ");
110+
// Oracle MERGE: columns in ON clause cannot be updated, so exclude match columns
111+
// Use insertedColumns instead of all columns because the USING subquery only contains insertedColumns
112+
var matchColumnSet = matchColumns.ToHashSet();
113+
var updateableColumns = insertedColumns.Where(c => !matchColumnSet.Contains(c.QuotedColumName)).ToList();
114+
if (updateableColumns.Count == 0)
115+
{
116+
throw new InvalidOperationException(
117+
"Oracle MERGE cannot update any columns because all available columns are used in the ON clause for conflict detection. " +
118+
"Specify different columns in the 'Match' option or use specific columns in the 'Update' expression.");
119+
}
120+
q.AppendJoin(", ", GetUpdates(context, target, updateableColumns, onConflictTyped.Update));
85121
q.AppendLine();
86122
}
87123
}

tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public async Task InsertEntities_MultipleTimes(InsertStrategy strategy)
3333
{
3434
Skip.If(_context.IsProvider(ProviderType.PostgreSql));
3535
Skip.If(_context.IsProvider(ProviderType.SqlServer));
36+
// Oracle MERGE requires match columns to be in the source data; auto-generated Id is not available
37+
Skip.If(_context.IsProvider(ProviderType.Oracle));
3638

3739
// Arrange
3840
var entities = new List<TestEntity>
@@ -126,6 +128,8 @@ public async Task InsertEntities_MultipleTimes_With_Conflict_On_Id(InsertStrateg
126128
public async Task InsertEntities_WithConflict_SingleColumn(InsertStrategy strategy)
127129
{
128130
Skip.If(_context.IsProvider(ProviderType.MySql));
131+
// Oracle MERGE does not support returning entities
132+
Skip.If(_context.IsProvider(ProviderType.Oracle));
129133

130134
// Arrange
131135
_context.TestEntities.Add(new TestEntity { TestRun = _run,Name = $"{_run}_Entity1" });
@@ -194,6 +198,8 @@ public async Task InsertEntities_WithConflict_DoNothing(InsertStrategy strategy)
194198
public async Task InsertEntities_WithConflict_RawCondition(InsertStrategy strategy)
195199
{
196200
Skip.If(_context.IsProvider(ProviderType.MySql));
201+
// Oracle MERGE does not support returning entities
202+
Skip.If(_context.IsProvider(ProviderType.Oracle));
197203

198204
// Arrange
199205
_context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 });
@@ -237,6 +243,8 @@ public async Task InsertEntities_WithConflict_RawCondition(InsertStrategy strate
237243
public async Task InsertEntities_WithConflict_ExpressionCondition(InsertStrategy strategy)
238244
{
239245
Skip.If(_context.IsProvider(ProviderType.MySql));
246+
// Oracle MERGE does not support returning entities
247+
Skip.If(_context.IsProvider(ProviderType.Oracle));
240248

241249
// Arrange
242250
_context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 });
@@ -280,6 +288,8 @@ public async Task InsertEntities_WithConflict_ExpressionCondition(InsertStrategy
280288
public async Task InsertEntities_WithConflict_ComplexExpressionCondition(InsertStrategy strategy)
281289
{
282290
Skip.If(_context.IsProvider(ProviderType.MySql));
291+
// Oracle MERGE does not support returning entities
292+
Skip.If(_context.IsProvider(ProviderType.Oracle));
283293

284294
// Arrange
285295
_context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 });
@@ -312,6 +322,8 @@ public async Task InsertEntities_WithConflict_ComplexExpressionCondition(InsertS
312322
public async Task InsertEntities_WithConflict_MultipleColumns(InsertStrategy strategy)
313323
{
314324
Skip.If(_context.IsProvider(ProviderType.MySql));
325+
// Oracle MERGE does not support returning entities
326+
Skip.If(_context.IsProvider(ProviderType.Oracle));
315327

316328
// Arrange
317329
_context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 });
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer;
2+
using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext;
3+
4+
using Xunit;
5+
6+
namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Merge;
7+
8+
[Trait("Category", "Oracle")]
9+
[Collection(TestDbContainerOracleCollection.Name)]
10+
public class MergeTestsOracle(TestDbContainerOracle dbContainer) : MergeTestsBase<TestDbContextOracle>(dbContainer)
11+
{
12+
}

0 commit comments

Comments
 (0)