diff --git a/Directory.Build.props b/Directory.Build.props index eca2ab5..4ad93c9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,8 @@ - 0.1.1 - 0.1.0 + 1.0.0 + 0.1.1 12.0 enable enable diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 61cd224..25b340e 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,5 +1,9 @@ # Release Notes +## 1.0.0 + +* Add `ValidateItemsAttribute`. + ## 0.1.1 * Add .NET 6 target. diff --git a/src/Faithlife.DataAnnotations/ValidateItemsAttribute.cs b/src/Faithlife.DataAnnotations/ValidateItemsAttribute.cs new file mode 100644 index 0000000..506928d --- /dev/null +++ b/src/Faithlife.DataAnnotations/ValidateItemsAttribute.cs @@ -0,0 +1,35 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations; + +namespace Faithlife.DataAnnotations; + +/// +/// Used to validate a collection property whose items have their own properties with data annotations. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class ValidateItemsAttribute : ValidationAttribute +{ + /// + /// True if null items are allowed. Defaults to false. + /// + public bool AllowNullItems { get; set; } + + /// + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value is not IEnumerable items) + return null; + + var (validationResults, index) = items.Cast() + .Where(x => !AllowNullItems || x is not null) + .Select((x, i) => (Results: x is null ? [new ValidationResult("The item is null.")] : ValidatorUtility.GetValidationResults(x), Index: i)) + .FirstOrDefault(x => x.Results.Count != 0); + if (validationResults is null) + return null; + + var innerErrorMessage = string.Join(" ", validationResults.Select(x => x.ErrorMessage).Where(x => x is not null)); + return new ValidationResult( + errorMessage: $"{FormatErrorMessage(validationContext.DisplayName)}{(innerErrorMessage.Length == 0 ? "" : $" ([{index}]: {innerErrorMessage})")}", + memberNames: validationContext.MemberName is { } memberName ? new[] { memberName } : null); + } +} diff --git a/tests/Faithlife.DataAnnotations.Tests/ValidateItemsAttributeTests.cs b/tests/Faithlife.DataAnnotations.Tests/ValidateItemsAttributeTests.cs new file mode 100644 index 0000000..6ee69c2 --- /dev/null +++ b/tests/Faithlife.DataAnnotations.Tests/ValidateItemsAttributeTests.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; +using NUnit.Framework; + +namespace Faithlife.DataAnnotations.Tests; + +public sealed class ValidateItemsAttributeTests +{ + [Test] + public void ValidateItems() + { + var validatable = new ValidatableDto { Required = "x" }; + + var results = ValidatorUtility.GetValidationResults(validatable); + Assert.That(results, Is.Empty); + + validatable.Validatables = [null!]; + results = ValidatorUtility.GetValidationResults(validatable); + Assert.That(results.Single().MemberNames.Single(), Is.EqualTo(nameof(ValidatableDto.Validatables))); + Assert.That(results.Single().ErrorMessage, Is.EqualTo("The field Validatables is invalid. ([0]: The item is null.)")); + + validatable.Validatables = [new ValidatableDto(), new ValidatableDto()]; + results = ValidatorUtility.GetValidationResults(validatable); + Assert.That(results.Single().MemberNames.Single(), Is.EqualTo(nameof(ValidatableDto.Validatables))); + Assert.That(results.Single().ErrorMessage, Is.EqualTo("The field Validatables is invalid. ([0]: The Required field is required.)")); + + validatable.Validatables[0].Required = "x"; + results = ValidatorUtility.GetValidationResults(validatable); + Assert.That(results.Single().MemberNames.Single(), Is.EqualTo(nameof(ValidatableDto.Validatables))); + Assert.That(results.Single().ErrorMessage, Is.EqualTo("The field Validatables is invalid. ([1]: The Required field is required.)")); + + validatable.Validatables[1].Required = "x"; + results = ValidatorUtility.GetValidationResults(validatable); + Assert.That(results, Is.Empty); + } + + [Test] + public void ValidateNullableItems() + { + var validatable = new ValidatableDto { Required = "x" }; + + var results = ValidatorUtility.GetValidationResults(validatable); + Assert.That(results, Is.Empty); + + validatable.NullableValidatables = [null!]; + results = ValidatorUtility.GetValidationResults(validatable); + Assert.That(results, Is.Empty); + + validatable.Validatables = [new ValidatableDto()]; + results = ValidatorUtility.GetValidationResults(validatable); + Assert.That(results.Single().MemberNames.Single(), Is.EqualTo(nameof(ValidatableDto.Validatables))); + Assert.That(results.Single().ErrorMessage, Is.EqualTo("The field Validatables is invalid. ([0]: The Required field is required.)")); + } + + private sealed class ValidatableDto + { + [Required] + public string? Required { get; set; } + + [ValidateItems] + public IReadOnlyList? Validatables { get; set; } + + [ValidateItems(AllowNullItems = true)] + public IReadOnlyList? NullableValidatables { get; set; } + } +}