Skip to content

Commit 2b493c4

Browse files
Ability to deactivate DataAnnotationsValidation dynamically. Fixes #31027 (#31413)
1 parent 2e78ac0 commit 2b493c4

File tree

9 files changed

+198
-82
lines changed

9 files changed

+198
-82
lines changed

src/Components/Forms/src/DataAnnotationsValidator.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ namespace Microsoft.AspNetCore.Components.Forms
88
/// <summary>
99
/// Adds Data Annotations validation support to an <see cref="EditContext"/>.
1010
/// </summary>
11-
public class DataAnnotationsValidator : ComponentBase
11+
public class DataAnnotationsValidator : ComponentBase, IDisposable
1212
{
13+
private IDisposable? _subscriptions;
14+
private EditContext? _originalEditContext;
15+
1316
[CascadingParameter] EditContext? CurrentEditContext { get; set; }
1417

1518
/// <inheritdoc />
@@ -22,7 +25,33 @@ protected override void OnInitialized()
2225
$"inside an EditForm.");
2326
}
2427

25-
CurrentEditContext.AddDataAnnotationsValidation();
28+
_subscriptions = CurrentEditContext.EnableDataAnnotationsValidation();
29+
_originalEditContext = CurrentEditContext;
30+
}
31+
32+
/// <inheritdoc />
33+
protected override void OnParametersSet()
34+
{
35+
if (CurrentEditContext != _originalEditContext)
36+
{
37+
// While we could support this, there's no known use case presently. Since InputBase doesn't support it,
38+
// it's more understandable to have the same restriction.
39+
throw new InvalidOperationException($"{GetType()} does not support changing the " +
40+
$"{nameof(EditContext)} dynamically.");
41+
}
42+
}
43+
44+
/// <inheritdoc/>
45+
protected virtual void Dispose(bool disposing)
46+
{
47+
}
48+
49+
void IDisposable.Dispose()
50+
{
51+
_subscriptions?.Dispose();
52+
_subscriptions = null;
53+
54+
Dispose(disposing: true);
2655
}
2756
}
2857
}

src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs

Lines changed: 85 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -16,98 +16,118 @@ namespace Microsoft.AspNetCore.Components.Forms
1616
/// </summary>
1717
public static class EditContextDataAnnotationsExtensions
1818
{
19-
private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache
20-
= new ConcurrentDictionary<(Type, string), PropertyInfo?>();
21-
2219
/// <summary>
2320
/// Adds DataAnnotations validation support to the <see cref="EditContext"/>.
2421
/// </summary>
2522
/// <param name="editContext">The <see cref="EditContext"/>.</param>
23+
[Obsolete("Use " + nameof(EnableDataAnnotationsValidation) + " instead.")]
2624
public static EditContext AddDataAnnotationsValidation(this EditContext editContext)
2725
{
28-
if (editContext == null)
29-
{
30-
throw new ArgumentNullException(nameof(editContext));
31-
}
32-
33-
var messages = new ValidationMessageStore(editContext);
34-
35-
// Perform object-level validation on request
36-
editContext.OnValidationRequested +=
37-
(sender, eventArgs) => ValidateModel((EditContext)sender!, messages);
38-
39-
// Perform per-field validation on each field edit
40-
editContext.OnFieldChanged +=
41-
(sender, eventArgs) => ValidateField(editContext, messages, eventArgs.FieldIdentifier);
42-
26+
EnableDataAnnotationsValidation(editContext);
4327
return editContext;
4428
}
4529

46-
private static void ValidateModel(EditContext editContext, ValidationMessageStore messages)
30+
/// <summary>
31+
/// Enables DataAnnotations validation support for the <see cref="EditContext"/>.
32+
/// </summary>
33+
/// <param name="editContext">The <see cref="EditContext"/>.</param>
34+
/// <returns>A disposable object whose disposal will remove DataAnnotations validation support from the <see cref="EditContext"/>.</returns>
35+
public static IDisposable EnableDataAnnotationsValidation(this EditContext editContext)
36+
{
37+
return new DataAnnotationsEventSubscriptions(editContext);
38+
}
39+
40+
private sealed class DataAnnotationsEventSubscriptions : IDisposable
4741
{
48-
var validationContext = new ValidationContext(editContext.Model);
49-
var validationResults = new List<ValidationResult>();
50-
Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);
42+
private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache = new();
5143

52-
// Transfer results to the ValidationMessageStore
53-
messages.Clear();
54-
foreach (var validationResult in validationResults)
44+
private readonly EditContext _editContext;
45+
private readonly ValidationMessageStore _messages;
46+
47+
public DataAnnotationsEventSubscriptions(EditContext editContext)
5548
{
56-
if (validationResult == null)
57-
{
58-
continue;
59-
}
49+
_editContext = editContext ?? throw new ArgumentNullException(nameof(editContext));
50+
_messages = new ValidationMessageStore(_editContext);
6051

61-
if (!validationResult.MemberNames.Any())
62-
{
63-
messages.Add(new FieldIdentifier(editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage!);
64-
continue;
65-
}
52+
_editContext.OnFieldChanged += OnFieldChanged;
53+
_editContext.OnValidationRequested += OnValidationRequested;
54+
}
6655

67-
foreach (var memberName in validationResult.MemberNames)
56+
private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs)
57+
{
58+
var fieldIdentifier = eventArgs.FieldIdentifier;
59+
if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
6860
{
69-
messages.Add(editContext.Field(memberName), validationResult.ErrorMessage!);
61+
var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
62+
var validationContext = new ValidationContext(fieldIdentifier.Model)
63+
{
64+
MemberName = propertyInfo.Name
65+
};
66+
var results = new List<ValidationResult>();
67+
68+
Validator.TryValidateProperty(propertyValue, validationContext, results);
69+
_messages.Clear(fieldIdentifier);
70+
_messages.Add(fieldIdentifier, results.Select(result => result.ErrorMessage!));
71+
72+
// We have to notify even if there were no messages before and are still no messages now,
73+
// because the "state" that changed might be the completion of some async validation task
74+
_editContext.NotifyValidationStateChanged();
7075
}
7176
}
7277

73-
editContext.NotifyValidationStateChanged();
74-
}
75-
76-
private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
77-
{
78-
if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
78+
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
7979
{
80-
var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
81-
var validationContext = new ValidationContext(fieldIdentifier.Model)
80+
var validationContext = new ValidationContext(_editContext.Model);
81+
var validationResults = new List<ValidationResult>();
82+
Validator.TryValidateObject(_editContext.Model, validationContext, validationResults, true);
83+
84+
// Transfer results to the ValidationMessageStore
85+
_messages.Clear();
86+
foreach (var validationResult in validationResults)
8287
{
83-
MemberName = propertyInfo.Name
84-
};
85-
var results = new List<ValidationResult>();
88+
if (validationResult == null)
89+
{
90+
continue;
91+
}
92+
93+
if (!validationResult.MemberNames.Any())
94+
{
95+
_messages.Add(new FieldIdentifier(_editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage!);
96+
continue;
97+
}
98+
99+
foreach (var memberName in validationResult.MemberNames)
100+
{
101+
_messages.Add(_editContext.Field(memberName), validationResult.ErrorMessage!);
102+
}
103+
}
86104

87-
Validator.TryValidateProperty(propertyValue, validationContext, results);
88-
messages.Clear(fieldIdentifier);
89-
messages.Add(fieldIdentifier, results.Select(result => result.ErrorMessage!));
105+
_editContext.NotifyValidationStateChanged();
106+
}
90107

91-
// We have to notify even if there were no messages before and are still no messages now,
92-
// because the "state" that changed might be the completion of some async validation task
93-
editContext.NotifyValidationStateChanged();
108+
public void Dispose()
109+
{
110+
_messages.Clear();
111+
_editContext.OnFieldChanged -= OnFieldChanged;
112+
_editContext.OnValidationRequested -= OnValidationRequested;
113+
_editContext.NotifyValidationStateChanged();
94114
}
95-
}
96115

97-
private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, [NotNullWhen(true)] out PropertyInfo? propertyInfo)
98-
{
99-
var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
100-
if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
116+
private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, [NotNullWhen(true)] out PropertyInfo? propertyInfo)
101117
{
102-
// DataAnnotations only validates public properties, so that's all we'll look for
103-
// If we can't find it, cache 'null' so we don't have to try again next time
104-
propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);
118+
var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
119+
if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
120+
{
121+
// DataAnnotations only validates public properties, so that's all we'll look for
122+
// If we can't find it, cache 'null' so we don't have to try again next time
123+
propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);
105124

106-
// No need to lock, because it doesn't matter if we write the same value twice
107-
_propertyInfoCache[cacheKey] = propertyInfo;
108-
}
125+
// No need to lock, because it doesn't matter if we write the same value twice
126+
_propertyInfoCache[cacheKey] = propertyInfo;
127+
}
109128

110-
return propertyInfo != null;
129+
return propertyInfo != null;
130+
}
111131
}
112132
}
113133
}

src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.WarningSuppressions.xml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,19 @@
55
<argument>ILLink</argument>
66
<argument>IL2026</argument>
77
<property name="Scope">member</property>
8-
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.ValidateField(Microsoft.AspNetCore.Components.Forms.EditContext,Microsoft.AspNetCore.Components.Forms.ValidationMessageStore,Microsoft.AspNetCore.Components.Forms.FieldIdentifier@)</property>
8+
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.DataAnnotationsEventSubscriptions.OnFieldChanged(System.Object,Microsoft.AspNetCore.Components.Forms.FieldChangedEventArgs)</property>
99
</attribute>
1010
<attribute fullname="System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessageAttribute">
1111
<argument>ILLink</argument>
1212
<argument>IL2026</argument>
1313
<property name="Scope">member</property>
14-
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.ValidateModel(Microsoft.AspNetCore.Components.Forms.EditContext,Microsoft.AspNetCore.Components.Forms.ValidationMessageStore)</property>
14+
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.DataAnnotationsEventSubscriptions.OnValidationRequested(System.Object,Microsoft.AspNetCore.Components.Forms.ValidationRequestedEventArgs)</property>
1515
</attribute>
1616
<attribute fullname="System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessageAttribute">
1717
<argument>ILLink</argument>
1818
<argument>IL2080</argument>
1919
<property name="Scope">member</property>
20-
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.TryGetValidatableProperty(Microsoft.AspNetCore.Components.Forms.FieldIdentifier@,System.Reflection.PropertyInfo@)</property>
20+
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.DataAnnotationsEventSubscriptions.TryGetValidatableProperty(Microsoft.AspNetCore.Components.Forms.FieldIdentifier@,System.Reflection.PropertyInfo@)</property>
2121
</attribute>
2222
</assembly>
2323
</linker>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
#nullable enable
2+
override Microsoft.AspNetCore.Components.Forms.DataAnnotationsValidator.OnParametersSet() -> void
3+
static Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.EnableDataAnnotationsValidation(this Microsoft.AspNetCore.Components.Forms.EditContext! editContext) -> System.IDisposable!
4+
virtual Microsoft.AspNetCore.Components.Forms.DataAnnotationsValidator.Dispose(bool disposing) -> void

src/Components/Forms/test/EditContextDataAnnotationsExtensionsTest.cs

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,17 @@ public class EditContextDataAnnotationsExtensionsTest
1313
public void CannotUseNullEditContext()
1414
{
1515
var editContext = (EditContext)null;
16-
var ex = Assert.Throws<ArgumentNullException>(() => editContext.AddDataAnnotationsValidation());
16+
var ex = Assert.Throws<ArgumentNullException>(() => editContext.EnableDataAnnotationsValidation());
1717
Assert.Equal("editContext", ex.ParamName);
1818
}
1919

2020
[Fact]
21-
public void ReturnsEditContextForChaining()
21+
public void ObsoleteApiReturnsEditContextForChaining()
2222
{
2323
var editContext = new EditContext(new object());
24+
#pragma warning disable 0618
2425
var returnValue = editContext.AddDataAnnotationsValidation();
26+
#pragma warning restore 0618
2527
Assert.Same(editContext, returnValue);
2628
}
2729

@@ -30,7 +32,8 @@ public void GetsValidationMessagesFromDataAnnotations()
3032
{
3133
// Arrange
3234
var model = new TestModel { IntFrom1To100 = 101 };
33-
var editContext = new EditContext(model).AddDataAnnotationsValidation();
35+
var editContext = new EditContext(model);
36+
editContext.EnableDataAnnotationsValidation();
3437

3538
// Act
3639
var isValid = editContext.Validate();
@@ -59,7 +62,8 @@ public void ClearsExistingValidationMessagesOnFurtherRuns()
5962
{
6063
// Arrange
6164
var model = new TestModel { IntFrom1To100 = 101 };
62-
var editContext = new EditContext(model).AddDataAnnotationsValidation();
65+
var editContext = new EditContext(model);
66+
editContext.EnableDataAnnotationsValidation();
6367

6468
// Act/Assert 1: Initially invalid
6569
Assert.False(editContext.Validate());
@@ -75,7 +79,8 @@ public void NotifiesValidationStateChangedAfterObjectValidation()
7579
{
7680
// Arrange
7781
var model = new TestModel { IntFrom1To100 = 101 };
78-
var editContext = new EditContext(model).AddDataAnnotationsValidation();
82+
var editContext = new EditContext(model);
83+
editContext.EnableDataAnnotationsValidation();
7984
var onValidationStateChangedCount = 0;
8085
editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;
8186

@@ -102,7 +107,8 @@ public void PerformsPerPropertyValidationOnFieldChange()
102107
// Arrange
103108
var model = new TestModel { IntFrom1To100 = 101 };
104109
var independentTopLevelModel = new object(); // To show we can validate things on any model, not just the top-level one
105-
var editContext = new EditContext(independentTopLevelModel).AddDataAnnotationsValidation();
110+
var editContext = new EditContext(independentTopLevelModel);
111+
editContext.EnableDataAnnotationsValidation();
106112
var onValidationStateChangedCount = 0;
107113
var requiredStringIdentifier = new FieldIdentifier(model, nameof(TestModel.RequiredString));
108114
var intFrom1To100Identifier = new FieldIdentifier(model, nameof(TestModel.IntFrom1To100));
@@ -141,7 +147,8 @@ public void PerformsPerPropertyValidationOnFieldChange()
141147
public void IgnoresFieldChangesThatDoNotCorrespondToAValidatableProperty(string fieldName)
142148
{
143149
// Arrange
144-
var editContext = new EditContext(new TestModel()).AddDataAnnotationsValidation();
150+
var editContext = new EditContext(new TestModel());
151+
editContext.EnableDataAnnotationsValidation();
145152
var onValidationStateChangedCount = 0;
146153
editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;
147154

@@ -154,6 +161,24 @@ public void IgnoresFieldChangesThatDoNotCorrespondToAValidatableProperty(string
154161
Assert.Equal(1, onValidationStateChangedCount);
155162
}
156163

164+
[Fact]
165+
public void CanDetachFromEditContext()
166+
{
167+
// Arrange
168+
var model = new TestModel { IntFrom1To100 = 101 };
169+
var editContext = new EditContext(model);
170+
var subscription = editContext.EnableDataAnnotationsValidation();
171+
172+
// Act/Assert 1: when we're attached
173+
Assert.False(editContext.Validate());
174+
Assert.NotEmpty(editContext.GetValidationMessages());
175+
176+
// Act/Assert 2: when we're detached
177+
subscription.Dispose();
178+
Assert.True(editContext.Validate());
179+
Assert.Empty(editContext.GetValidationMessages());
180+
}
181+
157182
class TestModel
158183
{
159184
[Required(ErrorMessage = "RequiredString:required")] public string RequiredString { get; set; }

0 commit comments

Comments
 (0)