Skip to content

Commit 6b6b003

Browse files
author
fabien.menager
committed
Code cleanup and handle providers that don't support inserted ids when trying to include graph
1 parent d1f0632 commit 6b6b003

10 files changed

Lines changed: 146 additions & 110 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ internal class MySqlBulkInsertProvider(ILogger<MySqlBulkInsertProvider> logger)
1818
/// <inheritdoc />
1919
protected override string AddTableCopyBulkInsertId => $"ALTER TABLE {{0}} ADD {BulkInsertId} INT AUTO_INCREMENT PRIMARY KEY;";
2020

21+
/// <inheritdoc />
22+
public override bool SupportsOutputInsertedIds => false;
23+
2124
/// <inheritdoc />
2225
protected override string GetTempTableName(string tableName) => $"#_temp_bulk_insert_{tableName}_{Helpers.RandomString(6)}";
2326

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ internal class OracleBulkInsertProvider(ILogger<OracleBulkInsertProvider>? logge
1919
/// <inheritdoc />
2020
protected override string AddTableCopyBulkInsertId => ""; // No need to add an ID column in Oracle
2121

22+
/// <inheritdoc />
23+
public override bool SupportsOutputInsertedIds => false;
24+
2225
/// <inheritdoc />
2326
/// <summary>
2427
/// The temporary table name is generated with a random 8-character suffix to ensure uniqueness, and is limited to less than 30 characters,

src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,9 @@ public static async Task ExecuteBulkInsertAsync<T, TOptions>(
167167
"Either disable IncludeGraph or remove the onConflict parameter.");
168168
}
169169

170-
var orchestrator = new GraphBulkInsertOrchestrator();
171-
await orchestrator.InsertGraphAsync(context, entities, options, provider, cancellationToken);
170+
var orchestrator = new GraphBulkInsertOrchestrator(context);
171+
await orchestrator.InsertGraph(false, entities, options, provider, cancellationToken);
172+
172173
return;
173174
}
174175

@@ -228,9 +229,10 @@ public static void ExecuteBulkInsert<T, TOptions>(
228229
"Either disable IncludeGraph or remove the onConflict parameter.");
229230
}
230231

231-
var orchestrator = new GraphBulkInsertOrchestrator();
232-
orchestrator.InsertGraphAsync(context, entities, options, provider, CancellationToken.None)
232+
var orchestrator = new GraphBulkInsertOrchestrator(context);
233+
orchestrator.InsertGraph(true, entities, options, provider, CancellationToken.None)
233234
.GetAwaiter().GetResult();
235+
234236
return;
235237
}
236238

src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs

Lines changed: 39 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,14 @@
44

55
using Microsoft.EntityFrameworkCore;
66
using Microsoft.EntityFrameworkCore.Infrastructure;
7-
using Microsoft.EntityFrameworkCore.Metadata;
7+
using Microsoft.Extensions.Logging;
88

99
using PhenX.EntityFrameworkCore.BulkInsert.Abstractions;
1010
using PhenX.EntityFrameworkCore.BulkInsert.Metadata;
1111
using PhenX.EntityFrameworkCore.BulkInsert.Options;
1212

1313
namespace PhenX.EntityFrameworkCore.BulkInsert.Graph;
1414

15-
/// <summary>
16-
/// Result of a graph insert operation.
17-
/// </summary>
18-
/// <typeparam name="T">The root entity type.</typeparam>
19-
internal sealed class GraphInsertResult<T> where T : class
20-
{
21-
/// <summary>
22-
/// The root entities that were inserted.
23-
/// </summary>
24-
public required IReadOnlyList<T> RootEntities { get; init; }
25-
26-
/// <summary>
27-
/// Total count of all entities inserted across all types.
28-
/// </summary>
29-
public required int TotalInsertedCount { get; init; }
30-
}
31-
3215
/// <summary>
3316
/// Orchestrates bulk insertion of entity graphs with FK propagation.
3417
/// </summary>
@@ -37,25 +20,35 @@ internal sealed class GraphBulkInsertOrchestrator
3720
private static readonly ConcurrentDictionary<(Type, string), Action<object, object?>> PropertySetters = new();
3821
private static readonly ConcurrentDictionary<(Type, string), Func<object, object?>> PropertyGetters = new();
3922

23+
private readonly DbContext _context;
4024
private readonly MetadataProvider _metadataProvider;
25+
private readonly ILogger<GraphBulkInsertOrchestrator>? _logger;
4126

42-
public GraphBulkInsertOrchestrator()
27+
public GraphBulkInsertOrchestrator(DbContext context)
4328
{
44-
_metadataProvider = new MetadataProvider();
29+
_context = context;
30+
_metadataProvider = context.GetService<MetadataProvider>();
31+
_logger = context.GetService<ILogger<GraphBulkInsertOrchestrator>>();
4532
}
4633

4734
/// <summary>
4835
/// Orchestrates the bulk insert of an entity graph.
4936
/// </summary>
50-
public async Task<GraphInsertResult<T>> InsertGraphAsync<T>(
51-
DbContext context,
37+
public async Task<GraphInsertResult<T>> InsertGraph<T>(
38+
bool sync,
5239
IEnumerable<T> entities,
5340
BulkInsertOptions options,
5441
IBulkInsertProvider provider,
5542
CancellationToken ctk) where T : class
5643
{
44+
if (!provider.SupportsOutputInsertedIds)
45+
{
46+
throw new NotSupportedException(
47+
$"The bulk insert provider '{provider.GetType().Name}' does not support returning generated IDs, which is required for IncludeGraph operations.");
48+
}
49+
5750
// 1. Collect and sort entities
58-
var collector = new GraphEntityCollector(context, options);
51+
var collector = new GraphEntityCollector(_context, options);
5952
var collectionResult = collector.Collect(entities);
6053

6154
if (collectionResult.EntitiesByType.Count == 0)
@@ -68,7 +61,7 @@ public async Task<GraphInsertResult<T>> InsertGraphAsync<T>(
6861
}
6962

7063
var totalInserted = 0;
71-
var graphMetadata = new GraphMetadata(context, options);
64+
var graphMetadata = new GraphMetadata(_context, options);
7265

7366
// 2. Insert in dependency order (parents first)
7467
foreach (var entityType in collectionResult.InsertionOrder)
@@ -80,18 +73,18 @@ public async Task<GraphInsertResult<T>> InsertGraphAsync<T>(
8073
}
8174

8275
// Propagate FK values from already-inserted parents
83-
PropagateParentForeignKeys(entitiesToInsert, entityType, graphMetadata, context);
76+
PropagateParentForeignKeys(entitiesToInsert, entityType, graphMetadata);
8477

8578
// Insert entities of this type
86-
await InsertEntitiesOfTypeAsync(context, entityType, entitiesToInsert, options, provider, ctk);
79+
await InsertEntitiesOfType(sync, _context, entityType, entitiesToInsert, options, provider, ctk);
8780

8881
totalInserted += entitiesToInsert.Count;
8982
}
9083

9184
// 3. Insert join table records for many-to-many relationships
9285
if (collectionResult.JoinRecords.Count > 0)
9386
{
94-
await InsertJoinRecordsAsync(context, collectionResult.JoinRecords, options, provider, ctk);
87+
await InsertJoinRecords(sync, _context, collectionResult.JoinRecords, options, provider, ctk);
9588
}
9689

9790
// Return root entities
@@ -109,8 +102,7 @@ public async Task<GraphInsertResult<T>> InsertGraphAsync<T>(
109102
private static void PropagateParentForeignKeys(
110103
List<object> entities,
111104
Type entityType,
112-
GraphMetadata graphMetadata,
113-
DbContext context)
105+
GraphMetadata graphMetadata)
114106
{
115107
var efEntityType = graphMetadata.GetEntityType(entityType);
116108
if (efEntityType == null)
@@ -162,7 +154,8 @@ private static void PropagateParentForeignKeys(
162154
}
163155
}
164156

165-
private async Task InsertEntitiesOfTypeAsync(
157+
private async Task InsertEntitiesOfType(
158+
bool sync,
166159
DbContext context,
167160
Type entityType,
168161
List<object> entities,
@@ -218,7 +211,7 @@ private async Task InsertEntitiesGenericAsync<TEntity>(
218211
}
219212
}
220213

221-
private static void CopyGeneratedIds<TEntity>(
214+
private void CopyGeneratedIds<TEntity>(
222215
List<TEntity> originalEntities,
223216
List<TEntity> insertedEntities,
224217
TableMetadata tableInfo) where TEntity : class
@@ -228,10 +221,10 @@ private static void CopyGeneratedIds<TEntity>(
228221
// Count mismatch - this can happen if the bulk insert operation
229222
// doesn't preserve order. Log a warning for debugging purposes.
230223
// The graph insert will continue but FK propagation may be incomplete.
231-
System.Diagnostics.Debug.WriteLine(
232-
$"Warning: IncludeGraph ID propagation failed for {typeof(TEntity).Name}. " +
233-
$"Original count: {originalEntities.Count}, Inserted count: {insertedEntities.Count}. " +
234-
"Foreign key values may not be correctly propagated to dependent entities.");
224+
_logger?.LogWarning(
225+
"IncludeGraph ID propagation failed for {EntityType}. Original count: {OriginalCount}, Inserted count: {InsertedCount}. Foreign key values may not be correctly propagated to dependent entities.",
226+
typeof(TEntity).Name, originalEntities.Count, insertedEntities.Count);
227+
235228
return;
236229
}
237230

@@ -254,7 +247,8 @@ private static void CopyGeneratedIds<TEntity>(
254247
}
255248
}
256249

257-
private async Task InsertJoinRecordsAsync(
250+
private async Task InsertJoinRecords(
251+
bool sync,
258252
DbContext context,
259253
List<JoinRecord> joinRecords,
260254
BulkInsertOptions options,
@@ -293,9 +287,11 @@ private async Task InsertJoinRecordsAsync(
293287
var joinEntry = Activator.CreateInstance(joinEntityType);
294288
if (joinEntry == null)
295289
{
296-
System.Diagnostics.Debug.WriteLine(
297-
$"Warning: IncludeGraph failed to create join entry for {joinEntityType.Name}. " +
298-
"Many-to-many relationship may be incomplete.");
290+
_logger?.LogWarning(
291+
"IncludeGraph failed to create join entry for {EntityType}. Many-to-many relationship may be incomplete.",
292+
joinEntityType.Name
293+
);
294+
299295
continue;
300296
}
301297

@@ -342,12 +338,13 @@ private async Task InsertJoinRecordsAsync(
342338
if (joinEntities.Count > 0)
343339
{
344340
// Insert join entities
345-
await InsertJoinEntitiesAsync(context, joinEntityType, joinEntities, options, provider, ctk);
341+
await InsertJoinEntities(sync, context, joinEntityType, joinEntities, options, provider, ctk);
346342
}
347343
}
348344
}
349345

350-
private async Task InsertJoinEntitiesAsync(
346+
private async Task InsertJoinEntities(
347+
bool sync,
351348
DbContext context,
352349
Type joinEntityType,
353350
List<object> joinEntities,
@@ -369,7 +366,7 @@ private async Task InsertJoinEntitiesAsync(
369366
.GetMethod(nameof(IBulkInsertProvider.BulkInsert))!
370367
.MakeGenericMethod(joinEntityType);
371368

372-
var result = method.Invoke(provider, [false, context, tableInfo, joinEntities, options, null, ctk]);
369+
var result = method.Invoke(provider, [sync, context, tableInfo, joinEntities, options, null, ctk]);
373370
if (result is not Task task)
374371
{
375372
throw new InvalidOperationException(
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace PhenX.EntityFrameworkCore.BulkInsert.Graph;
2+
3+
/// <summary>
4+
/// Result of collecting entities from an object graph.
5+
/// </summary>
6+
internal sealed class GraphCollectionResult
7+
{
8+
/// <summary>
9+
/// Entities grouped by type.
10+
/// </summary>
11+
public required Dictionary<Type, List<object>> EntitiesByType { get; init; }
12+
13+
/// <summary>
14+
/// Types in topological insertion order (parents before children).
15+
/// </summary>
16+
public required IReadOnlyList<Type> InsertionOrder { get; init; }
17+
18+
/// <summary>
19+
/// Many-to-many join records to insert after both sides are inserted.
20+
/// </summary>
21+
public required List<JoinRecord> JoinRecords { get; init; }
22+
}

src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs

Lines changed: 38 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -8,38 +8,6 @@
88

99
namespace PhenX.EntityFrameworkCore.BulkInsert.Graph;
1010

11-
/// <summary>
12-
/// Result of collecting entities from an object graph.
13-
/// </summary>
14-
internal sealed class GraphCollectionResult
15-
{
16-
/// <summary>
17-
/// Entities grouped by type.
18-
/// </summary>
19-
public required Dictionary<Type, List<object>> EntitiesByType { get; init; }
20-
21-
/// <summary>
22-
/// Types in topological insertion order (parents before children).
23-
/// </summary>
24-
public required IReadOnlyList<Type> InsertionOrder { get; init; }
25-
26-
/// <summary>
27-
/// Many-to-many join records to insert after both sides are inserted.
28-
/// </summary>
29-
public required List<JoinRecord> JoinRecords { get; init; }
30-
}
31-
32-
/// <summary>
33-
/// Represents a join table record for many-to-many relationships.
34-
/// </summary>
35-
internal sealed class JoinRecord
36-
{
37-
public required Type JoinEntityType { get; init; }
38-
public required object LeftEntity { get; init; }
39-
public required object RightEntity { get; init; }
40-
public required NavigationMetadata Navigation { get; init; }
41-
}
42-
4311
/// <summary>
4412
/// Collects all entities from an object graph for bulk insertion.
4513
/// </summary>
@@ -82,20 +50,27 @@ public GraphCollectionResult Collect<T>(IEnumerable<T> rootEntities) where T : c
8250
};
8351
}
8452

85-
private void CollectEntity(object entity, int depth)
53+
private void CollectEntity(object? entity, int depth)
8654
{
87-
if (entity == null || !_visited.Add(entity))
55+
if (entity == null)
8856
{
89-
// Already visited or null
57+
// Null entity, nothing to collect
9058
return;
9159
}
9260

93-
// Check max depth
61+
// Check max depth before marking as visited to avoid permanently
62+
// excluding entities that might be reachable at a valid depth later.
9463
if (_options.MaxGraphDepth > 0 && depth > _options.MaxGraphDepth)
9564
{
9665
return;
9766
}
9867

68+
if (!_visited.Add(entity))
69+
{
70+
// Already visited
71+
return;
72+
}
73+
9974
var entityType = entity.GetType();
10075
var efEntityType = _graphMetadata.GetEntityType(entityType);
10176

@@ -133,33 +108,37 @@ private void CollectEntity(object entity, int depth)
133108

134109
if (navigation.IsCollection)
135110
{
136-
if (value is IEnumerable collection)
111+
if (value is not IEnumerable collection)
137112
{
138-
foreach (var item in collection)
113+
continue;
114+
}
115+
116+
foreach (var item in collection)
117+
{
118+
if (item == null)
119+
{
120+
continue;
121+
}
122+
123+
if (navigation.IsManyToMany)
139124
{
140-
if (item != null)
125+
// Record join table entry
126+
_joinRecords.Add(new JoinRecord
141127
{
142-
if (navigation.IsManyToMany)
143-
{
144-
// Record join table entry
145-
_joinRecords.Add(new JoinRecord
146-
{
147-
JoinEntityType = navigation.JoinEntityType!.ClrType,
148-
LeftEntity = entity,
149-
RightEntity = item,
150-
Navigation = navigation,
151-
});
152-
}
153-
else
154-
{
155-
// For one-to-many, set the inverse navigation property
156-
// so that FK propagation can find the parent
157-
SetInverseNavigation(entity, item, navigation);
158-
}
159-
160-
CollectEntity(item, depth + 1);
161-
}
128+
JoinEntityType = navigation.JoinEntityType!.ClrType,
129+
LeftEntity = entity,
130+
RightEntity = item,
131+
Navigation = navigation,
132+
});
162133
}
134+
else
135+
{
136+
// For one-to-many, set the inverse navigation property
137+
// so that FK propagation can find the parent
138+
SetInverseNavigation(entity, item, navigation);
139+
}
140+
141+
CollectEntity(item, depth + 1);
163142
}
164143
}
165144
else

0 commit comments

Comments
 (0)