Skip to content

Fix config binding source generator duplicating collection items bound through a constructor parameter#131092

Draft
svick wants to merge 2 commits into
dotnet:mainfrom
svick:config-binding-sg-record-duplicate
Draft

Fix config binding source generator duplicating collection items bound through a constructor parameter#131092
svick wants to merge 2 commits into
dotnet:mainfrom
svick:config-binding-sg-record-duplicate

Conversation

@svick

@svick svick commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #83803.

The Configuration Binding source generator duplicated the items of a collection property that is populated through a matching constructor parameter. For example, given:

internal sealed class Source(string Name, IEnumerable<string> Addresses, IList<int> Ints, string[] Strings)
{
    public string Name { get; } = Name;
    public IEnumerable<string> Addresses { get; } = Addresses;
    public IList<int> Ints { get; } = Ints;
    public string[] Strings { get; } = Strings;
}

binding ints: [1, 2, 3, 4, 5] produced 1, 2, 3, 4, 5, 1, 2, 3, 4, 5. Arrays and scalars were unaffected; only additive collections (IList<T>, IEnumerable<T>, settable collections) duplicated. The reflection binder was already fixed (#106158, #129775); this brings the source generator in line.

Root cause

For a type with a parameterized constructor, the generated code first calls Initialize(...) — which invokes the constructor, binding each constructor parameter into its collection — and then calls BindCore(...), which re-bound every property. Re-binding a collection property appends to the collection the constructor already filled, so the items appear twice.

Fix

The generated BindCore method now takes an optional boundThroughConstructor parameter, emitted only for parameterized-constructor object types that actually have a re-bindable constructor-matched property. Constructor-matched properties are deferred into an if (!boundThroughConstructor) block, so they are bound only when the instance was not created through its constructor (e.g. config.Bind(existingInstance)), matching the reflection binder's BindProperties/ResetPropertyValue behavior.

Call sites pass the flag when the instance is (re)created:

  • true for unconditional construction (var x = Initialize(...)),
  • a runtime wasNull flag for the x ??= Initialize(...) case, since the constructor only runs when the existing value was null.

No baseline .generated.txt changes: the extra parameter/guard only appear for the affected type shapes, none of which are in the baseline suite.

Known limitation

A settable scalar that is both a constructor parameter and has an observable side-effecting setter has its setter invoked once here vs. twice by the reflection binder (which resets it to its current value). This is untested/pathological, and the previous source-gen behavior already diverged there too. Collections, init/required properties, and normal setters all match the reflection binder exactly.

Note

This pull request was authored with the assistance of GitHub Copilot.

Fixes dotnet#83803.

For a type with a parameterized constructor, the generated code ran
`Initialize(...)` (invoking the constructor, which binds the constructor
parameters into their collections) and then `BindCore(...)`, which re-bound
every property - re-appending to collections the constructor had already
filled. This duplicated items for collection properties bound through a
matching constructor parameter (e.g. `IList<int> Ints` via ctor param
`ints`/`Ints`); arrays and scalars were unaffected.

The generated `BindCore` now takes an optional `boundThroughConstructor`
parameter (emitted only for parameterized-ctor object types that actually
have a re-bindable ctor-matched property). Constructor-matched properties are
deferred into an `if (!boundThroughConstructor)` block so they are only bound
when the instance was not created through the constructor (e.g.
`Bind(existingInstance)`), matching the reflection binder. Call sites pass
`true` when the instance is freshly constructed, and a runtime `wasNull` flag
for the `x ??= Initialize()` case, which only constructs when the value was
null.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1a0d7ac2-f2a3-4526-93ab-36bf1a23933f
@azure-pipelines

Copy link
Copy Markdown
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Microsoft.Extensions.Configuration.Binder source generator to avoid re-binding constructor-matched collection properties (which can cause additive collections to duplicate items) by adding a boundThroughConstructor flag to generated BindCore overloads and guarding the relevant property-binding paths. Tests are updated to validate the behavior in both reflection and source-gen modes.

Changes:

  • Extend generated BindCore methods (for affected parameterized-constructor object shapes) with an optional boundThroughConstructor parameter and skip re-binding ctor-matched properties when it’s true.
  • Update the emitter call sites to pass boundThroughConstructor: true when an instance is freshly constructed, and a runtime wasNull flag for ??= initialization patterns.
  • Update binder tests: remove the SourceGen-mode ActiveIssue gate for the case-mismatched ctor-parameter scenario and add coverage for same-cased ctor-parameter collection binding.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs Adds a new test class with ctor-matched collection parameters/properties for same-cased repro coverage.
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs Un-gates an existing test for source-gen mode and adds a new regression test for same-cased ctor parameter/property collection binding.
src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/Helpers.cs Adds identifier constants used by the emitter (boundThroughConstructor, wasNull).
src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs Implements the boundThroughConstructor parameter, defers ctor-matched properties behind a guard, and updates key call sites to pass the flag on construction.

Dictionary value binding instantiates the element through a separate
EmitObjectInit and then calls BindCore with InitializationKind.None, which
bypassed the boundThroughConstructor flag. A dictionary whose value type is a
parameterized-ctor type with a matching additive collection property would
therefore still duplicate items.

EmitBindingLogic now accepts an explicit run-time flag (constructedExpr) for
callers that instantiate separately. The dictionary element path tracks whether
the element was newly constructed (it is only created when not already present)
and forwards that to BindCore. The enumerable-with-add and array paths already
construct each element through InitializationKind.Declaration, so they were
unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1a0d7ac2-f2a3-4526-93ab-36bf1a23933f
Copilot AI review requested due to automatic review settings July 20, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@tarekgh

tarekgh commented Jul 20, 2026

Copy link
Copy Markdown
Member

Looks good. One suggestion on test coverage: the added tests cover the top-level Get<T>() path and the dictionary path, but not the nested-property ??= path, which is where boundThroughConstructor is computed at runtime (wasNull) rather than being a constant.

Worth adding (true regression test): a container whose property type has a ctor-matched collection, bound via a nested null property. This exercises the wasNull branch and would duplicate on the unfixed source generator.

public sealed class ContainerWithCtorCollectionChild
{
    public GetterOnlyInterfaceCollectionWithCaseMismatchedCtorParameter Child { get; set; }
}

[Fact]
public void CanBind_NestedTypeWithCollectionConstructorParameter()
{
    string json = """
    {
        "Child": { "Instances": [ "a", "b" ] }
    }
    """;

    IConfiguration config = TestHelpers.GetConfigurationFromJsonString(json);
    var result = config.Get<ContainerWithCtorCollectionChild>();

    Assert.Equal(new[] { "a", "b" }, result.Child.Instances);
}

Without the fix this produces ["a","b","a","b"]; with it, ["a","b"]. It also runs under both binders since it lives in tests/Common.

Nice to have (guard, not a repro): a Bind(existingInstance) case for a settable ctor-matched collection. This one passes with or without the change, so it does not catch the current bug, but it locks in the boundThroughConstructor: false branch so a future change cannot start over-skipping and silently stop binding those properties.

[Fact]
public void CanBindExistingInstance_BindsCtorMatchedCollection()
{
    string json = """
    {
        "Instances": [ "first", "second" ]
    }
    """;

    IConfiguration config = TestHelpers.GetConfigurationFromJsonString(json);
    var instance = new SettableCollectionWithCaseMismatchedCtorParameter(new List<string>());
    config.Bind(instance);

    Assert.Equal(new[] { "first", "second" }, instance.Instances);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Attempting to Get a configuration section using a type defined as a record will duplicate collection values.

3 participants