diff --git a/README.md b/README.md index efefd14..b88d52d 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,31 @@ 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); +``` + +> ℹ️ 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 and provider-specific notes. + ### 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 +177,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..25527d4 --- /dev/null +++ b/docs/graph-insert.md @@ -0,0 +1,137 @@ +# Graph Insert (Navigation Properties) + +> ℹ️ 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. + +## 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 with explicit join entity types are populated automatically (see Limitations below) + +## 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 `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 + +## 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. +- **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/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.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/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..1d5a3ba 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,21 @@ public static async Task ExecuteBulkInsertAsync( { var (provider, context, options) = InitProvider(dbSet, configure); + 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(context); + await orchestrator.InsertGraph(false, entities, options, provider, cancellationToken); + + return; + } + await provider.BulkInsert(false, context, dbSet.GetDbContext().GetTableInfo(), entities, options, onConflict, cancellationToken); } @@ -204,6 +220,22 @@ public static void ExecuteBulkInsert( { var (provider, context, options) = InitProvider(dbSet, configure); + 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(context); + orchestrator.InsertGraph(true, 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/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 new file mode 100644 index 0000000..984f1d3 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphBulkInsertOrchestrator.cs @@ -0,0 +1,548 @@ +using System.Reflection; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; + +using PhenX.EntityFrameworkCore.BulkInsert.Abstractions; +using PhenX.EntityFrameworkCore.BulkInsert.Extensions; +using PhenX.EntityFrameworkCore.BulkInsert.Metadata; +using PhenX.EntityFrameworkCore.BulkInsert.Options; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; + +/// +/// Orchestrates bulk insertion of entity graphs with FK propagation. +/// +internal sealed class GraphBulkInsertOrchestrator +{ + + private readonly DbContext _context; + private readonly MetadataProvider _metadataProvider; + private readonly ILogger? _logger; + + public GraphBulkInsertOrchestrator(DbContext context) + { + _context = context; + _metadataProvider = context.GetService(); + _logger = context.GetService>(); + } + + /// + /// Orchestrates the bulk insert of an entity graph. + /// + public async Task> InsertGraph( + bool sync, + IEnumerable entities, + BulkInsertOptions options, + 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); + + if (collectionResult.EntitiesByType.Count == 0) + { + return new GraphInsertResult + { + RootEntities = [], + TotalInsertedCount = 0, + }; + } + + var totalInserted = 0; + var graphMetadata = new GraphMetadata(_context, options); + + // 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 (hasAnyDatabaseGeneratedKeys && !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())."); + } + + 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) + foreach (var entityType in collectionResult.InsertionOrder) + { + if (!collectionResult.EntitiesByType.TryGetValue(entityType, out var entitiesToInsert) || + entitiesToInsert.Count == 0) + { + 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); + + // Insert entities of this type + await InsertEntitiesOfType(sync, _context, entityType, entitiesToInsert, options, provider, + graphMetadata, ctk); + + totalInserted += entitiesToInsert.Count; + } + + // 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() + : []; + + // Commit the transaction if we own them. + await connection.Commit(sync, ctk); + + return new GraphInsertResult + { + RootEntities = rootEntities, + TotalInsertedCount = totalInserted, + }; + } + catch + { + // Restore original PK values on rollback + if (options.RestoreOriginalPrimaryKeysOnGraphInsertFailure) + { + RestoreOriginalPrimaryKeyValues(originalPkValues, graphMetadata); + } + + throw; + } + finally + { + await connection.Close(sync, ctk); + } + } + + private static void PropagateParentForeignKeys( + List entities, + Type entityType, + GraphMetadata graphMetadata) + { + var efEntityType = graphMetadata.GetEntityType(entityType); + if (efEntityType == null) + { + 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()) + { + 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 = 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; + + 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 = parentMetadata.GetPropertyValue(parentEntity, pkProp.Name); + entityMetadata.SetPropertyValue(entity, fkProp.Name, pkValue); + } + } + } + } + + private async Task InsertEntitiesOfType( + bool sync, + DbContext context, + Type entityType, + List entities, + BulkInsertOptions options, + IBulkInsertProvider provider, + GraphMetadata graphMetadata, + 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, [sync, context, entities, options, provider, graphMetadata, ctk])!; + await task; + } + + private async Task InsertEntitiesGenericAsync( + bool sync, + DbContext context, + List entities, + BulkInsertOptions options, + IBulkInsertProvider provider, + GraphMetadata graphMetadata, + 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( + sync, + context, + tableInfo, + typedEntities, + options, + null, + ctk)) + { + insertedEntities.Add(inserted); + } + + // Copy generated IDs back to original entities + var entityMetadata = graphMetadata.GetEntityMetadata(typeof(TEntity)); + if (entityMetadata != null) + { + CopyGeneratedIds(typedEntities, insertedEntities, tableInfo, entityMetadata); + } + } + else + { + // No identity columns, just insert directly + await provider.BulkInsert(sync, context, tableInfo, typedEntities, options, null, ctk); + } + } + + private void CopyGeneratedIds( + List originalEntities, + List insertedEntities, + TableMetadata tableInfo, + EntityMetadata entityMetadata) where TEntity : class + { + if (originalEntities.Count != insertedEntities.Count) + { + // 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. + _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; + } + + 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 = entityMetadata.GetPropertyValue(inserted, pkProp.PropertyName); + entityMetadata.SetPropertyValue(original, pkProp.PropertyName, value); + } + } + } + + private async Task InsertJoinRecords( + bool sync, + DbContext context, + List joinRecords, + BulkInsertOptions options, + IBulkInsertProvider provider, + GraphMetadata graphMetadata, + CancellationToken ctk) + { + var totalJoinRecordsInserted = 0; + + // 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; + } + + // Get entity metadata for join type + var joinEntityMetadata = graphMetadata.GetEntityMetadata(joinEntityType); + + // 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()); + + if (leftMetadata == null || rightMetadata == null) + { + continue; + } + + // Create a dictionary-based join entity + var joinEntry = Activator.CreateInstance(joinEntityType); + if (joinEntry == null) + { + _logger?.LogWarning( + "IncludeGraph failed to create join entry for {EntityType}. Many-to-many relationship may be incomplete.", + joinEntityType.Name + ); + + continue; + } + + // Check if the join entity is a dictionary (shared-type entity) + var dictEntry = joinEntry as IDictionary; + + // 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 = leftMetadata.GetPropertyValue(record.LeftEntity, pkProp.Name); + if (dictEntry != null) + { + dictEntry[fkProp.Name] = pkValue!; + } + else if (joinEntityMetadata != null) + { + joinEntityMetadata.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 = rightMetadata.GetPropertyValue(record.RightEntity, pkProp.Name); + if (dictEntry != null) + { + dictEntry[fkProp.Name] = pkValue!; + } + else if (joinEntityMetadata != null) + { + joinEntityMetadata.SetPropertyValue(joinEntry, fkProp.Name, pkValue); + } + } + + joinEntities.Add(joinEntry); + } + + if (joinEntities.Count > 0) + { + // Insert join entities (only unique ones) + await InsertJoinEntities(sync, context, joinEntityType, joinEntities, options, provider, ctk); + totalJoinRecordsInserted += joinEntities.Count; + } + } + + return totalJoinRecordsInserted; + } + + private async Task InsertJoinEntities( + bool sync, + DbContext context, + Type joinEntityType, + List joinEntities, + BulkInsertOptions options, + 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 (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) + { + return; + } + + var sqlDialect = provider.SqlDialect; + var tableInfo = new TableMetadata(efEntityType, sqlDialect); + + // Use reflection to call the generic BulkInsert method with correctly typed entities + var method = typeof(GraphBulkInsertOrchestrator) + .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 static async Task InsertJoinEntitiesGeneric( + 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); + } + + 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/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 new file mode 100644 index 0000000..b9d2fbd --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Graph/GraphEntityCollector.cs @@ -0,0 +1,172 @@ +using System.Collections; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +using PhenX.EntityFrameworkCore.BulkInsert.Metadata; +using PhenX.EntityFrameworkCore.BulkInsert.Options; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Graph; + +/// +/// 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); + // 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 = []; + } + + /// + /// 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) + { + // Null entity, nothing to collect + return; + } + + // 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); + + 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 value = navigation.GetValue(entity); + if (value == null) + { + continue; + } + + if (navigation.IsCollection) + { + if (value is not IEnumerable collection) + { + continue; + } + + foreach (var item in collection) + { + if (item == null) + { + continue; + } + + 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, set the inverse navigation property + // (e.g., if Blog.Posts is the navigation, set Post.Blog = blog) + if (!navigation.HasInverseSetter) + { + 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 nav && nav.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) + { + navigation.SetInverseValue(childEntity, parentEntity); + } + } +} 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/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 new file mode 100644 index 0000000..d82b816 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/GraphMetadata.cs @@ -0,0 +1,179 @@ +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 Dictionary _entityMetadataByType = []; + 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 || + entityType.FindPrimaryKey() == null) // Keyless entities have no primary key + { + 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.GetValueOrDefault(clrType); + } + + /// + /// Gets the navigations for a CLR type. + /// + public IReadOnlyList GetNavigations(Type clrType) + { + return _navigationsByType.TryGetValue(clrType, out var navigations) + ? navigations + : []; + } + + /// + /// 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. + /// + 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.Add(type)) + { + // Cycle detected - this is handled gracefully, just skip + return; + } + + // 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)) + { + 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..4cec5d4 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/NavigationMetadata.cs @@ -0,0 +1,133 @@ +using System.Reflection; + +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; + + // 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); + + // 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; + JoinEntityType = skipNavigation.JoinEntityType; + ForeignKey = skipNavigation.ForeignKey; + InverseForeignKey = skipNavigation.Inverse?.ForeignKey; + } + else if (navigation is INavigation regularNavigation) + { + IsManyToMany = false; + ForeignKey = regularNavigation.ForeignKey; + IsDependentToPrincipal = regularNavigation.IsOnDependent; + } + } + + private readonly Func _getter; + private readonly Func? _inverseGetter; + private readonly Action? _inverseSetter; + + /// + /// 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 value of the navigation property from the entity using an optimized getter. + /// + 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"); + return $"{PropertyName} -> {TargetType.Name} ({relationshipType})"; + } +} 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 diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs index 13535b3..955fe53 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Options/BulkInsertOptions.cs @@ -74,6 +74,38 @@ 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; } + + /// + /// 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.Benchmark/LibComparator.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs index 178f3ea..f7e8088 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs @@ -19,18 +19,33 @@ public abstract partial class LibComparator [Params(500_000/*, 1_000_000/*, 10_000_000*/)] public int N; + private IList data = []; protected TestDbContext DbContext { get; set; } = null!; [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 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(); ConfigureDbContext(); @@ -58,7 +73,12 @@ protected virtual string GetConnectionString() [Benchmark(Baseline = true)] public async Task PhenX_EntityFrameworkCore_BulkInsert() { - await DbContext.ExecuteBulkInsertAsync(data); + await DbContext.ExecuteBulkInsertAsync(data, options => + { +#if BENCHMARK_INCLUDE_GRAPH + options.IncludeGraph = true; +#endif + }); } // // [Benchmark] @@ -67,6 +87,7 @@ public async Task PhenX_EntityFrameworkCore_BulkInsert() // DbContext.ExecuteBulkInsert(data); // } +#if !BENCHMARK_INCLUDE_GRAPH [Benchmark] public void RawInsert() { @@ -107,11 +128,17 @@ 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 = false); + await DbContext.BulkInsertOptimizedAsync(data, options => + { +#if BENCHMARK_INCLUDE_GRAPH + options.IncludeGraph = true; +#endif + }); } // [Benchmark] @@ -123,10 +150,12 @@ public async Task Z_EntityFramework_Extensions_EFCore() [Benchmark] public async Task EFCore_BulkExtensions() { - await DbContext.BulkInsertAsync(data, options => + await DbContextBulkExtensions.BulkInsertAsync(DbContext, data, options => { - options.IncludeGraph = false; - options.PreserveInsertOrder = false; +#if BENCHMARK_INCLUDE_GRAPH + options.IncludeGraph = true; + options.PreserveInsertOrder = true; // Required for graph insertion +#endif }); } @@ -140,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.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 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..c7ed521 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Graph/GraphTestsBase.cs @@ -0,0 +1,810 @@ +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(IDbContextFactory dbContextFactory) : IAsyncLifetime + where TDbContext : TestDbContext, new() +{ + private readonly Guid _run = Guid.NewGuid(); + private TDbContext _context = null!; + + public async Task InitializeAsync() + { + _context = await dbContextFactory.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); + } + + [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)); + } + + [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); + } + + [SkippableFact] + public async Task InsertGraph_LargeScale() + { + // 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; + 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 < 20; 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 50 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}"); + } + + [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; + options.RestoreOriginalPrimaryKeysOnGraphInsertFailure = true; // Ensure original entities are restored on failure + }); + + 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"); + + // 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"); + } +} 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) +{ +}