Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

List content of nested OwnsMany dropped on owned entity change #34965

Open
davidferneding opened this issue Oct 23, 2024 · 1 comment
Open

List content of nested OwnsMany dropped on owned entity change #34965

davidferneding opened this issue Oct 23, 2024 · 1 comment

Comments

@davidferneding
Copy link

I recently noticed an issue with seemingly non-related data getting lost on changes to owned entities.

We have an aggregate with several owned entities. Some of these owned entities own other nested entities, and in one case, the owned entity owns a list of nested entities. The code snippet below shows a minimal example of this configuration. When the owned entity is updated and the nested entity list is unchanged, the list is always empty after saving the changes to the database.

The owned entity is properly updated before saving the changes, the list content is only lost when SaveChanges is called. Non-List onwed entities (eg. x.OwnsOne(..., y => y.OwnsOne(...))) are not affected by this issue.

I'm happy to help, if there is any additional information you might need to analyse this issue. I didn't see any issues or documentation mentioning this, if this is known or expected/intended behaviour, please just let me know.

Version information

EF Core version: Tested with 8.0.10 and 8.0.5
Database provider: Tested with PostgreSQL and SQLite
Target framework: .NET 8.0
Operating system: Tested on Windows 11 and macOS 15, and in aspnet Docker image

Code

Config - Minimal reproducible example

public class DemoContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite($"Data Source={Path.Join(AppDomain.CurrentDomain.BaseDirectory, "demo.db")}");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new ContactEntityTypeConfiguration());
    }
}

public class ContactEntityTypeConfiguration : IEntityTypeConfiguration<Contact>
{
    public void Configure(EntityTypeBuilder<Contact> builder)
    {
        builder.ToTable("Contacts");
        builder.HasKey(x => x.Id);

        builder.OwnsOne(x => x.CompanyInfo, companyInfo =>
        {
            companyInfo.Property(x => x.Name).IsRequired();
            companyInfo.OwnsMany(x => x.Tags, tags =>
            {
                tags.WithOwner().HasForeignKey("ContactId");

                tags.Property<int>("Id").ValueGeneratedOnAdd();
                tags.HasKey("Id");

                tags.Property(x => x.Name).IsRequired();
            });
        });
    }
}

public sealed class Contact // aggregate
{
    public Guid Id { get; init; }
    public CompanyInfo? CompanyInfo { get; private set; }

    public void AddCompanyInfo(CompanyInfo companyInfo)
    {
        CompanyInfo = companyInfo;
    }

    public void ChangeCompanyName(string companyName)
    {
        if (CompanyInfo is null) return;
        CompanyInfo = CompanyInfo with { Name = companyName };
    }
}

public record CompanyInfo // owned entity
{
    public required string Name { get; init; }
    public required List<Tag> Tags { get; init; }
}

public record Tag // nested owned entity
{
    public required string Name { get; init; }
}

Demo Console App

class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("1: Testing without writing data to db");
        TestWithoutDb();

        Console.WriteLine("\n---\n");

        Console.WriteLine("2: Testing with writing data to db");
        await TestWithDb();
    }

    private static async Task TestWithDb()
    {
        await using var db = new DemoContext();
        await db.Database.EnsureCreatedAsync();

        Console.WriteLine("Creating a test contact.");

        var contact = new Contact()
        {
            Id = Guid.NewGuid(),
        };
        var companyInfo = new CompanyInfo() { Name = "CompanyName", Tags = [new Tag() { Name = "Tag1" }] };
        contact.AddCompanyInfo(companyInfo);

        await db.AddAsync(contact);
        await db.SaveChangesAsync();

        Console.WriteLine("Created test contact. Data before update:");
        Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(contact));

        Console.WriteLine("Updating the test contact.");

        contact.ChangeCompanyName("ChangedCompanyName");
        await db.SaveChangesAsync();

        Console.WriteLine("Updated the test contact. Data after update:");
        Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(contact));

        Console.WriteLine("Deleting the test contact.");

        db.Remove(contact);
        await db.SaveChangesAsync();
    }

    private static void TestWithoutDb()
    {
        Console.WriteLine("Creating a test contact.");

        var contact = new Contact()
        {
            Id = Guid.NewGuid(),
        };
        var companyInfo = new CompanyInfo() { Name = "CompanyName", Tags = [new Tag() { Name = "Tag1" }] };
        contact.AddCompanyInfo(companyInfo);

        Console.WriteLine("Created test contact. Data before update:");
        Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(contact));

        Console.WriteLine("Updating the test contact.");

        contact.ChangeCompanyName("ChangedCompanyName");

        Console.WriteLine("Updated the test contact. Data after update:");
        Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(contact));
    }
}

Sample output

Tags content is dropped when writing to database.

1: Testing without writing data to db
Creating a test contact.
Created test contact. Data before update:
{"Id":"1b6a4e9b-90c9-49c4-a575-480ce4557edc","CompanyInfo":{"Name":"CompanyName","Tags":[{"Name":"Tag1"}]}}
Updating the test contact.
Updated the test contact. Data after update:
{"Id":"1b6a4e9b-90c9-49c4-a575-480ce4557edc","CompanyInfo":{"Name":"ChangedCompanyName","Tags":[{"Name":"Tag1"}]}}

---

2: Testing with writing data to db
Creating a test contact.
Created test contact. Data before update:
{"Id":"5136e52f-f554-4485-a08a-d8a695333dc1","CompanyInfo":{"Name":"CompanyName","Tags":[{"Name":"Tag1"}]}}
Updating the test contact.
Updated the test contact. Data after update:
{"Id":"5136e52f-f554-4485-a08a-d8a695333dc1","CompanyInfo":{"Name":"ChangedCompanyName","Tags":[]}}
Deleting the test contact.

Process finished with exit code 0.
@davidferneding
Copy link
Author

Additional info: A workaround for this is to explicitly create a new list and copying the content:

    public void ChangeCompanyName(string companyName)
    {
        if (CompanyInfo is null) return;
        CompanyInfo = CompanyInfo with
        {
            Name = companyName,
            Tags = CompanyInfo.Tags.Select(x => new Tag() { Name = x.Name }).ToList()
        };
    }

Reusing the old list does not work, the object reference needs to change:

    public void ChangeCompanyName(string companyName)
    {
        if (CompanyInfo is null) return;
        CompanyInfo = CompanyInfo with
        {
            Name = companyName,
            Tags = CompanyInfo.Tags // doesn't work
        };
    }

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

No branches or pull requests

4 participants