fix(validation): guard null comparison bounds in comparison builders

Passing a null bound to GreaterThan/LessThan/Between etc. called
value.CompareTo(null), throwing NullReferenceException for non-null
reference-type values. Short-circuit when the bound is null so the rule is
satisfied instead of crashing.
This commit is contained in:
Louis Seubert 2026-07-12 18:42:07 +02:00
commit fd6ed64755
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
4 changed files with 92 additions and 5 deletions

View file

@ -51,6 +51,7 @@ To have a consistent experience across all packages, some public interfaces have
- **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result``Geekeey.Request.Result`) - **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result``Geekeey.Request.Result`)
- **request.result:** Fold `IsSuccess` into `Result<T>.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0` - **request.result:** Fold `IsSuccess` into `Result<T>.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0`
- **request.validation:** Guard null comparison bounds in `GreaterThan`/`LessThan`/`Between` etc. so a `null` bound no longer throws (`NullReferenceException`)
### Removed ### Removed

View file

@ -314,6 +314,71 @@ internal sealed class RuleBuilderExtensionsTests
.Throws<ArgumentOutOfRangeException>(); .Throws<ArgumentOutOfRangeException>();
} }
[Test]
public async Task I_can_validate_greater_than_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.GreaterThan(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_greater_than_or_equal_to_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.GreaterThanOrEqualTo(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.LessThan(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_or_equal_to_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.LessThanOrEqualTo(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_between_with_null_bounds_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.Between(null, null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test] [Test]
public async Task I_can_see_it_throw_for_invalid_between_configuration() public async Task I_can_see_it_throw_for_invalid_between_configuration()
{ {

View file

@ -0,0 +1,19 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class ComparableModel
{
public ComparableValue? Value { get; init; }
}
internal sealed class ComparableValue : IComparable<ComparableValue?>
{
public int Number { get; init; }
public int CompareTo(ComparableValue? other)
{
return Number.CompareTo(other!.Number);
}
}

View file

@ -170,7 +170,7 @@ public static class RuleBuilderExtensions
{ {
ArgumentNullException.ThrowIfNull(rule); ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0, return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) > 0,
$"Value must be greater than {comparisonValue}."); $"Value must be greater than {comparisonValue}.");
} }
@ -189,7 +189,7 @@ public static class RuleBuilderExtensions
{ {
ArgumentNullException.ThrowIfNull(rule); ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0, return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) >= 0,
$"Value must be greater than or equal to {comparisonValue}."); $"Value must be greater than or equal to {comparisonValue}.");
} }
@ -208,7 +208,7 @@ public static class RuleBuilderExtensions
{ {
ArgumentNullException.ThrowIfNull(rule); ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0, return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) < 0,
$"Value must be less than {comparisonValue}."); $"Value must be less than {comparisonValue}.");
} }
@ -227,7 +227,7 @@ public static class RuleBuilderExtensions
{ {
ArgumentNullException.ThrowIfNull(rule); ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0, return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) <= 0,
$"Value must be less than or equal to {comparisonValue}."); $"Value must be less than or equal to {comparisonValue}.");
} }
@ -254,7 +254,9 @@ public static class RuleBuilderExtensions
"Maximum value must be greater than or equal to minimum value."); "Maximum value must be greater than or equal to minimum value.");
} }
return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0), return rule.Must(value => IsNull(value)
|| ((minValue is null || value.CompareTo(minValue) >= 0)
&& (maxValue is null || value.CompareTo(maxValue) <= 0)),
$"Value must be between {minValue} and {maxValue}."); $"Value must be between {minValue} and {maxValue}.");
} }