From 3441a89f08270858caf659b990e8eb9a1a7931dc Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Thu, 4 Jun 2026 18:43:05 -0700 Subject: [PATCH] Add IUpdateAdapter.CreateEntry overload that works with non-collection complex properties Fixes #37846 --- .../Storage/Internal/CosmosDatabaseCreator.cs | 11 +- .../Storage/Internal/InMemoryStore.cs | 16 +- .../ChangeTracking/Internal/IStateManager.cs | 9 + .../Internal/InternalEntityEntry.cs | 70 ++++++++ .../ChangeTracking/Internal/StateManager.cs | 18 ++ src/EFCore/EFCore.baseline.json | 6 +- src/EFCore/Update/IUpdateAdapter.cs | 14 ++ src/EFCore/Update/Internal/UpdateAdapter.cs | 14 ++ .../Internal/InternalEntityEntryTestBase.cs | 48 +++++- .../Internal/StateManagerTest.cs | 157 ++++++++++++++++++ .../TestUtilities/FakeStateManager.cs | 3 + 11 files changed, 360 insertions(+), 6 deletions(-) diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseCreator.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseCreator.cs index 8e80d1e6030..4c638600b78 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseCreator.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseCreator.cs @@ -213,7 +213,16 @@ private IUpdateAdapter AddModelData() foreach (var targetSeed in entityType.GetSeedData()) { var runtimeEntityType = updateAdapter.Model.FindEntityType(entityType.Name)!; - var entry = updateAdapter.CreateEntry(targetSeed, runtimeEntityType); + var values = new Dictionary(); + foreach (var (name, value) in targetSeed) + { + if (runtimeEntityType.FindProperty(name) is { } property) + { + values[property] = value; + } + } + + var entry = updateAdapter.CreateEntry(values, runtimeEntityType); entry.EntityState = EntityState.Added; } } diff --git a/src/EFCore.InMemory/Storage/Internal/InMemoryStore.cs b/src/EFCore.InMemory/Storage/Internal/InMemoryStore.cs index b05f0eb85d4..250e576421a 100644 --- a/src/EFCore.InMemory/Storage/Internal/InMemoryStore.cs +++ b/src/EFCore.InMemory/Storage/Internal/InMemoryStore.cs @@ -74,7 +74,7 @@ public virtual bool EnsureCreated( foreach (var targetSeed in entityType.GetSeedData()) { targetEntityType ??= updateAdapter.Model.FindEntityType(entityType.Name)!; - var entry = updateAdapter.CreateEntry(targetSeed, targetEntityType); + var entry = updateAdapter.CreateEntry(GetValues(targetSeed, targetEntityType), targetEntityType); entry.EntityState = EntityState.Added; entries.Add(entry); } @@ -87,6 +87,20 @@ public virtual bool EnsureCreated( } } + private static Dictionary GetValues(IDictionary seed, IEntityType entityType) + { + var values = new Dictionary(); + foreach (var (name, value) in seed) + { + if (entityType.FindProperty(name) is { } property) + { + values[property] = value; + } + } + + return values; + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/ChangeTracking/Internal/IStateManager.cs b/src/EFCore/ChangeTracking/Internal/IStateManager.cs index 36f6fe46cc4..c7b3187de64 100644 --- a/src/EFCore/ChangeTracking/Internal/IStateManager.cs +++ b/src/EFCore/ChangeTracking/Internal/IStateManager.cs @@ -73,8 +73,17 @@ public interface IStateManager : IResettableService /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// + [Obsolete("Use the overload that accepts a dictionary keyed by " + nameof(IProperty) + " instead.")] InternalEntityEntry CreateEntry(IDictionary values, IEntityType entityType); + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + InternalEntityEntry CreateEntry(IReadOnlyDictionary values, IEntityType entityType); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs b/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs index 7f56a25178c..b30865ec382 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs @@ -57,6 +57,7 @@ public InternalEntityEntry( /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// + [Obsolete("Use the overload that accepts a dictionary keyed by " + nameof(IProperty) + " instead.")] public InternalEntityEntry( IStateManager stateManager, IEntityType entityType, @@ -83,6 +84,75 @@ public InternalEntityEntry( new MaterializationContext(new ValueBuffer(valuesArray), stateManager.Context)); } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public InternalEntityEntry( + IStateManager stateManager, + IEntityType entityType, + IReadOnlyDictionary values, + IStructuralTypeMaterializerSource entityMaterializerSource) + : base((IRuntimeTypeBase)entityType, GetShadowValues(entityType, values)) + { + StateManager = stateManager; + var valuesArray = new object?[EntityType.PropertyCount]; + foreach (var property in entityType.GetFlattenedProperties()) + { + var index = property.GetIndex(); + if (index >= 0) + { + valuesArray[index] = property.Sentinel; + } + } + + foreach (var (property, value) in values) + { + var index = property.GetIndex(); + if (index < 0) + { + continue; + } + + if (!property.DeclaringType.ContainingEntityType.IsAssignableFrom(entityType)) + { + throw new InvalidOperationException( + CoreStrings.PropertyDoesNotBelong( + property.Name, + property.DeclaringType.ContainingEntityType.DisplayName(), + entityType.DisplayName())); + } + + valuesArray[index] = value; + } + + Entity = entityType.GetOrCreateMaterializer(entityMaterializerSource)( + new MaterializationContext(new ValueBuffer(valuesArray), stateManager.Context)); + } + + private static IDictionary GetShadowValues(IEntityType entityType, IReadOnlyDictionary values) + { + var shadowValues = new Dictionary(); + if (((IRuntimeTypeBase)entityType).ShadowPropertyCount == 0) + { + return shadowValues; + } + + // Shadow values are keyed by name because shadow properties are unique by name on an entity type; + // shadow properties on complex types are not supported (see #35613). + foreach (var (property, value) in values) + { + if (property.IsShadowProperty()) + { + shadowValues[property.Name] = value; + } + } + + return shadowValues; + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/ChangeTracking/Internal/StateManager.cs b/src/EFCore/ChangeTracking/Internal/StateManager.cs index 9a5a169ef1b..1a6e473e476 100644 --- a/src/EFCore/ChangeTracking/Internal/StateManager.cs +++ b/src/EFCore/ChangeTracking/Internal/StateManager.cs @@ -272,7 +272,25 @@ public virtual InternalEntityEntry GetOrCreateEntry(object entity, IEntityType? /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// + [Obsolete("Use the overload that accepts a dictionary keyed by " + nameof(IProperty) + " instead.")] public virtual InternalEntityEntry CreateEntry(IDictionary values, IEntityType entityType) + { +#pragma warning disable CS0618 // Type or member is obsolete + var entry = new InternalEntityEntry(this, entityType, values, EntityMaterializerSource); +#pragma warning restore CS0618 // Type or member is obsolete + + UpdateReferenceMaps(entry, EntityState.Detached, null); + + return entry; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual InternalEntityEntry CreateEntry(IReadOnlyDictionary values, IEntityType entityType) { var entry = new InternalEntityEntry(this, entityType, values, EntityMaterializerSource); diff --git a/src/EFCore/EFCore.baseline.json b/src/EFCore/EFCore.baseline.json index 0b10aa510e8..21348f41541 100644 --- a/src/EFCore/EFCore.baseline.json +++ b/src/EFCore/EFCore.baseline.json @@ -16507,7 +16507,11 @@ "Member": "void CascadeDelete(Microsoft.EntityFrameworkCore.Update.IUpdateEntry entry, System.Collections.Generic.IEnumerable? foreignKeys = null);" }, { - "Member": "Microsoft.EntityFrameworkCore.Update.IUpdateEntry CreateEntry(System.Collections.Generic.IDictionary values, Microsoft.EntityFrameworkCore.Metadata.IEntityType entityType);" + "Member": "Microsoft.EntityFrameworkCore.Update.IUpdateEntry CreateEntry(System.Collections.Generic.IDictionary values, Microsoft.EntityFrameworkCore.Metadata.IEntityType entityType);", + "Stage": "Obsolete" + }, + { + "Member": "Microsoft.EntityFrameworkCore.Update.IUpdateEntry CreateEntry(System.Collections.Generic.IReadOnlyDictionary values, Microsoft.EntityFrameworkCore.Metadata.IEntityType entityType);" }, { "Member": "void DetectChanges();" diff --git a/src/EFCore/Update/IUpdateAdapter.cs b/src/EFCore/Update/IUpdateAdapter.cs index 514dd3bdcf8..4df1affe470 100644 --- a/src/EFCore/Update/IUpdateAdapter.cs +++ b/src/EFCore/Update/IUpdateAdapter.cs @@ -117,8 +117,22 @@ public interface IUpdateAdapter /// A dictionary of property names to values. /// The entity type. /// The created entry. + [Obsolete("Use the overload that accepts a dictionary keyed by " + nameof(IProperty) + " instead.")] IUpdateEntry CreateEntry(IDictionary values, IEntityType entityType); + /// + /// Creates a new entry with the given property values for the given entity type. + /// + /// + /// Keying the values by rather than by name allows distinct values to be + /// supplied for multiple complex properties that share the same complex type. Complex collections + /// are not supported by this overload. + /// + /// A dictionary of properties to values. + /// The entity type. + /// The created entry. + IUpdateEntry CreateEntry(IReadOnlyDictionary values, IEntityType entityType); + /// /// The model with which the data is associated. /// diff --git a/src/EFCore/Update/Internal/UpdateAdapter.cs b/src/EFCore/Update/Internal/UpdateAdapter.cs index f9271ba69ec..d2bf7eaf2cc 100644 --- a/src/EFCore/Update/Internal/UpdateAdapter.cs +++ b/src/EFCore/Update/Internal/UpdateAdapter.cs @@ -147,9 +147,23 @@ public virtual IList GetEntriesToSave() /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// + [Obsolete("Use the overload that accepts a dictionary keyed by " + nameof(IProperty) + " instead.")] public virtual IUpdateEntry CreateEntry( IDictionary values, IEntityType entityType) +#pragma warning disable CS0618 // Type or member is obsolete + => _stateManager.CreateEntry(values, entityType); +#pragma warning restore CS0618 // Type or member is obsolete + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual IUpdateEntry CreateEntry( + IReadOnlyDictionary values, + IEntityType entityType) => _stateManager.CreateEntry(values, entityType); /// diff --git a/test/EFCore.Tests/ChangeTracking/Internal/InternalEntityEntryTestBase.cs b/test/EFCore.Tests/ChangeTracking/Internal/InternalEntityEntryTestBase.cs index f414f5a9a84..8b99a451ec9 100644 --- a/test/EFCore.Tests/ChangeTracking/Internal/InternalEntityEntryTestBase.cs +++ b/test/EFCore.Tests/ChangeTracking/Internal/InternalEntityEntryTestBase.cs @@ -424,31 +424,73 @@ public virtual void Can_get_property_value_after_creation_from_value_buffer() var stateManager = context.GetService(); var entityType = context.Model.FindEntityType(typeof(TSomeEntity)); + var keyProperty = entityType.FindProperty("Id"); + var property = entityType.FindProperty("Name"); + var entry = stateManager.CreateEntry( - new Dictionary { { "Id", 1 }, { "Name", "Kool" } }, + new Dictionary { { keyProperty, 1 }, { property, "Kool" } }, + entityType + ); + + Assert.Equal(1, entry[keyProperty]); + Assert.Equal("Kool", entry[property]); + } + + [Fact] + public virtual void Can_set_property_value_after_creation_from_value_buffer() + { + using var context = new TKContext(); + var stateManager = context.GetService(); + var entityType = context.Model.FindEntityType(typeof(TSomeEntity)); + + var keyProperty = entityType.FindProperty("Id"); + var nameProperty = entityType.FindProperty("Name"); + + var entry = stateManager.CreateEntry( + new Dictionary { { keyProperty, 1 }, { nameProperty, "Kool" } }, entityType ); + entry[nameProperty] = "Mule"; + + Assert.Equal("Mule", entry[nameProperty]); + } + + [Fact] + [Obsolete("Tests the obsolete name-keyed CreateEntry overload.")] + public virtual void Can_get_property_value_after_creation_from_value_buffer_using_property_names() + { + using var context = new TKContext(); + var stateManager = context.GetService(); + var entityType = context.Model.FindEntityType(typeof(TSomeEntity)); + var keyProperty = entityType.FindProperty("Id"); var property = entityType.FindProperty("Name"); + var entry = stateManager.CreateEntry( + new Dictionary { { "Id", 1 }, { "Name", "Kool" } }, + entityType + ); + Assert.Equal(1, entry[keyProperty]); Assert.Equal("Kool", entry[property]); } [Fact] - public virtual void Can_set_property_value_after_creation_from_value_buffer() + [Obsolete("Tests the obsolete name-keyed CreateEntry overload.")] + public virtual void Can_set_property_value_after_creation_from_value_buffer_using_property_names() { using var context = new TKContext(); var stateManager = context.GetService(); var entityType = context.Model.FindEntityType(typeof(TSomeEntity)); + var nameProperty = entityType.FindProperty("Name"); + var entry = stateManager.CreateEntry( new Dictionary { { "Id", 1 }, { "Name", "Kool" } }, entityType ); - var nameProperty = entityType.FindProperty("Name"); entry[nameProperty] = "Mule"; Assert.Equal("Mule", entry[nameProperty]); diff --git a/test/EFCore.Tests/ChangeTracking/Internal/StateManagerTest.cs b/test/EFCore.Tests/ChangeTracking/Internal/StateManagerTest.cs index af0570038e4..c6fbbc44813 100644 --- a/test/EFCore.Tests/ChangeTracking/Internal/StateManagerTest.cs +++ b/test/EFCore.Tests/ChangeTracking/Internal/StateManagerTest.cs @@ -971,6 +971,126 @@ public void Does_not_throws_when_instance_of_unmapped_derived_type_is_used() private static IStateManager CreateStateManager(IModel model) => InMemoryTestHelpers.Instance.CreateContextServices(model).GetRequiredService(); + [Fact] + public void CreateEntry_distinguishes_complex_properties_that_share_a_complex_type() + { + var model = BuildModelWithDuplicateComplexType(); + var stateManager = CreateStateManager(model); + var entityType = model.FindEntityType(typeof(Place))!; + + var id = entityType.FindProperty(nameof(Place.Id))!; + var note = entityType.FindProperty("Note")!; + var origin = entityType.FindComplexProperty(nameof(Place.Origin))!; + var destination = entityType.FindComplexProperty(nameof(Place.Destination))!; + var originLatitude = origin.ComplexType.FindProperty(nameof(Coordinate.Latitude))!; + var originLongitude = origin.ComplexType.FindProperty(nameof(Coordinate.Longitude))!; + var destinationLatitude = destination.ComplexType.FindProperty(nameof(Coordinate.Latitude))!; + var destinationLongitude = destination.ComplexType.FindProperty(nameof(Coordinate.Longitude))!; + + var entry = stateManager.CreateEntry( + new Dictionary + { + { id, 1 }, + { note, "Somewhere" }, + { originLatitude, 11 }, + { originLongitude, 12 }, + { destinationLatitude, 21 }, + { destinationLongitude, 22 } + }, + entityType); + + Assert.Equal("Somewhere", entry[note]); + Assert.Equal(11, entry[originLatitude]); + Assert.Equal(12, entry[originLongitude]); + Assert.Equal(21, entry[destinationLatitude]); + Assert.Equal(22, entry[destinationLongitude]); + + var place = (Place)entry.Entity; + Assert.Equal(11, place.Origin.Latitude); + Assert.Equal(12, place.Origin.Longitude); + Assert.Equal(21, place.Destination.Latitude); + Assert.Equal(22, place.Destination.Longitude); + } + + [Fact] + public void CreateEntry_uses_sentinel_for_unsupplied_value_type_properties() + { + var model = BuildModelWithDuplicateComplexType(); + var stateManager = CreateStateManager(model); + var entityType = model.FindEntityType(typeof(Place))!; + + var id = entityType.FindProperty(nameof(Place.Id))!; + var origin = entityType.FindComplexProperty(nameof(Place.Origin))!; + var originLatitude = origin.ComplexType.FindProperty(nameof(Coordinate.Latitude))!; + var originLongitude = origin.ComplexType.FindProperty(nameof(Coordinate.Longitude))!; + + // Supply only the key and one coordinate component. The unsupplied non-nullable value-type + // properties must fall back to their sentinel (default) values rather than null, which would + // otherwise cause an invalid cast during materialization. + var entry = stateManager.CreateEntry( + new Dictionary { { id, 1 }, { originLatitude, 11 } }, + entityType); + + Assert.Equal(1, entry[id]); + Assert.Equal(11, entry[originLatitude]); + Assert.Equal(originLongitude.Sentinel, entry[originLongitude]); + + var place = (Place)entry.Entity; + Assert.Equal(11, place.Origin.Latitude); + Assert.Equal(0, place.Origin.Longitude); + Assert.Equal(0, place.Destination.Latitude); + Assert.Equal(0, place.Destination.Longitude); + } + + [Fact] + public void CreateEntry_throws_for_property_from_another_entity_type() + { + var model = BuildModelWithDuplicateComplexType(); + var stateManager = CreateStateManager(model); + var entityType = model.FindEntityType(typeof(Place))!; + var otherEntityType = model.FindEntityType(typeof(Location))!; + + var id = entityType.FindProperty(nameof(Place.Id))!; + var foreignProperty = otherEntityType.FindProperty(nameof(Location.Planet))!; + + Assert.Equal( + CoreStrings.PropertyDoesNotBelong( + foreignProperty.Name, otherEntityType.DisplayName(), entityType.DisplayName()), + Assert.Throws( + () => stateManager.CreateEntry( + new Dictionary + { + { id, 1 }, + { foreignProperty, "Mars" } + }, + entityType)).Message); + } + + [Fact] + public void CreateEntry_throws_for_complex_property_from_another_entity_type() + { + var model = BuildModelWithDuplicateComplexType(); + var stateManager = CreateStateManager(model); + var entityType = model.FindEntityType(typeof(Place))!; + var otherEntityType = model.FindEntityType(typeof(Pin))!; + + var id = entityType.FindProperty(nameof(Place.Id))!; + var otherCoordinate = otherEntityType.FindComplexProperty(nameof(Pin.At))!; + var foreignLatitude = otherCoordinate.ComplexType.FindProperty(nameof(Coordinate.Latitude))!; + + Assert.Equal( + CoreStrings.PropertyDoesNotBelong( + foreignLatitude.Name, otherEntityType.DisplayName(), entityType.DisplayName()), + Assert.Throws( + () => stateManager.CreateEntry( + new Dictionary + { + { id, 1 }, + { foreignLatitude, 11 } + }, + entityType)).Message); + } + public class Widget { public int Id { get; set; } @@ -1009,6 +1129,25 @@ private class Location public string Planet { get; set; } } + private class Place + { + public int Id { get; set; } + public Coordinate Origin { get; set; } = null!; + public Coordinate Destination { get; set; } = null!; + } + + private class Pin + { + public int Id { get; set; } + public Coordinate At { get; set; } = null!; + } + + private class Coordinate + { + public int Latitude { get; set; } + public int Longitude { get; set; } + } + private static IModel BuildModel() { var builder = InMemoryTestHelpers.Instance.CreateConventionBuilder(); @@ -1030,4 +1169,22 @@ private static IModel BuildModel() return builder.Model.FinalizeModel(); } + + private static IModel BuildModelWithDuplicateComplexType() + { + var builder = InMemoryTestHelpers.Instance.CreateConventionBuilder(); + + builder.Entity(b => + { + b.Property("Note"); + b.ComplexProperty(e => e.Origin); + b.ComplexProperty(e => e.Destination); + }); + + builder.Entity(b => b.ComplexProperty(e => e.At)); + + builder.Entity(); + + return builder.Model.FinalizeModel(); + } } diff --git a/test/EFCore.Tests/TestUtilities/FakeStateManager.cs b/test/EFCore.Tests/TestUtilities/FakeStateManager.cs index b3556b00e46..d07fe29f226 100644 --- a/test/EFCore.Tests/TestUtilities/FakeStateManager.cs +++ b/test/EFCore.Tests/TestUtilities/FakeStateManager.cs @@ -131,6 +131,9 @@ public InternalEntityEntry GetOrCreateEntry(object entity, IEntityType entityTyp public InternalEntityEntry CreateEntry(IDictionary values, IEntityType entityType) => throw new NotImplementedException(); + public InternalEntityEntry CreateEntry(IReadOnlyDictionary values, IEntityType entityType) + => throw new NotImplementedException(); + public InternalEntityEntry StartTrackingFromQuery( IEntityType baseEntityType, object entity,