Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add validation functionality #14

Merged
merged 10 commits into from
Mar 8, 2024
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
10 changes: 8 additions & 2 deletions Dbosoft.Functional.sln
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30907.101
# Visual Studio Version 17
VisualStudioVersion = 17.9.34622.214
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dbosoft.Functional", "src\Dbosoft.Functional\Dbosoft.Functional.csproj", "{D38F1C28-A069-4E32-81FA-1F3F1C7F3619}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dbosoft.Functional.Tests", "test\Dbosoft.Functional.Tests\Dbosoft.Functional.Tests.csproj", "{ADA73324-0DE5-479D-A63B-888EB922984E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -15,6 +17,10 @@ Global
{D38F1C28-A069-4E32-81FA-1F3F1C7F3619}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D38F1C28-A069-4E32-81FA-1F3F1C7F3619}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D38F1C28-A069-4E32-81FA-1F3F1C7F3619}.Release|Any CPU.Build.0 = Release|Any CPU
{ADA73324-0DE5-479D-A63B-888EB922984E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ADA73324-0DE5-479D-A63B-888EB922984E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ADA73324-0DE5-479D-A63B-888EB922984E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ADA73324-0DE5-479D-A63B-888EB922984E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
24 changes: 22 additions & 2 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,34 @@ trigger:
pool:
name: 'default'

variables:
buildConfiguration: 'Release'

steps:
- script: dotnet restore
- task: DotNetCoreCLI@2
inputs:
command: restore

- task: DotNetCoreCLI@2
displayName: Build
inputs:
command: build
projects: '**/*.csproj'
arguments: '--configuration Release --output $(build.artifactstagingdirectory)'
arguments: '--configuration $(buildConfiguration)'

- task: DotNetCoreCLI@2
displayName: dotnet test
inputs:
command: test
projects: '**/*Tests/*.csproj'
arguments: '--configuration $(buildConfiguration) --collect "Code coverage" --no-build'

- task: DotNetCoreCLI@2
displayName: dotnet pack
inputs:
command: pack
arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory) --no-build'

- task: PublishBuildArtifacts@1
inputs:
ArtifactName: drop
Expand Down
65 changes: 65 additions & 0 deletions src/Dbosoft.Functional/DataTypes/ValidatingNewType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using LanguageExt.ClassInstances.Pred;
using LanguageExt.Common;
using LanguageExt.TypeClasses;
using LanguageExt;
using System;
using System.Collections.Generic;
using System.Text;
using static LanguageExt.Prelude;

namespace Dbosoft.Functional.DataTypes;

/// <summary>
/// This class extends the LanguageExt <see cref="NewType{NEWTYPE, A, PRED, ORD}"/>
/// for support for validation using <see cref="Validation{FAIL,SUCCESS}"/>.
/// </summary>
public abstract class ValidatingNewType<NEWTYPE, A, ORD> : NewType<NEWTYPE, A, True<A>, ORD>
where NEWTYPE : ValidatingNewType<NEWTYPE, A, ORD>
where ORD : struct, Ord<A>
{
protected ValidatingNewType(A value) : base(value) { }

public static Validation<Error, NEWTYPE> NewValidation(A value) =>
NewTry(value).Match(
Succ: Success<Error, NEWTYPE>,
Fail: ex => ex switch
{
ValidationException<NEWTYPE> vex => Fail<Error, NEWTYPE>(vex.Errors),
ArgumentNullException _ => Fail<Error, NEWTYPE>(Error.New("The value cannot be null.")),
_ => Fail<Error, NEWTYPE>(Error.New(ex))
});

public static Either<Error, NEWTYPE> NewEither(A value) =>
NewValidation(value).ToEither().MapLeft(
errors => Error.New($"The value is not a valid {typeof(NEWTYPE).Name}.", Error.Many(errors)));

/// <summary>
/// Subclasses should use this method in their constructors to validate the value.
/// </summary>
/// <exception cref="ValidationException{NEWTYPE}">
/// Thrown when the validation has failed.
/// </exception>
protected static T ValidOrThrow<T>(Validation<Error, T> validation) =>
validation.Match(
Succ: identity,
Fail: errors => throw new ValidationException<NEWTYPE>(errors));

/// <summary>
/// This exception is thrown by <see cref="ValidatingNewType{NEWTYPE, A, ORD}"/>
/// when the validation has failed.
/// </summary>
/// <typeparam name="T"></typeparam>
public sealed class ValidationException<T> : Exception
{
internal ValidationException(Seq<Error> errors)
: base($"The value is not a valid {typeof(T).Name}: {errors.ToFullArrayString()}")
{
Errors = errors;
}

/// <summary>
/// The actual validation errors.
/// </summary>
public Seq<Error> Errors { get; }
}
}
5 changes: 2 additions & 3 deletions src/Dbosoft.Functional/Dbosoft.Functional.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>10</LangVersion>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down Expand Up @@ -30,13 +31,11 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.*" PrivateAssets="All"/>

<PackageReference Include="GitVersion.MsBuild" Version="5.*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="LanguageExt.Core" Version="4.4.*" />
<PackageReference Include="LanguageExt.Core" Version="[4.4.0,4.5.0)" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="[5,)" />
</ItemGroup>

Expand Down
145 changes: 145 additions & 0 deletions src/Dbosoft.Functional/Validations/ComplexValidations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using LanguageExt.Common;
using LanguageExt;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Text;
using static LanguageExt.Prelude;

namespace Dbosoft.Functional.Validations;

#nullable enable

/// <summary>
/// These functions can be used to validate complex objects. They
/// automate the process of the descending through an object tree
/// and collecting the validation issues.
/// </summary>
public static class ComplexValidations
{
public static Validation<ValidationIssue, Unit> ValidateProperty<T, TResult>(
T toValidate,
Expression<Func<T, string?>> getProperty,
Func<string, Validation<Error, TResult>> validate,
string path = "",
bool required = false) =>
ValidateProperty(
Optional(getProperty.Compile().Invoke(toValidate)).Filter(notEmpty),
v => validate(v).Map(_ => unit)
.MapFail(e => new ValidationIssue(JoinPath(path, getProperty), e.Message)),
path,
GetPropertyName(getProperty),
required);

public static Validation<ValidationIssue, Unit> ValidateProperty<T, TProperty, TResult>(
T toValidate,
Expression<Func<T, TProperty?>> getProperty,
Func<TProperty, Validation<Error, TResult>> validate,
string path = "",
bool required = false) where TProperty :struct
{
var value = getProperty.Compile().Invoke(toValidate);
return ValidateProperty(
value.HasValue ? Some(value.Value) : None,
v => validate(v).Map(_ => unit)
.MapFail(e => new ValidationIssue(JoinPath(path, getProperty), e.Message)),
path,
GetPropertyName(getProperty),
required);
}

public static Validation<ValidationIssue, Unit> ValidateProperty<T, TProperty, TResult>(
T toValidate,
Expression<Func<T, TProperty?>> getProperty,
Func<TProperty, Validation<Error, TResult>> validate,
string path = "",
bool required = false) =>
ValidateProperty(
Optional(getProperty.Compile().Invoke(toValidate)),
v => validate(v).Map(_ => unit)
.MapFail(e => new ValidationIssue(JoinPath(path, getProperty), e.Message)),
path,
GetPropertyName(getProperty),
required);

public static Validation<ValidationIssue, Unit> ValidateProperty<T, TProperty>(
T toValidate,
Expression<Func<T, TProperty?>> getProperty,
Func<TProperty, string, Validation<ValidationIssue, Unit>> validate,
string path = "",
bool required = false) =>
ValidateProperty(
Optional(getProperty.Compile().Invoke(toValidate)),
v => validate(v, JoinPath(path, getProperty)),
path,
GetPropertyName(getProperty),
required);

private static Validation<ValidationIssue, Unit> ValidateProperty<TProperty>(
Option<TProperty> value,
Func<TProperty, Validation<ValidationIssue, Unit>> validate,
string path,
string propertyName,
bool required) =>
value.Match(
Some: validate,
None: () => required
? Fail<ValidationIssue, Unit>(
new ValidationIssue(JoinPath(path, propertyName),
$"The {propertyName} is required."))
: Success<ValidationIssue, Unit>(unit));

public static Validation<ValidationIssue, Unit> ValidateList<T, TProperty>(
T toValidate,
Expression<Func<T, IEnumerable<TProperty?>?>> getList,
Func<TProperty, string, Validation<ValidationIssue, Unit>> validate,
string path = "",
Option<int> minCount = default,
Option<int> maxCount = default) =>
ValidateList(
getList.Compile().Invoke(toValidate).ToSeq(),
getList,
validate,
path,
minCount,
maxCount);

private static Validation<ValidationIssue, Unit> ValidateList<T, TProperty>(
Seq<TProperty?> list,
Expression<Func<T, IEnumerable<TProperty?>?>> getList,
Func<TProperty, string, Validation<ValidationIssue, Unit>> validate,
string path = "",
Option<int> minCount = default,
Option<int> maxCount = default) =>
match(minCount.Filter(c => c > list.Count),
Some: c => Fail<ValidationIssue, Unit>(
new ValidationIssue(JoinPath(path, getList), $"The list must have {c} or more entries.")),
None: () => Success<ValidationIssue, Unit>(unit))
| match(maxCount.Filter(c => c < list.Count),
Some: c => Fail<ValidationIssue, Unit>(
new ValidationIssue(JoinPath(path, getList), $"The list must have {c} or fewer entries.")),
None: () => Success<ValidationIssue, Unit>(unit))
| list.Map((index, listItem) =>
from li in Optional(listItem).ToValidation(
new ValidationIssue($"{JoinPath(path, getList)}[{index}]", "The entry must not be null."))
from _ in validate(li, $"{JoinPath(path, getList)}[{index}]")
select unit)
.Fold(Success<ValidationIssue, Unit>(unit), (acc, listItem) => acc | listItem);

private static string JoinPath<T, TProperty>(string path, Expression<Func<T, TProperty?>> getProperty) =>
notEmpty(path) ? $"{path}.{GetPropertyName(getProperty)}" : GetPropertyName(getProperty);

private static string JoinPath(string path, string propertyName) =>
notEmpty(path) ? $"{path}.{propertyName}" : propertyName;

private static string GetPropertyName<T, TProperty>(Expression<Func<T, TProperty?>> getProperty) =>
getProperty.Body switch
{
MemberExpression memberExpression => memberExpression.Member.Name,
_ => throw new ArgumentException("The expression must access and return a class member.",
nameof(getProperty))
};
}

#nullable restore
46 changes: 46 additions & 0 deletions src/Dbosoft.Functional/Validations/ValidationIssue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using LanguageExt.Common;
using System;
using System.Collections.Generic;
using System.Text;

namespace Dbosoft.Functional.Validations;

/// <summary>
/// Represents an issue which was detected during a validation.
/// </summary>
public readonly struct ValidationIssue
{
private readonly string _member;
private readonly string _message;

/// <summary>
/// Creates a new validation issue.
/// </summary>
public ValidationIssue(string member, string message)
{
_member = member;
_message = message;
}

/// <summary>
/// The path to the member in the object tree which caused the issue,
/// e.g. <c>Participants[2].Name</c>.
/// </summary>
public string Member => _member;

/// <summary>
/// The description of the issue.
/// </summary>
public string Message => _message;

/// <summary>
/// Converts the issue to an <see cref="Error"/>.
/// </summary>
public Error ToError() => Error.New(ToString());

/// <summary>
/// Returns a string representation of the issue.
/// </summary>
public override string ToString() =>
string.IsNullOrWhiteSpace(_member) ? _message : $"{_member}: {_message}";
}
Loading