From ed6921292d6d58aee0f87e1e5ba59f018787ef6e Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 17 Jul 2026 14:33:01 +0200 Subject: [PATCH 1/3] Implement IConvertible on the numeric wrappers and Digit RangeAttribute's numeric constructors read the validated value via Convert.ToInt32/ToDouble, which require IConvertible; without it the InvalidCastException is swallowed and every value - in range or not - silently reports as out of range. An explicit IConvertible delegating to Value makes plain [Range(1, 100)] validate the underlying number, and Convert.ChangeType interop comes along for free. Co-Authored-By: Claude Fable 5 --- Skill/references/numeric.md | 28 +++++++ Skill/references/parsing.md | 2 + .../NumericWrapperGenerator.cs | 17 ++++ src/StrongTypes.Tests/Digits/DigitTests.cs | 21 +++++ .../Numbers/NumericConvertibleTests.cs | 81 +++++++++++++++++++ src/StrongTypes/Digits/Digit.cs | 23 ++++++ 6 files changed, 172 insertions(+) create mode 100644 src/StrongTypes.Tests/Numbers/NumericConvertibleTests.cs diff --git a/Skill/references/numeric.md b/Skill/references/numeric.md index 9f913fb9..22eb239b 100644 --- a/Skill/references/numeric.md +++ b/Skill/references/numeric.md @@ -55,6 +55,9 @@ All four types have `AsX` / `ToX` extension methods available on any `string` or a `ReadOnlySpan`, 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` / @@ -97,6 +100,31 @@ Read>("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, but not here + public Positive? 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), "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 diff --git a/Skill/references/parsing.md b/Skill/references/parsing.md index 5e9ae6ca..b7f2b9f5 100644 --- a/Skill/references/parsing.md +++ b/Skill/references/parsing.md @@ -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: diff --git a/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs b/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs index dd0eff74..70e7f853 100644 --- a/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs +++ b/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs @@ -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(">,"); @@ -258,6 +259,22 @@ public static string EmitType(Model m) sb.AppendLine(); sb.AppendLine(" public bool TryFormat(global::System.Span destination, out int charsWritten, global::System.ReadOnlySpan 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(); diff --git a/src/StrongTypes.Tests/Digits/DigitTests.cs b/src/StrongTypes.Tests/Digits/DigitTests.cs index 9f2b5c45..53852d65 100644 --- a/src/StrongTypes.Tests/Digits/DigitTests.cs +++ b/src/StrongTypes.Tests/Digits/DigitTests.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel.DataAnnotations; using FsCheck.Xunit; using Xunit; @@ -238,4 +239,24 @@ public void Parse_SingleDigit_WrapsValue() => [InlineData("42")] public void Parse_Invalid_Throws(string input) => Assert.Throws(() => 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)); + } } diff --git a/src/StrongTypes.Tests/Numbers/NumericConvertibleTests.cs b/src/StrongTypes.Tests/Numbers/NumericConvertibleTests.cs new file mode 100644 index 00000000..b9945764 --- /dev/null +++ b/src/StrongTypes.Tests/Numbers/NumericConvertibleTests.cs @@ -0,0 +1,81 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using FsCheck.Xunit; +using Xunit; + +namespace StrongTypes.Tests; + +/// Without , 's numeric constructors hit an in Convert that the attribute swallows — every value, in range or not, silently reports as invalid — so these pin the Convert bridge and the resulting validation behavior. +[Properties(Arbitrary = new[] { typeof(Generators) })] +public class NumericConvertibleTests +{ + [Property] + public void ConvertToInt32_MatchesTheUnderlyingValue(Positive value) => + Assert.Equal(value.Value, Convert.ToInt32(value)); + + [Property] + public void ConvertToDouble_MatchesTheUnderlyingValue(Positive value) => + Assert.Equal(value.Value, Convert.ToDouble(value)); + + [Property] + public void ChangeType_ConvertsToTheUnderlyingType(Positive value) => + Assert.Equal(value.Value, Convert.ChangeType(value, typeof(int))); + + [Fact] + public void EveryWrapper_IsConvertible() + { + Assert.Equal(42, Convert.ToInt32(Positive.Create(42))); + Assert.Equal(0, Convert.ToInt32(NonNegative.Create(0))); + Assert.Equal(-42, Convert.ToInt32(Negative.Create(-42))); + Assert.Equal(-42, Convert.ToInt32(NonPositive.Create(-42))); + } + + [Fact] + public void ConvertToString_HonoursTheProvider() => + Assert.Equal("1234,5", Convert.ToString(Positive.Create(1234.5m), CultureInfo.GetCultureInfo("de-DE"))); + + [Fact] + public void GetTypeCode_IsObject() => + Assert.Equal(TypeCode.Object, ((IConvertible)Positive.Create(1)).GetTypeCode()); + + // ── RangeAttribute ────────────────────────────────────────────────── + + private sealed class IntRangeModel + { + [Range(1, 100)] public Positive? Quantity { get; set; } + } + + private sealed class DoubleRangeModel + { + [Range(0.5, 2.5)] public Positive? Factor { get; set; } + } + + [Property] + public void RangeAttribute_ValidatesTheUnderlyingValue(Positive 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.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.Create(value) })); + + private static bool Validate(object model) => + Validator.TryValidateObject(model, new ValidationContext(model), null, validateAllProperties: true); +} diff --git a/src/StrongTypes/Digits/Digit.cs b/src/StrongTypes/Digits/Digit.cs index 8493068a..16d409ab 100644 --- a/src/StrongTypes/Digits/Digit.cs +++ b/src/StrongTypes/Digits/Digit.cs @@ -16,6 +16,7 @@ namespace StrongTypes; IComparable, IComparable, IComparable, + IConvertible, IParsable { private Digit(byte value) @@ -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(); } From acca7bb4690c68044cd0883949f597febe57d7c3 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 17 Jul 2026 14:49:10 +0200 Subject: [PATCH 2/3] Cover DataAnnotations [Range] on wrappers in the ASP.NET Core pipeline The IConvertible change exists so that RangeAttribute works in consumer applications; assert that at the application level - a [ApiController] endpoint with [Required][Range(1, 100)] Positive? returns 200 in range, the attribute's own 400 out of range, and a binding 400 for values the type itself rejects. Co-Authored-By: Claude Fable 5 --- .../Tests/DataAnnotationsValidationTests.cs | 84 +++++++++++++++++++ .../Controllers/ValidationProbeController.cs | 20 +++++ testing.md | 6 ++ 3 files changed, 110 insertions(+) create mode 100644 src/StrongTypes.AspNetCore.IntegrationTests/Tests/DataAnnotationsValidationTests.cs create mode 100644 src/StrongTypes.AspNetCore.TestApi/Controllers/ValidationProbeController.cs diff --git a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/DataAnnotationsValidationTests.cs b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/DataAnnotationsValidationTests.cs new file mode 100644 index 00000000..8b3bcbc7 --- /dev/null +++ b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/DataAnnotationsValidationTests.cs @@ -0,0 +1,84 @@ +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; + +/// +/// 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 ([Required]) and narrowing ([Range]) on +/// top — and [Range]'s numeric form must evaluate the wrapped value, not +/// reject every wrapper as unconvertible. +/// +public sealed class DataAnnotationsValidationTests(AspNetCoreTestApiFactory factory) + : IClassFixture, IDisposable +{ + private readonly HttpClient _client = factory.CreateClient(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); + + private async Task 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 ErrorsFor(HttpResponseMessage response, string key) + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(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); + } +} diff --git a/src/StrongTypes.AspNetCore.TestApi/Controllers/ValidationProbeController.cs b/src/StrongTypes.AspNetCore.TestApi/Controllers/ValidationProbeController.cs new file mode 100644 index 00000000..316e4d53 --- /dev/null +++ b/src/StrongTypes.AspNetCore.TestApi/Controllers/ValidationProbeController.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; + +namespace StrongTypes.AspNetCore.TestApi.Controllers; + +/// +/// Endpoints used to observe DataAnnotations validation applied to strong-typed +/// properties: the attribute must evaluate the wrapped value (e.g. +/// reads it through ), +/// composing with the invariant the type already enforces at deserialization. +/// +[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? Quantity); diff --git a/testing.md b/testing.md index 03594f52..3e3927ce 100644 --- a/testing.md +++ b/testing.md @@ -159,6 +159,12 @@ no database, so these run on any host without containers): - **Model binding** (`BindingTests`) — `NonEmptyEnumerable` 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 From bad0ab9d9995e367a987a82102faa61ccb601f05 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 17 Jul 2026 15:09:01 +0200 Subject: [PATCH 3/3] Cover a non-integer wrapper in the [Range] integration tests Positive under the double-ctor [Range(0.5, 2.5)] exercises the Convert.ToDouble path end-to-end. The expected message is built with CultureInfo.CurrentCulture because RangeAttribute renders its bounds with the current culture ("0,5" on a cs-CZ host). Co-Authored-By: Claude Fable 5 --- .../Tests/DataAnnotationsValidationTests.cs | 26 +++++++++++++++++++ .../Controllers/ValidationProbeController.cs | 4 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/DataAnnotationsValidationTests.cs b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/DataAnnotationsValidationTests.cs index 8b3bcbc7..297d778f 100644 --- a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/DataAnnotationsValidationTests.cs +++ b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/DataAnnotationsValidationTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Net.Http.Json; using System.Text; @@ -81,4 +82,29 @@ public async Task InvalidForTheType_FailsAtBinding() 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); + } } diff --git a/src/StrongTypes.AspNetCore.TestApi/Controllers/ValidationProbeController.cs b/src/StrongTypes.AspNetCore.TestApi/Controllers/ValidationProbeController.cs index 316e4d53..d2a65bd0 100644 --- a/src/StrongTypes.AspNetCore.TestApi/Controllers/ValidationProbeController.cs +++ b/src/StrongTypes.AspNetCore.TestApi/Controllers/ValidationProbeController.cs @@ -17,4 +17,6 @@ public sealed class ValidationProbeController : ControllerBase public IActionResult Range(RangeBody body) => Ok(); } -public sealed record RangeBody([Required][Range(1, 100)] Positive? Quantity); +public sealed record RangeBody( + [Required][Range(1, 100)] Positive? Quantity, + [Range(0.5, 2.5)] Positive? Factor);