From 423a107e1e160763b18779e0b3107049f03ffa63 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 28 Jul 2021 09:42:55 -0700 Subject: [PATCH] Cosmos: Add basic support for collections and dictionaries of primitive types (#25344) Fixes #14762 --- .../ChangeTracking/Internal/ListComparer.cs | 101 +++++++++++ .../Internal/NullableEqualityComparer.cs | 48 +++++ .../Internal/NullableListComparer.cs | 112 ++++++++++++ .../NullableSingleDimensionalArrayComparer.cs | 101 +++++++++++ .../NullableStringDictionaryComparer.cs | 119 ++++++++++++ .../SingleDimensionalArrayComparer.cs | 88 +++++++++ .../Internal/StringDictionaryComparer.cs | 107 +++++++++++ ...osmosInversePropertyAttributeConvention.cs | 2 +- .../CosmosRelationshipDiscoveryConvention.cs | 12 +- .../Internal/CosmosTypeMappingSource.cs | 113 +++++++++++- src/EFCore/Storage/CoreTypeMapping.cs | 10 ++ .../EndToEndCosmosTest.cs | 170 ++++++++++++++++++ .../CosmosModelBuilderGenericTest.cs | 14 ++ 13 files changed, 989 insertions(+), 8 deletions(-) create mode 100644 src/EFCore.Cosmos/ChangeTracking/Internal/ListComparer.cs create mode 100644 src/EFCore.Cosmos/ChangeTracking/Internal/NullableEqualityComparer.cs create mode 100644 src/EFCore.Cosmos/ChangeTracking/Internal/NullableListComparer.cs create mode 100644 src/EFCore.Cosmos/ChangeTracking/Internal/NullableSingleDimensionalArrayComparer.cs create mode 100644 src/EFCore.Cosmos/ChangeTracking/Internal/NullableStringDictionaryComparer.cs create mode 100644 src/EFCore.Cosmos/ChangeTracking/Internal/SingleDimensionalArrayComparer.cs create mode 100644 src/EFCore.Cosmos/ChangeTracking/Internal/StringDictionaryComparer.cs diff --git a/src/EFCore.Cosmos/ChangeTracking/Internal/ListComparer.cs b/src/EFCore.Cosmos/ChangeTracking/Internal/ListComparer.cs new file mode 100644 index 00000000000..f55d5ea3891 --- /dev/null +++ b/src/EFCore.Cosmos/ChangeTracking/Internal/ListComparer.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public sealed class ListComparer : ValueComparer + where TCollection : class, IEnumerable + { + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public ListComparer(ValueComparer elementComparer, bool readOnly) + : base( + (a, b) => Compare(a, b, (ValueComparer)elementComparer), + o => GetHashCode(o, (ValueComparer)elementComparer), + source => Snapshot(source, (ValueComparer)elementComparer, readOnly)) + { + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override Type Type => typeof(TCollection); + + private static bool Compare(TCollection? a, TCollection? b, ValueComparer elementComparer) + { + if (a is not IReadOnlyList aList) + { + return b is not IReadOnlyList; + } + + if (b is not IReadOnlyList bList || aList.Count != bList.Count) + { + return false; + } + + if (aList == bList) + { + return true; + } + + for (var i = 0; i < aList.Count; i++) + { + if (!elementComparer.Equals(aList[i], bList[i])) + { + return false; + } + } + + return true; + } + + private static int GetHashCode(TCollection source, ValueComparer elementComparer) + { + var hash = new HashCode(); + foreach (var el in source) + { + hash.Add(el, elementComparer); + } + + return hash.ToHashCode(); + } + + private static TCollection? Snapshot(TCollection? source, ValueComparer elementComparer, bool readOnly) + { + if (source == null) + { + return null; + } + + if (readOnly) + { + return source; + } + + var snapshot = new List(((IReadOnlyList)source).Count); + foreach (var e in source) + { + snapshot.Add(elementComparer.Snapshot(e)!); + } + + return (TCollection)(object)snapshot; + } + } +} diff --git a/src/EFCore.Cosmos/ChangeTracking/Internal/NullableEqualityComparer.cs b/src/EFCore.Cosmos/ChangeTracking/Internal/NullableEqualityComparer.cs new file mode 100644 index 00000000000..5ee02748849 --- /dev/null +++ b/src/EFCore.Cosmos/ChangeTracking/Internal/NullableEqualityComparer.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public class NullableEqualityComparer : IEqualityComparer + where T : struct + { + private readonly IEqualityComparer _underlyingComparer; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public NullableEqualityComparer(IEqualityComparer underlyingComparer) + => _underlyingComparer = underlyingComparer; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual bool Equals(T? x, T? y) + => x is null + ? y is null + : y.HasValue && _underlyingComparer.Equals(x.Value, y.Value); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual int GetHashCode(T? obj) + => obj is null ? 0 : _underlyingComparer.GetHashCode(obj.Value); + } +} diff --git a/src/EFCore.Cosmos/ChangeTracking/Internal/NullableListComparer.cs b/src/EFCore.Cosmos/ChangeTracking/Internal/NullableListComparer.cs new file mode 100644 index 00000000000..b1582cc32b1 --- /dev/null +++ b/src/EFCore.Cosmos/ChangeTracking/Internal/NullableListComparer.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public sealed class NullableListComparer : ValueComparer + where TCollection : class, IEnumerable + where TElement : struct + { + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public NullableListComparer(ValueComparer elementComparer, bool readOnly) + : base( + (a, b) => Compare(a, b, (ValueComparer)elementComparer), + o => GetHashCode(o, (ValueComparer)elementComparer), + source => Snapshot(source, (ValueComparer)elementComparer, readOnly)) + { } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override Type Type => typeof(TCollection); + + private static bool Compare(TCollection? a, TCollection? b, ValueComparer elementComparer) + { + if (a is not IReadOnlyList aList) + { + return b is not IReadOnlyList; + } + + if (b is not IReadOnlyList bList || aList.Count != bList.Count) + { + return false; + } + + if (aList == bList) + { + return true; + } + + for (var i = 0; i < aList.Count; i++) + { + var (aElement, bElement) = (aList[i], bList[i]); + if (aElement is null) + { + if (bElement is null) + { + continue; + } + + return false; + } + if (bElement is null || !elementComparer.Equals(aElement, bElement)) + { + return false; + } + } + + return true; + } + + private static int GetHashCode(TCollection source, ValueComparer elementComparer) + { + var nullableEqualityComparer = new NullableEqualityComparer(elementComparer); + var hash = new HashCode(); + foreach (var el in source) + { + hash.Add(el, nullableEqualityComparer); + } + + return hash.ToHashCode(); + } + + private static TCollection? Snapshot(TCollection? source, ValueComparer elementComparer, bool readOnly) + { + if (source == null) + { + return null; + } + + if (readOnly) + { + return source; + } + + var snapshot = new List(((IReadOnlyList)source).Count); + foreach (var e in source) + { + snapshot.Add(e is { } value ? elementComparer.Snapshot(value) : null); + } + + return (TCollection)(object)snapshot; + } + } +} diff --git a/src/EFCore.Cosmos/ChangeTracking/Internal/NullableSingleDimensionalArrayComparer.cs b/src/EFCore.Cosmos/ChangeTracking/Internal/NullableSingleDimensionalArrayComparer.cs new file mode 100644 index 00000000000..04f6d950605 --- /dev/null +++ b/src/EFCore.Cosmos/ChangeTracking/Internal/NullableSingleDimensionalArrayComparer.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public sealed class NullableSingleDimensionalArrayComparer : ValueComparer + where TElement : struct + { + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public NullableSingleDimensionalArrayComparer(ValueComparer elementComparer) : base( + (a, b) => Compare(a, b, (ValueComparer)elementComparer), + o => GetHashCode(o, (ValueComparer)elementComparer), + source => Snapshot(source, (ValueComparer)elementComparer)) + { } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override Type Type => typeof(TElement?[]); + + private static bool Compare(TElement?[]? a, TElement?[]? b, ValueComparer elementComparer) + { + if (a is null) + { + return b is null; + } + + if (b is null || a.Length != b.Length) + { + return false; + } + + for (var i = 0; i < a.Length; i++) + { + var (aElement, bElement) = (a[i], b[i]); + if (aElement is null) + { + if (bElement is null) + { + continue; + } + + return false; + } + if (bElement is null || !elementComparer.Equals(aElement, bElement)) + { + return false; + } + } + + return true; + } + + private static int GetHashCode(TElement?[] source, ValueComparer elementComparer) + { + var nullableEqualityComparer = new NullableEqualityComparer(elementComparer); + var hash = new HashCode(); + foreach (var el in source) + { + hash.Add(el, nullableEqualityComparer); + } + + return hash.ToHashCode(); + } + + [return: NotNullIfNotNull("source")] + private static TElement?[]? Snapshot(TElement?[]? source, ValueComparer elementComparer) + { + if (source == null) + { + return null; + } + + var snapshot = new TElement?[source.Length]; + for (var i = 0; i < source.Length; i++) + { + snapshot[i] = source[i] is { } value ? elementComparer.Snapshot(value) : null; + } + + return snapshot; + } + } +} diff --git a/src/EFCore.Cosmos/ChangeTracking/Internal/NullableStringDictionaryComparer.cs b/src/EFCore.Cosmos/ChangeTracking/Internal/NullableStringDictionaryComparer.cs new file mode 100644 index 00000000000..5f3c4ae58be --- /dev/null +++ b/src/EFCore.Cosmos/ChangeTracking/Internal/NullableStringDictionaryComparer.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public sealed class NullableStringDictionaryComparer : ValueComparer + where TCollection : class, IEnumerable> + where TElement : struct + { + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public NullableStringDictionaryComparer(ValueComparer elementComparer, bool readOnly) + : base( + (a, b) => Compare(a, b, (ValueComparer)elementComparer), + o => GetHashCode(o, (ValueComparer)elementComparer), + source => Snapshot(source, (ValueComparer)elementComparer, readOnly)) + { + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override Type Type => typeof(TCollection); + + private static bool Compare(TCollection? a, TCollection? b, ValueComparer elementComparer) + { + if (a is not IReadOnlyDictionary aDict) + { + return b is not IReadOnlyDictionary; + } + + if (b is not IReadOnlyDictionary bDict || aDict.Count != bDict.Count) + { + return false; + } + + if (aDict == bDict) + { + return true; + } + + foreach (var aPair in aDict) + { + if (!bDict.TryGetValue(aPair.Key, out var bValue)) + { + return false; + } + + if (aPair.Value is null) + { + if (bValue is null) + { + continue; + } + + return false; + } + + if (bValue is null || !elementComparer.Equals(aPair.Value, bValue)) + { + return false; + } + } + + return true; + } + + private static int GetHashCode(TCollection source, ValueComparer elementComparer) + { + var nullableEqualityComparer = new NullableEqualityComparer(elementComparer); + var hash = new HashCode(); + foreach (var el in source) + { + hash.Add(el.Key); + hash.Add(el.Value, nullableEqualityComparer); + } + + return hash.ToHashCode(); + } + + private static TCollection? Snapshot(TCollection? source, ValueComparer elementComparer, bool readOnly) + { + if (source == null) + { + return null; + } + + if (readOnly) + { + return source; + } + + var snapshot = new Dictionary(((IReadOnlyDictionary)source).Count); + foreach (var e in source) + { + snapshot.Add(e.Key, e.Value is null ? null : elementComparer.Snapshot(e.Value.Value)); + } + + return (TCollection)(object)snapshot; + } + } +} diff --git a/src/EFCore.Cosmos/ChangeTracking/Internal/SingleDimensionalArrayComparer.cs b/src/EFCore.Cosmos/ChangeTracking/Internal/SingleDimensionalArrayComparer.cs new file mode 100644 index 00000000000..bf195dc1169 --- /dev/null +++ b/src/EFCore.Cosmos/ChangeTracking/Internal/SingleDimensionalArrayComparer.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public sealed class SingleDimensionalArrayComparer : ValueComparer + { + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SingleDimensionalArrayComparer(ValueComparer elementComparer) : base( + (a, b) => Compare(a, b, (ValueComparer)elementComparer), + o => GetHashCode(o, (ValueComparer)elementComparer), + source => Snapshot(source, (ValueComparer)elementComparer)) + { } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override Type Type => typeof(TElement[]); + + private static bool Compare(TElement[]? a, TElement[]? b, ValueComparer elementComparer) + { + if (a is null) + { + return b is null; + } + + if (b is null || a.Length != b.Length) + { + return false; + } + + for (var i = 0; i < a.Length; i++) + { + if (!elementComparer.Equals(a[i], b[i])) + { + return false; + } + } + + return true; + } + + private static int GetHashCode(TElement[] source, ValueComparer elementComparer) + { + var hash = new HashCode(); + foreach (var el in source) + { + hash.Add(el, elementComparer); + } + + return hash.ToHashCode(); + } + + [return: NotNullIfNotNull("source")] + private static TElement[]? Snapshot(TElement[]? source, ValueComparer elementComparer) + { + if (source == null) + { + return null; + } + + var snapshot = new TElement[source.Length]; + for (var i = 0; i < source.Length; i++) + { + snapshot[i] = elementComparer.Snapshot(source[i])!; + } + return snapshot; + } + } +} diff --git a/src/EFCore.Cosmos/ChangeTracking/Internal/StringDictionaryComparer.cs b/src/EFCore.Cosmos/ChangeTracking/Internal/StringDictionaryComparer.cs new file mode 100644 index 00000000000..caa08e52258 --- /dev/null +++ b/src/EFCore.Cosmos/ChangeTracking/Internal/StringDictionaryComparer.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public sealed class StringDictionaryComparer : ValueComparer + where TCollection : class, IEnumerable> + { + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public StringDictionaryComparer(ValueComparer elementComparer, bool readOnly) + : base( + (a, b) => Compare(a, b, (ValueComparer)elementComparer), + o => GetHashCode(o, (ValueComparer)elementComparer), + source => Snapshot(source, (ValueComparer)elementComparer, readOnly)) + { + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override Type Type => typeof(TCollection); + + private static bool Compare(TCollection? a, TCollection? b, ValueComparer elementComparer) + { + if (a is not IReadOnlyDictionary aDict) + { + return b is not IReadOnlyDictionary; + } + + if (b is not IReadOnlyDictionary bDict || aDict.Count != bDict.Count) + { + return false; + } + + if (aDict == bDict) + { + return true; + } + + foreach (var aPair in aDict) + { + if (!bDict.TryGetValue(aPair.Key, out var bValue)) + { + return false; + } + + if (!elementComparer.Equals(aPair.Value, bValue)) + { + return false; + } + } + + return true; + } + + private static int GetHashCode(TCollection source, ValueComparer elementComparer) + { + var hash = new HashCode(); + foreach (var el in source) + { + hash.Add(el.Key); + hash.Add(el.Value, elementComparer); + } + + return hash.ToHashCode(); + } + + private static TCollection? Snapshot(TCollection? source, ValueComparer elementComparer, bool readOnly) + { + if (source == null) + { + return null; + } + + if (readOnly) + { + return source; + } + + var snapshot = new Dictionary(((IReadOnlyDictionary)source).Count); + foreach (var e in source) + { + snapshot.Add(e.Key, elementComparer.Snapshot(e.Value)!); + } + + return (TCollection)(object)snapshot; + } + } +} diff --git a/src/EFCore.Cosmos/Metadata/Conventions/CosmosInversePropertyAttributeConvention.cs b/src/EFCore.Cosmos/Metadata/Conventions/CosmosInversePropertyAttributeConvention.cs index 0d5bf5164d1..ac5f1f8bc4b 100644 --- a/src/EFCore.Cosmos/Metadata/Conventions/CosmosInversePropertyAttributeConvention.cs +++ b/src/EFCore.Cosmos/Metadata/Conventions/CosmosInversePropertyAttributeConvention.cs @@ -46,7 +46,7 @@ public CosmosInversePropertyAttributeConvention(ProviderConventionSetBuilderDepe targetClrType, navigationMemberInfo, shouldCreate ? ConfigurationSource.DataAnnotation : null, - targetShouldBeOwned: true); + CosmosRelationshipDiscoveryConvention.ShouldBeOwnedType(targetClrType, entityTypeBuilder.Metadata.Model)); #pragma warning restore EF1001 // Internal EF Core API usage. } } diff --git a/src/EFCore.Cosmos/Metadata/Conventions/CosmosRelationshipDiscoveryConvention.cs b/src/EFCore.Cosmos/Metadata/Conventions/CosmosRelationshipDiscoveryConvention.cs index ca95450527d..10a660c8c9e 100644 --- a/src/EFCore.Cosmos/Metadata/Conventions/CosmosRelationshipDiscoveryConvention.cs +++ b/src/EFCore.Cosmos/Metadata/Conventions/CosmosRelationshipDiscoveryConvention.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; // ReSharper disable once CheckNamespace @@ -30,6 +31,15 @@ public CosmosRelationshipDiscoveryConvention(ProviderConventionSetBuilderDepende /// The model. /// if the given entity type should be owned. protected override bool? ShouldBeOwned(Type targetType, IConventionModel model) - => true; + => ShouldBeOwnedType(targetType, model); + + /// + /// Returns a value indicating whether the given entity type should be added as owned if it isn't currently in the model. + /// + /// Target entity type. + /// The model. + /// if the given entity type should be owned. + public static bool ShouldBeOwnedType(Type targetType, IConventionModel model) + => !targetType.IsGenericType || targetType.GetGenericTypeDefinition() != typeof(List<>); } } diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs index 1755aea378a..fce71a4dfc9 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Utilities; using Newtonsoft.Json.Linq; @@ -32,7 +33,6 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) _clrTypeMappings = new Dictionary { - { typeof(byte[]), new CosmosTypeMapping(typeof(byte[]), keyComparer: new ArrayStructuralComparer()) }, { typeof(JObject), new CosmosTypeMapping(typeof(JObject)) } }; } @@ -48,11 +48,16 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) var clrType = mappingInfo.ClrType; Check.DebugAssert(clrType != null, "ClrType is null"); - if (_clrTypeMappings.TryGetValue(clrType, out var mapping)) - { - return mapping; - } + return _clrTypeMappings.TryGetValue(clrType, out var mapping) + ? mapping + : FindPrimitiveMapping(mappingInfo) + ?? FindCollectionMapping(mappingInfo) + ?? base.FindMapping(mappingInfo); + } + private CoreTypeMapping? FindPrimitiveMapping(in TypeMappingInfo mappingInfo) + { + var clrType = mappingInfo.ClrType!; if ((clrType.IsValueType && !clrType.IsEnum) || clrType == typeof(string)) @@ -60,7 +65,103 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) return new CosmosTypeMapping(clrType); } - return base.FindMapping(mappingInfo); + return null; + } + + private CoreTypeMapping? FindCollectionMapping(in TypeMappingInfo mappingInfo) + { + var clrType = mappingInfo.ClrType!; + var elementType = clrType.TryGetSequenceType(); + if (elementType == null) + { + return null; + } + + if (clrType.IsArray) + { + var elementMapping = FindPrimitiveMapping(new TypeMappingInfo(elementType)); + return elementMapping == null + ? null + : new CosmosTypeMapping(clrType, CreateArrayComparer(elementMapping, elementType)); + } + + if (clrType.IsGenericType + && !clrType.IsGenericTypeDefinition) + { + var genericTypeDefinition = clrType.GetGenericTypeDefinition(); + if (genericTypeDefinition == typeof(List<>) + || genericTypeDefinition == typeof(IList<>) + || genericTypeDefinition == typeof(IReadOnlyList<>)) + { + var elementMapping = FindPrimitiveMapping(new TypeMappingInfo(elementType)); + return elementMapping == null + ? null + : new CosmosTypeMapping(clrType, + CreateListComparer(elementMapping, elementType, clrType, genericTypeDefinition == typeof(IReadOnlyList<>))); + } + + if (genericTypeDefinition == typeof(Dictionary<,>) + || genericTypeDefinition == typeof(IDictionary<,>) + || genericTypeDefinition == typeof(IReadOnlyDictionary<,>)) + { + var genericArguments = clrType.GenericTypeArguments; + if (genericArguments[0] != typeof(string)) + { + return null; + } + + elementType = genericArguments[1]; + var elementMapping = FindPrimitiveMapping(new TypeMappingInfo(elementType)); + if(elementMapping == null) + { + return null; + } + + return elementMapping == null + ? null + : new CosmosTypeMapping(clrType, + CreateStringDictionaryComparer(elementMapping, elementType, clrType, genericTypeDefinition == typeof(IReadOnlyDictionary<,>))); + } + } + + return null; + } + + private static ValueComparer CreateArrayComparer(CoreTypeMapping elementMapping, Type elementType) + { + var unwrappedType = elementType.UnwrapNullableType(); + + return (ValueComparer)Activator.CreateInstance( + elementType == unwrappedType + ? typeof(SingleDimensionalArrayComparer<>).MakeGenericType(elementType) + : typeof(NullableSingleDimensionalArrayComparer<>).MakeGenericType(unwrappedType), + elementMapping.Comparer)!; + } + + private static ValueComparer CreateListComparer( + CoreTypeMapping elementMapping, Type elementType, Type listType, bool readOnly) + { + var unwrappedType = elementType.UnwrapNullableType(); + + return (ValueComparer)Activator.CreateInstance( + elementType == unwrappedType + ? typeof(ListComparer<,>).MakeGenericType(elementType, listType) + : typeof(NullableListComparer<,>).MakeGenericType(unwrappedType, listType), + elementMapping.Comparer, + readOnly)!; + } + + private static ValueComparer CreateStringDictionaryComparer( + CoreTypeMapping elementMapping, Type elementType, Type dictType, bool readOnly) + { + var unwrappedType = elementType.UnwrapNullableType(); + + return (ValueComparer)Activator.CreateInstance( + elementType == unwrappedType + ? typeof(StringDictionaryComparer<,>).MakeGenericType(elementType, dictType) + : typeof(NullableStringDictionaryComparer<,>).MakeGenericType(unwrappedType, dictType), + elementMapping.Comparer, + readOnly)!; } } } diff --git a/src/EFCore/Storage/CoreTypeMapping.cs b/src/EFCore/Storage/CoreTypeMapping.cs index 6aa5cf863dc..5de231426e9 100644 --- a/src/EFCore/Storage/CoreTypeMapping.cs +++ b/src/EFCore/Storage/CoreTypeMapping.cs @@ -146,11 +146,21 @@ protected CoreTypeMapping(CoreTypeMappingParameters parameters) var clrType = converter?.ModelClrType ?? parameters.ClrType; ClrType = clrType; + Check.DebugAssert(parameters.Comparer == null + || parameters.ClrType == null + || converter != null + || parameters.Comparer.Type == parameters.ClrType, + $"Expected {parameters.ClrType}, got {parameters.Comparer?.Type}"); if (parameters.Comparer?.Type == clrType) { _comparer = parameters.Comparer; } + Check.DebugAssert(parameters.KeyComparer == null + || parameters.ClrType == null + || converter != null + || parameters.KeyComparer.Type == parameters.ClrType, + $"Expected {parameters.ClrType}, got {parameters.KeyComparer?.Type}"); if (parameters.KeyComparer?.Type == clrType) { _keyComparer = parameters.KeyComparer; diff --git a/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs index 99b8c47d794..7a4f38700de 100644 --- a/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs @@ -2,9 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; using Microsoft.Azure.Cosmos; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Internal; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Conventions; @@ -462,6 +466,172 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } + [ConditionalFact] + public async Task Can_add_update_delete_with_collections() + { + await Can_add_update_delete_with_collection( + new List { 1, 2 }, + c => + { + c.Collection.Clear(); + c.Collection.Add(3); + }, + new List { 3 }); + + await Can_add_update_delete_with_collection>( + new List(), + c => + { + c.Collection.Clear(); + c.Collection.Add(3); + c.Collection.Add(null); + }, + new List { 3, null }); + + await Can_add_update_delete_with_collection>( + new[] { "1", null }, + c => + { + c.Collection = new List { "3", "2", "1" }; + }, + new List { "3", "2", "1" }); + + // See #25343 + await Can_add_update_delete_with_collection( + new List { EntityType.Base, EntityType.Derived, EntityType.Derived }, + c => + { + c.Collection.Clear(); + c.Collection.Add(EntityType.Base); + }, + new List { EntityType.Base }, + modelBuilder => modelBuilder.Entity>>(c => + c.Property(s => s.Collection) + .HasConversion(m => m.Select(v => (int)v).ToList(), p => p.Select(v => (EntityType)v).ToList(), + new ListComparer>(ValueComparer.CreateDefault(typeof(EntityType), false), readOnly: false)))); + + await Can_add_update_delete_with_collection( + new[] { 1f, 2 }, + c => + { + c.Collection[0] = 3f; + }, + new[] { 3f, 2 }); + + + await Can_add_update_delete_with_collection( + new decimal?[] { 1, null }, + c => + { + c.Collection[0] = 3; + }, + new decimal?[] { 3, null }); + + await Can_add_update_delete_with_collection( + new Dictionary { { "1", 1 } }, + c => + { + c.Collection["2"] = 3; + }, + new Dictionary { { "1", 1 }, { "2", 3 } }); + + await Can_add_update_delete_with_collection>( + new SortedDictionary { { "2", 2 }, { "1", 1 } }, + c => + { + c.Collection.Clear(); + c.Collection["2"] = null; + }, + new SortedDictionary { { "2", null } }); + + await Can_add_update_delete_with_collection>( + ImmutableDictionary.Empty + .Add("2", 2).Add("1", 1), + c => + { + c.Collection = ImmutableDictionary.Empty.Add("1", 1).Add("2", null); + }, + new Dictionary { { "1", 1 }, { "2", null } }); + } + + private async Task Can_add_update_delete_with_collection( + TCollection initialValue, + Action> modify, + TCollection modifiedValue, + Action onModelBuilder = null) + where TCollection : class + { + var options = Fixture.CreateOptions(); + + var customer = new CustomerWithCollection { Id = 42, Name = "Theon", Collection = initialValue }; + + using (var context = new CollectionCustomerContext(options, onModelBuilder)) + { + await context.Database.EnsureCreatedAsync(); + + context.Add(customer); + + await context.SaveChangesAsync(); + } + + using (var context = new CollectionCustomerContext(options)) + { + var customerFromStore = await context.Customers.SingleAsync(); + + Assert.Equal(42, customerFromStore.Id); + Assert.Equal(initialValue, customerFromStore.Collection); + + modify(customerFromStore); + + await context.SaveChangesAsync(); + } + + using (var context = new CollectionCustomerContext(options)) + { + var customerFromStore = await context.Customers.SingleAsync(); + + Assert.Equal(42, customerFromStore.Id); + Assert.Equal(modifiedValue, customerFromStore.Collection); + + customerFromStore.Collection = null; + + await context.SaveChangesAsync(); + } + + using (var context = new CollectionCustomerContext(options)) + { + var customerFromStore = await context.Customers.SingleAsync(); + + Assert.Equal(42, customerFromStore.Id); + Assert.Null(customerFromStore.Collection); + } + } + + private class CustomerWithCollection + { + public int Id { get; set; } + public string Name { get; set; } + public TCollection Collection { get; set; } + } + + private class CollectionCustomerContext : DbContext + { + private readonly Action _onModelBuilder; + + public DbSet> Customers { get; set; } + + public CollectionCustomerContext(DbContextOptions dbContextOptions, Action onModelBuilder = null) + : base(dbContextOptions) + { + _onModelBuilder = onModelBuilder; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + _onModelBuilder?.Invoke(modelBuilder); + } + } + [ConditionalFact] public async Task Can_read_with_find_with_resource_id_async() { diff --git a/test/EFCore.Cosmos.Tests/ModelBuilding/CosmosModelBuilderGenericTest.cs b/test/EFCore.Cosmos.Tests/ModelBuilding/CosmosModelBuilderGenericTest.cs index 6b5850d310e..e19682fec6b 100644 --- a/test/EFCore.Cosmos.Tests/ModelBuilding/CosmosModelBuilderGenericTest.cs +++ b/test/EFCore.Cosmos.Tests/ModelBuilding/CosmosModelBuilderGenericTest.cs @@ -31,6 +31,20 @@ public override void Properties_specified_by_string_are_shadow_properties_unless // Fails due to extra shadow properties } + protected override void Mapping_throws_for_non_ignored_array() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity(); + + var model = modelBuilder.FinalizeModel(); + var entityType = model.FindEntityType(typeof(OneDee)); + + var property = entityType.FindProperty(nameof(OneDee.One)); + Assert.Null(property.GetProviderClrType()); + Assert.NotNull(property.FindTypeMapping()); + } + [ConditionalFact] public override void Properties_can_have_provider_type_set_for_type() {