From ecf418bfa364d661096a131aac0b5adceb82932e Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Sun, 17 Jan 2021 08:29:56 -0800 Subject: [PATCH] Updates to value converter docs Lots of examples! Fixes #614 Fixes #784 Part of #685 Fixes #2252 Fixes #2415 Fixes #2456 Fixes #2824 Fixes #2979 --- .../core/modeling/value-comparers.md | 39 +- .../core/modeling/value-conversions.md | 797 +++++++++++++++--- .../CaseInsensitiveStrings.cs | 108 +++ .../ValueConversions/CompositeValueObject.cs | 94 +++ .../ValueConversions/EncryptPropertyValues.cs | 66 ++ .../EnumToStringConversions.cs | 237 ++++++ .../ValueConversions/FixedLengthStrings.cs | 114 +++ .../ValueConversions/KeyValueObjects.cs | 116 +++ .../ValueConversions/PreserveDateTimeKind.cs | 97 +++ .../ValueConversions/PrimitiveCollection.cs | 82 ++ .../core/Modeling/ValueConversions/Program.cs | 15 +- .../ValueConversions/SimpleValueObject.cs | 79 ++ .../ValueConversions/ULongConcurrency.cs | 96 +++ .../ValueConversions/ValueConversions.csproj | 3 +- .../ValueConversions/ValueObjectCollection.cs | 133 +++ .../ValueConversions/WithMappingHints.cs | 90 ++ 16 files changed, 2039 insertions(+), 127 deletions(-) create mode 100644 samples/core/Modeling/ValueConversions/CaseInsensitiveStrings.cs create mode 100644 samples/core/Modeling/ValueConversions/CompositeValueObject.cs create mode 100644 samples/core/Modeling/ValueConversions/EncryptPropertyValues.cs create mode 100644 samples/core/Modeling/ValueConversions/EnumToStringConversions.cs create mode 100644 samples/core/Modeling/ValueConversions/FixedLengthStrings.cs create mode 100644 samples/core/Modeling/ValueConversions/KeyValueObjects.cs create mode 100644 samples/core/Modeling/ValueConversions/PreserveDateTimeKind.cs create mode 100644 samples/core/Modeling/ValueConversions/PrimitiveCollection.cs create mode 100644 samples/core/Modeling/ValueConversions/SimpleValueObject.cs create mode 100644 samples/core/Modeling/ValueConversions/ULongConcurrency.cs create mode 100644 samples/core/Modeling/ValueConversions/ValueObjectCollection.cs create mode 100644 samples/core/Modeling/ValueConversions/WithMappingHints.cs diff --git a/entity-framework/core/modeling/value-comparers.md b/entity-framework/core/modeling/value-comparers.md index f59fc4ac8e..2a025c8dc1 100644 --- a/entity-framework/core/modeling/value-comparers.md +++ b/entity-framework/core/modeling/value-comparers.md @@ -2,7 +2,7 @@ title: Value Comparers - EF Core description: Using value comparers to control how EF Core compares property values author: ajcvickers -ms.date: 03/20/2020 +ms.date: 01/16/2021 uid: core/modeling/value-comparers --- @@ -16,9 +16,9 @@ uid: core/modeling/value-comparers ## Background -Change tracking means that EF Core automatically determines what changes were performed by the application on a loaded entity instance, so that those changes can be saved back to the database when is called. EF Core usually performs this by taking a *snapshot* of the instance when it's loaded from the database, and *comparing* that snapshot to the instance handed out to the application. +[Change tracking](xref:core/change-tracking/index) means that EF Core automatically determines what changes were performed by the application on a loaded entity instance, so that those changes can be saved back to the database when is called. EF Core usually performs this by taking a *snapshot* of the instance when it's loaded from the database, and *comparing* that snapshot to the instance handed out to the application. -EF Core comes with built-in logic for snapshotting and comparing most standard types used in databases, so users don't usually need to worry about this topic. However, when a property is mapped through a [value converter](xref:core/modeling/value-conversions), EF Core needs to perform comparison on arbitrary user types, which may be complex. By default, EF Core uses the default equality comparison defined by types (e.g. the `Equals` method); for snapshotting, value types are copied to produce the snapshot, while for reference types no copying occurs, and the same instance is used as the snapshot. +EF Core comes with built-in logic for snapshotting and comparing most standard types used in databases, so users don't usually need to worry about this topic. However, when a property is mapped through a [value converter](xref:core/modeling/value-conversions), EF Core needs to perform comparison on arbitrary user types, which may be complex. By default, EF Core uses the default equality comparison defined by types (e.g. the `Equals` method); for snapshotting, [value types](/dotnet/csharp/language-reference/builtin-types/value-types) are copied to produce the snapshot, while for [reference types](/dotnet/csharp/language-reference/keywords/reference-types) no copying occurs, and the same instance is used as the snapshot. In cases where the built-in comparison behavior isn't appropriate, users may provide a *value comparer*, which contains logic for snapshotting, comparing and calculating a hash code. For example, the following sets up value conversion for `List` property to be value converted to a JSON string in the database, and defines an appropriate value comparer as well: @@ -37,7 +37,7 @@ Consider byte arrays, which can be arbitrarily large. These 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 -By default, EF Core uses the first of these approaches for non-key byte arrays. That is, only references are compared and a change is detected only when an existing byte array is replaced with a new one. This is a pragmatic decision that avoids copying entire arrays and comparing them byte-to-byte when executing , and the common scenario of replacing, say, one image with another is handled in a performant way. +By default, EF Core uses the first of these approaches for non-key byte arrays. That is, only references are compared and a change is detected only when an existing byte array is replaced with a new one. This is a pragmatic decision that avoids copying entire arrays and comparing them byte-to-byte when executing . It means the common scenario of replacing, say, one image with another is handled in a performant way. On the other hand, reference equality would not work when byte arrays are used to represent binary keys, since 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. @@ -66,18 +66,13 @@ The mapping for simple structs is also simple and requires no special comparers [!code-csharp[ConfigureImmutableStructProperty](../../../samples/core/Modeling/ValueConversions/MappingImmutableStructProperty.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 Core, but you may still choose to do this for [other reasons](/dotnet/csharp/programming-guide/statements-expressions-operators/how-to-define-value-equality-for-a-type). -Also, special snapshotting is not needed since structs are immutable and are always copied memberwise anyway. -(This is also true for mutable structs, but [mutable structs should in general be avoided](/dotnet/csharp/write-safe-efficient-code).) +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 Core, but you may still choose to do this for [other reasons](/dotnet/csharp/programming-guide/statements-expressions-operators/how-to-define-value-equality-for-a-type). Also, special snapshotting is not needed since structs are immutable and are always copied memberwise anyway. (This is also true for mutable structs, but [mutable structs should in general be avoided](/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. +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: +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/MappingListProperty.cs?name=ListProperty)] @@ -106,24 +101,16 @@ The constr In this case the comparison 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.) +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. +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 Core 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. +> Value converters and comparers are constructed using expressions rather than simple delegates. This is because EF Core 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 covers why key comparisons may require special semantics. -Make sure to create a comparer that is appropriate for keys when setting it on a primary, principal, or foreign key property. +The background section covers why key comparisons may require special semantics. Make sure to create a comparer that is appropriate for keys when setting it on a primary, principal, or foreign key property. Use in the rare cases where different semantics is required on the same property. @@ -132,9 +119,7 @@ Use [!TIP] +> You can run and debug into all the code in this document by [downloading the sample code from GitHub](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/Modeling/ValueConversions/). + +## Overview Value converters are specified in terms of a `ModelClrType` and a `ProviderClrType`. The model type is the .NET type of the property in the entity type. The provider type is the .NET type understood by the database provider. For example, to save enums as strings in the database, the model type is the type of the enum, and the provider type is `String`. These two types can be the same. -Conversions are defined using two `Func` expression trees: one from `ModelClrType` to `ProviderClrType` and the other from `ProviderClrType` to `ModelClrType`. Expression trees are used so that they can be compiled into the database access code for efficient conversions. For complex conversions, the expression tree may be a simple call to a method that performs the conversion. +Conversions are defined using two `Func` expression trees: one from `ModelClrType` to `ProviderClrType` and the other from `ProviderClrType` to `ModelClrType`. Expression trees are used so that they can be compiled into the database access delegate for efficient conversions. The expression tree may contain a simple call to a conversion method for complex conversions. + +> [!NOTE] +> A property that has been configured for value conversion may also need to specify a . See the examples below, and the [Value Comparers](xref:core/modeling/value-comparers) documentation for more information. ## Configuring a value converter -Value conversions are defined on properties in the `OnModelCreating` of your `DbContext`. For example, consider an enum and entity type defined as: +Value conversions are configured in . For example, consider an enum and entity type defined as: + + +[!code-csharp[BeastAndRider](../../../samples/core/Modeling/ValueConversions/EnumToStringConversions.cs?name=BeastAndRider)] + +Conversions can be configured in to store the enum values as strings such as "Donkey", "Mule", etc. in the database: + + +[!code-csharp[ExplicitConversion](../../../samples/core/Modeling/ValueConversions/EnumToStringConversions.cs?name=ExplicitConversion)] -```csharp -public class Rider -{ - public int Id { get; set; } - public EquineBeast Mount { get; set; } -} - -public enum EquineBeast -{ - Donkey, - Mule, - Horse, - Unicorn -} -``` +> [!NOTE] +> A `null` value will never be passed to a value converter. A null in a database column is always a null in the entity instance, and vice-versa. This makes the implementation of conversions easier and allows them to be shared amongst nullable and non-nullable properties. See [GitHub issue #13850](https://github.com/dotnet/efcore/issues/13850) for more information. -Then conversions can be defined in `OnModelCreating` to store the enum values as strings (for example, "Donkey", "Mule", ...) in the database: +## Pre-defined conversions -```csharp -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - modelBuilder - .Entity() - .Property(e => e.Mount) - .HasConversion( - v => v.ToString(), - v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v)); -} -``` +EF Core contains many pre-defined conversions that avoid the need to write conversion functions manually. Instead, EF Core will pick the conversion to used based on the property type in the model and the requested database provider type. -> [!NOTE] -> A `null` value will never be passed to a value converter. This makes the implementation of conversions easier and allows them to be shared amongst nullable and non-nullable properties. +For example, enum to string conversions are used as an example above, but EF Core will actually do this automatically when the provider type is configured as `string` using the generic type of : + + +[!code-csharp[ConversionByClrType](../../../samples/core/Modeling/ValueConversions/EnumToStringConversions.cs?name=ConversionByClrType)] + +The same thing can be achieved by explicitly specifying the database column type. For example, if the entity type is defined like so: + + +[!code-csharp[ConversionByDatabaseType](../../../samples/core/Modeling/ValueConversions/EnumToStringConversions.cs?name=ConversionByDatabaseType)] + +Then the enum values will be saved as strings in the database without any further configuration in OnModelCreating. ## The ValueConverter class -Calling `HasConversion` as shown above will create a `ValueConverter` instance and set it on the property. The `ValueConverter` can instead be created explicitly. For example: +Calling as shown above will create a instance and set it on the property. The `ValueConverter` can instead be created explicitly. For example: -```csharp -var converter = new ValueConverter( - v => v.ToString(), - v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v)); - -modelBuilder - .Entity() - .Property(e => e.Mount) - .HasConversion(converter); -``` + +[!code-csharp[ConversionByConverterInstance](../../../samples/core/Modeling/ValueConversions/EnumToStringConversions.cs?name=ConversionByConverterInstance)] This can be useful when multiple properties use the same conversion. +## Built-in converters + +As mentioned above, EF Core ships with a set of pre-defined classes, found in the namespace. In many cases EF will choose the appropriate built-in converter based on the type of the property in the model and the type requested in the database, as shown above for enums. For example, using `.HasConversion()` on a `bool` property will cause EF Core to convert bool values to numerical zero and one values: + + +[!code-csharp[ConversionByBuiltInBoolToInt](../../../samples/core/Modeling/ValueConversions/EnumToStringConversions.cs?name=ConversionByBuiltInBoolToInt)] + +This is functionally the same as creating an instance of the built-in and setting it explicitly: + + +[!code-csharp[ConversionByBuiltInBoolToIntExplicit](../../../samples/core/Modeling/ValueConversions/EnumToStringConversions.cs?name=ConversionByBuiltInBoolToIntExplicit)] + +The following table summarizes commonly used pre-defined conversions from model/property types to database provider types. + +In the table `any_numeric_type` means one of `int`, `short`, `long`, `byte`, `uint`, `ushort`, `ulong`, `sbyte`, `char`, `decimal`, `float`, or `double`. + +| Model/property type | Provider/database type | Conversion | Usage +|:--------------------|------------------------|-----------------------------------------------------------|------ +| bool | any_numeric_type | False/true to 0/1 | `.HasConversion()` +| | any_numeric_type | False/true to any two numbers | Use +| | string | False/true to "Y"/"N" | `.HasConversion()` +| | string | False/true to any two strings | Use +| any_numeric_type | bool | 0/1 to false/true | `.HasConversion()` +| | any_numeric_type | Simple cast | `.HasConversion()` +| | string | The number as a string | `.HasConversion()` +| Enum | any_numeric_type | The numeric value of the enum | `.HasConversion()` +| | string | The string representation of the enum value | `.HasConversion()` +| string | bool | Parses the string as a bool | `.HasConversion()` +| | any_numeric_type | Parses the string as the given numeric type | `.HasConversion()` +| | char | The first character of the string | `.HasConversion()` +| | DateTime | Parses the string as a DateTime | `.HasConversion()` +| | DateTimeOffset | Parses the string as a DateTimeOffset | `.HasConversion()` +| | TimeSpan | Parses the string as a TimeSpan | `.HasConversion()` +| | Guid | Parses the string as a Guid | `.HasConversion()` +| | byte[] | The string as UTF8 bytes | `.HasConversion()` +| char | string | A single character string | `.HasConversion()` +| DateTime | long | Encoded date/time preserving DateTime.Kind | `.HasConversion()` +| | long | Ticks | Use +| | string | Invariant culture date/time string | `.HasConversion()` +| DateTimeOffset | long | Encoded date/time with offset | `.HasConversion()` +| | string | Invariant culture date/time string with offset | `.HasConversion()` +| TimeSpan | long | Ticks | `.HasConversion()` +| | string | Invariant culture time span string | `.HasConversion()` +| Uri | string | The URI as a string | `.HasConversion()` +| PhysicalAddress | string | The address as a string | `.HasConversion()` +| | byte[] | Bytes in big-endian network order | `.HasConversion()` +| IPAddress | string | The address as a string | `.HasConversion()` +| | byte[] | Bytes in big-endian network order | `.HasConversion()` +| Guid | string | The GUID in 'dddddddd-dddd-dddd-dddd-dddddddddddd' format | `.HasConversion()` +| | byte[] | Bytes in .NET binary serialization order | `.HasConversion()` + +Note that these conversions assume that the format of the value is appropriate for the conversion. For example, converting strings to numbers will fail if the string values cannot be parsed as numbers. + +The full list of built-in converters is: + +* Converting bool properties: + * - Bool to strings such as "Y" and "N" + * - Bool to any two values + * - Bool to zero and one +* Converting byte array properties: + * - Byte array to Base64-encoded string +* Any conversion that requires only a type-cast + * - Conversions that require only a type cast +* Converting char properties: + * - Char to single character string +* Converting properties: + * - to binary-encoded 64-bit value + * - to byte array + * - to string +* Converting properties: + * - to 64-bit value including DateTimeKind + * - to string + * - to ticks +* Converting enum properties: + * - Enum to underlying number + * - Enum to string +* Converting properties: + * - to byte array + * - to string +* Converting properties: + * - to byte array + * - to string +* Converting numeric (int, double, decimal, etc.) properties: + * - Any numerical value to byte array + * - Any numerical value to string +* Converting properties: + * - to byte array + * - to string +* Converting string properties: + * - Strings such as "Y" and "N" to bool + * - String to UTF8 bytes + * - String to character + * - String to + * - String to + * - String to enum + * - String to + * - String to numeric type + * - String to + * - String to +* Converting properties: + * - to string + * - to ticks +* Converting properties: + * - to string + +Note that all the built-in converters are stateless and so a single instance can be safely shared by multiple properties. + +## Examples + +### Simple value objects + +This example uses a simple type to wrap a primitive type. This can be useful when you want the type in your model to be more specific (and hence more type-safe) than a primitive type. In this example, that type is `Dollars`, which wraps the decimal primitive: + + +[!code-csharp[SimpleValueObject](../../../samples/core/Modeling/ValueConversions/SimpleValueObject.cs?name=SimpleValueObject)] + +This can be used in an entity type: + + +[!code-csharp[SimpleValueObjectModel](../../../samples/core/Modeling/ValueConversions/SimpleValueObject.cs?name=SimpleValueObjectModel)] + +And converted to the underlying `decimal` when stored in the database: + + +[!code-csharp[ConfigureImmutableStructProperty](../../../samples/core/Modeling/ValueConversions/SimpleValueObject.cs?name=ConfigureImmutableStructProperty)] + > [!NOTE] -> There is currently no way to specify in one place that every property of a given type must use the same value converter. This feature will be considered for a future release. +> This value object is implemented as a [readonly struct](/dotnet/csharp/language-reference/builtin-types/struct). This means that EF Core can snapshot and compare values without issue. See [Value Comparers](xref:core/modeling/value-comparers) for more information. + +### Composite value objects + +In the previous example, the value object type contained only a single property. It is more common for a value object type to compose multiple properties that together form a domain concept. For example, a general `Money` type that contains both the amount and the currency: + + +[!code-csharp[CompositeValueObject](../../../samples/core/Modeling/ValueConversions/CompositeValueObject.cs?name=CompositeValueObject)] + +This value object can be used in an entity type as before: + + +[!code-csharp[CompositeValueObjectModel](../../../samples/core/Modeling/ValueConversions/CompositeValueObject.cs?name=CompositeValueObjectModel)] + +Value converters can currently only convert values to and from a single database column. This limitation means that all property values from the object must be encoded into a single column value. This is typically handled by serializing the object as it goes into the database, and then deserializing it again on the way out. For example, using : + + +[!code-csharp[ConfigureCompositeValueObject](../../../samples/core/Modeling/ValueConversions/CompositeValueObject.cs?name=ConfigureCompositeValueObject)] -## Built-in converters +> [!NOTE] +> We plan to allow mapping an object to multiple columns in EF Core 6.0. This will remove the need to use serialization here. This is tacked by [GitHub issue #13947](https://github.com/dotnet/efcore/issues/13947). -EF Core ships with a set of pre-defined `ValueConverter` classes, found in the `Microsoft.EntityFrameworkCore.Storage.ValueConversion` namespace. These are: - -* `BoolToZeroOneConverter` - Bool to zero and one -* `BoolToStringConverter` - Bool to strings such as "Y" and "N" -* `BoolToTwoValuesConverter` - Bool to any two values -* `BytesToStringConverter` - Byte array to Base64-encoded string -* `CastingConverter` - Conversions that require only a type cast -* `CharToStringConverter` - Char to single character string -* `DateTimeOffsetToBinaryConverter` - DateTimeOffset to binary-encoded 64-bit value -* `DateTimeOffsetToBytesConverter` - DateTimeOffset to byte array -* `DateTimeOffsetToStringConverter` - DateTimeOffset to string -* `DateTimeToBinaryConverter` - DateTime to 64-bit value including DateTimeKind -* `DateTimeToStringConverter` - DateTime to string -* `DateTimeToTicksConverter` - DateTime to ticks -* `EnumToNumberConverter` - Enum to underlying number -* `EnumToStringConverter` - Enum to string -* `GuidToBytesConverter` - Guid to byte array -* `GuidToStringConverter` - Guid to string -* `NumberToBytesConverter` - Any numerical value to byte array -* `NumberToStringConverter` - Any numerical value to string -* `StringToBytesConverter` - String to UTF8 bytes -* `TimeSpanToStringConverter` - TimeSpan to string -* `TimeSpanToTicksConverter` - TimeSpan to ticks - -Notice that `EnumToStringConverter` is included in this list. This means that there is no need to specify the conversion explicitly, as shown above. Instead, just use the built-in converter: +> [!NOTE] +> As with the previous example, this value object is implemented as a [readonly struct](/dotnet/csharp/language-reference/builtin-types/struct). This means that EF Core can snapshot and compare values without issue. See [Value Comparers](xref:core/modeling/value-comparers) for more information. -```csharp -var converter = new EnumToStringConverter(); +### Collections of primitives -modelBuilder - .Entity() - .Property(e => e.Mount) - .HasConversion(converter); -``` +Serialization can also be used to store a collection of primitive values. For example: -Note that all the built-in converters are stateless and so a single instance can be safely shared by multiple properties. + +[!code-csharp[PrimitiveCollectionModel](../../../samples/core/Modeling/ValueConversions/PrimitiveCollection.cs?name=PrimitiveCollectionModel)] -For common conversions for which a built-in converter exists there is no need to specify the converter explicitly. Instead, just configure which provider type should be used and EF will automatically use the appropriate built-in converter. Enum to string conversions are used as an example above, but EF will actually do this automatically if the provider type is configured: +Using again: ```csharp -modelBuilder - .Entity() - .Property(e => e.Mount) - .HasConversion(); + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(e => e.Tags) + .HasConversion( + v => JsonSerializer.Serialize(v, null), + v => JsonSerializer.Deserialize>(v, null), + new ValueComparer>( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => (ICollection)c.ToList())); + } ``` -The same thing can be achieved by explicitly specifying the column type. For example, if the entity type is defined like so: + +[!code-csharp[ConfigurePrimitiveCollection](../../../samples/core/Modeling/ValueConversions/PrimitiveCollection.cs?name=ConfigurePrimitiveCollection)] + +`ICollection` represents a mutable reference type. This means that a is needed so that EF Core can track and detect changes correctly. See [Value Comparers](xref:core/modeling/value-comparers) for more information. + +### Collections of value objects + +Combining the previous two examples together we can create a collection of value objects. For example, consider an `AnnualFinance` type that models blog finances for a single year: + + +[!code-csharp[ValueObjectCollection](../../../samples/core/Modeling/ValueConversions/ValueObjectCollection.cs?name=ValueObjectCollection)] + +This type composes several of the `Money` types we created previously: + + +[!code-csharp[ValueObjectCollectionMoney](../../../samples/core/Modeling/ValueConversions/ValueObjectCollection.cs?name=ValueObjectCollectionMoney)] + +We can then add a collection of `AnnualFinance` to our entity type: + + +[!code-csharp[ValueObjectCollectionModel](../../../samples/core/Modeling/ValueConversions/ValueObjectCollection.cs?name=ValueObjectCollectionModel)] + +And again use serialization to store this: + + +[!code-csharp[ConfigureValueObjectCollection](../../../samples/core/Modeling/ValueConversions/ValueObjectCollection.cs?name=ConfigureValueObjectCollection)] -```csharp -public class Rider -{ - public int Id { get; set; } +> [!NOTE] +> As before, this conversion requires a . See [Value Comparers](xref:core/modeling/value-comparers) for more information. + +### Value objects as keys + +Sometimes primitive key properties may be wrapped in value objects to add an additional level of type-safety in assigning values. For example, we could implement a key type for blogs, and a key type for posts: + + +[!code-csharp[KeyValueObjects](../../../samples/core/Modeling/ValueConversions/KeyValueObjects.cs?name=KeyValueObjects)] + +These can then be used in the domain model: + + +[!code-csharp[KeyValueObjectsModel](../../../samples/core/Modeling/ValueConversions/KeyValueObjects.cs?name=KeyValueObjectsModel)] + +Notice that `Blog.Id` cannot accidentally be assigned a `PostKey`, and `Post.Id` cannot accidentally be assigned a `BlogKey`. Similarly, the `Post.BlogId` foreign key property must be assigned a `BlogKey`. + +> [!NOTE] +> Showing this pattern does not mean we recommend it. Carefully consider whether this level of abstraction is helping or hampering your development experience. Also, consider using navigations and generated keys instead of dealing with key values directly. + +These key properties can then be mapped using value converters: + + +[!code-csharp[ConfigureKeyValueObjects](../../../samples/core/Modeling/ValueConversions/KeyValueObjects.cs?name=ConfigureKeyValueObjects)] + +> [!NOTE] +> Currently key properties with conversions cannot use generated key values. Vote for [GitHub issue #11597](https://github.com/dotnet/efcore/issues/11597) to have this limitation removed. + +### Use ulong for timestamp/rowversion + +SQL Server supports automatic [optimistic concurrency](xref:core/saving/concurrency) using [8-byte binary rowversion/timestamp columns](/sql/t-sql/data-types/rowversion-transact-sql). These are always read from and written to the database using an 8-byte array. However, byte arrays are a mutable reference type, which makes them someone painful to deal with. Value converters allow the rowversion to instead be mapped to a `ulong` property, which is much more appropriate and easy to use than the byte array. For example, consider a `Blog` entity with a ulong concurrency token: + + +[!code-csharp[ULongConcurrencyModel](../../../samples/core/Modeling/ValueConversions/ULongConcurrency.cs?name=ULongConcurrencyModel)] + +This can be mapped to a SQL server rowversion column using a value converter: + + +[!code-csharp[ConfigureULongConcurrency](../../../samples/core/Modeling/ValueConversions/ULongConcurrency.cs?name=ConfigureULongConcurrency)] + +### Specify the DateTime.Kind when reading dates + +SQL Server discards the flag when storing a as a [datetime](/sql/t-sql/data-types/datetime-transact-sql) or [datetime2](/sql/t-sql/data-types/datetime2-transact-sql). This means that DateTime values coming back from the database always have of `Unspecified`. + +Value converters can be used in two ways to deal with this. First, EF Core has a value converter that creates an 8-byte opaque value which preserves the `Kind` flag. For example: + + +[!code-csharp[ConfigurePreserveDateTimeKind1](../../../samples/core/Modeling/ValueConversions/PreserveDateTimeKind.cs?name=ConfigurePreserveDateTimeKind1)] + +This allows DateTime values with different `Kind` flags to be mixed in the database. + +The problem with this approach is that the database no longer has recognizable `datetime` or `datetime2` columns. So instead it is common to always store UTC time (or, less commonly, always local time) and then either ignore the `Kind` flag or set it to the appropriate value using a value converter. For example, the converter below ensures that the `DateTime` value read from the database will have the `UTC` kind: + + +[!code-csharp[ConfigurePreserveDateTimeKind2](../../../samples/core/Modeling/ValueConversions/PreserveDateTimeKind.cs?name=ConfigurePreserveDateTimeKind2)] + +### Use case-insensitive string keys + +Some databases, including SQL Server, perform case-insensitive string comparisons by default. .NET on the other hand performs case-sensitive string comparisons by default. This means that a foreign key value like "DotNet" will match the primary key value "dotnet" on SQL Server, but will not match it in EF Core. A value comparer for keys can be used for force EF Core into case-insensitive string comparisons like in the database. For example, consider a blog/posts model with string keys: + + +[!code-csharp[CaseInsensitiveStringsModel](../../../samples/core/Modeling/ValueConversions/CaseInsensitiveStrings.cs?name=CaseInsensitiveStringsModel)] + +This will not work as expected if some of the `Post.BlogId` values have different casing. A value comparer can be used to correct this: + + +[!code-csharp[ConfigureCaseInsensitiveStrings](../../../samples/core/Modeling/ValueConversions/CaseInsensitiveStrings.cs?name=ConfigureCaseInsensitiveStrings)] + +> [!NOTE] +> .NET string comparisons and database string comparisons can differ in more than just case sensitivity. This pattern works for simple ASCII keys, but may fail for keys with any kind of culture-specific characters. See [Collations and Case Sensitivity](xref:core/miscellaneous/collations-and-case-sensitivity) for more information. + +### Handle fixed-length database strings + +The previous example did not need a value converter. However, a converter can be useful for fixed-length database string types like `char(20)` or `nchar(20)`. Fixed-length strings are padded to their full length whenever a value is inserted into the database. This means that a key value of "dotnet" will be read back from the database as "dotnet ". This will then not compare correctly with key values that are not padded. + +A value converter can be used to trim the padding when reading key values. This can be combined with the value comparer in the previous example to compare fixed length case-insensitive ASCII keys correctly. For example: + + +[!code-csharp[ConfigureFixedLengthStrings](../../../samples/core/Modeling/ValueConversions/FixedLengthStrings.cs?name=ConfigureFixedLengthStrings)] + +### Encrypt property values + +Value converters can be used to encrypt property values before sending them to the database, and then decrypt them on the way out. For example, using string reversal as a substitute for a real encryption algorithm: + + +[!code-csharp[ConfigureEncryptPropertyValues](../../../samples/core/Modeling/ValueConversions/EncryptPropertyValues.cs?name=ConfigureEncryptPropertyValues)] + +> [!NOTE] +> There is currently no way to get a reference to the current DbContext, or other session state, from within a value converter. This limits the kinds of encryption that can be used. Vote for [GitHub issue #11597](https://github.com/dotnet/efcore/issues/12205) to have this limitation removed. + +> [!WARNING] +> Make sure to understand all the implications if you roll your own encryption to protect sensitive data. Consider instead using pre-built encryption mechanisms, such as [Always Encrypted](/sql/relational-databases/security/encryption/always-encrypted-database-engine) on SQL Server. + +### Column facets and mapping hints + +Some database types have facets that modify how the data is stored. These include: + +* Precision and scale for decimals and date/time columns +* Size/length for binary and string columns +* Unicode for string columns + +These facets can be configured in the normal way for a property that uses a value converter, and will apply to the converted database type. For example, using the `Dollars` type from the first example above, we can set the precision and scale for the underlying decimal column: + + +[!code-csharp[ConfigureWithFacets](../../../samples/core/Modeling/ValueConversions/WithMappingHints.cs?name=ConfigureWithFacets)] + +This results in a `decimal(20,2)` column when using EF Core migrations against SQL Server: + +```sql +CREATE TABLE [Order2] ( + [Id] int NOT NULL IDENTITY, + [Price] decimal(20,2) NOT NULL, + CONSTRAINT [PK_Order2] PRIMARY KEY ([Id])); ``` -Then the enum values will be saved as strings in the database without any further configuration in `OnModelCreating`. +However, if by default all `Dollars` columns should be, for example, `decimal(16,2)`, then this information can be given to the value converter as a . For example: + + +[!code-csharp[ConverterWithMappingHints](../../../samples/core/Modeling/ValueConversions/WithMappingHints.cs?name=ConverterWithMappingHints)] + +These are only hints since they are only used when facets have not been explicitly set on the mapped property. ## Limitations There are a few known current limitations of the value conversion system: -* As noted above, `null` cannot be converted. -* There is currently no way to spread a conversion of one property to multiple columns or vice-versa. -* Use of value conversions may impact the ability of EF Core to translate expressions to SQL. A warning will be logged for such cases. -Removal of these limitations is being considered for a future release. +* There is currently no way to specify in one place that every property of a given type must use the same value converter. Please vote (👍) for [GitHub issue #10784](https://github.com/dotnet/efcore/issues/10784) if this is something you need. +* As noted above, `null` cannot be converted. Please vote (👍) for [GitHub issue #13850](https://github.com/dotnet/efcore/issues/13850) if this is something you need. +* There is currently no way to spread a conversion of one property to multiple columns or vice-versa. Please vote (👍) for [GitHub issue #13947](https://github.com/dotnet/efcore/issues/13947) if this is something you need. +* Value generation is not supported for most keys mapped through value converters. Please vote (👍) for [GitHub issue #11597](https://github.com/dotnet/efcore/issues/11597) if this is something you need. +* Value conversions cannot reference the current DbContext instance. Please vote (👍) for [GitHub issue #11597](https://github.com/dotnet/efcore/issues/12205) if this is something you need. + +Removal of these limitations is being considered for future releases. diff --git a/samples/core/Modeling/ValueConversions/CaseInsensitiveStrings.cs b/samples/core/Modeling/ValueConversions/CaseInsensitiveStrings.cs new file mode 100644 index 0000000000..4880b92100 --- /dev/null +++ b/samples/core/Modeling/ValueConversions/CaseInsensitiveStrings.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EFModeling.ValueConversions +{ + public class CaseInsensitiveStrings : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions for case-insensitive string keys..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save new entities..."); + + context.AddRange( + new Blog + { + Id = "dotnet", + Name = ".NET Blog", + }, + new Post + { + Id = "post1", + BlogId = "dotnet", + Title = "Some good .NET stuff" + }, + new Post + { + Id = "Post2", + BlogId = "DotNet", + Title = "Some more good .NET stuff" + }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entities back..."); + + var blog = context.Set().Include(e => e.Posts).Single(); + + ConsoleWriteLines($"The blog has {blog.Posts.Count} posts with foreign keys '{blog.Posts.First().BlogId}' and '{blog.Posts.Skip(1).First().BlogId}'"); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + #region ConfigureCaseInsensitiveStrings + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var comparer = new ValueComparer( + (l, r) => string.Equals(l, r, StringComparison.OrdinalIgnoreCase), + v => v.ToUpper().GetHashCode(), + v => v); + + modelBuilder.Entity() + .Property(e => e.Id) + .Metadata.SetValueComparer(comparer); + + modelBuilder.Entity( + b => + { + b.Property(e => e.Id).Metadata.SetValueComparer(comparer); + b.Property(e => e.BlogId).Metadata.SetValueComparer(comparer); + }); + } + #endregion + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=CaseInsensitiveStrings;Integrated Security=True") + .EnableSensitiveDataLogging(); + } + + #region CaseInsensitiveStringsModel + public class Blog + { + public string Id { get; set; } + public string Name { get; set; } + + public ICollection Posts { get; set; } + } + + public class Post + { + public string Id { get; set; } + public string Title { get; set; } + public string Content { get; set; } + + public string BlogId { get; set; } + public Blog Blog { get; set; } + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/CompositeValueObject.cs b/samples/core/Modeling/ValueConversions/CompositeValueObject.cs new file mode 100644 index 0000000000..82224f8810 --- /dev/null +++ b/samples/core/Modeling/ValueConversions/CompositeValueObject.cs @@ -0,0 +1,94 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EFModeling.ValueConversions +{ + public class CompositeValueObject : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions for a composite value object..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save a new entity..."); + + context.Add(new Order { Price = new Money(3.99m, Currency.UsDollars) }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var order = context.Set().Single(); + + ConsoleWriteLines($"Order with price {order.Price.Amount} in {order.Price.Currency}."); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConfigureCompositeValueObject + modelBuilder.Entity() + .Property(e => e.Price) + .HasConversion( + v => JsonSerializer.Serialize(v, null), + v => JsonSerializer.Deserialize(v, null)); + #endregion + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlite("DataSource=test.db") + .EnableSensitiveDataLogging(); + } + + #region CompositeValueObjectModel + public class Order + { + public int Id { get; set; } + + public Money Price { get; set; } + } + #endregion + + #region CompositeValueObject + public readonly struct Money + { + [JsonConstructor] + public Money(decimal amount, Currency currency) + { + Amount = amount; + Currency = currency; + } + + public override string ToString() + => (Currency == Currency.UsDollars ? "$" : "£") + Amount; + + public decimal Amount { get; } + public Currency Currency { get; } + } + + public enum Currency + { + UsDollars, + PoundsStirling + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/EncryptPropertyValues.cs b/samples/core/Modeling/ValueConversions/EncryptPropertyValues.cs new file mode 100644 index 0000000000..ea4b340e5e --- /dev/null +++ b/samples/core/Modeling/ValueConversions/EncryptPropertyValues.cs @@ -0,0 +1,66 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EFModeling.ValueConversions +{ + public class EncryptPropertyValues : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions for encrypting property values..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save a new entity..."); + + context.Add(new User { Name = "arthur", Password = "password" }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var user = context.Set().Single(); + + ConsoleWriteLines($"User {user.Name} has password '{user.Password}'"); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConfigureEncryptPropertyValues + modelBuilder.Entity().Property(e => e.Password).HasConversion( + v => new string(v.Reverse().ToArray()), + v => new string(v.Reverse().ToArray())); + #endregion + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=EncryptPropertyValues;Integrated Security=True") + .EnableSensitiveDataLogging(); + } + + #region EncryptPropertyValuesModel + public class User + { + public int Id { get; set; } + public string Name { get; set; } + public string Password { get; set; } + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/EnumToStringConversions.cs b/samples/core/Modeling/ValueConversions/EnumToStringConversions.cs new file mode 100644 index 0000000000..798c43326c --- /dev/null +++ b/samples/core/Modeling/ValueConversions/EnumToStringConversions.cs @@ -0,0 +1,237 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace EFModeling.ValueConversions +{ + public class EnumToStringConversions : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing explicitly configured value converter"); + + using (var context = new SampleDbContextExplicit()) + { + CleanDatabase(context); + + context.Add(new Rider { Mount = EquineBeast.Horse }); + context.SaveChanges(); + + context.SaveChanges(); + } + + using (var context = new SampleDbContextExplicit()) + { + ConsoleWriteLines($"Enum value read as '{context.Set().Single().Mount}'."); + } + + ConsoleWriteLines("Sample showing conversion configured by CLR type"); + + using (var context = new SampleDbContextByClrType()) + { + CleanDatabase(context); + + context.Add(new Rider { Mount = EquineBeast.Horse }); + context.SaveChanges(); + + context.SaveChanges(); + } + + using (var context = new SampleDbContextByClrType()) + { + ConsoleWriteLines($"Enum value read as '{context.Set().Single().Mount}'."); + } + + ConsoleWriteLines("Sample showing conversion configured by database type"); + + using (var context = new SampleDbContextByDatabaseType()) + { + CleanDatabase(context); + + context.Add(new Rider2 { Mount = EquineBeast.Horse }); + context.SaveChanges(); + + context.SaveChanges(); + } + + using (var context = new SampleDbContextByDatabaseType()) + { + ConsoleWriteLines($"Enum value read as '{context.Set().Single().Mount}'."); + } + + ConsoleWriteLines("Sample showing conversion configured by a ValueConverter instance"); + + using (var context = new SampleDbContextByConverterInstance()) + { + CleanDatabase(context); + + context.Add(new Rider { Mount = EquineBeast.Horse }); + context.SaveChanges(); + + context.SaveChanges(); + } + + using (var context = new SampleDbContextByConverterInstance()) + { + ConsoleWriteLines($"Enum value read as '{context.Set().Single().Mount}'."); + } + + ConsoleWriteLines("Sample showing conversion configured by a built-in ValueConverter instance"); + + using (var context = new SampleDbContextByBuiltInInstance()) + { + CleanDatabase(context); + + context.Add(new Rider { Mount = EquineBeast.Horse }); + context.SaveChanges(); + + context.SaveChanges(); + } + + using (var context = new SampleDbContextByBuiltInInstance()) + { + ConsoleWriteLines($"Enum value read as '{context.Set().Single().Mount}'."); + } + } + + public class SampleDbContextExplicit : SampleDbContextBase + { + #region ExplicitConversion + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .Property(e => e.Mount) + .HasConversion( + v => v.ToString(), + v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v)); + } + #endregion + } + + public class SampleDbContextByClrType : SampleDbContextBase + { + #region ConversionByClrType + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .Property(e => e.Mount) + .HasConversion(); + } + #endregion + } + + public class SampleDbContextByDatabaseType : SampleDbContextBase + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + } + } + + public class SampleDbContextByConverterInstance : SampleDbContextBase + { + #region ConversionByConverterInstance + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var converter = new ValueConverter( + v => v.ToString(), + v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v)); + + modelBuilder + .Entity() + .Property(e => e.Mount) + .HasConversion(converter); + } + #endregion + } + + public class SampleDbContextByBuiltInInstance : SampleDbContextBase + { + #region ConversionByBuiltInInstance + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var converter = new EnumToStringConverter(); + + modelBuilder + .Entity() + .Property(e => e.Mount) + .HasConversion(converter); + } + #endregion + } + + public class SampleDbContextBoolToInt : SampleDbContextBase + { + #region ConversionByBuiltInBoolToInt + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .Property(e => e.IsActive) + .HasConversion(); + } + #endregion + } + + public class SampleDbContextBoolToIntExplicit : SampleDbContextBase + { + #region ConversionByBuiltInBoolToIntExplicit + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var converter = new BoolToZeroOneConverter(); + + modelBuilder + .Entity() + .Property(e => e.IsActive) + .HasConversion(converter); + } + #endregion + } + + public class SampleDbContextBase : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlite("DataSource=test.db") + .EnableSensitiveDataLogging(); + } + + #region BeastAndRider + public class Rider + { + public int Id { get; set; } + public EquineBeast Mount { get; set; } + } + + public enum EquineBeast + { + Donkey, + Mule, + Horse, + Unicorn + } + #endregion + + #region ConversionByDatabaseType + public class Rider2 + { + public int Id { get; set; } + + [Column(TypeName = "nvarchar(24)")] + public EquineBeast Mount { get; set; } + } + #endregion + + public class User + { + public int Id { get; set; } + public bool IsActive { get; set; } + } + } +} diff --git a/samples/core/Modeling/ValueConversions/FixedLengthStrings.cs b/samples/core/Modeling/ValueConversions/FixedLengthStrings.cs new file mode 100644 index 0000000000..6726019c52 --- /dev/null +++ b/samples/core/Modeling/ValueConversions/FixedLengthStrings.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace EFModeling.ValueConversions +{ + public class FixedLengthStrings : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions for fixed-length, case-insensitive string keys..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save new entities..."); + + context.AddRange( + new Blog + { + Id = "dotnet", + Name = ".NET Blog", + }, + new Post + { + Id = "post1", + BlogId = "dotnet", + Title = "Some good .NET stuff" + }, + new Post + { + Id = "Post2", + BlogId = "DotNet", + Title = "Some more good .NET stuff" + }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entities back..."); + + var blog = context.Set().Include(e => e.Posts).Single(); + + ConsoleWriteLines($"The blog has {blog.Posts.Count} posts with foreign keys '{blog.Posts.First().BlogId}' and '{blog.Posts.Skip(1).First().BlogId}'"); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + #region ConfigureFixedLengthStrings + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var converter = new ValueConverter( + v => v, + v => v.Trim()); + + var comparer = new ValueComparer( + (l, r) => string.Equals(l, r, StringComparison.OrdinalIgnoreCase), + v => v.ToUpper().GetHashCode(), + v => v); + + modelBuilder.Entity() + .Property(e => e.Id) + .HasColumnType("char(20)") + .HasConversion(converter, comparer); + + modelBuilder.Entity( + b => + { + b.Property(e => e.Id).HasColumnType("char(20)").HasConversion(converter, comparer); + b.Property(e => e.BlogId).HasColumnType("char(20)").HasConversion(converter, comparer); + }); + } + #endregion + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=FixedLengthStrings;Integrated Security=True") + .EnableSensitiveDataLogging(); + } + + #region FixedLengthStringsModel + public class Blog + { + public string Id { get; set; } + public string Name { get; set; } + + public ICollection Posts { get; set; } + } + + public class Post + { + public string Id { get; set; } + public string Title { get; set; } + public string Content { get; set; } + + public string BlogId { get; set; } + public Blog Blog { get; set; } + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/KeyValueObjects.cs b/samples/core/Modeling/ValueConversions/KeyValueObjects.cs new file mode 100644 index 0000000000..4853551dfe --- /dev/null +++ b/samples/core/Modeling/ValueConversions/KeyValueObjects.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace EFModeling.ValueConversions +{ + public class KeyValueObjects : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions for a value objects used as keys..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save a new entity..."); + + var blog = new Blog + { + Id = new BlogKey(1), + Posts = new List + { + new Post + { + Id = new PostKey(1) + }, + new Post + { + Id = new PostKey(2) + }, + } + }; + context.Add(blog); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var blog = context.Set().Include(e => e.Posts).Single(); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + #region ConfigureKeyValueObjects + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var blogKeyConverter = new ValueConverter( + v => v.Id, + v => new BlogKey(v)); + + modelBuilder.Entity().Property(e => e.Id).HasConversion(blogKeyConverter); + + modelBuilder.Entity( + b => + { + b.Property(e => e.Id).HasConversion(v => v.Id, v => new PostKey(v)); + b.Property(e => e.BlogId).HasConversion(blogKeyConverter); + }); + } + #endregion + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlite("DataSource=test.db") + .EnableSensitiveDataLogging(); + } + + #region KeyValueObjectsModel + public class Blog + { + public BlogKey Id { get; set; } + public string Name { get; set; } + + public ICollection Posts { get; set; } + } + + public class Post + { + public PostKey Id { get; set; } + + public string Title { get; set; } + public string Content { get; set; } + + public BlogKey? BlogId { get; set; } + public Blog Blog { get; set; } + } + #endregion + + #region KeyValueObjects + public readonly struct BlogKey + { + public BlogKey(int id) => Id = id; + public int Id { get; } + } + + public readonly struct PostKey + { + public PostKey(int id) => Id = id; + public int Id { get; } + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/PreserveDateTimeKind.cs b/samples/core/Modeling/ValueConversions/PreserveDateTimeKind.cs new file mode 100644 index 0000000000..7c9c867a56 --- /dev/null +++ b/samples/core/Modeling/ValueConversions/PreserveDateTimeKind.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EFModeling.ValueConversions +{ + public class PreserveDateTimeKind : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions for preserving/setting DateTime.Kind..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save new entities..."); + + context.AddRange( + new Post + { + Title = "Post 1", + PostedOn = new DateTime(1973, 9, 3, 0, 0, 0, 0, DateTimeKind.Utc), + LastUpdated = new DateTime(1974, 9, 3, 0, 0, 0, 0, DateTimeKind.Utc), + DeletedOn = new DateTime(2007, 9, 3, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new Post + { + Title = "Post 2", + PostedOn = new DateTime(1975, 9, 3, 0, 0, 0, 0, DateTimeKind.Local), + LastUpdated = new DateTime(1976, 9, 3, 0, 0, 0, 0, DateTimeKind.Utc), + DeletedOn = new DateTime(2017, 9, 3, 0, 0, 0, 0, DateTimeKind.Utc) + }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entities back..."); + + var blog1 = context.Set().Single(e => e.Title == "Post 1"); + + ConsoleWriteLines($"Blog 1: PostedOn.Kind = {blog1.PostedOn.Kind} LastUpdated.Kind = {blog1.LastUpdated.Kind} DeletedOn.Kind = {blog1.DeletedOn.Kind}"); + + var blog2 = context.Set().Single(e => e.Title == "Post 2"); + + ConsoleWriteLines($"Blog 2: PostedOn.Kind = {blog2.PostedOn.Kind} LastUpdated.Kind = {blog2.LastUpdated.Kind} DeletedOn.Kind = {blog2.DeletedOn.Kind}"); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConfigurePreserveDateTimeKind1 + modelBuilder.Entity() + .Property(e => e.PostedOn) + .HasConversion(); + #endregion + + #region ConfigurePreserveDateTimeKind2 + modelBuilder.Entity() + .Property(e => e.LastUpdated) + .HasConversion( + v => v, + v => new DateTime(v.Ticks, DateTimeKind.Utc)); + #endregion + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=PreserveDateTimeKind;Integrated Security=True") + .EnableSensitiveDataLogging(); + } + + #region PreserveDateTimeKindModel + public class Post + { + public int Id { get; set; } + + public string Title { get; set; } + public string Content { get; set; } + + public DateTime PostedOn { get; set; } + public DateTime LastUpdated { get; set; } + public DateTime DeletedOn { get; set; } + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/PrimitiveCollection.cs b/samples/core/Modeling/ValueConversions/PrimitiveCollection.cs new file mode 100644 index 0000000000..a847c86af9 --- /dev/null +++ b/samples/core/Modeling/ValueConversions/PrimitiveCollection.cs @@ -0,0 +1,82 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EFModeling.ValueConversions +{ + public class PrimitiveCollection : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions for a collections of primitive values..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save a new entity..."); + + context.Add(new Post { Tags = new List { "EF Core", "Unicorns", "Donkeys" } }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var post = context.Set().Single(); + + ConsoleWriteLines($"Post with tags {string.Join(", ", post.Tags)}."); + + ConsoleWriteLines("Changing the value object and saving again"); + + post.Tags.Add("ASP.NET Core"); + context.SaveChanges(); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConfigurePrimitiveCollection + modelBuilder.Entity() + .Property(e => e.Tags) + .HasConversion( + v => JsonSerializer.Serialize(v, null), + v => JsonSerializer.Deserialize>(v, null), + new ValueComparer>( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => (ICollection)c.ToList())); + #endregion + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlite("DataSource=test.db") + .EnableSensitiveDataLogging(); + } + + #region PrimitiveCollectionModel + public class Post + { + public int Id { get; set; } + public string Title { get; set; } + public string Contents { get; set; } + + public ICollection Tags { get; set; } + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/Program.cs b/samples/core/Modeling/ValueConversions/Program.cs index 2bc4edf6cc..9e0365e12b 100644 --- a/samples/core/Modeling/ValueConversions/Program.cs +++ b/samples/core/Modeling/ValueConversions/Program.cs @@ -4,7 +4,7 @@ namespace EFModeling.ValueConversions { /// - /// Samples for value conversions and comparisons. + /// Samples for value conversions and comparisons. /// public class Program { @@ -15,6 +15,18 @@ public static void Main() new MappingListProperty().Run(); new MappingListPropertyOld().Run(); new OverridingByteArrayComparisons().Run(); + new EnumToStringConversions().Run(); + new KeyValueObjects().Run(); + new SimpleValueObject().Run(); + new CompositeValueObject().Run(); + new PrimitiveCollection().Run(); + new ValueObjectCollection().Run(); + new ULongConcurrency().Run(); + new PreserveDateTimeKind().Run(); + new CaseInsensitiveStrings().Run(); + new FixedLengthStrings().Run(); + new EncryptPropertyValues().Run(); + new WithMappingHints().Run(); } protected static void ConsoleWriteLines(params string[] values) @@ -24,6 +36,7 @@ protected static void ConsoleWriteLines(params string[] values) { Console.WriteLine(value); } + Console.WriteLine(); } diff --git a/samples/core/Modeling/ValueConversions/SimpleValueObject.cs b/samples/core/Modeling/ValueConversions/SimpleValueObject.cs new file mode 100644 index 0000000000..c3e378ccfe --- /dev/null +++ b/samples/core/Modeling/ValueConversions/SimpleValueObject.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EFModeling.ValueConversions +{ + public class SimpleValueObject : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions for a simple value object..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save a new entity..."); + + context.Add(new Order { Price = new Dollars(3.99m) }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var entity = context.Set().Single(); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConfigureImmutableStructProperty + modelBuilder.Entity() + .Property(e => e.Price) + .HasConversion( + v => v.Amount, + v => new Dollars(v)); + #endregion + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlite("DataSource=test.db") + .EnableSensitiveDataLogging(); + } + + #region SimpleValueObjectModel + public class Order + { + public int Id { get; set; } + + public Dollars Price { get; set; } + } + #endregion + + #region SimpleValueObject + public readonly struct Dollars + { + public Dollars(decimal amount) + => Amount = amount; + + public decimal Amount { get; } + + public override string ToString() + => $"${Amount}"; + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/ULongConcurrency.cs b/samples/core/Modeling/ValueConversions/ULongConcurrency.cs new file mode 100644 index 0000000000..ae50aea49d --- /dev/null +++ b/samples/core/Modeling/ValueConversions/ULongConcurrency.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EFModeling.ValueConversions +{ + public class ULongConcurrency : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing how to map rowversion to ulong..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save a new entity..."); + + context.Add( + new Blog + { + Name = "OneUnicorn" + }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back in one context..."); + + var blog = context.Set().Single(); + blog.Name = "TwoUnicorns"; + + using (var context2 = new SampleDbContext()) + { + ConsoleWriteLines("Change the blog name and save in a different context..."); + + context2.Set().Single().Name = "1unicorn2"; + context2.SaveChanges(); + } + + try + { + ConsoleWriteLines("Change the blog name and save in the first context..."); + + context.SaveChanges(); + } + catch (DbUpdateConcurrencyException e) + { + ConsoleWriteLines($"{e.GetType().FullName}: {e.Message}"); + + var databaseValues = context.Entry(blog).GetDatabaseValues(); + context.Entry(blog).OriginalValues.SetValues(databaseValues); + + ConsoleWriteLines("Refresh original values and save again..."); + + context.SaveChanges(); + } + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConfigureULongConcurrency + modelBuilder.Entity() + .Property(e => e.Version) + .IsRowVersion() + .HasConversion(); + #endregion + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=ULongConcurrency;Integrated Security=True") + .EnableSensitiveDataLogging(); + } + + #region ULongConcurrencyModel + public class Blog + { + public int Id { get; set; } + public string Name { get; set; } + public ulong Version { get; set; } + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/ValueConversions.csproj b/samples/core/Modeling/ValueConversions/ValueConversions.csproj index cc4560ac41..8265c829a1 100644 --- a/samples/core/Modeling/ValueConversions/ValueConversions.csproj +++ b/samples/core/Modeling/ValueConversions/ValueConversions.csproj @@ -8,7 +8,8 @@ - + + diff --git a/samples/core/Modeling/ValueConversions/ValueObjectCollection.cs b/samples/core/Modeling/ValueConversions/ValueObjectCollection.cs new file mode 100644 index 0000000000..0d4eb49dda --- /dev/null +++ b/samples/core/Modeling/ValueConversions/ValueObjectCollection.cs @@ -0,0 +1,133 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EFModeling.ValueConversions +{ + public class ValueObjectCollection : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions for a collection of value objects..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save a new entity..."); + + context.Add( + new Blog + { + Finances = new List + { + new AnnualFinance(2018, new Money(326.65m, Currency.UsDollars), new Money(125m, Currency.UsDollars)), + new AnnualFinance(2019, new Money(112.20m, Currency.UsDollars), new Money(125m, Currency.UsDollars)), + new AnnualFinance(2020, new Money(25.77m, Currency.UsDollars), new Money(125m, Currency.UsDollars)) + } + }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entity back..."); + + var blog = context.Set().Single(); + + ConsoleWriteLines($"Blog with finances {string.Join(", ", blog.Finances.Select(f => $"{f.Year}: I={f.Income} E={f.Expenses} R={f.Revenue}"))}."); + + ConsoleWriteLines("Changing the value object and saving again"); + + blog.Finances.Add(new AnnualFinance(2021, new Money(12.0m, Currency.UsDollars), new Money(125m, Currency.UsDollars))); + context.SaveChanges(); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConfigureValueObjectCollection + modelBuilder.Entity() + .Property(e => e.Finances) + .HasConversion( + v => JsonSerializer.Serialize(v, null), + v => JsonSerializer.Deserialize>(v, null), + new ValueComparer>( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => (IList)c.ToList())); + #endregion + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlite("DataSource=test.db") + .EnableSensitiveDataLogging(); + } + + #region ValueObjectCollection + public readonly struct AnnualFinance + { + [JsonConstructor] + public AnnualFinance(int year, Money income, Money expenses) + { + Year = year; + Income = income; + Expenses = expenses; + } + + public int Year { get; } + public Money Income { get; } + public Money Expenses { get; } + public Money Revenue => new Money(Income.Amount - Expenses.Amount, Income.Currency); + } + #endregion + + #region ValueObjectCollectionMoney + public readonly struct Money + { + [JsonConstructor] + public Money(decimal amount, Currency currency) + { + Amount = amount; + Currency = currency; + } + + public override string ToString() + => (Currency == Currency.UsDollars ? "$" : "£") + Amount; + + public decimal Amount { get; } + public Currency Currency { get; } + } + + public enum Currency + { + UsDollars, + PoundsStirling + } + #endregion + + #region ValueObjectCollectionModel + public class Blog + { + public int Id { get; set; } + public string Name { get; set; } + + public IList Finances { get; set; } + } + #endregion + } +} diff --git a/samples/core/Modeling/ValueConversions/WithMappingHints.cs b/samples/core/Modeling/ValueConversions/WithMappingHints.cs new file mode 100644 index 0000000000..e14c0002b1 --- /dev/null +++ b/samples/core/Modeling/ValueConversions/WithMappingHints.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace EFModeling.ValueConversions +{ + public class WithMappingHints : Program + { + public void Run() + { + ConsoleWriteLines("Sample showing value conversions with mapping hints for facets..."); + + using (var context = new SampleDbContext()) + { + CleanDatabase(context); + + ConsoleWriteLines("Save a entities..."); + + context.Add(new Order1 { Price = new Dollars(3.99m) }); + context.Add(new Order2 { Price = new Dollars(3.99m) }); + context.SaveChanges(); + } + + using (var context = new SampleDbContext()) + { + ConsoleWriteLines("Read the entities back..."); + + var entity1 = context.Set().Single(); + var entity2 = context.Set().Single(); + } + + ConsoleWriteLines("Sample finished."); + } + + public class SampleDbContext : DbContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConverterWithMappingHints + var converter = new ValueConverter( + v => v.Amount, + v => new Dollars(v), + new ConverterMappingHints(precision: 16, scale: 2)); + #endregion + + modelBuilder.Entity() + .Property(e => e.Price) + .HasConversion(converter); + + #region ConfigureWithFacets + modelBuilder.Entity() + .Property(e => e.Price) + .HasConversion(converter) + .HasPrecision(20, 2); + #endregion + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }) + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=WithMappingHints;Integrated Security=True") + .EnableSensitiveDataLogging(); + } + + public class Order1 + { + public int Id { get; set; } + + public Dollars Price { get; set; } + } + + public class Order2 + { + public int Id { get; set; } + + public Dollars Price { get; set; } + } + + public readonly struct Dollars + { + public Dollars(decimal amount) => Amount = amount; + public decimal Amount { get; } + } + } +}