Skip to content
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

Add SmartEnumNameAttribute, a DataAnnotations ValidationAttribute #447

Merged
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
8 changes: 4 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Contributing to Ardalis.GuardClauses
# Contributing to Ardalis.SmartEnum

We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:

Expand Down Expand Up @@ -31,15 +31,15 @@ You can just add a pull request out of the blue if you want, but it's much bette

## Getting Started

Look for [issues marked with 'help wanted'](https://github.com/ardalis/guardclauses/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) to find good places to start contributing.
Look for [issues marked with 'help wanted'](https://github.com/ardalis/SmartEnum/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) to find good places to start contributing.

## Any contributions you make will be under the MIT Software License

In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers this project.

## Report bugs using Github's [issues](https://github.com/ardalis/guardclauses/issues)
## Report bugs using Github's [issues](https://github.com/ardalis/SmartEnum/issues)

We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/ardalis/GuardClauses/issues/new/choose); it's that easy!
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/ardalis/SmartEnum/issues/new/choose); it's that easy!

## Sponsor us

Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* [Dapper support](#dapper-support)
* [DapperSmartEnum](#dappersmartenum)
* [Case Insensitive String Enum](#case-insensitive-string-enum)
* [Name Validation Attribute](#name-validation-attribute)
* [Examples in the Real World](#examples-in-the-real-world)
* [References](#references)

Expand Down Expand Up @@ -61,7 +62,7 @@ An implementation of a [type-safe object-oriented alternative](https://codeblog.

## Contributors

Thanks to [Scott Depouw](https://github.com/sdepouw), [Antão Almada](https://github.com/aalmada), and [Nagasudhir Pulla](https://github.com/nagasudhirpulla) for help with this project!
Thanks to [Scott DePouw](https://github.com/sdepouw), [Antão Almada](https://github.com/aalmada), and [Nagasudhir Pulla](https://github.com/nagasudhirpulla) for help with this project!

# Install

Expand Down Expand Up @@ -828,7 +829,35 @@ var e2 = CaseInsensitiveEnum.FromValue("one");

//e1 is equal to e2
```
## Name Validation Attribute
The DataAnnotations ValidationAttribute `SmartEnumNameAttribute` allows you to validate your models, mandating that when provided a value it must be matching the name of a given `SmartEnum`. This attribute allows `null` values (use `[Required]` to disallow nulls).

In addition to specifying the `SmartEnum` to match, you may also pass additional parameters:
- `allowCaseInsensitiveMatch` (default `false`)
- `errorMessage` (default `"{0} must be one of: {1}"`): A format string to customize the error
- `{0}` is the name of the property being validated
- `{1}` is the comma-separated list of valid `SmartEnum` names

### Example of Name Validation Attribute
```csharp
public sealed class ExampleSmartEnum : SmartEnum<ExampleSmartEnum>
{
public static readonly ExampleSmartEnum Foo = new ExampleSmartEnum(nameof(Foo), 1);
public static readonly ExampleSmartEnum Bar = new ExampleSmartEnum(nameof(Bar), 2);

private ExampleSmartEnum(string name, int value) : base(name, value) { }
}

public class ExampleModel
{
[Required]
[SmartEnumName(typeof(ExampleSmartEnum)]
public string MyExample { get; set; } // must be "Foo" or "Bar"
[SmartEnumName(typeof(ExampleSmartEnum), allowCaseInsensitiveMatch: true)]
public string CaseInsensitiveExample { get; set; } // "Foo", "foo", etc. allowed; null also allowed here
}
```

## Examples in the Real World

Expand Down
81 changes: 81 additions & 0 deletions src/SmartEnum/SmartEnumNameAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;

namespace Ardalis.SmartEnum
{
/// <summary>
/// A <see cref="ValidationAttribute"/> that ensures the provided value matches the
/// <see cref="SmartEnum{TEnum}.Name"/> of a <see cref="SmartEnum{TEnum}"/>/<see cref="SmartEnum{TEnum,TValue}"/>.
/// Nulls and non-<see cref="string"/> values are considered valid
/// (add <see cref="RequiredAttribute"/> if you want the field to be required).
/// </summary>
public class SmartEnumNameAttribute : ValidationAttribute
{
private readonly bool _allowCaseInsensitiveMatch;
private readonly Type _smartEnumType;

/// <param name="smartEnumType">The expected SmartEnum type.</param>
/// <param name="propertyName">The name of the property that the attribute is being used on.</param>
/// <param name="allowCaseInsensitiveMatch">
/// Unless this is true, only exact case matching the
/// <see cref="SmartEnum{TEnum}" /> Name will validate.
/// </param>
/// <param name="errorMessage">
/// Message template to show when validation fails. {0} is <paramref name="propertyName" /> and
/// {1} is the comma-separated list of SmartEnum names.
/// </param>
/// <exception cref="ArgumentNullException">When any of the constructor parameters are null.</exception>
/// <exception cref="InvalidOperationException">
/// When <paramref name="smartEnumType" /> is not a
/// <see cref="SmartEnum{TEnum}" /> or <see cref="SmartEnum{TEnum,TValue}" />
/// </exception>
public SmartEnumNameAttribute(
Type smartEnumType,
[CallerMemberName] string propertyName = null,
bool allowCaseInsensitiveMatch = false,
string errorMessage = "{0} must be one of: {1}"
)
{
if (smartEnumType is null) throw new ArgumentNullException(nameof(smartEnumType));
if (propertyName is null) throw new ArgumentNullException(nameof(propertyName));
if (errorMessage is null) throw new ArgumentNullException(nameof(errorMessage));
List<string> smartEnumBaseTypes = new() { typeof(SmartEnum<>).Name, typeof(SmartEnum<,>).Name };
if (smartEnumType.BaseType == null || !smartEnumBaseTypes.Contains(smartEnumType.BaseType.Name))
throw new InvalidOperationException($"{nameof(smartEnumType)} must be a SmartEnum.");
_smartEnumType = smartEnumType;
_allowCaseInsensitiveMatch = allowCaseInsensitiveMatch;
ErrorMessage = string.Format(errorMessage, propertyName, string.Join(", ", GetValidSmartEnumNames()));
}

public override bool IsValid(object value)
{
if (value is not string name) return true;

return _allowCaseInsensitiveMatch
? GetValidSmartEnumNames().Contains(name, StringComparer.InvariantCultureIgnoreCase)
: GetValidSmartEnumNames().Contains(name);
}

private List<string> GetValidSmartEnumNames()
{
List<string> validNames = new();
var typeWithList = _smartEnumType.BaseType!.Name == typeof(SmartEnum<>).Name
? _smartEnumType.BaseType.BaseType!
: _smartEnumType.BaseType!;
var listProp = typeWithList.GetProperty("List", BindingFlags.Public | BindingFlags.Static);
var rawValue = listProp!.GetValue(null);
foreach (var val in (IEnumerable)rawValue!)
{
var namePropInfo = val.GetType().GetProperty("Name", BindingFlags.Public | BindingFlags.Instance);
var value = namePropInfo!.GetValue(val);
if (value is string name) validNames.Add(name);
}
return validNames;
}
}
}
177 changes: 177 additions & 0 deletions test/SmartEnum.UnitTests/SmartEnumNameAttributeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using FluentAssertions;
using FluentAssertions.Execution;
using Xunit;

namespace Ardalis.SmartEnum.UnitTests
{
public class SmartEnumNameAttributeTests
{
[Fact]
public void ThrowsWhenCtorGetsNullType()
{
Action ctorCall = () => new SmartEnumNameAttribute(null);

ctorCall.Should().ThrowExactly<ArgumentNullException>();
}

[Fact]
public void ThrowsWhenCtorGetsNullPropertyName()
{
Action ctorCall = () => new SmartEnumNameAttribute(typeof(TestSmartEnum), propertyName: null, errorMessage: "Some Error Message");

ctorCall.Should().ThrowExactly<ArgumentNullException>();
}

[Fact]
public void ThrowsWhenCtorGetsNullErrorMessage()
{
Action ctorCall = () => new SmartEnumNameAttribute(typeof(TestSmartEnum), errorMessage: null);

ctorCall.Should().ThrowExactly<ArgumentNullException>();
}

[Fact]
public void ThrowsWhenCtorGetsNonSmartEnumType()
{
Action ctorCall = () => new SmartEnumNameAttribute(typeof(SmartEnumNameAttributeTests));

ctorCall.Should().ThrowExactly<InvalidOperationException>();
}

[Fact]
public void DoesNotThrowWhenCtorForSmartEnumWithDifferentKeyType()
{
Action ctorCall = () => new SmartEnumNameAttribute(typeof(TestSmartEnumWithStringKeyType));

ctorCall.Should().NotThrow();
}

[Fact]
public void ReturnsErrorMessageContainingPropertyNameAndAllPossibleSmartEnumValues()
{
var model = new TestValidationModel { SomeProp = "foo" };
var validationContext = new ValidationContext(model, null, null);
List<ValidationResult> validationResults = new List<ValidationResult>();

Validator.TryValidateObject(model, validationContext, validationResults, true);

using (new AssertionScope())
{
validationResults.Should().HaveCount(1);
string errorMessage = validationResults.Single().ErrorMessage;
errorMessage.Should().Contain(nameof(TestValidationModel.SomeProp));
errorMessage.Should().Contain(TestSmartEnum.TestFoo.Name);
errorMessage.Should().Contain(TestSmartEnum.TestBar.Name);
errorMessage.Should().Contain(TestSmartEnum.TestFizz.Name);
errorMessage.Should().Contain(TestSmartEnum.TestBuzz.Name);
}
}

[Fact]
public void IsValidGivenNonString()
{
var attribute = new SmartEnumNameAttribute(typeof(TestSmartEnum));
object nonString = new { };

bool isValid = attribute.IsValid(nonString);

isValid.Should().BeTrue();
}

[Fact]
public void IsValidGivenNullString()
{
var attribute = new SmartEnumNameAttribute(typeof(TestSmartEnum));
string nullString = null;

bool isValid = attribute.IsValid(nullString);

isValid.Should().BeTrue();
}

[Fact]
public void IsValidGivenNullNonString()
{
var attribute = new SmartEnumNameAttribute(typeof(TestSmartEnum));
object nullObject = null;

bool isValid = attribute.IsValid(nullObject);

isValid.Should().BeTrue();
}

[Fact]
public void IsValidForEachMemberOfAGivenSmartEnum()
{
var attribute = new SmartEnumNameAttribute(typeof(TestSmartEnum));
using (new AssertionScope())
{
foreach (var name in TestSmartEnum.List.Select(at => at.Name))
{
bool isValid = attribute.IsValid(name);
isValid.Should().BeTrue();
}
}
}

[Fact]
public void IsValidForCaseInsensitiveStringWhenCaseInsensitiveMatchingEnabled()
{
var attribute = new SmartEnumNameAttribute(typeof(TestSmartEnum), allowCaseInsensitiveMatch: true);
var caseInsensitiveSource = TestSmartEnum.TestFoo.Name.ToLower();

bool isValid = attribute.IsValid(caseInsensitiveSource);

isValid.Should().BeTrue();
}

[Fact]
public void IsNotValidForCaseInsensitiveStringWhenCaseInsensitiveMatchingDisabled()
{
var attribute = new SmartEnumNameAttribute(typeof(TestSmartEnum));
var caseInsensitiveSource = TestSmartEnum.TestFoo.Name.ToLower();

bool isValid = attribute.IsValid(caseInsensitiveSource);

isValid.Should().BeFalse();
}

[Theory]
[InlineData(" ")]
[InlineData("Some Wrong Value")]
[InlineData("25")]
public void IsNotValidGivenNonSmartEnumNames(string invalidName)
{
var attribute = new SmartEnumNameAttribute(typeof(TestSmartEnum));

bool isValid = attribute.IsValid(invalidName);

isValid.Should().BeFalse();
}

private class TestValidationModel
{
[SmartEnumName(typeof(TestSmartEnum))]
public string SomeProp { get; set; }
}

private class TestSmartEnum : SmartEnum<TestSmartEnum>
{
public static readonly TestSmartEnum TestFoo = new TestSmartEnum(nameof(TestFoo), 1);
public static readonly TestSmartEnum TestBar = new TestSmartEnum(nameof(TestBar), 2);
public static readonly TestSmartEnum TestFizz = new TestSmartEnum(nameof(TestFizz), 3);
public static readonly TestSmartEnum TestBuzz = new TestSmartEnum(nameof(TestBuzz), 4);

private TestSmartEnum(string name, int value) : base(name, value) { }
}

private class TestSmartEnumWithStringKeyType : SmartEnum<TestSmartEnumWithStringKeyType, string>
{
private TestSmartEnumWithStringKeyType(string name, string value) : base(name, value) { }
}
}
}
Loading