Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support immutable types with configuration binding #67258

Merged
merged 41 commits into from
Jun 9, 2022

Conversation

SteveDunn
Copy link
Contributor

Fixes #43662 Support immutable types with configuration binding

@ghost ghost added the community-contribution Indicates that the PR has been added by a community member label Mar 28, 2022
@ghost
Copy link

ghost commented Mar 28, 2022

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

Issue Details

Fixes #43662 Support immutable types with configuration binding

Author: SteveDunn
Assignees: -
Labels:

area-Extensions-Configuration

Milestone: -

@SteveDunn
Copy link
Contributor Author

This PR supersedes #67161 due to several git mishaps in a row!

@SteveDunn
Copy link
Contributor Author

SteveDunn commented Mar 31, 2022

@eerhardt / @davidfowl - how does this PR look? Is there anything that still looks unresolved? There's a couple of things that are unclear and need resolving from what I can tell:

  1. With the PR, we can now bind to records and other immutable classes. But not structs (Support immutable types with configuration binding #67258 (comment)) NEW! Now with structs!
  2. There is a potentially unused method (Support immutable types with configuration binding #67258 (comment)). It relates to the existing attribute named ConfigurationKeyNameAttribute. This can be applied to properties, but I think it might be useful if it was also applied to parameters. What do you think?

@maryamariyan
Copy link
Member

@SteveDunn
Copy link
Contributor Author

@madelson - I just merged in your changes from main. Would you mind giving this PR a quick look-over?

@SteveDunn
Copy link
Contributor Author

@SteveDunn did you notice the CI build failure in https://github.com/dotnet/runtime/pull/67258/checks?check_run_id=5824336121?

@maryamariyan - yes, I just resolved the conflicts with @madelson 's change. It now builds and passes the tests, although I think it would be good if @madelson could give this PR a quick once-over.

@@ -703,10 +759,10 @@ private static string GetPropertyName(MemberInfo property!!)
.Value?
.ToString();

return !string.IsNullOrWhiteSpace(name) ? name : property.Name;
Copy link
Member

@maryamariyan maryamariyan Apr 4, 2022

Choose a reason for hiding this comment

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

There is a potentially unused method (#67258 (comment)). It relates to the existing attribute named ConfigurationKeyNameAttribute. This can be applied to properties, but I think it might be useful if it was also applied to parameters. What do you think?

We could add this support in a separate PR. The idea would be to allow usage of ConfigurationKeyName attribute over on constructor parameters, not just over a property as it is defined today here.

Sample usage:

With records:

public record Poco([ConfigurationKeyName="my_string"] string myString);

Workaround without this would be to add the annotation over the property as shown below:

public class Poco
{
    [ConfigurationKeyName="my_string"]
    public string MyString { get; init; }

    public Poco(string myString) { .. }
}

TODO:

  • Add API proposal for this change (outside the scope for this PR).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @maryamariyan - is the task for me, or for somebody else?

Copy link
Member

Choose a reason for hiding this comment

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

I will take this task to API review.

Copy link
Contributor Author

@SteveDunn SteveDunn Apr 5, 2022

Choose a reason for hiding this comment

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

Should I create a branch off this branch and implement it?

Copy link
Member

@maryamariyan maryamariyan Apr 5, 2022

Choose a reason for hiding this comment

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

Sure, but we'd be able to add that only after the API review gets approved.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK. I think I've addressed all the issues in this PR then. Happy to respond to any more.

@madelson
Copy link
Contributor

madelson commented Apr 5, 2022

I think it would be good if @madelson could give this PR a quick once-over.

@SteveDunn happy to do so; left a few quick comments but should hopefully be able to look in more depth later this week.

continue;
}

if (chosenParameters is null || constructorParameters.Length > chosenParameters.Length)
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it a problem that ties are broken based on constructor order which is inherently undefined? I wonder if it would be better if ties resulted in an exception.

In general, it feels like one of the most important things for this issue is to clearly define the constructor selection policy. Once selected, changing it would break compat. Just to summarize some of the considerations:

  • Public default constructors are preferred for compat
  • Are non-public constructors allowed (e. g. internal)?
  • Are in and ref parameters allowed?
  • Do we populate parameters with no default value and no configuration key (I think we shouldn't)?
  • Do the available configuration keys matter for constructor selection (e. g. if I have just "a": "2" in my config and we're choosing between constructors Options(int a) and Options(string x, string y), which one should it choose?
  • What do we do if 2 constructors are equally eligible for selection?

One policy I might suggest is select the public constructor with the most bindable parameters, where bindable means not ref or out, and either has a matching configuration key or has a default value. Prefer default constructors over any others for compat.

A more conservative approach could be Select the default constructor if there is one. Otherwise, if there is a single public constructor with no ref/out parameters use that. Otherwise, throw. The advantage of this more conservative approach is that we can amend it later without breaking compat.

Copy link
Member

Choose a reason for hiding this comment

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

We should look at what JSON does here. There’s prior art

Copy link
Contributor Author

@SteveDunn SteveDunn Apr 9, 2022

Choose a reason for hiding this comment

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

I took a look at the STJ source and this article. It chooses the default public parameterless constructor, or, if there is one, a constructor (doesn't have to be public) decorated with a JsonConstructor attribute. There's other ways to coerce the use of other constructors, such as using contract resolvers and the like.
STJ disallows non-public constructors.

Re. these points:

  • Public default constructors are preferred for compat
    Yes, I think that makes sense. It currently does this.

  • Are non-public constructors allowed (e. g. internal)?
    Currently, no. As above, this is consistent with System.Text.Json

  • Are in and ref parameters allowed?
    In STJ, both ref and in parameters are disallowed, resulting in
    Unhandled exception. System.ArgumentException: The type 'System.String&' may not be used as a type argument. at System.RuntimeType.ThrowIfTypeNeverValidGenericArgument(RuntimeType type)
    Should we do the same? Or should we silently not consider the constructor?

  • Do we populate parameters with no default value and no configuration key (I think we shouldn't)?
    Currently we do. I've added a test for this current behaviour (CanBindImmutableClass_PicksLargestConstructorEvenIfItHasExtraneousParameters)

  • Do the available configuration keys matter for constructor selection (e. g. if I have just "a": "2" in my config and we're choosing between constructors Options(int a) and Options(string x, string y), which one should it choose?
    The available keys are not considered. It currently picks the largest constructor in terms of parameters. As above, I added a test for this current behaviour (CanBindImmutableClass_PicksLargestConstructorEvenIfItHasExtraneousParameters)

  • What do we do if 2 constructors are equally eligible for selection?
    We pick first one. I added a test to cover this behaviour (CanBindImmutableClass_PicksFirstOfAnyAmbiguousConstructors)

You make some very valid points, and I think we should add constraints to avoid ambiguity and confusion. I think a model closer to STJ makes sense (except the use of an attribute):

  • Use the default public constructor if it has one
  • Use the only other parameterised constructor if there is one
  • Disallow in and ref

Re. the parameterised constructor: I think we should leave this fairly tolerant with regards to the relationship between the parameters and the properties. It will allow config and classes to evolve separately, and will allow users to add 'synthetic' fields at bind time. What do we think of this?

Copy link
Contributor Author

@SteveDunn SteveDunn Apr 14, 2022

Choose a reason for hiding this comment

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

Any thoughts on these suggestions? Making the behaviour match closer to STJ seems like the right choice.

Copy link
Member

Choose a reason for hiding this comment

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

Generally those look like the right choices to me. Lets do it. Are we going to have an attribute to disambiguate ctors?

Copy link
Contributor Author

@SteveDunn SteveDunn Apr 21, 2022

Choose a reason for hiding this comment

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

I exercised some initiative and decided to go with the System.Text.Json way of binding constructors. This means that the behaviour now is this:

  • Use the default public parameterless constructor if it has one
  • Use the only other parameterised constructor if there is one
  • Disallow in, out, and ref parameters on that constructor by throwing an InvalidOperationException
  • Throw is there are multiple parameterised constructors

Copy link
Contributor Author

@SteveDunn SteveDunn Apr 22, 2022

Choose a reason for hiding this comment

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

How odd - I must've typed my last comment while you were typing yours!

I think that if we don't consider non-public constructors, then we won't need an attribute to disambiguate any other constructor. That's because we use the public parameterless constructor, if there is one, or the only other public parameterised constructor.
We could consider adding an attribute, but that decision is based more on the question of 'Should we allow non-public constructors?'

Copy link
Member

Choose a reason for hiding this comment

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

I think that if we don't consider non-public constructors, then we won't need an attribute to disambiguate any other constructor.

Structs always have a default constructor. What if you want to make config prefer your non-default constructor? Otherwise the following would always bind to X = 0, Y = 0 because there's a default constructor with no parameters but the properties aren't settable.

public struct Point
{
    public int X { get; }

    public int Y { get; }

    // An attribute would be nice here to tell Configuration not to use the default constructor
    public Point(int x, int y) => (X, Y) = (x, y);
}

Also, are we going to use default values for constructor parameters that are missing?

public struct Point
{
    // ...
    public Point(int x, int y = 0) => (X, Y) = (x, y);
}

https://github.com/dotnet/runtime/blob/0e18cfda13a8136103c83442e1a649cdecc237b0/src/libraries/System.Text.Json/docs/ParameterizedCtorSpec.md#feature-behavior

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good points. I'll add some more tests. If we go with an attribute, will that have to go through an API Proposal? And will that (should that) be part of another task and PR?

Copy link
Contributor Author

@SteveDunn SteveDunn Apr 28, 2022

Choose a reason for hiding this comment

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

Actually, binding to structs do work, and there is a test for it (CanBindStructOptions). The logic for constructors (currently) is:

  • if it's not a value type && there are no constructors- then throw an exception
  • if there is more than one constructor && none of them are the parameterless constructor, then throw and exception
  • if it has just one constructor && that constructor is not the parameterless constructor, then try to bind to constructor parameters
  • otherwise, use Activator.CreateInstance with the default constructor (no parameter binding)

parameterValues[index] = BindParameter(parameters[index], config, options);
}

return constructor.Invoke(parameterValues);
Copy link
Member

@halter73 halter73 Apr 21, 2022

Choose a reason for hiding this comment

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

@layomia Can you take a look at this since S.T.J has to deal with the very similar problem of deserializing custom types using parameterized constructors?

I would like the behavior of Microsoft.Extensions.Configuraiton and S.T.J to align as much is possible. I realize there are already many difference for various reasons. Default case-insensitivity is an example.

In the doc describing the S.T.J behavior, I see the following:

If a constructor parameter does not match with a property, InvalidOperationException will be thrown if deserialization is attempted.

Do you think that would make sense to do here? We could always relax the restriction later. Are there other behaviors or restrictions that would make sense to copy?

Copy link
Member

Choose a reason for hiding this comment

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

Did you see this #67258 (comment)?

@halter73
Copy link
Member

halter73 commented May 4, 2022

Another thing to consider here is that the following test that used to pass now fails. This is because we'd use the default parameterless constructor for structs before, but now we try to pass null into the single-parameter constructor that doesn't expect a null value resulting in an NullReferenceException:

public struct MutableStructWithConstructor
{
    public MutableStructWithConstructor(string randomParameter)
    {
        Color = randomParameter;
        Length = randomParameter.Length;
    }

    public string Color { get; set; }
    public int Length { get; set;  }
}

[Fact]
public void CanBindMutableStructWithConstructor()
{
    var dic = new Dictionary<string, string>
    {
        {"Length", "42"},
        {"Color", "Green"},
    };
    var configurationBuilder = new ConfigurationBuilder();
    configurationBuilder.AddInMemoryCollection(dic);
    var config = configurationBuilder.Build();

    var options = config.Get<MutableStructWithConstructor>();
    Assert.Equal(42, options.Length);
    Assert.Equal("Green", options.Color);
}

I don't think we want to regress this scenario.

@halter73
Copy link
Member

halter73 commented May 4, 2022

This brings up the larger issue that we should probably not just be passing null into parameters that are missing unless they have a default value or are nullable.

We should also add tests for structs that are populated partially with a constructor and partially with properties since that's a thing we now support with this PR. Do we do this for JSON? Also, I don't think I saw a response this this question.

In the doc describing the S.T.J behavior, I see the following:

If a constructor parameter does not match with a property, InvalidOperationException will be thrown if deserialization is attempted.

Do you think that would make sense to do here? We could always relax the restriction later. Are there other behaviors or restrictions that would make sense to copy?

#67258 (comment)

@SteveDunn
Copy link
Contributor Author

SteveDunn commented May 6, 2022

This brings up the larger issue that we should probably not just be passing null into parameters that are missing unless they have a default value or are nullable.

We should also add tests for structs that are populated partially with a constructor and partially with properties since that's a thing we now support with this PR. Do we do this for JSON? Also, I don't think I saw a response this this question.

In the doc describing the S.T.J behavior, I see the following:

If a constructor parameter does not match with a property, InvalidOperationException will be thrown if deserialization is attempted.

Do you think that would make sense to do here? We could always relax the restriction later. Are there other behaviors or restrictions that would make sense to copy?

#67258 (comment)


Re. the comment:

We should also add tests for structs that are populated partially with a constructor and partially with properties

This is already covered in a test that I added named CanBindSemiImmutableClass that binds 2 constructor parameters and 1 property.

Re. the STJ question - "If a constructor parameter does not match a property": for binding, on one hand, it would make sense to throw if there is a constructor and we don't have any properties that match any parameters. But on the other hand, a user might want a best-effort approach, e.g. I have a constructor that "I" use, and I'd like binding to bind to whatever properties it can. But there will have to be a trade-off for this flexibility; either the user will ensure that the constructor behaves nicely with unspecified properties from config, or they'll need to ensure that the properties are specified.

@SteveDunn
Copy link
Contributor Author

SteveDunn commented May 16, 2022

This is one of a few of my PRs laying dormant. Is there anything I need to do to progress them?

Copy link
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

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

This looks really close. Thanks for the great work here @SteveDunn. I just have a few comments.

@davidfowl
Copy link
Member

Is this resolved?

@SteveDunn
Copy link
Contributor Author

Is this resolved?

I believe that everything is now resolved in this PR. It's certainly been a journey, but a very satisfying one!

@davidfowl
Copy link
Member

davidfowl commented Jun 4, 2022

@SteveDunn I would send you some .NET swag for your patience with us on this PR 😄

@SteveDunn
Copy link
Contributor Author

@SteveDunn I would send you some .NET swag for your patience with us on this PR 😄

Very kind - I'd take anything that's free! 😆

Copy link
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

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

This LGTM. Thanks for all your work here, @SteveDunn!

It would be good to get @halter73 and @maryamariyan to sign off as well.

Copy link
Member

@halter73 halter73 left a comment

Choose a reason for hiding this comment

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

@SteveDunn I would send you some .NET swag for your patience with us on this PR 😄

Agreed! Great work.

@maryamariyan maryamariyan merged commit d78f364 into dotnet:main Jun 9, 2022
@SteveDunn SteveDunn deleted the bind-to-immutable-types branch June 9, 2022 19:50
@ghost ghost locked as resolved and limited conversation to collaborators Jul 10, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-Extensions-Configuration community-contribution Indicates that the PR has been added by a community member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support immutable types with configuration binding
7 participants