diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/BindingHelperInfo.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/BindingHelperInfo.cs index 29e1c6c16cbea9..b4a3959648e6c2 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/BindingHelperInfo.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/BindingHelperInfo.cs @@ -169,7 +169,17 @@ bool TryRegisterCore() // an error for config properties that don't map to object properties. _namespaces.Add("System.Collections.Generic"); - if (_typeIndex.HasBindableMembers(objectSpec)) + bool hasBindableMembers = _typeIndex.HasBindableMembers(objectSpec); + + // A type with a parameterized constructor gets its constructor parameters bound + // in the Initialize method regardless of whether it also has other bindable + // members; that binding capability must be registered even when + // HasBindableMembers is false (e.g. the only member is a ctor parameter backed + // by a non-bindable read-only collection type), otherwise the emitter can end up + // calling an Initialize method that was never generated. + bool needsInitializeMethod = objectSpec is { InstantiationStrategy: ObjectInstantiationStrategy.ParameterizedConstructor, InitExceptionMessage: null }; + + if (hasBindableMembers || needsInitializeMethod) { foreach (PropertySpec property in objectSpec.Properties!) { @@ -189,10 +199,13 @@ bool TryRegisterCore() } } - bool registeredForBindCore = TryRegisterTypeForBindCoreGen(objectSpec); - Debug.Assert(registeredForBindCore); + if (hasBindableMembers) + { + bool registeredForBindCore = TryRegisterTypeForBindCoreGen(objectSpec); + Debug.Assert(registeredForBindCore); + } - if (objectSpec is { InstantiationStrategy: ObjectInstantiationStrategy.ParameterizedConstructor, InitExceptionMessage: null }) + if (needsInitializeMethod) { RegisterTypeForMethodGen(MethodsToGen_CoreBindingHelper.Initialize, objectSpec); } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs index 18e111e169fc41..906cbd18820dec 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.Helpers.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.Loader; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis; @@ -230,5 +231,21 @@ private static byte[] CreateAssemblyImage(Compilation compilation) } return stream.ToArray(); } + + /// + /// Compiles the source-generated output to an in-memory assembly, invokes its Program.Main(), + /// and returns the value of the public static field named . + /// Used to assert on real bound values rather than just that the generated code compiles, + /// since a generator can emit code that builds cleanly but binds the wrong data. + /// + private static object? LoadAndInvokeMain(Compilation compilation, string resultFieldName) + { + byte[] image = CreateAssemblyImage(compilation); + Assembly assembly = AssemblyLoadContext.Default.LoadFromStream(new MemoryStream(image)); + + Type programType = assembly.GetType("Program")!; + programType.GetMethod("Main", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, null); + return programType.GetField(resultFieldName, BindingFlags.Public | BindingFlags.Static)!.GetValue(null); + } } } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs index 4678e009325257..6aafd1461a0aef 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs @@ -542,6 +542,97 @@ public record Options(string Name, {{collectionType}} Values); AssertCanCreateAssemblyImage(result.OutputCompilation); } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + [InlineData("IReadOnlyList")] + [InlineData("IReadOnlyCollection")] + [InlineData("IReadOnlySet")] + [InlineData("IEnumerable")] + public async Task SoleReadOnlyCollectionConstructorParameterIsBindable(string collectionType) + { + // Regression test: a type whose only member is a non-bindable, read-only collection + // constructor parameter (no other bindable property) used to make the generator emit a + // call to an Initialize method that was never generated, producing CS0103 at compile time. + string source = $$""" + using Microsoft.Extensions.Configuration; + using System.Collections.Generic; + + public class Program + { + public static object? Result; + + public static void Main() + { + ConfigurationBuilder configurationBuilder = new(); + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["Values:0"] = "a", + ["Values:1"] = "b", + }); + IConfiguration config = configurationBuilder.Build(); + Options options = config.Get(); + Result = options.Values; + } + } + + public record Options({{collectionType}} Values); + """; + + ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source, assemblyReferences: GetAssemblyRefsWithAdditional(typeof(ConfigurationBuilder), typeof(List<>))); + Assert.NotNull(result.GeneratedSource); + Assert.Empty(result.Diagnostics); + + // Compiling only proves the Initialize method the fix registers is emitted; loading and + // running the assembly proves it also binds the right values, not just compilable code. + var boundValues = (IEnumerable)LoadAndInvokeMain(result.OutputCompilation, "Result")!; + Assert.Equal(new[] { "a", "b" }, boundValues); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] + public async Task SoleReadOnlyCollectionConstructorParameterOfComplexElementIsBindable() + { + // Same regression as SoleReadOnlyCollectionConstructorParameterIsBindable, but the element + // type is itself a bindable object rather than a string. ComplexReadOnlyListConstructorParameterIsBindable + // below covers the complex-element case, but always pairs it with a second, ordinarily-bindable + // property, so it never exercises the fixed code path (the type has bindable members either way). + string source = """ + using Microsoft.Extensions.Configuration; + using System.Collections.Generic; + using System.Linq; + + public class Program + { + public static object? Result; + + public static void Main() + { + ConfigurationBuilder configurationBuilder = new(); + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["Values:0:Value"] = "a", + ["Values:1:Value"] = "b", + }); + IConfiguration config = configurationBuilder.Build(); + Options options = config.Get(); + Result = options.Values.Select(v => v.Value).ToArray(); + } + } + + public class Child + { + public string Value { get; set; } + } + + public record Options(IReadOnlyList Values); + """; + + ConfigBindingGenRunResult result = await RunGeneratorAndUpdateCompilation(source, assemblyReferences: GetAssemblyRefsWithAdditional(typeof(ConfigurationBuilder), typeof(List<>))); + Assert.NotNull(result.GeneratedSource); + Assert.Empty(result.Diagnostics); + + var boundValues = (string[])LoadAndInvokeMain(result.OutputCompilation, "Result")!; + Assert.Equal(new[] { "a", "b" }, boundValues); + } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNetCore))] public async Task ComplexReadOnlyListConstructorParameterIsBindable() {