Skip to content
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
28 changes: 28 additions & 0 deletions Skill/references/numeric.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ All four types have `AsX` / `ToX` extension methods available on any
`string` or a `ReadOnlySpan<char>`, enforcing the invariant on the way in.
This is also what lets ASP.NET Core bind a wrapper from `[FromQuery]` /
`[FromRoute]` without any package.
- `IConvertible` (explicit) — `Convert.ToInt32(...)` / `Convert.ChangeType(...)`
reach the underlying value, which is what makes plain DataAnnotations
`[Range(1, 100)]` work (see "Validation attributes" below).
- `System.Text.Json` converter via `[JsonConverter]` — serialises as the
underlying primitive.
- `TypeConverter` via `[TypeConverter]` — binds from `appsettings.json` /
Expand Down Expand Up @@ -97,6 +100,31 @@ Read<Positive<int>>("42");
Render(price, "C");
```

## Validation attributes (DataAnnotations)

The wrapper enforces its sign invariant itself — at JSON deserialization,
parsing, and binding — so validation attributes are only for rules *beyond*
the invariant. Those compose on a nullable property:

```csharp
public class CreateOrder
{
[Required] // presence: null / missing fails validation
[Range(1, 100)] // narrowing: 150 is a valid Positive<int>, but not here
public Positive<int>? Quantity { get; set; }
}
```

- `[Required]` works on any nullable wrapper property (it checks for null;
an invalid value like `0` never gets that far — the converter rejects it
during binding).
- `[Range(min, max)]` reads the value through `IConvertible`, so the plain
numeric form works. The `[Range(typeof(Positive<int>), "1", "100")]`
overload works too and compares without unwrapping.
- `[Required]` on a **non-nullable** wrapper never fires (a struct is never
null) — same as `int`. Make the property nullable when absence must be
an error.

## Arithmetic

The wrappers deliberately do **not** overload arithmetic operators — a
Expand Down
2 changes: 2 additions & 0 deletions Skill/references/parsing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Conversions and invariants:
- Implicit `Digit → byte` and `Digit → int` — drop a `Digit` into any
numeric slot without `.Value`.
- `IEquatable` / `IComparable` against `Digit`, `byte`, and `int`.
- `IConvertible` (explicit) — `Convert.To…` sees the underlying value, so
DataAnnotations `[Range(0, 5)]` works on a `Digit?` property.
- `default(Digit)` is `0`.

Also a string helper for extracting digits:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using StrongTypes.AspNetCore.IntegrationTests.Infrastructure;
using Xunit;

namespace StrongTypes.AspNetCore.IntegrationTests.Tests;

/// <summary>
/// DataAnnotations on strong-typed properties through the real MVC pipeline.
/// The layering under test: the type's own invariant is enforced by the JSON
/// converter at binding (an invalid value never reaches validation), while the
/// attributes add presence (<c>[Required]</c>) and narrowing (<c>[Range]</c>) on
/// top — and <c>[Range]</c>'s numeric form must evaluate the wrapped value, not
/// reject every wrapper as unconvertible.
/// </summary>
public sealed class DataAnnotationsValidationTests(AspNetCoreTestApiFactory factory)
: IClassFixture<AspNetCoreTestApiFactory>, IDisposable
{
private readonly HttpClient _client = factory.CreateClient();

private static CancellationToken Ct => TestContext.Current.CancellationToken;

public void Dispose() => _client.Dispose();

private async Task<HttpResponseMessage> Post(string json)
{
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await _client.PostAsync("/validation-probe/range", content, Ct);
}

private static async Task<string[]> ErrorsFor(HttpResponseMessage response, string key)
{
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<JsonElement>(Ct);
Assert.Equal(400, problem.GetProperty("status").GetInt32());
return problem.GetProperty("errors").GetProperty(key).EnumerateArray()
.Select(e => e.GetString()!).ToArray();
}

[Theory]
[InlineData(1)]
[InlineData(50)]
[InlineData(100)]
public async Task InRangeWrapper_PassesValidation(int quantity)
{
var response = await Post($$"""{"quantity":{{quantity}}}""");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

[Theory]
[InlineData(101)]
[InlineData(150)]
public async Task OutOfRangeWrapper_FailsWithTheRangeMessage(int quantity)
{
var response = await Post($$"""{"quantity":{{quantity}}}""");

var errors = await ErrorsFor(response, "Quantity");
Assert.Contains("The field Quantity must be between 1 and 100.", errors);
}

[Theory]
[InlineData("{}")]
[InlineData("""{"quantity":null}""")]
public async Task MissingWrapper_FailsRequired(string json)
{
var response = await Post(json);

var errors = await ErrorsFor(response, "Quantity");
Assert.Contains("The Quantity field is required.", errors);
}

// 0 fails the type's own invariant at binding — [Range] never runs.
[Fact]
public async Task InvalidForTheType_FailsAtBinding()
{
var response = await Post("""{"quantity":0}""");

var errors = await ErrorsFor(response, "Quantity");
Assert.DoesNotContain("The field Quantity must be between 1 and 100.", errors);
}

[Theory]
[InlineData("0.5")]
[InlineData("1.25")]
[InlineData("2.5")]
public async Task InRangeNonIntegerWrapper_PassesValidation(string factor)
{
var response = await Post($$"""{"quantity":50,"factor":{{factor}}}""");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

[Theory]
[InlineData("0.49")]
[InlineData("2.51")]
public async Task OutOfRangeNonIntegerWrapper_FailsWithTheRangeMessage(string factor)
{
var response = await Post($$"""{"quantity":50,"factor":{{factor}}}""");

var errors = await ErrorsFor(response, "Factor");
// RangeAttribute renders its bounds with the current culture ("0,5" on a cs-CZ host).
var expected = string.Format(
CultureInfo.CurrentCulture, "The field Factor must be between {0} and {1}.", 0.5, 2.5);
Assert.Contains(expected, errors);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;

namespace StrongTypes.AspNetCore.TestApi.Controllers;

/// <summary>
/// Endpoints used to observe DataAnnotations validation applied to strong-typed
/// properties: the attribute must evaluate the wrapped value (e.g.
/// <see cref="RangeAttribute"/> reads it through <see cref="IConvertible"/>),
/// composing with the invariant the type already enforces at deserialization.
/// </summary>
[ApiController]
[Route("validation-probe")]
public sealed class ValidationProbeController : ControllerBase
{
[HttpPost("range")]
public IActionResult Range(RangeBody body) => Ok();
}

public sealed record RangeBody(
[Required][Range(1, 100)] Positive<int>? Quantity,
[Range(0.5, 2.5)] Positive<decimal>? Factor);
17 changes: 17 additions & 0 deletions src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ public static string EmitType(Model m)
sb.Append(" global::System.IComparable<").Append(self).AppendLine(">,");
sb.Append(" global::System.IComparable<").Append(t).AppendLine(">,");
sb.AppendLine(" global::System.IComparable,");
sb.AppendLine(" global::System.IConvertible,");
sb.AppendLine(" global::System.IFormattable,");
sb.AppendLine(" global::System.ISpanFormattable,");
sb.Append(" global::System.IParsable<").Append(self).AppendLine(">,");
Expand Down Expand Up @@ -258,6 +259,22 @@ public static string EmitType(Model m)
sb.AppendLine();
sb.AppendLine(" public bool TryFormat(global::System.Span<char> destination, out int charsWritten, global::System.ReadOnlySpan<char> format, global::System.IFormatProvider? provider)");
sb.AppendLine(" => Value.TryFormat(destination, out charsWritten, format, provider);");
sb.AppendLine();

sb.AppendLine(" global::System.TypeCode global::System.IConvertible.GetTypeCode() => global::System.TypeCode.Object;");
sb.AppendLine(" string global::System.IConvertible.ToString(global::System.IFormatProvider? provider) => Value.ToString(null, provider);");
sb.AppendLine(" object global::System.IConvertible.ToType(global::System.Type conversionType, global::System.IFormatProvider? provider) => ((global::System.IConvertible)Value).ToType(conversionType, provider);");
foreach (var (returnType, method) in new[]
{
("bool", "ToBoolean"), ("byte", "ToByte"), ("char", "ToChar"), ("global::System.DateTime", "ToDateTime"),
("decimal", "ToDecimal"), ("double", "ToDouble"), ("short", "ToInt16"), ("int", "ToInt32"),
("long", "ToInt64"), ("sbyte", "ToSByte"), ("float", "ToSingle"), ("ushort", "ToUInt16"),
("uint", "ToUInt32"), ("ulong", "ToUInt64")
})
{
sb.Append(" ").Append(returnType).Append(" global::System.IConvertible.").Append(method)
.Append("(global::System.IFormatProvider? provider) => ((global::System.IConvertible)Value).").Append(method).AppendLine("(provider);");
}

sb.AppendLine("}");
return sb.ToString();
Expand Down
21 changes: 21 additions & 0 deletions src/StrongTypes.Tests/Digits/DigitTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.ComponentModel.DataAnnotations;
using FsCheck.Xunit;
using Xunit;

Expand Down Expand Up @@ -238,4 +239,24 @@ public void Parse_SingleDigit_WrapsValue() =>
[InlineData("42")]
public void Parse_Invalid_Throws(string input) =>
Assert.Throws<ArgumentException>(() => Digit.Parse(input, provider: null));

[Fact]
public void ConvertToInt32_MatchesTheUnderlyingValue() =>
Assert.Equal(7, Convert.ToInt32(Digit.Create('7')));

private sealed class RangeModel
{
[Range(0, 5)] public Digit? Value { get; set; }
}

[Theory]
[InlineData('0', true)]
[InlineData('5', true)]
[InlineData('6', false)]
[InlineData('9', false)]
public void RangeAttribute_ValidatesTheUnderlyingValue(char digit, bool expected)
{
var model = new RangeModel { Value = Digit.Create(digit) };
Assert.Equal(expected, Validator.TryValidateObject(model, new ValidationContext(model), null, validateAllProperties: true));
}
}
81 changes: 81 additions & 0 deletions src/StrongTypes.Tests/Numbers/NumericConvertibleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using FsCheck.Xunit;
using Xunit;

namespace StrongTypes.Tests;

/// <summary>Without <see cref="IConvertible"/>, <see cref="RangeAttribute"/>'s numeric constructors hit an <see cref="InvalidCastException"/> in <c>Convert</c> that the attribute swallows — every value, in range or not, silently reports as invalid — so these pin the <c>Convert</c> bridge and the resulting validation behavior.</summary>
[Properties(Arbitrary = new[] { typeof(Generators) })]
public class NumericConvertibleTests
{
[Property]
public void ConvertToInt32_MatchesTheUnderlyingValue(Positive<int> value) =>
Assert.Equal(value.Value, Convert.ToInt32(value));

[Property]
public void ConvertToDouble_MatchesTheUnderlyingValue(Positive<int> value) =>
Assert.Equal(value.Value, Convert.ToDouble(value));

[Property]
public void ChangeType_ConvertsToTheUnderlyingType(Positive<int> value) =>
Assert.Equal(value.Value, Convert.ChangeType(value, typeof(int)));

[Fact]
public void EveryWrapper_IsConvertible()
{
Assert.Equal(42, Convert.ToInt32(Positive<int>.Create(42)));
Assert.Equal(0, Convert.ToInt32(NonNegative<int>.Create(0)));
Assert.Equal(-42, Convert.ToInt32(Negative<int>.Create(-42)));
Assert.Equal(-42, Convert.ToInt32(NonPositive<int>.Create(-42)));
}

[Fact]
public void ConvertToString_HonoursTheProvider() =>
Assert.Equal("1234,5", Convert.ToString(Positive<decimal>.Create(1234.5m), CultureInfo.GetCultureInfo("de-DE")));

[Fact]
public void GetTypeCode_IsObject() =>
Assert.Equal(TypeCode.Object, ((IConvertible)Positive<int>.Create(1)).GetTypeCode());

// ── RangeAttribute ──────────────────────────────────────────────────

private sealed class IntRangeModel
{
[Range(1, 100)] public Positive<int>? Quantity { get; set; }
}

private sealed class DoubleRangeModel
{
[Range(0.5, 2.5)] public Positive<double>? Factor { get; set; }
}

[Property]
public void RangeAttribute_ValidatesTheUnderlyingValue(Positive<int> value)
{
var model = new IntRangeModel { Quantity = value };
Assert.Equal(value.Value <= 100, Validate(model));
}

[Theory]
[InlineData(1, true)]
[InlineData(50, true)]
[InlineData(100, true)]
[InlineData(101, false)]
[InlineData(150, false)]
public void RangeAttribute_IntBounds_AreHonoured(int value, bool expected) =>
Assert.Equal(expected, Validate(new IntRangeModel { Quantity = Positive<int>.Create(value) }));

[Theory]
[InlineData(0.5, true)]
[InlineData(1.5, true)]
[InlineData(2.5, true)]
[InlineData(0.4, false)]
[InlineData(2.6, false)]
public void RangeAttribute_DoubleBounds_AreHonoured(double value, bool expected) =>
Assert.Equal(expected, Validate(new DoubleRangeModel { Factor = Positive<double>.Create(value) }));

private static bool Validate(object model) =>
Validator.TryValidateObject(model, new ValidationContext(model), null, validateAllProperties: true);
}
23 changes: 23 additions & 0 deletions src/StrongTypes/Digits/Digit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ namespace StrongTypes;
IComparable<byte>,
IComparable<int>,
IComparable,
IConvertible,
IParsable<Digit>
{
private Digit(byte value)
Expand Down Expand Up @@ -155,6 +156,28 @@ int IComparable.CompareTo(object? obj) =>

#endregion Comparison

#region IConvertible

TypeCode IConvertible.GetTypeCode() => TypeCode.Object;
string IConvertible.ToString(IFormatProvider? provider) => Value.ToString(provider);
object IConvertible.ToType(Type conversionType, IFormatProvider? provider) => ((IConvertible)Value).ToType(conversionType, provider);
bool IConvertible.ToBoolean(IFormatProvider? provider) => ((IConvertible)Value).ToBoolean(provider);
byte IConvertible.ToByte(IFormatProvider? provider) => Value;
char IConvertible.ToChar(IFormatProvider? provider) => ((IConvertible)Value).ToChar(provider);
DateTime IConvertible.ToDateTime(IFormatProvider? provider) => ((IConvertible)Value).ToDateTime(provider);
decimal IConvertible.ToDecimal(IFormatProvider? provider) => Value;
double IConvertible.ToDouble(IFormatProvider? provider) => Value;
short IConvertible.ToInt16(IFormatProvider? provider) => Value;
int IConvertible.ToInt32(IFormatProvider? provider) => Value;
long IConvertible.ToInt64(IFormatProvider? provider) => Value;
sbyte IConvertible.ToSByte(IFormatProvider? provider) => (sbyte)Value;
float IConvertible.ToSingle(IFormatProvider? provider) => Value;
ushort IConvertible.ToUInt16(IFormatProvider? provider) => Value;
uint IConvertible.ToUInt32(IFormatProvider? provider) => Value;
ulong IConvertible.ToUInt64(IFormatProvider? provider) => Value;

#endregion IConvertible

[Pure]
public override string ToString() => Value.ToString();
}
6 changes: 6 additions & 0 deletions testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ no database, so these run on any host without containers):

- **Model binding** (`BindingTests`) — `NonEmptyEnumerable<T>` from
`[FromForm]` / `[FromQuery]` / `[FromHeader]` / `[FromRoute]`.
- **DataAnnotations over strong types** (`DataAnnotationsValidationTests`) —
validation attributes (`[Required]`, `[Range]`) on strong-typed body
properties through the `[ApiController]` pipeline. Pins that the attribute
evaluates the wrapped value (in-range passes, out-of-range fails with the
attribute's message) and that a value invalid for the type itself fails at
binding, before validation runs.
- **JSON error-key normalization** (`JsonBodyErrorKeyTests`) — the
opt-out feature that rewrites a failed body's error key from the
System.Text.Json path (`$.value`) to the property name (`Value`). Test
Expand Down