From 252ceeab822b4f9363dd423d8321b64bff4fd8d2 Mon Sep 17 00:00:00 2001 From: warappa Date: Thu, 12 Oct 2023 00:30:48 +0200 Subject: [PATCH 1/6] Support runtime property attributes via TypeDescriptor --- src/MiniValidation/TypeDetailsCache.cs | 23 ++++ tests/MiniValidation.UnitTests/TestTypes.cs | 5 + tests/MiniValidation.UnitTests/TryValidate.cs | 16 ++- .../TypeDescriptorUtils.cs | 102 ++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/MiniValidation.UnitTests/TypeDescriptorUtils.cs diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index e26bd5d..9b0eb9d 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; @@ -198,6 +199,11 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut ? paramAttributes.Concat(propertyAttributes) : propertyAttributes; + if (TryGetAttributesViaTypeDescriptor(property, out var typeDescriptorAttributes)) + { + customAttributes = customAttributes.Concat(typeDescriptorAttributes!.Cast()); + } + foreach (var attr in customAttributes) { if (attr is ValidationAttribute validationAttr) @@ -218,6 +224,23 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut return new(validationAttributes?.ToArray(), displayAttribute, skipRecursionAttribute); } + private static bool TryGetAttributesViaTypeDescriptor(PropertyInfo property, out IEnumerable? typeDescriptorAttributes) + { + var attributes = TypeDescriptor.GetProperties(property.ReflectedType!) + .Cast() + .FirstOrDefault(x => x.Name == property.Name) + ?.Attributes; + + if (attributes is { Count: > 0 } tdps) + { + typeDescriptorAttributes = tdps.Cast(); + return true; + } + + typeDescriptorAttributes = null; + return false; + } + private static Type? GetEnumerableType(Type type) { if (type.IsInterface && type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) diff --git a/tests/MiniValidation.UnitTests/TestTypes.cs b/tests/MiniValidation.UnitTests/TestTypes.cs index d23d4ac..dbf991a 100644 --- a/tests/MiniValidation.UnitTests/TestTypes.cs +++ b/tests/MiniValidation.UnitTests/TestTypes.cs @@ -238,3 +238,8 @@ class ClassWithUri [Required] public Uri? BaseAddress { get; set; } } + +class TestTypeForTypeDescriptor +{ + public string? PropertyToBeRequired { get; set; } +} \ No newline at end of file diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index 159de37..51ee84e 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; namespace MiniValidation.UnitTests; @@ -396,4 +395,19 @@ public async Task TryValidateAsync_With_ServiceProvider() Assert.Equal(1, errors.Count); Assert.Equal(nameof(IServiceProvider), errors.Keys.First()); } + + [Fact] + public async Task TryValidateAsync_With_Attribute_Attached_Via_TypeDescriptor() + { + var thingToValidate = new TestTypeForTypeDescriptor(); + + TypeDescriptorExtensions.AttachAttribute( + nameof(TestTypeForTypeDescriptor.PropertyToBeRequired), + _ => new RequiredAttribute()); + + var (isValid, errors) = await MiniValidator.TryValidateAsync(thingToValidate); + + Assert.False(isValid); + Assert.Equal(1, errors.Count); + } } diff --git a/tests/MiniValidation.UnitTests/TypeDescriptorUtils.cs b/tests/MiniValidation.UnitTests/TypeDescriptorUtils.cs new file mode 100644 index 0000000..913be37 --- /dev/null +++ b/tests/MiniValidation.UnitTests/TypeDescriptorUtils.cs @@ -0,0 +1,102 @@ +using System.ComponentModel; + +namespace MiniValidation.UnitTests +{ + internal static class TypeDescriptorExtensions + { + public static void AttachAttribute(string propertyName, Func attributeFactory) + { + var type = typeof(T); + + var ctd = new PropertyOverridingTypeDescriptor(TypeDescriptor.GetProvider(type).GetTypeDescriptor(type)!); + + foreach (PropertyDescriptor pd in TypeDescriptor.GetProperties(type)) + { + if (pd.Name.EndsWith(propertyName)) + { + var pdWithAttribute = TypeDescriptor.CreateProperty( + type, + pd, + attributeFactory(pd)); + + ctd.OverrideProperty(pdWithAttribute); + } + } + + TypeDescriptor.AddProvider(new TypeDescriptorOverridingProvider(ctd), type); + } + } + + // From https://stackoverflow.com/questions/12143650/how-to-add-property-level-attribute-to-the-typedescriptor-at-runtime + internal class PropertyOverridingTypeDescriptor : CustomTypeDescriptor + { + private readonly Dictionary overridePds = new Dictionary(); + + public PropertyOverridingTypeDescriptor(ICustomTypeDescriptor parent) + : base(parent) + { } + + public void OverrideProperty(PropertyDescriptor pd) + { + overridePds[pd.Name] = pd; + } + + public override object? GetPropertyOwner(PropertyDescriptor? pd) + { + var propertyOwner = base.GetPropertyOwner(pd); + + if (propertyOwner == null) + { + return this; + } + + return propertyOwner; + } + + public override PropertyDescriptorCollection GetProperties() + { + return GetPropertiesImpl(base.GetProperties()); + } + + public override PropertyDescriptorCollection GetProperties(Attribute[]? attributes) + { + return GetPropertiesImpl(base.GetProperties(attributes)); + } + + private PropertyDescriptorCollection GetPropertiesImpl(PropertyDescriptorCollection pdc) + { + var pdl = new List(pdc.Count + 1); + + foreach (PropertyDescriptor pd in pdc) + { + if (overridePds.ContainsKey(pd.Name)) + { + pdl.Add(overridePds[pd.Name]); + } + else + { + pdl.Add(pd); + } + } + + var ret = new PropertyDescriptorCollection(pdl.ToArray()); + + return ret; + } + } + + internal class TypeDescriptorOverridingProvider : TypeDescriptionProvider + { + private readonly ICustomTypeDescriptor ctd; + + public TypeDescriptorOverridingProvider(ICustomTypeDescriptor ctd) + { + this.ctd = ctd; + } + + public override ICustomTypeDescriptor? GetTypeDescriptor(Type objectType, object? instance) + { + return ctd; + } + } +} From 8bc9087e1531e8a4c0b54107ea88f3841b5f84f7 Mon Sep 17 00:00:00 2001 From: warappa Date: Fri, 13 Oct 2023 07:51:03 +0200 Subject: [PATCH 2/6] Support nullable annotations in .NET Standard 2.0 --- Directory.Packages.props | 3 ++- src/MiniValidation/MiniValidation.csproj | 4 ++++ src/MiniValidation/TypeDetailsCache.cs | 5 +++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 65a469c..76b6d47 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,7 @@ true + @@ -13,4 +14,4 @@ - + \ No newline at end of file diff --git a/src/MiniValidation/MiniValidation.csproj b/src/MiniValidation/MiniValidation.csproj index 39cdbd0..7f2afc4 100644 --- a/src/MiniValidation/MiniValidation.csproj +++ b/src/MiniValidation/MiniValidation.csproj @@ -15,6 +15,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index 9b0eb9d..2dc7cf8 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -201,7 +202,7 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut if (TryGetAttributesViaTypeDescriptor(property, out var typeDescriptorAttributes)) { - customAttributes = customAttributes.Concat(typeDescriptorAttributes!.Cast()); + customAttributes = customAttributes.Concat(typeDescriptorAttributes.Cast()); } foreach (var attr in customAttributes) @@ -224,7 +225,7 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut return new(validationAttributes?.ToArray(), displayAttribute, skipRecursionAttribute); } - private static bool TryGetAttributesViaTypeDescriptor(PropertyInfo property, out IEnumerable? typeDescriptorAttributes) + private static bool TryGetAttributesViaTypeDescriptor(PropertyInfo property, [NotNullWhen(true)] out IEnumerable? typeDescriptorAttributes) { var attributes = TypeDescriptor.GetProperties(property.ReflectedType!) .Cast() From c24dcd9ed1277fe7b79c4278cf98fefbaf544690 Mon Sep 17 00:00:00 2001 From: warappa Date: Fri, 13 Oct 2023 07:53:17 +0200 Subject: [PATCH 3/6] Refactor AttachAttribute to an extension method --- tests/MiniValidation.UnitTests/TryValidate.cs | 2 +- .../{TypeDescriptorUtils.cs => TypeDescriptorExtensions.cs} | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) rename tests/MiniValidation.UnitTests/{TypeDescriptorUtils.cs => TypeDescriptorExtensions.cs} (95%) diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index 51ee84e..5b16b4e 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -401,7 +401,7 @@ public async Task TryValidateAsync_With_Attribute_Attached_Via_TypeDescriptor() { var thingToValidate = new TestTypeForTypeDescriptor(); - TypeDescriptorExtensions.AttachAttribute( + typeof(TestTypeForTypeDescriptor).AttachAttribute( nameof(TestTypeForTypeDescriptor.PropertyToBeRequired), _ => new RequiredAttribute()); diff --git a/tests/MiniValidation.UnitTests/TypeDescriptorUtils.cs b/tests/MiniValidation.UnitTests/TypeDescriptorExtensions.cs similarity index 95% rename from tests/MiniValidation.UnitTests/TypeDescriptorUtils.cs rename to tests/MiniValidation.UnitTests/TypeDescriptorExtensions.cs index 913be37..21a7b93 100644 --- a/tests/MiniValidation.UnitTests/TypeDescriptorUtils.cs +++ b/tests/MiniValidation.UnitTests/TypeDescriptorExtensions.cs @@ -4,10 +4,8 @@ namespace MiniValidation.UnitTests { internal static class TypeDescriptorExtensions { - public static void AttachAttribute(string propertyName, Func attributeFactory) + public static void AttachAttribute(this Type type, string propertyName, Func attributeFactory) { - var type = typeof(T); - var ctd = new PropertyOverridingTypeDescriptor(TypeDescriptor.GetProvider(type).GetTypeDescriptor(type)!); foreach (PropertyDescriptor pd in TypeDescriptor.GetProperties(type)) From e12a6b942ddb0d1cbf3082639eaf75ee274d588a Mon Sep 17 00:00:00 2001 From: warappa Date: Fri, 13 Oct 2023 08:00:11 +0200 Subject: [PATCH 4/6] Property name check should check for exact match --- tests/MiniValidation.UnitTests/TypeDescriptorExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/MiniValidation.UnitTests/TypeDescriptorExtensions.cs b/tests/MiniValidation.UnitTests/TypeDescriptorExtensions.cs index 21a7b93..2a6b30e 100644 --- a/tests/MiniValidation.UnitTests/TypeDescriptorExtensions.cs +++ b/tests/MiniValidation.UnitTests/TypeDescriptorExtensions.cs @@ -10,7 +10,7 @@ public static void AttachAttribute(this Type type, string propertyName, Func Date: Fri, 13 Oct 2023 17:20:57 +0200 Subject: [PATCH 5/6] Bump version to 0.9.0 --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index a70ca7e..d2d2e35 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,7 @@ - 0.8.0 + 0.9.0 dev From 12eaf12bfbcf3d28a01bd3a206afc2f274a0e9e5 Mon Sep 17 00:00:00 2001 From: warappa Date: Fri, 13 Oct 2023 17:50:02 +0200 Subject: [PATCH 6/6] Ensure validation attributes are still only considered once --- src/MiniValidation/TypeDetailsCache.cs | 4 +++- tests/MiniValidation.UnitTests/TestTypes.cs | 3 +++ tests/MiniValidation.UnitTests/TryValidate.cs | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index 2dc7cf8..8301f3a 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -202,7 +202,9 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut if (TryGetAttributesViaTypeDescriptor(property, out var typeDescriptorAttributes)) { - customAttributes = customAttributes.Concat(typeDescriptorAttributes.Cast()); + customAttributes = customAttributes + .Concat(typeDescriptorAttributes.Cast()) + .Distinct(); } foreach (var attr in customAttributes) diff --git a/tests/MiniValidation.UnitTests/TestTypes.cs b/tests/MiniValidation.UnitTests/TestTypes.cs index dbf991a..865ca97 100644 --- a/tests/MiniValidation.UnitTests/TestTypes.cs +++ b/tests/MiniValidation.UnitTests/TestTypes.cs @@ -242,4 +242,7 @@ class ClassWithUri class TestTypeForTypeDescriptor { public string? PropertyToBeRequired { get; set; } + + [MaxLength(1)] + public string? AnotherProperty { get; set; } = "Test"; } \ No newline at end of file diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index 5b16b4e..f4e7ddb 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -408,6 +408,9 @@ public async Task TryValidateAsync_With_Attribute_Attached_Via_TypeDescriptor() var (isValid, errors) = await MiniValidator.TryValidateAsync(thingToValidate); Assert.False(isValid); - Assert.Equal(1, errors.Count); + Assert.Equal(2, errors.Count); + + Assert.Single(errors["PropertyToBeRequired"]); + Assert.Single(errors["AnotherProperty"]); } }