Fix config binder source gen for a sole read-only collection ctor param#131358
Fix config binder source gen for a sole read-only collection ctor param#131358TemRevil wants to merge 2 commits into
Conversation
The configuration binder source generator emitted a call to an Initialize method that was never generated for a parameterized- constructor type whose only member is a non-bindable copy-constructor collection parameter (IReadOnlyList<T>, IReadOnlyCollection<T>, IReadOnlySet<T>, or IEnumerable<T>, with no other bindable property). The reflection binder already handled this shape correctly, so this was a parity gap that broke the build with CS0103 instead. The registration pass only registered a type for Initialize-method generation inside the HasBindableMembers check, but constructor parameters are bound in Initialize independently of whether the type has any other bindable property. Register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of HasBindableMembers. Verified by running the incremental generator directly against the repro from the issue (record with a single IReadOnlyList<string> constructor parameter): before this change the generated source calls InitializeOptions but never defines it, reproducing CS0103 exactly; after this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces. Fixes dotnet#131320
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @dotnet/area-extensions-configuration |
tarekgh
left a comment
There was a problem hiding this comment.
A few test-hardening notes on the new regression test. The product change itself looks correct and well targeted; these are non-blocking suggestions. One additional stylistic thought: the sibling ReadOnlyCollectionConstructorParameterIsBindable, ComplexReadOnlyListConstructorParameterIsBindable, and this new Sole... test all cover variations of the same shape, so they could eventually be consolidated into a single data-driven theory.
| Assert.NotNull(result.GeneratedSource); | ||
| Assert.Empty(result.Diagnostics); | ||
|
|
||
| AssertCanCreateAssemblyImage(result.OutputCompilation); |
There was a problem hiding this comment.
The assertions stop at compile-clean plus a produced assembly image. Since this fixes a source generator vs reflection parity regression, consider also asserting runtime binding behavior: populate an in-memory configuration with a value for Values, call Get<Options>(), and assert the resulting collection contents. A generator can emit compilable but functionally wrong binding, and only a functional assertion guards against that.
| } | ||
| } | ||
|
|
||
| public record Options({{collectionType}}<string> Values); |
There was a problem hiding this comment.
Two coverage gaps around this shape:
- Sole member plus complex element type is not exercised. The existing
ComplexReadOnlyListConstructorParameterIsBindablepairs the collection with a bindableName(soHasBindableMembersis true, the old path). Here the element isstring. The intersection, for examplerecord Options(IReadOnlyList<Child> Values)with no other member, is what newly drives the property walk transitive registration andRegisterForGen_AsConfigWithChildrenHelperin the changed code path, and it is untested. - Nested usage is not exercised. Reaching this type as a member of an outer bound type goes through
BindCoreandEmitObjectInitrather than top levelGetCore, which is a distinct emission path. It very likely works, but a nested case would lock it in.
| [InlineData("IReadOnlyList")] | ||
| [InlineData("IReadOnlyCollection")] | ||
| [InlineData("IReadOnlySet")] | ||
| [InlineData("IEnumerable")] |
There was a problem hiding this comment.
IEnumerable<string> may classify as a bindable member, in which case HasBindableMembers is true and this input routes through the pre-existing path rather than the newly fixed one. It still passes and is harmless, but it may not actually exercise the regression. Worth confirming, or swapping in an element type or shape you have verified takes the new path.
… fix Compiling proved the Initialize method gets emitted, but never asserted the generated code binds the right values. Extend the existing theory to populate real config and check the bound collection contents for all four interface shapes (including IEnumerable, which still routes through the same CopyConstructor/HasBindableMembers=false path as the others). Add a sibling test for a complex (non-string) element type, the other gap called out in review.
|
Added functional coverage for both gaps, pushed as On whether The nested case turned up something real, though not what either of us expected. Binding Since it's a different root cause in a different file, I didn't fold a fix into this PR without checking first. Happy to open a follow-up issue with the repro, take a shot at fixing it separately, or fold it into this PR if you'd rather keep it together, whichever you prefer. |
Fixes #131320
The configuration binder source generator emitted an uncompilable call to an Initialize method that was never generated, for a parameterized-constructor type whose only member is a non-bindable copy-constructor collection parameter (a positional record with a single IReadOnlyList, IReadOnlyCollection, IReadOnlySet, or IEnumerable parameter and no other bindable property). The reflection binder handles this shape correctly, so this was a parity regression that broke the build instead.
Root cause:
BindingHelperInfo.Builder.TryRegisterTransitiveTypesForMethodGenonly registered a type for Initialize-method generation inside theHasBindableMembers(objectSpec)check. But constructor parameters are bound inInitializeindependently of whether the type has any other bindable property, per the comment already on the ctor-param property loop a few lines above. For a type where the only "property" is a ctor param backed by a non-bindable read-only collection type,HasBindableMembersis false, soInitializenever got registered/emitted, while the emitter'sEmitBindingLogic/EmitObjectInitstill unconditionally callsInitializeXxx(...)for anyParameterizedConstructortype.Fix: register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of
HasBindableMembers.BindCoreregistration stays gated onHasBindableMembersas before, since that part is unrelated.Verified by running the incremental generator directly against the repro from the issue:
Before this change the generated source calls
InitializeOptionsbut never defines it, reproducingCS0103: The name 'InitializeOptions' does not exist in the current contextexactly. After this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces (IReadOnlyList, IReadOnlyCollection, IReadOnlySet, IEnumerable).Added
SoleReadOnlyCollectionConstructorParameterIsBindablenext to the existingReadOnlyCollectionConstructorParameterIsBindabletest, covering the case where the collection parameter is the type's only member (the existing test always paired it with a second, ordinarily-bindable property, so it didn't exercise this gap).