From 79e14b7976cb405c465aa82bc95c788d352234e4 Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Tue, 17 Mar 2020 16:00:26 -0700 Subject: [PATCH] Documentation for value comparers Fixes #1986 --- .../core/modeling/value-comparers.md | 143 ++++++++ .../core/Modeling/ValueConversions/Program.cs | 308 ++++++++++++++++++ .../ValueConversions/ValueConversions.csproj | 15 + samples/core/Samples.sln | 19 ++ 4 files changed, 485 insertions(+) create mode 100644 entity-framework/core/modeling/value-comparers.md create mode 100644 samples/core/Modeling/ValueConversions/Program.cs create mode 100644 samples/core/Modeling/ValueConversions/ValueConversions.csproj diff --git a/entity-framework/core/modeling/value-comparers.md b/entity-framework/core/modeling/value-comparers.md new file mode 100644 index 0000000000..3afae12601 --- /dev/null +++ b/entity-framework/core/modeling/value-comparers.md @@ -0,0 +1,143 @@ +--- +title: Value Comparers - EF Core +author: ajcvickers +ms.date: 03/17/2020 +uid: core/modeling/value-comparers +--- + +# Value Comparers + +> [!NOTE] +> This feature is new in EF Core 3.0. + +## Background + +EF Core needs to compare property values when: + +* Determining whether or not a property has been changed as part of [detecting changes for updates](../saving/basic) +* Determining whether or not two key values are the same when resolving relationships + +This is handled automatically for common primitive types with little internal structure, such as int, DateTime, etc. + +For more complex types, choices need to be made as to how to do the comparison. +For example, a byte array could be compared: + +* By reference, such that a difference is only detected if a new byte array is used +* By deep comparison, such that mutation of the bytes in the array is detected + +EF Core uses the first of these approaches for non-key byte arrays. +That is, only references are compared and a change is detected when an existing byte array is replaced with a new one. +This is a pragmatic decision that avoids deep comparison of many large byte arrays when executing SaveChanges. +But the common scenario of replacing, say, an image with a different image is handled in a performant way. + +On the other hand, reference equality would not work when byte arrays are used to represent binary keys. +It's very unlikely that an FK property is set to the _same instance_ as a PK property to which it needs to be compared. +Therefore, EF Core uses deep comparisons for byte arrays acting as keys. +This is unlikely to have a big performance hit since binary keys are usually short. + +### Snapshots + +Deep comparisons on mutable types means that EF Core needs the ability to create a deep "snapshot" of the property value. +Just copying the reference instead would result in mutating both the current value and the snapshot, since they are _the same object_. +Therefore, when deep comparisons are used, deep snapshotting is also required. + +## Properties with value converters + +In the case above, EF Core has native mapping support for byte arrays and so can automatically choose appropriate defaults. +However, if the property is mapped through a [value converter](value-conversions), then EF Core can't always determine the appropriate comparison to use. +Instead, EF Core always uses the default equality comparison defined by the property type. +This is often correct, but may need to be overridden when mapping more complex types. + +### Simple immutable classes + +Consider a property the uses a value converter to map a simple, immutable class. + +[!code-csharp[SimpleImmutableClass](../../../samples/core/Modeling/ValueConversions/Program.cs?name=SimpleImmutableClass)] + +[!code-csharp[ConfigureImmutableClassProperty](../../../samples/core/Modeling/ValueConversions/Program.cs?name=ConfigureImmutableClassProperty)] + +Properties of this type do not need special comparisons or snapshots because: +* Equality is overridden so that different instances will compare correctly +* The type is immutable, so there is no chance of mutating a snapshot value + +So in this case the value converted mapping is fine as it is. + +### Simple immutable Structs + +The mapping for simple structs is also simple and requires no special comparers or snapshotting. + +[!code-csharp[SimpleImmutableStruct](../../../samples/core/Modeling/ValueConversions/Program.cs?name=SimpleImmutableStruct)] + +[!code-csharp[ConfigureImmutableStructProperty](../../../samples/core/Modeling/ValueConversions/Program.cs?name=ConfigureImmutableStructProperty)] + +EF Core has built-in support for generating compiled, memberwise comparisons of struct properties. +This means structs don't need to have equality overridden for EF, but you may still choose to do this for other reasons. +Also, snapshotting is fine since structs are always memberwise copied. +(This is also true for mutable structs, but [mutable structs should in general be avoided](https://docs.microsoft.com/dotnet/csharp/write-safe-efficient-code).) + +### Mutable classes + +It is recommended that you use immutable types (classes or structs) with value converters when possible. +This is usually more efficient and has cleaner semantics than using a mutable type. + +However, that being said, it is common to use properties of types that the application cannot change. +For example, mapping a property containing a list of numbers: + +[!code-csharp[ListProperty](../../../samples/core/Modeling/ValueConversions/Program.cs?name=ListProperty)] + +The [List class](https://docs.microsoft.com/dotnet/api/system.collections.generic.list-1?view=netstandard-2.1): +* Has reference equality; two lists containing the same values are treated as different. +* Is mutable; values in the list can be added, removed, and modified. + +A typical value conversion on a list property might convert the list to and from JSON: + +[!code-csharp[ConfigureListProperty](../../../samples/core/Modeling/ValueConversions/Program.cs?name=ConfigureListProperty)] + +Then, to get correct comparisons, create and set a `ValueComparer` on the same property: + +[!code-csharp[ConfigureListPropertyComparer](../../../samples/core/Modeling/ValueConversions/Program.cs?name=ConfigureListPropertyComparer)] + +> [!NOTE] +> The model builder ("fluent") API to set a value comparer has not yet been implemented. +> Instead, the code above gets the lower-level `IMutableProperty` from the builder's `Metadata` property and sets the comparer directly. + +The `ValueComparer` constructor accepts three expressions: +* An expression for checking quality +* An expression for generating a hash code +* An expression to snapshot a value + +In this case the comparision is done by checking if the sequences of numbers are the same. + +Likewise, the hash code is built from this same sequence. +(Note that this is a hash code over mutable values and hence can [cause problems](https://ericlippert.com/2011/02/28/guidelines-and-rules-for-gethashcode/). +Be immutable instead if you can.) + +The snapshot is created by cloning the list with `ToList`. +Again, this is only needed if the lists are going to be mutated. +Be immutable instead if you can. + +> [!NOTE] +> Value converters and comparers are constructed using expressions rather than simple delegates. +> This is because EF inserts these expressions into a much more complex expression tree that is then compiled into an entity shaper delegate. +> Conceptually, this is similar to compiler inlining. +> For example, a simple conversion may just be a compiled in cast, rather than a call to another method to do the conversion. + +### Key comparers + +The background section covered why key comparisons may require special semantics. +A property is either part of a key property or it isn't. +Make sure to create a comparer that is appropriate for keys when setting one for key properties. + +Use [SetKeyValueComparer](https://docs.microsoft.com/dotnet/api/microsoft.entityframeworkcore.mutablepropertyextensions.setkeyvaluecomparer?view=efcore-3.1) in the rare cases where different semantics is required on the same property. + +> [!NOTE] +> SetStructuralComparer has been obsoleted in EF Core 5.0. +> Use SetKeyValueComparer instead. + +### Overriding defaults + +Sometimes the default comparison used by EF Core may not be appropriate. +For example, mutation of byte arrays is not by default detected by EF Core. +This can be overridden by setting a different comparer on the property: + +[!code-csharp[OverrideComparer](../../../samples/core/Modeling/ValueConversions/Program.cs?name=OverrideComparer)] diff --git a/samples/core/Modeling/ValueConversions/Program.cs b/samples/core/Modeling/ValueConversions/Program.cs new file mode 100644 index 0000000000..83ecefc21e --- /dev/null +++ b/samples/core/Modeling/ValueConversions/Program.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.Extensions.Logging; + +namespace ValueConversions +{ + public class Program + { + public static void Main() + { + Mapping_immutable_class_property(); + Mapping_immutable_struct_property(); + Mapping_List_property(); + Overriding_byte_array_comparisons(); + } + + private static void Mapping_immutable_class_property() + { + ConsoleWriteLines("Sample showing value conversions for a simple immutable class..."); + + CleanDatabase(); + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Save a new entity..."); + + var entity = new EntityType1 { MyProperty = new ImmutableClass(7) }; + context.Add(entity); + context.SaveChanges(); + + ConsoleWriteLines("Change the property value and save again..."); + + // This will be detected and EF will update the database on SaveChanges + entity.MyProperty = new ImmutableClass(77); + + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var entity = context.Set().Single(); + + Debug.Assert(entity.MyProperty.Value == 77); + } + + ConsoleWriteLines("Sample finished."); + } + + private static void Mapping_immutable_struct_property() + { + ConsoleWriteLines("Sample showing value conversions for a simple immutable struct..."); + + CleanDatabase(); + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Save a new entity..."); + + var entity = new EntityType2 { MyProperty = new ImmutableStruct(6) }; + context.Add(entity); + context.SaveChanges(); + + ConsoleWriteLines("Change the property value and save again..."); + + // This will be detected and EF will update the database on SaveChanges + entity.MyProperty = new ImmutableStruct(66); + + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var entity = context.Set().Single(); + + Debug.Assert(entity.MyProperty.Value == 66); + } + + ConsoleWriteLines("Sample finished."); + } + + private static void Mapping_List_property() + { + ConsoleWriteLines("Sample showing value conversions for a List..."); + + CleanDatabase(); + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Save a new entity..."); + + var entity = new EntityType3 { MyProperty = new List { 1, 2, 3 } }; + context.Add(entity); + context.SaveChanges(); + + ConsoleWriteLines("Mutate the property value and save again..."); + + // This will be detected and EF will update the database on SaveChanges + entity.MyProperty.Add(4); + + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var entity = context.Set().Single(); + + Debug.Assert(entity.MyProperty.SequenceEqual(new List { 1, 2, 3, 4 })); + } + + ConsoleWriteLines("Sample finished."); + } + + + private static void Overriding_byte_array_comparisons() + { + ConsoleWriteLines("Sample showing overriding byte array comparisons..."); + + CleanDatabase(); + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Save a new entity..."); + + var entity = new EntityType4 { MyBytes = new byte[] { 1, 2, 3 } }; + context.Add(entity); + context.SaveChanges(); + + ConsoleWriteLines("Mutate the property value and save again..."); + + // Normally mutating the byte array would not be detected by EF Core. + // In this case it will be detected because the comparer in the model is overridden. + entity.MyBytes[1] = 4; + + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var entity = context.Set().Single(); + + Debug.Assert(entity.MyBytes.SequenceEqual(new byte[] { 1, 4, 3 })); + } + + ConsoleWriteLines("Sample finished."); + } + + private static void ConsoleWriteLines(params string[] values) + { + Console.WriteLine(); + foreach (var value in values) + { + Console.WriteLine(value); + } + Console.WriteLine(); + } + + private static void CleanDatabase() + { + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Deleting and re-creating database..."); + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + ConsoleWriteLines("Done. Database is clean and fresh."); + } + } + } + + public class SampleDbContext : DbContext + { + private static readonly ILoggerFactory + Logger = LoggerFactory.Create(x => x.AddConsole()); //.SetMinimumLevel(LogLevel.Debug)); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConfigureImmutableClassProperty + modelBuilder + .Entity() + .Property(e => e.MyProperty) + .HasConversion( + v => v.Value, + v => new ImmutableClass(v)); + #endregion + + #region ConfigureImmutableStructProperty + modelBuilder + .Entity() + .Property(e => e.MyProperty) + .HasConversion( + v => v.Value, + v => new ImmutableStruct(v)); + #endregion + + #region ConfigureListProperty + modelBuilder + .Entity() + .Property(e => e.MyProperty) + .HasConversion( + v => JsonSerializer.Serialize(v, null), + v => JsonSerializer.Deserialize>(v, null)); + #endregion + + #region ConfigureListPropertyComparer + var valueComparer = new ValueComparer>( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c.ToList()); + + modelBuilder + .Entity() + .Property(e => e.MyProperty) + .Metadata + .SetValueComparer(valueComparer); + #endregion + + #region OverrideComparer + modelBuilder + .Entity() + .Property(e => e.MyBytes) + .Metadata + .SetValueComparer(new ValueComparer( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c.ToArray())); + #endregion + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseLoggerFactory(Logger) + .UseSqlite("DataSource=test.db") + .EnableSensitiveDataLogging(); + } + + public class EntityType1 + { + public int Id { get; set; } + public ImmutableClass MyProperty { get; set; } + } + + public class EntityType2 + { + public int Id { get; set; } + public ImmutableStruct MyProperty { get; set; } + } + + public class EntityType3 + { + public int Id { get; set; } + + #region ListProperty + public List MyProperty { get; set; } + #endregion + } + + public class EntityType4 + { + public int Id { get; set; } + + public byte[] MyBytes { get; set; } + } + + #region SimpleImmutableStruct + public readonly struct ImmutableStruct + { + public ImmutableStruct(int value) + { + Value = value; + } + + public int Value { get; } + } + #endregion + + #region SimpleImmutableClass + public sealed class ImmutableClass + { + public ImmutableClass(int value) + { + Value = value; + } + + public int Value { get; } + + private bool Equals(ImmutableClass other) + => Value == other.Value; + + public override bool Equals(object obj) + => ReferenceEquals(this, obj) || obj is ImmutableClass other && Equals(other); + + public override int GetHashCode() + => Value; + } + #endregion +} diff --git a/samples/core/Modeling/ValueConversions/ValueConversions.csproj b/samples/core/Modeling/ValueConversions/ValueConversions.csproj new file mode 100644 index 0000000000..c3f413a2ff --- /dev/null +++ b/samples/core/Modeling/ValueConversions/ValueConversions.csproj @@ -0,0 +1,15 @@ + + + + Exe + netcoreapp3.1 + EFModeling.ValueConversions + EFModeling.ValueConversions + + + + + + + + diff --git a/samples/core/Samples.sln b/samples/core/Samples.sln index 5a4d704b6a..4bc897dc0b 100644 --- a/samples/core/Samples.sln +++ b/samples/core/Samples.sln @@ -49,6 +49,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EFGetStarted", "GetStarted\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlServer", "SqlServer\SqlServer.csproj", "{80756004-3664-481A-879F-E1A261328758}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ValueConversions", "Modeling\ValueConversions\ValueConversions.csproj", "{FE71504E-C32B-4E2F-9830-21ED448DABC4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -357,6 +359,22 @@ Global {80756004-3664-481A-879F-E1A261328758}.Release|x64.Build.0 = Release|Any CPU {80756004-3664-481A-879F-E1A261328758}.Release|x86.ActiveCfg = Release|Any CPU {80756004-3664-481A-879F-E1A261328758}.Release|x86.Build.0 = Release|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Debug|ARM.ActiveCfg = Debug|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Debug|ARM.Build.0 = Debug|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Debug|x64.ActiveCfg = Debug|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Debug|x64.Build.0 = Debug|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Debug|x86.ActiveCfg = Debug|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Debug|x86.Build.0 = Debug|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|Any CPU.Build.0 = Release|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|ARM.ActiveCfg = Release|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|ARM.Build.0 = Release|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|x64.ActiveCfg = Release|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|x64.Build.0 = Release|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|x86.ActiveCfg = Release|Any CPU + {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -374,6 +392,7 @@ Global {FD21B5E9-B1D9-4788-A313-5C0CDA30D1D7} = {CA5046EC-C894-4535-8190-A31F75FDEB96} {802E31AD-2F1E-41A1-A662-5929E2626601} = {CA5046EC-C894-4535-8190-A31F75FDEB96} {63685B9A-1233-4B44-AAC1-8DDD4B16B65D} = {CA5046EC-C894-4535-8190-A31F75FDEB96} + {FE71504E-C32B-4E2F-9830-21ED448DABC4} = {CA5046EC-C894-4535-8190-A31F75FDEB96} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {20C98D35-54EF-46A6-8F3B-1855C1AE4F70}