Skip to content

Commit e1aa4de

Browse files
CopilotPhenX
andcommitted
Add IncludeGraph support for full object graph bulk insert
Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com>
1 parent c0b4240 commit e1aa4de

24 files changed

Lines changed: 1644 additions & 2 deletions

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,29 @@ await dbContext.ExecuteBulkInsertAsync(entities, o =>
115115
await dbContext.ExecuteBulkInsertReturnEntitiesAsync(entities);
116116
```
117117

118+
### Insert with navigation properties (Graph Insert)
119+
120+
Insert entities with their related navigation properties:
121+
122+
```csharp
123+
var blogs = new List<Blog>
124+
{
125+
new Blog
126+
{
127+
Name = "Blog 1",
128+
Posts = new List<Post>
129+
{
130+
new Post { Title = "Post 1" },
131+
new Post { Title = "Post 2" }
132+
}
133+
}
134+
};
135+
136+
await dbContext.ExecuteBulkInsertAsync(blogs, o => o.IncludeGraph = true);
137+
```
138+
139+
See [Graph Insert documentation](https://phenx.github.io/PhenX.EntityFrameworkCore.BulkInsert/graph-insert.html) for details.
140+
118141
### Conflict resolution / merge / upsert
119142

120143
Conflict resolution works by specifying columns that should be used to detect conflicts and the action to take when
@@ -152,7 +175,7 @@ await dbContext.ExecuteBulkInsertAsync(entities, onConflict: new OnConflictOptio
152175

153176
## Roadmap
154177

155-
- [ ] [Add support for navigation properties](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/2)
178+
- [x] [Add support for navigation properties](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/2)
156179
- [x] [Add support for complex types](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/3)
157180
- [x] Add support for owned types
158181
- [ ] Add support for shadow properties

docs/documentation.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,36 @@ Enable streaming bulk copy for SQL Server
204204
* Default: `unset` (PostgreSQL only)
205205

206206
Custom PostgreSQL type providers for handling specific data types.
207+
208+
### IncludeGraph
209+
210+
* Type: `bool`
211+
* Default: `false`
212+
213+
When enabled, recursively inserts all reachable entities via navigation properties.
214+
This includes one-to-one, one-to-many, many-to-one, and many-to-many relationships.
215+
216+
See [Graph Insert documentation](./graph-insert.md) for details.
217+
218+
### MaxGraphDepth
219+
220+
* Type: `int`
221+
* Default: `0` (unlimited)
222+
223+
Maximum depth for graph traversal when `IncludeGraph` is enabled.
224+
Use 0 for unlimited depth.
225+
226+
### IncludeNavigations
227+
228+
* Type: `HashSet<string>?`
229+
* Default: `null` (all navigations)
230+
231+
Navigation properties to explicitly include when `IncludeGraph` is enabled.
232+
If empty and `IncludeGraph` is true, all navigation properties are included.
233+
234+
### ExcludeNavigations
235+
236+
* Type: `HashSet<string>?`
237+
* Default: `null` (none)
238+
239+
Navigation properties to explicitly exclude when `IncludeGraph` is enabled.

docs/graph-insert.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Graph Insert (Navigation Properties)
2+
3+
This library supports bulk inserting entire object graphs, including entities with their related navigation properties.
4+
5+
## Enabling Graph Insert
6+
7+
```csharp
8+
await dbContext.ExecuteBulkInsertAsync(blogs, options =>
9+
{
10+
options.IncludeGraph = true;
11+
});
12+
```
13+
14+
## How It Works
15+
16+
1. The library traverses all reachable entities via navigation properties
17+
2. Entities are sorted in topological order (parents before children) to respect foreign key constraints
18+
3. Each entity type is bulk inserted in dependency order
19+
4. Generated IDs (identity columns) are propagated to foreign key properties
20+
5. Many-to-many join tables are populated automatically
21+
22+
## Options
23+
24+
| Option | Default | Description |
25+
|--------|---------|-------------|
26+
| `IncludeGraph` | `false` | Enable graph traversal |
27+
| `MaxGraphDepth` | `0` (unlimited) | Maximum depth to traverse. Use 0 for unlimited. |
28+
| `IncludeNavigations` | `null` (all) | Specific navigation property names to include |
29+
| `ExcludeNavigations` | `null` (none) | Navigation property names to exclude |
30+
31+
## Supported Relationship Types
32+
33+
- ✅ One-to-Many (e.g., Blog → Posts)
34+
- ✅ Many-to-One (e.g., Post → Blog)
35+
- ✅ One-to-One (e.g., Blog → BlogSettings)
36+
- ✅ Many-to-Many with join table (e.g., Post ↔ Tags)
37+
- ✅ Self-referencing/Hierarchies (e.g., Category → Parent/Children)
38+
39+
## Performance Considerations
40+
41+
- Graph insert is inherently slower than flat insert due to FK propagation overhead
42+
- For entities with identity columns, the library uses `ExecuteBulkInsertReturnEntities` internally to retrieve generated IDs
43+
- Consider using client-generated keys (GUIDs with `ValueGeneratedNever()`) to avoid ID propagation overhead
44+
- Use `MaxGraphDepth` to limit traversal for large/deep graphs
45+
- Use `IncludeNavigations` or `ExcludeNavigations` to reduce the scope of insertions
46+
47+
## Example
48+
49+
### One-to-Many Relationship
50+
51+
```csharp
52+
var blog = new Blog
53+
{
54+
Name = "My Blog",
55+
Posts = new List<Post>
56+
{
57+
new Post { Title = "First Post" },
58+
new Post { Title = "Second Post" }
59+
}
60+
};
61+
62+
await dbContext.ExecuteBulkInsertAsync(new[] { blog }, o => o.IncludeGraph = true);
63+
64+
// After insert:
65+
// - blog.Id is populated
66+
// - blog.Posts[0].BlogId == blog.Id
67+
// - blog.Posts[1].BlogId == blog.Id
68+
```
69+
70+
### One-to-One Relationship
71+
72+
```csharp
73+
var blog = new Blog
74+
{
75+
Name = "My Blog",
76+
Settings = new BlogSettings { EnableComments = true }
77+
};
78+
79+
await dbContext.ExecuteBulkInsertAsync(new[] { blog }, o => o.IncludeGraph = true);
80+
81+
// After insert:
82+
// - blog.Id is populated
83+
// - blog.Settings.BlogId == blog.Id
84+
```
85+
86+
### Selective Navigation Inclusion
87+
88+
```csharp
89+
var blog = new Blog
90+
{
91+
Name = "My Blog",
92+
Posts = new List<Post> { new Post { Title = "Post" } },
93+
Settings = new BlogSettings { EnableComments = true }
94+
};
95+
96+
// Only insert Posts, not Settings
97+
await dbContext.ExecuteBulkInsertAsync(new[] { blog }, o =>
98+
{
99+
o.IncludeGraph = true;
100+
o.IncludeNavigations = new HashSet<string> { "Posts" };
101+
});
102+
```
103+
104+
### Limiting Graph Depth
105+
106+
```csharp
107+
var blog = new Blog
108+
{
109+
Name = "My Blog",
110+
Posts = new List<Post>
111+
{
112+
new Post
113+
{
114+
Title = "Post",
115+
Tags = new List<Tag> { new Tag { Name = "EF Core" } } // Won't be inserted
116+
}
117+
}
118+
};
119+
120+
// MaxGraphDepth = 1 means only Blog and direct children (Posts)
121+
await dbContext.ExecuteBulkInsertAsync(new[] { blog }, o =>
122+
{
123+
o.IncludeGraph = true;
124+
o.MaxGraphDepth = 1;
125+
});
126+
```
127+
128+
## Limitations
129+
130+
- **Shadow foreign keys**: Currently not supported. Add a CLR property for foreign keys.
131+
- **Circular references**: Handled gracefully by tracking visited entities, but may result in incomplete graphs.
132+
- **OnConflict/Upsert**: Not currently supported with `IncludeGraph = true`.

docs/limitations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
For now this library does not support the following features:
44

5-
* **Navigation properties**: The library does not support inserting entities with navigation properties. You can only insert simple entities without any relationships.
5+
* **Navigation properties**: ✅ Supported via the `IncludeGraph` option (see [Graph Insert documentation](./graph-insert.md)).
66
* **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.
77
* **Inheritance**: The library does not support inserting entities with inheritance (TPT, TPH, TPC). You can only insert entities of a single type.
88

src/PhenX.EntityFrameworkCore.BulkInsert/Abstractions/IBulkInsertProvider.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ internal Task BulkInsert<T>(
3939

4040
SqlDialectBuilder SqlDialect { get; }
4141

42+
/// <summary>
43+
/// Returns whether this provider supports returning generated IDs efficiently.
44+
/// Required for IncludeGraph when entities have identity columns.
45+
/// </summary>
46+
bool SupportsOutputInsertedIds { get; }
47+
4248
/// <summary>
4349
/// Make the default options for the provider, can be a subclass of <see cref="BulkInsertOptions"/>.
4450
/// </summary>

src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderUntyped.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ internal abstract class BulkInsertProviderUntyped<TDialect, TOptions> : IBulkIns
1515

1616
SqlDialectBuilder IBulkInsertProvider.SqlDialect => SqlDialect;
1717

18+
/// <summary>
19+
/// Returns whether this provider supports returning generated IDs efficiently.
20+
/// Default implementation returns true for all providers.
21+
/// </summary>
22+
public virtual bool SupportsOutputInsertedIds => true;
23+
1824
BulkInsertOptions IBulkInsertProvider.CreateDefaultOptions() => CreateDefaultOptions();
1925

2026
/// <summary>

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.EntityFrameworkCore;
22

3+
using PhenX.EntityFrameworkCore.BulkInsert.Graph;
34
using PhenX.EntityFrameworkCore.BulkInsert.Options;
45

56
namespace PhenX.EntityFrameworkCore.BulkInsert.Extensions;
@@ -157,6 +158,13 @@ public static async Task ExecuteBulkInsertAsync<T, TOptions>(
157158
{
158159
var (provider, context, options) = InitProvider(dbSet, configure);
159160

161+
if (options.IncludeGraph)
162+
{
163+
var orchestrator = new GraphBulkInsertOrchestrator();
164+
await orchestrator.InsertGraphAsync(context, entities, options, provider, cancellationToken);
165+
return;
166+
}
167+
160168
await provider.BulkInsert(false, context, dbSet.GetDbContext().GetTableInfo<T>(), entities, options, onConflict,
161169
cancellationToken);
162170
}
@@ -204,6 +212,14 @@ public static void ExecuteBulkInsert<T, TOptions>(
204212
{
205213
var (provider, context, options) = InitProvider(dbSet, configure);
206214

215+
if (options.IncludeGraph)
216+
{
217+
var orchestrator = new GraphBulkInsertOrchestrator();
218+
orchestrator.InsertGraphAsync(context, entities, options, provider, CancellationToken.None)
219+
.GetAwaiter().GetResult();
220+
return;
221+
}
222+
207223
provider.BulkInsert(true, context, dbSet.GetDbContext().GetTableInfo<T>(), entities, options, onConflict)
208224
.GetAwaiter().GetResult();
209225
}

0 commit comments

Comments
 (0)