Skip to content

Validation fixes for Blazor #14972

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

Merged
merged 9 commits into from
Oct 18, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eng/ProjectReferences.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<ProjectReferenceProvider Include="GetDocument.Insider" ProjectPath="$(RepoRoot)src\Tools\GetDocumentInsider\src\GetDocumentInsider.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.SignalR.Specification.Tests" ProjectPath="$(RepoRoot)src\SignalR\server\Specification.Tests\src\Microsoft.AspNetCore.SignalR.Specification.Tests.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Blazor.Build" ProjectPath="$(RepoRoot)src\Components\Blazor\Build\src\Microsoft.AspNetCore.Blazor.Build.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation" ProjectPath="$(RepoRoot)src\Components\Blazor\Validation\src\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj" />
<ProjectReferenceProvider Include="Ignitor" ProjectPath="$(RepoRoot)src\Components\Ignitor\src\Ignitor.csproj" />
<ProjectReferenceProvider Include="BlazorServerApp" ProjectPath="$(RepoRoot)src\Components\Samples\BlazorServerApp\BlazorServerApp.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore" ProjectPath="$(RepoRoot)src\DefaultBuilder\src\Microsoft.AspNetCore.csproj" RefProjectPath="$(RepoRoot)src\DefaultBuilder\ref\Microsoft.AspNetCore.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

namespace System.ComponentModel.DataAnnotations
{
/// <summary>
/// A <see cref="ValidationAttribute"/> that compares two properties
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class ComparePropertyAttribute : CompareAttribute
Copy link
Member

Choose a reason for hiding this comment

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

👍

Choose a reason for hiding this comment

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

This class should be in the System.ComponentModel.DataAnnotations project, not Blazor.

To add this validation to a property in MyApp.Shared means the shared app has to reference the Blazor assemblies - which isn't good for a contracts assembly that could be used in different types of client apps.

{
/// <summary>
/// Initializes a new instance of <see cref="BlazorCompareAttribute"/>.
/// </summary>
/// <param name="otherProperty">The property to compare with the current property.</param>
public ComparePropertyAttribute(string otherProperty)
: base(otherProperty)
{
}

/// <inheritdoc />
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
var validationResult = base.IsValid(value, validationContext);
if (validationResult == ValidationResult.Success)
{
return validationResult;
}

return new ValidationResult(validationResult.ErrorMessage, new[] { validationContext.MemberName });
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Description>Provides experimental support for validation using DataAnnotations.</Description>
<IsShippingPackage>true</IsShippingPackage>
<HasReferenceAssembly>false</HasReferenceAssembly>
</PropertyGroup>

<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Components.Forms" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;

namespace Microsoft.AspNetCore.Components.Forms
{
public class ObjectGraphDataAnnotationsValidator : ComponentBase
{
private static readonly object ValidationContextValidatorKey = new object();
private static readonly object ValidatedObjectsKey = new object();
private ValidationMessageStore _validationMessageStore;

[CascadingParameter]
internal EditContext EditContext { get; set; }

protected override void OnInitialized()
{
_validationMessageStore = new ValidationMessageStore(EditContext);

// Perform object-level validation (starting from the root model) on request
EditContext.OnValidationRequested += (sender, eventArgs) =>
{
_validationMessageStore.Clear();
ValidateObject(EditContext.Model, new HashSet<object>());
EditContext.NotifyValidationStateChanged();
};

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

internal void ValidateObject(object value, HashSet<object> visited)
{
Copy link
Member

Choose a reason for hiding this comment

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

Do we have a test for this that validates object graphs with cycles? (I haven't been able to find it).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if (value is null)
{
return;
}

if (!visited.Add(value))
{
// Already visited this object.
return;
}

if (value is IEnumerable<object> enumerable)
{
var index = 0;
foreach (var item in enumerable)
{
ValidateObject(item, visited);
index++;
}

return;
}

var validationResults = new List<ValidationResult>();

Choose a reason for hiding this comment

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

You need to add the following code....

if (value.GetType().Assembly == typeof(object).Assembly)
  return;

Otherwise you will validate every string in a string[], and then every character of every string.

ValidateObject(value, visited, validationResults);

// Transfer results to the ValidationMessageStore
foreach (var validationResult in validationResults)
{
if (!validationResult.MemberNames.Any())
{
_validationMessageStore.Add(new FieldIdentifier(value, string.Empty), validationResult.ErrorMessage);
continue;
}

foreach (var memberName in validationResult.MemberNames)
{
var fieldIdentifier = new FieldIdentifier(value, memberName);
_validationMessageStore.Add(fieldIdentifier, validationResult.ErrorMessage);
}
}
}

private void ValidateObject(object value, HashSet<object> visited, List<ValidationResult> validationResults)
{
var validationContext = new ValidationContext(value);
validationContext.Items.Add(ValidationContextValidatorKey, this);
validationContext.Items.Add(ValidatedObjectsKey, visited);
Validator.TryValidateObject(value, validationContext, validationResults, validateAllProperties: true);
}

internal static bool TryValidateRecursive(object value, ValidationContext validationContext)
{
if (validationContext.Items.TryGetValue(ValidationContextValidatorKey, out var result) && result is ObjectGraphDataAnnotationsValidator validator)
{
var visited = (HashSet<object>)validationContext.Items[ValidatedObjectsKey];
validator.ValidateObject(value, visited);

return true;
}

return false;
}

private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
{
// DataAnnotations only validates public properties, so that's all we'll look for
var propertyInfo = fieldIdentifier.Model.GetType().GetProperty(fieldIdentifier.FieldName);
if (propertyInfo != null)
{
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();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using Microsoft.AspNetCore.Components.Forms;

namespace System.ComponentModel.DataAnnotations
{
/// <summary>
/// A <see cref="ValidationAttribute"/> that indicates that the property is a complex or collection type that further needs to be validated.
/// <para>
/// By default <see cref="Validator"/> does not recurse in to complex property types during validation.
/// When used in conjunction with <see cref="ObjectGraphDataAnnotationsValidator"/>, this property allows the validation system to validate
/// complex or collection type properties.
/// </para>
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class ValidateComplexTypeAttribute : ValidationAttribute
{
/// <inheritdoc />
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
if (!ObjectGraphDataAnnotationsValidator.TryValidateRecursive(value, validationContext))
{
throw new InvalidOperationException($"{nameof(ValidateComplexTypeAttribute)} can only used with {nameof(ObjectGraphDataAnnotationsValidator)}.");
}

return ValidationResult.Success;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation" />
</ItemGroup>

</Project>
Loading