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

Ability to deactivate DataAnnotationsValidation dynamically. Fixes #31027 #31413

Merged
merged 11 commits into from
Apr 5, 2021
33 changes: 31 additions & 2 deletions src/Components/Forms/src/DataAnnotationsValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ namespace Microsoft.AspNetCore.Components.Forms
/// <summary>
/// Adds Data Annotations validation support to an <see cref="EditContext"/>.
/// </summary>
public class DataAnnotationsValidator : ComponentBase
public class DataAnnotationsValidator : ComponentBase, IDisposable
{
private IDisposable? _subscriptions;
private EditContext? _originalEditContext;

[CascadingParameter] EditContext? CurrentEditContext { get; set; }

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

CurrentEditContext.AddDataAnnotationsValidation();
_subscriptions = CurrentEditContext.EnableDataAnnotationsValidation();
pranavkm marked this conversation as resolved.
Show resolved Hide resolved
_originalEditContext = CurrentEditContext;
}

/// <inheritdoc />
protected override void OnParametersSet()
{
if (CurrentEditContext != _originalEditContext)
{
// While we could support this, there's no known use case presently. Since InputBase doesn't support it,
// it's more understandable to have the same restriction.
throw new InvalidOperationException($"{GetType()} does not support changing the " +
$"{nameof(EditContext)} dynamically.");
}
}

/// <inheritdoc/>
protected virtual void Dispose(bool disposing)
{
}

void IDisposable.Dispose()
{
_subscriptions?.Dispose();
_subscriptions = null;

Dispose(disposing: true);
}
}
}
150 changes: 85 additions & 65 deletions src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,98 +16,118 @@ namespace Microsoft.AspNetCore.Components.Forms
/// </summary>
public static class EditContextDataAnnotationsExtensions
{
private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache
= new ConcurrentDictionary<(Type, string), PropertyInfo?>();

/// <summary>
/// Adds DataAnnotations validation support to the <see cref="EditContext"/>.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
[Obsolete("Use " + nameof(EnableDataAnnotationsValidation) + " instead.")]
Copy link
Contributor

Choose a reason for hiding this comment

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

We should do a breaking change announcement for this.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed, will do so if/when this is merged.

Copy link
Member Author

Choose a reason for hiding this comment

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

public static EditContext AddDataAnnotationsValidation(this EditContext editContext)
{
if (editContext == null)
{
throw new ArgumentNullException(nameof(editContext));
}

var messages = new ValidationMessageStore(editContext);

// Perform object-level validation on request
editContext.OnValidationRequested +=
(sender, eventArgs) => ValidateModel((EditContext)sender!, messages);

// Perform per-field validation on each field edit
editContext.OnFieldChanged +=
(sender, eventArgs) => ValidateField(editContext, messages, eventArgs.FieldIdentifier);

EnableDataAnnotationsValidation(editContext);
return editContext;
}

private static void ValidateModel(EditContext editContext, ValidationMessageStore messages)
/// <summary>
/// Enables DataAnnotations validation support for the <see cref="EditContext"/>.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
/// <returns>A disposable object whose disposal will remove DataAnnotations validation support from the <see cref="EditContext"/>.</returns>
public static IDisposable EnableDataAnnotationsValidation(this EditContext editContext)
{
return new DataAnnotationsEventSubscriptions(editContext);
}

private sealed class DataAnnotationsEventSubscriptions : IDisposable
{
var validationContext = new ValidationContext(editContext.Model);
var validationResults = new List<ValidationResult>();
Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);
private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache = new();

// Transfer results to the ValidationMessageStore
messages.Clear();
foreach (var validationResult in validationResults)
private readonly EditContext _editContext;
private readonly ValidationMessageStore _messages;

public DataAnnotationsEventSubscriptions(EditContext editContext)
{
if (validationResult == null)
{
continue;
}
_editContext = editContext ?? throw new ArgumentNullException(nameof(editContext));
_messages = new ValidationMessageStore(_editContext);

if (!validationResult.MemberNames.Any())
{
messages.Add(new FieldIdentifier(editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage!);
continue;
}
_editContext.OnFieldChanged += OnFieldChanged;
_editContext.OnValidationRequested += OnValidationRequested;
}

foreach (var memberName in validationResult.MemberNames)
private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs)
{
var fieldIdentifier = eventArgs.FieldIdentifier;
if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
{
messages.Add(editContext.Field(memberName), validationResult.ErrorMessage!);
var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
var validationContext = new ValidationContext(fieldIdentifier.Model)
{
MemberName = propertyInfo.Name
};
var results = new List<ValidationResult>();

Validator.TryValidateProperty(propertyValue, validationContext, results);
_messages.Clear(fieldIdentifier);
_messages.Add(fieldIdentifier, results.Select(result => result.ErrorMessage!));

// We have to notify even if there were no messages before and are still no messages now,
// because the "state" that changed might be the completion of some async validation task
_editContext.NotifyValidationStateChanged();
}
}

editContext.NotifyValidationStateChanged();
}

private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
{
if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
{
var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
var validationContext = new ValidationContext(fieldIdentifier.Model)
var validationContext = new ValidationContext(_editContext.Model);
var validationResults = new List<ValidationResult>();
Validator.TryValidateObject(_editContext.Model, validationContext, validationResults, true);

// Transfer results to the ValidationMessageStore
_messages.Clear();
foreach (var validationResult in validationResults)
{
MemberName = propertyInfo.Name
};
var results = new List<ValidationResult>();
if (validationResult == null)
{
continue;
}

if (!validationResult.MemberNames.Any())
{
_messages.Add(new FieldIdentifier(_editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage!);
continue;
}

foreach (var memberName in validationResult.MemberNames)
{
_messages.Add(_editContext.Field(memberName), validationResult.ErrorMessage!);
}
}

Validator.TryValidateProperty(propertyValue, validationContext, results);
messages.Clear(fieldIdentifier);
messages.Add(fieldIdentifier, results.Select(result => result.ErrorMessage!));
_editContext.NotifyValidationStateChanged();
}

// We have to notify even if there were no messages before and are still no messages now,
// because the "state" that changed might be the completion of some async validation task
editContext.NotifyValidationStateChanged();
public void Dispose()
{
_messages.Clear();
_editContext.OnFieldChanged -= OnFieldChanged;
_editContext.OnValidationRequested -= OnValidationRequested;
_editContext.NotifyValidationStateChanged();
}
}

private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, [NotNullWhen(true)] out PropertyInfo? propertyInfo)
{
var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, [NotNullWhen(true)] out PropertyInfo? propertyInfo)
{
// DataAnnotations only validates public properties, so that's all we'll look for
// If we can't find it, cache 'null' so we don't have to try again next time
propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);
var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
{
// DataAnnotations only validates public properties, so that's all we'll look for
// If we can't find it, cache 'null' so we don't have to try again next time
propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);

// No need to lock, because it doesn't matter if we write the same value twice
_propertyInfoCache[cacheKey] = propertyInfo;
}
// No need to lock, because it doesn't matter if we write the same value twice
_propertyInfoCache[cacheKey] = propertyInfo;
}

return propertyInfo != null;
return propertyInfo != null;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@
<argument>ILLink</argument>
<argument>IL2026</argument>
<property name="Scope">member</property>
<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>
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.DataAnnotationsEventSubscriptions.OnFieldChanged(System.Object,Microsoft.AspNetCore.Components.Forms.FieldChangedEventArgs)</property>
</attribute>
<attribute fullname="System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessageAttribute">
<argument>ILLink</argument>
<argument>IL2026</argument>
<property name="Scope">member</property>
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.ValidateModel(Microsoft.AspNetCore.Components.Forms.EditContext,Microsoft.AspNetCore.Components.Forms.ValidationMessageStore)</property>
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.DataAnnotationsEventSubscriptions.OnValidationRequested(System.Object,Microsoft.AspNetCore.Components.Forms.ValidationRequestedEventArgs)</property>
</attribute>
<attribute fullname="System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessageAttribute">
<argument>ILLink</argument>
<argument>IL2080</argument>
<property name="Scope">member</property>
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.TryGetValidatableProperty(Microsoft.AspNetCore.Components.Forms.FieldIdentifier@,System.Reflection.PropertyInfo@)</property>
<property name="Target">M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.DataAnnotationsEventSubscriptions.TryGetValidatableProperty(Microsoft.AspNetCore.Components.Forms.FieldIdentifier@,System.Reflection.PropertyInfo@)</property>
</attribute>
</assembly>
</linker>
3 changes: 3 additions & 0 deletions src/Components/Forms/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
#nullable enable
override Microsoft.AspNetCore.Components.Forms.DataAnnotationsValidator.OnParametersSet() -> void
static Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.EnableDataAnnotationsValidation(this Microsoft.AspNetCore.Components.Forms.EditContext! editContext) -> System.IDisposable!
virtual Microsoft.AspNetCore.Components.Forms.DataAnnotationsValidator.Dispose(bool disposing) -> void
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ public class EditContextDataAnnotationsExtensionsTest
public void CannotUseNullEditContext()
{
var editContext = (EditContext)null;
var ex = Assert.Throws<ArgumentNullException>(() => editContext.AddDataAnnotationsValidation());
var ex = Assert.Throws<ArgumentNullException>(() => editContext.EnableDataAnnotationsValidation());
Assert.Equal("editContext", ex.ParamName);
}

[Fact]
public void ReturnsEditContextForChaining()
public void ObsoleteApiReturnsEditContextForChaining()
{
var editContext = new EditContext(new object());
#pragma warning disable 0618
var returnValue = editContext.AddDataAnnotationsValidation();
#pragma warning restore 0618
Assert.Same(editContext, returnValue);
}

Expand All @@ -30,7 +32,8 @@ public void GetsValidationMessagesFromDataAnnotations()
{
// Arrange
var model = new TestModel { IntFrom1To100 = 101 };
var editContext = new EditContext(model).AddDataAnnotationsValidation();
var editContext = new EditContext(model);
editContext.EnableDataAnnotationsValidation();

// Act
var isValid = editContext.Validate();
Expand Down Expand Up @@ -59,7 +62,8 @@ public void ClearsExistingValidationMessagesOnFurtherRuns()
{
// Arrange
var model = new TestModel { IntFrom1To100 = 101 };
var editContext = new EditContext(model).AddDataAnnotationsValidation();
var editContext = new EditContext(model);
editContext.EnableDataAnnotationsValidation();

// Act/Assert 1: Initially invalid
Assert.False(editContext.Validate());
Expand All @@ -75,7 +79,8 @@ public void NotifiesValidationStateChangedAfterObjectValidation()
{
// Arrange
var model = new TestModel { IntFrom1To100 = 101 };
var editContext = new EditContext(model).AddDataAnnotationsValidation();
var editContext = new EditContext(model);
editContext.EnableDataAnnotationsValidation();
var onValidationStateChangedCount = 0;
editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;

Expand All @@ -102,7 +107,8 @@ public void PerformsPerPropertyValidationOnFieldChange()
// Arrange
var model = new TestModel { IntFrom1To100 = 101 };
var independentTopLevelModel = new object(); // To show we can validate things on any model, not just the top-level one
var editContext = new EditContext(independentTopLevelModel).AddDataAnnotationsValidation();
var editContext = new EditContext(independentTopLevelModel);
editContext.EnableDataAnnotationsValidation();
var onValidationStateChangedCount = 0;
var requiredStringIdentifier = new FieldIdentifier(model, nameof(TestModel.RequiredString));
var intFrom1To100Identifier = new FieldIdentifier(model, nameof(TestModel.IntFrom1To100));
Expand Down Expand Up @@ -141,7 +147,8 @@ public void PerformsPerPropertyValidationOnFieldChange()
public void IgnoresFieldChangesThatDoNotCorrespondToAValidatableProperty(string fieldName)
{
// Arrange
var editContext = new EditContext(new TestModel()).AddDataAnnotationsValidation();
var editContext = new EditContext(new TestModel());
editContext.EnableDataAnnotationsValidation();
var onValidationStateChangedCount = 0;
editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;

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

[Fact]
public void CanDetachFromEditContext()
{
// Arrange
var model = new TestModel { IntFrom1To100 = 101 };
var editContext = new EditContext(model);
var subscription = editContext.EnableDataAnnotationsValidation();

// Act/Assert 1: when we're attached
Assert.False(editContext.Validate());
Assert.NotEmpty(editContext.GetValidationMessages());

// Act/Assert 2: when we're detached
subscription.Dispose();
Assert.True(editContext.Validate());
Assert.Empty(editContext.GetValidationMessages());
}

class TestModel
{
[Required(ErrorMessage = "RequiredString:required")] public string RequiredString { get; set; }
Expand Down
Loading