Skip to content

Commit 4913279

Browse files
authored
Enhance conflict resolution with support for raw SQL conditions and e… (#51)
* Enhance conflict resolution with support for raw SQL conditions and expressions in OnConflictOptions * Fix upsert for SQL server and add "excluded" to the update lambda * Factorize pseudo column names and fix comments * Change RawWhere to be a delegate
1 parent c0e0c0e commit 4913279

9 files changed

Lines changed: 314 additions & 80 deletions

File tree

README.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Install-Package PhenX.EntityFrameworkCore.BulkInsert.MySql
4343

4444
## Usage
4545

46-
1. Register the bulk insert provider in your `DbContextOptions`:
46+
Register the bulk insert provider in your `DbContextOptions`:
4747

4848
```csharp
4949
services.AddDbContext<MyDbContext>(options =>
@@ -62,7 +62,7 @@ services.AddDbContext<MyDbContext>(options =>
6262
});
6363
```
6464

65-
2. Use the bulk insert extension method:
65+
### Very basic usage
6666

6767
```csharp
6868
// Asynchronously
@@ -72,7 +72,7 @@ await dbContext.ExecuteBulkInsertAsync(entities);
7272
dbContext.ExecuteBulkInsert(entities);
7373
```
7474

75-
3. You can also configure the bulk insert options:
75+
### Bulk insert with options
7676

7777
```csharp
7878
// Common options
@@ -103,12 +103,47 @@ await dbContext.ExecuteBulkInsertAsync(entities, o =>
103103
});
104104
```
105105

106-
4. You can also return the inserted entities (slower):
106+
### Returning inserted entities
107107

108108
```csharp
109109
await dbContext.ExecuteBulkInsertReturnEntitiesAsync(entities);
110110
```
111111

112+
### Conflict resolution / merge / upsert
113+
114+
Conflict resolution works by specifying columns that should be used to detect conflicts and the action to take when
115+
a conflict is detected (e.g., update existing rows), using the `onConflict` parameter.
116+
117+
* The conflicting columns are specified with the `Match` property and must have a unique constraint in the database.
118+
* The action to take when a conflict is detected is specified with the `Update` property. If not specified, the default action is to do nothing (i.e., skip the conflicting rows).
119+
* You can also specify the condition for the update action using either the `Where` or the `RawWhere` property. If not specified, the update action will be applied to all conflicting rows.
120+
121+
```csharp
122+
await dbContext.ExecuteBulkInsertAsync(entities, onConflict: new OnConflictOptions<TestEntity>
123+
{
124+
Match = e => new
125+
{
126+
e.Name,
127+
// ...other columns to match on
128+
},
129+
130+
// Optional: specify the update action, if not specified, the default action is to do nothing
131+
// Excluded is the row being inserted which is in conflict, and Inserted is the row already in the database.
132+
Update = (inserted, excluded) => new TestEntity
133+
{
134+
Price = inserted.Price // Update the Price column with the new value
135+
},
136+
137+
// Optional: specify the condition for the update action
138+
// Excluded is the row being inserted which is in conflict, and Inserted is the row already in the database.
139+
// Using raw SQL condition
140+
RawWhere = (insertedTable, excludedTable) => $"{excludedTable}.some_price > {insertedTable}.some_price",
141+
142+
// OR using a lambda expression
143+
Where = (inserted, excluded) => excluded.Price > inserted.Price,
144+
});
145+
```
146+
112147
## Roadmap
113148

114149
- [ ] [Add support for navigation properties](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/2)

src/PhenX.EntityFrameworkCore.BulkInsert.MySql/MySqlDialectBuilder.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System.Text;
22

3+
using Microsoft.EntityFrameworkCore;
4+
35
using PhenX.EntityFrameworkCore.BulkInsert.Dialect;
46
using PhenX.EntityFrameworkCore.BulkInsert.Metadata;
57
using PhenX.EntityFrameworkCore.BulkInsert.Options;
@@ -12,14 +14,22 @@ internal class MySqlServerDialectBuilder : SqlDialectBuilder
1214

1315
protected override string CloseDelimiter => "`";
1416

17+
/// <inheritdoc />
1518
protected override bool SupportsMoveRows => false;
1619

20+
/// <inheritdoc />
21+
protected override bool SupportsInsertIntoAlias => false;
22+
1723
public override string CreateTableCopySql(string tempNameName, TableMetadata tableInfo, IReadOnlyList<ColumnMetadata> columns)
1824
{
1925
return $"CREATE TEMPORARY TABLE {tempNameName} SELECT * FROM {tableInfo.QuotedTableName} WHERE 1 = 0;";
2026
}
2127

22-
protected override void AppendConflictCondition<T>(StringBuilder sql, OnConflictOptions<T> onConflictTyped)
28+
protected override void AppendConflictCondition<T>(
29+
StringBuilder sql,
30+
TableMetadata target,
31+
DbContext context,
32+
OnConflictOptions<T> onConflictTyped)
2333
{
2434
throw new NotSupportedException("Conflict conditions are not supported in MYSQL");
2535
}

src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerDialectBuilder.cs

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System.Text;
22

3+
using Microsoft.EntityFrameworkCore;
4+
35
using PhenX.EntityFrameworkCore.BulkInsert.Dialect;
46
using PhenX.EntityFrameworkCore.BulkInsert.Metadata;
57
using PhenX.EntityFrameworkCore.BulkInsert.Options;
@@ -34,7 +36,10 @@ public override string CreateTableCopySql(string tempTableName, TableMetadata ta
3436
return q.ToString();
3537
}
3638

39+
protected override string Trim(string lhs) => $"TRIM({lhs})";
40+
3741
public override string BuildMoveDataSql<T>(
42+
DbContext context,
3843
TableMetadata target,
3944
string source,
4045
IReadOnlyList<ColumnMetadata> insertedColumns,
@@ -66,24 +71,37 @@ public override string BuildMoveDataSql<T>(
6671
throw new InvalidOperationException("Table has no primary key that can be used for conflict detection.");
6772
}
6873

69-
q.AppendLine($"MERGE INTO {target.QuotedTableName} AS TARGET");
74+
q.AppendLine($"MERGE INTO {target.QuotedTableName} AS {PseudoTableInserted}");
7075

7176
q.Append("USING (SELECT ");
7277
q.AppendColumns(insertedColumns);
73-
q.Append($" FROM {source}) AS SOURCE (");
78+
q.Append($" FROM {source}) AS {PseudoTableExcluded} (");
7479
q.AppendColumns(insertedColumns);
7580
q.AppendLine(")");
7681

7782
q.Append("ON ");
78-
q.AppendJoin(" AND ", matchColumns, (b, col) => b.Append($"TARGET.{col} = SOURCE.{col}"));
83+
q.AppendJoin(" AND ", matchColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col} = {PseudoTableExcluded}.{col}"));
7984
q.AppendLine();
8085

8186
if (onConflictTyped.Update != null)
8287
{
8388
var columns = target.GetColumns(false);
8489

85-
q.AppendLine("WHEN MATCHED THEN UPDATE SET ");
86-
q.AppendJoin(", ", GetUpdates(target, columns, onConflictTyped.Update));
90+
q.AppendLine("WHEN MATCHED ");
91+
92+
if (onConflictTyped.RawWhere != null || onConflictTyped.Where != null)
93+
{
94+
if (onConflictTyped is { RawWhere: not null, Where: not null })
95+
{
96+
throw new ArgumentException("Cannot specify both RawWhere and Where in OnConflictOptions.");
97+
}
98+
99+
q.Append("AND ");
100+
AppendConflictCondition(q, target, context, onConflictTyped);
101+
}
102+
103+
q.AppendLine("THEN UPDATE SET ");
104+
q.AppendJoin(", ", GetUpdates(context, target, columns, onConflictTyped.Update));
87105
q.AppendLine();
88106
}
89107

@@ -92,13 +110,13 @@ public override string BuildMoveDataSql<T>(
92110
q.AppendLine(")");
93111

94112
q.Append("VALUES (");
95-
q.AppendJoin(", ", insertedColumns, (b, col) => b.Append($"SOURCE.{col.QuotedColumName}"));
113+
q.AppendJoin(", ", insertedColumns, (b, col) => b.Append($"{PseudoTableExcluded}.{col.QuotedColumName}"));
96114
q.AppendLine(")");
97115

98116
if (returnedColumns.Count != 0)
99117
{
100118
q.Append("OUTPUT ");
101-
q.AppendJoin(", ", returnedColumns, (b, col) => b.Append($"INSERTED.{col.QuotedColumName} AS {col.QuotedColumName}"));
119+
q.AppendJoin(", ", returnedColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col.QuotedColumName} AS {col.QuotedColumName}"));
102120
q.AppendLine();
103121
}
104122
}
@@ -113,7 +131,7 @@ public override string BuildMoveDataSql<T>(
113131
if (returnedColumns.Count != 0)
114132
{
115133
q.Append("OUTPUT ");
116-
q.AppendJoin(", ", returnedColumns, (b, col) => b.Append($"INSERTED.{col.QuotedColumName} AS {col.QuotedColumName}"));
134+
q.AppendJoin(", ", returnedColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col.QuotedColumName} AS {col.QuotedColumName}"));
117135
q.AppendLine();
118136
}
119137

@@ -131,12 +149,6 @@ public override string BuildMoveDataSql<T>(
131149
q.AppendLine($"SET IDENTITY_INSERT {target.QuotedTableName} OFF;");
132150
}
133151

134-
var result = q.ToString();
135-
return result;
136-
}
137-
138-
protected override string GetExcludedColumnName(string columnName)
139-
{
140-
return $"SOURCE.{columnName}";
152+
return q.ToString();
141153
}
142154
}

src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteDialectBuilder.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ public override string CreateTableCopySql(string tempNameName, TableMetadata tab
1515
{
1616
return $"CREATE TEMP TABLE {tempNameName} AS SELECT * FROM {tableInfo.QuotedTableName} WHERE 0;";
1717
}
18+
19+
protected override string Trim(string lhs) => $"TRIM({lhs})";
1820
}

src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Runtime.CompilerServices;
2-
using System.Runtime.InteropServices;
32

43
using Microsoft.EntityFrameworkCore;
54
using Microsoft.EntityFrameworkCore.Storage;
@@ -202,6 +201,7 @@ protected virtual async Task AddBulkInsertIdColumn<T>(
202201
{
203202
var query =
204203
SqlDialect.BuildMoveDataSql<T>(
204+
context,
205205
tableInfo,
206206
tempTableName,
207207
tableInfo.GetColumns(options.CopyGeneratedColumns),

0 commit comments

Comments
 (0)