-
Notifications
You must be signed in to change notification settings - Fork 10k
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
Add DateOnly and TimeOnly to MVC Model Binding #45243
Closed
Closed
Changes from 3 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5474e5d
Add TimeOnly and DateOnly bind availability
Nick-Stanton 5534faf
Merge branch 'dotnet:main' into DateOnlyTimeOnlyBinding
Nick-Stanton cff11c5
Add test coverage for binders and binderproviders
Nick-Stanton 50beacf
Move new API to unshipped file
Nick-Stanton ed90b05
Replace null exception throwing
Nick-Stanton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
104 changes: 104 additions & 0 deletions
104
src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinder.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
#nullable enable | ||
|
||
using System.Globalization; | ||
using Microsoft.Extensions.Logging; | ||
|
||
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; | ||
|
||
/// <summary> | ||
/// An <see cref="IModelBinder"/> for <see cref="DateOnly"/> and nullable <see cref="DateOnly"/> models. | ||
/// </summary> | ||
public class DateOnlyModelBinder : IModelBinder | ||
{ | ||
private readonly DateTimeStyles _supportedStyles; | ||
private readonly ILogger _logger; | ||
|
||
/// <summary> | ||
/// Initializes a new instance of <see cref="DateOnlyModelBinder"/>. | ||
/// </summary> | ||
/// <param name="supportedStyles">The <see cref="DateTimeStyles"/>.</param> | ||
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param> | ||
public DateOnlyModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory) | ||
{ | ||
if (loggerFactory == null) | ||
{ | ||
throw new ArgumentNullException(nameof(loggerFactory)); | ||
} | ||
|
||
_supportedStyles = supportedStyles; | ||
_logger = loggerFactory.CreateLogger<DateOnlyModelBinder>(); | ||
} | ||
|
||
/// <inheritdoc /> | ||
public Task BindModelAsync(ModelBindingContext bindingContext) | ||
{ | ||
if (bindingContext == null) | ||
{ | ||
throw new ArgumentNullException(nameof(bindingContext)); | ||
} | ||
|
||
_logger.AttemptingToBindModel(bindingContext); | ||
|
||
var modelName = bindingContext.ModelName; | ||
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); | ||
if (valueProviderResult == ValueProviderResult.None) | ||
{ | ||
_logger.FoundNoValueInRequest(bindingContext); | ||
|
||
// no entry | ||
_logger.DoneAttemptingToBindModel(bindingContext); | ||
return Task.CompletedTask; | ||
} | ||
|
||
var modelState = bindingContext.ModelState; | ||
modelState.SetModelValue(modelName, valueProviderResult); | ||
|
||
var metadata = bindingContext.ModelMetadata; | ||
var type = metadata.UnderlyingOrModelType; | ||
try | ||
{ | ||
var value = valueProviderResult.FirstValue; | ||
|
||
object? model; | ||
if (string.IsNullOrWhiteSpace(value)) | ||
{ | ||
// Parse() method trims the value (with common DateTimeSyles) then throws if the result is empty. | ||
model = null; | ||
} | ||
else if (type == typeof(DateOnly)) | ||
{ | ||
model = DateOnly.Parse(value, valueProviderResult.Culture, _supportedStyles); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I recommend the |
||
} | ||
else | ||
{ | ||
throw new NotSupportedException(); | ||
Nick-Stanton marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// When converting value, a null model may indicate a failed conversion for an otherwise required | ||
// model (can't set a ValueType to null). This detects if a null model value is acceptable given the | ||
// current bindingContext. If not, an error is logged. | ||
if (model == null && !metadata.IsReferenceOrNullableType) | ||
{ | ||
modelState.TryAddModelError( | ||
modelName, | ||
metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor( | ||
valueProviderResult.ToString())); | ||
} | ||
else | ||
{ | ||
bindingContext.Result = ModelBindingResult.Success(model); | ||
} | ||
} | ||
catch (Exception exception) | ||
{ | ||
// Conversion failed. | ||
modelState.TryAddModelError(modelName, exception, metadata); | ||
} | ||
|
||
_logger.DoneAttemptingToBindModel(bindingContext); | ||
return Task.CompletedTask; | ||
} | ||
} |
36 changes: 36 additions & 0 deletions
36
src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinderProvider.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
#nullable enable | ||
|
||
using System.Globalization; | ||
using Microsoft.Extensions.DependencyInjection; | ||
using Microsoft.Extensions.Logging; | ||
|
||
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; | ||
|
||
/// <summary> | ||
/// An <see cref="IModelBinderProvider"/> for binding <see cref="DateOnly" /> and nullable <see cref="DateOnly"/> models. | ||
/// </summary> | ||
public class DateOnlyModelBinderProvider : IModelBinderProvider | ||
{ | ||
internal const DateTimeStyles SupportedStyles = DateTimeStyles.AllowWhiteSpaces; | ||
|
||
/// <inheritdoc /> | ||
public IModelBinder? GetBinder(ModelBinderProviderContext context) | ||
{ | ||
if (context == null) | ||
{ | ||
throw new ArgumentNullException(nameof(context)); | ||
} | ||
Nick-Stanton marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
var modelType = context.Metadata.UnderlyingOrModelType; | ||
if (modelType == typeof(DateOnly)) | ||
{ | ||
var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>(); | ||
return new DateOnlyModelBinder(SupportedStyles, loggerFactory); | ||
} | ||
|
||
return null; | ||
} | ||
} |
104 changes: 104 additions & 0 deletions
104
src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinder.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
#nullable enable | ||
|
||
using System.Globalization; | ||
using Microsoft.Extensions.Logging; | ||
|
||
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; | ||
|
||
/// <summary> | ||
/// An <see cref="IModelBinder"/> for <see cref="TimeOnly"/> and nullable <see cref="TimeOnly"/> models. | ||
/// </summary> | ||
public class TimeOnlyModelBinder : IModelBinder | ||
{ | ||
private readonly DateTimeStyles _supportedStyles; | ||
private readonly ILogger _logger; | ||
|
||
/// <summary> | ||
/// Initializes a new instance of <see cref="TimeOnlyModelBinder"/>. | ||
/// </summary> | ||
/// <param name="supportedStyles">The <see cref="DateTimeStyles"/>.</param> | ||
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param> | ||
public TimeOnlyModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory) | ||
TanayParikh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
if (loggerFactory == null) | ||
{ | ||
throw new ArgumentNullException(nameof(loggerFactory)); | ||
} | ||
|
||
_supportedStyles = supportedStyles; | ||
_logger = loggerFactory.CreateLogger<TimeOnlyModelBinder>(); | ||
} | ||
|
||
/// <inheritdoc /> | ||
public Task BindModelAsync(ModelBindingContext bindingContext) | ||
{ | ||
if (bindingContext == null) | ||
{ | ||
throw new ArgumentNullException(nameof(bindingContext)); | ||
} | ||
|
||
_logger.AttemptingToBindModel(bindingContext); | ||
|
||
var modelName = bindingContext.ModelName; | ||
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); | ||
if (valueProviderResult == ValueProviderResult.None) | ||
{ | ||
_logger.FoundNoValueInRequest(bindingContext); | ||
|
||
// no entry | ||
_logger.DoneAttemptingToBindModel(bindingContext); | ||
return Task.CompletedTask; | ||
} | ||
|
||
var modelState = bindingContext.ModelState; | ||
modelState.SetModelValue(modelName, valueProviderResult); | ||
|
||
var metadata = bindingContext.ModelMetadata; | ||
var type = metadata.UnderlyingOrModelType; | ||
try | ||
{ | ||
var value = valueProviderResult.FirstValue; | ||
|
||
object? model; | ||
if (string.IsNullOrWhiteSpace(value)) | ||
{ | ||
// Parse() method trims the value (with common DateTimeSyles) then throws if the result is empty. | ||
model = null; | ||
} | ||
else if (type == typeof(TimeOnly)) | ||
{ | ||
model = TimeOnly.Parse(value, valueProviderResult.Culture, _supportedStyles); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I recommend the |
||
} | ||
else | ||
{ | ||
throw new NotSupportedException(); | ||
} | ||
|
||
// When converting value, a null model may indicate a failed conversion for an otherwise required | ||
// model (can't set a ValueType to null). This detects if a null model value is acceptable given the | ||
// current bindingContext. If not, an error is logged. | ||
if (model == null && !metadata.IsReferenceOrNullableType) | ||
{ | ||
modelState.TryAddModelError( | ||
modelName, | ||
metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor( | ||
valueProviderResult.ToString())); | ||
} | ||
else | ||
{ | ||
bindingContext.Result = ModelBindingResult.Success(model); | ||
} | ||
} | ||
catch (Exception exception) | ||
{ | ||
// Conversion failed. | ||
modelState.TryAddModelError(modelName, exception, metadata); | ||
} | ||
|
||
_logger.DoneAttemptingToBindModel(bindingContext); | ||
return Task.CompletedTask; | ||
} | ||
} |
36 changes: 36 additions & 0 deletions
36
src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinderProvider.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
#nullable enable | ||
|
||
using System.Globalization; | ||
using Microsoft.Extensions.DependencyInjection; | ||
using Microsoft.Extensions.Logging; | ||
|
||
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; | ||
|
||
/// <summary> | ||
/// An <see cref="IModelBinderProvider"/> for binding <see cref="TimeOnly"/> and nullable <see cref="TimeOnly"/> models. | ||
/// </summary> | ||
public class TimeOnlyModelBinderProvider : IModelBinderProvider | ||
{ | ||
internal const DateTimeStyles SupportedStyles = DateTimeStyles.AllowWhiteSpaces; | ||
|
||
/// <inheritdoc /> | ||
public IModelBinder? GetBinder(ModelBinderProviderContext context) | ||
{ | ||
if (context == null) | ||
{ | ||
throw new ArgumentNullException(nameof(context)); | ||
} | ||
|
||
var modelType = context.Metadata.UnderlyingOrModelType; | ||
if (modelType == typeof(TimeOnly)) | ||
{ | ||
var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>(); | ||
return new TimeOnlyModelBinder(SupportedStyles, loggerFactory); | ||
} | ||
|
||
return null; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
54 changes: 54 additions & 0 deletions
54
src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderProviderTest.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; | ||
|
||
public class DateOnlyModelBinderProviderTest | ||
{ | ||
private readonly DateOnlyModelBinderProvider _provider = new DateOnlyModelBinderProvider(); | ||
|
||
[Theory] | ||
[InlineData(typeof(string))] | ||
[InlineData(typeof(DateTimeOffset))] | ||
[InlineData(typeof(DateTimeOffset?))] | ||
[InlineData(typeof(DateTime))] | ||
[InlineData(typeof(DateTime?))] | ||
[InlineData(typeof(TimeSpan))] | ||
public void Create_ForNonDateOnly_ReturnsNull(Type modelType) | ||
{ | ||
// Arrange | ||
var context = new TestModelBinderProviderContext(modelType); | ||
|
||
// Act | ||
var result = _provider.GetBinder(context); | ||
|
||
// Assert | ||
Assert.Null(result); | ||
} | ||
|
||
[Fact] | ||
public void Create_ForDateOnly_ReturnsBinder() | ||
{ | ||
// Arrange | ||
var context = new TestModelBinderProviderContext(typeof(DateOnly)); | ||
|
||
// Act | ||
var result = _provider.GetBinder(context); | ||
|
||
// Assert | ||
Assert.IsType<DateOnlyModelBinder>(result); | ||
} | ||
|
||
[Fact] | ||
public void Create_ForNullableDateOnly_ReturnsBinder() | ||
{ | ||
// Arrange | ||
var context = new TestModelBinderProviderContext(typeof(DateOnly?)); | ||
|
||
// Act | ||
var result = _provider.GetBinder(context); | ||
|
||
// Assert | ||
Assert.IsType<DateOnlyModelBinder>(result); | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Can these new
IModelBinderProvider
s ever get hit outside of unit tests? I cloned this PR and added the following lines to MvcSandbox'sHomeController
.aspnetcore/src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs
Lines 8 to 17 in 63c4948
And then made a request to
/?Id=3&Time=12:34:56%20PM
and added breakpoints. The Time got populated by theTryParseModelBinderProvider
added by @brunolins16 in #40233. Since that's added before these providers, it gets first crack at bindingDateOnly
andTimeOnly
.For this reason and to maintain back compat, we put
TryParseModelBinderProvider
afterDateTimeModelBinderProvider
. Even if we remove theTryParseModelBinderProvider
, theSimpleTypeModelBinderProvider
would handle this first as well usingTypeConverter
.aspnetcore/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs
Lines 65 to 67 in 63c4948
Since we don't have any back compat concerns for
DateOnly
andTimeOnly
, I'm not sure we need these. It does allow customizingDateTimeStyles
if you construct the providers yourself and add them manually, but I don't like having to maintain providers for each possible type.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.
@halter73 Are there any differences in behavior between the TryParse implementation and the concrete modelbinders? Or any other concern like perf? If the TryParse implementation supports all of them, should we get rid of the DateTime and DateTimeOffset modelbinders?
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 would like to get rid of the
DateTime
andDateTimeOffset
model binders, but I don't think it's worth potentially breaking working code. @brunolins16 What do you think?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.
@halter73 I do not think most people will change those binders directly. We can remove them from 8.0, make an announcement in preview1 and see if someone is broken by 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.
As reported here , it could potentially be break and was decided (I don't have the historical reason) to create specific binders for them even though the simpletypebinder supported them a long ago. That said I believe this is a worthy investigation and we don't need to delete them and instead just remove the default registration and let the binder available for manual registration.
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.
Great point, this was an essential part of getting
DateTime
binding correct. That being said,DateTimeStyles.AdjustToUniversal
is not supported forDateOnly
andTimeOnly
so the same requirement is not there.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.
Yeah, the only benefit I see in have those two new binders, is allow users to customize the
DateTimeStyles
creating their ownModelBinderProviders
.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.
Suggestion 💡: What if we add those two new
Binder
to allow users customization, without theProviders
(no registration), and let the TryParse/Converter binder do the work by default.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.
These are the
DatetimeStyles
when using the TryParse binder:aspnetcore/src/Shared/ParameterBindingMethodCache.cs
Lines 130 to 135 in 5c62d0f
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.
Yep,
DateTimeStyles.AllowWhiteSpaces
is what I currently have for these proposed providers. While writing them I found that the only supported styles forTimeOnly
andDateOnly
were the ones pertaining to white space, but I can't seem to find a doc page to back that up.Also, do you think there's any gain/loss when TryParse is used over any of the other providers/binders? I see that TryParse goes before SimpleType.