Skip to content

Commit b48d272

Browse files
committed
Add support for type-level validation attributes (WIP)
1 parent 12fc3b0 commit b48d272

File tree

9 files changed

+138
-12
lines changed

9 files changed

+138
-12
lines changed

src/Components/test/E2ETest/ServerRenderingTests/AddValidationIntegrationTest.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public void FormWithNestedValidation_Works()
6060
}
6161

6262
[Fact]
63-
public void FormWithTypeLevelValidations_Works()
63+
public void FormWithValidatableObjectInterface_Works()
6464
{
6565
// This is the new way of initializing test page
6666
Navigate("subdir/forms/type-level-validation-form");
@@ -76,21 +76,21 @@ public void FormWithTypeLevelValidations_Works()
7676
var allMessagesAccessor = CreateValidationMessagesAccessor(
7777
Browser.FindElement(By.ClassName("all-errors")));
7878

79-
//// Cause a property-level validation error
79+
// Cause a property-level validation error
8080
ageInput.Clear();
8181
ageInput.SendKeys("-1");
8282
submitButton.Click();
8383
Browser.Collection(allMessagesAccessor, x => Assert.Equal("Under-zeros should not be filling out forms", x));
8484
Browser.Empty(modelMessagesAccessor);
8585

86-
//// Cause a model-level validation error
86+
// Cause a model-level validation error
8787
ageInput.Clear();
8888
ageInput.SendKeys("10");
8989
submitButton.Click();
9090
Browser.Collection(allMessagesAccessor, x => Assert.Equal("Sorry, you're not old enough as a non-cat", x));
9191
Browser.Collection(modelMessagesAccessor, x => Assert.Equal("Sorry, you're not old enough as a non-cat", x));
9292

93-
//// Become valid
93+
// Become valid
9494
isCatCheckbox.Click();
9595
submitButton.Click();
9696
Browser.Empty(allMessagesAccessor);

src/Validation/gen/Emitters/ValidationsGenerator.Emitter.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,18 @@ public GeneratedValidatablePropertyInfo(
8181
public GeneratedValidatableTypeInfo(
8282
[param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
8383
global::System.Type type,
84-
ValidatablePropertyInfo[] members) : base(type, members) { }
84+
ValidatablePropertyInfo[] members) : base(type, members)
85+
{
86+
Type = type;
87+
Name = type.Name;
88+
}
89+
90+
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
91+
internal global::System.Type Type { get; }
92+
internal string Name { get; }
93+
94+
protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
95+
=> ValidationAttributeCache.GetValidationAttributes(Type, Name);
8596
}
8697
8798
{{GeneratedCodeAttribute}}

src/Validation/gen/Models/ValidatableType.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ namespace Microsoft.Extensions.Validation;
88

99
internal sealed record class ValidatableType(
1010
ITypeSymbol Type,
11-
ImmutableArray<ValidatableProperty> Members
11+
ImmutableArray<ValidatableProperty> Members,
12+
ImmutableArray<ValidationAttribute> Attributes
1213
);

src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow
115115
// Add the type itself as a validatable type itself.
116116
validatableTypes.Add(new ValidatableType(
117117
Type: typeSymbol,
118-
Members: members));
118+
Members: members,
119+
Attributes: [])); // TODO: Extract validation attributes from the type itself.
119120

120121
return true;
121122
}

src/Validation/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,4 @@ abstract Microsoft.Extensions.Validation.ValidatablePropertyInfo.GetValidationAt
5151
static Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Microsoft.Extensions.Validation.ValidationOptions!>? configureOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
5252
virtual Microsoft.Extensions.Validation.ValidatableParameterInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
5353
virtual Microsoft.Extensions.Validation.ValidatablePropertyInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
54-
virtual Microsoft.Extensions.Validation.ValidatableTypeInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
54+
virtual Microsoft.Extensions.Validation.ValidatableTypeInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!

src/Validation/src/ValidatableTypeInfo.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,12 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context,
8787
}
8888

8989
// Validate direct type-level attributes
90-
ValidateTypeAttributes(value, context);
90+
ValidateTypeAttributes(value, Type.Name, context.CurrentValidationPath, context);
9191

9292
// Validate inherited type-level attributes
9393
foreach (var superTypeInfo in GetSuperTypeInfos(actualType, context))
9494
{
95-
superTypeInfo.ValidateTypeAttributes(value, context);
95+
superTypeInfo.ValidateTypeAttributes(value, Type.Name, context.CurrentValidationPath, context);
9696
context.CurrentValidationPath = originalPrefix;
9797
}
9898

@@ -116,9 +116,28 @@ private async Task ValidateMembersAsync(object? value, ValidateContext context,
116116
}
117117
}
118118

119-
private void ValidateTypeAttributes(object? value, ValidateContext context)
119+
private void ValidateTypeAttributes(object? value, string name, string errorPrefix, ValidateContext context)
120120
{
121-
// TODO: Implement this, probably record attributes in SG
121+
var validationAttributes = GetValidationAttributes();
122+
123+
for (var i = 0; i < validationAttributes.Length; i++)
124+
{
125+
var attribute = validationAttributes[i];
126+
try
127+
{
128+
var result = attribute.GetValidationResult(value, context.ValidationContext);
129+
if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null)
130+
{
131+
var key = errorPrefix.TrimStart('.');
132+
context.AddOrExtendValidationErrors(name, key, [result.ErrorMessage], value);
133+
}
134+
}
135+
catch (Exception ex)
136+
{
137+
var key = errorPrefix.TrimStart('.');
138+
context.AddOrExtendValidationErrors(name, key, [ex.Message], value);
139+
}
140+
}
122141
}
123142

124143
private void ValidateValidatableObjectInterface(object? value, ValidateContext context)

src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableInfoResolverTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,5 +219,6 @@ private class TestValidatableTypeInfo(
219219
Type type,
220220
ValidatablePropertyInfo[] members) : ValidatableTypeInfo(type, members)
221221
{
222+
protected override ValidationAttribute[] GetValidationAttributes() => [];
222223
}
223224
}

src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableParameterInfoTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ private class TestValidatableTypeInfo(
352352
Type type,
353353
ValidatablePropertyInfo[] members) : ValidatableTypeInfo(type, members)
354354
{
355+
protected override ValidationAttribute[] GetValidationAttributes() => [];
355356
}
356357

357358
private class TestValidationOptions : ValidationOptions

src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.ComponentModel.DataAnnotations;
77
using System.Diagnostics.CodeAnalysis;
88
using System.Reflection;
9+
using Xunit.Sdk;
910

1011
namespace Microsoft.Extensions.Validation.Tests;
1112

@@ -586,6 +587,65 @@ public async Task Validate_IValidatableObject_WithZeroAndMultipleMemberNames_Beh
586587
});
587588
}
588589

590+
// The expected order of validation is:
591+
// 1. Attributes on properties
592+
// 2. Attributes on the type
593+
// 3. IValidatableObject implementation
594+
// If any of these steps report an error, the later steps are skipped.
595+
[Fact]
596+
public async Task Validate_IValidatableObject_WithPropertyErrors_ShortCircuitsProperly()
597+
{
598+
var testTypeInfo = new TestValidatableTypeInfo(
599+
typeof(PropertyAndTypeLevelErrorObject),
600+
[
601+
CreatePropertyInfo(typeof(PropertyAndTypeLevelErrorObject), typeof(int), "Value", "Value",
602+
[new RangeAttribute(0, int.MaxValue) { ErrorMessage = "Property attribute error" }])
603+
]);
604+
605+
// First case:
606+
var testTypeInstance = new PropertyAndTypeLevelErrorObject { Value = 15 };
607+
608+
var context = new ValidateContext
609+
{
610+
ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>
611+
{
612+
{ typeof(PropertyAndTypeLevelErrorObject), testTypeInfo }
613+
}),
614+
ValidationContext = new ValidationContext(testTypeInstance)
615+
};
616+
617+
await testTypeInfo.ValidateAsync(testTypeInstance, context, default);
618+
619+
Assert.NotNull(context.ValidationErrors);
620+
var interfaceError = Assert.Single(context.ValidationErrors);
621+
Assert.Equal(string.Empty, interfaceError.Key);
622+
Assert.Equal("IValidatableObject error", interfaceError.Value.Single());
623+
624+
// Second case:
625+
testTypeInstance.Value = 5;
626+
context.ValidationErrors = [];
627+
context.ValidationContext = new ValidationContext(testTypeInstance);
628+
629+
await testTypeInfo.ValidateAsync(testTypeInstance, context, default);
630+
631+
Assert.NotNull(context.ValidationErrors);
632+
var classAttributeError = Assert.Single(context.ValidationErrors);
633+
Assert.Equal("", classAttributeError.Key);
634+
Assert.Equal("Class attribute error", classAttributeError.Value.Single());
635+
636+
// Third case:
637+
testTypeInstance.Value = -5;
638+
context.ValidationErrors = [];
639+
context.ValidationContext = new ValidationContext(testTypeInstance);
640+
641+
await testTypeInfo.ValidateAsync(testTypeInstance, context, default);
642+
643+
Assert.NotNull(context.ValidationErrors);
644+
var propertyAttributeError = Assert.Single(context.ValidationErrors);
645+
Assert.Equal("Value", propertyAttributeError.Key);
646+
Assert.Equal("Class attribute error", propertyAttributeError.Value.Single());
647+
}
648+
589649
// Returns no member names to validate https://github.com/dotnet/aspnetcore/issues/61739
590650
private class GlobalErrorObject : IValidatableObject
591651
{
@@ -618,6 +678,36 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex
618678
}
619679
}
620680

681+
[CustomValidation]
682+
private class PropertyAndTypeLevelErrorObject : IValidatableObject
683+
{
684+
[Range(0, int.MaxValue, ErrorMessage = "Property attribute error")]
685+
public int Value { get; set; }
686+
687+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
688+
{
689+
if (Value < 20)
690+
{
691+
yield return new ValidationResult($"IValidatableObject error");
692+
}
693+
}
694+
}
695+
696+
private class CustomValidationAttribute : ValidationAttribute
697+
{
698+
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
699+
{
700+
if (value is PropertyAndTypeLevelErrorObject instance)
701+
{
702+
if (instance.Value < 10)
703+
{
704+
return new ValidationResult($"Class attribute error");
705+
}
706+
}
707+
return ValidationResult.Success;
708+
}
709+
}
710+
621711
private ValidatablePropertyInfo CreatePropertyInfo(
622712
Type containingType,
623713
Type propertyType,
@@ -789,6 +879,8 @@ public TestValidatableTypeInfo(
789879
: base(type, members)
790880
{
791881
}
882+
883+
protected override ValidationAttribute[] GetValidationAttributes() => [];
792884
}
793885

794886
private class TestValidationOptions : ValidationOptions

0 commit comments

Comments
 (0)