Skip to content

Commit da3f97b

Browse files
authored
Fix client validation for record types (#26159)
* Fix client validation for record types Server validation for record types uses metadata from parameters when validating record type properties. However client validation does not use the parameter to harvest client validation attributes. In the absence of this change, validation on parameters would require server round trips which is unexcepted and not at parity with validation applied to properties on regular classes or record types. Validation experience with record types is subpar and requires server round trips. No. This feature is new to 5.0. Low. The change is isolated to record types and does not affect other code paths. We have unit and functional test coverage to verify this change. * Correctly dispose app after use
1 parent 7caa0d4 commit da3f97b

File tree

8 files changed

+141
-12
lines changed

8 files changed

+141
-12
lines changed

src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public abstract class ModelMetadata : IEquatable<ModelMetadata?>, IModelMetadata
3131
private int? _hashCode;
3232
private IReadOnlyList<ModelMetadata>? _boundProperties;
3333
private IReadOnlyDictionary<ModelMetadata, ModelMetadata>? _parameterMapping;
34+
private IReadOnlyDictionary<ModelMetadata, ModelMetadata>? _boundConstructorPropertyMapping;
3435
private Exception? _recordTypeValidatorsOnPropertiesError;
3536
private bool _recordTypeConstructorDetailsCalculated;
3637

@@ -153,6 +154,21 @@ internal IReadOnlyDictionary<ModelMetadata, ModelMetadata> BoundConstructorParam
153154
}
154155
}
155156

157+
/// <summary>
158+
/// A mapping from properties to their corresponding constructor parameter on a record type.
159+
/// This is the inverse mapping of <see cref="BoundConstructorParameterMapping"/>.
160+
/// </summary>
161+
internal IReadOnlyDictionary<ModelMetadata, ModelMetadata> BoundConstructorPropertyMapping
162+
{
163+
get
164+
{
165+
Debug.Assert(BoundConstructor != null, "This API can be only called for types with bound constructors.");
166+
CalculateRecordTypeConstructorDetails();
167+
168+
return _boundConstructorPropertyMapping;
169+
}
170+
}
171+
156172
/// <summary>
157173
/// Gets <see cref="ModelMetadata"/> instance for a constructor of a record type that is used during binding and validation.
158174
/// </summary>
@@ -492,18 +508,19 @@ internal void ThrowIfRecordTypeHasValidationOnProperties()
492508
}
493509
}
494510

495-
[MemberNotNull(nameof(_parameterMapping))]
511+
[MemberNotNull(nameof(_parameterMapping), nameof(_boundConstructorPropertyMapping))]
496512
private void CalculateRecordTypeConstructorDetails()
497513
{
498514
if (_recordTypeConstructorDetailsCalculated)
499515
{
500516
Debug.Assert(_parameterMapping != null);
517+
Debug.Assert(_boundConstructorPropertyMapping != null);
501518
return;
502519
}
503520

504-
505521
var boundParameters = BoundConstructor!.BoundConstructorParameters!;
506522
var parameterMapping = new Dictionary<ModelMetadata, ModelMetadata>();
523+
var propertyMapping = new Dictionary<ModelMetadata, ModelMetadata>();
507524

508525
foreach (var parameter in boundParameters)
509526
{
@@ -514,6 +531,7 @@ private void CalculateRecordTypeConstructorDetails()
514531
if (property != null)
515532
{
516533
parameterMapping[parameter] = property;
534+
propertyMapping[property] = parameter;
517535

518536
if (property.PropertyHasValidators)
519537
{
@@ -529,6 +547,7 @@ private void CalculateRecordTypeConstructorDetails()
529547

530548
_recordTypeConstructorDetailsCalculated = true;
531549
_parameterMapping = parameterMapping;
550+
_boundConstructorPropertyMapping = propertyMapping;
532551
}
533552

534553
/// <summary>

src/Mvc/Mvc.Core/src/ModelBinding/Validation/ClientValidatorCache.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
55
using System.Collections.Concurrent;
66
using System.Collections.Generic;
77
using System.Diagnostics;
8+
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
89

910
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation
1011
{
@@ -14,6 +15,15 @@ public class ClientValidatorCache
1415

1516
public IReadOnlyList<IClientModelValidator> GetValidators(ModelMetadata metadata, IClientModelValidatorProvider validatorProvider)
1617
{
18+
if (metadata.MetadataKind == ModelMetadataKind.Property &&
19+
metadata.ContainerMetadata?.BoundConstructor != null &&
20+
metadata.ContainerMetadata.BoundConstructorPropertyMapping.TryGetValue(metadata, out var parameter))
21+
{
22+
// "metadata" typically points to properties. When working with record types, we want to read validation details from the
23+
// constructor parameter instead. So let's switch it out.
24+
metadata = parameter;
25+
}
26+
1727
if (_cacheEntries.TryGetValue(metadata, out var entry))
1828
{
1929
return GetValidatorsFromEntry(entry, metadata, validatorProvider);
@@ -107,7 +117,7 @@ private IReadOnlyList<IClientModelValidator> ExtractValidators(List<ClientValida
107117

108118
var validators = new IClientModelValidator[count];
109119
var clientValidatorIndex = 0;
110-
for (int i = 0; i < items.Count; i++)
120+
for (var i = 0; i < items.Count; i++)
111121
{
112122
var validator = items[i].Validator;
113123
if (validator != null)

src/Mvc/Mvc.Core/test/ModelBinding/Validation/ClientValidatorCacheTest.cs

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System.ComponentModel.DataAnnotations;
@@ -64,13 +64,63 @@ public void GetValidators_DoesNotCacheValidatorsWithIsReusableFalse()
6464
Assert.NotSame(validator2, Assert.Single(validators2.OfType<StringLengthAttributeAdapter>())); // not cached
6565
}
6666

67+
[Fact]
68+
public void GetValidators_ReadsValidatorsFromCorrespondingRecordTypeParameter()
69+
{
70+
// Arrange
71+
var cache = new ClientValidatorCache();
72+
var modelMetadataProvider = new TestModelMetadataProvider();
73+
var metadata = modelMetadataProvider.GetMetadataForType(typeof(TestRecordType));
74+
var property = metadata.Properties[nameof(TestRecordType.Property1)];
75+
var parameter = metadata.BoundConstructor.BoundConstructorParameters.First(f => f.Name == nameof(TestRecordType.Property1));
76+
var validatorProvider = new ProviderWithNonReusableValidators();
77+
78+
// Act
79+
var validators = cache.GetValidators(property, validatorProvider);
80+
81+
// Assert
82+
var validator1 = Assert.Single(validators.OfType<RequiredAttributeAdapter>());
83+
var validator2 = Assert.Single(validators.OfType<StringLengthAttributeAdapter>());
84+
Assert.Contains(validator1.Attribute, parameter.ValidatorMetadata); // Copied by provider
85+
Assert.Contains(validator2.Attribute, parameter.ValidatorMetadata); // Copied by provider
86+
}
87+
88+
[Fact]
89+
public void GetValidators_ReadsValidatorsFromProperty_IfRecordTypeDoesNotHaveCorrespondingParameter()
90+
{
91+
// Arrange
92+
var cache = new ClientValidatorCache();
93+
var modelMetadataProvider = new TestModelMetadataProvider();
94+
var metadata = modelMetadataProvider.GetMetadataForType(typeof(TestRecordTypeWithProperty));
95+
var property = metadata.Properties[nameof(TestRecordTypeWithProperty.Property2)];
96+
var validatorProvider = new ProviderWithNonReusableValidators();
97+
98+
// Act
99+
var validators = cache.GetValidators(property, validatorProvider);
100+
101+
// Assert
102+
var validator1 = Assert.Single(validators.OfType<RequiredAttributeAdapter>());
103+
var validator2 = Assert.Single(validators.OfType<StringLengthAttributeAdapter>());
104+
Assert.Contains(validator1.Attribute, property.ValidatorMetadata); // Copied by provider
105+
Assert.Contains(validator2.Attribute, property.ValidatorMetadata); // Copied by provider
106+
}
107+
67108
private class TypeWithProperty
68109
{
69110
[Required]
70111
[StringLength(10)]
71112
public string Property1 { get; set; }
72113
}
73114

115+
private record TestRecordType([Required][StringLength(10)] string Property1);
116+
117+
private record TestRecordTypeWithProperty([Required][StringLength(10)] string Property1)
118+
{
119+
[Required]
120+
[StringLength(10)]
121+
public string Property2 { get; set; }
122+
}
123+
74124
private class ProviderWithNonReusableValidators : IClientModelValidatorProvider
75125
{
76126
public void CreateValidators(ClientValidatorProviderContext context)
@@ -101,4 +151,4 @@ public void CreateValidators(ClientValidatorProviderContext context)
101151
}
102152
}
103153
}
104-
}
154+
}

src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,32 @@ public async Task ValidationTagHelpers_GeneratesExpectedSpansAndDivs()
299299
#endif
300300
}
301301

302+
[Fact]
303+
public async Task ClientValidators_AreGeneratedDuringInitialRender()
304+
{
305+
// Arrange
306+
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Customer/HtmlGeneration_Customer/CustomerWithRecords");
307+
308+
// Act
309+
var response = await Client.SendAsync(request);
310+
311+
// Assert
312+
var document = await response.GetHtmlDocumentAsync();
313+
314+
var numberInput = document.RequiredQuerySelector("input[id=Number]");
315+
Assert.Equal("true", numberInput.GetAttribute("data-val"));
316+
Assert.Equal("The field Number must be between 1 and 100.", numberInput.GetAttribute("data-val-range"));
317+
Assert.Equal("The Number field is required.", numberInput.GetAttribute("data-val-required"));
318+
319+
var passwordInput = document.RequiredQuerySelector("input[id=Password]");
320+
Assert.Equal("true", passwordInput.GetAttribute("data-val"));
321+
Assert.Equal("The Password field is required.", passwordInput.GetAttribute("data-val-required"));
322+
323+
var addressInput = document.RequiredQuerySelector("input[id=Address]");
324+
Assert.Equal("true", addressInput.GetAttribute("data-val"));
325+
Assert.Equal("The Address field is required.", addressInput.GetAttribute("data-val-required"));
326+
}
327+
302328
[Fact]
303329
public async Task ValidationTagHelpers_UsingRecords()
304330
{
@@ -310,7 +336,8 @@ public async Task ValidationTagHelpers_UsingRecords()
310336
new KeyValuePair<string,string>("Name", string.Empty),
311337
new KeyValuePair<string,string>("Email", string.Empty),
312338
new KeyValuePair<string,string>("PhoneNumber", string.Empty),
313-
new KeyValuePair<string,string>("Password", string.Empty)
339+
new KeyValuePair<string,string>("Password", string.Empty),
340+
new KeyValuePair<string,string>("Address", string.Empty),
314341
};
315342
request.Content = new FormUrlEncodedContent(nameValueCollection);
316343

@@ -331,6 +358,9 @@ public async Task ValidationTagHelpers_UsingRecords()
331358

332359
validation = document.QuerySelector("span[data-valmsg-for=Password]");
333360
Assert.Equal("The Password field is required.", validation.TextContent);
361+
362+
validation = document.QuerySelector("span[data-valmsg-for=Address]");
363+
Assert.Equal("The Address field is required.", validation.TextContent);
334364
}
335365

336366
[Fact]

src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Controllers/HtmlGeneration_CustomerController.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ public IActionResult Index(Models.Customer customer)
1313
return View("Customer");
1414
}
1515

16+
[HttpGet]
17+
public IActionResult CustomerWithRecords()
18+
{
19+
return View("CustomerWithRecords");
20+
}
21+
1622
public IActionResult CustomerWithRecords(Models.CustomerRecord customer)
1723
{
1824
return View("CustomerWithRecords");
1925
}
2026
}
21-
}
27+
}

src/Mvc/test/WebSites/HtmlGenerationWebSite/Models/CustomerRecord.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,9 @@ public record CustomerRecord
2525
string Email,
2626

2727
string Key
28-
);
29-
}
28+
)
29+
{
30+
[Required]
31+
public string Address { get; set; }
32+
}
33+
}

src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/Shared/CustomerWithRecords.cshtml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,15 @@
3737
<input asp-for="Gender" type="radio" value="Male" /> Male
3838
<input asp-for="Gender" type="radio" value="Female" /> Female
3939
<span asp-validation-for="Gender"></span>
40+
</div>
41+
<div>
42+
<label asp-for="Address" class="order"></label>
43+
<input asp-for="Address" type="text" />
44+
<span asp-validation-for="Address"></span>
4045
</div>
4146
<div id="validation-summary-all" asp-validation-summary="All" class="order"></div>
4247
<div id="validation-summary-model" asp-validation-summary="ModelOnly" class="order"></div>
4348
<input type="submit"/>
4449
</form>
4550
</body>
46-
</html>
51+
</html>

src/Tools/dotnet-watch/test/BrowserLaunchTests.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests
1111
{
12-
public class BrowserLaunchTests
12+
public class BrowserLaunchTests : IDisposable
1313
{
1414
private readonly WatchableApp _app;
1515

@@ -64,5 +64,10 @@ public async Task UsesBrowserSpecifiedInEnvironment()
6464
// Verify we launched the browser.
6565
await _app.Process.GetOutputLineStartsWithAsync(launchBrowserMessage, TimeSpan.FromMinutes(2));
6666
}
67+
68+
public void Dispose()
69+
{
70+
_app.Dispose();
71+
}
6772
}
6873
}

0 commit comments

Comments
 (0)