diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 087b222..e366c64 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -2,11 +2,11 @@ name: Build on: workflow_call: - inputs: - packShipCandidate: - required: false - type: boolean - default: false +# inputs: +# packShipCandidate: +# required: false +# type: boolean +# default: false jobs: build: @@ -38,25 +38,25 @@ jobs: - name: Test run: dotnet test --configuration Release --no-restore --no-build - - name: Publish samples - run: | - for projectPath in ./samples/**/*.csproj ; do - projectFileName=${projectPath##*/} - projectName=${projectFileName%.*} - dotnet publish "$projectPath" --output "./artifacts/$projectName" --configuration Release --no-build --verbosity normal - done; - ./artifacts/Samples.Console/Samples.Console "MiniValidation" +# - name: Publish samples +# run: | +# for projectPath in ./samples/**/*.csproj ; do +# projectFileName=${projectPath##*/} +# projectName=${projectFileName%.*} +# dotnet publish "$projectPath" --output "./artifacts/$projectName" --configuration Release --no-build --verbosity normal +# done; +# ./artifacts/Samples.Console/Samples.Console "MiniValidationPlus" - - name: Pack (ci) - run: dotnet pack --configuration Release --output ./artifacts/ci --verbosity normal -p:BuildNumber=$BUILD_NUMBER -p:SourceRevisionId=$GITHUB_SHA -p:ContinuousIntegrationBuild=true +# - name: Pack (ci) +# run: dotnet pack --configuration Release --output ./artifacts/ci --verbosity normal -p:BuildNumber=$BUILD_NUMBER -p:SourceRevisionId=$GITHUB_SHA -p:ContinuousIntegrationBuild=true - name: Pack (ship candidate) - if: ${{ inputs.packShipCandidate }} +# if: ${{ inputs.packShipCandidate }} run: dotnet pack --configuration Release --output ./artifacts/ship --verbosity normal -p:BuildNumber=$BUILD_NUMBER -p:SourceRevisionId=$GITHUB_SHA -p:ContinuousIntegrationBuild=true -p:IsShipCandidate=true - name: Upload artifacts (packages) uses: actions/upload-artifact@v4 with: name: nupkg - path: ./artifacts/**/*.nupkg + path: ./artifacts/ship/*.nupkg retention-days: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1eb40e0..dae698c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,36 +10,7 @@ on: workflow_dispatch: -env: - PACKAGE_ID: MiniValidation - jobs: build: name: Build & Test uses: ./.github/workflows/_build.yml - with: - packShipCandidate: true - - deploy: - name: Deploy - needs: build - runs-on: ubuntu-latest - - steps: - - name: Download artifacts - uses: actions/download-artifact@v4 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - - - name: Add GitHub Package Repository source - run: dotnet nuget add source --username ${{ secrets.GPR_USERNAME }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name GPR ${{ secrets.GPR_URI }} - - - name: Push to GitHub Packages - run: dotnet nuget push **/ci/*.nupkg -s "GPR" --skip-duplicate - - - name: Delete old packages - uses: smartsquaregmbh/delete-old-packages@v0.5.0 - with: - keep: 5 - names: ${{ env.PACKAGE_ID }} diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml new file mode 100644 index 0000000..f4e2d63 --- /dev/null +++ b/.github/workflows/nuget.yml @@ -0,0 +1,19 @@ +name: NuGet + +on: + workflow_dispatch: + +jobs: + push-package: + name: Publish NuGet Package + runs-on: ubuntu-latest + + steps: + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + + - name: Add nuget.org source + run: dotnet nuget add source --name NUGET https://www.nuget.org + + - name: Push to nuget.org + run: dotnet nuget push **/*.nupkg -s "NUGET" -k ${{ secrets.NUGET_APIKEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index bc899e1..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Release - -on: - workflow_dispatch: - inputs: - runId: - description: The run ID of the CI workflow to release NuGet artifacts from - required: true - type: string - -env: - PACKAGE_ID: MiniValidation - -jobs: - push-package: - name: Release - runs-on: ubuntu-latest - - steps: - - name: Download workflow run details - run: | - workflowUrl="https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ inputs.runId }}" - curl -s -H "Accept: application/json" "${workflowUrl}" > workflow_details.json - - - name: Extract workflow run commit SHA - uses: sergeysova/jq-action@v2 - id: workflowsha - with: - cmd: 'jq .head_sha workflow_details.json -r' - - - name: Download workflow run artifacts - uses: dawidd6/action-download-artifact@v3 - with: - run_id: ${{ inputs.runId }} - workflow_conclusion: success - name: nupkg - - - name: Get package version into an environment variable - run: | - _filepath="$(find ./ship -iname $PACKAGE_ID.*.nupkg)" - _filename="${_filepath##*/}" - _pkgname="${_filename%.*}" - _version="${_pkgname##$PACKAGE_ID.}" - echo "PACKAGE_VERSION=${_version}" >> $GITHUB_ENV - echo "PACKAGE_FILEPATH=${_filepath}" >> $GITHUB_ENV - - - name: Verify package version doesn't exist - run: | - _packageId="$PACKAGE_ID" - _packageVersion="$PACKAGE_VERSION" - _packageIdLower="${_packageId,,}" - _packageUrl="https://api.nuget.org/v3/registration5-semver1/${_packageIdLower}/${_packageVersion}.json" - echo "Checking for existing package at ${_packageUrl}" - _statusCode=$(curl -s -o /dev/null -I -w '%{http_code}' "${_packageUrl}") - if [ $_statusCode == "200" ]; then - echo "The package ${_packageId} with version ${_packageVersion} already exists on nuget.org"; exit 1 - elif [ $_statusCode == "404" ]; then - echo "Confirmed package ${_packageId} with version ${_packageVersion} does not already exist on nuget.org" - else - echo "Unexpected status code ${_statusCode} received from nuget.org"; exit 1 - fi - - - name: Create release - uses: ncipollo/release-action@v1 - with: - tag: v${{ env.PACKAGE_VERSION }} - commit: ${{ steps.workflowsha.outputs.value }} - generateReleaseNotes: true - draft: true - prerelease: ${{ contains(env.PACKAGE_VERSION, '-') }} - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - - - name: Add nuget.org source - run: dotnet nuget add source --name NUGET https://www.nuget.org - - - name: Push to nuget.org - run: dotnet nuget push "$PACKAGE_FILEPATH" -s "NUGET" -k ${{ secrets.NUGET_API_KEY }} diff --git a/.idea/.idea.MiniValidationPlus/.idea/.gitignore b/.idea/.idea.MiniValidationPlus/.idea/.gitignore new file mode 100644 index 0000000..5b5cfd8 --- /dev/null +++ b/.idea/.idea.MiniValidationPlus/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.MiniValidationPlus.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.MiniValidationPlus/.idea/indexLayout.xml b/.idea/.idea.MiniValidationPlus/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.MiniValidationPlus/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.MiniValidationPlus/.idea/vcs.xml b/.idea/.idea.MiniValidationPlus/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/.idea.MiniValidationPlus/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE index 9802294..def4414 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) Copyright (c) 2021 Damian Edwards +Copyright (c) 2024 Lubos Hladik (non-nullable and other enhancements) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/MiniValidation.sln b/MiniValidationPlus.sln similarity index 100% rename from MiniValidation.sln rename to MiniValidationPlus.sln diff --git a/MiniValidationPlus.sln.DotSettings b/MiniValidationPlus.sln.DotSettings new file mode 100644 index 0000000..4b214bd --- /dev/null +++ b/MiniValidationPlus.sln.DotSettings @@ -0,0 +1,10 @@ + + LEAVE_ALL + True + True + 135 + False + True + True + True + True \ No newline at end of file diff --git a/README.md b/README.md index 3650c9f..92f6955 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ -# MiniValidation +# MiniValidationPlus + +👉 with support of non-nullable reference types. + A minimalistic validation library built atop the existing features in .NET's `System.ComponentModel.DataAnnotations` namespace. Adds support for single-line validation calls and recursion with cycle detection. +This project is fork of the original great repo [MiniValidation](https://github.com/DamianEdwards/MiniValidation) from [Damian Edwards](https://github.com/DamianEdwards) and adds support of **non-nullable reference types**. Now validation works more like validation in model binding of ASP.NET Core MVC. + Supports .NET Standard 2.0 compliant runtimes. ## Installation -[![Nuget](https://img.shields.io/nuget/v/MiniValidation)](https://www.nuget.org/packages/MiniValidation/) +[![Nuget](https://img.shields.io/nuget/v/MiniValidationPlus)](https://www.nuget.org/packages/MiniValidationPlus/) -Install the library from [NuGet](https://www.nuget.org/packages/MiniValidation): +Install the library from [NuGet](https://www.nuget.org/packages/MiniValidationPlus): ``` console -❯ dotnet add package MiniValidation +❯ dotnet add package MiniValidationPlus ``` ### ASP.NET Core 6+ Projects @@ -24,12 +29,15 @@ If installing into an ASP.NET Core 6+ project, consider using the [MinimalApis.E ```csharp var widget = new Widget { Name = "" }; -var isValid = MiniValidator.TryValidate(widget, out var errors); +var isValid = MiniValidatorPlus.TryValidate(widget, out var errors); class Widget { [Required, MinLength(3)] public string Name { get; set; } + + // Non-nullable reference types are required automatically + public string Category { get; set; } public override string ToString() => Name; } @@ -42,12 +50,15 @@ var widget = new Widget { Name = "" }; // Get your serviceProvider from wherever makes sense var serviceProvider = ... -var isValid = MiniValidator.TryValidate(widget, serviceProvider, out var errors); +var isValid = MiniValidatorPlus.TryValidate(widget, serviceProvider, out var errors); class Widget : IValidatableObject { [Required, MinLength(3)] public string Name { get; set; } + + // Non-nullable reference types are required automatically + public string Category { get; set; } public override string ToString() => Name; @@ -72,19 +83,19 @@ class Widget : IValidatableObject ```csharp using System.ComponentModel.DataAnnotations; -using MiniValidation; +using MiniValidationPlus; -var title = args.Length > 0 ? args[0] : ""; +var nameAndCategory = args.Length > 0 ? args[0] : ""; var widgets = new List { - new Widget { Name = title }, - new WidgetWithCustomValidation { Name = title } + new Widget { Name = nameAndCategory, Category = nameAndCategory }, + new WidgetWithCustomValidation { Name = nameAndCategory, Category = nameAndCategory } }; foreach (var widget in widgets) { - if (!MiniValidator.TryValidate(widget, out var errors)) + if (!MiniValidatorPlus.TryValidate(widget, out var errors)) { Console.WriteLine($"{nameof(Widget)} has errors!"); foreach (var entry in errors) @@ -106,6 +117,9 @@ class Widget { [Required, MinLength(3)] public string Name { get; set; } + + // Non-nullable reference types are required automatically + public string Category { get; set; } public override string ToString() => Name; } @@ -123,18 +137,24 @@ class WidgetWithCustomValidation : Widget, IValidatableObject ``` ``` console ❯ widget.exe -Widget 'widget' is valid! Widget has errors! Name: - - Cannot name a widget 'widget'. + - The Widget name field is required. + Category: + - The Category field is required. +Widget has errors! + Name: + - The Widget name field is required. + Category: + - The Category field is required. ❯ widget.exe Ok Widget has errors! Name: - - The field Name must be a string or array type with a minimum length of '3'. + - The field Widget name must be a string or array type with a minimum length of '3'. Widget has errors! Name: - - The field Name must be a string or array type with a minimum length of '3'. + - The field Widget name must be a string or array type with a minimum length of '3'. ❯ widget.exe Widget Widget 'Widget' is valid! @@ -142,15 +162,15 @@ Widget has errors! Name: - Cannot name a widget 'Widget'. -❯ widget.exe MiniValidation -Widget 'MiniValidation' is valid! -Widget 'MiniValidation' is valid! +❯ widget.exe MiniValidationPlus +Widget 'MiniValidationPlus' is valid! +Widget 'MiniValidationPlus' is valid! ``` ### Web app (.NET 6) ```csharp using System.ComponentModel.DataAnnotations; -using MiniValidation; +using MiniValidationPlus; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); @@ -167,12 +187,12 @@ app.MapGet("/widgets/{name}", (string name) => new Widget { Name = name }); app.MapPost("/widgets", (Widget widget) => - !MiniValidator.TryValidate(widget, out var errors) + !MiniValidatorPlus.TryValidate(widget, out var errors) ? Results.ValidationProblem(errors) : Results.Created($"/widgets/{widget.Name}", widget)); app.MapPost("/widgets/custom-validation", (WidgetWithCustomValidation widget) => - !MiniValidator.TryValidate(widget, out var errors) + !MiniValidatorPlus.TryValidate(widget, out var errors) ? Results.ValidationProblem(errors) : Results.Created($"/widgets/{widget.Name}", widget)); diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 20e41e7..2885c8b 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -1,10 +1,10 @@ -MiniValidation uses third-party libraries or other resources that may be -distributed under licenses different than the MiniValidation software. +MiniValidationPlus uses third-party libraries or other resources that may be +distributed under licenses different from the MiniValidationPlus software. In the event that I accidentally failed to list a required notice, please bring it to my attention. Post an issue or email me: - damian@damianedwards.com + lubos@luboshladik.cz The attached notices are provided for information only. diff --git a/samples/Samples.Console/Program.cs b/samples/Samples.Console/Program.cs index 0e23023..0e9212d 100644 --- a/samples/Samples.Console/Program.cs +++ b/samples/Samples.Console/Program.cs @@ -1,12 +1,12 @@ using System.ComponentModel.DataAnnotations; using MiniValidation; -var title = args.Length > 0 ? args[0] : ""; +var nameAndCategory = args.Length > 0 ? args[0] : null; var widgets = new List { - new Widget { Name = title }, - new WidgetWithCustomValidation { Name = title } + new Widget { Name = nameAndCategory, Category = nameAndCategory }, + new WidgetWithCustomValidation { Name = nameAndCategory, Category = nameAndCategory } }; var allValid = true; @@ -37,6 +37,9 @@ class Widget { [Required, MinLength(3), Display(Name = "Widget name")] public string Name { get; set; } + + // Non-nullable reference types are required automatically + public string Category { get; set; } public override string ToString() => Name; } diff --git a/samples/Samples.Console/Properties/launchSettings.json b/samples/Samples.Console/Properties/launchSettings.json index 0fe5370..b98aaca 100644 --- a/samples/Samples.Console/Properties/launchSettings.json +++ b/samples/Samples.Console/Properties/launchSettings.json @@ -1,8 +1,7 @@ { "profiles": { "Samples.Console": { - "commandName": "Project", - "commandLineArgs": "widget" + "commandName": "Project" } } } \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5cac6aa..8b4329c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,18 +1,18 @@ - 0.9.1 + 0.0.1 dev - ci.$(BuildNumber) + - Damian Edwards - Copyright © Damian Edwards + Lubos Hladik + Copyright © Lubos Hladik MIT - https://github.com/DamianEdwards/MiniValidation - https://github.com/DamianEdwards/MiniValidation + https://github.com/luboshl/MiniValidationPlus + https://github.com/luboshl/MiniValidationPlus git true true diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index ca7c3b6..c1fea2e 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -44,7 +44,8 @@ public static bool RequiresValidation(Type targetType, bool recurse = true) return typeof(IValidatableObject).IsAssignableFrom(targetType) || typeof(IAsyncValidatableObject).IsAssignableFrom(targetType) || (recurse && typeof(IEnumerable).IsAssignableFrom(targetType)) - || _typeDetailsCache.Get(targetType).Properties.Any(p => p.HasValidationAttributes || recurse); + || _typeDetailsCache.Get(targetType).Properties + .Any(p => p.HasValidationAttributes || p.IsNonNullableType || recurse); } /// @@ -397,25 +398,44 @@ private static async Task TryValidateImpl( var propertyValueType = propertyValue?.GetType(); var (properties, _) = _typeDetailsCache.Get(propertyValueType); + validationResults ??= new(); + var isPropertyValid = true; + if (property.HasValidationAttributes) { validationContext.MemberName = property.Name; validationContext.DisplayName = GetDisplayName(property); - validationResults ??= new(); - var propertyIsValid = Validator.TryValidateValue(propertyValue!, validationContext, validationResults, property.ValidationAttributes); - if (!propertyIsValid) + isPropertyValid = Validator.TryValidateValue(propertyValue!, validationContext, validationResults, property.ValidationAttributes); + } + + if (property.IsNonNullableType) + { + validationContext.MemberName = property.Name; + validationContext.DisplayName = GetDisplayName(property); + + if (propertyValue is null) { - ProcessValidationResults(property.Name, validationResults, workingErrors, prefix); - isValid = false; + validationResults.Add(new ValidationResult($"The {validationContext.DisplayName} field is required.", new[] { property.Name })); + isPropertyValid = false; } } - if (recurse && propertyValue is not null && - (property.Recurse - || typeof(IValidatableObject).IsAssignableFrom(propertyValueType) - || typeof(IAsyncValidatableObject).IsAssignableFrom(propertyValueType) - || properties.Any(p => p.Recurse))) + if (!isPropertyValid) + { + ProcessValidationResults(property.Name, validationResults, workingErrors, prefix); + isValid = false; + } + + if (recurse + && propertyValue is not null + && (property.Recurse + /* + || typeof(IValidatableObject).IsAssignableFrom(propertyValueType) + || typeof(IAsyncValidatableObject).IsAssignableFrom(propertyValueType) + || properties.Any(p => p.Recurse) + */ + )) { propertiesToRecurse!.Add(property, propertyValue); } @@ -598,9 +618,11 @@ private static async Task TryValidateEnumerable( { break; } + index++; } } + return isValid; } @@ -615,12 +637,12 @@ private static IDictionary MapToFinalErrorsResult(Dictionary valid { errors.Add(key, new()); } + errors[key].Add(result.ErrorMessage ?? ""); hasMemberNames = true; } @@ -651,6 +674,7 @@ private static void ProcessValidationResults(IEnumerable valid { errors.Add(key, new()); } + errors[key].Add(result.ErrorMessage ?? ""); } } diff --git a/src/MiniValidation/NonNullablePropertyHelper.cs b/src/MiniValidation/NonNullablePropertyHelper.cs new file mode 100644 index 0000000..0b569e8 --- /dev/null +++ b/src/MiniValidation/NonNullablePropertyHelper.cs @@ -0,0 +1,31 @@ +#if NET6_0_OR_GREATER + +using System.Reflection; + +namespace MiniValidation +{ + /// + /// Helper for non-nullable reference types. + /// + public static class NonNullablePropertyHelper + { + private static readonly NullabilityInfoContext NullabilityContext = new (); + + /// + /// Gets information whether the is non-nullable reference type. + /// + /// The property. + /// True when is non-nullable reference type, False otherwise. + public static bool IsNonNullableReferenceType(PropertyInfo propertyInfo) + { + if (propertyInfo.PropertyType.IsValueType) + { + return false; + } + + var nullabilityInfo = NullabilityContext.Create(propertyInfo); + return nullabilityInfo.WriteState is not NullabilityState.Nullable; + } + } +} +#endif diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index aee8fb2..9928084 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -4,8 +4,10 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using System.Reflection; +using System.Threading; namespace MiniValidation; @@ -87,10 +89,17 @@ private void Visit(Type type, HashSet visited, ref bool requiresAsync) var (validationAttributes, displayAttribute, skipRecursionAttribute) = TypeDetailsCache.GetPropertyAttributes(primaryCtorParams, property); validationAttributes ??= Array.Empty(); - var hasValidationOnProperty = validationAttributes.Length > 0; + +#if NET6_0_OR_GREATER + var isNonNullableReferenceType = NonNullablePropertyHelper.IsNonNullableReferenceType(property); +#else + var isNonNullableReferenceType = false; +#endif + + var hasValidationOnProperty = validationAttributes.Length > 0 || isNonNullableReferenceType; var hasSkipRecursionOnProperty = skipRecursionAttribute is not null; var enumerableType = GetEnumerableType(property.PropertyType); - if (enumerableType != null) + if (enumerableType != null && property.PropertyType != typeof(string)) { Visit(enumerableType, visited, ref requiresAsync); } @@ -100,7 +109,16 @@ private void Visit(Type type, HashSet visited, ref bool requiresAsync) if (type == property.PropertyType && !hasSkipRecursionOnProperty) { propertiesToValidate ??= new List(); - propertiesToValidate.Add(new(property.Name, displayAttribute, property.PropertyType, PropertyHelper.MakeNullSafeFastPropertyGetter(property), validationAttributes, true, enumerableType)); + propertiesToValidate.Add( + new PropertyDetails( + property.Name, + displayAttribute, + property.PropertyType, + PropertyHelper.MakeNullSafeFastPropertyGetter(property), + validationAttributes, + true, + enumerableType, + isNonNullableReferenceType)); hasPropertiesOfOwnType = true; continue; } @@ -118,10 +136,19 @@ private void Visit(Type type, HashSet visited, ref bool requiresAsync) || propertyTypeSupportsPolymorphism) && !hasSkipRecursionOnProperty; - if (recurse || hasValidationOnProperty) + if (recurse || hasValidationOnProperty || isNonNullableReferenceType) { propertiesToValidate ??= new List(); - propertiesToValidate.Add(new(property.Name, displayAttribute, property.PropertyType, PropertyHelper.MakeNullSafeFastPropertyGetter(property), validationAttributes, recurse, enumerableTypeHasProperties ? enumerableType : null)); + propertiesToValidate.Add( + new PropertyDetails( + property.Name, + displayAttribute, + property.PropertyType, + PropertyHelper.MakeNullSafeFastPropertyGetter(property), + validationAttributes, + recurse, + enumerableTypeHasProperties ? enumerableType : null, + isNonNullableReferenceType)); hasValidatableProperties = true; } } @@ -160,7 +187,17 @@ private static bool DoNotRecurseIntoPropertiesOf(Type type) => || type == typeof(DateOnly) || type == typeof(TimeOnly) #endif - ; + || type == typeof(Type) + || type == typeof(Delegate) + || type == typeof(MethodInfo) + || type == typeof(MemberInfo) + || type == typeof(ParameterInfo) + || type == typeof(Assembly) + || type == typeof(Uri) + || type == typeof(CancellationToken) + || type == typeof(Stream) + // TODO: Add extension point to add other types to ignore + ; private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribute?) GetPropertyAttributes(ParameterInfo[]? primaryCtorParameters, PropertyInfo property) { @@ -251,7 +288,16 @@ private static bool TryGetAttributesViaTypeDescriptor(PropertyInfo property, [No } } -internal record PropertyDetails(string Name, DisplayAttribute? DisplayAttribute, Type Type, Func PropertyGetter, ValidationAttribute[] ValidationAttributes, bool Recurse, Type? EnumerableType) +internal record PropertyDetails( + string Name, + DisplayAttribute? DisplayAttribute, + Type Type, + Func PropertyGetter, + ValidationAttribute[] ValidationAttributes, + bool Recurse, + Type? EnumerableType, + bool IsNonNullableType +) { public object? GetValue(object target) => PropertyGetter(target); diff --git a/tests/MiniValidation.Benchmarks/Program.cs b/tests/MiniValidation.Benchmarks/Program.cs index e4ad3bd..c67b6a5 100644 --- a/tests/MiniValidation.Benchmarks/Program.cs +++ b/tests/MiniValidation.Benchmarks/Program.cs @@ -76,7 +76,7 @@ public class TodoWithNoValidation { public int Id { get; set; } - public string Title { get; set; } = default!; + public string? Title { get; set; } = default!; public bool IsComplete { get; set; } } diff --git a/tests/MiniValidation.UnitTests/NonNullablePropertyHelperTests.cs b/tests/MiniValidation.UnitTests/NonNullablePropertyHelperTests.cs new file mode 100644 index 0000000..9c22f48 --- /dev/null +++ b/tests/MiniValidation.UnitTests/NonNullablePropertyHelperTests.cs @@ -0,0 +1,104 @@ +#if NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace MiniValidation.UnitTests; + +public class NonNullablePropertyHelperTests +{ + [Fact] + public void IsNonNullableReferenceType_Identifies_Correct_Properties_Of_Class() + { + var type = typeof(ClassModel); + + var nonNullableReferenceTypes = new List(); + var other = new List(); + + foreach (var property in type.GetProperties(BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.FlattenHierarchy)) + { + var isNonNullableReferenceType = NonNullablePropertyHelper.IsNonNullableReferenceType(property); + if (isNonNullableReferenceType) + { + nonNullableReferenceTypes.Add(property.Name); + } + else + { + other.Add(property.Name); + } + } + + Assert.Contains(nameof(ClassModel.StringNonNullable), nonNullableReferenceTypes); + Assert.Contains(nameof(ClassModel.AnotherNonNullable), nonNullableReferenceTypes); + + Assert.Contains(nameof(ClassModel.IntNonNullable), other); + Assert.Contains(nameof(ClassModel.IntNullable), other); + Assert.Contains(nameof(ClassModel.StringNullable), other); + Assert.Contains(nameof(ClassModel.AnotherNullable), other); + } + + [Fact] + public void IsNonNullableReferenceType_Identifies_Correct_Properties_Of_Record() + { + var type = typeof(RecordModel); + + var nonNullableReferenceTypes = new List(); + var other = new List(); + + foreach (var property in type.GetProperties(BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.FlattenHierarchy)) + { + var isNonNullableReferenceType = NonNullablePropertyHelper.IsNonNullableReferenceType(property); + if (isNonNullableReferenceType) + { + nonNullableReferenceTypes.Add(property.Name); + } + else + { + other.Add(property.Name); + } + } + + Assert.Contains(nameof(RecordModel.StringNonNullable), nonNullableReferenceTypes); + Assert.Contains(nameof(RecordModel.AnotherNonNullable), nonNullableReferenceTypes); + + Assert.Contains(nameof(RecordModel.IntNonNullable), other); + Assert.Contains(nameof(RecordModel.IntNullable), other); + Assert.Contains(nameof(RecordModel.StringNullable), other); + Assert.Contains(nameof(RecordModel.AnotherNullable), other); + } + + private class ClassModel + { + public int IntNonNullable { get; set; } + public int? IntNullable { get; set; } + public string StringNonNullable { get; set; } = null!; + public string? StringNullable { get; set; } + public AnotherModel AnotherNonNullable { get; set; } = new(); + public AnotherModel? AnotherNullable { get; set; } + } + + [SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Local")] + private record RecordModel( + int IntNonNullable, + int? IntNullable, + string StringNonNullable, + string? StringNullable, + AnotherModel AnotherNonNullable, + AnotherModel? AnotherNullable); + + [SuppressMessage("ReSharper", "UnusedMember.Local")] + // ReSharper disable once FunctionRecursiveOnAllPaths + private class AnotherModel + { + public int IntNonNullable { get; set; } + public int? IntNullable { get; set; } + public string StringNonNullable { get; set; } = null!; + public string? StringNullable { get; set; } + public AnotherModel AnotherNonNullable { get; set; } = new(); + public AnotherModel? AnotherNullable { get; set; } + } +} +#endif diff --git a/tests/MiniValidation.UnitTests/TestTypes.cs b/tests/MiniValidation.UnitTests/TestTypes.cs index 24dc04c..b8d057a 100644 --- a/tests/MiniValidation.UnitTests/TestTypes.cs +++ b/tests/MiniValidation.UnitTests/TestTypes.cs @@ -4,6 +4,8 @@ namespace MiniValidation.UnitTests; class TestType { + public string NonNullableString { get; set; } = "Default"; + [Required] public string? RequiredName { get; set; } = "Default"; @@ -235,11 +237,15 @@ interface IAnInterface { } #if NET6_0_OR_GREATER abstract record BaseRecordType(string Type); -record TestRecordType([Required, Display(Name = "Required name")] string RequiredName = "Default", [Range(10, 100)] int TenOrMore = 10) +record TestRecordType( + [Required, Display(Name = "Required name")] + string RequiredName = "Default", + [Range(10, 100)] int TenOrMore = 10, + string NonNullableString = "Default") : BaseRecordType(nameof(TestRecordType)) { #pragma warning disable IDE0060 // Remove unused parameter - public TestRecordType(string anotherParam, bool doTheThing) : this("Another name", 23) + public TestRecordType(string anotherParam, bool doTheThing) : this("Another name", 23, "Another string value") #pragma warning restore IDE0060 // Remove unused parameter { } diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index e7713d3..e6fad0d 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -95,6 +95,73 @@ public void NonRequiredValidator_Invalid_When_Invalid_On_Record() Assert.False(result); Assert.Single(errors); } + + [Fact] + public void NonNullable_Invalid_When_Null() + { + var thingToValidate = new TestType { NonNullableString = null! }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.False(result); + var entry = Assert.Single(errors); + Assert.Equal(nameof(TestType.NonNullableString), entry.Key); + } + + [Fact] + public void NonNullable_Valid_When_Empty() + { + var thingToValidate = new TestType { NonNullableString = string.Empty }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.True(result); + Assert.Empty(errors); + } + + [Fact] + public void NonNullable_Valid_When_NonEmpty_Value() + { + var thingToValidate = new TestType { NonNullableString = "test" }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.True(result); + Assert.Empty(errors); + } + + [Fact] + public void NonNullable_Valid_When_Empty_On_Record() + { + var thingToValidate = new TestRecordType(NonNullableString: string.Empty); + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.True(result); + Assert.Empty(errors); + } + + [Fact] + public void NonNullable_Valid_When_NonEmpty_On_Record() + { + var thingToValidate = new TestRecordType(NonNullableString: "test"); + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.True(result); + Assert.Empty(errors); + } + + [Fact] + public void NonNullable_Invalid_When_Null_On_Record() + { + var thingToValidate = new TestRecordType(NonNullableString: null!); + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.False(result); + Assert.Single(errors); + } #endif [Fact]