Skip to content
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
91 changes: 91 additions & 0 deletions CSharpExtender/Attributes/UniqueItemsAttribute.cs
Original file line number Diff line number Diff line change
@@ -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<object>().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;
}
}
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
181 changes: 181 additions & 0 deletions Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs
Original file line number Diff line number Diff line change
@@ -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<string> };
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<string>() };
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<string> { "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<string> { "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);

Check warning on line 55 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 55 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
Assert.Contains("indices 0 and 1", result.ErrorMessage);
}

[Fact]
public void UniqueItemsAttribute_DuplicateStrings_CustomErrorMessage()
{
var model = new { Items = new List<string> { "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);

Check warning on line 67 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 67 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
}

[Fact]
public void UniqueItemsAttribute_UniqueNumbers_ReturnsSuccess()
{
var model = new { Items = new List<int> { 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<int> { 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);

Check warning on line 88 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
Assert.Contains("indices 1 and 2", result.ErrorMessage);
}

[Fact]
public void UniqueItemsAttribute_DuplicateNumbers_CustomErrorMessage()
{
var model = new { Items = new List<int> { 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);

Check warning on line 100 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
}

[Fact]
public void UniqueItemsAttribute_UniqueComplexObjects_ReturnsSuccess()
{
var model = new
{
Items = new List<TestItem>
{
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<TestItem>
{
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);

Check warning on line 137 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
Assert.Contains("indices 0 and 1", result.ErrorMessage);
}

[Fact]
public void UniqueItemsAttribute_DuplicateComplexObjects_CustomErrorMessage()
{
var model = new
{
Items = new List<TestItem>
{
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);

Check warning on line 157 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
}

[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);

Check warning on line 168 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
}

[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);

Check warning on line 179 in Test.CSharpExtender/Attributes/Test_UniqueItemsAttribute.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
}
}
2 changes: 1 addition & 1 deletion Test.CSharpExtender/Test.CSharpExtender.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
Expand Down