From d6a07dbcf68c7aa8f87ea73b1127121819588440 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Fri, 21 Mar 2025 11:27:25 -0700 Subject: [PATCH 1/3] Mark entities as unchanged when matched by entities loaded from database Fixes #35762 --- src/EFCore/Internal/EntityFinder.cs | 1 - src/EFCore/Query/QueryContext.cs | 22 ++++-- .../LoadTestBase.cs | 11 +++ .../LoadExistingEntityStateSqliteTest.cs | 74 +++++++++++++++++++ 4 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 test/EFCore.Sqlite.FunctionalTests/LoadExistingEntityStateSqliteTest.cs diff --git a/src/EFCore/Internal/EntityFinder.cs b/src/EFCore/Internal/EntityFinder.cs index 2e199fb6d1b..6eed4e1d08a 100644 --- a/src/EFCore/Internal/EntityFinder.cs +++ b/src/EFCore/Internal/EntityFinder.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections; -using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; namespace Microsoft.EntityFrameworkCore.Internal; diff --git a/src/EFCore/Query/QueryContext.cs b/src/EFCore/Query/QueryContext.cs index 9c77405f5b0..c92bccddd04 100644 --- a/src/EFCore/Query/QueryContext.cs +++ b/src/EFCore/Query/QueryContext.cs @@ -114,12 +114,24 @@ public virtual void InitializeStateManager(bool standAlone = false) /// [EntityFrameworkInternal] public virtual InternalEntityEntry? TryGetEntry( - IKey key, - object[] keyValues, - bool throwOnNullKey, - out bool hasNullKey) + IKey key, + object[] keyValues, + bool throwOnNullKey, + out bool hasNullKey) + { // InitializeStateManager will populate the field before calling here - => _stateManager!.TryGetEntry(key, keyValues, throwOnNullKey, out hasNullKey); + var entry = _stateManager!.TryGetEntry(key, keyValues, throwOnNullKey, out hasNullKey); + + // An entity returned by a tracking query provably exists in the store, so an entry that is being tracked as + // Added (e.g. a new entity whose key matches an existing row, possibly created during an Add graph traversal) + // must be corrected to Unchanged. Otherwise it would be re-inserted, causing a duplicate key. See #35762. + if (entry is { EntityState: EntityState.Added }) + { + entry.SetEntityState(EntityState.Unchanged); + } + + return entry; + } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/test/EFCore.Specification.Tests/LoadTestBase.cs b/test/EFCore.Specification.Tests/LoadTestBase.cs index 1e2fa898084..2df5b78670f 100644 --- a/test/EFCore.Specification.Tests/LoadTestBase.cs +++ b/test/EFCore.Specification.Tests/LoadTestBase.cs @@ -1726,6 +1726,12 @@ public virtual async Task Load_collection_untyped(EntityState state, bool async) Assert.Equal(2, parent.Children.Count()); Assert.All(parent.Children.Select(e => e.Parent), c => Assert.Same(parent, c)); + var expectedChildState = state == EntityState.Detached ? EntityState.Detached : EntityState.Unchanged; + foreach(var child in parent.Children) + { + Assert.Equal(expectedChildState, context.Entry(child).State); + } + Assert.Equal(state == EntityState.Detached ? 0 : 3, context.ChangeTracker.Entries().Count()); } @@ -1868,9 +1874,14 @@ public virtual async Task Load_one_to_one_reference_to_dependent_untyped(EntityS var single = context.ChangeTracker.Entries().Single().Entity; + Assert.Equal(EntityState.Unchanged, context.Entry(single).State); Assert.Same(single, parent.Single); Assert.Same(parent, single.Parent); } + else + { + Assert.Equal(EntityState.Detached, navigationEntry.EntityEntry.State); + } } [Theory, InlineData(EntityState.Unchanged, true), InlineData(EntityState.Unchanged, false), diff --git a/test/EFCore.Sqlite.FunctionalTests/LoadExistingEntityStateSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/LoadExistingEntityStateSqliteTest.cs new file mode 100644 index 00000000000..b4e224512c5 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/LoadExistingEntityStateSqliteTest.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Data.Sqlite; + +namespace Microsoft.EntityFrameworkCore; + +public class LoadExistingEntityStateSqliteTest +{ + [Fact] // Issue #35762 + public void Load_collection_marks_tracked_Added_member_that_exists_in_store_as_Unchanged() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + + int municipalityId; + using (var context = new LoadContext(connection)) + { + context.Database.EnsureCreated(); + var municipality = new Municipality + { + Name = "M1", + Residences = [new ChildResidence { Id = 1 }, new ChildResidence { Id = 2 }] + }; + context.Add(municipality); + context.SaveChanges(); + municipalityId = municipality.Id; + } + + using (var context = new LoadContext(connection)) + { + var municipality = context.Set().Single(); + + // A residence that already exists in the store is incorrectly tracked as Added. + var existing = new ChildResidence { Id = 1, MunicipalityId = municipalityId }; + context.Add(existing); + + var collectionEntry = context.Entry(municipality).Collection(m => m.Residences); + Assert.False(collectionEntry.IsLoaded); + Assert.Equal(EntityState.Added, context.Entry(existing).State); + + collectionEntry.Load(); + + // The load query returned the residence, proving it exists in the store, so it is now Unchanged. + Assert.Equal(EntityState.Unchanged, context.Entry(existing).State); + } + } + + private class LoadContext(SqliteConnection connection) : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseSqlite(connection); + + public DbSet Municipalities + => Set(); + + public DbSet ChildResidences + => Set(); + } + + private class Municipality + { + public int Id { get; set; } + public string Name { get; set; } = null!; + public List Residences { get; set; } = null!; + } + + private class ChildResidence + { + public int Id { get; set; } + public int MunicipalityId { get; set; } + public Municipality Municipality { get; set; } = null!; + } +} From c9b232d80612689c91c824b5e87835c319b93e69 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 17 Jun 2026 16:54:49 -0700 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test/EFCore.Specification.Tests/LoadTestBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/EFCore.Specification.Tests/LoadTestBase.cs b/test/EFCore.Specification.Tests/LoadTestBase.cs index 2df5b78670f..d386677e349 100644 --- a/test/EFCore.Specification.Tests/LoadTestBase.cs +++ b/test/EFCore.Specification.Tests/LoadTestBase.cs @@ -1727,7 +1727,7 @@ public virtual async Task Load_collection_untyped(EntityState state, bool async) Assert.All(parent.Children.Select(e => e.Parent), c => Assert.Same(parent, c)); var expectedChildState = state == EntityState.Detached ? EntityState.Detached : EntityState.Unchanged; - foreach(var child in parent.Children) + foreach (var child in parent.Children) { Assert.Equal(expectedChildState, context.Entry(child).State); } From 8d2d3100c23a59578135de86179d304e8325819f Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 17 Jun 2026 16:55:47 -0700 Subject: [PATCH 3/3] Add using directive for ChangeTracking.Internal --- src/EFCore/Internal/EntityFinder.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/EFCore/Internal/EntityFinder.cs b/src/EFCore/Internal/EntityFinder.cs index 6eed4e1d08a..2e199fb6d1b 100644 --- a/src/EFCore/Internal/EntityFinder.cs +++ b/src/EFCore/Internal/EntityFinder.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections; +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; namespace Microsoft.EntityFrameworkCore.Internal;