Skip to content

Commit

Permalink
Ability to deactivate DataAnnotationsValidation dynamically. Fixes #3…
Browse files Browse the repository at this point in the history
  • Loading branch information
SteveSandersonMS authored Apr 5, 2021
1 parent 2e78ac0 commit 2b493c4
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 82 deletions.
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();
_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.")]
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

0 comments on commit 2b493c4

Please sign in to comment.