diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 087b222..754f544 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -19,7 +19,10 @@ jobs: - name: Install .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x global-json-file: "./global.json" - name: Restore dependencies diff --git a/global.json b/global.json index 501e79a..512142d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100", + "version": "10.0.100", "rollForward": "latestFeature" } -} \ No newline at end of file +} diff --git a/samples/Samples.Console/Samples.Console.csproj b/samples/Samples.Console/Samples.Console.csproj index 92b9057..7bfb498 100644 --- a/samples/Samples.Console/Samples.Console.csproj +++ b/samples/Samples.Console/Samples.Console.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 enable diff --git a/samples/Samples.Web/Samples.Web.csproj b/samples/Samples.Web/Samples.Web.csproj index 7190ba0..f3b5e57 100644 --- a/samples/Samples.Web/Samples.Web.csproj +++ b/samples/Samples.Web/Samples.Web.csproj @@ -1,7 +1,7 @@  - net7.0 + net8.0 enable enable diff --git a/src/MiniValidation/MiniValidation.csproj b/src/MiniValidation/MiniValidation.csproj index 7f2afc4..44f3c99 100644 --- a/src/MiniValidation/MiniValidation.csproj +++ b/src/MiniValidation/MiniValidation.csproj @@ -2,7 +2,7 @@ A minimalist validation library built atop the existing validation features in .NET's `System.ComponentModel.DataAnnotations` namespace. - netstandard2.0;net6.0 + netstandard2.0;net8.0 ComponentModel DataAnnotations validation README.md 10.0 diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index 91d00c4..9a0c531 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -503,7 +503,7 @@ private static async Task TryValidateImpl( } } - if (isValid && typeof(IValidatableObject).IsAssignableFrom(targetType)) + if (typeof(IValidatableObject).IsAssignableFrom(targetType)) { var validatable = (IValidatableObject)target; @@ -514,12 +514,11 @@ private static async Task TryValidateImpl( var validatableResults = validatable.Validate(validationContext); if (validatableResults is not null) { - ProcessValidationResults(validatableResults, workingErrors, prefix); - isValid = workingErrors.Count == 0 && isValid; + isValid = ProcessValidationResults(validatableResults, workingErrors, prefix) && isValid; } } - if (isValid && typeof(IAsyncValidatableObject).IsAssignableFrom(targetType)) + if ((isValid || allowAsync) && typeof(IAsyncValidatableObject).IsAssignableFrom(targetType)) { var validatable = (IAsyncValidatableObject)target; @@ -533,8 +532,7 @@ private static async Task TryValidateImpl( var validatableResults = await validateTask.ConfigureAwait(false); if (validatableResults is not null) { - ProcessValidationResults(validatableResults, workingErrors, prefix); - isValid = workingErrors.Count == 0 && isValid; + isValid = ProcessValidationResults(validatableResults, workingErrors, prefix) && isValid; } } @@ -598,12 +596,7 @@ private static async Task TryValidateEnumerable( throw; } - isValid = await validateTask.ConfigureAwait(false); - - if (!isValid) - { - break; - } + isValid = await validateTask.ConfigureAwait(false) && isValid; index++; } } @@ -633,10 +626,13 @@ private static IDictionary MapToFinalErrorsResult(Dictionary validationResults, Dictionary> errors, string? prefix) + private static bool ProcessValidationResults(IEnumerable validationResults, Dictionary> errors, string? prefix) { + var isValid = true; + foreach (var result in validationResults) { + isValid = false; var hasMemberNames = false; foreach (var memberName in result.MemberNames) { @@ -652,7 +648,7 @@ private static void ProcessValidationResults(IEnumerable valid if (!hasMemberNames) { // Class level error message - var key = ""; + var key = GetClassLevelKey(prefix); if (!errors.ContainsKey(key)) { errors.Add(key, new()); @@ -660,6 +656,20 @@ private static void ProcessValidationResults(IEnumerable valid errors[key].Add(result.ErrorMessage ?? ""); } } + + return isValid; + + static string GetClassLevelKey(string? prefix) + { + if (string.IsNullOrEmpty(prefix)) + { + return ""; + } + + return prefix!.EndsWith(".", StringComparison.Ordinal) + ? prefix.Substring(0, prefix.Length - 1) + : prefix; + } } private static void ProcessValidationResults(string propertyName, ICollection validationResults, Dictionary> errors, string? prefix) diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index aee8fb2..0d743de 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -14,6 +14,21 @@ internal class TypeDetailsCache private static readonly PropertyDetails[] _emptyPropertyDetails = Array.Empty(); private readonly ConcurrentDictionary _cache = new(); + public TypeDetailsCache() + { + TypeDescriptor.Refreshed += args => + { + if (args.TypeChanged is { } type) + { + _cache.TryRemove(type, out _); + } + else + { + _cache.Clear(); + } + }; + } + public (PropertyDetails[] Properties, bool RequiresAsync) Get(Type? type) { if (type is null) @@ -21,12 +36,13 @@ internal class TypeDetailsCache return (_emptyPropertyDetails, false); } - if (!_cache.ContainsKey(type)) + (PropertyDetails[] Properties, bool RequiresAsync) details; + while (!_cache.TryGetValue(type, out details)) { Visit(type); } - return _cache[type]; + return details; } private void Visit(Type type) diff --git a/tests/MiniValidation.Benchmarks/MiniValidation.Benchmarks.csproj b/tests/MiniValidation.Benchmarks/MiniValidation.Benchmarks.csproj index e142f3c..87f0f67 100644 --- a/tests/MiniValidation.Benchmarks/MiniValidation.Benchmarks.csproj +++ b/tests/MiniValidation.Benchmarks/MiniValidation.Benchmarks.csproj @@ -2,8 +2,7 @@ Exe - net6.0;net7.0 - net471;net6.0;net7.0 + net8.0;net9.0;net10.0 enable enable 10 diff --git a/tests/MiniValidation.UnitTests/MiniValidation.UnitTests.csproj b/tests/MiniValidation.UnitTests/MiniValidation.UnitTests.csproj index 0e7f43a..a640ad8 100644 --- a/tests/MiniValidation.UnitTests/MiniValidation.UnitTests.csproj +++ b/tests/MiniValidation.UnitTests/MiniValidation.UnitTests.csproj @@ -1,8 +1,7 @@  - net6.0;net7.0;net8.0 - net471;net6.0;net7.0;net8.0 + net8.0;net9.0;net10.0 10.0 enable enable diff --git a/tests/MiniValidation.UnitTests/Recursion.cs b/tests/MiniValidation.UnitTests/Recursion.cs index c43d394..eac9ff9 100644 --- a/tests/MiniValidation.UnitTests/Recursion.cs +++ b/tests/MiniValidation.UnitTests/Recursion.cs @@ -160,7 +160,7 @@ public void Error_Message_Keys_For_Descendant_Enumerable_Are_Formatted_Correctly } [Fact] - public void First_Error_In_Root_Enumerable_Returns_Immediately() + public void All_Errors_In_Root_Enumerable_Are_Reported() { var thingToValidate = new List { @@ -171,13 +171,13 @@ public void First_Error_In_Root_Enumerable_Returns_Immediately() var result = MiniValidator.TryValidate(thingToValidate, recurse: true, out var errors); Assert.False(result); - Assert.Single(errors); - var entry = Assert.Single(errors); - Assert.Equal($"[0].{nameof(TestType.RequiredName)}", entry.Key); + Assert.Equal(2, errors.Count); + Assert.Contains($"[0].{nameof(TestType.RequiredName)}", errors.Keys); + Assert.Contains($"[1].{nameof(TestType.RequiredName)}", errors.Keys); } [Fact] - public void First_Error_In_Descendant_Enumerable_Returns_Immediately() + public void All_Errors_In_Descendant_Enumerable_Are_Reported() { var thingToValidate = new TestType(); thingToValidate.Children.Add(new() { MinLengthFive = "123" }); @@ -186,9 +186,26 @@ public void First_Error_In_Descendant_Enumerable_Returns_Immediately() var result = MiniValidator.TryValidate(thingToValidate, recurse: true, out var errors); Assert.False(result); - Assert.Single(errors); - var entry = Assert.Single(errors); - Assert.Equal($"{nameof(TestType.Children)}[0].{nameof(TestChildType.MinLengthFive)}", entry.Key); + Assert.Equal(2, errors.Count); + Assert.Contains($"{nameof(TestType.Children)}[0].{nameof(TestChildType.MinLengthFive)}", errors.Keys); + Assert.Contains($"{nameof(TestType.Children)}[1].{nameof(TestChildType.RequiredCategory)}", errors.Keys); + } + + [Fact] + public void Class_Level_Errors_In_Root_Enumerable_Are_Keyed_By_Item() + { + var thingToValidate = new List + { + new() { TwentyOrMore = 12 }, + new() { TwentyOrMore = 12 }, + }; + + var result = MiniValidator.TryValidate(thingToValidate, recurse: true, out var errors); + + Assert.False(result); + Assert.Equal(2, errors.Count); + Assert.Contains("[0]", errors.Keys); + Assert.Contains("[1]", errors.Keys); } [Fact] @@ -492,4 +509,4 @@ public async Task DoesntThrow_When_Validates_Without_Recurse_And_Object_Has_Not_ Assert.True(isValid); } -} \ No newline at end of file +} diff --git a/tests/MiniValidation.UnitTests/TestTypes.cs b/tests/MiniValidation.UnitTests/TestTypes.cs index cd54e4e..2d8e907 100644 --- a/tests/MiniValidation.UnitTests/TestTypes.cs +++ b/tests/MiniValidation.UnitTests/TestTypes.cs @@ -82,6 +82,22 @@ public IEnumerable Validate(ValidationContext validationContex } } +class TestTypeWithClassLevelValidation : IValidatableObject +{ + [Required] + public string? RequiredName { get; set; } = "Default"; + + public bool IsValid { get; set; } = true; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (!IsValid) + { + yield return new ValidationResult($"{validationContext.DisplayName} is invalid."); + } + } +} + class TestClassWithEnumerable { public IEnumerable? Enumerable { get; set; } diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index 0d59e1e..0e445aa 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -264,7 +264,7 @@ public void Invalid_When_ValidatableObject_Has_Invalid_Attributes() } [Fact] - public void ValidatableObject_Is_Not_Validated_When_Has_Invalid_Attributes() + public void ValidatableObject_Validate_Member_Errors_Are_Aggregated_With_Invalid_Attributes() { var thingToValidate = new TestValidatableType { @@ -275,8 +275,26 @@ public void ValidatableObject_Is_Not_Validated_When_Has_Invalid_Attributes() var result = MiniValidator.TryValidate(thingToValidate, out var errors); Assert.False(result); - Assert.Single(errors); - Assert.Equal(nameof(TestValidatableType.TenOrMore), errors.Keys.First()); + Assert.Equal(2, errors.Count); + Assert.Contains(nameof(TestValidatableType.TenOrMore), errors.Keys); + Assert.Contains(nameof(TestValidatableType.TwentyOrMore), errors.Keys); + } + + [Fact] + public void ValidatableObject_Validate_Class_Level_Errors_Are_Aggregated_With_Invalid_Attributes() + { + var thingToValidate = new TestTypeWithClassLevelValidation + { + RequiredName = null, + IsValid = false + }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.False(result); + Assert.Equal(2, errors.Count); + Assert.Contains(nameof(TestTypeWithClassLevelValidation.RequiredName), errors.Keys); + Assert.Contains("", errors.Keys); } [Fact]