Skip to content

Commit

Permalink
Add SmartEnumNameAttribute, a DataAnnotations ValidationAttribute (#447)
Browse files Browse the repository at this point in the history
* Add SmartEnum Validation attribute, with tests.

* Rename GetValidSmartEnumValues to GetValidSmartEnumNames

* Clarify tests.

* Readme notes (to be removed).

* Rename to SmartEnumNameAttribute

* Additional tests

* Flesh out README entry for SmartEnumNameAttribute

* Remove ReSharper comments.

* Fix variable name.

* Update test class to be more clear.

* Fix my name :)

* Update Contributing documentation to reference SmartEnum instead of GuardClauses

* Fix typo

* Variable renames

---------

Co-authored-by: Steve Smith <[email protected]>
  • Loading branch information
sdepouw and ardalis authored Jan 16, 2024
1 parent 33fbdc6 commit 9f3dbec
Show file tree
Hide file tree
Showing 4 changed files with 292 additions and 5 deletions.
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) { }
}
}
}

0 comments on commit 9f3dbec

Please sign in to comment.