From 9f3dbec6225675a064ea6ba42db8a672559e27d4 Mon Sep 17 00:00:00 2001 From: Scott DePouw Date: Tue, 16 Jan 2024 09:44:59 -0500 Subject: [PATCH] Add SmartEnumNameAttribute, a DataAnnotations ValidationAttribute (#447) * 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 --- CONTRIBUTING.md | 8 +- README.md | 31 ++- src/SmartEnum/SmartEnumNameAttribute.cs | 81 ++++++++ .../SmartEnumNameAttributeTests.cs | 177 ++++++++++++++++++ 4 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 src/SmartEnum/SmartEnumNameAttribute.cs create mode 100644 test/SmartEnum.UnitTests/SmartEnumNameAttributeTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c29d7aba..57e0690b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: @@ -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 diff --git a/README.md b/README.md index 84176688..7ec641a5 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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 +{ + 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 diff --git a/src/SmartEnum/SmartEnumNameAttribute.cs b/src/SmartEnum/SmartEnumNameAttribute.cs new file mode 100644 index 00000000..ebbfa268 --- /dev/null +++ b/src/SmartEnum/SmartEnumNameAttribute.cs @@ -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 +{ + /// + /// A that ensures the provided value matches the + /// of a /. + /// Nulls and non- values are considered valid + /// (add if you want the field to be required). + /// + public class SmartEnumNameAttribute : ValidationAttribute + { + private readonly bool _allowCaseInsensitiveMatch; + private readonly Type _smartEnumType; + + /// The expected SmartEnum type. + /// The name of the property that the attribute is being used on. + /// + /// Unless this is true, only exact case matching the + /// Name will validate. + /// + /// + /// Message template to show when validation fails. {0} is and + /// {1} is the comma-separated list of SmartEnum names. + /// + /// When any of the constructor parameters are null. + /// + /// When is not a + /// or + /// + 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 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 GetValidSmartEnumNames() + { + List 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; + } + } +} diff --git a/test/SmartEnum.UnitTests/SmartEnumNameAttributeTests.cs b/test/SmartEnum.UnitTests/SmartEnumNameAttributeTests.cs new file mode 100644 index 00000000..6a31f59d --- /dev/null +++ b/test/SmartEnum.UnitTests/SmartEnumNameAttributeTests.cs @@ -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(); + } + + [Fact] + public void ThrowsWhenCtorGetsNullPropertyName() + { + Action ctorCall = () => new SmartEnumNameAttribute(typeof(TestSmartEnum), propertyName: null, errorMessage: "Some Error Message"); + + ctorCall.Should().ThrowExactly(); + } + + [Fact] + public void ThrowsWhenCtorGetsNullErrorMessage() + { + Action ctorCall = () => new SmartEnumNameAttribute(typeof(TestSmartEnum), errorMessage: null); + + ctorCall.Should().ThrowExactly(); + } + + [Fact] + public void ThrowsWhenCtorGetsNonSmartEnumType() + { + Action ctorCall = () => new SmartEnumNameAttribute(typeof(SmartEnumNameAttributeTests)); + + ctorCall.Should().ThrowExactly(); + } + + [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 validationResults = new List(); + + 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 + { + 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 + { + private TestSmartEnumWithStringKeyType(string name, string value) : base(name, value) { } + } + } +}