-
Notifications
You must be signed in to change notification settings - Fork 4.8k
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
Conversation
Tagging subscribers to this area: @dotnet/area-extensions-configuration Issue DetailsFixes #43662 Support immutable types with configuration binding
|
This PR supersedes #67161 due to several git mishaps in a row! |
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/ConfigurationBinderTests.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/ConfigurationBinderTests.cs
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
@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:
|
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
@SteveDunn did you notice the CI build failure in https://github.com/dotnet/runtime/pull/67258/checks?check_run_id=5824336121? |
@madelson - I just merged in your changes from main. Would you mind giving this PR a quick look-over? |
@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. |
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
@@ -703,10 +759,10 @@ private static string GetPropertyName(MemberInfo property!!) | |||
.Value? | |||
.ToString(); | |||
|
|||
return !string.IsNullOrWhiteSpace(name) ? name : property.Name; |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/ConfigurationBinderTests.cs
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
@SteveDunn happy to do so; left a few quick comments but should hopefully be able to look in more depth later this week. |
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
continue; | ||
} | ||
|
||
if (chosenParameters is null || constructorParameters.Length > chosenParameters.Length) |
There was a problem hiding this comment.
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
andref
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 constructorsOptions(int a)
andOptions(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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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, bothref
andin
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
andref
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?'
There was a problem hiding this comment.
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);
}
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
parameterValues[index] = BindParameter(parameters[index], config, options); | ||
} | ||
|
||
return constructor.Invoke(parameterValues); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)?
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Show resolved
Hide resolved
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 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. |
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.
|
Re. the comment:
This is already covered in a test that I added named 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. |
This is one of a few of my PRs laying dormant. Is there anything I need to do to progress them? |
There was a problem hiding this 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.
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/ConfigurationBinderTests.cs
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/ConfigurationBinderTests.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/Resources/Strings.resx
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/ConfigurationBinderTests.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/ConfigurationBinderTests.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/ConfigurationBinderTests.cs
Outdated
Show resolved
Hide resolved
…t have corresponding config items.
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs
Outdated
Show resolved
Hide resolved
Is this resolved? |
I believe that everything is now resolved in this PR. It's certainly been a journey, but a very satisfying one! |
@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! 😆 |
There was a problem hiding this 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.
There was a problem hiding this 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.
Fixes #43662 Support immutable types with configuration binding