Skip to content

Commit d927f1f

Browse files
CopilotPhenX
andcommitted
Address code review feedback for IncludeGraph feature
Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com>
1 parent 2c994ae commit d927f1f

4 files changed

Lines changed: 52 additions & 20 deletions

File tree

docs/graph-insert.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Graph Insert (Navigation Properties)
22

3-
> ℹ️ This feature is not available for Oracle and MySQL providers due to limitations in retrieving generated IDs.
3+
> ℹ️ Graph inserts that require database-generated key propagation are not supported for Oracle and MySQL providers due to limitations in retrieving generated IDs. Graph inserts using client-generated keys (e.g., GUIDs with `ValueGeneratedNever()`) are supported on all providers.
44
55
This library supports bulk inserting entire object graphs, including entities with their related navigation properties.
66

@@ -19,7 +19,7 @@ await dbContext.ExecuteBulkInsertAsync(blogs, options =>
1919
2. Entities are sorted in topological order (parents before children) to respect foreign key constraints
2020
3. Each entity type is bulk inserted in dependency order
2121
4. Generated IDs (identity columns) are propagated to foreign key properties
22-
5. Many-to-many join tables are populated automatically
22+
5. Many-to-many join tables with explicit join entity types are populated automatically (see Limitations below)
2323

2424
## Options
2525

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

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,12 @@ private async Task InsertEntitiesOfType(
177177
.GetMethod(nameof(InsertEntitiesGenericAsync), BindingFlags.NonPublic | BindingFlags.Instance)!
178178
.MakeGenericMethod(entityType);
179179

180-
var task = (Task)method.Invoke(this, [context, entities, options, provider, graphMetadata, ctk])!;
180+
var task = (Task)method.Invoke(this, [sync, context, entities, options, provider, graphMetadata, ctk])!;
181181
await task;
182182
}
183183

184184
private async Task InsertEntitiesGenericAsync<TEntity>(
185+
bool sync,
185186
DbContext context,
186187
List<object> entities,
187188
BulkInsertOptions options,
@@ -200,7 +201,7 @@ private async Task InsertEntitiesGenericAsync<TEntity>(
200201
// Use BulkInsertReturnEntities to get back the generated IDs
201202
var insertedEntities = new List<TEntity>();
202203
await foreach (var inserted in provider.BulkInsertReturnEntities(
203-
false,
204+
sync,
204205
context,
205206
tableInfo,
206207
typedEntities,
@@ -221,7 +222,7 @@ private async Task InsertEntitiesGenericAsync<TEntity>(
221222
else
222223
{
223224
// No identity columns, just insert directly
224-
await provider.BulkInsert(false, context, tableInfo, typedEntities, options, null, ctk);
225+
await provider.BulkInsert(sync, context, tableInfo, typedEntities, options, null, ctk);
225226
}
226227
}
227228

@@ -380,6 +381,18 @@ private async Task InsertJoinEntities(
380381
IBulkInsertProvider provider,
381382
CancellationToken ctk)
382383
{
384+
// Skip dictionary-based shared-type join entities as they are not supported
385+
// by the bulk insert infrastructure (requires typed IEnumerable<T>)
386+
if (joinEntityType == typeof(Dictionary<string, object>) ||
387+
typeof(IDictionary<string, object>).IsAssignableFrom(joinEntityType))
388+
{
389+
_logger?.LogWarning(
390+
"IncludeGraph: Skipping join table insertion for shared-type entity (Dictionary<string, object>). " +
391+
"Many-to-many relationships using implicit join tables are not supported. " +
392+
"Consider using an explicit join entity type.");
393+
return;
394+
}
395+
383396
var efEntityType = context.Model.FindEntityType(joinEntityType);
384397
if (efEntityType == null)
385398
{
@@ -389,17 +402,26 @@ private async Task InsertJoinEntities(
389402
var sqlDialect = provider.SqlDialect;
390403
var tableInfo = new TableMetadata(efEntityType, sqlDialect);
391404

392-
// Use raw SQL insert for join entities since they're often dictionary-based
393-
var method = typeof(IBulkInsertProvider)
394-
.GetMethod(nameof(IBulkInsertProvider.BulkInsert))!
405+
// Use reflection to call the generic BulkInsert method with correctly typed entities
406+
var method = typeof(GraphBulkInsertOrchestrator)
407+
.GetMethod(nameof(InsertJoinEntitiesGenericAsync), BindingFlags.NonPublic | BindingFlags.Instance)!
395408
.MakeGenericMethod(joinEntityType);
396409

397-
var result = method.Invoke(provider, [sync, context, tableInfo, joinEntities, options, null, ctk]);
398-
if (result is not Task task)
399-
{
400-
throw new InvalidOperationException(
401-
$"The BulkInsert method for join entity type '{joinEntityType.Name}' did not return a Task as expected.");
402-
}
410+
var task = (Task)method.Invoke(this, [sync, context, tableInfo, joinEntities, options, provider, ctk])!;
403411
await task;
404412
}
413+
414+
private async Task InsertJoinEntitiesGenericAsync<TJoin>(
415+
bool sync,
416+
DbContext context,
417+
TableMetadata tableInfo,
418+
List<object> joinEntities,
419+
BulkInsertOptions options,
420+
IBulkInsertProvider provider,
421+
CancellationToken ctk) where TJoin : class
422+
{
423+
// Cast to correctly typed list for the provider
424+
var typedEntities = joinEntities.Cast<TJoin>().ToList();
425+
await provider.BulkInsert(sync, context, tableInfo, typedEntities, options, null, ctk);
426+
}
405427
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections;
22

33
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.EntityFrameworkCore.Metadata;
45

56
using PhenX.EntityFrameworkCore.BulkInsert.Metadata;
67
using PhenX.EntityFrameworkCore.BulkInsert.Options;
@@ -152,6 +153,15 @@ private static void SetInverseNavigation(object parentEntity, object childEntity
152153
return;
153154
}
154155

156+
// Only set inverse navigations that are reference properties (not collections).
157+
// If the inverse is a collection, the parent should be added to the collection,
158+
// not assigned directly (which would cause an invalid cast).
159+
if (navigation.Navigation is INavigation { Inverse.IsCollection: true })
160+
{
161+
// Skip: inverse is a collection, not a reference property
162+
return;
163+
}
164+
155165
// Check if the inverse navigation is already set
156166
var currentValue = navigation.GetInverseValue(childEntity);
157167
if (currentValue == null)

tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -592,17 +592,17 @@ await _context.ExecuteBulkInsertAsync(blogs, options =>
592592
[SkippableFact]
593593
public async Task InsertGraph_LargeScale()
594594
{
595-
// Arrange - Create many blogs with many children each (Posts, Tags, BlogSettings)
596-
// This tests performance and correctness at scale
597-
const int blogCount = 5000;
598-
const int postsPerBlog = 100;
599-
const int tagsPerPost = 10;
595+
// Arrange - Create blogs with children each (Posts, Tags, BlogSettings)
596+
// This tests correctness with a reasonable amount of data that won't cause CI timeouts
597+
const int blogCount = 50;
598+
const int postsPerBlog = 10;
599+
const int tagsPerPost = 3;
600600

601601
var blogs = new List<Blog>();
602602
var allTags = new List<Tag>();
603603

604604
// Pre-create a pool of tags that will be shared across posts
605-
for (var i = 0; i < 50; i++)
605+
for (var i = 0; i < 20; i++)
606606
{
607607
allTags.Add(new Tag
608608
{

0 commit comments

Comments
 (0)