diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs index 8001e8f9175c0f..323b514191ad0c 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs @@ -976,11 +976,13 @@ complexType is not CollectionSpec && // The current configuration section doesn't have any children, let's check if we are binding to an array and the configuration value is empty string. // In this case, we will assign an empty array to the member. Otherwise, we will skip the binding logic. - if (complexType is ArraySpec arraySpec && canSet) + if ((complexType is ArraySpec || complexType.IsExactIEnumerableOfT) && canSet) { + // Either we have an array or we have an IEnumerable both these types can be assigned an empty array when having empty string configuration value. + Debug.Assert(complexType is ArraySpec || complexType is EnumerableSpec); string valueIdentifier = GetIncrementalIdentifier(Identifier.value); EmitStartBlock($@"if ({memberAccessExpr} is null && {Identifier.TryGetConfigurationValue}({configSection}, {Identifier.key}: null, out string? {valueIdentifier}) && {valueIdentifier} == string.Empty)"); - _writer.WriteLine($"{memberAccessExpr} = global::System.{Identifier.Array}.Empty<{arraySpec.ElementTypeRef.FullyQualifiedName}>();"); + _writer.WriteLine($"{memberAccessExpr} = global::System.{Identifier.Array}.Empty<{((CollectionSpec)complexType).ElementTypeRef.FullyQualifiedName}>();"); EmitEndBlock(); } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Parser/KnownTypeSymbols.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Parser/KnownTypeSymbols.cs index 2f084ec7bdfce9..7eba00408d053a 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Parser/KnownTypeSymbols.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Parser/KnownTypeSymbols.cs @@ -84,7 +84,7 @@ public KnownTypeSymbols(CSharpCompilation compilation) Uri = compilation.GetBestTypeByMetadataName(typeof(Uri)); Version = compilation.GetBestTypeByMetadataName(typeof(Version)); - // Used to verify input configuation binding API calls. + // Used to verify input configuration binding API calls. INamedTypeSymbol? binderOptions = compilation.GetBestTypeByMetadataName("Microsoft.Extensions.Configuration.BinderOptions"); ActionOfBinderOptions = binderOptions is null ? null : compilation.GetBestTypeByMetadataName(typeof(Action<>))?.Construct(binderOptions); ConfigurationBinder = compilation.GetBestTypeByMetadataName("Microsoft.Extensions.Configuration.ConfigurationBinder"); diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Types/TypeSpec.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Types/TypeSpec.cs index ac532f553ca1a6..eaa8e4a401386c 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Types/TypeSpec.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Types/TypeSpec.cs @@ -17,7 +17,12 @@ public TypeSpec(ITypeSymbol type) (DisplayString, FullName) = type.GetTypeNames(); IdentifierCompatibleSubstring = type.ToIdentifierCompatibleSubstring(); IsValueType = type.IsValueType; - IsValueTuple = type is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.IsTupleType; + + if (type is INamedTypeSymbol namedTypeSymbol) + { + IsValueTuple = namedTypeSymbol.IsTupleType; + IsExactIEnumerableOfT = namedTypeSymbol.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T; + } } public TypeRef TypeRef { get; } @@ -39,6 +44,8 @@ public TypeSpec(ITypeSymbol type) public bool IsValueType { get; } public bool IsValueTuple { get; } + + public bool IsExactIEnumerableOfT { get; } } public abstract record ComplexTypeSpec : TypeSpec diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs index fc6c51d8342d2a..08bfa3d778fe2c 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs @@ -503,20 +503,19 @@ private static void BindInstance( throw new InvalidOperationException(SR.Format(SR.Error_FailedBinding, configValue, section.Path, type)); } } - else + else if (!bindingPoint.IsReadOnly && bindingPoint.Value is null) { - if (isParentCollection && bindingPoint.Value is null) + if (isParentCollection) { // Try to create the default instance of the type bindingPoint.TrySetValue(CreateInstance(type, config, options, out _)); } - else if (isConfigurationExist && bindingPoint.Value is null) + else if (isConfigurationExist) { - // Don't override the existing array in bindingPoint.Value if it is already set. - if (type.IsArray || IsImmutableArrayCompatibleInterface(type)) + if (type.IsArray || IsIEnumerableInterface(type)) { // When having configuration value set to empty string, we create an empty array - bindingPoint.TrySetValue(configValue is null ? null : Array.CreateInstance(type.GetElementType()!, 0)); + bindingPoint.TrySetValue(configValue is null ? null : Array.CreateInstance(type.IsArray ? type.GetElementType()! : type.GetGenericArguments()[0], 0)); } else { @@ -1056,6 +1055,9 @@ private static bool IsImmutableArrayCompatibleInterface(Type type) || genericTypeDefinition == typeof(IReadOnlyList<>); } + private static bool IsIEnumerableInterface(Type type) + => type.IsInterface && type.IsConstructedGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>); + private static bool TypeIsASetInterface(Type type) { if (!type.IsInterface || !type.IsConstructedGenericType) { return false; } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs index a36c3b2d6e9e24..0dbdd2e5b9ecb6 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs @@ -1178,5 +1178,11 @@ public class ArraysContainer public byte[] ByteArray2 { get; set; } public byte[] ByteArray3 { get; set; } } + + public class MyOptionsWithNullableEnumerable + { + public IEnumerable? IEnumerableProperty { get; set; } + public string[] StringArray { get; set; } + } } } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs index d869a5ad827125..18dda3e8cc4354 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs @@ -3038,5 +3038,27 @@ public void TestProvidersOrder() Assert.Equal("Provider2A", result.A); // Value should come from the last provider Assert.Equal("Provider1B", result.B); // B should not be overridden by the second provider } + + [Fact] + public void TestBindingEmptyArrayToNullIEnumerable() + { + string jsonConfig1 = @" + { + ""MyService"": { + ""IEnumerableProperty"": [], + ""StringArray"": [] + }, + }"; + + var configuration = new ConfigurationBuilder() + .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(jsonConfig1))) + .Build().GetSection("MyService"); + + MyOptionsWithNullableEnumerable? result = configuration.Get(); + + Assert.NotNull(result); + Assert.Equal(Array.Empty(), result.IEnumerableProperty); + Assert.Equal(Array.Empty(), result.StringArray); + } } }