Skip to content

Commit

Permalink
MandatoryAttribute should allow nullable values with default of under…
Browse files Browse the repository at this point in the history
…lying type (#58)

* Extend Mandatory Attribute.
* Resolve nullables by using ValidationContext.
  • Loading branch information
Corniel Nobel committed Jul 9, 2019
1 parent 4933aa0 commit cae74ff
Show file tree
Hide file tree
Showing 12 changed files with 104 additions and 130 deletions.
13 changes: 6 additions & 7 deletions README.md
@@ -1,7 +1,6 @@
![Qowaiv](https://github.com/Qowaiv/Qowaiv/blob/master/design/qowaiv-logo_linkedin_100x060.jpg)

[![Build status](https://ci.appveyor.com/api/projects/status/j8o76flxqkh0o9fk?svg=true)](https://ci.appveyor.com/project/qowaiv/qowaiv)
![version](https://img.shields.io/badge/version-3.2.4-blue.svg?cacheSeconds=2592000)
![version](https://img.shields.io/badge/version-3.2.5-blue.svg?cacheSeconds=2592000)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Code of Conduct](https://img.shields.io/badge/%E2%9D%A4-code%20of%20conduct-blue.svg?style=flat)](https://github.com/Qowaiv/Qowaiv/blob/master/CODE_OF_CONDUCT.md)

Expand Down Expand Up @@ -259,11 +258,11 @@ public void TestSomething()
### Annotations
We're extending the DataAnnotations from Microsoft with some more attributes:

* [Mandatory] Here the difference with Microsoft's [Required] attribute is that it works for value types as well, it will be invalid if the default value is used.
* [AllowedValues] and
* [ForbiddenValues] make it easy to validate string values, or objects/value types that have a string representation.
* [Any] Tells that a collection should have at least one item.
* [DefinedEnumValuesOnly] limits the allowed enum values to those defined by the enum.
* [`Mandatory`] Here the difference with Microsoft's [`Required`] attribute is that it works for value types as well, it will be invalid if the default value is used.
* [`AllowedValues`] and
* [`ForbiddenValues`] make it easy to validate string values, or objects/value types that have a string representation.
* [`Any`] Tells that a collection should have at least one item.
* [`DefinedEnumValuesOnly`] limits the allowed enum values to those defined by the enum.

### Result model
Also we propose a Result model that includes the validation messages, and Result which can contain both an object and validation messages. This can be a helpful return type for methods that need to return objects but first have to validate them.
Expand Down
2 changes: 1 addition & 1 deletion props/version.props
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<VersionPrefix>3.2.5.2</VersionPrefix>
<VersionPrefix>3.2.5</VersionPrefix>
<VersionSuffix Condition="'$(VersionSuffix)'!='' AND '$(BuildNumber)' != ''"></VersionSuffix>
</PropertyGroup>
</Project>
50 changes: 44 additions & 6 deletions src/Qowaiv.ComponentModel/DataAnnotations/MandatoryAttribute.cs
@@ -1,34 +1,72 @@
using System;
using Qowaiv.ComponentModel.Messages;
using Qowaiv.Reflection;
using System;
using System.ComponentModel.DataAnnotations;

namespace Qowaiv.ComponentModel.DataAnnotations
{
/// <summary>Specifies that a field is mandatory (for value types the default is rejected).</summary>
/// <summary>Specifies that a field is mandatory (for value types the default value is not allowed).</summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)]
public sealed class MandatoryAttribute : RequiredAttribute
{
/// <summary>Gets or sets a value that indicates whether an empty string is allowed.</summary>
public bool AllowUnknownValue { get; set; }

/// <inheritdoc />
public override bool RequiresValidationContext => true;

/// <inheritdoc />
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
Guard.NotNull(validationContext, nameof(validationContext));

if (IsValid(value, GetMemberType(validationContext)))
{
return ValidationResult.Success;
}

var memberNames = validationContext.MemberName != null ? new[] { validationContext.MemberName } : null;
return ValidationMessage.Error(FormatErrorMessage(validationContext.DisplayName), memberNames);
}

/// <summary>Gets the type of the field/property.</summary>
/// <remarks>
/// Because the values of the member are boxed, this is (unfortunately)
/// the only way to determine if the provided value is a nullable type,
/// or not.
/// </remarks>
private Type GetMemberType(ValidationContext context)
{
if (string.IsNullOrEmpty(context.MemberName))
{
return null;
}
return context.ObjectType.GetProperty(context.MemberName)?.PropertyType
?? context.ObjectType.GetField(context.MemberName)?.FieldType;
}

/// <summary>Returns true if the value is not null and value types are
/// not equal to their default value, otherwise false.
/// </summary>
/// <remarks>
/// The unknown value is expected to be static field or property of the type with the name "Unknown".
/// </remarks>
public override bool IsValid(object value)
public override bool IsValid(object value) => IsValid(value, null);

private bool IsValid(object value, Type memberType)
{
if (value != null)
{
var type = value.GetType();
var type = memberType ?? value.GetType();
var underlyingType = QowaivType.GetNotNullableType(type);

if (!AllowUnknownValue && value.Equals(Unknown.Value(type)))
if (!AllowUnknownValue && value.Equals(Unknown.Value(underlyingType)))
{
return false;
}
if (type.IsValueType)
{
return !value.Equals(Activator.CreateInstance(value.GetType()));
return !value.Equals(Activator.CreateInstance(type));
}
}
return base.IsValid(value);
Expand Down
@@ -1,5 +1,7 @@
using NUnit.Framework;
using Qowaiv.ComponentModel.DataAnnotations;
using Qowaiv.ComponentModel.Messages;
using Qowaiv.TestTools.ComponentModel;
using System;

namespace Qowaiv.ComponentModel.Tests.DataAnnotations
Expand Down Expand Up @@ -52,5 +54,32 @@ public void IsValid_EmailAddressUnknown_False()
var act = attr.IsValid(EmailAddress.Unknown);
Assert.IsFalse(act);
}

[Test]
public void IsValid_MandatoryNullableProperty_IsValid()
{
var model = new MandatoryNullableProperty { Income = 0 };
DataAnnotationsAssert.IsValid(Result.For(model));
}

[Test]
public void IsValidNullableWithUnknownValue_IsInvalid()
{
var model = new MandatoryNullablePropertyWithUnknown { Gender = Gender.Unknown };
DataAnnotationsAssert.WithErrors(model, ValidationMessage.Error("The Gender field is required.", "Gender"));
}

internal class MandatoryNullableProperty
{
[Mandatory]
public decimal? Income { get; set; }
}

internal class MandatoryNullablePropertyWithUnknown
{
[Mandatory]
public Gender? Gender { get; set; }
}

}
}
@@ -1,7 +1,7 @@
using NUnit.Framework;
using Qowaiv.ComponentModel.Messages;
using Qowaiv.ComponentModel.Tests.TestTools;
using Qowaiv.TestTools;
using Qowaiv.TestTools.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace Qowaiv.ComponentModel.UnitTests
Expand All @@ -25,14 +25,7 @@ public void Serializable_ExceptionWithErrors_Successful()
Assert.AreEqual(expected.Message, actual.Message);
Assert.IsNull(actual.InnerException);

var actualErrors = actual.Errors.ForAssertion();

Assert.AreEqual(new[]
{
ValidationTestMessage.Error("Not a prime", "_value"),
ValidationTestMessage.Error("Not serializable", "this")
},
actualErrors);
ValidationResultAssert.SameMessages(expected.Errors, actual.Errors);
}
}
}

This file was deleted.

This file was deleted.

This file was deleted.

@@ -1,8 +1,9 @@
using NUnit.Framework;
using Qowaiv.ComponentModel.Tests.TestTools;
using Qowaiv.ComponentModel.Messages;
using Qowaiv.ComponentModel.UnitTests.Validation.Models;
using Qowaiv.ComponentModel.Validation;
using Qowaiv.Globalization;
using Qowaiv.TestTools.ComponentModel;
using System;

namespace Qowaiv.ComponentModel.Tests.Validation
Expand All @@ -17,8 +18,8 @@ public void Validate_ModelWithMandatoryProperties_WithErrors()
var model = new ModelWithMandatoryProperties();

DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("The E-mail address field is required.", "Email"),
ValidationTestMessage.Error("The SomeString field is required.", "SomeString")
ValidationMessage.Error("The E-mail address field is required.", "Email"),
ValidationMessage.Error("The SomeString field is required.", "SomeString")
);
}
}
Expand All @@ -42,7 +43,7 @@ public void Validate_ModelWithAllowedValues_WithError()
};

DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("The value of the Country field is not allowed.", "Country")
ValidationMessage.Error("The value of the Country field is not allowed.", "Country")
);
}
[Test]
Expand All @@ -60,7 +61,7 @@ public void Validate_ModelWithForbiddenValues_WithError()
Email = EmailAddress.Parse("spam@qowaiv.org"),
};
DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("The value of the Email field is not allowed.", "Email"));
ValidationMessage.Error("The value of the Email field is not allowed.", "Email"));
}
[Test]
public void Validate_ModelWithForbiddenValues_IsValid()
Expand Down Expand Up @@ -95,8 +96,8 @@ public void Validate_PostalCodeModelWithEmptyValues_With2Errors()
};

DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("The Country field is required.", "Country"),
ValidationTestMessage.Error("The PostalCode field is required.", "PostalCode")
ValidationMessage.Error("The Country field is required.", "Country"),
ValidationMessage.Error("The PostalCode field is required.", "PostalCode")
);
}

Expand All @@ -112,7 +113,7 @@ public void Validate_PostalCodeModelWithInvalidPostalCode_WithError()
};

DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("De postcode 2629JD is niet geldig voor België.", "PostalCode", "Country")
ValidationMessage.Error("De postcode 2629JD is niet geldig voor België.", "PostalCode", "Country")
);
}
}
Expand All @@ -132,7 +133,7 @@ public void Validate_PostalCodeModelWithErrorByService_WithError()
};

DataAnnotationsAssert.WithErrors(model, validator,
ValidationTestMessage.Error("Postal code does not exist.", "PostalCode")
ValidationMessage.Error("Postal code does not exist.", "PostalCode")
);
}

Expand All @@ -141,7 +142,7 @@ public void Validate_ModelWithCustomizedResource_WithError()
{
var model = new ModelWithCustomizedResource();
DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("This IBAN is wrong.", "Iban"));
ValidationMessage.Error("This IBAN is wrong.", "Iban"));
}

[Test]
Expand All @@ -152,7 +153,7 @@ public void Validate_NestedModelWithNullChild_With1Error()
Id = Guid.NewGuid()
};
DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("The Child field is required.", "Child"));
ValidationMessage.Error("The Child field is required.", "Child"));
}

[Test]
Expand All @@ -164,7 +165,7 @@ public void Validate_NestedModelWithInvalidChild_With1Error()
Child = new NestedModel.ChildModel()
};
DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("The Name field is required.", "Child.Name"));
ValidationMessage.Error("The Name field is required.", "Child.Name"));
}

[Test]
Expand All @@ -180,7 +181,7 @@ public void Validate_NestedModelWithInvalidChildren_With1Error()
}
};
DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("The Name field is required.", "Children[1].Name"));
ValidationMessage.Error("The Name field is required.", "Children[1].Name"));
}

[Test]
Expand All @@ -194,7 +195,7 @@ public void Validate_NestedModelWithLoop_With1Error()
model.Child.Parent = model;

DataAnnotationsAssert.WithErrors(model,
ValidationTestMessage.Error("The Name field is required.", "Child.Name"));
ValidationMessage.Error("The Name field is required.", "Child.Name"));
}
}
}
Expand Up @@ -19,6 +19,13 @@
<ProjectReference Include="..\..\tooling\Qowaiv.TestTools\Qowaiv.TestTools.csproj" />
</ItemGroup>


<ItemGroup>
<Reference Include="System.Data">
<HintPath>..\..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Data.dll</HintPath>
</Reference>
</ItemGroup>

<ItemGroup>
<Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" />
</ItemGroup>
Expand Down

0 comments on commit cae74ff

Please sign in to comment.