diff --git a/src/MongoDB.Bson/Serialization/Serializers/EnumerableInterfaceImplementerSerializer.cs b/src/MongoDB.Bson/Serialization/Serializers/EnumerableInterfaceImplementerSerializer.cs index 2238e9562aa..cf80bae1070 100644 --- a/src/MongoDB.Bson/Serialization/Serializers/EnumerableInterfaceImplementerSerializer.cs +++ b/src/MongoDB.Bson/Serialization/Serializers/EnumerableInterfaceImplementerSerializer.cs @@ -153,23 +153,19 @@ protected override object CreateAccumulator() protected override TValue FinalizeResult(object accumulator) { // find and call a constructor that we can pass the accumulator to - var accumulatorType = accumulator.GetType(); - foreach (var constructorInfo in typeof(TValue).GetTypeInfo().GetConstructors()) + var accumulatorCtor = FindSingleArgumentConstructor(accumulator.GetType()); + if (accumulatorCtor != null) { - var parameterInfos = constructorInfo.GetParameters(); - if (parameterInfos.Length == 1 && parameterInfos[0].ParameterType.GetTypeInfo().IsAssignableFrom(accumulatorType)) - { - return (TValue)constructorInfo.Invoke(new object[] { accumulator }); - } + return (TValue)accumulatorCtor.Invoke(new object[] { accumulator }); } // otherwise try to find a no-argument constructor and an Add method var valueTypeInfo = typeof(TValue).GetTypeInfo(); - var noArgumentConstructorInfo = valueTypeInfo.GetConstructor(new Type[] { }); - var addMethodInfo = typeof(TValue).GetTypeInfo().GetMethod("Add", new Type[] { typeof(TItem) }); + var noArgumentConstructorInfo = valueTypeInfo.GetConstructor(Type.EmptyTypes); + var addMethodInfo = valueTypeInfo.GetMethod("Add", new Type[] { typeof(TItem) }); if (noArgumentConstructorInfo != null && addMethodInfo != null) { - var value = (TValue)noArgumentConstructorInfo.Invoke(new Type[] { }); + var value = (TValue)noArgumentConstructorInfo.Invoke(null); foreach (var item in (IEnumerable)accumulator) { addMethodInfo.Invoke(value, new object[] { item }); @@ -177,10 +173,32 @@ protected override TValue FinalizeResult(object accumulator) return value; } + // last resort: try a constructor that takes an ISet (e.g. ReadOnlySet(ISet)) + // Note: collapses duplicate elements, which is correct for set-shaped targets. + var setCtor = FindSingleArgumentConstructor(typeof(ISet)); + if (setCtor != null) + { + var hashSet = new HashSet((IEnumerable)accumulator); + return (TValue)setCtor.Invoke(new object[] { hashSet }); + } + var message = string.Format("Type '{0}' does not have a suitable constructor or Add method.", typeof(TValue).FullName); throw new BsonSerializationException(message); } + private static ConstructorInfo FindSingleArgumentConstructor(Type argumentType) + { + foreach (var constructorInfo in typeof(TValue).GetTypeInfo().GetConstructors()) + { + var parameterInfos = constructorInfo.GetParameters(); + if (parameterInfos.Length == 1 && parameterInfos[0].ParameterType.GetTypeInfo().IsAssignableFrom(argumentType)) + { + return constructorInfo; + } + } + return null; + } + // explicit interface implementations IBsonSerializer IChildSerializerConfigurable.ChildSerializer { diff --git a/tests/MongoDB.Bson.Tests/Serialization/Serializers/EnumerableInterfaceImplementerSerializerTests.cs b/tests/MongoDB.Bson.Tests/Serialization/Serializers/EnumerableInterfaceImplementerSerializerTests.cs index ba33a1beb88..7f266afd8e9 100644 --- a/tests/MongoDB.Bson.Tests/Serialization/Serializers/EnumerableInterfaceImplementerSerializerTests.cs +++ b/tests/MongoDB.Bson.Tests/Serialization/Serializers/EnumerableInterfaceImplementerSerializerTests.cs @@ -16,6 +16,9 @@ using System; using System.Collections; using System.Collections.Generic; +#if NET9_0_OR_GREATER +using System.Collections.ObjectModel; +#endif using System.IO; using FluentAssertions; using MongoDB.Bson.IO; @@ -226,5 +229,76 @@ private IBsonSerializer CreateSubject() serializerRegistry.RegisterSerializer(typeof(C), subject); return subject; } + + // target type with both an IEnumerable ctor and an ISet ctor — verifies the + // existing IEnumerable path still wins (otherwise duplicates would silently collapse) + public class BothCtorsEnumerable : IEnumerable + { + private readonly List _items; + public BothCtorsEnumerable(IEnumerable items) { _items = new List(items); } + public BothCtorsEnumerable(ISet items) { _items = new List(items); } + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [Fact] + public void Deserialize_should_prefer_enumerable_ctor_over_set_ctor() + { + const string json = "[1, 2, 2, 3]"; + var subject = new EnumerableInterfaceImplementerSerializer, int>(__itemSerializer1); + + using var reader = new JsonReader(json); + var context = BsonDeserializationContext.CreateRoot(reader); + var result = subject.Deserialize(context); + + result.Should().Equal(1, 2, 2, 3); + } + +#if NET9_0_OR_GREATER + [Fact] + public void Deserialize_should_use_set_ctor_when_no_other_ctor_matches() + { + const string json = "[1, 2, 3, 4]"; + var subject = new EnumerableInterfaceImplementerSerializer, int>(__itemSerializer1); + + using var reader = new JsonReader(json); + var context = BsonDeserializationContext.CreateRoot(reader); + var result = subject.Deserialize(context); + + result.Should().BeEquivalentTo(new[] { 1, 2, 3, 4 }); + } + + [Fact] + public void Deserialize_via_set_ctor_should_collapse_duplicates() + { + const string json = "[1, 2, 2, 3]"; + var subject = new EnumerableInterfaceImplementerSerializer, int>(__itemSerializer1); + + using var reader = new JsonReader(json); + var context = BsonDeserializationContext.CreateRoot(reader); + var result = subject.Deserialize(context); + + result.Should().BeEquivalentTo(new[] { 1, 2, 3 }); + } + + public class ReadOnlySetHolder + { + public ReadOnlySet X { get; set; } + } + + [Fact] + public void ReadOnlySet_should_roundtrip_via_BsonSerializer() + { + var original = new ReadOnlySetHolder + { + X = new ReadOnlySet(new HashSet { 1, 2, 3, 4 }) + }; + + var bson = original.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + + rehydrated.X.Should().BeEquivalentTo(original.X); + } +#endif } }