diff --git a/CSharpExtender/Attributes/UniqueItemsAttribute.cs b/CSharpExtender/Attributes/UniqueItemsAttribute.cs new file mode 100644 index 0000000..0a43cdb --- /dev/null +++ b/CSharpExtender/Attributes/UniqueItemsAttribute.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; + +namespace CSharpExtender.Attributes; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +public class UniqueItemsAttribute : ValidationAttribute +{ + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + // If the value is null, let [Required] handle it if needed + if (value == null) + { + return ValidationResult.Success; + } + + // Ensure the value is a collection. + // Need to explicitly check for string, as it is enumerable. + if (value is string || value is not IEnumerable collection) + { + return new ValidationResult("The UniqueItemsAttribute must be applied to a collection."); + } + + var items = collection.Cast().ToList(); + if (items.Count == 0) + { + return ValidationResult.Success; + } + + // Check for duplicates based on type + for (int i = 0; i < items.Count - 1; i++) + { + for (int j = i + 1; j < items.Count; j++) + { + if (AreItemsEqual(items[i], items[j])) + { + // Use ErrorMessage if provided; otherwise, use default + string errorMessage = ErrorMessage ?? + $"The list contains duplicate items at indices {i} and {j}."; + return new ValidationResult(errorMessage); + } + } + } + + return ValidationResult.Success; + } + + private bool AreItemsEqual(object item1, object item2) + { + if (item1 == null && item2 == null) + { + return true; + } + if (item1 == null || item2 == null) + { + return false; + } + + // Handle simple types (e.g., string, int) + if (item1.GetType().IsValueType || item1 is string) + { + return item1.Equals(item2); + } + + // Handle complex objects by comparing all properties + var properties = item1.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var prop in properties) + { + var value1 = prop.GetValue(item1); + var value2 = prop.GetValue(item2); + + if (value1 == null && value2 == null) + { + continue; + } + if (value1 == null || value2 == null) + { + return false; + } + if (!value1.Equals(value2)) + { + return false; + } + } + + return true; + } +} \ No newline at end of file diff --git a/README.md b/README.md index ede55f5..488c5c0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Extension methods and classes I often find useful for C# development. +## DataAnnotations + +Attribute classes to validate properties in models. + +- **`UniqueItemsAttribute`**: Check that a collection property does not contain duplicate items. + ## Collections ### GenericCache diff --git a/Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs b/Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs new file mode 100644 index 0000000..3dd97f7 --- /dev/null +++ b/Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs @@ -0,0 +1,181 @@ +using CSharpExtender.Attributes; +using System.ComponentModel.DataAnnotations; + +namespace Test.CSharpExtender.Attributes; + +public class UniqueItemsAttributeTests +{ + private static ValidationContext GetValidationContext(object instance) => new(instance); + + // Test class for complex object validation + private class TestItem(int id, string name) + { + public int Id { get; set; } = id; + public string Name { get; set; } = name; + } + + [Fact] + public void UniqueItemsAttribute_NullList_ReturnsSuccess() + { + var model = new { Items = null as List }; + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public void UniqueItemsAttribute_EmptyList_ReturnsSuccess() + { + var model = new { Items = new List() }; + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public void UniqueItemsAttribute_UniqueStrings_ReturnsSuccess() + { + var model = new { Items = new List { "apple", "banana", "cherry" } }; + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public void UniqueItemsAttribute_DuplicateStrings_DefaultErrorMessage() + { + var model = new { Items = new List { "apple", "apple", "cherry" } }; + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.NotEqual(ValidationResult.Success, result); + Assert.Contains("duplicate items", result.ErrorMessage); + Assert.Contains("indices 0 and 1", result.ErrorMessage); + } + + [Fact] + public void UniqueItemsAttribute_DuplicateStrings_CustomErrorMessage() + { + var model = new { Items = new List { "apple", "apple", "cherry" } }; + var attribute = new UniqueItemsAttribute { ErrorMessage = "All items must be unique in the list." }; + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.NotEqual(ValidationResult.Success, result); + Assert.Equal("All items must be unique in the list.", result.ErrorMessage); + } + + [Fact] + public void UniqueItemsAttribute_UniqueNumbers_ReturnsSuccess() + { + var model = new { Items = new List { 1, 2, 3 } }; + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public void UniqueItemsAttribute_DuplicateNumbers_DefaultErrorMessage() + { + var model = new { Items = new List { 1, 2, 2 } }; + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.NotEqual(ValidationResult.Success, result); + Assert.Contains("duplicate items", result.ErrorMessage); + Assert.Contains("indices 1 and 2", result.ErrorMessage); + } + + [Fact] + public void UniqueItemsAttribute_DuplicateNumbers_CustomErrorMessage() + { + var model = new { Items = new List { 1, 2, 2 } }; + var attribute = new UniqueItemsAttribute { ErrorMessage = "Numbers must be unique." }; + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.NotEqual(ValidationResult.Success, result); + Assert.Equal("Numbers must be unique.", result.ErrorMessage); + } + + [Fact] + public void UniqueItemsAttribute_UniqueComplexObjects_ReturnsSuccess() + { + var model = new + { + Items = new List + { + new TestItem(1, "apple"), + new TestItem(2, "banana") + } + }; + + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public void UniqueItemsAttribute_DuplicateComplexObjects_DefaultErrorMessage() + { + var model = new + { + Items = new List + { + new TestItem(1, "apple"), + new TestItem(1, "apple") + } + }; + + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.NotEqual(ValidationResult.Success, result); + Assert.Contains("duplicate items", result.ErrorMessage); + Assert.Contains("indices 0 and 1", result.ErrorMessage); + } + + [Fact] + public void UniqueItemsAttribute_DuplicateComplexObjects_CustomErrorMessage() + { + var model = new + { + Items = new List + { + new TestItem(1, "apple"), + new TestItem(1, "apple") + } + }; + + var attribute = new UniqueItemsAttribute { ErrorMessage = "Objects in the list must have unique values." }; + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.NotEqual(ValidationResult.Success, result); + Assert.Equal("Objects in the list must have unique values.", result.ErrorMessage); + } + + [Fact] + public void UniqueItemsAttribute_SingleString_ReturnsError() + { + var model = new { Items = "not a list" }; + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.NotEqual(ValidationResult.Success, result); + Assert.Contains("must be applied to a collection", result.ErrorMessage); + } + + [Fact] + public void UniqueItemsAttribute_NonCollectionNonString_ReturnsError() + { + var model = new { Items = 42 }; // An int, not a string or collection + var attribute = new UniqueItemsAttribute(); + var result = attribute.GetValidationResult(model.Items, GetValidationContext(model)); + + Assert.NotEqual(ValidationResult.Success, result); + Assert.Contains("must be applied to a collection", result.ErrorMessage); + } +} \ No newline at end of file diff --git a/Test.CSharpExtender/Test.CSharpExtender.csproj b/Test.CSharpExtender/Test.CSharpExtender.csproj index 09d3916..ee9e73d 100644 --- a/Test.CSharpExtender/Test.CSharpExtender.csproj +++ b/Test.CSharpExtender/Test.CSharpExtender.csproj @@ -1,4 +1,4 @@ - + net8.0