Skip to content

Commit 1df4148

Browse files
author
fabien.menager
committed
Merge branch 'main' into dotnet10
2 parents b546aad + 844a080 commit 1df4148

9 files changed

Lines changed: 215 additions & 27 deletions

File tree

.github/workflows/release.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ jobs:
4646
- name: Build
4747
run: dotnet build --configuration Release --no-restore /p:Version=$VERSION
4848

49-
- name: Test
50-
run: dotnet test --configuration Release --no-restore --no-build --verbosity normal
49+
- name: Test net8.0
50+
run: dotnet test --configuration Release --no-restore --no-build --verbosity normal --framework net8.0
51+
52+
- name: Test net9.0
53+
run: dotnet test --configuration Release --no-restore --no-build --verbosity normal --framework net9.0
5154

5255
- name: Pack nuget packages
5356
run: dotnet pack --configuration Release --no-restore --no-build --output nupkgs /p:PackageVersion=$VERSION

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
}

src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ protected IEnumerable<string> GetUpdates<T>(DbContext context, TableMetadata tab
246246
{
247247
foreach (var arg in newExpr.Arguments.Zip(newExpr.Members, (expr, member) => (expr, member)))
248248
{
249-
yield return $"{table.GetColumnName(arg.member.Name)} = {ToSqlExpression<T>(context, table, arg.expr, lambda)}";
249+
yield return $"{table.GetQuotedColumnName(arg.member.Name)} = {ToSqlExpression<T>(context, table, arg.expr, lambda)}";
250250
}
251251

252252
break;
@@ -255,13 +255,13 @@ protected IEnumerable<string> GetUpdates<T>(DbContext context, TableMetadata tab
255255
{
256256
foreach (var binding in memberInit.Bindings.OfType<MemberAssignment>())
257257
{
258-
yield return $"{table.GetColumnName(binding.Member.Name)} = {ToSqlExpression<T>(context, table, binding.Expression, lambda)}";
258+
yield return $"{table.GetQuotedColumnName(binding.Member.Name)} = {ToSqlExpression<T>(context, table, binding.Expression, lambda)}";
259259
}
260260

261261
break;
262262
}
263263
case MemberExpression memberExpr:
264-
yield return $"{table.GetColumnName(memberExpr.Member.Name)} = {ToSqlExpression<T>(context, table, memberExpr, lambda)}";
264+
yield return $"{table.GetQuotedColumnName(memberExpr.Member.Name)} = {ToSqlExpression<T>(context, table, memberExpr, lambda)}";
265265
break;
266266

267267
case ParameterExpression parameterExpr when (parameterExpr.Type == typeof(T)):

tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class TestDbContext : TestDbContextBase
1414
public DbSet<TestEntityWithConverters> TestEntitiesWithConverter { get; set; } = null!;
1515
public DbSet<TestEntityWithComplexType> TestEntitiesWithComplexType { get; set; } = null!;
1616
public DbSet<TestEntityWithSmartEnum> TestEntitiesWithSmartEnum { get; set; } = null!;
17+
public DbSet<TestEntityWithSpecialColumnNames> TestEntitiesWithSpecialColumnNames { get; set; } = null!;
1718
public DbSet<Student> Students { get; set; } = null!;
1819
public DbSet<Course> Courses { get; set; } = null!;
1920

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using System.ComponentModel.DataAnnotations.Schema;
3+
4+
using Microsoft.EntityFrameworkCore;
5+
6+
namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext;
7+
8+
/// <summary>
9+
/// Test entity with column names containing spaces and SQL reserved keywords.
10+
/// This is used to test that column names are properly quoted in SQL statements.
11+
/// </summary>
12+
[PrimaryKey(nameof(Id))]
13+
[Index(nameof(BusinessFunctionText), IsUnique = true)]
14+
[Table("test_entity_special_columns")]
15+
public class TestEntityWithSpecialColumnNames : TestEntityBase
16+
{
17+
public int Id { get; set; }
18+
19+
/// <summary>
20+
/// Column name with spaces and SQL reserved keyword "Function".
21+
/// </summary>
22+
[Column("Business Function Text")]
23+
[MaxLength(255)]
24+
public string BusinessFunctionText { get; set; } = string.Empty;
25+
26+
/// <summary>
27+
/// Column name with SQL reserved keyword "Order".
28+
/// </summary>
29+
[Column("Order Number")]
30+
public int OrderNumber { get; set; }
31+
32+
/// <summary>
33+
/// Regular column name for comparison.
34+
/// </summary>
35+
[Column("description")]
36+
[MaxLength(500)]
37+
public string Description { get; set; } = string.Empty;
38+
}

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+
}

tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Various/VariousTestsBase.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
using FluentAssertions;
22

3+
using PhenX.EntityFrameworkCore.BulkInsert.Enums;
4+
using PhenX.EntityFrameworkCore.BulkInsert.Extensions;
5+
using PhenX.EntityFrameworkCore.BulkInsert.Options;
36
using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer;
47
using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext;
58

@@ -42,4 +45,87 @@ public async Task InsertSmartEnumEntities(InsertStrategy strategy)
4245
insertedEntities.Should().BeEquivalentTo(entities,
4346
o => o.RespectingRuntimeTypes().Excluding(e => e.Id));
4447
}
48+
49+
/// <summary>
50+
/// Tests that column names with spaces and SQL reserved keywords are properly quoted.
51+
/// This addresses the issue where columns like "Business Function Text" were not being
52+
/// properly escaped, causing SQL syntax errors.
53+
/// </summary>
54+
[SkippableTheory]
55+
[CombinatorialData]
56+
public async Task InsertEntities_WithSpecialColumnNames(InsertStrategy strategy)
57+
{
58+
// Arrange
59+
var entities = new List<TestEntityWithSpecialColumnNames>
60+
{
61+
new TestEntityWithSpecialColumnNames
62+
{
63+
TestRun = _run,
64+
BusinessFunctionText = $"{_run}_BusinessFunction1",
65+
OrderNumber = 100,
66+
Description = "Test description 1"
67+
},
68+
new TestEntityWithSpecialColumnNames
69+
{
70+
TestRun = _run,
71+
BusinessFunctionText = $"{_run}_BusinessFunction2",
72+
OrderNumber = 200,
73+
Description = "Test description 2"
74+
}
75+
};
76+
77+
// Act
78+
var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities);
79+
80+
// Assert
81+
insertedEntities.Should().BeEquivalentTo(entities,
82+
o => o.RespectingRuntimeTypes().Excluding(e => e.Id));
83+
}
84+
85+
/// <summary>
86+
/// Tests that merge/upsert operations work correctly with column names containing
87+
/// spaces and SQL reserved keywords.
88+
/// </summary>
89+
[SkippableTheory]
90+
[CombinatorialData]
91+
public async Task InsertEntities_WithSpecialColumnNames_Merge(InsertStrategy strategy)
92+
{
93+
Skip.If(_context.IsProvider(ProviderType.MySql, ProviderType.Oracle));
94+
95+
// Arrange
96+
var entities = new List<TestEntityWithSpecialColumnNames>
97+
{
98+
new TestEntityWithSpecialColumnNames
99+
{
100+
TestRun = _run,
101+
BusinessFunctionText = $"{_run}_BusinessFunction1",
102+
OrderNumber = 100,
103+
Description = "Initial description"
104+
}
105+
};
106+
107+
// First insert
108+
await _context.InsertWithStrategyAsync(strategy, entities);
109+
110+
// Update entity for upsert
111+
entities[0].OrderNumber = 200;
112+
entities[0].Description = "Updated description";
113+
114+
// Act - Merge with update on conflict
115+
var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities, _ => { },
116+
onConflict: new OnConflictOptions<TestEntityWithSpecialColumnNames>
117+
{
118+
Match = e => new { e.BusinessFunctionText },
119+
Update = (inserted, excluded) => new TestEntityWithSpecialColumnNames
120+
{
121+
OrderNumber = excluded.OrderNumber,
122+
Description = excluded.Description
123+
}
124+
});
125+
126+
// Assert
127+
insertedEntities.Should().HaveCount(1);
128+
insertedEntities[0].OrderNumber.Should().Be(200);
129+
insertedEntities[0].Description.Should().Be("Updated description");
130+
}
45131
}

0 commit comments

Comments
 (0)