From c0b4240b390ef8839b81d9d1ee799b069afa1755 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 07:45:43 +0000 Subject: [PATCH 01/26] Initial plan From e1aa4deb89f77205ccb40e3183e9cd8b1104320b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 08:04:23 +0000 Subject: [PATCH 02/26] Add IncludeGraph support for full object graph bulk insert Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- README.md | 25 +- docs/documentation.md | 33 ++ docs/graph-insert.md | 132 ++++++ docs/limitations.md | 2 +- .../Abstractions/IBulkInsertProvider.cs | 6 + .../BulkInsertProviderUntyped.cs | 6 + .../Extensions/PublicExtensions.DbSet.cs | 16 + .../Graph/GraphBulkInsertOrchestrator.cs | 396 ++++++++++++++++++ .../Graph/GraphEntityCollector.cs | 199 +++++++++ .../Metadata/GraphMetadata.cs | 160 +++++++ .../Metadata/NavigationMetadata.cs | 108 +++++ .../Options/BulkInsertOptions.cs | 24 ++ .../DbContext/Blog.cs | 15 + .../DbContext/BlogSettings.cs | 15 + .../DbContext/Category.cs | 16 + .../DbContext/Post.cs | 16 + .../DbContext/Tag.cs | 14 + .../DbContext/TestDbContext.cs | 41 ++ .../Tests/Graph/GraphTestsBase.cs | 362 ++++++++++++++++ .../Tests/Graph/GraphTestsMySql.cs | 12 + .../Tests/Graph/GraphTestsOracle.cs | 12 + .../Tests/Graph/GraphTestsPostgreSql.cs | 12 + .../Tests/Graph/GraphTestsSqlServer.cs | 12 + .../Tests/Graph/GraphTestsSqlite.cs | 12 + 24 files changed, 1644 insertions(+), 2 deletions(-) create mode 100644 docs/graph-insert.md create mode 100644 src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs create mode 100644 src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs create mode 100644 src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs create mode 100644 src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Blog.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/BlogSettings.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Category.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Post.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Tag.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsMySql.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsOracle.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsPostgreSql.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsSqlServer.cs create mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsSqlite.cs diff --git a/README.md b/README.md index efefd14..921b254 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,29 @@ await dbContext.ExecuteBulkInsertAsync(entities, o => await dbContext.ExecuteBulkInsertReturnEntitiesAsync(entities); ``` +### Insert with navigation properties (Graph Insert) + +Insert entities with their related navigation properties: + +```csharp +var blogs = new List +{ + new Blog + { + Name = "Blog 1", + Posts = new List + { + new Post { Title = "Post 1" }, + new Post { Title = "Post 2" } + } + } +}; + +await dbContext.ExecuteBulkInsertAsync(blogs, o => o.IncludeGraph = true); +``` + +See [Graph Insert documentation](https://phenx.github.io/PhenX.EntityFrameworkCore.BulkInsert/graph-insert.html) for details. + ### Conflict resolution / merge / upsert Conflict resolution works by specifying columns that should be used to detect conflicts and the action to take when @@ -152,7 +175,7 @@ await dbContext.ExecuteBulkInsertAsync(entities, onConflict: new OnConflictOptio ## Roadmap -- [ ] [Add support for navigation properties](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/2) +- [x] [Add support for navigation properties](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/2) - [x] [Add support for complex types](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/3) - [x] Add support for owned types - [ ] Add support for shadow properties diff --git a/docs/documentation.md b/docs/documentation.md index e58fc0d..728f964 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -204,3 +204,36 @@ Enable streaming bulk copy for SQL Server * Default: `unset` (PostgreSQL only) Custom PostgreSQL type providers for handling specific data types. + +### IncludeGraph + +* Type: `bool` +* Default: `false` + +When enabled, recursively inserts all reachable entities via navigation properties. +This includes one-to-one, one-to-many, many-to-one, and many-to-many relationships. + +See [Graph Insert documentation](./graph-insert.md) for details. + +### MaxGraphDepth + +* Type: `int` +* Default: `0` (unlimited) + +Maximum depth for graph traversal when `IncludeGraph` is enabled. +Use 0 for unlimited depth. + +### IncludeNavigations + +* Type: `HashSet?` +* Default: `null` (all navigations) + +Navigation properties to explicitly include when `IncludeGraph` is enabled. +If empty and `IncludeGraph` is true, all navigation properties are included. + +### ExcludeNavigations + +* Type: `HashSet?` +* Default: `null` (none) + +Navigation properties to explicitly exclude when `IncludeGraph` is enabled. diff --git a/docs/graph-insert.md b/docs/graph-insert.md new file mode 100644 index 0000000..da2b7cd --- /dev/null +++ b/docs/graph-insert.md @@ -0,0 +1,132 @@ +# Graph Insert (Navigation Properties) + +This library supports bulk inserting entire object graphs, including entities with their related navigation properties. + +## Enabling Graph Insert + +```csharp +await dbContext.ExecuteBulkInsertAsync(blogs, options => +{ + options.IncludeGraph = true; +}); +``` + +## How It Works + +1. The library traverses all reachable entities via navigation properties +2. Entities are sorted in topological order (parents before children) to respect foreign key constraints +3. Each entity type is bulk inserted in dependency order +4. Generated IDs (identity columns) are propagated to foreign key properties +5. Many-to-many join tables are populated automatically + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `IncludeGraph` | `false` | Enable graph traversal | +| `MaxGraphDepth` | `0` (unlimited) | Maximum depth to traverse. Use 0 for unlimited. | +| `IncludeNavigations` | `null` (all) | Specific navigation property names to include | +| `ExcludeNavigations` | `null` (none) | Navigation property names to exclude | + +## Supported Relationship Types + +- ✅ One-to-Many (e.g., Blog → Posts) +- ✅ Many-to-One (e.g., Post → Blog) +- ✅ One-to-One (e.g., Blog → BlogSettings) +- ✅ Many-to-Many with join table (e.g., Post ↔ Tags) +- ✅ Self-referencing/Hierarchies (e.g., Category → Parent/Children) + +## Performance Considerations + +- Graph insert is inherently slower than flat insert due to FK propagation overhead +- For entities with identity columns, the library uses `ExecuteBulkInsertReturnEntities` internally to retrieve generated IDs +- Consider using client-generated keys (GUIDs with `ValueGeneratedNever()`) to avoid ID propagation overhead +- Use `MaxGraphDepth` to limit traversal for large/deep graphs +- Use `IncludeNavigations` or `ExcludeNavigations` to reduce the scope of insertions + +## Example + +### One-to-Many Relationship + +```csharp +var blog = new Blog +{ + Name = "My Blog", + Posts = new List + { + new Post { Title = "First Post" }, + new Post { Title = "Second Post" } + } +}; + +await dbContext.ExecuteBulkInsertAsync(new[] { blog }, o => o.IncludeGraph = true); + +// After insert: +// - blog.Id is populated +// - blog.Posts[0].BlogId == blog.Id +// - blog.Posts[1].BlogId == blog.Id +``` + +### One-to-One Relationship + +```csharp +var blog = new Blog +{ + Name = "My Blog", + Settings = new BlogSettings { EnableComments = true } +}; + +await dbContext.ExecuteBulkInsertAsync(new[] { blog }, o => o.IncludeGraph = true); + +// After insert: +// - blog.Id is populated +// - blog.Settings.BlogId == blog.Id +``` + +### Selective Navigation Inclusion + +```csharp +var blog = new Blog +{ + Name = "My Blog", + Posts = new List { new Post { Title = "Post" } }, + Settings = new BlogSettings { EnableComments = true } +}; + +// Only insert Posts, not Settings +await dbContext.ExecuteBulkInsertAsync(new[] { blog }, o => +{ + o.IncludeGraph = true; + o.IncludeNavigations = new HashSet { "Posts" }; +}); +``` + +### Limiting Graph Depth + +```csharp +var blog = new Blog +{ + Name = "My Blog", + Posts = new List + { + new Post + { + Title = "Post", + Tags = new List { new Tag { Name = "EF Core" } } // Won't be inserted + } + } +}; + +// MaxGraphDepth = 1 means only Blog and direct children (Posts) +await dbContext.ExecuteBulkInsertAsync(new[] { blog }, o => +{ + o.IncludeGraph = true; + o.MaxGraphDepth = 1; +}); +``` + +## Limitations + +- **Shadow foreign keys**: Currently not supported. Add a CLR property for foreign keys. +- **Circular references**: Handled gracefully by tracking visited entities, but may result in incomplete graphs. +- **OnConflict/Upsert**: Not currently supported with `IncludeGraph = true`. diff --git a/docs/limitations.md b/docs/limitations.md index c89dfef..5cbd0b3 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -2,7 +2,7 @@ For now this library does not support the following features: -* **Navigation properties**: The library does not support inserting entities with navigation properties. You can only insert simple entities without any relationships. +* **Navigation properties**: ✅ Supported via the `IncludeGraph` option (see [Graph Insert documentation](./graph-insert.md)). * **Change tracking**: The library does not track changes to the entities being inserted. This means that you cannot use the `DbContext.ChangeTracker` to track changes to the entities after they have been inserted. * **Inheritance**: The library does not support inserting entities with inheritance (TPT, TPH, TPC). You can only insert entities of a single type. diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Abstractions/IBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Abstractions/IBulkInsertProvider.cs index e7e8756..a41d5a5 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Abstractions/IBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Abstractions/IBulkInsertProvider.cs @@ -39,6 +39,12 @@ internal Task BulkInsert( SqlDialectBuilder SqlDialect { get; } + /// + /// Returns whether this provider supports returning generated IDs efficiently. + /// Required for IncludeGraph when entities have identity columns. + /// + bool SupportsOutputInsertedIds { get; } + /// /// Make the default options for the provider, can be a subclass of . /// diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderUntyped.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderUntyped.cs index 28a545a..3d55130 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderUntyped.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderUntyped.cs @@ -15,6 +15,12 @@ internal abstract class BulkInsertProviderUntyped : IBulkIns SqlDialectBuilder IBulkInsertProvider.SqlDialect => SqlDialect; + /// + /// Returns whether this provider supports returning generated IDs efficiently. + /// Default implementation returns true for all providers. + /// + public virtual bool SupportsOutputInsertedIds => true; + BulkInsertOptions IBulkInsertProvider.CreateDefaultOptions() => CreateDefaultOptions(); /// diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs index 83ab641..40f7f51 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; +using PhenX.EntityFrameworkCore.BulkInsert.Graph; using PhenX.EntityFrameworkCore.BulkInsert.Options; namespace PhenX.EntityFrameworkCore.BulkInsert.Extensions; @@ -157,6 +158,13 @@ public static async Task ExecuteBulkInsertAsync( { var (provider, context, options) = InitProvider(dbSet, configure); + if (options.IncludeGraph) + { + var orchestrator = new GraphBulkInsertOrchestrator(); + await orchestrator.InsertGraphAsync(context, entities, options, provider, cancellationToken); + return; + } + await provider.BulkInsert(false, context, dbSet.GetDbContext().GetTableInfo(), entities, options, onConflict, cancellationToken); } @@ -204,6 +212,14 @@ public static void ExecuteBulkInsert( { var (provider, context, options) = InitProvider(dbSet, configure); + if (options.IncludeGraph) + { + var orchestrator = new GraphBulkInsertOrchestrator(); + orchestrator.InsertGraphAsync(context, entities, options, provider, CancellationToken.None) + .GetAwaiter().GetResult(); + return; + } + provider.BulkInsert(true, context, dbSet.GetDbContext().GetTableInfo(), entities, options, onConflict) .GetAwaiter().GetResult(); } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs new file mode 100644 index 0000000..4414a27 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -0,0 +1,396 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +using PhenX.EntityFrameworkCore.BulkInsert.Abstractions; +using PhenX.EntityFrameworkCore.BulkInsert.Metadata; +using PhenX.EntityFrameworkCore.BulkInsert.Options; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; + +/// +/// Result of a graph insert operation. +/// +/// The root entity type. +internal sealed class GraphInsertResult where T : class +{ + /// + /// The root entities that were inserted. + /// + public required IReadOnlyList RootEntities { get; init; } + + /// + /// Total count of all entities inserted across all types. + /// + public required int TotalInsertedCount { get; init; } +} + +/// +/// Orchestrates bulk insertion of entity graphs with FK propagation. +/// +internal sealed class GraphBulkInsertOrchestrator +{ + private static readonly ConcurrentDictionary<(Type, string), Action> PropertySetters = new(); + private static readonly ConcurrentDictionary<(Type, string), Func> PropertyGetters = new(); + + private readonly MetadataProvider _metadataProvider; + + public GraphBulkInsertOrchestrator() + { + _metadataProvider = new MetadataProvider(); + } + + /// + /// Orchestrates the bulk insert of an entity graph. + /// + public async Task> InsertGraphAsync( + DbContext context, + IEnumerable entities, + BulkInsertOptions options, + IBulkInsertProvider provider, + CancellationToken ctk) where T : class + { + // 1. Collect and sort entities + var collector = new GraphEntityCollector(context, options); + var collectionResult = collector.Collect(entities); + + if (collectionResult.EntitiesByType.Count == 0) + { + return new GraphInsertResult + { + RootEntities = [], + TotalInsertedCount = 0, + }; + } + + var totalInserted = 0; + var graphMetadata = new GraphMetadata(context, options); + + // 2. Insert in dependency order (parents first) + foreach (var entityType in collectionResult.InsertionOrder) + { + if (!collectionResult.EntitiesByType.TryGetValue(entityType, out var entitiesToInsert) || + entitiesToInsert.Count == 0) + { + continue; + } + + // Propagate FK values from already-inserted parents + PropagateParentForeignKeys(entitiesToInsert, entityType, graphMetadata, context); + + // Insert entities of this type + await InsertEntitiesOfTypeAsync(context, entityType, entitiesToInsert, options, provider, ctk); + + totalInserted += entitiesToInsert.Count; + } + + // 3. Insert join table records for many-to-many relationships + if (collectionResult.JoinRecords.Count > 0) + { + await InsertJoinRecordsAsync(context, collectionResult.JoinRecords, options, provider, ctk); + } + + // Return root entities + var rootEntities = collectionResult.EntitiesByType.TryGetValue(typeof(T), out var roots) + ? roots.Cast().ToList() + : []; + + return new GraphInsertResult + { + RootEntities = rootEntities, + TotalInsertedCount = totalInserted, + }; + } + + private static void PropagateParentForeignKeys( + List entities, + Type entityType, + GraphMetadata graphMetadata, + DbContext context) + { + var efEntityType = graphMetadata.GetEntityType(entityType); + if (efEntityType == null) + { + return; + } + + // For each FK relationship, propagate PK values from parent entities + foreach (var fk in efEntityType.GetForeignKeys()) + { + var principalEntityType = fk.PrincipalEntityType; + var dependentNavigation = fk.DependentToPrincipal; + + if (dependentNavigation == null) + { + continue; + } + + var navigationPropertyName = dependentNavigation.Name; + + foreach (var entity in entities) + { + // Get the parent entity via navigation property + var parentEntity = GetPropertyValue(entity, navigationPropertyName); + if (parentEntity == null) + { + continue; + } + + // Copy PK values from parent to FK properties on this entity + var fkProperties = fk.Properties; + var pkProperties = fk.PrincipalKey.Properties; + + for (var i = 0; i < fkProperties.Count; i++) + { + var fkProp = fkProperties[i]; + var pkProp = pkProperties[i]; + + if (fkProp.IsShadowProperty()) + { + // Shadow properties are handled by EF Core's change tracker + // For bulk insert, we can't easily handle shadow FKs + continue; + } + + var pkValue = GetPropertyValue(parentEntity, pkProp.Name); + SetPropertyValue(entity, fkProp.Name, pkValue); + } + } + } + } + + private async Task InsertEntitiesOfTypeAsync( + DbContext context, + Type entityType, + List entities, + BulkInsertOptions options, + IBulkInsertProvider provider, + CancellationToken ctk) + { + // Use reflection to call the generic BulkInsert method + var method = typeof(GraphBulkInsertOrchestrator) + .GetMethod(nameof(InsertEntitiesGenericAsync), BindingFlags.NonPublic | BindingFlags.Instance)! + .MakeGenericMethod(entityType); + + var task = (Task)method.Invoke(this, [context, entities, options, provider, ctk])!; + await task; + } + + private async Task InsertEntitiesGenericAsync( + DbContext context, + List entities, + BulkInsertOptions options, + IBulkInsertProvider provider, + CancellationToken ctk) where TEntity : class + { + var typedEntities = entities.Cast().ToList(); + var tableInfo = _metadataProvider.GetTableInfo(context); + + // Check if the entity has identity columns and we need to retrieve generated IDs + var hasIdentity = tableInfo.PrimaryKey.Any(pk => pk.IsGenerated); + + if (hasIdentity) + { + // Use BulkInsertReturnEntities to get back the generated IDs + var insertedEntities = new List(); + await foreach (var inserted in provider.BulkInsertReturnEntities( + false, + context, + tableInfo, + typedEntities, + options, + null, + ctk)) + { + insertedEntities.Add(inserted); + } + + // Copy generated IDs back to original entities + CopyGeneratedIds(typedEntities, insertedEntities, tableInfo); + } + else + { + // No identity columns, just insert directly + await provider.BulkInsert(false, context, tableInfo, typedEntities, options, null, ctk); + } + } + + private static void CopyGeneratedIds( + List originalEntities, + List insertedEntities, + TableMetadata tableInfo) where TEntity : class + { + if (originalEntities.Count != insertedEntities.Count) + { + // Can't reliably map back + return; + } + + var pkProps = tableInfo.PrimaryKey.Where(pk => pk.IsGenerated).ToList(); + if (pkProps.Count == 0) + { + return; + } + + for (var i = 0; i < originalEntities.Count; i++) + { + var original = originalEntities[i]; + var inserted = insertedEntities[i]; + + foreach (var pkProp in pkProps) + { + var value = GetPropertyValue(inserted, pkProp.PropertyName); + SetPropertyValue(original, pkProp.PropertyName, value); + } + } + } + + private async Task InsertJoinRecordsAsync( + DbContext context, + List joinRecords, + BulkInsertOptions options, + IBulkInsertProvider provider, + CancellationToken ctk) + { + // Group join records by join entity type + var groupedRecords = joinRecords.GroupBy(jr => jr.JoinEntityType); + + foreach (var group in groupedRecords) + { + var joinEntityType = group.Key; + var records = group.ToList(); + + if (records.Count == 0) + { + continue; + } + + // Get the join table metadata from the first record + var navigation = records[0].Navigation; + var fk = navigation.ForeignKey; + var inverseFk = navigation.InverseForeignKey; + + if (fk == null || inverseFk == null) + { + continue; + } + + // Create join table entries + var joinEntities = new List(); + + foreach (var record in records) + { + // Create a dictionary-based join entity + var joinEntry = Activator.CreateInstance(joinEntityType); + if (joinEntry == null) + { + continue; + } + + // Set FK values for left entity + for (var i = 0; i < fk.Properties.Count; i++) + { + var fkProp = fk.Properties[i]; + var pkProp = fk.PrincipalKey.Properties[i]; + + var pkValue = GetPropertyValue(record.LeftEntity, pkProp.Name); + SetPropertyValue(joinEntry, fkProp.Name, pkValue); + } + + // Set FK values for right entity + for (var i = 0; i < inverseFk.Properties.Count; i++) + { + var fkProp = inverseFk.Properties[i]; + var pkProp = inverseFk.PrincipalKey.Properties[i]; + + var pkValue = GetPropertyValue(record.RightEntity, pkProp.Name); + SetPropertyValue(joinEntry, fkProp.Name, pkValue); + } + + joinEntities.Add(joinEntry); + } + + if (joinEntities.Count > 0) + { + // Insert join entities + await InsertJoinEntitiesAsync(context, joinEntityType, joinEntities, options, provider, ctk); + } + } + } + + private async Task InsertJoinEntitiesAsync( + DbContext context, + Type joinEntityType, + List joinEntities, + BulkInsertOptions options, + IBulkInsertProvider provider, + CancellationToken ctk) + { + var efEntityType = context.Model.FindEntityType(joinEntityType); + if (efEntityType == null) + { + return; + } + + var sqlDialect = provider.SqlDialect; + var tableInfo = new TableMetadata(efEntityType, sqlDialect); + + // Use raw SQL insert for join entities since they're often dictionary-based + var method = typeof(IBulkInsertProvider) + .GetMethod(nameof(IBulkInsertProvider.BulkInsert))! + .MakeGenericMethod(joinEntityType); + + var task = (Task)method.Invoke(provider, [false, context, tableInfo, joinEntities, options, null, ctk])!; + await task; + } + + private static object? GetPropertyValue(object entity, string propertyName) + { + var key = (entity.GetType(), propertyName); + var getter = PropertyGetters.GetOrAdd(key, k => + { + var property = k.Item1.GetProperty(k.Item2, BindingFlags.Public | BindingFlags.Instance); + if (property == null) + { + return _ => null; + } + + var param = Expression.Parameter(typeof(object), "obj"); + var cast = Expression.Convert(param, k.Item1); + var access = Expression.Property(cast, property); + var convertResult = Expression.Convert(access, typeof(object)); + + return Expression.Lambda>(convertResult, param).Compile(); + }); + + return getter(entity); + } + + private static void SetPropertyValue(object entity, string propertyName, object? value) + { + var key = (entity.GetType(), propertyName); + var setter = PropertySetters.GetOrAdd(key, k => + { + var property = k.Item1.GetProperty(k.Item2, BindingFlags.Public | BindingFlags.Instance); + if (property == null || !property.CanWrite) + { + return (_, _) => { }; + } + + var param = Expression.Parameter(typeof(object), "obj"); + var valueParam = Expression.Parameter(typeof(object), "value"); + var cast = Expression.Convert(param, k.Item1); + var access = Expression.Property(cast, property); + var convertValue = Expression.Convert(valueParam, property.PropertyType); + var assign = Expression.Assign(access, convertValue); + + return Expression.Lambda>(assign, param, valueParam).Compile(); + }); + + setter(entity, value); + } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs new file mode 100644 index 0000000..d0b2101 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs @@ -0,0 +1,199 @@ +using System.Collections; +using System.Reflection; + +using Microsoft.EntityFrameworkCore; + +using PhenX.EntityFrameworkCore.BulkInsert.Metadata; +using PhenX.EntityFrameworkCore.BulkInsert.Options; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; + +/// +/// Result of collecting entities from an object graph. +/// +internal sealed class GraphCollectionResult +{ + /// + /// Entities grouped by type. + /// + public required Dictionary> EntitiesByType { get; init; } + + /// + /// Types in topological insertion order (parents before children). + /// + public required IReadOnlyList InsertionOrder { get; init; } + + /// + /// Many-to-many join records to insert after both sides are inserted. + /// + public required List JoinRecords { get; init; } +} + +/// +/// Represents a join table record for many-to-many relationships. +/// +internal sealed class JoinRecord +{ + public required Type JoinEntityType { get; init; } + public required object LeftEntity { get; init; } + public required object RightEntity { get; init; } + public required NavigationMetadata Navigation { get; init; } +} + +/// +/// Collects all entities from an object graph for bulk insertion. +/// +internal sealed class GraphEntityCollector +{ + private readonly GraphMetadata _graphMetadata; + private readonly BulkInsertOptions _options; + private readonly HashSet _visited; + private readonly Dictionary> _entitiesByType; + private readonly List _joinRecords; + + public GraphEntityCollector(DbContext context, BulkInsertOptions options) + { + _options = options; + _graphMetadata = new GraphMetadata(context, options); + _visited = new HashSet(ReferenceEqualityComparer.Instance); + _entitiesByType = []; + _joinRecords = []; + } + + /// + /// Traverses the entity graph and returns entities grouped by type in insertion order. + /// + public GraphCollectionResult Collect(IEnumerable rootEntities) where T : class + { + foreach (var entity in rootEntities) + { + CollectEntity(entity, 0); + } + + var insertionOrder = _graphMetadata.GetInsertionOrder(_entitiesByType.Keys); + + return new GraphCollectionResult + { + EntitiesByType = _entitiesByType, + InsertionOrder = insertionOrder, + JoinRecords = _joinRecords, + }; + } + + private void CollectEntity(object entity, int depth) + { + if (entity == null || !_visited.Add(entity)) + { + // Already visited or null + return; + } + + // Check max depth + if (_options.MaxGraphDepth > 0 && depth > _options.MaxGraphDepth) + { + return; + } + + var entityType = entity.GetType(); + var efEntityType = _graphMetadata.GetEntityType(entityType); + + if (efEntityType == null) + { + // Not a known entity type + return; + } + + // Add to collection + if (!_entitiesByType.TryGetValue(entityType, out var entities)) + { + entities = []; + _entitiesByType[entityType] = entities; + } + + entities.Add(entity); + + // Traverse navigation properties + var navigations = _graphMetadata.GetNavigations(entityType); + + foreach (var navigation in navigations) + { + var propertyInfo = entityType.GetProperty(navigation.PropertyName, BindingFlags.Public | BindingFlags.Instance); + if (propertyInfo == null) + { + continue; + } + + var value = propertyInfo.GetValue(entity); + if (value == null) + { + continue; + } + + if (navigation.IsCollection) + { + if (value is IEnumerable collection) + { + foreach (var item in collection) + { + if (item != null) + { + if (navigation.IsManyToMany) + { + // Record join table entry + _joinRecords.Add(new JoinRecord + { + JoinEntityType = navigation.JoinEntityType!.ClrType, + LeftEntity = entity, + RightEntity = item, + Navigation = navigation, + }); + } + else + { + // For one-to-many, set the inverse navigation property + // so that FK propagation can find the parent + SetInverseNavigation(entity, item, navigation); + } + + CollectEntity(item, depth + 1); + } + } + } + } + else + { + // For reference navigations (one-to-one), set the inverse navigation + SetInverseNavigation(entity, value, navigation); + CollectEntity(value, depth + 1); + } + } + } + + private static void SetInverseNavigation(object parentEntity, object childEntity, NavigationMetadata navigation) + { + // For one-to-many navigations, find and set the inverse navigation property + // (e.g., if Blog.Posts is the navigation, set Post.Blog = blog) + var nav = navigation.Navigation; + if (nav is not Microsoft.EntityFrameworkCore.Metadata.INavigation regularNav) + { + return; + } + + var inverse = regularNav.Inverse; + if (inverse == null) + { + return; + } + + // Set the inverse navigation property on the child + var inversePropertyInfo = childEntity.GetType().GetProperty(inverse.Name, BindingFlags.Public | BindingFlags.Instance); + if (inversePropertyInfo != null && inversePropertyInfo.CanWrite) + { + var currentValue = inversePropertyInfo.GetValue(childEntity); + if (currentValue == null) + { + inversePropertyInfo.SetValue(childEntity, parentEntity); + } + } + } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs new file mode 100644 index 0000000..621a21d --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs @@ -0,0 +1,160 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +using PhenX.EntityFrameworkCore.BulkInsert.Options; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Metadata; + +/// +/// Metadata for analyzing entity graph relationships. +/// +internal sealed class GraphMetadata +{ + private readonly Dictionary _entityTypes; + private readonly Dictionary> _navigationsByType; + private readonly BulkInsertOptions _options; + + public GraphMetadata(DbContext context, BulkInsertOptions options) + { + _options = options; + + // Filter entity types - exclude keyless entities, owned entities, and entities with null table names + // Also handle potential duplicates (e.g., shared type entities like Dictionary for join tables) + _entityTypes = []; + foreach (var entityType in context.Model.GetEntityTypes()) + { + if (entityType.IsOwned() || entityType.ClrType == null || entityType.GetTableName() == null) + { + continue; + } + + // For shared type entities (like many-to-many join tables), only keep the first one + _entityTypes.TryAdd(entityType.ClrType, entityType); + } + + _navigationsByType = []; + + foreach (var entityType in _entityTypes.Values) + { + var navigations = GetNavigationsForType(entityType); + _navigationsByType[entityType.ClrType] = navigations; + } + } + + /// + /// Gets the entity type for a CLR type. + /// + public IEntityType? GetEntityType(Type clrType) + { + return _entityTypes.TryGetValue(clrType, out var entityType) ? entityType : null; + } + + /// + /// Gets the navigations for a CLR type. + /// + public IReadOnlyList GetNavigations(Type clrType) + { + return _navigationsByType.TryGetValue(clrType, out var navigations) + ? navigations + : []; + } + + /// + /// Determines the topological insertion order for a set of types based on FK dependencies. + /// Parents are inserted before children to satisfy FK constraints. + /// + public IReadOnlyList GetInsertionOrder(IEnumerable typesToInsert) + { + var types = typesToInsert.ToHashSet(); + var result = new List(); + var visited = new HashSet(); + var visiting = new HashSet(); + + foreach (var type in types) + { + TopologicalSort(type, types, visited, visiting, result); + } + + return result; + } + + private void TopologicalSort( + Type type, + HashSet validTypes, + HashSet visited, + HashSet visiting, + List result) + { + if (visited.Contains(type)) + { + return; + } + + if (visiting.Contains(type)) + { + // Cycle detected - this is handled gracefully, just skip + return; + } + + visiting.Add(type); + + // Get dependencies (types that this type references via FKs) + var navigations = GetNavigations(type); + foreach (var nav in navigations) + { + // Only consider dependent-to-principal navigations (this entity has the FK) + if (nav.IsDependentToPrincipal && validTypes.Contains(nav.TargetType) && nav.TargetType != type) + { + TopologicalSort(nav.TargetType, validTypes, visited, visiting, result); + } + } + + visiting.Remove(type); + visited.Add(type); + result.Add(type); + } + + private List GetNavigationsForType(IEntityType entityType) + { + var navigations = new List(); + + // Get regular navigations + foreach (var navigation in entityType.GetNavigations()) + { + if (!ShouldIncludeNavigation(navigation.Name)) + { + continue; + } + + navigations.Add(new NavigationMetadata(navigation)); + } + + // Get skip navigations (many-to-many) + foreach (var skipNavigation in entityType.GetSkipNavigations()) + { + if (!ShouldIncludeNavigation(skipNavigation.Name)) + { + continue; + } + + navigations.Add(new NavigationMetadata(skipNavigation)); + } + + return navigations; + } + + private bool ShouldIncludeNavigation(string name) + { + if (_options.ExcludeNavigations?.Contains(name) == true) + { + return false; + } + + if (_options.IncludeNavigations?.Count > 0) + { + return _options.IncludeNavigations.Contains(name); + } + + return true; + } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs new file mode 100644 index 0000000..2d04fa3 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore.Metadata; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Metadata; + +/// +/// Metadata for a navigation property in an entity type. +/// +internal sealed class NavigationMetadata +{ + public NavigationMetadata(INavigationBase navigation) + { + Navigation = navigation; + PropertyName = navigation.Name; + TargetType = navigation.TargetEntityType.ClrType; + IsCollection = navigation.IsCollection; + + if (navigation is ISkipNavigation skipNavigation) + { + IsManyToMany = true; + JoinEntityType = skipNavigation.JoinEntityType; + ForeignKey = skipNavigation.ForeignKey; + InverseForeignKey = skipNavigation.Inverse?.ForeignKey; + } + else if (navigation is INavigation regularNavigation) + { + IsManyToMany = false; + ForeignKey = regularNavigation.ForeignKey; + IsDependentToPrincipal = regularNavigation.IsOnDependent; + } + } + + /// + /// The underlying EF Core navigation. + /// + public INavigationBase Navigation { get; } + + /// + /// The name of the navigation property. + /// + public string PropertyName { get; } + + /// + /// The CLR type of the related entity. + /// + public Type TargetType { get; } + + /// + /// True if this is a collection navigation (one-to-many, many-to-many). + /// + public bool IsCollection { get; } + + /// + /// True if this is a many-to-many navigation. + /// + public bool IsManyToMany { get; } + + /// + /// For many-to-many, the join entity type. + /// + public IEntityType? JoinEntityType { get; } + + /// + /// The foreign key associated with this navigation. + /// + public IForeignKey? ForeignKey { get; } + + /// + /// For many-to-many, the inverse foreign key. + /// + public IForeignKey? InverseForeignKey { get; } + + /// + /// True if this navigation goes from dependent to principal (the entity owns the FK). + /// + public bool IsDependentToPrincipal { get; } + + /// + /// Gets the FK property names on the source entity (for dependent-to-principal navigations). + /// + public IReadOnlyList GetForeignKeyPropertyNames() + { + if (ForeignKey == null) + { + return []; + } + + return ForeignKey.Properties.Select(p => p.Name).ToList(); + } + + /// + /// Gets the principal key property names. + /// + public IReadOnlyList GetPrincipalKeyPropertyNames() + { + if (ForeignKey == null) + { + return []; + } + + return ForeignKey.PrincipalKey.Properties.Select(p => p.Name).ToList(); + } + + public override string ToString() + { + var relationshipType = IsManyToMany ? "ManyToMany" : (IsCollection ? "OneToMany" : "OneToOne"); + return $"{PropertyName} -> {TargetType.Name} ({relationshipType})"; + } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs index 13535b3..f4e080d 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs @@ -74,6 +74,30 @@ public class BulkInsertOptions /// public ProgressCallback? OnProgress { get; set; } + /// + /// When enabled, recursively inserts all reachable entities via navigation properties. + /// This includes one-to-one, one-to-many, many-to-one, and many-to-many relationships. + /// Default: false (only the root entities are inserted). + /// + public bool IncludeGraph { get; set; } + + /// + /// Maximum depth for graph traversal when IncludeGraph is enabled. + /// Use 0 for unlimited depth. Default: 0. + /// + public int MaxGraphDepth { get; set; } + + /// + /// Navigation properties to explicitly include when IncludeGraph is enabled. + /// If empty and IncludeGraph is true, all navigation properties are included. + /// + public HashSet? IncludeNavigations { get; set; } + + /// + /// Navigation properties to explicitly exclude when IncludeGraph is enabled. + /// + public HashSet? ExcludeNavigations { get; set; } + internal int GetCopyTimeoutInSeconds() { return Math.Max(0, (int)CopyTimeout.TotalSeconds); diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Blog.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Blog.cs new file mode 100644 index 0000000..7a9d263 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Blog.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +/// +/// Blog entity for testing one-to-many relationships. +/// +[Table("blog")] +public class Blog : TestEntityBase +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public ICollection Posts { get; set; } = new List(); + public BlogSettings? Settings { get; set; } +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/BlogSettings.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/BlogSettings.cs new file mode 100644 index 0000000..58474ca --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/BlogSettings.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +/// +/// BlogSettings entity for testing one-to-one relationships. +/// +[Table("blog_settings")] +public class BlogSettings : TestEntityBase +{ + public int Id { get; set; } + public int BlogId { get; set; } + public Blog Blog { get; set; } = null!; + public bool EnableComments { get; set; } +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Category.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Category.cs new file mode 100644 index 0000000..8f21f96 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Category.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +/// +/// Category entity for testing self-referencing/hierarchical relationships. +/// +[Table("category")] +public class Category : TestEntityBase +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public int? ParentId { get; set; } + public Category? Parent { get; set; } + public ICollection Children { get; set; } = new List(); +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Post.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Post.cs new file mode 100644 index 0000000..567440a --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Post.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +/// +/// Post entity for testing one-to-many and many-to-many relationships. +/// +[Table("post")] +public class Post : TestEntityBase +{ + public int Id { get; set; } + public string Title { get; set; } = null!; + public int BlogId { get; set; } + public Blog Blog { get; set; } = null!; + public ICollection Tags { get; set; } = new List(); +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Tag.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Tag.cs new file mode 100644 index 0000000..f020145 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Tag.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +/// +/// Tag entity for testing many-to-many relationships. +/// +[Table("tag")] +public class Tag : TestEntityBase +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public ICollection Posts { get; set; } = new List(); +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs index 8cb9d64..00714f0 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs @@ -18,6 +18,13 @@ public class TestDbContext : TestDbContextBase public DbSet Students { get; set; } = null!; public DbSet Courses { get; set; } = null!; + // Graph insert test entities + public DbSet Blogs { get; set; } = null!; + public DbSet Posts { get; set; } = null!; + public DbSet Tags { get; set; } = null!; + public DbSet BlogSettings { get; set; } = null!; + public DbSet Categories { get; set; } = null!; + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -66,6 +73,40 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // We just reuse the table for the standard TestEntity. builder.ToView("test_entity"); }); + + // Configure Blog relationships + modelBuilder.Entity(builder => + { + builder.HasMany(b => b.Posts) + .WithOne(p => p.Blog) + .HasForeignKey(p => p.BlogId); + + builder.HasOne(b => b.Settings) + .WithOne(s => s.Blog) + .HasForeignKey(s => s.BlogId); + }); + + // Configure Post-Tag many-to-many + modelBuilder.Entity() + .HasMany(p => p.Tags) + .WithMany(t => t.Posts) + .UsingEntity>( + "PostTag", + j => j.HasOne().WithMany().HasForeignKey("TagId"), + j => j.HasOne().WithMany().HasForeignKey("PostId"), + j => + { + j.HasKey("PostId", "TagId"); + } + ); + + // Configure Category self-referencing + modelBuilder.Entity(builder => + { + builder.HasOne(c => c.Parent) + .WithMany(c => c.Children) + .HasForeignKey(c => c.ParentId); + }); } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs new file mode 100644 index 0000000..4eaae03 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -0,0 +1,362 @@ +using FluentAssertions; + +using PhenX.EntityFrameworkCore.BulkInsert.Extensions; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Graph; + +public abstract class GraphTestsBase(TestDbContainer dbContainer) : IAsyncLifetime + where TDbContext : TestDbContext, new() +{ + private readonly Guid _run = Guid.NewGuid(); + private TDbContext _context = null!; + + public async Task InitializeAsync() + { + _context = await dbContainer.CreateContextAsync("graph"); + } + + public Task DisposeAsync() + { + _context.Dispose(); + return Task.CompletedTask; + } + + [SkippableFact] + public async Task InsertGraph_OneToMany_InsertsParentAndChildren() + { + // Arrange + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_Blog1", + Posts = new List + { + new Post { TestRun = _run, Title = $"{_run}_Post1" }, + new Post { TestRun = _run, Title = $"{_run}_Post2" }, + } + }; + + // Act + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + }); + + // Assert + var insertedBlog = _context.Blogs.FirstOrDefault(b => b.TestRun == _run); + insertedBlog.Should().NotBeNull(); + insertedBlog!.Id.Should().BeGreaterThan(0); + + var insertedPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + insertedPosts.Should().HaveCount(2); + insertedPosts.Should().AllSatisfy(p => + { + p.Id.Should().BeGreaterThan(0); + p.BlogId.Should().Be(insertedBlog.Id); + }); + } + + [SkippableFact] + public async Task InsertGraph_OneToOne_InsertsRelatedEntity() + { + // Arrange + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_BlogWithSettings", + Settings = new BlogSettings + { + TestRun = _run, + EnableComments = true + } + }; + + // Act + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + }); + + // Assert + var insertedBlog = _context.Blogs.FirstOrDefault(b => b.TestRun == _run); + insertedBlog.Should().NotBeNull(); + insertedBlog!.Id.Should().BeGreaterThan(0); + + var insertedSettings = _context.BlogSettings.FirstOrDefault(s => s.TestRun == _run); + insertedSettings.Should().NotBeNull(); + insertedSettings!.Id.Should().BeGreaterThan(0); + insertedSettings.BlogId.Should().Be(insertedBlog.Id); + insertedSettings.EnableComments.Should().BeTrue(); + } + + [SkippableFact] + public async Task InsertGraph_PropagatesGeneratedIds() + { + // Arrange + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_BlogWithIdPropagation", + Posts = new List + { + new Post { TestRun = _run, Title = $"{_run}_PostWithIdPropagation" } + } + }; + + // Act + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + }); + + // Assert - the original entities should have their IDs populated + blog.Id.Should().BeGreaterThan(0, "Blog ID should be propagated"); + blog.Posts.First().Id.Should().BeGreaterThan(0, "Post ID should be propagated"); + blog.Posts.First().BlogId.Should().Be(blog.Id, "Post BlogId should reference the Blog"); + } + + [SkippableFact] + public async Task InsertGraph_EmptyCollections_DoesNotFail() + { + // Arrange + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_BlogWithNoPosts", + Posts = new List() // Empty collection + }; + + // Act + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + }); + + // Assert + var insertedBlog = _context.Blogs.FirstOrDefault(b => b.TestRun == _run); + insertedBlog.Should().NotBeNull(); + insertedBlog!.Id.Should().BeGreaterThan(0); + + var posts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + posts.Should().BeEmpty(); + } + + [SkippableFact] + public async Task InsertGraph_NullNavigations_DoesNotFail() + { + // Arrange + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_BlogWithNullSettings", + Settings = null // Null navigation + }; + + // Act + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + }); + + // Assert + var insertedBlog = _context.Blogs.FirstOrDefault(b => b.TestRun == _run); + insertedBlog.Should().NotBeNull(); + insertedBlog!.Id.Should().BeGreaterThan(0); + + var settings = _context.BlogSettings.FirstOrDefault(s => s.TestRun == _run); + settings.Should().BeNull(); + } + + [SkippableFact] + public async Task InsertGraph_DeepGraph_RespectsMaxDepth() + { + // Arrange + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_BlogWithDeepGraph", + Posts = new List + { + new Post + { + TestRun = _run, + Title = $"{_run}_DeepPost", + Tags = new List + { + new Tag { TestRun = _run, Name = $"{_run}_DeepTag" } + } + } + } + }; + + // Act - MaxGraphDepth = 1 means only Blog and Posts, not Tags + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + options.MaxGraphDepth = 1; + }); + + // Assert + var insertedBlog = _context.Blogs.FirstOrDefault(b => b.TestRun == _run); + insertedBlog.Should().NotBeNull(); + + var insertedPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + insertedPosts.Should().HaveCount(1); + + // Tags should NOT be inserted due to MaxGraphDepth + var insertedTags = _context.Tags.Where(t => t.TestRun == _run).ToList(); + insertedTags.Should().BeEmpty(); + } + + [SkippableFact] + public async Task InsertGraph_WithExcludeNavigations_SkipsSpecified() + { + // Arrange + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_BlogWithExcludedPosts", + Posts = new List + { + new Post { TestRun = _run, Title = $"{_run}_ExcludedPost" } + }, + Settings = new BlogSettings + { + TestRun = _run, + EnableComments = false + } + }; + + // Act - Exclude Posts navigation + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + options.ExcludeNavigations = ["Posts"]; + }); + + // Assert + var insertedBlog = _context.Blogs.FirstOrDefault(b => b.TestRun == _run); + insertedBlog.Should().NotBeNull(); + + // Posts should NOT be inserted due to ExcludeNavigations + var insertedPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + insertedPosts.Should().BeEmpty(); + + // Settings should still be inserted + var insertedSettings = _context.BlogSettings.FirstOrDefault(s => s.TestRun == _run); + insertedSettings.Should().NotBeNull(); + } + + [SkippableFact] + public async Task InsertGraph_WithIncludeNavigations_OnlyInsertsSpecified() + { + // Arrange + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_BlogWithIncludedPostsOnly", + Posts = new List + { + new Post { TestRun = _run, Title = $"{_run}_IncludedPost" } + }, + Settings = new BlogSettings + { + TestRun = _run, + EnableComments = true + } + }; + + // Act - Only include Posts navigation + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + options.IncludeNavigations = ["Posts"]; + }); + + // Assert + var insertedBlog = _context.Blogs.FirstOrDefault(b => b.TestRun == _run); + insertedBlog.Should().NotBeNull(); + + // Posts should be inserted + var insertedPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + insertedPosts.Should().HaveCount(1); + + // Settings should NOT be inserted due to IncludeNavigations + var insertedSettings = _context.BlogSettings.FirstOrDefault(s => s.TestRun == _run); + insertedSettings.Should().BeNull(); + } + + [SkippableFact] + public async Task InsertGraph_MultipleRootEntities_InsertsAll() + { + // Arrange + var blogs = new[] + { + new Blog + { + TestRun = _run, + Name = $"{_run}_MultiBlog1", + Posts = new List + { + new Post { TestRun = _run, Title = $"{_run}_Multi1Post1" } + } + }, + new Blog + { + TestRun = _run, + Name = $"{_run}_MultiBlog2", + Posts = new List + { + new Post { TestRun = _run, Title = $"{_run}_Multi2Post1" }, + new Post { TestRun = _run, Title = $"{_run}_Multi2Post2" } + } + } + }; + + // Act + await _context.ExecuteBulkInsertAsync(blogs, options => + { + options.IncludeGraph = true; + }); + + // Assert + var insertedBlogs = _context.Blogs.Where(b => b.TestRun == _run).ToList(); + insertedBlogs.Should().HaveCount(2); + insertedBlogs.Should().AllSatisfy(b => b.Id.Should().BeGreaterThan(0)); + + var insertedPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + insertedPosts.Should().HaveCount(3); + } + + [SkippableFact] + public async Task InsertGraph_SyncVariant_Works() + { + // Arrange + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_SyncBlog", + Posts = new List + { + new Post { TestRun = _run, Title = $"{_run}_SyncPost" } + } + }; + + // Act (synchronous) + _context.ExecuteBulkInsert(new[] { blog }, options => + { + options.IncludeGraph = true; + }); + + // Assert + var insertedBlog = _context.Blogs.FirstOrDefault(b => b.TestRun == _run); + insertedBlog.Should().NotBeNull(); + + var insertedPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + insertedPosts.Should().HaveCount(1); + } +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsMySql.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsMySql.cs new file mode 100644 index 0000000..267dcb8 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsMySql.cs @@ -0,0 +1,12 @@ +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Graph; + +[Trait("Category", "MySql")] +[Collection(TestDbContainerMySqlCollection.Name)] +public class GraphTestsMySql(TestDbContainerMySql dbContainer) : GraphTestsBase(dbContainer) +{ +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsOracle.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsOracle.cs new file mode 100644 index 0000000..024fe8c --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsOracle.cs @@ -0,0 +1,12 @@ +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Graph; + +[Trait("Category", "Oracle")] +[Collection(TestDbContainerOracleCollection.Name)] +public class GraphTestsOracle(TestDbContainerOracle dbContainer) : GraphTestsBase(dbContainer) +{ +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsPostgreSql.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsPostgreSql.cs new file mode 100644 index 0000000..f72a7be --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsPostgreSql.cs @@ -0,0 +1,12 @@ +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Graph; + +[Trait("Category", "PostgreSql")] +[Collection(TestDbContainerPostgreSqlCollection.Name)] +public class GraphTestsPostgreSql(TestDbContainerPostgreSql dbContainer) : GraphTestsBase(dbContainer) +{ +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsSqlServer.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsSqlServer.cs new file mode 100644 index 0000000..a6d8bb9 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsSqlServer.cs @@ -0,0 +1,12 @@ +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Graph; + +[Trait("Category", "SqlServer")] +[Collection(TestDbContainerSqlServerCollection.Name)] +public class GraphTestsSqlServer(TestDbContainerSqlServer dbContainer) : GraphTestsBase(dbContainer) +{ +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsSqlite.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsSqlite.cs new file mode 100644 index 0000000..99c6593 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsSqlite.cs @@ -0,0 +1,12 @@ +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Graph; + +[Trait("Category", "Sqlite")] +[Collection(TestDbContainerSqliteCollection.Name)] +public class GraphTestsSqlite(TestDbContainerSqlite dbContainer) : GraphTestsBase(dbContainer) +{ +} From 570bd6a91cbf1c3ffc5ea50b6bf73b64e25f7d18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 08:07:52 +0000 Subject: [PATCH 03/26] Address code review comments: add debug logging and fix docs Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- docs/graph-insert.md | 2 +- .../Graph/GraphBulkInsertOrchestrator.cs | 11 ++++++++++- .../Graph/GraphEntityCollector.cs | 2 ++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/graph-insert.md b/docs/graph-insert.md index da2b7cd..e8d6f4c 100644 --- a/docs/graph-insert.md +++ b/docs/graph-insert.md @@ -39,7 +39,7 @@ await dbContext.ExecuteBulkInsertAsync(blogs, options => ## Performance Considerations - Graph insert is inherently slower than flat insert due to FK propagation overhead -- For entities with identity columns, the library uses `ExecuteBulkInsertReturnEntities` internally to retrieve generated IDs +- For entities with identity columns, the library uses `ExecuteBulkInsertReturnEntitiesAsync` internally to retrieve generated IDs - Consider using client-generated keys (GUIDs with `ValueGeneratedNever()`) to avoid ID propagation overhead - Use `MaxGraphDepth` to limit traversal for large/deep graphs - Use `IncludeNavigations` or `ExcludeNavigations` to reduce the scope of insertions diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 4414a27..045e84d 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -226,7 +226,13 @@ private static void CopyGeneratedIds( { if (originalEntities.Count != insertedEntities.Count) { - // Can't reliably map back + // Count mismatch - this can happen if the bulk insert operation + // doesn't preserve order. Log a warning for debugging purposes. + // The graph insert will continue but FK propagation may be incomplete. + System.Diagnostics.Debug.WriteLine( + $"Warning: IncludeGraph ID propagation failed for {typeof(TEntity).Name}. " + + $"Original count: {originalEntities.Count}, Inserted count: {insertedEntities.Count}. " + + "Foreign key values may not be correctly propagated to dependent entities."); return; } @@ -288,6 +294,9 @@ private async Task InsertJoinRecordsAsync( var joinEntry = Activator.CreateInstance(joinEntityType); if (joinEntry == null) { + System.Diagnostics.Debug.WriteLine( + $"Warning: IncludeGraph failed to create join entry for {joinEntityType.Name}. " + + "Many-to-many relationship may be incomplete."); continue; } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs index d0b2101..1628106 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs @@ -55,6 +55,8 @@ public GraphEntityCollector(DbContext context, BulkInsertOptions options) { _options = options; _graphMetadata = new GraphMetadata(context, options); + // Use ReferenceEqualityComparer to track visited entity instances by reference, + // not by property values, to correctly handle cycles in the object graph _visited = new HashSet(ReferenceEqualityComparer.Instance); _entitiesByType = []; _joinRecords = []; From 1b4553f54f080d05173aac8c7674e03c19e4ead0 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 1 Feb 2026 09:36:10 +0100 Subject: [PATCH 04/26] Remove tests for Oracle and MySQL as they don't support returing ids --- .../Tests/Graph/GraphTestsMySql.cs | 12 ------------ .../Tests/Graph/GraphTestsOracle.cs | 12 ------------ 2 files changed, 24 deletions(-) delete mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsMySql.cs delete mode 100644 tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsOracle.cs diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsMySql.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsMySql.cs deleted file mode 100644 index 267dcb8..0000000 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsMySql.cs +++ /dev/null @@ -1,12 +0,0 @@ -using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; -using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; - -using Xunit; - -namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Graph; - -[Trait("Category", "MySql")] -[Collection(TestDbContainerMySqlCollection.Name)] -public class GraphTestsMySql(TestDbContainerMySql dbContainer) : GraphTestsBase(dbContainer) -{ -} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsOracle.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsOracle.cs deleted file mode 100644 index 024fe8c..0000000 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsOracle.cs +++ /dev/null @@ -1,12 +0,0 @@ -using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; -using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; - -using Xunit; - -namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Graph; - -[Trait("Category", "Oracle")] -[Collection(TestDbContainerOracleCollection.Name)] -public class GraphTestsOracle(TestDbContainerOracle dbContainer) : GraphTestsBase(dbContainer) -{ -} From 2514aacb1625da6e3c637464ea7e87ed40672817 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 1 Feb 2026 09:41:57 +0100 Subject: [PATCH 05/26] Add note about Oracle and MySQL limitations for IncludeGraph feature --- README.md | 2 ++ docs/graph-insert.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index 921b254..510ff4f 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ var blogs = new List await dbContext.ExecuteBulkInsertAsync(blogs, o => o.IncludeGraph = true); ``` +> ℹ️ This feature is not available for Oracle and MySQL providers due to limitations in retrieving generated IDs. + See [Graph Insert documentation](https://phenx.github.io/PhenX.EntityFrameworkCore.BulkInsert/graph-insert.html) for details. ### Conflict resolution / merge / upsert diff --git a/docs/graph-insert.md b/docs/graph-insert.md index e8d6f4c..2f6eab1 100644 --- a/docs/graph-insert.md +++ b/docs/graph-insert.md @@ -1,5 +1,7 @@ # Graph Insert (Navigation Properties) +> ℹ️ This feature is not available for Oracle and MySQL providers due to limitations in retrieving generated IDs. + This library supports bulk inserting entire object graphs, including entities with their related navigation properties. ## Enabling Graph Insert From 9f337a91096719d2555241879a5af68dc709bcb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:46:11 +0000 Subject: [PATCH 06/26] Address PR review feedback: add tests, validation, and docs improvements Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- docs/graph-insert.md | 3 + .../Extensions/PublicExtensions.DbSet.cs | 14 ++++ .../Graph/GraphBulkInsertOrchestrator.cs | 29 ++++++- .../Metadata/GraphMetadata.cs | 8 +- .../Tests/Graph/GraphTestsBase.cs | 75 +++++++++++++++++++ 5 files changed, 119 insertions(+), 10 deletions(-) diff --git a/docs/graph-insert.md b/docs/graph-insert.md index 2f6eab1..9187818 100644 --- a/docs/graph-insert.md +++ b/docs/graph-insert.md @@ -131,4 +131,7 @@ await dbContext.ExecuteBulkInsertAsync(new[] { blog }, o => - **Shadow foreign keys**: Currently not supported. Add a CLR property for foreign keys. - **Circular references**: Handled gracefully by tracking visited entities, but may result in incomplete graphs. +- **Owned entities**: Owned entity types are not included in graph traversal and are not inserted when using `IncludeGraph = true`. +- **Self-referencing hierarchies**: Multi-level self-referencing hierarchies (e.g., Category → Children) require multiple insert operations. Root entities can be inserted, but nested children with FK references to other entities of the same type within the same batch are not supported. +- **Many-to-many join tables**: Entities on both sides of many-to-many relationships are traversed and inserted. However, automatic join table population only works with explicit join entity types (not `Dictionary` shared-type entities). - **OnConflict/Upsert**: Not currently supported with `IncludeGraph = true`. diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs index 40f7f51..aac8d86 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs @@ -160,6 +160,13 @@ public static async Task ExecuteBulkInsertAsync( if (options.IncludeGraph) { + if (onConflict != null) + { + throw new InvalidOperationException( + "OnConflict options cannot be used together with IncludeGraph. " + + "Either disable IncludeGraph or remove the onConflict parameter."); + } + var orchestrator = new GraphBulkInsertOrchestrator(); await orchestrator.InsertGraphAsync(context, entities, options, provider, cancellationToken); return; @@ -214,6 +221,13 @@ public static void ExecuteBulkInsert( if (options.IncludeGraph) { + if (onConflict != null) + { + throw new InvalidOperationException( + "OnConflict options cannot be used together with IncludeGraph. " + + "Either disable IncludeGraph or remove the onConflict parameter."); + } + var orchestrator = new GraphBulkInsertOrchestrator(); orchestrator.InsertGraphAsync(context, entities, options, provider, CancellationToken.None) .GetAwaiter().GetResult(); diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 045e84d..652bf5a 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -121,7 +121,6 @@ private static void PropagateParentForeignKeys( // For each FK relationship, propagate PK values from parent entities foreach (var fk in efEntityType.GetForeignKeys()) { - var principalEntityType = fk.PrincipalEntityType; var dependentNavigation = fk.DependentToPrincipal; if (dependentNavigation == null) @@ -300,6 +299,9 @@ private async Task InsertJoinRecordsAsync( continue; } + // Check if the join entity is a dictionary (shared-type entity) + var isDictionary = joinEntry is IDictionary; + // Set FK values for left entity for (var i = 0; i < fk.Properties.Count; i++) { @@ -307,7 +309,14 @@ private async Task InsertJoinRecordsAsync( var pkProp = fk.PrincipalKey.Properties[i]; var pkValue = GetPropertyValue(record.LeftEntity, pkProp.Name); - SetPropertyValue(joinEntry, fkProp.Name, pkValue); + if (isDictionary) + { + ((IDictionary)joinEntry)[fkProp.Name] = pkValue!; + } + else + { + SetPropertyValue(joinEntry, fkProp.Name, pkValue); + } } // Set FK values for right entity @@ -317,7 +326,14 @@ private async Task InsertJoinRecordsAsync( var pkProp = inverseFk.PrincipalKey.Properties[i]; var pkValue = GetPropertyValue(record.RightEntity, pkProp.Name); - SetPropertyValue(joinEntry, fkProp.Name, pkValue); + if (isDictionary) + { + ((IDictionary)joinEntry)[fkProp.Name] = pkValue!; + } + else + { + SetPropertyValue(joinEntry, fkProp.Name, pkValue); + } } joinEntities.Add(joinEntry); @@ -353,7 +369,12 @@ private async Task InsertJoinEntitiesAsync( .GetMethod(nameof(IBulkInsertProvider.BulkInsert))! .MakeGenericMethod(joinEntityType); - var task = (Task)method.Invoke(provider, [false, context, tableInfo, joinEntities, options, null, ctk])!; + var result = method.Invoke(provider, [false, context, tableInfo, joinEntities, options, null, ctk]); + if (result is not Task task) + { + throw new InvalidOperationException( + $"The BulkInsert method for join entity type '{joinEntityType.Name}' did not return a Task as expected."); + } await task; } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs index 621a21d..53515c3 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs @@ -100,13 +100,9 @@ private void TopologicalSort( // Get dependencies (types that this type references via FKs) var navigations = GetNavigations(type); - foreach (var nav in navigations) + foreach (var nav in navigations.Where(n => n.IsDependentToPrincipal && validTypes.Contains(n.TargetType) && n.TargetType != type)) { - // Only consider dependent-to-principal navigations (this entity has the FK) - if (nav.IsDependentToPrincipal && validTypes.Contains(nav.TargetType) && nav.TargetType != type) - { - TopologicalSort(nav.TargetType, validTypes, visited, visiting, result); - } + TopologicalSort(nav.TargetType, validTypes, visited, visiting, result); } visiting.Remove(type); diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs index 4eaae03..31b2ae6 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -359,4 +359,79 @@ public async Task InsertGraph_SyncVariant_Works() var insertedPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); insertedPosts.Should().HaveCount(1); } + + [SkippableFact] + public async Task InsertGraph_SelfReferencing_InsertsRootOnly() + { + // Arrange - Self-referencing hierarchies require multiple inserts in order + // This test verifies that root entities without parents can be inserted + var rootCategory = new Category + { + TestRun = _run, + Name = $"{_run}_RootCategory", + Parent = null, + ParentId = null + }; + + // Act + await _context.ExecuteBulkInsertAsync(new[] { rootCategory }, options => + { + options.IncludeGraph = true; + }); + + // Assert + var insertedCategories = _context.Categories.Where(c => c.TestRun == _run).ToList(); + insertedCategories.Should().HaveCount(1); + + var insertedRoot = insertedCategories.First(); + insertedRoot.Id.Should().BeGreaterThan(0); + insertedRoot.ParentId.Should().BeNull(); + } + + [SkippableFact] + public async Task InsertGraph_ManyToMany_TraversesRelatedEntities() + { + // Note: Many-to-many join table insertion requires explicit join entity types. + // Dictionary join entities are not supported by the bulk insert infrastructure. + // This test verifies that many-to-many navigations are traversed and related entities are collected. + + // Arrange - Create a post with tags + var tag1 = new Tag { TestRun = _run, Name = $"{_run}_Tag1" }; + var tag2 = new Tag { TestRun = _run, Name = $"{_run}_Tag2" }; + + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_BlogWithTaggedPost", + Posts = new List + { + new Post + { + TestRun = _run, + Title = $"{_run}_TaggedPost", + Tags = new List { tag1, tag2 } + } + } + }; + + // Act + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + }); + + // Assert - Verify the entities were inserted (even if join table wasn't populated) + var insertedBlog = _context.Blogs.FirstOrDefault(b => b.TestRun == _run); + insertedBlog.Should().NotBeNull(); + insertedBlog!.Id.Should().BeGreaterThan(0); + + var insertedPost = _context.Posts.FirstOrDefault(p => p.TestRun == _run); + insertedPost.Should().NotBeNull(); + insertedPost!.Id.Should().BeGreaterThan(0); + + // Tags should be inserted as they were traversed via many-to-many navigation + var insertedTags = _context.Tags.Where(t => t.TestRun == _run).ToList(); + insertedTags.Should().HaveCount(2); + insertedTags.Should().AllSatisfy(t => t.Id.Should().BeGreaterThan(0)); + } } From 5f74e09be13c9f6a950ff68d2a30bd739b560639 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:47:31 +0000 Subject: [PATCH 07/26] Improve dictionary cast in join entity handling Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Graph/GraphBulkInsertOrchestrator.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 652bf5a..9ba305a 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -300,7 +300,7 @@ private async Task InsertJoinRecordsAsync( } // Check if the join entity is a dictionary (shared-type entity) - var isDictionary = joinEntry is IDictionary; + var dictEntry = joinEntry as IDictionary; // Set FK values for left entity for (var i = 0; i < fk.Properties.Count; i++) @@ -309,9 +309,9 @@ private async Task InsertJoinRecordsAsync( var pkProp = fk.PrincipalKey.Properties[i]; var pkValue = GetPropertyValue(record.LeftEntity, pkProp.Name); - if (isDictionary) + if (dictEntry != null) { - ((IDictionary)joinEntry)[fkProp.Name] = pkValue!; + dictEntry[fkProp.Name] = pkValue!; } else { @@ -326,9 +326,9 @@ private async Task InsertJoinRecordsAsync( var pkProp = inverseFk.PrincipalKey.Properties[i]; var pkValue = GetPropertyValue(record.RightEntity, pkProp.Name); - if (isDictionary) + if (dictEntry != null) { - ((IDictionary)joinEntry)[fkProp.Name] = pkValue!; + dictEntry[fkProp.Name] = pkValue!; } else { From 8e4dfe620408afa5afcd17d6a3f75db5e030c34e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:03:07 +0000 Subject: [PATCH 08/26] Add tests for entity linking with generated and client-generated keys Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Tests/Graph/GraphTestsBase.cs | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs index 31b2ae6..aead6ee 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -434,4 +434,158 @@ await _context.ExecuteBulkInsertAsync(new[] { blog }, options => insertedTags.Should().HaveCount(2); insertedTags.Should().AllSatisfy(t => t.Id.Should().BeGreaterThan(0)); } + + [SkippableFact] + public async Task InsertGraph_OriginalEntitiesLinked_WithGeneratedKeys() + { + // Arrange - Keep references to original entities + var post1 = new Post { TestRun = _run, Title = $"{_run}_LinkedPost1" }; + var post2 = new Post { TestRun = _run, Title = $"{_run}_LinkedPost2" }; + var settings = new BlogSettings { TestRun = _run, EnableComments = true }; + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_LinkedBlog", + Posts = new List { post1, post2 }, + Settings = settings + }; + + // Act + await _context.ExecuteBulkInsertAsync(new[] { blog }, options => + { + options.IncludeGraph = true; + }); + + // Assert - Verify the original entity references are the same objects + // and have their generated IDs populated + blog.Id.Should().BeGreaterThan(0, "Blog should have generated ID populated"); + post1.Id.Should().BeGreaterThan(0, "Post1 should have generated ID populated"); + post2.Id.Should().BeGreaterThan(0, "Post2 should have generated ID populated"); + settings.Id.Should().BeGreaterThan(0, "Settings should have generated ID populated"); + + // Verify FK values are propagated + post1.BlogId.Should().Be(blog.Id, "Post1.BlogId should reference the Blog"); + post2.BlogId.Should().Be(blog.Id, "Post2.BlogId should reference the Blog"); + settings.BlogId.Should().Be(blog.Id, "Settings.BlogId should reference the Blog"); + + // Verify the same objects are in the collections + blog.Posts.Should().Contain(post1, "Original post1 reference should still be in the collection"); + blog.Posts.Should().Contain(post2, "Original post2 reference should still be in the collection"); + blog.Settings.Should().BeSameAs(settings, "Original settings reference should still be assigned"); + + // Verify data matches what's in the database + var dbBlog = _context.Blogs.FirstOrDefault(b => b.Id == blog.Id); + dbBlog.Should().NotBeNull(); + dbBlog!.Name.Should().Be(blog.Name); + + var dbPosts = _context.Posts.Where(p => p.BlogId == blog.Id).ToList(); + dbPosts.Should().HaveCount(2); + dbPosts.Select(p => p.Id).Should().Contain(post1.Id); + dbPosts.Select(p => p.Id).Should().Contain(post2.Id); + } + + [SkippableFact] + public async Task InsertGraph_OriginalEntitiesLinked_WithClientGeneratedKeys() + { + // Arrange - Create entities with pre-set GUIDs (client-generated keys) + // Using TestEntityWithGuidId which has ValueGeneratedNever() + var guid1 = Guid.NewGuid(); + var guid2 = Guid.NewGuid(); + + var entity1 = new TestEntityWithGuidId + { + TestRun = _run, + Id = guid1, + Name = $"{_run}_ClientGenKey1" + }; + var entity2 = new TestEntityWithGuidId + { + TestRun = _run, + Id = guid2, + Name = $"{_run}_ClientGenKey2" + }; + + // Act - Insert without graph (since these don't have navigations) + // but test that client-generated keys are preserved + await _context.ExecuteBulkInsertAsync(new[] { entity1, entity2 }, options => + { + // No IncludeGraph needed since no navigations + }); + + // Assert - Verify the original entity references maintain their IDs + entity1.Id.Should().Be(guid1, "Entity1 should retain its client-generated ID"); + entity2.Id.Should().Be(guid2, "Entity2 should retain its client-generated ID"); + + // Verify data is in database with the same IDs + var dbEntity1 = _context.TestEntitiesWithGuidId.FirstOrDefault(e => e.Id == guid1); + var dbEntity2 = _context.TestEntitiesWithGuidId.FirstOrDefault(e => e.Id == guid2); + + dbEntity1.Should().NotBeNull(); + dbEntity2.Should().NotBeNull(); + dbEntity1!.Name.Should().Be(entity1.Name); + dbEntity2!.Name.Should().Be(entity2.Name); + } + + [SkippableFact] + public async Task InsertGraph_MultipleRootEntities_OriginalEntitiesLinked() + { + // Arrange - Multiple root entities with children, keep all references + var post1 = new Post { TestRun = _run, Title = $"{_run}_Multi1Post1" }; + var post2 = new Post { TestRun = _run, Title = $"{_run}_Multi2Post1" }; + var post3 = new Post { TestRun = _run, Title = $"{_run}_Multi2Post2" }; + + var blog1 = new Blog + { + TestRun = _run, + Name = $"{_run}_MultiBlogLinked1", + Posts = new List { post1 } + }; + var blog2 = new Blog + { + TestRun = _run, + Name = $"{_run}_MultiBlogLinked2", + Posts = new List { post2, post3 } + }; + + var blogs = new[] { blog1, blog2 }; + + // Act + await _context.ExecuteBulkInsertAsync(blogs, options => + { + options.IncludeGraph = true; + }); + + // Assert - All original entities should have IDs and be linked correctly + blog1.Id.Should().BeGreaterThan(0); + blog2.Id.Should().BeGreaterThan(0); + blog1.Id.Should().NotBe(blog2.Id, "Different blogs should have different IDs"); + + post1.Id.Should().BeGreaterThan(0); + post2.Id.Should().BeGreaterThan(0); + post3.Id.Should().BeGreaterThan(0); + post1.Id.Should().NotBe(post2.Id); + post2.Id.Should().NotBe(post3.Id); + + // Verify FK relationships + post1.BlogId.Should().Be(blog1.Id); + post2.BlogId.Should().Be(blog2.Id); + post3.BlogId.Should().Be(blog2.Id); + + // Verify original objects are still in collections + blog1.Posts.Should().Contain(post1); + blog2.Posts.Should().Contain(post2); + blog2.Posts.Should().Contain(post3); + + // Verify database state matches + var dbBlogs = _context.Blogs.Where(b => b.TestRun == _run).ToList(); + dbBlogs.Should().HaveCount(2); + dbBlogs.Select(b => b.Id).Should().Contain(blog1.Id); + dbBlogs.Select(b => b.Id).Should().Contain(blog2.Id); + + var dbPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + dbPosts.Should().HaveCount(3); + dbPosts.Select(p => p.Id).Should().Contain(post1.Id); + dbPosts.Select(p => p.Id).Should().Contain(post2.Id); + dbPosts.Select(p => p.Id).Should().Contain(post3.Id); + } } From f81160983bb823693642a6946acdd601b57c0689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:23:39 +0000 Subject: [PATCH 09/26] Add UseIncludeGraph property to benchmark project Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../LibComparator.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs index ccc76a0..f332cff 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs @@ -19,6 +19,12 @@ public abstract partial class LibComparator [Params(500_000/*, 1_000_000/*, 10_000_000*/)] public int N; + /// + /// Set to true to benchmark with IncludeGraph option enabled. + /// Default is false, which runs the benchmark exactly as before. + /// + public bool UseIncludeGraph { get; set; } = false; + private IList data = []; protected TestDbContext DbContext { get; set; } = null!; @@ -58,7 +64,10 @@ protected virtual string GetConnectionString() [Benchmark(Baseline = true)] public async Task PhenX_EntityFrameworkCore_BulkInsert() { - await DbContext.ExecuteBulkInsertAsync(data); + await DbContext.ExecuteBulkInsertAsync(data, options => + { + options.IncludeGraph = UseIncludeGraph; + }); } // // [Benchmark] From c06aee24b6439a575d470437520c45abd3b58784 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:27:06 +0000 Subject: [PATCH 10/26] Update benchmark to generate entities with children when UseIncludeGraph is true Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../LibComparator.cs | 46 +++++++++++++++---- .../TestDbContext.cs | 13 ++++++ .../TestEntity.cs | 20 ++++++++ 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs index f332cff..4dc3fd8 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs @@ -21,7 +21,8 @@ public abstract partial class LibComparator /// /// Set to true to benchmark with IncludeGraph option enabled. - /// Default is false, which runs the benchmark exactly as before. + /// When true, each entity will have 2 child entities for graph insertion benchmarking. + /// Default is false, which runs the benchmark exactly as before (flat entities only). /// public bool UseIncludeGraph { get; set; } = false; @@ -31,12 +32,27 @@ public abstract partial class LibComparator [IterationSetup] public void IterationSetup() { - data = Enumerable.Range(1, N).Select(i => new TestEntity + data = Enumerable.Range(1, N).Select(i => { - Name = $"Entity{i}", - Price = (decimal)(i * 0.1), - Identifier = Guid.NewGuid(), - NumericEnumValue = (NumericEnum)(i % 2), + var entity = new TestEntity + { + Name = $"Entity{i}", + Price = (decimal)(i * 0.1), + Identifier = Guid.NewGuid(), + NumericEnumValue = (NumericEnum)(i % 2), + }; + + // When UseIncludeGraph is true, add child entities for graph insertion benchmarking + if (UseIncludeGraph) + { + entity.Children = new List + { + new TestEntityChild { Description = $"Child1 of Entity{i}", Quantity = i }, + new TestEntityChild { Description = $"Child2 of Entity{i}", Quantity = i * 2 }, + }; + } + + return entity; }).ToList(); ConfigureDbContext(); @@ -79,6 +95,12 @@ await DbContext.ExecuteBulkInsertAsync(data, options => [Benchmark] public void RawInsert() { + if (UseIncludeGraph) + { + // Raw insert doesn't support graph insertion - skip when UseIncludeGraph is true + return; + } + if (DbContext.Database.ProviderName!.Contains("SqlServer", StringComparison.InvariantCultureIgnoreCase)) { // Use SqlBulkCopy for SQL Server @@ -109,6 +131,12 @@ public void RawInsert() [Benchmark] public async Task Linq2Db() { + if (UseIncludeGraph) + { + // Linq2Db doesn't support graph insertion - skip when UseIncludeGraph is true + return; + } + await DbContext.BulkCopyAsync(new BulkCopyOptions { BulkCopyType = BulkCopyType.ProviderSpecific, @@ -118,7 +146,7 @@ await DbContext.BulkCopyAsync(new BulkCopyOptions [Benchmark] public async Task Z_EntityFramework_Extensions_EFCore() { - await DbContext.BulkInsertOptimizedAsync(data, options => options.IncludeGraph = false); + await DbContext.BulkInsertOptimizedAsync(data, options => options.IncludeGraph = UseIncludeGraph); } // [Benchmark] @@ -132,8 +160,8 @@ public async Task EFCore_BulkExtensions() { await DbContext.BulkInsertAsync(data, options => { - options.IncludeGraph = false; - options.PreserveInsertOrder = false; + options.IncludeGraph = UseIncludeGraph; + options.PreserveInsertOrder = UseIncludeGraph; // Required for graph insertion }); } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestDbContext.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestDbContext.cs index 0e09a4b..fad5314 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestDbContext.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestDbContext.cs @@ -7,9 +7,22 @@ public class TestDbContext(Action configure) : DbContex public Action Configure { get; } = configure; public DbSet TestEntities { get; set; } = null!; + public DbSet TestEntityChildren { get; set; } = null!; protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { Configure(optionsBuilder); } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(builder => + { + builder.HasMany(e => e.Children) + .WithOne(c => c.TestEntity) + .HasForeignKey(c => c.TestEntityId); + }); + } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestEntity.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestEntity.cs index ef9ecd2..ff90936 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestEntity.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestEntity.cs @@ -17,6 +17,26 @@ public class TestEntity public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public NumericEnum NumericEnumValue { get; set; } + + /// + /// Child entities for IncludeGraph benchmarking. + /// + public ICollection Children { get; set; } = new List(); +} + +/// +/// Child entity for benchmarking IncludeGraph with navigation properties. +/// +[PrimaryKey(nameof(Id))] +[Table(nameof(TestEntityChild))] +public class TestEntityChild +{ + public int Id { get; set; } + public string Description { get; set; } = string.Empty; + public int Quantity { get; set; } + + public int TestEntityId { get; set; } + public TestEntity TestEntity { get; set; } = null!; } public enum NumericEnum From d1f06323379648f5c7daf355aac6b1d9b3be4011 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 7 Feb 2026 19:05:04 +0100 Subject: [PATCH 11/26] Refactor GraphTestsBase to use IDbContextFactory for context creation --- .../Tests/Graph/GraphTestsBase.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs index aead6ee..17dae4c 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -8,7 +8,7 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Graph; -public abstract class GraphTestsBase(TestDbContainer dbContainer) : IAsyncLifetime +public abstract class GraphTestsBase(IDbContextFactory dbContextFactory) : IAsyncLifetime where TDbContext : TestDbContext, new() { private readonly Guid _run = Guid.NewGuid(); @@ -16,7 +16,7 @@ public abstract class GraphTestsBase(TestDbContainer dbContainer) : public async Task InitializeAsync() { - _context = await dbContainer.CreateContextAsync("graph"); + _context = await dbContextFactory.CreateContextAsync("graph"); } public Task DisposeAsync() From 6b6b0030bde088c799b1575df708bf900bd5e5b6 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 8 Feb 2026 09:35:53 +0100 Subject: [PATCH 12/26] Code cleanup and handle providers that don't support inserted ids when trying to include graph --- .../MySqlBulkInsertProvider.cs | 3 + .../OracleBulkInsertProvider.cs | 3 + .../Extensions/PublicExtensions.DbSet.cs | 10 +- .../Graph/GraphBulkInsertOrchestrator.cs | 81 ++++++++-------- .../Graph/GraphCollectionResult.cs | 22 +++++ .../Graph/GraphEntityCollector.cs | 97 ++++++++----------- .../Graph/GraphInsertResult.cs | 18 ++++ .../Graph/JoinRecord.cs | 14 +++ .../Metadata/GraphMetadata.cs | 6 +- .../LibComparator.cs | 2 +- 10 files changed, 146 insertions(+), 110 deletions(-) create mode 100644 src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphCollectionResult.cs create mode 100644 src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphInsertResult.cs create mode 100644 src/PhenX.EntityFrameworkCore.BulkInsert/Graph/JoinRecord.cs diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.MySql/MySqlBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.MySql/MySqlBulkInsertProvider.cs index fc14d5f..20cdfd3 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.MySql/MySqlBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.MySql/MySqlBulkInsertProvider.cs @@ -18,6 +18,9 @@ internal class MySqlBulkInsertProvider(ILogger logger) /// protected override string AddTableCopyBulkInsertId => $"ALTER TABLE {{0}} ADD {BulkInsertId} INT AUTO_INCREMENT PRIMARY KEY;"; + /// + public override bool SupportsOutputInsertedIds => false; + /// protected override string GetTempTableName(string tableName) => $"#_temp_bulk_insert_{tableName}_{Helpers.RandomString(6)}"; diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs index b97feab..712150f 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs @@ -19,6 +19,9 @@ internal class OracleBulkInsertProvider(ILogger? logge /// protected override string AddTableCopyBulkInsertId => ""; // No need to add an ID column in Oracle + /// + public override bool SupportsOutputInsertedIds => false; + /// /// /// The temporary table name is generated with a random 8-character suffix to ensure uniqueness, and is limited to less than 30 characters, diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs index aac8d86..1d5a3ba 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs @@ -167,8 +167,9 @@ public static async Task ExecuteBulkInsertAsync( "Either disable IncludeGraph or remove the onConflict parameter."); } - var orchestrator = new GraphBulkInsertOrchestrator(); - await orchestrator.InsertGraphAsync(context, entities, options, provider, cancellationToken); + var orchestrator = new GraphBulkInsertOrchestrator(context); + await orchestrator.InsertGraph(false, entities, options, provider, cancellationToken); + return; } @@ -228,9 +229,10 @@ public static void ExecuteBulkInsert( "Either disable IncludeGraph or remove the onConflict parameter."); } - var orchestrator = new GraphBulkInsertOrchestrator(); - orchestrator.InsertGraphAsync(context, entities, options, provider, CancellationToken.None) + var orchestrator = new GraphBulkInsertOrchestrator(context); + orchestrator.InsertGraph(true, entities, options, provider, CancellationToken.None) .GetAwaiter().GetResult(); + return; } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 9ba305a..bc8559a 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Logging; using PhenX.EntityFrameworkCore.BulkInsert.Abstractions; using PhenX.EntityFrameworkCore.BulkInsert.Metadata; @@ -12,23 +12,6 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; -/// -/// Result of a graph insert operation. -/// -/// The root entity type. -internal sealed class GraphInsertResult where T : class -{ - /// - /// The root entities that were inserted. - /// - public required IReadOnlyList RootEntities { get; init; } - - /// - /// Total count of all entities inserted across all types. - /// - public required int TotalInsertedCount { get; init; } -} - /// /// Orchestrates bulk insertion of entity graphs with FK propagation. /// @@ -37,25 +20,35 @@ internal sealed class GraphBulkInsertOrchestrator private static readonly ConcurrentDictionary<(Type, string), Action> PropertySetters = new(); private static readonly ConcurrentDictionary<(Type, string), Func> PropertyGetters = new(); + private readonly DbContext _context; private readonly MetadataProvider _metadataProvider; + private readonly ILogger? _logger; - public GraphBulkInsertOrchestrator() + public GraphBulkInsertOrchestrator(DbContext context) { - _metadataProvider = new MetadataProvider(); + _context = context; + _metadataProvider = context.GetService(); + _logger = context.GetService>(); } /// /// Orchestrates the bulk insert of an entity graph. /// - public async Task> InsertGraphAsync( - DbContext context, + public async Task> InsertGraph( + bool sync, IEnumerable entities, BulkInsertOptions options, IBulkInsertProvider provider, CancellationToken ctk) where T : class { + if (!provider.SupportsOutputInsertedIds) + { + throw new NotSupportedException( + $"The bulk insert provider '{provider.GetType().Name}' does not support returning generated IDs, which is required for IncludeGraph operations."); + } + // 1. Collect and sort entities - var collector = new GraphEntityCollector(context, options); + var collector = new GraphEntityCollector(_context, options); var collectionResult = collector.Collect(entities); if (collectionResult.EntitiesByType.Count == 0) @@ -68,7 +61,7 @@ public async Task> InsertGraphAsync( } var totalInserted = 0; - var graphMetadata = new GraphMetadata(context, options); + var graphMetadata = new GraphMetadata(_context, options); // 2. Insert in dependency order (parents first) foreach (var entityType in collectionResult.InsertionOrder) @@ -80,10 +73,10 @@ public async Task> InsertGraphAsync( } // Propagate FK values from already-inserted parents - PropagateParentForeignKeys(entitiesToInsert, entityType, graphMetadata, context); + PropagateParentForeignKeys(entitiesToInsert, entityType, graphMetadata); // Insert entities of this type - await InsertEntitiesOfTypeAsync(context, entityType, entitiesToInsert, options, provider, ctk); + await InsertEntitiesOfType(sync, _context, entityType, entitiesToInsert, options, provider, ctk); totalInserted += entitiesToInsert.Count; } @@ -91,7 +84,7 @@ public async Task> InsertGraphAsync( // 3. Insert join table records for many-to-many relationships if (collectionResult.JoinRecords.Count > 0) { - await InsertJoinRecordsAsync(context, collectionResult.JoinRecords, options, provider, ctk); + await InsertJoinRecords(sync, _context, collectionResult.JoinRecords, options, provider, ctk); } // Return root entities @@ -109,8 +102,7 @@ public async Task> InsertGraphAsync( private static void PropagateParentForeignKeys( List entities, Type entityType, - GraphMetadata graphMetadata, - DbContext context) + GraphMetadata graphMetadata) { var efEntityType = graphMetadata.GetEntityType(entityType); if (efEntityType == null) @@ -162,7 +154,8 @@ private static void PropagateParentForeignKeys( } } - private async Task InsertEntitiesOfTypeAsync( + private async Task InsertEntitiesOfType( + bool sync, DbContext context, Type entityType, List entities, @@ -218,7 +211,7 @@ private async Task InsertEntitiesGenericAsync( } } - private static void CopyGeneratedIds( + private void CopyGeneratedIds( List originalEntities, List insertedEntities, TableMetadata tableInfo) where TEntity : class @@ -228,10 +221,10 @@ private static void CopyGeneratedIds( // Count mismatch - this can happen if the bulk insert operation // doesn't preserve order. Log a warning for debugging purposes. // The graph insert will continue but FK propagation may be incomplete. - System.Diagnostics.Debug.WriteLine( - $"Warning: IncludeGraph ID propagation failed for {typeof(TEntity).Name}. " + - $"Original count: {originalEntities.Count}, Inserted count: {insertedEntities.Count}. " + - "Foreign key values may not be correctly propagated to dependent entities."); + _logger?.LogWarning( + "IncludeGraph ID propagation failed for {EntityType}. Original count: {OriginalCount}, Inserted count: {InsertedCount}. Foreign key values may not be correctly propagated to dependent entities.", + typeof(TEntity).Name, originalEntities.Count, insertedEntities.Count); + return; } @@ -254,7 +247,8 @@ private static void CopyGeneratedIds( } } - private async Task InsertJoinRecordsAsync( + private async Task InsertJoinRecords( + bool sync, DbContext context, List joinRecords, BulkInsertOptions options, @@ -293,9 +287,11 @@ private async Task InsertJoinRecordsAsync( var joinEntry = Activator.CreateInstance(joinEntityType); if (joinEntry == null) { - System.Diagnostics.Debug.WriteLine( - $"Warning: IncludeGraph failed to create join entry for {joinEntityType.Name}. " + - "Many-to-many relationship may be incomplete."); + _logger?.LogWarning( + "IncludeGraph failed to create join entry for {EntityType}. Many-to-many relationship may be incomplete.", + joinEntityType.Name + ); + continue; } @@ -342,12 +338,13 @@ private async Task InsertJoinRecordsAsync( if (joinEntities.Count > 0) { // Insert join entities - await InsertJoinEntitiesAsync(context, joinEntityType, joinEntities, options, provider, ctk); + await InsertJoinEntities(sync, context, joinEntityType, joinEntities, options, provider, ctk); } } } - private async Task InsertJoinEntitiesAsync( + private async Task InsertJoinEntities( + bool sync, DbContext context, Type joinEntityType, List joinEntities, @@ -369,7 +366,7 @@ private async Task InsertJoinEntitiesAsync( .GetMethod(nameof(IBulkInsertProvider.BulkInsert))! .MakeGenericMethod(joinEntityType); - var result = method.Invoke(provider, [false, context, tableInfo, joinEntities, options, null, ctk]); + var result = method.Invoke(provider, [sync, context, tableInfo, joinEntities, options, null, ctk]); if (result is not Task task) { throw new InvalidOperationException( diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphCollectionResult.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphCollectionResult.cs new file mode 100644 index 0000000..f08629d --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphCollectionResult.cs @@ -0,0 +1,22 @@ +namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; + +/// +/// Result of collecting entities from an object graph. +/// +internal sealed class GraphCollectionResult +{ + /// + /// Entities grouped by type. + /// + public required Dictionary> EntitiesByType { get; init; } + + /// + /// Types in topological insertion order (parents before children). + /// + public required IReadOnlyList InsertionOrder { get; init; } + + /// + /// Many-to-many join records to insert after both sides are inserted. + /// + public required List JoinRecords { get; init; } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs index 1628106..dd45d71 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs @@ -8,38 +8,6 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; -/// -/// Result of collecting entities from an object graph. -/// -internal sealed class GraphCollectionResult -{ - /// - /// Entities grouped by type. - /// - public required Dictionary> EntitiesByType { get; init; } - - /// - /// Types in topological insertion order (parents before children). - /// - public required IReadOnlyList InsertionOrder { get; init; } - - /// - /// Many-to-many join records to insert after both sides are inserted. - /// - public required List JoinRecords { get; init; } -} - -/// -/// Represents a join table record for many-to-many relationships. -/// -internal sealed class JoinRecord -{ - public required Type JoinEntityType { get; init; } - public required object LeftEntity { get; init; } - public required object RightEntity { get; init; } - public required NavigationMetadata Navigation { get; init; } -} - /// /// Collects all entities from an object graph for bulk insertion. /// @@ -82,20 +50,27 @@ public GraphCollectionResult Collect(IEnumerable rootEntities) where T : c }; } - private void CollectEntity(object entity, int depth) + private void CollectEntity(object? entity, int depth) { - if (entity == null || !_visited.Add(entity)) + if (entity == null) { - // Already visited or null + // Null entity, nothing to collect return; } - // Check max depth + // Check max depth before marking as visited to avoid permanently + // excluding entities that might be reachable at a valid depth later. if (_options.MaxGraphDepth > 0 && depth > _options.MaxGraphDepth) { return; } + if (!_visited.Add(entity)) + { + // Already visited + return; + } + var entityType = entity.GetType(); var efEntityType = _graphMetadata.GetEntityType(entityType); @@ -133,33 +108,37 @@ private void CollectEntity(object entity, int depth) if (navigation.IsCollection) { - if (value is IEnumerable collection) + if (value is not IEnumerable collection) { - foreach (var item in collection) + continue; + } + + foreach (var item in collection) + { + if (item == null) + { + continue; + } + + if (navigation.IsManyToMany) { - if (item != null) + // Record join table entry + _joinRecords.Add(new JoinRecord { - if (navigation.IsManyToMany) - { - // Record join table entry - _joinRecords.Add(new JoinRecord - { - JoinEntityType = navigation.JoinEntityType!.ClrType, - LeftEntity = entity, - RightEntity = item, - Navigation = navigation, - }); - } - else - { - // For one-to-many, set the inverse navigation property - // so that FK propagation can find the parent - SetInverseNavigation(entity, item, navigation); - } - - CollectEntity(item, depth + 1); - } + JoinEntityType = navigation.JoinEntityType!.ClrType, + LeftEntity = entity, + RightEntity = item, + Navigation = navigation, + }); } + else + { + // For one-to-many, set the inverse navigation property + // so that FK propagation can find the parent + SetInverseNavigation(entity, item, navigation); + } + + CollectEntity(item, depth + 1); } } else diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphInsertResult.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphInsertResult.cs new file mode 100644 index 0000000..f8bdc90 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphInsertResult.cs @@ -0,0 +1,18 @@ +namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; + +/// +/// Result of a graph insert operation. +/// +/// The root entity type. +internal sealed class GraphInsertResult where T : class +{ + /// + /// The root entities that were inserted. + /// + public required IReadOnlyList RootEntities { get; init; } + + /// + /// Total count of all entities inserted across all types. + /// + public required int TotalInsertedCount { get; init; } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/JoinRecord.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/JoinRecord.cs new file mode 100644 index 0000000..52802c7 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/JoinRecord.cs @@ -0,0 +1,14 @@ +using PhenX.EntityFrameworkCore.BulkInsert.Metadata; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; + +/// +/// Represents a join table record for many-to-many relationships. +/// +internal sealed class JoinRecord +{ + public required Type JoinEntityType { get; init; } + public required object LeftEntity { get; init; } + public required object RightEntity { get; init; } + public required NavigationMetadata Navigation { get; init; } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs index 53515c3..6b333f9 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs @@ -46,7 +46,7 @@ public GraphMetadata(DbContext context, BulkInsertOptions options) /// public IEntityType? GetEntityType(Type clrType) { - return _entityTypes.TryGetValue(clrType, out var entityType) ? entityType : null; + return _entityTypes.GetValueOrDefault(clrType); } /// @@ -90,14 +90,12 @@ private void TopologicalSort( return; } - if (visiting.Contains(type)) + if (!visiting.Add(type)) { // Cycle detected - this is handled gracefully, just skip return; } - visiting.Add(type); - // Get dependencies (types that this type references via FKs) var navigations = GetNavigations(type); foreach (var nav in navigations.Where(n => n.IsDependentToPrincipal && validTypes.Contains(n.TargetType) && n.TargetType != type)) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs index 7af329c..4027858 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs @@ -24,7 +24,7 @@ public abstract partial class LibComparator /// When true, each entity will have 2 child entities for graph insertion benchmarking. /// Default is false, which runs the benchmark exactly as before (flat entities only). /// - public bool UseIncludeGraph { get; set; } = false; + private const bool UseIncludeGraph = false; private IList data = []; protected TestDbContext DbContext { get; set; } = null!; From a181197de6aed2a5bf71f47067e2883b86431df3 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 8 Feb 2026 09:54:44 +0100 Subject: [PATCH 13/26] Optimize navigation getters --- .../Graph/GraphEntityCollector.cs | 8 +--- .../Metadata/NavigationMetadata.cs | 37 +++++++------------ 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs index dd45d71..c94213c 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs @@ -94,13 +94,7 @@ private void CollectEntity(object? entity, int depth) foreach (var navigation in navigations) { - var propertyInfo = entityType.GetProperty(navigation.PropertyName, BindingFlags.Public | BindingFlags.Instance); - if (propertyInfo == null) - { - continue; - } - - var value = propertyInfo.GetValue(entity); + var value = navigation.GetValue(entity); if (value == null) { continue; diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs index 2d04fa3..59fec33 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs @@ -1,3 +1,5 @@ +using System.Reflection; + using Microsoft.EntityFrameworkCore.Metadata; namespace PhenX.EntityFrameworkCore.BulkInsert.Metadata; @@ -14,6 +16,14 @@ public NavigationMetadata(INavigationBase navigation) TargetType = navigation.TargetEntityType.ClrType; IsCollection = navigation.IsCollection; + // Build optimized getter for the navigation property + var propertyInfo = navigation.DeclaringEntityType.ClrType.GetProperty( + navigation.Name, + BindingFlags.Public | BindingFlags.Instance) + ?? throw new InvalidOperationException($"Property '{navigation.Name}' not found on type '{navigation.DeclaringEntityType.ClrType.Name}'"); + + _getter = PropertyAccessor.CreateGetter(propertyInfo); + if (navigation is ISkipNavigation skipNavigation) { IsManyToMany = true; @@ -29,6 +39,8 @@ public NavigationMetadata(INavigationBase navigation) } } + private readonly Func _getter; + /// /// The underlying EF Core navigation. /// @@ -75,30 +87,9 @@ public NavigationMetadata(INavigationBase navigation) public bool IsDependentToPrincipal { get; } /// - /// Gets the FK property names on the source entity (for dependent-to-principal navigations). + /// Gets the value of the navigation property from the entity using an optimized getter. /// - public IReadOnlyList GetForeignKeyPropertyNames() - { - if (ForeignKey == null) - { - return []; - } - - return ForeignKey.Properties.Select(p => p.Name).ToList(); - } - - /// - /// Gets the principal key property names. - /// - public IReadOnlyList GetPrincipalKeyPropertyNames() - { - if (ForeignKey == null) - { - return []; - } - - return ForeignKey.PrincipalKey.Properties.Select(p => p.Name).ToList(); - } + public object? GetValue(object entity) => _getter.Invoke(entity); public override string ToString() { From 463dd4b476bb60bcae1aa92f142cdf3c2ed57514 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 8 Feb 2026 13:56:40 +0100 Subject: [PATCH 14/26] Enhance inverse navigation handling in GraphEntityCollector and NavigationMetadata --- .../Graph/GraphEntityCollector.cs | 24 ++++--------- .../Metadata/NavigationMetadata.cs | 34 +++++++++++++++++++ .../Metadata/PropertyAccessor.cs | 28 +++++++++++++++ 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs index c94213c..de5e1a9 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs @@ -1,5 +1,4 @@ using System.Collections; -using System.Reflection; using Microsoft.EntityFrameworkCore; @@ -146,29 +145,18 @@ private void CollectEntity(object? entity, int depth) private static void SetInverseNavigation(object parentEntity, object childEntity, NavigationMetadata navigation) { - // For one-to-many navigations, find and set the inverse navigation property + // For one-to-many navigations, set the inverse navigation property // (e.g., if Blog.Posts is the navigation, set Post.Blog = blog) - var nav = navigation.Navigation; - if (nav is not Microsoft.EntityFrameworkCore.Metadata.INavigation regularNav) + if (!navigation.HasInverseSetter) { return; } - var inverse = regularNav.Inverse; - if (inverse == null) + // Check if the inverse navigation is already set + var currentValue = navigation.GetInverseValue(childEntity); + if (currentValue == null) { - return; - } - - // Set the inverse navigation property on the child - var inversePropertyInfo = childEntity.GetType().GetProperty(inverse.Name, BindingFlags.Public | BindingFlags.Instance); - if (inversePropertyInfo != null && inversePropertyInfo.CanWrite) - { - var currentValue = inversePropertyInfo.GetValue(childEntity); - if (currentValue == null) - { - inversePropertyInfo.SetValue(childEntity, parentEntity); - } + navigation.SetInverseValue(childEntity, parentEntity); } } } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs index 59fec33..4cec5d4 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs @@ -24,6 +24,21 @@ public NavigationMetadata(INavigationBase navigation) _getter = PropertyAccessor.CreateGetter(propertyInfo); + // Build inverse navigation metadata if available + if (navigation is INavigation { Inverse: not null } regularNav) + { + var inverse = regularNav.Inverse; + var inversePropertyInfo = inverse.DeclaringEntityType.ClrType.GetProperty( + inverse.Name, + BindingFlags.Public | BindingFlags.Instance); + + if (inversePropertyInfo != null && inversePropertyInfo.CanWrite) + { + _inverseGetter = PropertyAccessor.CreateGetter(inversePropertyInfo); + _inverseSetter = PropertyAccessor.CreateSetter(inversePropertyInfo); + } + } + if (navigation is ISkipNavigation skipNavigation) { IsManyToMany = true; @@ -40,6 +55,8 @@ public NavigationMetadata(INavigationBase navigation) } private readonly Func _getter; + private readonly Func? _inverseGetter; + private readonly Action? _inverseSetter; /// /// The underlying EF Core navigation. @@ -91,6 +108,23 @@ public NavigationMetadata(INavigationBase navigation) /// public object? GetValue(object entity) => _getter.Invoke(entity); + /// + /// Gets the value of the inverse navigation property from the entity using an optimized getter. + /// Returns null if there is no inverse navigation. + /// + public object? GetInverseValue(object entity) => _inverseGetter?.Invoke(entity); + + /// + /// Sets the value of the inverse navigation property on the entity using an optimized setter. + /// Does nothing if there is no inverse navigation. + /// + public void SetInverseValue(object entity, object? value) => _inverseSetter?.Invoke(entity, value); + + /// + /// Returns true if this navigation has an inverse navigation with a setter. + /// + public bool HasInverseSetter => _inverseSetter != null; + public override string ToString() { var relationshipType = IsManyToMany ? "ManyToMany" : (IsCollection ? "OneToMany" : "OneToOne"); diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs index f66a813..7267800 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs @@ -86,6 +86,34 @@ internal static class PropertyAccessor return Expression.Lambda>(finalExpression, instanceParam).Compile(); } + public static Action CreateSetter(PropertyInfo propertyInfo) + { + ArgumentNullException.ThrowIfNull(propertyInfo); + + if (!propertyInfo.CanWrite) + { + throw new ArgumentException($"Property '{propertyInfo.Name}' is not writable", nameof(propertyInfo)); + } + + // (instance, value) => { } + var instanceParam = Expression.Parameter(typeof(object), "instance"); + var valueParam = Expression.Parameter(typeof(object), "value"); + + var propDeclaringType = propertyInfo.DeclaringType!; + + // Convert object to the declaring type + var typedInstance = GetTypedInstance(propDeclaringType, instanceParam); + + // Convert value to property type (both value types and reference types need conversion from object) + var typedValue = Expression.Convert(valueParam, propertyInfo.PropertyType); + + // ((TEntity)instance).Property = (TProperty)value + var propertyAccess = Expression.Property(typedInstance, propertyInfo); + var assignment = Expression.Assign(propertyAccess, typedValue); + + return Expression.Lambda>(assignment, instanceParam, valueParam).Compile(); + } + private static UnaryExpression GetTypedInstance(Type propDeclaringType, ParameterExpression instanceParam) { return propDeclaringType.IsValueType From 0410e401ca70690b635074f4df0fe80b1d390025 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 8 Feb 2026 14:14:21 +0100 Subject: [PATCH 15/26] Add optimized property accessors for entity metadata --- .../Graph/GraphBulkInsertOrchestrator.cs | 114 ++++++++---------- .../Metadata/EntityMetadata.cs | 87 +++++++++++++ .../Metadata/GraphMetadata.cs | 22 ++++ 3 files changed, 157 insertions(+), 66 deletions(-) create mode 100644 src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/EntityMetadata.cs diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index bc8559a..4c8049a 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; -using System.Linq.Expressions; using System.Reflection; using Microsoft.EntityFrameworkCore; @@ -17,8 +15,6 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; /// internal sealed class GraphBulkInsertOrchestrator { - private static readonly ConcurrentDictionary<(Type, string), Action> PropertySetters = new(); - private static readonly ConcurrentDictionary<(Type, string), Func> PropertyGetters = new(); private readonly DbContext _context; private readonly MetadataProvider _metadataProvider; @@ -76,7 +72,7 @@ public async Task> InsertGraph( PropagateParentForeignKeys(entitiesToInsert, entityType, graphMetadata); // Insert entities of this type - await InsertEntitiesOfType(sync, _context, entityType, entitiesToInsert, options, provider, ctk); + await InsertEntitiesOfType(sync, _context, entityType, entitiesToInsert, options, provider, graphMetadata, ctk); totalInserted += entitiesToInsert.Count; } @@ -84,7 +80,7 @@ public async Task> InsertGraph( // 3. Insert join table records for many-to-many relationships if (collectionResult.JoinRecords.Count > 0) { - await InsertJoinRecords(sync, _context, collectionResult.JoinRecords, options, provider, ctk); + await InsertJoinRecords(sync, _context, collectionResult.JoinRecords, options, provider, graphMetadata, ctk); } // Return root entities @@ -110,6 +106,12 @@ private static void PropagateParentForeignKeys( return; } + var entityMetadata = graphMetadata.GetEntityMetadata(entityType); + if (entityMetadata == null) + { + return; + } + // For each FK relationship, propagate PK values from parent entities foreach (var fk in efEntityType.GetForeignKeys()) { @@ -125,12 +127,18 @@ private static void PropagateParentForeignKeys( foreach (var entity in entities) { // Get the parent entity via navigation property - var parentEntity = GetPropertyValue(entity, navigationPropertyName); + var parentEntity = entityMetadata.GetPropertyValue(entity, navigationPropertyName); if (parentEntity == null) { continue; } + var parentMetadata = graphMetadata.GetEntityMetadata(parentEntity.GetType()); + if (parentMetadata == null) + { + continue; + } + // Copy PK values from parent to FK properties on this entity var fkProperties = fk.Properties; var pkProperties = fk.PrincipalKey.Properties; @@ -147,8 +155,8 @@ private static void PropagateParentForeignKeys( continue; } - var pkValue = GetPropertyValue(parentEntity, pkProp.Name); - SetPropertyValue(entity, fkProp.Name, pkValue); + var pkValue = parentMetadata.GetPropertyValue(parentEntity, pkProp.Name); + entityMetadata.SetPropertyValue(entity, fkProp.Name, pkValue); } } } @@ -161,6 +169,7 @@ private async Task InsertEntitiesOfType( List entities, BulkInsertOptions options, IBulkInsertProvider provider, + GraphMetadata graphMetadata, CancellationToken ctk) { // Use reflection to call the generic BulkInsert method @@ -168,7 +177,7 @@ private async Task InsertEntitiesOfType( .GetMethod(nameof(InsertEntitiesGenericAsync), BindingFlags.NonPublic | BindingFlags.Instance)! .MakeGenericMethod(entityType); - var task = (Task)method.Invoke(this, [context, entities, options, provider, ctk])!; + var task = (Task)method.Invoke(this, [context, entities, options, provider, graphMetadata, ctk])!; await task; } @@ -177,6 +186,7 @@ private async Task InsertEntitiesGenericAsync( List entities, BulkInsertOptions options, IBulkInsertProvider provider, + GraphMetadata graphMetadata, CancellationToken ctk) where TEntity : class { var typedEntities = entities.Cast().ToList(); @@ -202,7 +212,11 @@ private async Task InsertEntitiesGenericAsync( } // Copy generated IDs back to original entities - CopyGeneratedIds(typedEntities, insertedEntities, tableInfo); + var entityMetadata = graphMetadata.GetEntityMetadata(typeof(TEntity)); + if (entityMetadata != null) + { + CopyGeneratedIds(typedEntities, insertedEntities, tableInfo, entityMetadata); + } } else { @@ -214,7 +228,8 @@ private async Task InsertEntitiesGenericAsync( private void CopyGeneratedIds( List originalEntities, List insertedEntities, - TableMetadata tableInfo) where TEntity : class + TableMetadata tableInfo, + EntityMetadata entityMetadata) where TEntity : class { if (originalEntities.Count != insertedEntities.Count) { @@ -241,8 +256,8 @@ private void CopyGeneratedIds( foreach (var pkProp in pkProps) { - var value = GetPropertyValue(inserted, pkProp.PropertyName); - SetPropertyValue(original, pkProp.PropertyName, value); + var value = entityMetadata.GetPropertyValue(inserted, pkProp.PropertyName); + entityMetadata.SetPropertyValue(original, pkProp.PropertyName, value); } } } @@ -253,6 +268,7 @@ private async Task InsertJoinRecords( List joinRecords, BulkInsertOptions options, IBulkInsertProvider provider, + GraphMetadata graphMetadata, CancellationToken ctk) { // Group join records by join entity type @@ -278,11 +294,23 @@ private async Task InsertJoinRecords( continue; } + // Get entity metadata for join type + var joinEntityMetadata = graphMetadata.GetEntityMetadata(joinEntityType); + // Create join table entries var joinEntities = new List(); foreach (var record in records) { + // Get metadata for left and right entities + var leftMetadata = graphMetadata.GetEntityMetadata(record.LeftEntity.GetType()); + var rightMetadata = graphMetadata.GetEntityMetadata(record.RightEntity.GetType()); + + if (leftMetadata == null || rightMetadata == null) + { + continue; + } + // Create a dictionary-based join entity var joinEntry = Activator.CreateInstance(joinEntityType); if (joinEntry == null) @@ -304,14 +332,14 @@ private async Task InsertJoinRecords( var fkProp = fk.Properties[i]; var pkProp = fk.PrincipalKey.Properties[i]; - var pkValue = GetPropertyValue(record.LeftEntity, pkProp.Name); + var pkValue = leftMetadata.GetPropertyValue(record.LeftEntity, pkProp.Name); if (dictEntry != null) { dictEntry[fkProp.Name] = pkValue!; } - else + else if (joinEntityMetadata != null) { - SetPropertyValue(joinEntry, fkProp.Name, pkValue); + joinEntityMetadata.SetPropertyValue(joinEntry, fkProp.Name, pkValue); } } @@ -321,14 +349,14 @@ private async Task InsertJoinRecords( var fkProp = inverseFk.Properties[i]; var pkProp = inverseFk.PrincipalKey.Properties[i]; - var pkValue = GetPropertyValue(record.RightEntity, pkProp.Name); + var pkValue = rightMetadata.GetPropertyValue(record.RightEntity, pkProp.Name); if (dictEntry != null) { dictEntry[fkProp.Name] = pkValue!; } - else + else if (joinEntityMetadata != null) { - SetPropertyValue(joinEntry, fkProp.Name, pkValue); + joinEntityMetadata.SetPropertyValue(joinEntry, fkProp.Name, pkValue); } } @@ -374,50 +402,4 @@ private async Task InsertJoinEntities( } await task; } - - private static object? GetPropertyValue(object entity, string propertyName) - { - var key = (entity.GetType(), propertyName); - var getter = PropertyGetters.GetOrAdd(key, k => - { - var property = k.Item1.GetProperty(k.Item2, BindingFlags.Public | BindingFlags.Instance); - if (property == null) - { - return _ => null; - } - - var param = Expression.Parameter(typeof(object), "obj"); - var cast = Expression.Convert(param, k.Item1); - var access = Expression.Property(cast, property); - var convertResult = Expression.Convert(access, typeof(object)); - - return Expression.Lambda>(convertResult, param).Compile(); - }); - - return getter(entity); - } - - private static void SetPropertyValue(object entity, string propertyName, object? value) - { - var key = (entity.GetType(), propertyName); - var setter = PropertySetters.GetOrAdd(key, k => - { - var property = k.Item1.GetProperty(k.Item2, BindingFlags.Public | BindingFlags.Instance); - if (property == null || !property.CanWrite) - { - return (_, _) => { }; - } - - var param = Expression.Parameter(typeof(object), "obj"); - var valueParam = Expression.Parameter(typeof(object), "value"); - var cast = Expression.Convert(param, k.Item1); - var access = Expression.Property(cast, property); - var convertValue = Expression.Convert(valueParam, property.PropertyType); - var assign = Expression.Assign(access, convertValue); - - return Expression.Lambda>(assign, param, valueParam).Compile(); - }); - - setter(entity, value); - } } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/EntityMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/EntityMetadata.cs new file mode 100644 index 0000000..c7aca86 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/EntityMetadata.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; + +using Microsoft.EntityFrameworkCore.Metadata; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Metadata; + +/// +/// Metadata for an entity type with optimized property accessors. +/// Getters and setters are computed on demand and cached. +/// +internal sealed class EntityMetadata +{ + private readonly ConcurrentDictionary> _getters = new(); + private readonly ConcurrentDictionary> _setters = new(); + private readonly IEntityType _entityType; + + public EntityMetadata(IEntityType entityType) + { + _entityType = entityType; + ClrType = entityType.ClrType; + } + + public Type ClrType { get; } + + /// + /// Gets the value of a property from an entity using an optimized getter. + /// The getter is created on first access and cached for subsequent calls. + /// Returns null if the property is not found or is a shadow property. + /// + public object? GetPropertyValue(object entity, string propertyName) + { + var getter = _getters.GetOrAdd(propertyName, static (name, entityType) => + { + var property = entityType.FindProperty(name); + if (property == null || property.IsShadowProperty()) + { + // Try to find a navigation property + var navigation = entityType.FindNavigation(name); + if (navigation?.PropertyInfo != null) + { + return PropertyAccessor.CreateGetter(navigation.PropertyInfo); + } + + return _ => null; + } + + var propertyInfo = property.PropertyInfo; + if (propertyInfo == null) + { + return _ => null; + } + + return PropertyAccessor.CreateGetter(propertyInfo); + }, _entityType); + + return getter(entity); + } + + /// + /// Sets the value of a property on an entity using an optimized setter. + /// The setter is created on first access and cached for subsequent calls. + /// Does nothing if the property is not found, is a shadow property, or is not writable. + /// + public void SetPropertyValue(object entity, string propertyName, object? value) + { + var setter = _setters.GetOrAdd(propertyName, static (name, entityType) => + { + var property = entityType.FindProperty(name); + if (property == null || property.IsShadowProperty()) + { + return (_, _) => { }; + } + + var propertyInfo = property.PropertyInfo; + if (propertyInfo == null || !propertyInfo.CanWrite) + { + return (_, _) => { }; + } + + return PropertyAccessor.CreateSetter(propertyInfo); + }, _entityType); + + setter(entity, value); + } +} + + diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs index 6b333f9..91ba3a3 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs @@ -12,6 +12,7 @@ internal sealed class GraphMetadata { private readonly Dictionary _entityTypes; private readonly Dictionary> _navigationsByType; + private readonly Dictionary _entityMetadataByType = []; private readonly BulkInsertOptions _options; public GraphMetadata(DbContext context, BulkInsertOptions options) @@ -59,6 +60,27 @@ public IReadOnlyList GetNavigations(Type clrType) : []; } + /// + /// Gets or creates the entity metadata with optimized property accessors for a CLR type. + /// + public EntityMetadata? GetEntityMetadata(Type clrType) + { + if (_entityMetadataByType.TryGetValue(clrType, out var metadata)) + { + return metadata; + } + + var entityType = GetEntityType(clrType); + if (entityType == null) + { + return null; + } + + metadata = new EntityMetadata(entityType); + _entityMetadataByType[clrType] = metadata; + return metadata; + } + /// /// Determines the topological insertion order for a set of types based on FK dependencies. /// Parents are inserted before children to satisfy FK constraints. From 6283440cb40ba3b4cc63852aa67eab5f35d79cbb Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 8 Feb 2026 14:18:01 +0100 Subject: [PATCH 16/26] Add support for IncludeGraph option in benchmarks --- .../LibComparator.cs | 46 +++---- ...yFrameworkCore.BulkInsert.Benchmark.csproj | 2 + .../Tests/Graph/GraphTestsBase.cs | 126 ++++++++++++++++++ 3 files changed, 148 insertions(+), 26 deletions(-) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs index 4027858..3947ae7 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs @@ -19,12 +19,6 @@ public abstract partial class LibComparator [Params(500_000/*, 1_000_000/*, 10_000_000*/)] public int N; - /// - /// Set to true to benchmark with IncludeGraph option enabled. - /// When true, each entity will have 2 child entities for graph insertion benchmarking. - /// Default is false, which runs the benchmark exactly as before (flat entities only). - /// - private const bool UseIncludeGraph = false; private IList data = []; protected TestDbContext DbContext { get; set; } = null!; @@ -42,15 +36,14 @@ public void IterationSetup() NumericEnumValue = (NumericEnum)(i % 2), }; - // When UseIncludeGraph is true, add child entities for graph insertion benchmarking - if (UseIncludeGraph) - { + // When BENCHMARK_INCLUDE_GRAPH is set, add child entities for graph insertion benchmarking +#if BENCHMARK_INCLUDE_GRAPH entity.Children = new List { new TestEntityChild { Description = $"Child1 of Entity{i}", Quantity = i }, new TestEntityChild { Description = $"Child2 of Entity{i}", Quantity = i * 2 }, }; - } +#endif return entity; }).ToList(); @@ -82,7 +75,9 @@ public async Task PhenX_EntityFrameworkCore_BulkInsert() { await DbContext.ExecuteBulkInsertAsync(data, options => { - options.IncludeGraph = UseIncludeGraph; +#if BENCHMARK_INCLUDE_GRAPH + options.IncludeGraph = true; +#endif }); } // @@ -92,15 +87,10 @@ await DbContext.ExecuteBulkInsertAsync(data, options => // DbContext.ExecuteBulkInsert(data); // } +#if !BENCHMARK_INCLUDE_GRAPH [Benchmark] public void RawInsert() { - if (UseIncludeGraph) - { - // Raw insert doesn't support graph insertion - skip when UseIncludeGraph is true - return; - } - if (DbContext.Database.ProviderName!.Contains("SqlServer", StringComparison.InvariantCultureIgnoreCase)) { // Use SqlBulkCopy for SQL Server @@ -133,22 +123,22 @@ public void RawInsert() [Benchmark] public async Task Linq2Db() { - if (UseIncludeGraph) - { - // Linq2Db doesn't support graph insertion - skip when UseIncludeGraph is true - return; - } - await DbContext.BulkCopyAsync(new BulkCopyOptions { BulkCopyType = BulkCopyType.ProviderSpecific, }, data); } +#endif [Benchmark] public async Task Z_EntityFramework_Extensions_EFCore() { - await DbContext.BulkInsertOptimizedAsync(data, options => options.IncludeGraph = UseIncludeGraph); + await DbContext.BulkInsertOptimizedAsync(data, options => + { +#if BENCHMARK_INCLUDE_GRAPH + options.IncludeGraph = true; +#endif + }); } // [Benchmark] @@ -162,8 +152,10 @@ public async Task EFCore_BulkExtensions() { await DbContext.BulkInsertAsync(data, options => { - options.IncludeGraph = UseIncludeGraph; - options.PreserveInsertOrder = UseIncludeGraph; // Required for graph insertion +#if BENCHMARK_INCLUDE_GRAPH + options.IncludeGraph = true; + options.PreserveInsertOrder = true; // Required for graph insertion +#endif }); } @@ -177,10 +169,12 @@ await DbContext.BulkInsertAsync(data, options => // }); // } +#if !BENCHMARK_INCLUDE_GRAPH [Benchmark] public async Task EFCore_SaveChanges() { DbContext.AddRange(data); await DbContext.SaveChangesAsync(); } +#endif } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/PhenX.EntityFrameworkCore.BulkInsert.Benchmark.csproj b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/PhenX.EntityFrameworkCore.BulkInsert.Benchmark.csproj index 7f53af4..bd9932f 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/PhenX.EntityFrameworkCore.BulkInsert.Benchmark.csproj +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/PhenX.EntityFrameworkCore.BulkInsert.Benchmark.csproj @@ -4,6 +4,8 @@ Exe false false + + diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs index 17dae4c..9b36ba9 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -588,4 +588,130 @@ await _context.ExecuteBulkInsertAsync(blogs, options => dbPosts.Select(p => p.Id).Should().Contain(post2.Id); dbPosts.Select(p => p.Id).Should().Contain(post3.Id); } + + [SkippableFact] + public async Task InsertGraph_LargeScale() + { + // Arrange - Create many blogs with many children each (Posts, Tags, BlogSettings) + // This tests performance and correctness at scale + const int blogCount = 5000; + const int postsPerBlog = 100; + const int tagsPerPost = 10; + + var blogs = new List(); + var allTags = new List(); + + // Pre-create a pool of tags that will be shared across posts + for (var i = 0; i < 50; i++) + { + allTags.Add(new Tag + { + TestRun = _run, + Name = $"{_run}_SharedTag_{i}" + }); + } + + for (var blogIndex = 0; blogIndex < blogCount; blogIndex++) + { + var posts = new List(); + + // Create posts for this blog + for (var postIndex = 0; postIndex < postsPerBlog; postIndex++) + { + var post = new Post + { + TestRun = _run, + Title = $"{_run}_Blog{blogIndex}_Post{postIndex}" + }; + + // Add some tags to this post (from the shared pool) + var postTags = new List(); + for (var tagIndex = 0; tagIndex < tagsPerPost; tagIndex++) + { + var tagPoolIndex = (blogIndex * postsPerBlog + postIndex + tagIndex) % allTags.Count; + postTags.Add(allTags[tagPoolIndex]); + } + post.Tags = postTags; + + posts.Add(post); + } + + // Create the blog with its children + var blog = new Blog + { + TestRun = _run, + Name = $"{_run}_LargeScaleBlog_{blogIndex}", + Posts = posts, + Settings = new BlogSettings + { + TestRun = _run, + EnableComments = blogIndex % 2 == 0 + } + }; + + blogs.Add(blog); + } + + // Act - Insert all 1000 blogs with their children + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + await _context.ExecuteBulkInsertAsync(blogs, options => + { + options.IncludeGraph = true; + }); + stopwatch.Stop(); + + // Assert - Verify all entities were inserted correctly + var insertedBlogs = _context.Blogs.Where(b => b.TestRun == _run).ToList(); + insertedBlogs.Should().HaveCount(blogCount, "All blogs should be inserted"); + insertedBlogs.Should().AllSatisfy(b => b.Id.Should().BeGreaterThan(0), "All blogs should have generated IDs"); + + var insertedPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + insertedPosts.Should().HaveCount(blogCount * postsPerBlog, "All posts should be inserted"); + insertedPosts.Should().AllSatisfy(p => + { + p.Id.Should().BeGreaterThan(0, "Post should have generated ID"); + p.BlogId.Should().BeGreaterThan(0, "Post should have valid BlogId FK"); + }); + + var insertedSettings = _context.BlogSettings.Where(s => s.TestRun == _run).ToList(); + insertedSettings.Should().HaveCount(blogCount, "All blog settings should be inserted"); + insertedSettings.Should().AllSatisfy(s => + { + s.Id.Should().BeGreaterThan(0, "Settings should have generated ID"); + s.BlogId.Should().BeGreaterThan(0, "Settings should have valid BlogId FK"); + }); + + var insertedTags = _context.Tags.Where(t => t.TestRun == _run).ToList(); + insertedTags.Should().HaveCount(allTags.Count, "All unique tags should be inserted"); + insertedTags.Should().AllSatisfy(t => t.Id.Should().BeGreaterThan(0), "All tags should have generated IDs"); + + // Verify original entities have been updated with generated IDs + blogs.Should().AllSatisfy(b => + { + b.Id.Should().BeGreaterThan(0, "Original blog should have ID populated"); + b.Posts.Should().AllSatisfy(p => + { + p.Id.Should().BeGreaterThan(0, "Original post should have ID populated"); + p.BlogId.Should().Be(b.Id, "Original post FK should reference its blog"); + }); + b.Settings.Should().NotBeNull(); + b.Settings!.Id.Should().BeGreaterThan(0, "Original settings should have ID populated"); + b.Settings.BlogId.Should().Be(b.Id, "Original settings FK should reference its blog"); + }); + + allTags.Should().AllSatisfy(t => + { + t.Id.Should().BeGreaterThan(0, "Original tag should have ID populated"); + }); + + // Report performance metrics + var totalEntities = blogCount + (blogCount * postsPerBlog) + blogCount + allTags.Count; + var entitiesPerSecond = totalEntities / stopwatch.Elapsed.TotalSeconds; + + // Note: This is informational, not an assertion + // Output is visible in test logs + _context.GetType().Name.Should().NotBeNullOrEmpty( + $"Inserted {totalEntities:N0} entities in {stopwatch.Elapsed.TotalSeconds:F2}s " + + $"({entitiesPerSecond:F0} entities/sec) using {_context.GetType().Name}"); + } } From 2c994aeafbfc4ae042a9e538b7303a85321e64d1 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 8 Feb 2026 15:31:16 +0100 Subject: [PATCH 17/26] Fix benchmark to really use EFCoreBulkExtensions --- .../LibComparator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs index 3947ae7..f7e8088 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs @@ -150,7 +150,7 @@ await DbContext.BulkInsertOptimizedAsync(data, options => [Benchmark] public async Task EFCore_BulkExtensions() { - await DbContext.BulkInsertAsync(data, options => + await DbContextBulkExtensions.BulkInsertAsync(DbContext, data, options => { #if BENCHMARK_INCLUDE_GRAPH options.IncludeGraph = true; From d927f1f110956456dadff5228b62913a3914e264 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 15:05:38 +0000 Subject: [PATCH 18/26] Address code review feedback for IncludeGraph feature Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- docs/graph-insert.md | 4 +- .../Graph/GraphBulkInsertOrchestrator.cs | 46 ++++++++++++++----- .../Graph/GraphEntityCollector.cs | 10 ++++ .../Tests/Graph/GraphTestsBase.cs | 12 ++--- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/docs/graph-insert.md b/docs/graph-insert.md index 9187818..25527d4 100644 --- a/docs/graph-insert.md +++ b/docs/graph-insert.md @@ -1,6 +1,6 @@ # Graph Insert (Navigation Properties) -> ℹ️ This feature is not available for Oracle and MySQL providers due to limitations in retrieving generated IDs. +> ℹ️ 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. This library supports bulk inserting entire object graphs, including entities with their related navigation properties. @@ -19,7 +19,7 @@ await dbContext.ExecuteBulkInsertAsync(blogs, options => 2. Entities are sorted in topological order (parents before children) to respect foreign key constraints 3. Each entity type is bulk inserted in dependency order 4. Generated IDs (identity columns) are propagated to foreign key properties -5. Many-to-many join tables are populated automatically +5. Many-to-many join tables with explicit join entity types are populated automatically (see Limitations below) ## Options diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 4c8049a..42805e5 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -177,11 +177,12 @@ private async Task InsertEntitiesOfType( .GetMethod(nameof(InsertEntitiesGenericAsync), BindingFlags.NonPublic | BindingFlags.Instance)! .MakeGenericMethod(entityType); - var task = (Task)method.Invoke(this, [context, entities, options, provider, graphMetadata, ctk])!; + var task = (Task)method.Invoke(this, [sync, context, entities, options, provider, graphMetadata, ctk])!; await task; } private async Task InsertEntitiesGenericAsync( + bool sync, DbContext context, List entities, BulkInsertOptions options, @@ -200,7 +201,7 @@ private async Task InsertEntitiesGenericAsync( // Use BulkInsertReturnEntities to get back the generated IDs var insertedEntities = new List(); await foreach (var inserted in provider.BulkInsertReturnEntities( - false, + sync, context, tableInfo, typedEntities, @@ -221,7 +222,7 @@ private async Task InsertEntitiesGenericAsync( else { // No identity columns, just insert directly - await provider.BulkInsert(false, context, tableInfo, typedEntities, options, null, ctk); + await provider.BulkInsert(sync, context, tableInfo, typedEntities, options, null, ctk); } } @@ -380,6 +381,18 @@ private async Task InsertJoinEntities( IBulkInsertProvider provider, CancellationToken ctk) { + // Skip dictionary-based shared-type join entities as they are not supported + // by the bulk insert infrastructure (requires typed IEnumerable) + if (joinEntityType == typeof(Dictionary) || + typeof(IDictionary).IsAssignableFrom(joinEntityType)) + { + _logger?.LogWarning( + "IncludeGraph: Skipping join table insertion for shared-type entity (Dictionary). " + + "Many-to-many relationships using implicit join tables are not supported. " + + "Consider using an explicit join entity type."); + return; + } + var efEntityType = context.Model.FindEntityType(joinEntityType); if (efEntityType == null) { @@ -389,17 +402,26 @@ private async Task InsertJoinEntities( var sqlDialect = provider.SqlDialect; var tableInfo = new TableMetadata(efEntityType, sqlDialect); - // Use raw SQL insert for join entities since they're often dictionary-based - var method = typeof(IBulkInsertProvider) - .GetMethod(nameof(IBulkInsertProvider.BulkInsert))! + // Use reflection to call the generic BulkInsert method with correctly typed entities + var method = typeof(GraphBulkInsertOrchestrator) + .GetMethod(nameof(InsertJoinEntitiesGenericAsync), BindingFlags.NonPublic | BindingFlags.Instance)! .MakeGenericMethod(joinEntityType); - var result = method.Invoke(provider, [sync, context, tableInfo, joinEntities, options, null, ctk]); - if (result is not Task task) - { - throw new InvalidOperationException( - $"The BulkInsert method for join entity type '{joinEntityType.Name}' did not return a Task as expected."); - } + var task = (Task)method.Invoke(this, [sync, context, tableInfo, joinEntities, options, provider, ctk])!; await task; } + + private async Task InsertJoinEntitiesGenericAsync( + bool sync, + DbContext context, + TableMetadata tableInfo, + List joinEntities, + BulkInsertOptions options, + IBulkInsertProvider provider, + CancellationToken ctk) where TJoin : class + { + // Cast to correctly typed list for the provider + var typedEntities = joinEntities.Cast().ToList(); + await provider.BulkInsert(sync, context, tableInfo, typedEntities, options, null, ctk); + } } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs index de5e1a9..2fd908f 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs @@ -1,6 +1,7 @@ using System.Collections; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; using PhenX.EntityFrameworkCore.BulkInsert.Metadata; using PhenX.EntityFrameworkCore.BulkInsert.Options; @@ -152,6 +153,15 @@ private static void SetInverseNavigation(object parentEntity, object childEntity return; } + // Only set inverse navigations that are reference properties (not collections). + // If the inverse is a collection, the parent should be added to the collection, + // not assigned directly (which would cause an invalid cast). + if (navigation.Navigation is INavigation { Inverse.IsCollection: true }) + { + // Skip: inverse is a collection, not a reference property + return; + } + // Check if the inverse navigation is already set var currentValue = navigation.GetInverseValue(childEntity); if (currentValue == null) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs index 9b36ba9..0b18880 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -592,17 +592,17 @@ await _context.ExecuteBulkInsertAsync(blogs, options => [SkippableFact] public async Task InsertGraph_LargeScale() { - // Arrange - Create many blogs with many children each (Posts, Tags, BlogSettings) - // This tests performance and correctness at scale - const int blogCount = 5000; - const int postsPerBlog = 100; - const int tagsPerPost = 10; + // Arrange - Create blogs with children each (Posts, Tags, BlogSettings) + // This tests correctness with a reasonable amount of data that won't cause CI timeouts + const int blogCount = 50; + const int postsPerBlog = 10; + const int tagsPerPost = 3; var blogs = new List(); var allTags = new List(); // Pre-create a pool of tags that will be shared across posts - for (var i = 0; i < 50; i++) + for (var i = 0; i < 20; i++) { allTags.Add(new Tag { From 3e636b10ca7e0b5dbfcc870b63182f34655dba8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 15:07:17 +0000 Subject: [PATCH 19/26] Fix minor code review issues: null check, redundant condition, grammar Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Graph/GraphBulkInsertOrchestrator.cs | 3 +-- .../Graph/GraphEntityCollector.cs | 2 +- .../Tests/Graph/GraphTestsBase.cs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 42805e5..2a3a5ba 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -383,8 +383,7 @@ private async Task InsertJoinEntities( { // Skip dictionary-based shared-type join entities as they are not supported // by the bulk insert infrastructure (requires typed IEnumerable) - if (joinEntityType == typeof(Dictionary) || - typeof(IDictionary).IsAssignableFrom(joinEntityType)) + if (typeof(IDictionary).IsAssignableFrom(joinEntityType)) { _logger?.LogWarning( "IncludeGraph: Skipping join table insertion for shared-type entity (Dictionary). " + diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs index 2fd908f..b9d2fbd 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs @@ -156,7 +156,7 @@ private static void SetInverseNavigation(object parentEntity, object childEntity // Only set inverse navigations that are reference properties (not collections). // If the inverse is a collection, the parent should be added to the collection, // not assigned directly (which would cause an invalid cast). - if (navigation.Navigation is INavigation { Inverse.IsCollection: true }) + if (navigation.Navigation is INavigation nav && nav.Inverse?.IsCollection == true) { // Skip: inverse is a collection, not a reference property return; diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs index 0b18880..c1194a2 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -592,7 +592,7 @@ await _context.ExecuteBulkInsertAsync(blogs, options => [SkippableFact] public async Task InsertGraph_LargeScale() { - // Arrange - Create blogs with children each (Posts, Tags, BlogSettings) + // Arrange - Create blogs, each with their children (Posts, Tags, BlogSettings) // This tests correctness with a reasonable amount of data that won't cause CI timeouts const int blogCount = 50; const int postsPerBlog = 10; From f21b3a8c37ad6d0e9f40c840cbcdce7596e0f6de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:33:54 +0000 Subject: [PATCH 20/26] Allow IncludeGraph on Oracle/MySQL when keys are client-generated, include join records in total count, filter keyless entities Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Graph/GraphBulkInsertOrchestrator.cs | 32 ++++++++++++++----- .../Metadata/GraphMetadata.cs | 5 ++- .../Tests/Graph/GraphTestsBase.cs | 2 +- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 2a3a5ba..2d73dc6 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -37,12 +37,6 @@ public async Task> InsertGraph( IBulkInsertProvider provider, CancellationToken ctk) where T : class { - if (!provider.SupportsOutputInsertedIds) - { - throw new NotSupportedException( - $"The bulk insert provider '{provider.GetType().Name}' does not support returning generated IDs, which is required for IncludeGraph operations."); - } - // 1. Collect and sort entities var collector = new GraphEntityCollector(_context, options); var collectionResult = collector.Collect(entities); @@ -59,6 +53,21 @@ public async Task> InsertGraph( var totalInserted = 0; var graphMetadata = new GraphMetadata(_context, options); + // Check if any entity types have generated keys - if so, provider must support returning IDs + var hasAnyGeneratedKeys = collectionResult.InsertionOrder.Any(entityType => + { + var efEntityType = graphMetadata.GetEntityType(entityType); + return efEntityType?.FindPrimaryKey()?.Properties.Any(p => p.ValueGenerated != Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never) == true; + }); + + if (hasAnyGeneratedKeys && !provider.SupportsOutputInsertedIds) + { + throw new NotSupportedException( + $"The bulk insert provider '{provider.GetType().Name}' does not support returning generated IDs, " + + $"which is required for IncludeGraph operations when entities have database-generated keys. " + + $"Consider using client-generated keys (e.g., GUIDs with ValueGeneratedNever())."); + } + // 2. Insert in dependency order (parents first) foreach (var entityType in collectionResult.InsertionOrder) { @@ -78,9 +87,11 @@ public async Task> InsertGraph( } // 3. Insert join table records for many-to-many relationships + var joinRecordsInserted = 0; if (collectionResult.JoinRecords.Count > 0) { - await InsertJoinRecords(sync, _context, collectionResult.JoinRecords, options, provider, graphMetadata, ctk); + joinRecordsInserted = await InsertJoinRecords(sync, _context, collectionResult.JoinRecords, options, provider, graphMetadata, ctk); + totalInserted += joinRecordsInserted; } // Return root entities @@ -263,7 +274,7 @@ private void CopyGeneratedIds( } } - private async Task InsertJoinRecords( + private async Task InsertJoinRecords( bool sync, DbContext context, List joinRecords, @@ -272,6 +283,8 @@ private async Task InsertJoinRecords( GraphMetadata graphMetadata, CancellationToken ctk) { + var totalJoinRecordsInserted = 0; + // Group join records by join entity type var groupedRecords = joinRecords.GroupBy(jr => jr.JoinEntityType); @@ -368,8 +381,11 @@ private async Task InsertJoinRecords( { // Insert join entities await InsertJoinEntities(sync, context, joinEntityType, joinEntities, options, provider, ctk); + totalJoinRecordsInserted += joinEntities.Count; } } + + return totalJoinRecordsInserted; } private async Task InsertJoinEntities( diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs index 91ba3a3..d82b816 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs @@ -24,7 +24,10 @@ public GraphMetadata(DbContext context, BulkInsertOptions options) _entityTypes = []; foreach (var entityType in context.Model.GetEntityTypes()) { - if (entityType.IsOwned() || entityType.ClrType == null || entityType.GetTableName() == null) + if (entityType.IsOwned() || + entityType.ClrType == null || + entityType.GetTableName() == null || + entityType.FindPrimaryKey() == null) // Keyless entities have no primary key { continue; } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs index c1194a2..4f507ee 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -652,7 +652,7 @@ public async Task InsertGraph_LargeScale() blogs.Add(blog); } - // Act - Insert all 1000 blogs with their children + // Act - Insert all 50 blogs with their children var stopwatch = System.Diagnostics.Stopwatch.StartNew(); await _context.ExecuteBulkInsertAsync(blogs, options => { From 2ab4e87a45e5c5fa13caa6ee066173910f99a99d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:34:41 +0000 Subject: [PATCH 21/26] Rename variable to hasAnyDatabaseGeneratedKeys for clarity Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Graph/GraphBulkInsertOrchestrator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 2d73dc6..6e58b79 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -53,14 +53,14 @@ public async Task> InsertGraph( var totalInserted = 0; var graphMetadata = new GraphMetadata(_context, options); - // Check if any entity types have generated keys - if so, provider must support returning IDs - var hasAnyGeneratedKeys = collectionResult.InsertionOrder.Any(entityType => + // Check if any entity types have database-generated keys - if so, provider must support returning IDs + var hasAnyDatabaseGeneratedKeys = collectionResult.InsertionOrder.Any(entityType => { var efEntityType = graphMetadata.GetEntityType(entityType); return efEntityType?.FindPrimaryKey()?.Properties.Any(p => p.ValueGenerated != Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never) == true; }); - if (hasAnyGeneratedKeys && !provider.SupportsOutputInsertedIds) + if (hasAnyDatabaseGeneratedKeys && !provider.SupportsOutputInsertedIds) { throw new NotSupportedException( $"The bulk insert provider '{provider.GetType().Name}' does not support returning generated IDs, " + From 092ed1fa22668a1eb35215bd74fcea934593576a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20M=C3=A9nager?= Date: Sun, 8 Feb 2026 22:54:20 +0100 Subject: [PATCH 22/26] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 510ff4f..b88d52d 100644 --- a/README.md +++ b/README.md @@ -136,9 +136,9 @@ var blogs = new List await dbContext.ExecuteBulkInsertAsync(blogs, o => o.IncludeGraph = true); ``` -> ℹ️ This feature is not available for Oracle and MySQL providers due to limitations in retrieving generated IDs. +> ℹ️ Automatic propagation of **database-generated keys** for graph inserts is not available for Oracle and MySQL providers due to limitations in retrieving generated IDs. You can still use `IncludeGraph` with these providers when keys are client-generated. -See [Graph Insert documentation](https://phenx.github.io/PhenX.EntityFrameworkCore.BulkInsert/graph-insert.html) for details. +See [Graph Insert documentation](https://phenx.github.io/PhenX.EntityFrameworkCore.BulkInsert/graph-insert.html) for details and provider-specific notes. ### Conflict resolution / merge / upsert From 97e239ec74cf72c093f71c5be8dbb52197001c65 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 14 Feb 2026 10:13:04 +0100 Subject: [PATCH 23/26] Wrap full graph insert with a transaction --- .../Graph/GraphBulkInsertOrchestrator.cs | 71 ++++++++++++------- .../Tests/Graph/GraphTestsBase.cs | 55 ++++++++++++++ 2 files changed, 99 insertions(+), 27 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 6e58b79..b042dcf 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using PhenX.EntityFrameworkCore.BulkInsert.Abstractions; +using PhenX.EntityFrameworkCore.BulkInsert.Extensions; using PhenX.EntityFrameworkCore.BulkInsert.Metadata; using PhenX.EntityFrameworkCore.BulkInsert.Options; @@ -37,6 +38,10 @@ public async Task> InsertGraph( IBulkInsertProvider provider, CancellationToken ctk) where T : class { + + using var activity = Telemetry.ActivitySource.StartActivity("InsertGraph"); + activity?.AddTag("synchronous", sync); + // 1. Collect and sort entities var collector = new GraphEntityCollector(_context, options); var collectionResult = collector.Collect(entities); @@ -68,42 +73,54 @@ public async Task> InsertGraph( $"Consider using client-generated keys (e.g., GUIDs with ValueGeneratedNever())."); } - // 2. Insert in dependency order (parents first) - foreach (var entityType in collectionResult.InsertionOrder) + var connection = await _context.GetConnection(sync, ctk); + + try { - if (!collectionResult.EntitiesByType.TryGetValue(entityType, out var entitiesToInsert) || - entitiesToInsert.Count == 0) + // 2. Insert in dependency order (parents first) + foreach (var entityType in collectionResult.InsertionOrder) { - continue; - } + if (!collectionResult.EntitiesByType.TryGetValue(entityType, out var entitiesToInsert) || + entitiesToInsert.Count == 0) + { + continue; + } - // Propagate FK values from already-inserted parents - PropagateParentForeignKeys(entitiesToInsert, entityType, graphMetadata); + // Propagate FK values from already-inserted parents + PropagateParentForeignKeys(entitiesToInsert, entityType, graphMetadata); - // Insert entities of this type - await InsertEntitiesOfType(sync, _context, entityType, entitiesToInsert, options, provider, graphMetadata, ctk); + // Insert entities of this type + await InsertEntitiesOfType(sync, _context, entityType, entitiesToInsert, options, provider, + graphMetadata, ctk); - totalInserted += entitiesToInsert.Count; - } + totalInserted += entitiesToInsert.Count; + } - // 3. Insert join table records for many-to-many relationships - var joinRecordsInserted = 0; - if (collectionResult.JoinRecords.Count > 0) - { - joinRecordsInserted = await InsertJoinRecords(sync, _context, collectionResult.JoinRecords, options, provider, graphMetadata, ctk); - totalInserted += joinRecordsInserted; - } + // 3. Insert join table records for many-to-many relationships + if (collectionResult.JoinRecords.Count > 0) + { + totalInserted += await InsertJoinRecords(sync, _context, collectionResult.JoinRecords, options, + provider, graphMetadata, ctk); + } + + // Return root entities + var rootEntities = collectionResult.EntitiesByType.TryGetValue(typeof(T), out var roots) + ? roots.Cast().ToList() + : []; - // Return root entities - var rootEntities = collectionResult.EntitiesByType.TryGetValue(typeof(T), out var roots) - ? roots.Cast().ToList() - : []; + // Commit the transaction if we own them. + await connection.Commit(sync, ctk); - return new GraphInsertResult + return new GraphInsertResult + { + RootEntities = rootEntities, + TotalInsertedCount = totalInserted, + }; + } + finally { - RootEntities = rootEntities, - TotalInsertedCount = totalInserted, - }; + await connection.Close(sync, ctk); + } } private static void PropagateParentForeignKeys( diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs index 4f507ee..84e73b3 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -714,4 +714,59 @@ await _context.ExecuteBulkInsertAsync(blogs, options => $"Inserted {totalEntities:N0} entities in {stopwatch.Elapsed.TotalSeconds:F2}s " + $"({entitiesPerSecond:F0} entities/sec) using {_context.GetType().Name}"); } + + [SkippableFact] + public async Task InsertGraph_FailureMidRun_TransactionRolledBack() + { + // Arrange - Create a graph where the second blog has an invalid post (null Title) + // This should cause the entire transaction to be rolled back + var validBlog = new Blog + { + TestRun = _run, + Name = $"{_run}_ValidBlog", + Posts = new List + { + new Post { TestRun = _run, Title = $"{_run}_ValidPost" } + }, + Settings = new BlogSettings + { + TestRun = _run, + EnableComments = true + } + }; + + var invalidBlog = new Blog + { + TestRun = _run, + Name = $"{_run}_InvalidBlog", + Posts = new List + { + new Post { TestRun = _run, Title = null! } // This should violate NOT NULL constraint + } + }; + + var blogs = new[] { validBlog, invalidBlog }; + + // Act & Assert - Expect an exception during insert + var act = async () => await _context.ExecuteBulkInsertAsync(blogs, options => + { + options.IncludeGraph = true; + }); + + await act.Should().ThrowAsync("Insert should fail due to NULL constraint violation"); + + // Assert - Verify that NOTHING was inserted due to transaction rollback + var insertedBlogs = _context.Blogs.Where(b => b.TestRun == _run).ToList(); + insertedBlogs.Should().BeEmpty("Transaction should be rolled back - no blogs inserted"); + + var insertedPosts = _context.Posts.Where(p => p.TestRun == _run).ToList(); + insertedPosts.Should().BeEmpty("Transaction should be rolled back - no posts inserted"); + + var insertedSettings = _context.BlogSettings.Where(s => s.TestRun == _run).ToList(); + insertedSettings.Should().BeEmpty("Transaction should be rolled back - no settings inserted"); + + // Verify original entities do NOT have IDs populated (rollback means no database-generated values) + validBlog.Id.Should().Be(0, "Valid blog should not have ID after rollback"); + invalidBlog.Id.Should().Be(0, "Invalid blog should not have ID after rollback"); + } } From fef0db31922fdc2d2a8cf46e297ec6d25baa85f9 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 14 Feb 2026 10:36:17 +0100 Subject: [PATCH 24/26] Restore original PKs on failure --- .../Graph/GraphBulkInsertOrchestrator.cs | 85 ++++++++++++++++++- .../Options/BulkInsertOptions.cs | 8 ++ .../Tests/Graph/GraphTestsBase.cs | 38 +++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index b042dcf..ece7edc 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -75,6 +75,9 @@ public async Task> InsertGraph( var connection = await _context.GetConnection(sync, ctk); + // Track original primary key values for rollback + var originalPkValues = new Dictionary>(); + try { // 2. Insert in dependency order (parents first) @@ -86,6 +89,12 @@ public async Task> InsertGraph( continue; } + // Save original PK values before any modifications + if (options.RestoreOriginalPrimaryKeysOnGraphInsertFailure) + { + SaveOriginalPrimaryKeyValues(entitiesToInsert, entityType, graphMetadata, originalPkValues); + } + // Propagate FK values from already-inserted parents PropagateParentForeignKeys(entitiesToInsert, entityType, graphMetadata); @@ -117,6 +126,16 @@ await InsertEntitiesOfType(sync, _context, entityType, entitiesToInsert, options TotalInsertedCount = totalInserted, }; } + catch + { + // Restore original PK values on rollback + if (options.RestoreOriginalPrimaryKeysOnGraphInsertFailure) + { + RestoreOriginalPrimaryKeyValues(originalPkValues, graphMetadata); + } + + throw; + } finally { await connection.Close(sync, ctk); @@ -436,14 +455,14 @@ private async Task InsertJoinEntities( // Use reflection to call the generic BulkInsert method with correctly typed entities var method = typeof(GraphBulkInsertOrchestrator) - .GetMethod(nameof(InsertJoinEntitiesGenericAsync), BindingFlags.NonPublic | BindingFlags.Instance)! + .GetMethod(nameof(InsertJoinEntitiesGeneric), BindingFlags.NonPublic | BindingFlags.Instance)! .MakeGenericMethod(joinEntityType); var task = (Task)method.Invoke(this, [sync, context, tableInfo, joinEntities, options, provider, ctk])!; await task; } - private async Task InsertJoinEntitiesGenericAsync( + private static async Task InsertJoinEntitiesGeneric( bool sync, DbContext context, TableMetadata tableInfo, @@ -456,4 +475,66 @@ private async Task InsertJoinEntitiesGenericAsync( var typedEntities = joinEntities.Cast().ToList(); await provider.BulkInsert(sync, context, tableInfo, typedEntities, options, null, ctk); } + + private static void SaveOriginalPrimaryKeyValues( + List entities, + Type entityType, + GraphMetadata graphMetadata, + Dictionary> originalPkValues) + { + var entityMetadata = graphMetadata.GetEntityMetadata(entityType); + if (entityMetadata == null) + { + return; + } + + var efEntityType = graphMetadata.GetEntityType(entityType); + + var pkProperties = efEntityType?.FindPrimaryKey()?.Properties; + if (pkProperties == null || !pkProperties.Any()) + { + return; + } + + // Only save values for database-generated keys + var generatedPkProps = pkProperties + .Where(p => p.ValueGenerated != Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never) + .ToList(); + + if (generatedPkProps.Count == 0) + { + return; + } + + foreach (var entity in entities) + { + var pkValues = new Dictionary(); + foreach (var pkProp in generatedPkProps) + { + var value = entityMetadata.GetPropertyValue(entity, pkProp.Name); + pkValues[pkProp.Name] = value; + } + originalPkValues[entity] = pkValues; + } + } + + private static void RestoreOriginalPrimaryKeyValues( + Dictionary> originalPkValues, + GraphMetadata graphMetadata) + { + foreach (var (entity, pkValues) in originalPkValues) + { + var entityType = entity.GetType(); + var entityMetadata = graphMetadata.GetEntityMetadata(entityType); + if (entityMetadata == null) + { + continue; + } + + foreach (var (propertyName, originalValue) in pkValues) + { + entityMetadata.SetPropertyValue(entity, propertyName, originalValue); + } + } + } } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs index f4e080d..955fe53 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs @@ -98,6 +98,14 @@ public class BulkInsertOptions /// public HashSet? ExcludeNavigations { get; set; } + /// + /// When enabled, if a graph insert operation fails, the original primary key values of the entities will be restored. + /// This ensures that entities in memory remain consistent with the database state after a transaction rollback. + /// Can add a little overhead, so it is disabled by default. Enable this option if you need to access the primary + /// key values of entities after a failed graph insert operation. + /// + public bool RestoreOriginalPrimaryKeysOnGraphInsertFailure { get; set; } + internal int GetCopyTimeoutInSeconds() { return Math.Max(0, (int)CopyTimeout.TotalSeconds); diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs index 84e73b3..c7ed521 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -751,6 +751,7 @@ public async Task InsertGraph_FailureMidRun_TransactionRolledBack() var act = async () => await _context.ExecuteBulkInsertAsync(blogs, options => { options.IncludeGraph = true; + options.RestoreOriginalPrimaryKeysOnGraphInsertFailure = true; // Ensure original entities are restored on failure }); await act.Should().ThrowAsync("Insert should fail due to NULL constraint violation"); @@ -768,5 +769,42 @@ public async Task InsertGraph_FailureMidRun_TransactionRolledBack() // Verify original entities do NOT have IDs populated (rollback means no database-generated values) validBlog.Id.Should().Be(0, "Valid blog should not have ID after rollback"); invalidBlog.Id.Should().Be(0, "Invalid blog should not have ID after rollback"); + + // Act 2 - Fix the invalid data and retry insertion with the same entities + // This verifies that entities are properly restored and can be reused after rollback + invalidBlog.Posts.First().Title = $"{_run}_FixedPost"; + + // Should succeed this time + await _context.ExecuteBulkInsertAsync(blogs, options => + { + options.IncludeGraph = true; + }); + + // Assert 2 - Verify that ALL entities are now inserted successfully + var insertedBlogsAfterFix = _context.Blogs.Where(b => b.TestRun == _run).ToList(); + insertedBlogsAfterFix.Should().HaveCount(2, "Both blogs should be inserted after fixing the data"); + + var insertedPostsAfterFix = _context.Posts.Where(p => p.TestRun == _run).ToList(); + insertedPostsAfterFix.Should().HaveCount(2, "Both posts should be inserted after fixing the data"); + + var insertedSettingsAfterFix = _context.BlogSettings.Where(s => s.TestRun == _run).ToList(); + insertedSettingsAfterFix.Should().HaveCount(1, "Blog settings should be inserted after fixing the data"); + + // Verify that the original entity references now have IDs populated + validBlog.Id.Should().BeGreaterThan(0, "Valid blog should have ID after successful insert"); + invalidBlog.Id.Should().BeGreaterThan(0, "Fixed blog should have ID after successful insert"); + validBlog.Posts.First().Id.Should().BeGreaterThan(0, "Valid post should have ID after successful insert"); + invalidBlog.Posts.First().Id.Should().BeGreaterThan(0, "Fixed post should have ID after successful insert"); + validBlog.Settings!.Id.Should().BeGreaterThan(0, "Settings should have ID after successful insert"); + + // Verify FK relationships are correct + validBlog.Posts.First().BlogId.Should().Be(validBlog.Id, "Valid post FK should reference its blog"); + invalidBlog.Posts.First().BlogId.Should().Be(invalidBlog.Id, "Fixed post FK should reference its blog"); + validBlog.Settings.BlogId.Should().Be(validBlog.Id, "Settings FK should reference its blog"); + + // Verify the corrected title is in the database + var fixedPostInDb = _context.Posts.FirstOrDefault(p => p.Id == invalidBlog.Posts.First().Id); + fixedPostInDb.Should().NotBeNull(); + fixedPostInDb!.Title.Should().Be($"{_run}_FixedPost", "Fixed post should have the corrected title"); } } From e2fd19bbc705b2c18e1956a3d93b8dd323bc6051 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 14 Feb 2026 10:49:09 +0100 Subject: [PATCH 25/26] Handle deduplication of join records --- .../Graph/EntityPairEqualityComparer.cs | 28 +++++++++++++++++++ .../Graph/GraphBulkInsertOrchestrator.cs | 12 ++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 src/PhenX.EntityFrameworkCore.BulkInsert/Graph/EntityPairEqualityComparer.cs diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/EntityPairEqualityComparer.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/EntityPairEqualityComparer.cs new file mode 100644 index 0000000..191af4d --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/EntityPairEqualityComparer.cs @@ -0,0 +1,28 @@ +using System.Runtime.CompilerServices; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; + +/// +/// Compares pairs of entity references for equality using reference equality. +/// Used for deduplicating many-to-many join records. +/// +internal sealed class EntityPairEqualityComparer : IEqualityComparer<(object Left, object Right)> +{ + public static readonly EntityPairEqualityComparer Instance = new(); + + private EntityPairEqualityComparer() { } + + public bool Equals((object Left, object Right) x, (object Left, object Right) y) + { + return ReferenceEquals(x.Left, y.Left) && ReferenceEquals(x.Right, y.Right); + } + + public int GetHashCode((object Left, object Right) obj) + { + return HashCode.Combine( + RuntimeHelpers.GetHashCode(obj.Left), + RuntimeHelpers.GetHashCode(obj.Right) + ); + } +} + diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index ece7edc..984f1d3 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -347,11 +347,19 @@ private async Task InsertJoinRecords( // Get entity metadata for join type var joinEntityMetadata = graphMetadata.GetEntityMetadata(joinEntityType); - // Create join table entries + // Deduplicate join records by entity reference pairs + // Use a HashSet to track which (LeftEntity, RightEntity) pairs have been processed + var seenPairs = new HashSet<(object Left, object Right)>(EntityPairEqualityComparer.Instance); var joinEntities = new List(); foreach (var record in records) { + // Skip if this exact pair of entity instances has already been processed + if (!seenPairs.Add((record.LeftEntity, record.RightEntity))) + { + continue; + } + // Get metadata for left and right entities var leftMetadata = graphMetadata.GetEntityMetadata(record.LeftEntity.GetType()); var rightMetadata = graphMetadata.GetEntityMetadata(record.RightEntity.GetType()); @@ -415,7 +423,7 @@ private async Task InsertJoinRecords( if (joinEntities.Count > 0) { - // Insert join entities + // Insert join entities (only unique ones) await InsertJoinEntities(sync, context, joinEntityType, joinEntities, options, provider, ctk); totalJoinRecordsInserted += joinEntities.Count; } From 95957823da00450d30c5d9ea8e504c20e6778e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20M=C3=A9nager?= Date: Fri, 24 Apr 2026 23:20:58 +0200 Subject: [PATCH 26/26] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/limitations.md | 1 - .../Graph/GraphBulkInsertOrchestrator.cs | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/limitations.md b/docs/limitations.md index 5cbd0b3..f0cead5 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -2,7 +2,6 @@ For now this library does not support the following features: -* **Navigation properties**: ✅ Supported via the `IncludeGraph` option (see [Graph Insert documentation](./graph-insert.md)). * **Change tracking**: The library does not track changes to the entities being inserted. This means that you cannot use the `DbContext.ChangeTracker` to track changes to the entities after they have been inserted. * **Inheritance**: The library does not support inserting entities with inheritance (TPT, TPH, TPC). You can only insert entities of a single type. diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs index 984f1d3..0184ffb 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -466,7 +466,12 @@ private async Task InsertJoinEntities( .GetMethod(nameof(InsertJoinEntitiesGeneric), BindingFlags.NonPublic | BindingFlags.Instance)! .MakeGenericMethod(joinEntityType); - var task = (Task)method.Invoke(this, [sync, context, tableInfo, joinEntities, options, provider, ctk])!; + var invocationResult = method.Invoke(this, [sync, context, tableInfo, joinEntities, options, provider, ctk]); + if (invocationResult is not Task task) + { + throw new InvalidOperationException( + $"Reflected call to '{nameof(InsertJoinEntitiesGeneric)}' for join entity type '{joinEntityType}' did not return a Task as expected."); + } await task; }