Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c0b4240
Initial plan
Copilot Feb 1, 2026
e1aa4de
Add IncludeGraph support for full object graph bulk insert
Copilot Feb 1, 2026
570bd6a
Address code review comments: add debug logging and fix docs
Copilot Feb 1, 2026
1b4553f
Remove tests for Oracle and MySQL as they don't support returing ids
Feb 1, 2026
2514aac
Add note about Oracle and MySQL limitations for IncludeGraph feature
Feb 1, 2026
9f337a9
Address PR review feedback: add tests, validation, and docs improvements
Copilot Feb 4, 2026
5f74e09
Improve dictionary cast in join entity handling
Copilot Feb 4, 2026
8e4dfe6
Add tests for entity linking with generated and client-generated keys
Copilot Feb 4, 2026
f811609
Add UseIncludeGraph property to benchmark project
Copilot Feb 4, 2026
c06aee2
Update benchmark to generate entities with children when UseIncludeGr…
Copilot Feb 4, 2026
b20d8cd
Merge branch 'main' into copilot/add-include-graph-support
Feb 7, 2026
d1f0632
Refactor GraphTestsBase to use IDbContextFactory for context creation
Feb 7, 2026
6b6b003
Code cleanup and handle providers that don't support inserted ids whe…
Feb 8, 2026
a181197
Optimize navigation getters
Feb 8, 2026
463dd4b
Enhance inverse navigation handling in GraphEntityCollector and Navig…
Feb 8, 2026
0410e40
Add optimized property accessors for entity metadata
Feb 8, 2026
6283440
Add support for IncludeGraph option in benchmarks
Feb 8, 2026
2c994ae
Fix benchmark to really use EFCoreBulkExtensions
Feb 8, 2026
d927f1f
Address code review feedback for IncludeGraph feature
Copilot Feb 8, 2026
3e636b1
Fix minor code review issues: null check, redundant condition, grammar
Copilot Feb 8, 2026
f21b3a8
Allow IncludeGraph on Oracle/MySQL when keys are client-generated, in…
Copilot Feb 8, 2026
2ab4e87
Rename variable to hasAnyDatabaseGeneratedKeys for clarity
Copilot Feb 8, 2026
092ed1f
Apply suggestions from code review
PhenX Feb 8, 2026
29eb67b
Merge branch 'main' into copilot/add-include-graph-support
Feb 14, 2026
97e239e
Wrap full graph insert with a transaction
Feb 14, 2026
fef0db3
Restore original PKs on failure
Feb 14, 2026
e2fd19b
Handle deduplication of join records
Feb 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Blog>
{
new Blog
{
Name = "Blog 1",
Posts = new List<Post>
{
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
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions docs/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>?`
* 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<string>?`
* Default: `null` (none)

Navigation properties to explicitly exclude when `IncludeGraph` is enabled.
137 changes: 137 additions & 0 deletions docs/graph-insert.md
Original file line number Diff line number Diff line change
@@ -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<Post>
{
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<Post> { 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<string> { "Posts" };
});
```

### Limiting Graph Depth

```csharp
var blog = new Blog
{
Name = "My Blog",
Posts = new List<Post>
{
new Post
{
Title = "Post",
Tags = new List<Tag> { 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<string, object>` shared-type entities).
- **OnConflict/Upsert**: Not currently supported with `IncludeGraph = true`.
2 changes: 1 addition & 1 deletion docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ internal class MySqlBulkInsertProvider(ILogger<MySqlBulkInsertProvider> logger)
/// <inheritdoc />
protected override string AddTableCopyBulkInsertId => $"ALTER TABLE {{0}} ADD {BulkInsertId} INT AUTO_INCREMENT PRIMARY KEY;";

/// <inheritdoc />
public override bool SupportsOutputInsertedIds => false;

/// <inheritdoc />
protected override string GetTempTableName(string tableName) => $"#_temp_bulk_insert_{tableName}_{Helpers.RandomString(6)}";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ internal class OracleBulkInsertProvider(ILogger<OracleBulkInsertProvider>? logge
/// <inheritdoc />
protected override string AddTableCopyBulkInsertId => ""; // No need to add an ID column in Oracle

/// <inheritdoc />
public override bool SupportsOutputInsertedIds => false;

/// <inheritdoc />
/// <summary>
/// The temporary table name is generated with a random 8-character suffix to ensure uniqueness, and is limited to less than 30 characters,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ internal Task BulkInsert<T>(

SqlDialectBuilder SqlDialect { get; }

/// <summary>
/// Returns whether this provider supports returning generated IDs efficiently.
/// Required for IncludeGraph when entities have identity columns.
/// </summary>
bool SupportsOutputInsertedIds { get; }

/// <summary>
/// Make the default options for the provider, can be a subclass of <see cref="BulkInsertOptions"/>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ internal abstract class BulkInsertProviderUntyped<TDialect, TOptions> : IBulkIns

SqlDialectBuilder IBulkInsertProvider.SqlDialect => SqlDialect;

/// <summary>
/// Returns whether this provider supports returning generated IDs efficiently.
/// Default implementation returns true for all providers.
/// </summary>
public virtual bool SupportsOutputInsertedIds => true;

BulkInsertOptions IBulkInsertProvider.CreateDefaultOptions() => CreateDefaultOptions();

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;

using PhenX.EntityFrameworkCore.BulkInsert.Graph;
using PhenX.EntityFrameworkCore.BulkInsert.Options;

namespace PhenX.EntityFrameworkCore.BulkInsert.Extensions;
Expand Down Expand Up @@ -157,6 +158,21 @@ public static async Task ExecuteBulkInsertAsync<T, TOptions>(
{
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<T>(), entities, options, onConflict,
cancellationToken);
}
Expand Down Expand Up @@ -204,6 +220,22 @@ public static void ExecuteBulkInsert<T, TOptions>(
{
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<T>(), entities, options, onConflict)
.GetAwaiter().GetResult();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Runtime.CompilerServices;

namespace PhenX.EntityFrameworkCore.BulkInsert.Graph;

/// <summary>
/// Compares pairs of entity references for equality using reference equality.
/// Used for deduplicating many-to-many join records.
/// </summary>
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)
);
}
}

Loading