Skip to content

BulkInsertAsync: TableName/Schema override is not applied to separately mapped owned entities (child rows go to the model's table) #76

Closed

Description

@pengweiqhca

Summary

IBulkInsertOptions.TableName / Schema redirect the parent table only. Owned entities that are mapped to their own table (OwnsOne(...).ToTable(...)) are always bulk-copied into the table name taken from the EF model, so parent and child rows can end up in different places. There is no option to redirect the child table.

If the model's child table exists, the child rows are written there silently (wrong data, no exception). If it does not exist, the parent rows have already been written and the child insert throws (partial write).

Environment

  • Thinktecture.EntityFrameworkCore.SqlServer 10.4.0
  • Microsoft.EntityFrameworkCore.SqlServer 10.0.9
  • SQL Server (container, mcr.microsoft.com/mssql/server), .NET 10

Repro

public class Order
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public OrderLine Line { get; set; } = new();
}

public class OrderLine
{
    public int Qty { get; set; }
}

public class Db(string connectionString) : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder options) =>
        options.UseSqlServer(connectionString, o => o.AddBulkOperationSupport());

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<Order>(b =>
        {
            b.ToTable("Orders");
            b.HasKey(x => x.Id);
            b.Property(x => x.Id).ValueGeneratedNever();
            b.OwnsOne(x => x.Line, o => o.ToTable("OrderLines"));   // separate table
        });
}
CREATE TABLE Orders            (Id int NOT NULL PRIMARY KEY, Name nvarchar(50) NULL);
CREATE TABLE OrderLines        (OrderId int NOT NULL PRIMARY KEY, Qty int NOT NULL);
CREATE TABLE Orders_Archive    (Id int NOT NULL PRIMARY KEY, Name nvarchar(50) NULL);
CREATE TABLE OrderLines_Archive(OrderId int NOT NULL PRIMARY KEY, Qty int NOT NULL);
await db.BulkInsertAsync(
    [new Order { Id = 1, Name = "o1", Line = new() { Qty = 5 } }],
    new SqlServerBulkInsertOptions { TableName = "Orders_Archive" });

Expected

Both parent and child rows go to the redirected tables (or there is a way to redirect the child table as well), or an exception is thrown if the override cannot be applied consistently.

Actual

Orders               rows = 0
OrderLines           rows = 1   <- child row written to the model's table
Orders_Archive       rows = 1
OrderLines_Archive   rows = 0

No exception. If OrderLines does not exist, the child SqlBulkCopy fails with Invalid object name after the parent row has been written.

Cause

SqlServerBulkOperationExecutor.BulkInsertSeparatedOwnedEntitiesAsync reads the child table straight from the metadata and ignores the options:

var childTableName = childContext.EntityType.GetTableName()
                     ?? throw new InvalidOperationException($"The entity '{childContext.EntityType.Name}' has no table name.");

numberOfInsertedRows += await BulkInsertAsync(childContext.Entities,
                                              childContext.EntityType.GetSchema(),
                                              childTableName,
                                              childContext,
                                              cancellationToken);

The parent goes through BulkOperationModelExtensions.GetEntityType(model, options.TableName, options.Schema), the children do not. Schema has the same problem. From reading the source, the Npgsql and Sqlite executors use the same pattern (childContext.EntityType.GetTableName()); I only ran the SQL Server one.

Why this matters

The table name is only known at run time in several setups (table-per-tenant, sharded tables, archive tables). SqlBulkCopy does not go through EF Core command interceptors, so the only hook is the TableName option, and it does not reach the child tables.

Suggestions

  1. Minimum: fail fast. If TableName or Schema is set and the entity type has separately mapped owned types, throw instead of silently writing the children to another table.

  2. Better: a way to redirect child tables as well, e.g. a resolver on the options that is used for the parent and for every owned child, which would also cover the temp tables of the MERGE operations:

    Func<IEntityType, (string? Schema, string TableName)>? TableNameResolver { get; set; }

Workaround

Do not use separately mapped owned types with BulkInsertAsync when overriding the table name. Model the child as a regular entity and call BulkInsertAsync once per entity type, each with its own TableName, inside a transaction.

Activity

  1. PawelGerr commented on Oct 12, 2026

    @PawelGerr
    Owner

    Thanks for the detailed report. The fix is available in the pre-release 10.5.0-beta01.

    The new option TableNameProvider (IBulkOperationTableNameProvider) is called for the root entity type and for every owned type that is mapped to a separate table, so parent and child rows go to the redirected tables. IBulkOperationTableNameProvider.Create(entityType => ...) covers the delegate-based variant from your suggestion. TableName and Schema are obsolete now and throw when the entity has owned types in separate tables.

    The release notes list the breaking changes and the migration steps. I would appreciate it if you could verify the beta with your table-per-tenant and archive scenarios before I publish 10.5.0.

  2. pengweiqhca commented on Oct 13, 2026

    @pengweiqhca
    Author

    Thanks! Verified and working fine.

  3. PawelGerr commented on Oct 14, 2026

    @PawelGerr
    Owner

    10.5.0 is released

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions