원클릭으로
bulk-operations
Canonical EF Core bulk and batch operation guidance. Load only for explicit high-volume insert or batch tasks.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Canonical EF Core bulk and batch operation guidance. Load only for explicit high-volume insert or batch tasks.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
Structure and rules for .NET projects using Clean Architecture with DDD. Use when the project has complex business domain, multiple use cases, need to test business logic in isolation, or when the system is enterprise, e-commerce, ERP, CRM, or any rich domain. Do not use for simple CRUDs without logic — prefer Vertical Slice in that case. For implementation details, load the required `backend/dotnet/orms/ef-core/*/SKILL.md` leaf skill(s) or your chosen data access skill.
Structure and rules for .NET projects using Hexagonal Architecture (Ports & Adapters). Use when the project has multiple input channels (API, CLI, message queue), need to test the domain in isolation, or when adapters change frequently. Do not use for simple CRUDs without logic — prefer Vertical Slice in that case. For implementation details, load the required `backend/dotnet/orms/ef-core/*/SKILL.md` leaf skill(s) or your chosen data access skill.
Structure and rules for .NET projects using Onion Architecture. Use when the project has a strong domain-centric focus with DDD, aggregate roots, and the Specification pattern. All dependencies point inward toward the domain core. Do not use for simple CRUDs without logic — prefer Vertical Slice in that case. For implementation details, load the required `backend/dotnet/orms/ef-core/*/SKILL.md` leaf skill(s) or your chosen data access skill.
Canonical DbContext setup and registration rules for EF Core. Load when creating or modifying DbContext classes or their DI wiring.
Pure LINQ standards for .NET 10 projects. Covers language operators, query composition, projection patterns, filtering, grouping, and performance best practices over IEnumerable<T> and IQueryable<T>. Load when writing or reviewing LINQ expressions in any layer. DO NOT load expecting EF Core-specific methods (ToListAsync, AsNoTracking, Include, ExecuteUpdateAsync) — those belong to `backend/dotnet/orms/ef-core/query-best-practices/SKILL.md`.
Canonical rules for using AppDbContext DIRECTLY from application code (use cases / handlers) when the project's data-access pattern is Direct DbContext — no repositories, no UnitOfWork. Load when the chosen pattern is Direct DbContext and a use case reads or writes data. This is the counterpart to repository-usage; never load both for the same task.
SOC 직업 분류 기준
| name | bulk-operations |
| description | Canonical EF Core bulk and batch operation guidance. Load only for explicit high-volume insert or batch tasks. |
| requires | ["backend-dotnet-csharp"] |
| produces | ["bulk-insert","batch-update"] |
Use this skill only for explicit high-volume flows, typically 1000+ rows, where standard AddAsync() plus CompleteAsync() is too slow.
For ordinary CRUD and small batches, stay on the standard repository flow.
public interface IBulkInsertOperations
{
Task<int> BulkInsertAsync(
List<object> entities,
CancellationToken cancellationToken,
int batchSize = 10000,
int bulkCopyTimeout = 600);
}
BulkInsertOperations is responsible for:
SqlBulkCopypublic sealed class BulkInsertOperations : IBulkInsertOperations
{
private readonly DbContext _dbContext;
private readonly ILogger<BulkInsertOperations>? _logger;
public BulkInsertOperations(DbContext context, ILoggerFactory? loggerFactory = null)
{
_dbContext = context;
_logger = loggerFactory?.CreateLogger<BulkInsertOperations>();
}
public async Task<int> BulkInsertAsync(
List<object> entities,
CancellationToken cancellationToken,
int batchSize = 10000,
int bulkCopyTimeout = 600)
{
if (entities is null || entities.Count == 0)
return 0;
IDbContextTransaction? currentTransaction = _dbContext.Database.CurrentTransaction;
if (currentTransaction is null)
throw new InvalidOperationException("BulkInsertAsync requires an active transaction.");
IEntityType entityType = _dbContext.Model.FindEntityType(entities[0].GetType())
?? throw new InvalidOperationException("Could not resolve entity type.");
IKey? primaryKey = entityType.FindPrimaryKey();
if (primaryKey is null)
return await BulkInsertHasNoKeyAsync(...);
IProperty keyProperty = primaryKey.Properties[0];
if (keyProperty.ValueGenerated == ValueGenerated.Never)
return await BulkInsertValueGeneratedNeverAsync(...);
return await BulkInsertValueGeneratedOnAddAsync(...);
}
}
entities and return 0 for empty input._dbContext.Database.CurrentTransaction; never open an implicit transaction inside the bulk component.IEntityType from entities[0].GetType() and fail fast if metadata is missing.Dictionary<object, int> temp-id map with i + 1 values for every input entity.| Condition | Path |
|---|---|
| No primary key | BulkInsertHasNoKeyAsync(...) |
Key exists and ValueGenerated.Never | BulkInsertValueGeneratedNeverAsync(...) |
| Key exists and database generates it | BulkInsertValueGeneratedOnAddAsync(...) |
BulkInsertHasNoKeyAsyncValueGenerated == ValueGenerated.Never.SqlBulkCopy with:
DestinationTableName = $"[{schema}].[{tableName}]"BatchSize = batchSizeBulkCopyTimeout = bulkCopyTimeoutEnableStreaming = trueEntityDataReader.ParentKeyResolver.NoKey() and still call ProcessNavigationsAsync(...) so child graphs continue to be processed when possible.private async Task<int> BulkInsertHasNoKeyAsync(
List<object> entities,
IEntityType entityType,
Dictionary<object, int> parentTempIds,
int batchSize,
int bulkCopyTimeout,
CancellationToken cancellationToken)
{
if (entities.Count == 0)
return 0;
string? tableName = entityType.GetTableName();
if (tableName is null)
throw new InvalidOperationException($"Could not find table name for {entityType.Name}.");
string schema = entityType.GetSchema() ?? "dbo";
List<IProperty> properties = entityType.GetProperties()
.Where(p => !p.IsShadowProperty() && p.ValueGenerated == ValueGenerated.Never)
.ToList();
SqlConnection connection = (SqlConnection)_dbContext.Database.GetDbConnection();
SqlTransaction transaction = (SqlTransaction)_dbContext.Database.CurrentTransaction!.GetDbTransaction();
bool wasOpen = connection.State == ConnectionState.Open;
try
{
if (!wasOpen)
await connection.OpenAsync(cancellationToken);
using SqlBulkCopy bulkCopy = new(connection, SqlBulkCopyOptions.Default, transaction)
{
DestinationTableName = $"[{schema}].[{tableName}]",
BatchSize = batchSize,
BulkCopyTimeout = bulkCopyTimeout,
EnableStreaming = true
};
foreach (IProperty property in properties)
bulkCopy.ColumnMappings.Add(property.GetColumnName(), property.GetColumnName());
using EntityDataReader reader = new(entities.Cast<object>().ToList(), properties);
await bulkCopy.WriteToServerAsync(reader, cancellationToken);
_logger?.LogInformation(
"BulkInsert (HasNoKey) completed: {RowCount} rows inserted in [{Schema}].[{Table}]",
entities.Count,
schema,
tableName);
ParentKeyResolver keyResolver = ParentKeyResolver.NoKey();
await ProcessNavigationsAsync(entities, entityType, keyResolver, batchSize, bulkCopyTimeout, cancellationToken);
}
finally
{
if (!wasOpen && connection.State == ConnectionState.Open)
await connection.CloseAsync();
}
return entities.Count;
}
BulkInsertValueGeneratedNeverAsyncSqlBulkCopy path as the no-key case.Func<object, object?> getParentIdFromEntity = entity => keyProperty.PropertyInfo?.GetValue(entity).ParentKeyResolver.FromMemoryGenerated(...).private async Task<int> BulkInsertValueGeneratedNeverAsync(
List<object> entities,
IEntityType entityType,
Dictionary<object, int> parentTempIds,
IProperty keyProperty,
int batchSize,
int bulkCopyTimeout,
CancellationToken cancellationToken)
{
if (entities.Count == 0)
return 0;
string? tableName = entityType.GetTableName();
if (tableName is null)
throw new InvalidOperationException($"Could not find table name for {entityType.Name}.");
string schema = entityType.GetSchema() ?? "dbo";
List<IProperty> properties = entityType.GetProperties()
.Where(p => !p.IsShadowProperty() && p.ValueGenerated == ValueGenerated.Never)
.ToList();
SqlConnection connection = (SqlConnection)_dbContext.Database.GetDbConnection();
SqlTransaction transaction = (SqlTransaction)_dbContext.Database.CurrentTransaction!.GetDbTransaction();
bool wasOpen = connection.State == ConnectionState.Open;
try
{
if (!wasOpen)
await connection.OpenAsync(cancellationToken);
using SqlBulkCopy bulkCopy = new(connection, SqlBulkCopyOptions.Default, transaction)
{
DestinationTableName = $"[{schema}].[{tableName}]",
BatchSize = batchSize,
BulkCopyTimeout = bulkCopyTimeout,
EnableStreaming = true
};
foreach (IProperty property in properties)
bulkCopy.ColumnMappings.Add(property.GetColumnName(), property.GetColumnName());
using EntityDataReader reader = new(entities.Cast<object>().ToList(), properties);
await bulkCopy.WriteToServerAsync(reader, cancellationToken);
_logger?.LogInformation(
"BulkInsert (ValueGenerated.Never) completed: {RowCount} rows inserted in [{Schema}].[{Table}]",
entities.Count,
schema,
tableName);
Func<object, object?> getParentIdFromEntity = entity => keyProperty.PropertyInfo?.GetValue(entity);
ParentKeyResolver keyResolver = ParentKeyResolver.FromMemoryGenerated(getParentIdFromEntity);
await ProcessNavigationsAsync(entities, entityType, keyResolver, batchSize, bulkCopyTimeout, cancellationToken);
}
finally
{
if (!wasOpen && connection.State == ConnectionState.Open)
await connection.CloseAsync();
}
return entities.Count;
}
BulkInsertValueGeneratedOnAddAsyncBulkInsertWithTempTableAsync(...).Dictionary<int, object> temp-id to real-id mapping.ParentKeyResolver.FromDatabaseGenerated(mapping, tempIds).ProcessNavigationsAsync(...) so child rows receive the real parent IDs.private async Task<int> BulkInsertValueGeneratedOnAddAsync(
List<object> entities,
IEntityType entityType,
Dictionary<object, int> tempIds,
int batchSize,
int bulkCopyTimeout,
CancellationToken cancellationToken)
{
if (entities.Count == 0)
return 0;
Dictionary<int, object> mapping = await BulkInsertWithTempTableAsync(
entities,
entityType,
tempIds,
batchSize,
bulkCopyTimeout,
cancellationToken);
foreach (KeyValuePair<int, object> idMappingEntry in mapping)
{
object entity = entities[idMappingEntry.Key - 1];
IKey? key = entityType.FindPrimaryKey();
if (key != null)
key.Properties[0].PropertyInfo?.SetValue(entity, idMappingEntry.Value);
}
ParentKeyResolver keyResolver = ParentKeyResolver.FromDatabaseGenerated(mapping, tempIds);
await ProcessNavigationsAsync(entities, entityType, keyResolver, batchSize, bulkCopyTimeout, cancellationToken);
return entities.Count;
}
ValueGenerated.NeverValueGenerated.OnAdd__TempIdMERGE into the real table with OUTPUT INSERTED.[Key], source.__TempIdBulkInsertWithTempTableAsync contractValueGenerated == ValueGenerated.Never; generated keys must not be sent as source columns.#Temp_{tableName}_{Guid.NewGuid():N}.__TempId plus scalar columns into the temp table.MERGE/OUTPUT statement to capture database-generated IDs.finally block, even if the MERGE fails.private async Task<Dictionary<int, object>> BulkInsertWithTempTableAsync(
List<object> entities,
IEntityType entityType,
Dictionary<object, int> tempIds,
int batchSize,
int bulkCopyTimeout,
CancellationToken cancellationToken)
{
string? tableName = entityType.GetTableName();
if (tableName is null)
throw new InvalidOperationException($"No table name for {entityType.Name}.");
string schema = entityType.GetSchema() ?? "dbo";
IKey primaryKey = entityType.FindPrimaryKey() ?? throw new Exception("No primary key found");
IProperty keyProperty = primaryKey.Properties[0];
List<IProperty> properties = entityType.GetProperties()
.Where(p => !p.IsShadowProperty() && p.ValueGenerated == ValueGenerated.Never)
.ToList();
string tempTableName = $"#Temp_{tableName}_{Guid.NewGuid():N}";
SqlConnection connection = (SqlConnection)_dbContext.Database.GetDbConnection();
SqlTransaction transaction = (SqlTransaction)_dbContext.Database.CurrentTransaction!.GetDbTransaction();
bool wasOpen = connection.State == ConnectionState.Open;
try
{
if (!wasOpen)
await connection.OpenAsync(cancellationToken);
string createTempTableSql = GenerateCreateTempTableSql(tempTableName, properties);
await using (SqlCommand createTempTableCommand = new(createTempTableSql, connection, transaction))
{
await createTempTableCommand.ExecuteNonQueryAsync(cancellationToken);
}
using (SqlBulkCopy bulkCopy = new(connection, SqlBulkCopyOptions.Default, transaction))
{
bulkCopy.DestinationTableName = tempTableName;
bulkCopy.BatchSize = batchSize;
bulkCopy.BulkCopyTimeout = bulkCopyTimeout;
bulkCopy.EnableStreaming = true;
bulkCopy.ColumnMappings.Add("__TempId", "__TempId");
foreach (IProperty property in properties)
bulkCopy.ColumnMappings.Add(property.GetColumnName(), property.GetColumnName());
using EntityDataReader reader = new(entities, properties, tempIds);
await bulkCopy.WriteToServerAsync(reader, cancellationToken);
}
string insertSql = GenerateInsertWithOutputSql(tempTableName, schema, tableName, properties, keyProperty);
Dictionary<int, object> mapping = new(entities.Count);
try
{
await using (SqlCommand sqlCommand = new(insertSql, connection, transaction))
await using (SqlDataReader sqlDataReader = await sqlCommand.ExecuteReaderAsync(cancellationToken))
{
while (await sqlDataReader.ReadAsync(cancellationToken))
{
object realId = sqlDataReader.GetValue(0);
int tempIdValue = sqlDataReader.GetInt32(1);
mapping[tempIdValue] = realId;
}
}
}
finally
{
await using var dropCmd = new SqlCommand(
$"IF OBJECT_ID('tempdb..{tempTableName}') IS NOT NULL DROP TABLE {tempTableName}",
connection,
transaction);
await dropCmd.ExecuteNonQueryAsync(CancellationToken.None);
}
_logger?.LogInformation(
"BulkInsert (ValueGenerated.OnAdd) completed: {RowCount} rows inserted in [{Schema}].[{Table}]",
entities.Count,
schema,
tableName);
return mapping;
}
finally
{
if (!wasOpen && connection.State == ConnectionState.Open)
await connection.CloseAsync();
}
}
string insertSql = $@"
MERGE INTO [{schema}].[{table}] AS target
USING {tempTable} AS source
ON 1 = 0
WHEN NOT MATCHED THEN
INSERT ({string.Join(",", columns)})
VALUES ({string.Join(",", sourceColumns)})
OUTPUT INSERTED.[{keyColumn}], source.__TempId;";
Use a custom DbDataReader to stream entity property values into SqlBulkCopy without materializing a DataTable.
Critical behavior preserved from legacy guidance:
__TempId column when the strategy needs ID remappingDBNull.Value for null CLR valuesSqlBulkCopy bypasses EF Core convertersDbDataReader contract members that SqlBulkCopy still expects during schema and value resolutionGetOrdinal(string name) must resolve __TempId first when present, then mapped column namesGetValues(object[] values) must copy the current row sequentially through GetValue(i)this[int i] and this[string name] must delegate to GetValue(...)Depth, IsClosed, HasRows, RecordsAffected, NextResult(), Close(), ReadAsync(...), and GetEnumerator() must keep the simple streaming behavior from the legacy implementationGetBoolean, GetInt32, GetString, GetGuid, and GetDateTime should remain thin casts over GetValue(i) so SqlBulkCopy can access typed values without extra conversion layersvarchar/nvarchar/char/nchar, emit value.ToString()Convert.ToInt32(value)GetFieldType() must return typeof(string) for string-backed enums so SqlBulkCopy uses the correct mapperinternal sealed class EntityDataReader : DbDataReader
{
private readonly IReadOnlyList<object> _entities;
private readonly IReadOnlyList<IProperty> _properties;
private readonly Dictionary<object, int>? _tempIds;
private readonly bool _hasTempId;
private int _currentIndex = -1;
private bool _closed;
public EntityDataReader(IReadOnlyList<object> entities, IReadOnlyList<IProperty> properties)
{
_entities = entities;
_properties = properties;
_hasTempId = false;
}
public EntityDataReader(IReadOnlyList<object> entities, IReadOnlyList<IProperty> properties, Dictionary<object, int> tempIds)
{
_entities = entities;
_properties = properties;
_tempIds = tempIds;
_hasTempId = true;
}
public override int FieldCount => _hasTempId ? _properties.Count + 1 : _properties.Count;
public override bool Read()
{
_currentIndex++;
return _currentIndex < _entities.Count;
}
public override object GetValue(int i)
{
object entity = _entities[_currentIndex];
if (_hasTempId)
{
if (i == 0)
return _tempIds![entity];
return GetPropertyValue(_properties[i - 1], entity);
}
return GetPropertyValue(_properties[i], entity);
}
private static object GetPropertyValue(IProperty property, object entity)
{
object? value = property.PropertyInfo?.GetValue(entity);
if (value is null)
return DBNull.Value;
if (property.ClrType.IsEnum)
{
string? columnType = property.GetColumnType();
bool storedAsString = columnType is not null &&
(columnType.Contains("varchar", StringComparison.OrdinalIgnoreCase) ||
columnType.Contains("nvarchar", StringComparison.OrdinalIgnoreCase) ||
columnType.Contains("nchar", StringComparison.OrdinalIgnoreCase) ||
columnType.Contains("char", StringComparison.OrdinalIgnoreCase));
if (storedAsString)
return value.ToString() ?? string.Empty;
return Convert.ToInt32(value);
}
return value;
}
public override string GetName(int i)
{
if (_hasTempId)
return i == 0 ? "__TempId" : _properties[i - 1].GetColumnName();
return _properties[i].GetColumnName();
}
public override bool IsDBNull(int i) => GetValue(i) is DBNull;
public override Type GetFieldType(int i)
{
IProperty property;
if (_hasTempId)
{
if (i == 0)
return typeof(int);
property = _properties[i - 1];
}
else
{
property = _properties[i];
}
if (property.ClrType.IsEnum)
{
string? columnType = property.GetColumnType();
bool storedAsString = columnType is not null &&
(columnType.Contains("varchar", StringComparison.OrdinalIgnoreCase) ||
columnType.Contains("nvarchar", StringComparison.OrdinalIgnoreCase) ||
columnType.Contains("nchar", StringComparison.OrdinalIgnoreCase) ||
columnType.Contains("char", StringComparison.OrdinalIgnoreCase));
if (storedAsString)
return typeof(string);
}
return Nullable.GetUnderlyingType(property.ClrType) ?? property.ClrType;
}
public override int GetOrdinal(string name)
{
if (_hasTempId && name == "__TempId")
return 0;
int offset = _hasTempId ? 1 : 0;
for (int i = 0; i < _properties.Count; i++)
{
if (_properties[i].GetColumnName() == name)
return i + offset;
}
throw new IndexOutOfRangeException($"Column '{name}' not found.");
}
public override bool GetBoolean(int i) => (bool)GetValue(i);
public override byte GetByte(int i) => (byte)GetValue(i);
public override long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferOffset, int length) => 0;
public override char GetChar(int i) => (char)GetValue(i);
public override long GetChars(int i, long fieldOffset, char[]? buffer, int bufferOffset, int length) => 0;
public override string GetDataTypeName(int i) => GetFieldType(i).Name;
public override DateTime GetDateTime(int i) => (DateTime)GetValue(i);
public override decimal GetDecimal(int i) => (decimal)GetValue(i);
public override double GetDouble(int i) => (double)GetValue(i);
public override float GetFloat(int i) => (float)GetValue(i);
public override Guid GetGuid(int i) => (Guid)GetValue(i);
public override short GetInt16(int i) => (short)GetValue(i);
public override int GetInt32(int i) => (int)GetValue(i);
public override long GetInt64(int i) => (long)GetValue(i);
public override string GetString(int i) => (string)GetValue(i);
public override int GetValues(object[] values)
{
int count = Math.Min(values.Length, FieldCount);
for (int i = 0; i < count; i++)
values[i] = GetValue(i);
return count;
}
public override object this[int i] => GetValue(i);
public override object this[string name] => GetValue(GetOrdinal(name));
public override int Depth => 0;
public override bool IsClosed => _closed;
public override bool HasRows => _entities.Count > 0;
public override int RecordsAffected => -1;
public override bool NextResult() => false;
public override void Close() => _closed = true;
public override DataTable GetSchemaTable() => throw new NotSupportedException();
public override Task<bool> ReadAsync(CancellationToken cancellationToken) => Task.FromResult(Read());
public override System.Collections.IEnumerator GetEnumerator()
{
return new DbEnumerator(this, closeReader: false);
}
}
private static string GenerateCreateTempTableSql(string tableName, List<IProperty> properties)
{
List<string> columns = ["[__TempId] INT NOT NULL"];
foreach (IProperty property in properties)
{
string columnName = property.GetColumnName();
string sqlType = GetSqlType(property);
string isNullable = property.IsNullable ? "NULL" : "NOT NULL";
columns.Add($"[{columnName}] {sqlType} {isNullable}");
}
return $"CREATE TABLE {tableName} ({string.Join(",", columns)})";
}
private static string GenerateInsertWithOutputSql(
string tempTable,
string schema,
string table,
List<IProperty> properties,
IProperty keyProperty)
{
List<string> columns = properties.Select(p => $"[{p.GetColumnName()}]").ToList();
List<string> sourceColumns = properties.Select(p => $"source.[{p.GetColumnName()}]").ToList();
string keyColumn = keyProperty.GetColumnName();
return $@"
MERGE INTO [{schema}].[{table}] AS target
USING {tempTable} AS source
ON 1 = 0
WHEN NOT MATCHED THEN
INSERT ({string.Join(",", columns)})
VALUES ({string.Join(",", sourceColumns)})
OUTPUT INSERTED.[{keyColumn}], source.__TempId;";
}
private static string GetSqlType(IProperty property)
{
RelationalTypeMapping? typeMapping = property.FindRelationalTypeMapping();
return typeMapping?.StoreType
?? throw new InvalidOperationException($"Could not determine SQL type for property '{property.Name}'.");
}
Use a resolver abstraction to unify the three parent-key sources:
ValueGenerated.Neverpublic sealed class ParentKeyResolver
{
public Func<object, object?>? GetKeyFromEntity { get; private set; }
public Dictionary<int, object>? IdMapping { get; private set; }
public Dictionary<object, int>? TempIds { get; private set; }
private ParentKeyResolver() { }
public static ParentKeyResolver FromDatabaseGenerated(Dictionary<int, object> mapping, Dictionary<object, int> tempIds)
=> new() { IdMapping = mapping, TempIds = tempIds };
public static ParentKeyResolver FromMemoryGenerated(Func<object, object?> getKeyFunc)
=> new() { GetKeyFromEntity = getKeyFunc };
public static ParentKeyResolver NoKey() => new();
}
The legacy skill did more than insert the root rows. The canonical skill must also teach the recursive child-graph workflow:
parentEntityType.GetNavigations().foreignKeyProperty = navigation.ForeignKey.Properties.First()childEntityType = navigation.TargetEntityTypechildKeyProperty = childEntityType.FindPrimaryKey()?.Properties.FirstOrDefault()navigation.IsCollection:
GetParentRealId(...).ResolveForeignKeyForChild(...) for every child.InsertChildEntitiesAsync(...), which repeats the same key strategy selection for the child entity type.private async Task ProcessNavigationsAsync(
List<object> parentEntities,
IEntityType parentEntityType,
ParentKeyResolver keyResolver,
int batchSize,
int bulkCopyTimeout,
CancellationToken cancellationToken)
{
List<INavigation> navigations = parentEntityType.GetNavigations().ToList();
foreach (INavigation navigation in navigations)
{
IProperty foreignKeyProperty = navigation.ForeignKey.Properties.First();
IEntityType childEntityType = navigation.TargetEntityType;
IKey? childKey = childEntityType.FindPrimaryKey();
IProperty? childKeyProperty = childKey?.Properties.FirstOrDefault();
if (navigation.IsCollection)
{
List<object> children = [];
foreach (object parent in parentEntities)
{
IEnumerable<object>? collection = navigation.PropertyInfo?.GetValue(parent) as IEnumerable<object>;
if (collection == null)
continue;
object? parentRealId = GetParentRealId(parent, keyResolver);
if (parentRealId == null)
continue;
foreach (object child in collection)
{
ResolveForeignKeyForChild(child, childEntityType, childKeyProperty, foreignKeyProperty, parentRealId);
children.Add(child);
}
}
if (children.Count > 0)
await InsertChildEntitiesAsync(children, childEntityType, batchSize, bulkCopyTimeout, cancellationToken);
}
else
{
List<object> children = [];
foreach (object parent in parentEntities)
{
object? child = navigation.PropertyInfo?.GetValue(parent);
if (child == null)
continue;
object? parentRealId = GetParentRealId(parent, keyResolver);
if (parentRealId == null)
continue;
ResolveForeignKeyForChild(child, childEntityType, childKeyProperty, foreignKeyProperty, parentRealId);
children.Add(child);
}
if (children.Count > 0)
await InsertChildEntitiesAsync(children, childEntityType, batchSize, bulkCopyTimeout, cancellationToken);
}
}
}
private void ResolveForeignKeyForChild(
object child,
IEntityType childEntityType,
IProperty? childKeyProperty,
IProperty foreignKeyProperty,
object parentRealId)
{
if (childKeyProperty is null)
return;
if (childKeyProperty.ValueGenerated == ValueGenerated.OnAdd)
foreignKeyProperty.PropertyInfo?.SetValue(child, parentRealId);
}
private object? GetParentRealId(object parent, ParentKeyResolver keyResolver)
{
if (keyResolver.IdMapping != null && keyResolver.TempIds != null)
{
if (keyResolver.TempIds.TryGetValue(parent, out int tempId))
return keyResolver.IdMapping[tempId];
}
if (keyResolver.GetKeyFromEntity != null)
return keyResolver.GetKeyFromEntity(parent);
return null;
}
InsertChildEntitiesAsync(...) must repeat the same routing logic used by the root insert:
BulkInsertHasNoKeyAsync(...)ValueGenerated.Never: BulkInsertValueGeneratedNeverAsync(...)ValueGenerated.OnAdd: BulkInsertWithTempTableAsync(...) plus generated-ID rehydration plus recursive navigation processingThis preserved recursive behavior is what lets the legacy implementation handle full parent/child graphs instead of flat tables only.
Legacy EntityDataReader behavior preserved here intentionally: column ordinal resolution, typed accessors, async read shim, and reader enumeration are part of the canonical implementation, not optional sample details.
await _unitOfWork.BeginTransactionAsync(cancellationToken);
try
{
List<object> entities = GenerateLargeDataset();
BulkInsertOperations bulkInsert = new(_dbContext);
int insertedCount = await bulkInsert.BulkInsertAsync(entities, cancellationToken);
await _unitOfWork.CommitTransactionAsync(cancellationToken);
}
catch
{
await _unitOfWork.RollbackTransactionAsync(cancellationToken);
throw;
}
| Rule | Explanation |
|---|---|
| Active transaction required | Always call BeginTransactionAsync before BulkInsertAsync |
| Use for 1000+ rows | Smaller batches should use standard EF flows |
| Keep batch size configurable | Default 10000, tune per workload |
| Keep timeout configurable | Default 600, tune for large imports |
| Keep temp-table cleanup guaranteed | Always drop the temp table in finally |
| Rehydrate generated IDs | Update in-memory entities before processing children |
| Preserve navigation recursion | Child collections and references must be bulk inserted too |
| Commit after success | CommitTransactionAsync() only after the full insert finishes |
| Roll back on failure | Never leave partial bulk writes committed |
// ❌ NEVER call bulk insert without an active transaction
await bulkInsert.BulkInsertAsync(entities, cancellationToken);
// ❌ NEVER replace ordinary CRUD paths with bulk APIs
await bulkInsert.BulkInsertAsync([singleProduct], cancellationToken);
// ❌ NEVER ignore enum storage differences when building the reader
return value; // wrong for string-backed enums