Skip to content

ComplexProperty ToJson(): SaveChangesAsync throws 'ordinal -1 is invalid' when nested sub-collection grows (2+ JSON columns) #38299

@tbertran

Description

@tbertran

Bug: ComplexProperty + ToJson() — SaveChangesAsync throws InvalidOperationException "ordinal '-1' is invalid" when nested sub-collection grows

Description

When an entity has 2+ ComplexProperty columns mapped with ToJson(), and the JSON structure within one column contains a collection of items where each item has 2+ List<T> sub-collection properties, growing one of those sub-collections (adding items between load and save) causes SaveChangesAsync to throw:

System.InvalidOperationException: Complex entry original ordinal '-1' is invalid for property
'XWidget.XDeepData.XMiddleData[]XDeepItem[]XInnerEntry.Inner' as it's outside of the collection of length '1'.

Regression?

This appears to have been introduced around EF Core 10. Related to #37724 (which fixed a different ordinal scenario involving state transitions), but our scenario is distinct — no DetachedModified transitions are involved.

Trigger conditions (all must hold)

  1. Entity has 2+ ComplexProperty(x => x.Prop, p => p.ToJson()) columns
  2. One JSON column has a nested object (wrapper) containing a List<T> collection
  3. Each item in that collection defines 2+ List<T> sub-collection properties
  4. Between load and save, one of those sub-collections grows (more items than originally loaded)

Production scenario

This occurs with an "upsert" pattern: load a tracked entity from the DB, map new incoming data onto it (via Mapperly/AutoMapper/manual property assignment), then call SaveChangesAsync. If the new data has more items in a nested sub-collection than what was originally loaded, the change tracker's ordinal calculation fails.

Steps to reproduce

Full standalone repro project attached as ZIP. Here's the self-contained code (also works as a console app): HERE

Program.cs — full repro
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;

var postgres = new PostgreSqlBuilder("postgres:17").Build();
await postgres.StartAsync();
Console.WriteLine($"PostgreSQL started. EF Core version: {typeof(DbContext).Assembly.GetName().Version}");

await using var context = new XDbContext(
    new DbContextOptionsBuilder<XDbContext>()
        .UseNpgsql(postgres.GetConnectionString())
        .Options);

var connection = context.Database.GetDbConnection();
await connection.OpenAsync();
using (var cmd = connection.CreateCommand())
{
    cmd.CommandText = """
        CREATE TABLE "XWidgets" (
            "Id" uuid NOT NULL PRIMARY KEY,
            "Flat" jsonb NOT NULL DEFAULT '{}',
            "Deep" jsonb NOT NULL DEFAULT '{}'
        )
        """;
    await cmd.ExecuteNonQueryAsync();
}

// Step 1: Insert entity with nested JSON collections
var widgetId = Guid.NewGuid();
context.XWidgets.Add(new XWidget
{
    Id = widgetId,
    Flat = new XFlatData { Value = "active" },
    Deep = new XDeepData
    {
        Middle = new XMiddleData
        {
            PropA = "section-1",
            PropB = "initial",
            Items =
            [
                new XDeepItem
                {
                    F1 = "item-1", F2 = "code-A", F3 = "v1",
                    Inner = [new XInnerEntry { Text = "alice@example.com", Flag = true }],
                    Others = [new XSecondEntry { Data = "note-1", Active = true }],
                }
            ],
        },
    },
});
await context.SaveChangesAsync();
context.ChangeTracker.Clear();

// Step 2: Load tracked entity
var tracked = await context.XWidgets.SingleAsync(w => w.Id == widgetId);

// Step 3: Replace Deep JSON — Inner grows from 1 to 2 items
tracked.Deep = new XDeepData
{
    Middle = new XMiddleData
    {
        PropA = "section-1",
        PropB = "updated",
        Items =
        [
            new XDeepItem
            {
                F1 = "item-1", F2 = "code-A", F3 = "v2",
                Inner =
                [
                    new XInnerEntry { Text = "alice@example.com", Flag = true },
                    new XInnerEntry { Text = "alice-work@example.com", Flag = false }, // ← GREW from 1 to 2
                ],
                Others = [new XSecondEntry { Data = "note-1", Active = true }],
            }
        ],
    },
};

// Step 4: SaveChangesAsync — CRASHES
try
{
    await context.SaveChangesAsync();
    Console.WriteLine("SUCCESS — no crash.");
}
catch (InvalidOperationException ex) when (ex.Message.Contains("ordinal"))
{
    Console.WriteLine($"BUG REPRODUCED: {ex.Message}");
}
finally
{
    await postgres.StopAsync();
}

// ═══ Model ═══

public class XWidget
{
    public Guid Id { get; set; }
    public XFlatData Flat { get; set; } = new();
    public XDeepData Deep { get; set; } = new();
}

public class XFlatData
{
    public string Value { get; set; } = "";
}

public class XDeepData
{
    public XMiddleData Middle { get; set; } = new();
}

public class XMiddleData
{
    public string PropA { get; set; } = "";
    public string PropB { get; set; } = "";
    public List<XDeepItem> Items { get; set; } = [];
}

public class XDeepItem
{
    public string F1 { get; set; } = "";
    public string F2 { get; set; } = "";
    public string F3 { get; set; } = "";
    public List<XInnerEntry> Inner { get; set; } = [];
    public List<XSecondEntry> Others { get; set; } = [];
}

public class XInnerEntry
{
    public string Text { get; set; } = "";
    public bool Flag { get; set; }
}

public class XSecondEntry
{
    public string Data { get; set; } = "";
    public bool Active { get; set; }
}

public class XDbContext(DbContextOptions<XDbContext> options) : DbContext(options)
{
    public DbSet<XWidget> XWidgets { get; set; } = null!;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<XWidget>(builder =>
        {
            builder.HasKey(e => e.Id);
            builder.ComplexProperty(e => e.Flat, s => s.ToJson());
            builder.ComplexProperty(e => e.Deep, s => s.ToJson());
        });
    }
}

Expected behavior

SaveChangesAsync should successfully persist the updated JSON column with the grown sub-collection.

Actual behavior

SaveChangesAsync throws:

System.InvalidOperationException: Complex entry original ordinal '-1' is invalid for property
'XWidget.XDeepData.XMiddleData[]XDeepItem[]XInnerEntry.Inner' as it's outside of the collection of length '1'.
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntryBase.InternalComplexCollectionEntry.ValidateOrdinal(InternalComplexEntry entry, Boolean original, List`1 entries)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntryBase.InternalComplexCollectionEntry.ValidateOrdinal(InternalComplexEntry entry, Boolean original)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntryBase.ValidateOrdinal(InternalComplexEntry entry, Boolean original)

Environment

  • EF Core: 10.0.7
  • Npgsql.EntityFrameworkCore.PostgreSQL: 10.0.1
  • Database: PostgreSQL 17
  • OS: Windows 11 / .NET 10.0
  • Target framework: net10.0

Notes

  • The bug requires at least 2 JSON columns on the entity — a single ComplexProperty + ToJson() column does NOT trigger the issue.
  • The item in the outer collection must have 2+ List<T> properties defined in its class — having only a single sub-collection does not crash.
  • The sibling sub-collection (Others) can be completely empty ([]) — what matters is that the type definition has 2+ collection properties.
  • Shrinking a collection or keeping it the same size does not crash — only growth triggers the bug.
  • This reproduces regardless of whether the JSON column is replaced via full property assignment or mutated in-place.

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions