Skip to content

Commit

Permalink
Support runtime property attributes via TypeDescriptor (#53)
Browse files Browse the repository at this point in the history
* Support runtime property attributes via TypeDescriptor
* Support nullable annotations in .NET Standard 2.0
* Refactor AttachAttribute to an extension method
* Property name check should check for exact match
* Bump version to 0.9.0
* Ensure validation attributes are still only considered once
  • Loading branch information
warappa authored Oct 15, 2023
1 parent 969018c commit ce56e98
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 3 deletions.
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="PolySharp" Version="1.13.2" />
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="1.1.1" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.4.0" />
Expand All @@ -13,4 +14,4 @@
<PackageVersion Include="BenchmarkDotNet" Version="0.13.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
</ItemGroup>
</Project>
</Project>
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>

<PropertyGroup>
<VersionPrefix>0.8.0</VersionPrefix>
<VersionPrefix>0.9.0</VersionPrefix>
<!-- VersionSuffix used for local builds -->
<VersionSuffix>dev</VersionSuffix>
<!-- VersionSuffix to be used for CI builds -->
Expand Down
4 changes: 4 additions & 0 deletions src/MiniValidation/MiniValidation.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
<ItemGroup>
<None Include="../../README.md" Pack="true" PackagePath="\" />
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
<PackageReference Include="PolySharp">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

</Project>
26 changes: 26 additions & 0 deletions src/MiniValidation/TypeDetailsCache.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;

Expand Down Expand Up @@ -198,6 +200,13 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut
? paramAttributes.Concat(propertyAttributes)
: propertyAttributes;

if (TryGetAttributesViaTypeDescriptor(property, out var typeDescriptorAttributes))
{
customAttributes = customAttributes
.Concat(typeDescriptorAttributes.Cast<Attribute>())
.Distinct();
}

foreach (var attr in customAttributes)
{
if (attr is ValidationAttribute validationAttr)
Expand All @@ -218,6 +227,23 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut
return new(validationAttributes?.ToArray(), displayAttribute, skipRecursionAttribute);
}

private static bool TryGetAttributesViaTypeDescriptor(PropertyInfo property, [NotNullWhen(true)] out IEnumerable<Attribute>? typeDescriptorAttributes)
{
var attributes = TypeDescriptor.GetProperties(property.ReflectedType!)
.Cast<PropertyDescriptor>()
.FirstOrDefault(x => x.Name == property.Name)
?.Attributes;

if (attributes is { Count: > 0 } tdps)
{
typeDescriptorAttributes = tdps.Cast<Attribute>();
return true;
}

typeDescriptorAttributes = null;
return false;
}

private static Type? GetEnumerableType(Type type)
{
if (type.IsInterface && type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
Expand Down
8 changes: 8 additions & 0 deletions tests/MiniValidation.UnitTests/TestTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,11 @@ class ClassWithUri
[Required]
public Uri? BaseAddress { get; set; }
}

class TestTypeForTypeDescriptor
{
public string? PropertyToBeRequired { get; set; }

[MaxLength(1)]
public string? AnotherProperty { get; set; } = "Test";
}
19 changes: 18 additions & 1 deletion tests/MiniValidation.UnitTests/TryValidate.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.DependencyInjection;

namespace MiniValidation.UnitTests;
Expand Down Expand Up @@ -396,4 +395,22 @@ 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();

typeof(TestTypeForTypeDescriptor).AttachAttribute(
nameof(TestTypeForTypeDescriptor.PropertyToBeRequired),
_ => new RequiredAttribute());

var (isValid, errors) = await MiniValidator.TryValidateAsync(thingToValidate);

Assert.False(isValid);
Assert.Equal(2, errors.Count);

Assert.Single(errors["PropertyToBeRequired"]);
Assert.Single(errors["AnotherProperty"]);
}
}
100 changes: 100 additions & 0 deletions tests/MiniValidation.UnitTests/TypeDescriptorExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System.ComponentModel;

namespace MiniValidation.UnitTests
{
internal static class TypeDescriptorExtensions
{
public static void AttachAttribute(this Type type, string propertyName, Func<PropertyDescriptor, Attribute> attributeFactory)
{
var ctd = new PropertyOverridingTypeDescriptor(TypeDescriptor.GetProvider(type).GetTypeDescriptor(type)!);

foreach (PropertyDescriptor pd in TypeDescriptor.GetProperties(type))
{
if (pd.Name == 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<string, PropertyDescriptor> overridePds = new Dictionary<string, PropertyDescriptor>();

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<PropertyDescriptor>(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;
}
}
}

0 comments on commit ce56e98

Please sign in to comment.