From 8cde6a5e0c3fd953c289c31ac7371b731b5ae57f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 08:15:29 +0000 Subject: [PATCH 1/4] Add BoundedInt with custom-bounds witness pattern (closes #59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces an int constrained to [TBounds.Min, TBounds.Max] via a small witness type implementing IBounds. Bounds travel with the type, so e.g. `BoundedInt` makes the 1..100 invariant part of the signature. Also opens up the JSON converter factory to any struct marked with [NumericWrapper], so users can spin up their own validated numeric wrappers with just the attribute and a `Value` / `TryCreate` pair — the source generator and JSON converter take care of the rest. EF Core convention and the missing-EfCore-package analyzer now also recognize BoundedInt<>. --- readme.md | 46 +++ .../MissingEfCorePackageAnalyzerTests.cs | 35 ++ .../MissingEfCorePackageAnalyzer.cs | 5 +- .../StrongTypesConvention.cs | 11 +- src/StrongTypes.EfCore/readme.md | 7 +- .../Numbers/BoundedIntTests.cs | 334 ++++++++++++++++++ src/StrongTypes/Numbers/BoundedInt.cs | 40 +++ src/StrongTypes/Numbers/IBounds.cs | 12 + src/StrongTypes/Numbers/NumberExtensions.cs | 15 + .../NumericStrongTypeJsonConverterFactory.cs | 21 +- 10 files changed, 505 insertions(+), 21 deletions(-) create mode 100644 src/StrongTypes.Tests/Numbers/BoundedIntTests.cs create mode 100644 src/StrongTypes/Numbers/BoundedInt.cs create mode 100644 src/StrongTypes/Numbers/IBounds.cs diff --git a/readme.md b/readme.md index 4bdc5b5e..363829b2 100644 --- a/readme.md +++ b/readme.md @@ -26,6 +26,8 @@ StrongTypes adds small, focused types that make everyday code safer and more exp - [Helpful Types](#helpful-types) - [`NonEmptyString`](#nonemptystring) - [Numeric wrappers: `Positive`, `NonNegative`, `Negative`, `NonPositive`](#numeric-wrappers) + - [`BoundedInt`](#boundedinttbounds) + - [Defining your own validated wrapper](#defining-your-own-validated-wrapper) - [What you get for free](#what-you-get-for-free) - [JSON serialization](#json-serialization) - [EF Core persistence](#ef-core-persistence) @@ -104,6 +106,50 @@ All defaults (e.g. `default(Positive)`) still satisfy their invariants (e.g. [↑ Back to contents](#contents) +### `BoundedInt` + +A 32-bit integer constrained to a closed range `[Min, Max]`. The bounds are not values you pass at every call site — they live on a small witness type, so the rule travels with the type and the validity of `pageSize` is part of its signature: + +```csharp +public readonly struct PageSizeBounds : IBounds +{ + public static int Min => 1; + public static int Max => 100; +} + +BoundedInt? p = BoundedInt.TryCreate(input); // null if invalid +BoundedInt p2 = BoundedInt.Create(input); // throws if invalid +BoundedInt? p3 = input.AsBounded(); // null if invalid +BoundedInt p4 = input.ToBounded(); // throws if invalid + +void GetUsers(BoundedInt pageSize) { … } // 1..100 enforced at the boundary +``` + +Both endpoints are inclusive. `default(BoundedInt)` wraps `TBounds.Min` so it always satisfies the invariant. There is no `Sum` extension — bounded ranges are not closed under addition. + +[↑ Back to contents](#contents) + +### Defining your own validated wrapper + +`Positive`, `BoundedInt`, and the rest are just `partial struct`s decorated with `[NumericWrapper]`. The library's source generator emits all the equality, comparison, conversion, and `Create` boilerplate; the JSON converter factory recognizes any type that carries the attribute. To add your own validated numeric wrapper, write the `Value` property and `TryCreate` and tag the struct: + +```csharp +[NumericWrapper(InvariantDescription = "even")] +[JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +public readonly partial struct EvenInt +{ + public int Value { get; } + private EvenInt(int value) { Value = value; } + + public static EvenInt? TryCreate(int value) + => value % 2 == 0 ? new EvenInt(value) : null; +} +``` + +That is the whole file. The generated `Create`, `==`, `<`, `IEquatable`, `implicit operator int`, `ToString`, JSON round-tripping, and `EvenIntExtensions.Min` / `.Max` / `.Unwrap` come from the attribute alone. + +[↑ Back to contents](#contents) + ### What you get for free Every strong type in this library implements the full set of equality and comparison interfaces, so you can drop them into dictionaries, sorted collections, LINQ `OrderBy`, and equality checks without writing any boilerplate: diff --git a/src/StrongTypes.Analyzers.Tests/MissingEfCorePackageAnalyzerTests.cs b/src/StrongTypes.Analyzers.Tests/MissingEfCorePackageAnalyzerTests.cs index 647f8831..a39697d5 100644 --- a/src/StrongTypes.Analyzers.Tests/MissingEfCorePackageAnalyzerTests.cs +++ b/src/StrongTypes.Analyzers.Tests/MissingEfCorePackageAnalyzerTests.cs @@ -166,6 +166,41 @@ public class SampleContext : DbContext Assert.NotEmpty(diagnostics.WhereId(MissingEfCorePackageAnalyzer.DiagnosticId)); } + [Fact] + public async Task Detects_bounded_int_wrapper() + { + const string source = """ + using Microsoft.EntityFrameworkCore; + using StrongTypes; + + namespace Sample; + + public readonly struct PageSizeBounds : IBounds + { + public static int Min => 1; + public static int Max => 100; + } + + public class Entity + { + public int Id { get; set; } + public BoundedInt PageSize { get; set; } + } + + public class SampleContext : DbContext + { + public DbSet Entities { get; set; } = null!; + } + """; + + var diagnostics = await AnalyzerTester.RunAsync( + new MissingEfCorePackageAnalyzer(), + source, + TestReferences.With(TestReferences.EntityFrameworkCore)); + + Assert.NotEmpty(diagnostics.WhereId(MissingEfCorePackageAnalyzer.DiagnosticId)); + } + [Fact] public async Task Reports_at_dbset_entity_property_and_dbcontext_locations() { diff --git a/src/StrongTypes.Analyzers/MissingEfCorePackageAnalyzer.cs b/src/StrongTypes.Analyzers/MissingEfCorePackageAnalyzer.cs index e919a656..fed16197 100644 --- a/src/StrongTypes.Analyzers/MissingEfCorePackageAnalyzer.cs +++ b/src/StrongTypes.Analyzers/MissingEfCorePackageAnalyzer.cs @@ -37,7 +37,7 @@ public sealed class MissingEfCorePackageAnalyzer : DiagnosticAnalyzer category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: "Kalicz.StrongTypes.EfCore ships value converters and the Unwrap() LINQ translator needed for EF Core to round-trip NonEmptyString, Positive, NonNegative, Negative, and NonPositive to a database column. Without the package, EF Core infers the wrapper as an owned entity type and fails at model-build time.", + description: "Kalicz.StrongTypes.EfCore ships value converters and the Unwrap() LINQ translator needed for EF Core to round-trip NonEmptyString, Positive, NonNegative, Negative, NonPositive, and BoundedInt to a database column. Without the package, EF Core infers the wrapper as an owned entity type and fails at model-build time.", helpLinkUri: "https://www.nuget.org/packages/Kalicz.StrongTypes.EfCore"); // Canonical names in metadata form so compiled generics match @@ -47,7 +47,8 @@ public sealed class MissingEfCorePackageAnalyzer : DiagnosticAnalyzer "StrongTypes.Positive`1", "StrongTypes.NonNegative`1", "StrongTypes.Negative`1", - "StrongTypes.NonPositive`1"); + "StrongTypes.NonPositive`1", + "StrongTypes.BoundedInt`1"); public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule); diff --git a/src/StrongTypes.EfCore/StrongTypesConvention.cs b/src/StrongTypes.EfCore/StrongTypesConvention.cs index 9cc6e40c..da653879 100644 --- a/src/StrongTypes.EfCore/StrongTypesConvention.cs +++ b/src/StrongTypes.EfCore/StrongTypesConvention.cs @@ -52,7 +52,8 @@ private static bool IsStrongType(Type clrType) return definition == typeof(Positive<>) || definition == typeof(NonNegative<>) || definition == typeof(Negative<>) - || definition == typeof(NonPositive<>); + || definition == typeof(NonPositive<>) + || definition == typeof(BoundedInt<>); } return false; } @@ -77,9 +78,13 @@ private static bool IsStrongType(Type clrType) if (definition == typeof(Positive<>) || definition == typeof(NonNegative<>) || definition == typeof(Negative<>) || - definition == typeof(NonPositive<>)) + definition == typeof(NonPositive<>) || + definition == typeof(BoundedInt<>)) { - var underlying = unwrapped.GetGenericArguments()[0]; + // Read the underlying numeric type from the wrapper's Value + // property: BoundedInt closes over the bounds witness, + // so its first generic argument isn't the underlying type. + var underlying = unwrapped.GetProperty("Value", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)!.PropertyType; var converterType = typeof(NumericStrongTypeValueConverter<,>).MakeGenericType(unwrapped, underlying); return (ValueConverter)Activator.CreateInstance(converterType)!; } diff --git a/src/StrongTypes.EfCore/readme.md b/src/StrongTypes.EfCore/readme.md index f89aa40a..ae548c57 100644 --- a/src/StrongTypes.EfCore/readme.md +++ b/src/StrongTypes.EfCore/readme.md @@ -3,9 +3,10 @@ [![NuGet version](https://img.shields.io/nuget/v/Kalicz.StrongTypes.EfCore?label=nuget)](https://www.nuget.org/packages/Kalicz.StrongTypes.EfCore/) [![Downloads](https://img.shields.io/nuget/dt/Kalicz.StrongTypes.EfCore?label=downloads)](https://www.nuget.org/packages/Kalicz.StrongTypes.EfCore/) [![License](https://img.shields.io/github/license/KaliCZ/StrongTypes)](https://github.com/KaliCZ/StrongTypes/blob/main/license.txt) EF Core plumbing for [Kalicz.StrongTypes](https://www.nuget.org/packages/Kalicz.StrongTypes). -Lets you use `NonEmptyString`, `Positive`, `NonNegative`, `Negative`, and -`NonPositive` as regular entity properties — they round-trip through scalar -columns, and LINQ predicates over them translate to server-side SQL. +Lets you use `NonEmptyString`, `Positive`, `NonNegative`, `Negative`, +`NonPositive`, and `BoundedInt` as regular entity properties — they +round-trip through scalar columns, and LINQ predicates over them translate to +server-side SQL. ## Install diff --git a/src/StrongTypes.Tests/Numbers/BoundedIntTests.cs b/src/StrongTypes.Tests/Numbers/BoundedIntTests.cs new file mode 100644 index 00000000..565bd357 --- /dev/null +++ b/src/StrongTypes.Tests/Numbers/BoundedIntTests.cs @@ -0,0 +1,334 @@ +using System; +using System.Text.Json; +using FsCheck.Xunit; +using Xunit; + +namespace StrongTypes.Tests; + +public readonly struct PageSizeBounds : IBounds +{ + public static int Min => 1; + public static int Max => 100; +} + +public readonly struct SignedTinyBounds : IBounds +{ + public static int Min => -3; + public static int Max => 3; +} + +public readonly struct SingletonBounds : IBounds +{ + public static int Min => 7; + public static int Max => 7; +} + +public class BoundedIntTests +{ + private static bool IsInPageRange(int value) => + value >= PageSizeBounds.Min && value <= PageSizeBounds.Max; + + private static bool IsInSignedRange(int value) => + value >= SignedTinyBounds.Min && value <= SignedTinyBounds.Max; + + [Property] + public void TryCreate_NonNullIffWithinRange(int value) + { + var result = BoundedInt.TryCreate(value); + Assert.Equal(IsInPageRange(value), result is not null); + if (result is { } bounded) + { + Assert.Equal(value, bounded.Value); + } + } + + [Property] + public void TryCreate_NegativeRange_NonNullIffWithinRange(int value) + { + var result = BoundedInt.TryCreate(value); + Assert.Equal(IsInSignedRange(value), result is not null); + if (result is { } bounded) + { + Assert.Equal(value, bounded.Value); + } + } + + [Property] + public void Create_ThrowsIffOutsideRange(int value) + { + if (IsInPageRange(value)) + { + Assert.Equal(value, BoundedInt.Create(value).Value); + } + else + { + Assert.Throws(() => BoundedInt.Create(value)); + } + } + + [Property] + public void ImplicitConversion_RecoversUnderlyingValue(int value) + { + if (BoundedInt.TryCreate(value) is not { } bounded) + { + return; + } + + int recovered = bounded; + Assert.Equal(value, recovered); + } + + [Property] + public void Equality_MatchesUnderlyingValue(int a, int b) + { + var ba = BoundedInt.TryCreate(a); + var bb = BoundedInt.TryCreate(b); + if (ba is null || bb is null) + { + return; + } + + Assert.Equal(a == b, ba.Value == bb.Value); + Assert.Equal(a == b, ba.Value.Equals(bb.Value)); + Assert.Equal(a == b, ba.Value.Equals((object)bb.Value)); + Assert.False(ba.Value.Equals((object?)null)); + Assert.False(ba.Value.Equals("not a bounded")); + } + + [Property] + public void Comparison_MatchesUnderlyingValue(int a, int b) + { + var ba = BoundedInt.TryCreate(a); + var bb = BoundedInt.TryCreate(b); + if (ba is null || bb is null) + { + return; + } + + Assert.Equal(Math.Sign(a.CompareTo(b)), Math.Sign(ba.Value.CompareTo(bb.Value))); + Assert.Equal(a < b, ba.Value < bb.Value); + Assert.Equal(a <= b, ba.Value <= bb.Value); + Assert.Equal(a > b, ba.Value > bb.Value); + Assert.Equal(a >= b, ba.Value >= bb.Value); + } + + [Property] + public void CrossTypeEquality_MatchesUnderlyingValue(int value) + { + if (BoundedInt.TryCreate(value) is not { } bounded) + { + return; + } + + Assert.True(bounded.Equals(value)); + Assert.True(bounded == value); + Assert.True(value == bounded); + Assert.False(bounded != value); + } + + [Property] + public void CrossTypeComparison_MatchesUnderlyingValue(int a, int b) + { + if (BoundedInt.TryCreate(a) is not { } bounded) + { + return; + } + + Assert.Equal(Math.Sign(a.CompareTo(b)), Math.Sign(bounded.CompareTo(b))); + Assert.Equal(a < b, bounded < b); + Assert.Equal(a <= b, bounded <= b); + Assert.Equal(a > b, bounded > b); + Assert.Equal(a >= b, bounded >= b); + Assert.Equal(b < a, b < bounded); + Assert.Equal(b <= a, b <= bounded); + Assert.Equal(b > a, b > bounded); + Assert.Equal(b >= a, b >= bounded); + } + + // ── Boundary values ───────────────────────────────────────────────── + + [Fact] + public void Min_IsAccepted() + { + Assert.Equal(PageSizeBounds.Min, BoundedInt.Create(PageSizeBounds.Min).Value); + Assert.Equal(SignedTinyBounds.Min, BoundedInt.Create(SignedTinyBounds.Min).Value); + } + + [Fact] + public void Max_IsAccepted() + { + Assert.Equal(PageSizeBounds.Max, BoundedInt.Create(PageSizeBounds.Max).Value); + Assert.Equal(SignedTinyBounds.Max, BoundedInt.Create(SignedTinyBounds.Max).Value); + } + + [Fact] + public void JustBelowMin_IsRejected() + { + Assert.Null(BoundedInt.TryCreate(PageSizeBounds.Min - 1)); + Assert.Throws(() => BoundedInt.Create(PageSizeBounds.Min - 1)); + } + + [Fact] + public void JustAboveMax_IsRejected() + { + Assert.Null(BoundedInt.TryCreate(PageSizeBounds.Max + 1)); + Assert.Throws(() => BoundedInt.Create(PageSizeBounds.Max + 1)); + } + + [Fact] + public void SingletonRange_OnlyAcceptsTheOneValue() + { + Assert.Equal(7, BoundedInt.Create(7).Value); + Assert.Null(BoundedInt.TryCreate(6)); + Assert.Null(BoundedInt.TryCreate(8)); + } + + // ── Default ───────────────────────────────────────────────────────── + + [Fact] + public void Default_RepresentsMin_AndSatisfiesInvariant() + { + Assert.Equal(PageSizeBounds.Min, default(BoundedInt).Value); + Assert.Equal(SignedTinyBounds.Min, default(BoundedInt).Value); + } + + [Fact] + public void Default_EqualsCreateMin() + { + Assert.Equal(BoundedInt.Create(PageSizeBounds.Min), default(BoundedInt)); + Assert.True(default(BoundedInt) == BoundedInt.Create(PageSizeBounds.Min)); + Assert.Equal( + BoundedInt.Create(PageSizeBounds.Min).GetHashCode(), + default(BoundedInt).GetHashCode()); + } + + // ── Static Min/Max surface ────────────────────────────────────────── + + [Fact] + public void StaticMinMax_ReflectBounds() + { + Assert.Equal(PageSizeBounds.Min, BoundedInt.Min); + Assert.Equal(PageSizeBounds.Max, BoundedInt.Max); + } + + // ── Generated members: branch coverage ────────────────────────────── + + [Fact] + public void ExplicitOperator_FromUnderlying_Wraps() + { + var bounded = (BoundedInt)50; + Assert.Equal(50, bounded.Value); + } + + [Fact] + public void ExplicitOperator_FromUnderlying_ThrowsOnInvariantViolation() + { + Assert.Throws(() => (BoundedInt)0); + Assert.Throws(() => (BoundedInt)101); + } + + [Fact] + public void Create_ErrorMessage_MentionsBounds() + { + var ex = Assert.Throws(() => BoundedInt.Create(0)); + Assert.Contains("1", ex.Message); + Assert.Contains("100", ex.Message); + } + + [Fact] + public void Equals_BoxedUnderlying_MatchesValue() + { + var bounded = BoundedInt.Create(50); + Assert.True(bounded.Equals((object)50)); + Assert.False(bounded.Equals((object)51)); + } + + [Fact] + public void IComparable_CompareTo_Null_Returns1() + { + System.IComparable bounded = BoundedInt.Create(50); + Assert.Equal(1, bounded.CompareTo(null)); + } + + [Fact] + public void IComparable_CompareTo_BoxedSelf_MatchesUnderlying() + { + System.IComparable a = BoundedInt.Create(30); + Assert.Equal(0, a.CompareTo(BoundedInt.Create(30))); + Assert.True(a.CompareTo(BoundedInt.Create(50)) < 0); + Assert.True(a.CompareTo(BoundedInt.Create(10)) > 0); + } + + [Fact] + public void IComparable_CompareTo_BoxedUnderlying_MatchesUnderlying() + { + System.IComparable a = BoundedInt.Create(30); + Assert.Equal(0, a.CompareTo(30)); + Assert.True(a.CompareTo(50) < 0); + Assert.True(a.CompareTo(10) > 0); + } + + [Fact] + public void IComparable_CompareTo_UnrelatedType_Throws() + { + System.IComparable bounded = BoundedInt.Create(50); + Assert.Throws(() => bounded.CompareTo("not a number")); + } + + [Fact] + public void ToString_ReturnsUnderlyingString() + { + var bounded = BoundedInt.Create(42); + Assert.Equal("42", bounded.ToString()); + } + + // ── Extensions ────────────────────────────────────────────────────── + + [Property] + public void AsBounded_NonNullIffWithinRange(int value) + { + var result = value.AsBounded(); + Assert.Equal(IsInPageRange(value), result is not null); + if (result is { } bounded) + { + Assert.Equal(value, bounded.Value); + } + } + + [Property] + public void ToBounded_ThrowsIffOutsideRange(int value) + { + if (IsInPageRange(value)) + { + Assert.Equal(value, value.ToBounded().Value); + } + else + { + Assert.Throws(() => value.ToBounded()); + } + } + + // ── JSON ──────────────────────────────────────────────────────────── + + [Property] + public void Json_RoundTrips(int value) + { + if (BoundedInt.TryCreate(value) is not { } bounded) + { + return; + } + + var json = JsonSerializer.Serialize(bounded); + Assert.Equal(value.ToString(), json); + Assert.Equal(bounded, JsonSerializer.Deserialize>(json)); + } + + [Fact] + public void Json_OutOfRange_Throws() + { + Assert.Throws(() => + JsonSerializer.Deserialize>("0")); + Assert.Throws(() => + JsonSerializer.Deserialize>("101")); + } +} diff --git a/src/StrongTypes/Numbers/BoundedInt.cs b/src/StrongTypes/Numbers/BoundedInt.cs new file mode 100644 index 00000000..08060636 --- /dev/null +++ b/src/StrongTypes/Numbers/BoundedInt.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.Contracts; +using System.Text.Json.Serialization; + +namespace StrongTypes; + +/// An guaranteed to be within the closed range [TBounds.Min, TBounds.Max]. +/// A witness type carrying the inclusive lower and upper bounds. +/// Construct via or Create. default(BoundedInt<TBounds>) wraps TBounds.Min and satisfies the invariant. +[NumericWrapper(InvariantDescription = "between {TBounds.Min} and {TBounds.Max} (inclusive)")] +[JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +public readonly partial struct BoundedInt + where TBounds : IBounds +{ + // Stored as (Value - TBounds.Min); default(BoundedInt) therefore represents Value == TBounds.Min. + private readonly int _offset; + + private BoundedInt(int offset) + { + _offset = offset; + } + + [Pure] + public int Value => _offset + TBounds.Min; + + /// The inclusive lower bound supplied by . + public static int Min => TBounds.Min; + + /// The inclusive upper bound supplied by . + public static int Max => TBounds.Max; + + /// Wraps , or returns null when it is outside [TBounds.Min, TBounds.Max]. + /// The number to validate. + [Pure] + public static BoundedInt? TryCreate(int value) + { + return value >= TBounds.Min && value <= TBounds.Max + ? new BoundedInt(value - TBounds.Min) + : null; + } +} diff --git a/src/StrongTypes/Numbers/IBounds.cs b/src/StrongTypes/Numbers/IBounds.cs new file mode 100644 index 00000000..93e554ad --- /dev/null +++ b/src/StrongTypes/Numbers/IBounds.cs @@ -0,0 +1,12 @@ +using System.Numerics; + +namespace StrongTypes; + +/// Witness type that supplies a closed range [Min, Max] for (and future bounded numeric wrappers). +/// The underlying numeric type. +/// Implement on a small readonly struct with => literal getters; the bounds travel with the type and the JIT can inline them. +public interface IBounds where T : INumber +{ + static abstract T Min { get; } + static abstract T Max { get; } +} diff --git a/src/StrongTypes/Numbers/NumberExtensions.cs b/src/StrongTypes/Numbers/NumberExtensions.cs index 1cac1f30..0f5ada27 100644 --- a/src/StrongTypes/Numbers/NumberExtensions.cs +++ b/src/StrongTypes/Numbers/NumberExtensions.cs @@ -65,6 +65,21 @@ public static Negative ToNegative(this T value) where T : INumber public static NonPositive ToNonPositive(this T value) where T : INumber => NonPositive.Create(value); + /// Wraps as , or returns null when it is outside [TBounds.Min, TBounds.Max]. + /// A witness type carrying the inclusive lower and upper bounds. + /// The number to validate. + [Pure] + public static BoundedInt? AsBounded(this int value) where TBounds : IBounds + => BoundedInt.TryCreate(value); + + /// Wraps as . + /// A witness type carrying the inclusive lower and upper bounds. + /// The number to validate. + /// is outside [TBounds.Min, TBounds.Max]. + [Pure] + public static BoundedInt ToBounded(this int value) where TBounds : IBounds + => BoundedInt.Create(value); + /// Divides by , or returns null when is zero. /// The dividend. /// The divisor. diff --git a/src/StrongTypes/Numbers/NumericStrongTypeJsonConverterFactory.cs b/src/StrongTypes/Numbers/NumericStrongTypeJsonConverterFactory.cs index d5f1427f..845da379 100644 --- a/src/StrongTypes/Numbers/NumericStrongTypeJsonConverterFactory.cs +++ b/src/StrongTypes/Numbers/NumericStrongTypeJsonConverterFactory.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; using System.Text.Json; @@ -8,31 +7,27 @@ namespace StrongTypes; -/// for , , , and . +/// for any wrapper marked with — including the built-in , , , , and , and any user-defined wrapper that follows the same shape. /// Reads and writes the underlying numeric value; JSON values that fail the wrapper's invariant throw . public sealed class NumericStrongTypeJsonConverterFactory : JsonConverterFactory { - private static readonly HashSet SupportedDefinitions = - [ - typeof(Positive<>), - typeof(NonNegative<>), - typeof(Negative<>), - typeof(NonPositive<>) - ]; - // Inner holds no mutable state and is safe to share across all // JsonSerializerOptions instances, so one cached converter per wrapper type // is plenty. Keyed by the closed wrapper type (e.g. Positive). private static readonly ConcurrentDictionary s_converterCache = new(); public override bool CanConvert(Type typeToConvert) => - typeToConvert.IsGenericType - && SupportedDefinitions.Contains(typeToConvert.GetGenericTypeDefinition()); + typeToConvert.IsValueType + && typeToConvert.GetCustomAttribute(inherit: false) is not null; public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => s_converterCache.GetOrAdd(typeToConvert, static t => { - var innerType = t.GetGenericArguments()[0]; + // Read the underlying type from the wrapper's Value property rather + // than the first generic argument: BoundedInt closes over + // the bounds witness, so its first generic argument isn't the + // underlying numeric type. + var innerType = t.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance)!.PropertyType; var converterType = typeof(Inner<,>).MakeGenericType(t, innerType); return (JsonConverter)Activator.CreateInstance(converterType)!; }); From e7a5271786e16ecd0bb2dfe948a46a4c730c5f95 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Sun, 26 Apr 2026 14:25:41 +0200 Subject: [PATCH 2/4] Fix generator output for concrete value-type underlyings When a [NumericWrapper] type uses a concrete value type (e.g. int) as its underlying type, IEquatable.Equals(T) and IComparable.CompareTo(T) require the unannotated parameter type. Emitting Equals(int? other) / CompareTo(int? other) is Nullable, which doesn't satisfy the interface contracts. For generic-parameter underlyings (e.g. T : INumber), T? is a nullable annotation only, so the existing form remains correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../NumericWrapperGenerator.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs b/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs index ff689cc9..d01cd81b 100644 --- a/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs +++ b/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs @@ -36,6 +36,7 @@ internal sealed record Model( string TypeNameWithArity, string SelfType, string UnderlyingType, + bool UnderlyingIsValueType, string? TypeParameterList, ImmutableArray ConstraintClauses, string AccessModifier, @@ -56,6 +57,8 @@ internal sealed record Model( return null; var underlyingType = valueProperty.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var underlyingIsValueType = valueProperty.Type.IsValueType + && valueProperty.Type.TypeKind != TypeKind.TypeParameter; var ns = symbol.ContainingNamespace.IsGlobalNamespace ? string.Empty : symbol.ContainingNamespace.ToDisplayString(); @@ -108,6 +111,7 @@ internal sealed record Model( TypeNameWithArity: typeNameWithArity, SelfType: selfType, UnderlyingType: underlyingType, + UnderlyingIsValueType: underlyingIsValueType, TypeParameterList: typeParameterList, ConstraintClauses: constraintClauses, AccessModifier: accessibility, @@ -223,7 +227,10 @@ public static string EmitType(Model m) sb.AppendLine(); sb.Append(" public bool Equals(").Append(self).AppendLine(" other) => Value.Equals(other.Value);"); - sb.Append(" public bool Equals(").Append(t).AppendLine("? other) => other is not null && Value.Equals(other);"); + if (m.UnderlyingIsValueType) + sb.Append(" public bool Equals(").Append(t).AppendLine(" other) => Value.Equals(other);"); + else + sb.Append(" public bool Equals(").Append(t).AppendLine("? other) => other is not null && Value.Equals(other);"); sb.AppendLine(); sb.Append(" public static bool operator ==(").Append(self).Append(" left, ").Append(self).AppendLine(" right) => left.Equals(right);"); @@ -235,7 +242,10 @@ public static string EmitType(Model m) sb.AppendLine(); sb.Append(" public int CompareTo(").Append(self).AppendLine(" other) => Value.CompareTo(other.Value);"); - sb.Append(" public int CompareTo(").Append(t).AppendLine("? other) => other is null ? 1 : Value.CompareTo(other);"); + if (m.UnderlyingIsValueType) + sb.Append(" public int CompareTo(").Append(t).AppendLine(" other) => Value.CompareTo(other);"); + else + sb.Append(" public int CompareTo(").Append(t).AppendLine("? other) => other is null ? 1 : Value.CompareTo(other);"); sb.AppendLine(); sb.AppendLine(" int global::System.IComparable.CompareTo(object? obj) => obj switch"); From 98a8b86973120cef93f0b6024efa846cc1baa371 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 24 Jun 2026 10:05:59 +0200 Subject: [PATCH 3/4] Wire BoundedInt through OpenAPI, the API harness, and EF Core Picks up the parts of the BoundedInt feature that didn't exist when the original PR was opened (the OpenApi.*, Api.IntegrationTests, and AspNetCore projects all landed on main since), and fills the EF Core gap. - OpenAPI: BoundedInt renders as `{ type: integer, format: int32, minimum, maximum }` on both the Microsoft and Swashbuckle pipelines. The witness bounds are read off the closed type's static Min/Max at schema time (the range can't be derived from the generic definition alone). Schema tests cover value, nullable-value, and a dedicated nullables endpoint across all three document variants. - Api integration: BoundedIntEntity round-trips through the request pipeline and EF Core (both providers) via the shared EntityTests suite, which also pins the 400 ValidationProblemDetails shape and the `$.value` / `$.nullableValue` error keys. - EF Core: BoundedIntExtensions added to the Unwrap method-call translator so `.Unwrap()` translates to a bare int column, matching the convention that already recognizes BoundedInt<>. - Unit tests: full-range witness (int.MinValue..int.MaxValue) pins the offset-storage modular round-trip; the error-message test now asserts the witness bounds are interpolated, not left as a literal placeholder. - Docs: Skill numeric/openapi references and the OpenApi package readmes now cover BoundedInt and the custom-wrapper recipe. Co-Authored-By: Claude Opus 4.8 --- Skill/SKILL.md | 1 + Skill/references/numeric.md | 61 +++++++++++++++++++ Skill/references/openapi.md | 1 + .../ApiTests/Numeric/BoundedIntEntityTests.cs | 22 +++++++ .../Numeric/NumericUnwrapFilterTests.cs | 23 +++++++ .../Controllers/BoundedIntEntityController.cs | 10 +++ .../Data/PostgreSqlDbContext.cs | 1 + .../Data/SqlServerDbContext.cs | 1 + src/StrongTypes.Api/Entities/Entities.cs | 9 +++ .../UnwrapMethodCallTranslator.cs | 1 + .../NumericWrapperPainter.cs | 15 +++++ .../StrongTypeSchemaTypes.cs | 34 +++++++++++ .../Helpers/BindingSchemaAsserts.cs | 8 +++ .../OpenApiDocumentTestsBase.Numerics.cs | 24 ++++++++ .../Numbers/BoundedIntSchemaTransformer.cs | 27 ++++++++ src/StrongTypes.OpenApi.Microsoft/Startup.cs | 1 + src/StrongTypes.OpenApi.Microsoft/readme.md | 2 + .../Numbers/BoundedIntSchemaFilter.cs | 27 ++++++++ .../Startup.cs | 1 + src/StrongTypes.OpenApi.Swashbuckle/readme.md | 2 + .../BasicControllers.cs | 9 +++ .../Models.cs | 8 +++ .../Numbers/BoundedIntTests.cs | 42 ++++++++++++- 23 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Numeric/BoundedIntEntityTests.cs create mode 100644 src/StrongTypes.Api/Controllers/BoundedIntEntityController.cs create mode 100644 src/StrongTypes.OpenApi.Microsoft/Numbers/BoundedIntSchemaTransformer.cs create mode 100644 src/StrongTypes.OpenApi.Swashbuckle/Numbers/BoundedIntSchemaFilter.cs diff --git a/Skill/SKILL.md b/Skill/SKILL.md index 5063e8d4..4781a8cc 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -46,6 +46,7 @@ demand when about to write code against that surface. | ------------------------------------------------------------------- | ------------------------------------ | ---------------------------------- | | `NonEmptyString` | non-null, non-empty, non-whitespace | `references/nonemptystring.md` | | `Positive` / `NonNegative` / `Negative` / `NonPositive` | sign constraint on any `INumber` | `references/numeric.md` | +| `BoundedInt` | `int` in a closed `[Min, Max]` range | `references/numeric.md` | | `NonEmptyEnumerable` / `INonEmptyEnumerable` | at least one element | `references/nonemptyenumerable.md` | | `Digit` | a single `'0'`–`'9'` character | `references/parsing.md` | diff --git a/Skill/references/numeric.md b/Skill/references/numeric.md index 4ba7fa88..7f2d307a 100644 --- a/Skill/references/numeric.md +++ b/Skill/references/numeric.md @@ -90,6 +90,67 @@ underlying primitive. `Positive` on the wire is `42`, not `{ "Value": 42 }`. Invalid values (`0` for `Positive`, `-1` for `NonNegative`, …) fail with `JsonException` at deserialization. +## `BoundedInt` — a closed `[Min, Max]` range + +When the invariant is a range rather than a sign, use `BoundedInt`: +an `int` constrained to a closed `[Min, Max]` range carried by a **witness +type**. The bounds live on the type, so the rule travels with every +signature — `BoundedInt` *is* a 1..100 integer. + +```csharp +public readonly struct PageSizeBounds : IBounds +{ + public static int Min => 1; + public static int Max => 100; +} + +BoundedInt? p = BoundedInt.TryCreate(input); // null if outside [1,100] +BoundedInt p2 = BoundedInt.Create(input); // throws if outside +BoundedInt? p3 = input.AsBounded(); // null if outside +BoundedInt p4 = input.ToBounded(); // throws if outside + +void GetUsers(BoundedInt pageSize) { … } // 1..100 enforced at the boundary +``` + +- Both endpoints are **inclusive**. `Create` outside the range throws with a + message naming the bounds (`"… must be between 1 and 100 (inclusive)…"`). +- `default(BoundedInt)` wraps `TBounds.Min`, so the default still + satisfies the invariant. +- You get the same free surface as the sign wrappers (equality, comparison, + implicit `→ int`, `.Value` / `.Unwrap()`, JSON round-trip, `.Min` / `.Max` + extensions) **except** `Sum` — a bounded range is not closed under addition. +- Write the witness as a small `readonly struct` with `=> literal` getters so + the JIT can inline the bounds. +- OpenAPI: renders as `{ "type": "integer", "format": "int32", "minimum": Min, + "maximum": Max }`; EF Core stores it as a plain `int` column. + +## Defining your own validated wrapper + +`Positive`, `BoundedInt`, and the rest are just `partial struct`s +tagged with `[NumericWrapper]`. The source generator emits all the equality, +comparison, conversion, `Create`, operator, and JSON boilerplate; the JSON +converter factory recognises **any** struct carrying the attribute. To add a +new validated numeric wrapper, write only the `Value` property and `TryCreate`: + +```csharp +[NumericWrapper(InvariantDescription = "even")] +[JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +public readonly partial struct EvenInt +{ + public int Value { get; } + private EvenInt(int value) { Value = value; } + + public static EvenInt? TryCreate(int value) + => value % 2 == 0 ? new EvenInt(value) : null; +} +``` + +That's the whole file. `Create` (throwing `"Value must be even, …"`), `==`, +`<`, `IEquatable`, `implicit operator int`, `ToString`, JSON +round-tripping, and `EvenIntExtensions.Min` / `.Max` / `.Unwrap` are all +generated from the attribute. The EfCore and OpenAPI packages pick it up +automatically — no per-type registration. + ## Modelling tips ```csharp diff --git a/Skill/references/openapi.md b/Skill/references/openapi.md index e87c5044..611878a4 100644 --- a/Skill/references/openapi.md +++ b/Skill/references/openapi.md @@ -65,6 +65,7 @@ app.UseSwaggerUI(); | `NonNegative` | underlying primitive with `minimum: 0` | | `Negative` | underlying primitive with `exclusiveMaximum: 0` (3.1) or `maximum: 0, exclusiveMaximum: true` (3.0) | | `NonPositive` | underlying primitive with `maximum: 0` | +| `BoundedInt` | `{ "type": "integer", "format": "int32", "minimum": Min, "maximum": Max }` (witness bounds) | | `NonEmptyEnumerable` / `INonEmptyEnumerable` | `{ "type": "array", "minItems": 1, "items": }` | | `Maybe` | `{ "type": "object", "properties": { "Value": } }` | | `IEnumerable` where `T` is a strong-type wrapper | `{ "type": "array", "items": }` (no `minItems` — element schema only) | diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Numeric/BoundedIntEntityTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Numeric/BoundedIntEntityTests.cs new file mode 100644 index 00000000..840eefd8 --- /dev/null +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Numeric/BoundedIntEntityTests.cs @@ -0,0 +1,22 @@ +using StrongTypes.Api.Entities; +using StrongTypes.Api.IntegrationTests.Infrastructure; +using Xunit; + +namespace StrongTypes.Api.IntegrationTests.Tests; + +[Collection(IntegrationTestCollection.Name)] +public sealed class BoundedIntEntityTests(TestWebApplicationFactory factory) + : EntityTests, BoundedInt?, int>(factory), + IEntityTestData +{ + protected override string RoutePrefix => "bounded-int-entities"; + protected override BoundedInt Create(int raw) => BoundedInt.Create(raw); + protected override int FirstValid => 5; + protected override int UpdatedValid => 50; + + // Both ends of the inclusive 1..100 range plus interior values. + public static TheoryData ValidInputs => new() { 1, 5, 50, 99, 100 }; + + // Just-outside both bounds and the extremes. + public static TheoryData InvalidInputs => new() { 0, 101, -1, int.MinValue, int.MaxValue }; +} diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs index 6bdb43fd..5ca730e7 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs @@ -94,6 +94,29 @@ public async Task Negative_UnwrapArithmetic_TranslatesToSql(string provider) Assert.Contains(large.Id, ids); } + [Theory, MemberData(nameof(Providers))] + public async Task BoundedInt_UnwrapArithmetic_TranslatesToSql(string provider) + { + SkipIfSqlServerUnavailable(provider); + + var small = BoundedIntEntity.Create(BoundedInt.Create(3), null); + var large = BoundedIntEntity.Create(BoundedInt.Create(70), null); + SqlDb.Add(small); SqlDb.Add(large); + PgDb.Add(small); PgDb.Add(large); + await SqlDb.SaveChangesAsync(Ct); + await PgDb.SaveChangesAsync(Ct); + + var set = provider == "sql-server" ? SqlDb.BoundedIntEntities : PgDb.BoundedIntEntities; + + var ids = await set + .Where(e => (e.Id == small.Id || e.Id == large.Id) && (long)e.Value.Unwrap() * 2 > 10) + .Select(e => e.Id) + .ToListAsync(Ct); + + Assert.DoesNotContain(small.Id, ids); + Assert.Contains(large.Id, ids); + } + [Theory, MemberData(nameof(Providers))] public async Task NonPositive_UnwrapArithmetic_TranslatesToSql(string provider) { diff --git a/src/StrongTypes.Api/Controllers/BoundedIntEntityController.cs b/src/StrongTypes.Api/Controllers/BoundedIntEntityController.cs new file mode 100644 index 00000000..b549ed95 --- /dev/null +++ b/src/StrongTypes.Api/Controllers/BoundedIntEntityController.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; +using StrongTypes.Api.Data; +using StrongTypes.Api.Entities; + +namespace StrongTypes.Api.Controllers; + +[ApiController] +[Route("bounded-int-entities")] +public sealed class BoundedIntEntityController(SqlServerDbContext sqlCtx, PostgreSqlDbContext pgCtx) + : StructTypeEntityControllerBase>(sqlCtx, pgCtx); diff --git a/src/StrongTypes.Api/Data/PostgreSqlDbContext.cs b/src/StrongTypes.Api/Data/PostgreSqlDbContext.cs index 3158c221..d77893fe 100644 --- a/src/StrongTypes.Api/Data/PostgreSqlDbContext.cs +++ b/src/StrongTypes.Api/Data/PostgreSqlDbContext.cs @@ -7,6 +7,7 @@ public class PostgreSqlDbContext(DbContextOptions options) { public DbSet NonEmptyStringEntities { get; set; } public DbSet EmailEntities { get; set; } + public DbSet BoundedIntEntities { get; set; } public DbSet PositiveIntEntities { get; set; } public DbSet NonNegativeIntEntities { get; set; } diff --git a/src/StrongTypes.Api/Data/SqlServerDbContext.cs b/src/StrongTypes.Api/Data/SqlServerDbContext.cs index b62d64d0..804d8be6 100644 --- a/src/StrongTypes.Api/Data/SqlServerDbContext.cs +++ b/src/StrongTypes.Api/Data/SqlServerDbContext.cs @@ -7,6 +7,7 @@ public class SqlServerDbContext(DbContextOptions options) : { public DbSet NonEmptyStringEntities { get; set; } public DbSet EmailEntities { get; set; } + public DbSet BoundedIntEntities { get; set; } public DbSet PositiveIntEntities { get; set; } public DbSet NonNegativeIntEntities { get; set; } diff --git a/src/StrongTypes.Api/Entities/Entities.cs b/src/StrongTypes.Api/Entities/Entities.cs index 0520a70c..735f15d5 100644 --- a/src/StrongTypes.Api/Entities/Entities.cs +++ b/src/StrongTypes.Api/Entities/Entities.cs @@ -6,6 +6,15 @@ public sealed class NonEmptyStringEntity : EntityBase; +/// Witness giving a 1..100 page-size range. +public readonly struct PageSizeBounds : IBounds +{ + public static int Min => 1; + public static int Max => 100; +} + +public sealed class BoundedIntEntity : EntityBase, BoundedInt?>; + public sealed class PositiveIntEntity : EntityBase, Positive?>; public sealed class NonNegativeIntEntity : EntityBase, NonNegative?>; public sealed class NegativeIntEntity : EntityBase, Negative?>; diff --git a/src/StrongTypes.EfCore/UnwrapMethodCallTranslator.cs b/src/StrongTypes.EfCore/UnwrapMethodCallTranslator.cs index 94480a03..a655c722 100644 --- a/src/StrongTypes.EfCore/UnwrapMethodCallTranslator.cs +++ b/src/StrongTypes.EfCore/UnwrapMethodCallTranslator.cs @@ -23,6 +23,7 @@ public sealed class UnwrapMethodCallTranslator( UnwrapOn(typeof(NonNegativeExtensions)), UnwrapOn(typeof(NegativeExtensions)), UnwrapOn(typeof(NonPositiveExtensions)), + UnwrapOn(typeof(BoundedIntExtensions)), ]; private static MethodInfo UnwrapOn(Type extensionsType) => diff --git a/src/StrongTypes.OpenApi.Core/NumericWrapperPainter.cs b/src/StrongTypes.OpenApi.Core/NumericWrapperPainter.cs index 36c8fc88..dff89479 100644 --- a/src/StrongTypes.OpenApi.Core/NumericWrapperPainter.cs +++ b/src/StrongTypes.OpenApi.Core/NumericWrapperPainter.cs @@ -27,4 +27,19 @@ public static void Paint(OpenApiSchema schema, PrimitiveSchemaMap.Info info, Num else SchemaPaint.TightenUpperBound(schema, bound.Value, bound.Exclusive); } + + /// + /// Paints a closed inclusive range [min, max] over the underlying + /// primitive — the wire shape for , whose + /// witness type carries both bounds at once. + /// + public static void PaintRange(OpenApiSchema schema, Type underlying, decimal min, decimal max) + { + var info = PrimitiveSchemaMap.TryGet(underlying, out var i) ? i : new PrimitiveSchemaMap.Info(JsonSchemaType.Number, null); + SchemaPaint.ClearWrapperShape(schema); + schema.Type = info.Type; + schema.Format = info.Format; + SchemaPaint.TightenLowerBound(schema, min, floorExclusive: false); + SchemaPaint.TightenUpperBound(schema, max, floorExclusive: false); + } } diff --git a/src/StrongTypes.OpenApi.Core/StrongTypeSchemaTypes.cs b/src/StrongTypes.OpenApi.Core/StrongTypeSchemaTypes.cs index 8312799d..b02a17c9 100644 --- a/src/StrongTypes.OpenApi.Core/StrongTypeSchemaTypes.cs +++ b/src/StrongTypes.OpenApi.Core/StrongTypeSchemaTypes.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using System.Reflection; + namespace StrongTypes.OpenApi.Core; public static class StrongTypeSchemaTypes @@ -20,6 +23,36 @@ public static bool IsEmail(Type? clrType) public static bool IsDigit(Type? clrType) => UnwrapNullable(clrType) == typeof(Digit); + public static bool IsBoundedInt(Type? clrType) + { + var unwrapped = UnwrapNullable(clrType); + return unwrapped is { IsGenericType: true } + && unwrapped.GetGenericTypeDefinition() == typeof(BoundedInt<>); + } + + /// + /// Reads the underlying primitive and the inclusive [Min, Max] + /// range of a . Unlike the four + /// single-bound numeric wrappers, the range is carried by the witness + /// type, so it's read off the closed wrapper's static Min/Max + /// at schema-generation time rather than from a static table. + /// + public static bool TryGetBoundedInt(Type? clrType, out Type valueType, out decimal min, out decimal max) + { + valueType = null!; + min = default; + max = default; + + var unwrapped = UnwrapNullable(clrType); + if (unwrapped is not { IsGenericType: true } || unwrapped.GetGenericTypeDefinition() != typeof(BoundedInt<>)) + return false; + + valueType = unwrapped.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance)!.PropertyType; + min = Convert.ToDecimal(unwrapped.GetProperty("Min", BindingFlags.Public | BindingFlags.Static)!.GetValue(null), CultureInfo.InvariantCulture); + max = Convert.ToDecimal(unwrapped.GetProperty("Max", BindingFlags.Public | BindingFlags.Static)!.GetValue(null), CultureInfo.InvariantCulture); + return true; + } + public static bool TryGetNumeric(Type? clrType, out Type valueType, out NumericBound bound) { valueType = null!; @@ -63,6 +96,7 @@ public static bool TryGetMaybeValue(Type? clrType, out Type valueType) { if (IsNonEmptyString(clrType) || IsEmail(clrType)) return typeof(string); if (IsDigit(clrType)) return typeof(int); + if (TryGetBoundedInt(clrType, out var boundedValueType, out _, out _)) return boundedValueType; if (TryGetNumeric(clrType, out var valueType, out _)) return valueType; if (TryGetNonEmptyEnumerableElement(clrType, out var elementType)) return typeof(IEnumerable<>).MakeGenericType(elementType); return null; diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/BindingSchemaAsserts.cs b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/BindingSchemaAsserts.cs index 29119652..fdc214b6 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/BindingSchemaAsserts.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/BindingSchemaAsserts.cs @@ -50,6 +50,14 @@ internal static void AssertDigitSchema(JsonElement schema) internal static void AssertFormPropertyDigitSchema(JsonElement formSchema, string propertyName) => AssertDigitSchema(GetFormProperty(formSchema, propertyName)); + // ── BoundedInt ────────────────────────────────────────────── + // The witness type's inclusive [Min, Max] range becomes a plain + // minimum/maximum pair. PageSizeBounds carries 1..100. Inclusive bounds + // don't depend on OpenAPI version — there's no exclusive boundary. + + internal static void AssertPageSizeBoundedIntSchema(JsonElement schema) + => AssertJsonEquals(schema, """{"type":"integer","format":"int32","minimum":1,"maximum":100}"""); + // ── Other numeric wrappers ────────────────────────────────────────── // Inclusive-bound shapes (NonNegative, NonPositive) don't depend on // OpenAPI version — there's no exclusive boundary to encode. Exclusive diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Numerics.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Numerics.cs index 8dc25f50..a24ed8df 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Numerics.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Numerics.cs @@ -48,6 +48,30 @@ public async Task Digit_Renders_As_Integer_With_Zero_To_Nine_Bounds() AssertDigitSchema(Property(body, "value")); } + [Fact] + public async Task BoundedInt_Renders_As_Integer_With_Witness_Min_Max_Bounds() + { + var doc = await GetDocumentAsync(); + var body = FollowRef(doc, RequestSchema(doc, "/bounded-int-entities")); + AssertPageSizeBoundedIntSchema(Property(body, "value")); + } + + [Fact] + public async Task Nullable_BoundedInt_Property_Still_Renders_With_Witness_Min_Max_Bounds() + { + var doc = await GetDocumentAsync(); + var body = FollowRef(doc, RequestSchema(doc, "/bounded-int-entities")); + AssertPageSizeBoundedIntSchema(UnwrapNullableProperty(Property(body, "nullableValue"), Version)); + } + + [Fact] + public async Task Nullable_BoundedInt_On_Dedicated_Nullables_Endpoint_Renders_With_Witness_Min_Max_Bounds() + { + var doc = await GetDocumentAsync(); + var body = FollowRef(doc, RequestSchema(doc, "/nullable-strong-types")); + AssertPageSizeBoundedIntSchema(UnwrapNullableProperty(Property(body, "nullableBoundedInt"), Version)); + } + [Fact] public async Task Nullable_Positive_Int_Property_Still_Renders_As_Integer_With_ExclusiveMinimum_Zero() { diff --git a/src/StrongTypes.OpenApi.Microsoft/Numbers/BoundedIntSchemaTransformer.cs b/src/StrongTypes.OpenApi.Microsoft/Numbers/BoundedIntSchemaTransformer.cs new file mode 100644 index 00000000..3cafa9a8 --- /dev/null +++ b/src/StrongTypes.OpenApi.Microsoft/Numbers/BoundedIntSchemaTransformer.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; +using StrongTypes.OpenApi.Core; + +namespace StrongTypes.OpenApi.Microsoft; + +/// +/// Rewrites the schema for to the underlying +/// integer with the witness type's inclusive bounds — e.g. for +/// BoundedInt<PageSizeBounds> with a 1..100 range: +/// +/// { "type": "integer", "format": "int32", "minimum": 1, "maximum": 100 } +/// +/// +public sealed class BoundedIntSchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (StrongTypeSchemaTypes.TryGetBoundedInt(context.JsonTypeInfo.Type, out var valueType, out var min, out var max)) + { + NumericWrapperPainter.PaintRange(schema, valueType, min, max); + StrongTypeInlineMarker.Set(schema); + } + + return Task.CompletedTask; + } +} diff --git a/src/StrongTypes.OpenApi.Microsoft/Startup.cs b/src/StrongTypes.OpenApi.Microsoft/Startup.cs index 531e361a..1468c5fc 100644 --- a/src/StrongTypes.OpenApi.Microsoft/Startup.cs +++ b/src/StrongTypes.OpenApi.Microsoft/Startup.cs @@ -21,6 +21,7 @@ public static OpenApiOptions AddStrongTypes(this OpenApiOptions options) options.AddSchemaTransformer(); options.AddSchemaTransformer(); options.AddSchemaTransformer(); + options.AddSchemaTransformer(); options.AddSchemaTransformer(); options.AddSchemaTransformer(); options.AddSchemaTransformer(); diff --git a/src/StrongTypes.OpenApi.Microsoft/readme.md b/src/StrongTypes.OpenApi.Microsoft/readme.md index 570f101f..9f86be47 100644 --- a/src/StrongTypes.OpenApi.Microsoft/readme.md +++ b/src/StrongTypes.OpenApi.Microsoft/readme.md @@ -37,6 +37,8 @@ app.MapOpenApi(); - `NonNegative` → underlying primitive with `minimum: 0` - `Negative` → underlying primitive with `exclusiveMaximum: 0` - `NonPositive` → underlying primitive with `maximum: 0` +- `BoundedInt` → `{ "type": "integer", "format": "int32" }` with the + witness type's inclusive `minimum` / `maximum` (e.g. `minimum: 1, maximum: 100`) - `NonEmptyEnumerable` and `INonEmptyEnumerable` → `{ "type": "array", "minItems": 1, "items": }` - `Maybe` → object wrapper `{ "Value": }` matching the diff --git a/src/StrongTypes.OpenApi.Swashbuckle/Numbers/BoundedIntSchemaFilter.cs b/src/StrongTypes.OpenApi.Swashbuckle/Numbers/BoundedIntSchemaFilter.cs new file mode 100644 index 00000000..a44107bd --- /dev/null +++ b/src/StrongTypes.OpenApi.Swashbuckle/Numbers/BoundedIntSchemaFilter.cs @@ -0,0 +1,27 @@ +using Microsoft.OpenApi; +using StrongTypes.OpenApi.Core; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace StrongTypes.OpenApi.Swashbuckle; + +/// +/// Rewrites the schema for to the underlying +/// integer with the witness type's inclusive bounds — e.g. for +/// BoundedInt<PageSizeBounds> with a 1..100 range: +/// +/// { "type": "integer", "format": "int32", "minimum": 1, "maximum": 100 } +/// +/// +public sealed class BoundedIntSchemaFilter : ISchemaFilter +{ + public void Apply(IOpenApiSchema schema, SchemaFilterContext context) + { + if (schema is not OpenApiSchema concrete) return; + + if (StrongTypeSchemaTypes.TryGetBoundedInt(context.Type, out var valueType, out var min, out var max)) + { + NumericWrapperPainter.PaintRange(concrete, valueType, min, max); + StrongTypeInlineMarker.Set(concrete); + } + } +} diff --git a/src/StrongTypes.OpenApi.Swashbuckle/Startup.cs b/src/StrongTypes.OpenApi.Swashbuckle/Startup.cs index ba524ceb..5db8056d 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/Startup.cs +++ b/src/StrongTypes.OpenApi.Swashbuckle/Startup.cs @@ -22,6 +22,7 @@ public static SwaggerGenOptions AddStrongTypes(this SwaggerGenOptions options) options.SchemaFilter(); options.SchemaFilter(); options.SchemaFilter(); + options.SchemaFilter(); options.SchemaFilter(); options.SchemaFilter(); options.SchemaFilter(); diff --git a/src/StrongTypes.OpenApi.Swashbuckle/readme.md b/src/StrongTypes.OpenApi.Swashbuckle/readme.md index c1d973bc..ce96d059 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/readme.md +++ b/src/StrongTypes.OpenApi.Swashbuckle/readme.md @@ -38,6 +38,8 @@ app.UseSwaggerUI(); - `NonNegative` → underlying primitive with `minimum: 0` - `Negative` → underlying primitive with `maximum: 0, exclusiveMaximum: true` - `NonPositive` → underlying primitive with `maximum: 0` +- `BoundedInt` → `{ "type": "integer", "format": "int32" }` with the + witness type's inclusive `minimum` / `maximum` (e.g. `minimum: 1, maximum: 100`) - `NonEmptyEnumerable` and `INonEmptyEnumerable` → `{ "type": "array", "minItems": 1, "items": }` - `Maybe` → object wrapper `{ "Value": }` matching the diff --git a/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs b/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs index 4004f3e9..a8af0ddd 100644 --- a/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs +++ b/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs @@ -61,6 +61,15 @@ public IActionResult Create(StructEntityRequest request) => Ok(new EntityResponse(Guid.NewGuid())); } +[ApiController] +[Route("bounded-int-entities")] +public sealed class BoundedIntEntityController : ControllerBase +{ + [HttpPost] + public IActionResult Create(StructEntityRequest> request) + => Ok(new EntityResponse(Guid.NewGuid())); +} + [ApiController] [Route("non-positive-decimal-entities")] public sealed class NonPositiveDecimalEntityController : ControllerBase diff --git a/src/StrongTypes.OpenApi.TestApi.Shared/Models.cs b/src/StrongTypes.OpenApi.TestApi.Shared/Models.cs index 4fda539d..d3c05d70 100644 --- a/src/StrongTypes.OpenApi.TestApi.Shared/Models.cs +++ b/src/StrongTypes.OpenApi.TestApi.Shared/Models.cs @@ -4,6 +4,13 @@ namespace StrongTypes.OpenApi.TestApi.Shared; public sealed record StructEntityRequest(T Value, T? NullableValue) where T : struct; +/// Witness type giving a 1..100 range — a page-size invariant carried in the type. +public readonly struct PageSizeBounds : IBounds +{ + public static int Min => 1; + public static int Max => 100; +} + public sealed record DecimalEntityRequest(NonPositive Value, decimal PlainValue); public sealed record ReferenceEntityRequest(T Value, T? NullableValue) where T : class; @@ -34,6 +41,7 @@ public sealed record NullableStrongTypesRequest( NonEmptyString? NullableNonEmptyString, Positive? NullablePositiveInt, Digit? NullableDigit, + BoundedInt? NullableBoundedInt, NonEmptyEnumerable? NullableNonEmptyStringArray, NonEmptyEnumerable>? NullableNonEmptyPositiveIntArray); diff --git a/src/StrongTypes.Tests/Numbers/BoundedIntTests.cs b/src/StrongTypes.Tests/Numbers/BoundedIntTests.cs index 565bd357..44ebc330 100644 --- a/src/StrongTypes.Tests/Numbers/BoundedIntTests.cs +++ b/src/StrongTypes.Tests/Numbers/BoundedIntTests.cs @@ -23,6 +23,15 @@ namespace StrongTypes.Tests; public static int Max => 7; } +// Spans the entire int domain. Exercises the offset-storage trick +// (_offset = value - Min): for this range value - Min overflows, but +// two's-complement modular arithmetic makes Value round-trip exactly. +public readonly struct FullRangeBounds : IBounds +{ + public static int Min => int.MinValue; + public static int Max => int.MaxValue; +} + public class BoundedIntTests { private static bool IsInPageRange(int value) => @@ -228,11 +237,38 @@ public void ExplicitOperator_FromUnderlying_ThrowsOnInvariantViolation() } [Fact] - public void Create_ErrorMessage_MentionsBounds() + public void Create_ErrorMessage_InterpolatesWitnessBounds() { var ex = Assert.Throws(() => BoundedInt.Create(0)); - Assert.Contains("1", ex.Message); - Assert.Contains("100", ex.Message); + // The witness bounds are interpolated at runtime — the literal + // "{TBounds.Min}" placeholder must not survive into the message. + Assert.Contains("between 1 and 100 (inclusive)", ex.Message); + Assert.Contains("was '0'", ex.Message); + Assert.DoesNotContain("TBounds", ex.Message); + } + + // ── Full-range bounds: offset-storage round-trip ──────────────────── + + [Property] + public void FullRange_AcceptsEveryValue_AndRoundTrips(int value) + { + var bounded = BoundedInt.Create(value); + Assert.Equal(value, bounded.Value); + Assert.Equal(value, (int)bounded); + } + + [Fact] + public void FullRange_Boundaries_RoundTrip() + { + Assert.Equal(int.MinValue, BoundedInt.Create(int.MinValue).Value); + Assert.Equal(int.MaxValue, BoundedInt.Create(int.MaxValue).Value); + Assert.Equal(0, BoundedInt.Create(0).Value); + } + + [Fact] + public void FullRange_Default_RepresentsMin() + { + Assert.Equal(int.MinValue, default(BoundedInt).Value); } [Fact] From de9bf23749e009711b789722d9178d0d52ecef0d Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 24 Jun 2026 11:54:46 +0200 Subject: [PATCH 4/4] Wire BoundedInt through WPF and add a new-type integration checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WPF: BoundedInt is a scalar IParsable value, so it belongs in the WPF TypeConverter wiring alongside the sign wrappers. Adds it to the type description provider, a PageSize property + PageSizeBounds witness on the test view-model, and a BoundedIntBindingTests class (OneWay display, TwoWay valid, out-of-range and non-numeric validation errors). WPF readme + Skill WPF reference updated. Docs: the original BoundedInt PR missed WPF, the EF Core Unwrap translator, and OpenAPI because the guidance only covered *test* projects, never the production wiring. Adds an "Adding a new strong type — integration checklist" to CLAUDE.md enumerating every package a type must be wired through (core, EF Core convention + Unwrap translator, both OpenAPI pipelines, WPF, analyzers), with pointers to testing.md and CONTRIBUTING for tests and docs. testing.md gains the missing WPF row and a WPF binding-test section; CONTRIBUTING points at the checklist. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 66 ++++++++++++++++ CONTRIBUTING.md | 4 + Skill/references/wpf.md | 3 +- .../PersonViewModel.cs | 14 ++++ src/StrongTypes.Wpf.Tests/BindingTests.cs | 78 +++++++++++++++++++ .../StrongTypesTypeDescriptionProvider.cs | 3 +- src/StrongTypes.Wpf/readme.md | 2 +- testing.md | 22 ++++++ 8 files changed, 189 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ac5437d..526b6dcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,72 @@ Rules: globally yet, so add `#nullable enable` at the top of each new file. - Do **not** return `Option` from new code. +## Adding a new strong type — integration checklist + +A strong type is only "done" when it is wired through **every** package +that the rest of the library already supports for its shape. This list is +the single place that enumerates those wiring points; miss one and the +type works in isolation but silently breaks for consumers (no DB +converter, a `{}` OpenAPI schema, no WPF binding, …). Walk it top to +bottom for every new type, and tick only what applies to that type's +shape. + +**Core — always** (`src/StrongTypes//`) + +- [ ] The type, following the `TryCreate` / `Create` pattern above. +- [ ] JSON converter. For a numeric wrapper this is just + `[NumericWrapper]` + `[JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))]` + on the `partial struct` — the source generator and factory do the + rest. Other shapes ship a hand-written `JsonConverter`. +- [ ] `AsX` / `ToX` (or equivalent) extension methods in the feature + folder. + +**EF Core** (`src/StrongTypes.EfCore/`) — if the type can be stored in a column + +- [ ] `StrongTypesConvention` — add the type to **both** `IsStrongType` + and `ResolveConverter` so the value converter is attached during + property discovery. +- [ ] `UnwrapMethodCallTranslator` — add the type's generated + `Extensions` to `UnwrapMethodDefinitions` so `.Unwrap()` + translates to a bare column in LINQ. (Every source-generated wrapper + gets an `Unwrap`; if you skip this the convention still stores the + type but server-side predicates on `.Unwrap()` throw.) + +**Analyzers** (`src/StrongTypes.Analyzers/`) — if the type is EF-Core-storable + +- [ ] `MissingEfCorePackageAnalyzer` — recognize the type so the + "reference the EfCore package" diagnostic fires for consumers who + map it without the package. + +**OpenAPI — both pipelines** (`src/StrongTypes.OpenApi.*`) — if it appears in a request/response + +- [ ] `StrongTypeSchemaTypes` (Core) — a detection helper and a branch in + `ResolveWireType` returning the underlying wire type. +- [ ] A **Microsoft** schema transformer **and** a **Swashbuckle** schema + filter, each registered in that package's `Startup.AddStrongTypes`. + A single-bound numeric wrapper just adds a row to + `NumericWrapperKinds`; a fixed/range type follows `Digit` / + `BoundedInt` (paint both bounds + set the inline marker). +- [ ] Verify the document: generate it for both pipelines and confirm the + property carries the expected keywords and that **no `{}` wrapper + component leaks** into `components.schemas`. Only add a + `StrongTypesComponentSchemaFiller` (Microsoft) entry if the framework + actually dedups your type into an unpainted component — primitive- + painted types usually inline and need nothing. + +**WPF** (`src/StrongTypes.Wpf/`) — if the type is a scalar `IParsable` value worth binding to a `TextBox` + +- [ ] `StrongTypesTypeDescriptionProvider.TryCreateConverter` — add the + type so WPF's binding pipeline finds a `TypeConverter`. (Non-scalar + shapes like `Maybe` / `Result` don't apply.) + +**Tests** — see [`testing.md`](testing.md) for which test projects each +shape requires and how to write each kind. + +**Docs** — main [`readme.md`](readme.md), the affected package readmes, +and the `Skill/` (catalog row + reference) — see +[`CONTRIBUTING.md`](CONTRIBUTING.md) and "Skill — keep it in sync" below. + ## Tests All testing rules — unit, API integration, OpenAPI integration, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0276144..f4532503 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,10 @@ wasted work if the design needs to change. - Match the conventions in [`CLAUDE.md`](CLAUDE.md) — it covers folder layout, the `TryCreate` / `Create` pattern, comment style, and line wrapping rules. Those rules apply to every contributor. +- Adding a new strong type? Follow the **"Adding a new strong type — + integration checklist"** in [`CLAUDE.md`](CLAUDE.md). It enumerates + every package the type must be wired through (EF Core, both OpenAPI + pipelines, WPF, analyzers) so it isn't left half-supported. - PRs are squash-merged. ## Testing diff --git a/Skill/references/wpf.md b/Skill/references/wpf.md index 3d3b076e..ea8e4cef 100644 --- a/Skill/references/wpf.md +++ b/Skill/references/wpf.md @@ -17,7 +17,8 @@ UI dependency, so the wiring lives here. Call `this.UseStrongTypes()` once in `App.OnStartup`. One call covers every strong type, including every closed instantiation of the -generic numeric wrappers (`Positive`, `Negative`, …) — +generic numeric wrappers (`Positive`, `Negative`, +`BoundedInt`, …) — the package installs a `TypeDescriptionProvider` that synthesises the converter on demand the first time WPF asks for it. diff --git a/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs b/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs index c70c62a0..db8ddb4f 100644 --- a/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs +++ b/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs @@ -5,11 +5,19 @@ namespace StrongTypes.Wpf.TestApp; +/// Witness giving a 1..100 page-size range. +public readonly struct PageSizeBounds : IBounds +{ + public static int Min => 1; + public static int Max => 100; +} + public sealed class PersonViewModel : INotifyPropertyChanged { private NonEmptyString _name = NonEmptyString.Create("Alice"); private Email _email = Email.Create("alice@example.com"); private Positive _age = Positive.Create(30); + private BoundedInt _pageSize = BoundedInt.Create(20); public NonEmptyString Name { @@ -29,6 +37,12 @@ public Positive Age set { _age = value; Raise(); } } + public BoundedInt PageSize + { + get => _pageSize; + set { _pageSize = value; Raise(); } + } + public event PropertyChangedEventHandler? PropertyChanged; private void Raise([CallerMemberName] string? propertyName = null) => diff --git a/src/StrongTypes.Wpf.Tests/BindingTests.cs b/src/StrongTypes.Wpf.Tests/BindingTests.cs index 089a7fc0..14983b5e 100644 --- a/src/StrongTypes.Wpf.Tests/BindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/BindingTests.cs @@ -146,6 +146,84 @@ public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() }; } +public class BoundedIntBindingTests +{ + [Fact] + public void OneWay_DisplaysCurrentValue() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { PageSize = BoundedInt.Create(20) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, OneWay(nameof(vm.PageSize), vm)); + + Assert.Equal("20", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { PageSize = BoundedInt.Create(20) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.PageSize), vm)); + + textBox.Text = "50"; + + Assert.Equal(BoundedInt.Create(50), vm.PageSize); + }); + } + + [Fact] + public void TwoWay_OutOfRangeInput_DoesNotMutateSourceAndRaisesValidationError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { PageSize = BoundedInt.Create(20) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.PageSize), vm)); + + textBox.Text = "101"; + + Assert.Equal(BoundedInt.Create(20), vm.PageSize); + Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + }); + } + + [Fact] + public void TwoWay_NonNumericInput_DoesNotMutateSourceAndRaisesValidationError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { PageSize = BoundedInt.Create(20) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.PageSize), vm)); + + textBox.Text = "abc"; + + Assert.Equal(BoundedInt.Create(20), vm.PageSize); + Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + }); + } + + private static Binding OneWay(string path, object source) => new(path) + { + Source = source, + Mode = BindingMode.OneWay, + }; + + private static Binding TwoWay(string path, object source) => new(path) + { + Source = source, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, + ValidatesOnExceptions = true, + NotifyOnValidationError = true, + }; +} + public class PositiveIntBindingTests { [Fact] diff --git a/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs b/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs index 5a77ef97..502f5ae4 100644 --- a/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs +++ b/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs @@ -28,7 +28,8 @@ internal sealed class StrongTypesTypeDescriptionProvider(TypeDescriptionProvider if (definition != typeof(Positive<>) && definition != typeof(NonNegative<>) && definition != typeof(Negative<>) - && definition != typeof(NonPositive<>)) + && definition != typeof(NonPositive<>) + && definition != typeof(BoundedInt<>)) return null; var converterType = typeof(ParsableTypeConverter<>).MakeGenericType(type); return (TypeConverter)Activator.CreateInstance(converterType)!; diff --git a/src/StrongTypes.Wpf/readme.md b/src/StrongTypes.Wpf/readme.md index 15fefe8c..0a1f36b2 100644 --- a/src/StrongTypes.Wpf/readme.md +++ b/src/StrongTypes.Wpf/readme.md @@ -10,7 +10,7 @@ WPF's binding pipeline routes `string → T` through `TypeDescriptor.GetConverte ## Usage -Call `this.UseStrongTypes()` once in `App.OnStartup`. One call covers every strong type — including every closed instantiation of the generic numeric wrappers (`Positive`, `Negative`, …) — because the package installs a `TypeDescriptionProvider` that synthesizes the converter on demand the first time WPF asks for it. +Call `this.UseStrongTypes()` once in `App.OnStartup`. One call covers every strong type — including every closed instantiation of the generic numeric wrappers (`Positive`, `Negative`, `BoundedInt`, …) — because the package installs a `TypeDescriptionProvider` that synthesizes the converter on demand the first time WPF asks for it. ```csharp public partial class App : Application diff --git a/testing.md b/testing.md index a693a8e2..2b6d4893 100644 --- a/testing.md +++ b/testing.md @@ -15,6 +15,7 @@ needs to touch depends on what the type does: | Is meant to be stored in a database | `StrongTypes.Api.IntegrationTests` (covers both SQL Server and PostgreSQL) | | Appears in an ASP.NET Core request/response | `StrongTypes.OpenApi.IntegrationTests` | | Binds from a non-body source, or affects MVC error handling | `StrongTypes.AspNetCore.IntegrationTests` | +| Is a scalar `IParsable` value bindable in WPF | `StrongTypes.Wpf.Tests` | | Ships an analyzer or code fix | `StrongTypes.Analyzers.Tests` | ## Unit tests — `StrongTypes.Tests` @@ -201,3 +202,24 @@ through the real Roslyn pipeline using `AnalyzerTester` / Cover both directions: the analyzer **fires** when the offending pattern is present and the required reference is missing, and is **silent** when the reference is present. + +## WPF binding tests — `StrongTypes.Wpf.Tests` + +Covers the `Kalicz.StrongTypes.Wpf` `TypeConverter` wiring — the bridge +that lets WPF two-way-bind a `TextBox.Text` to a strong-typed view-model +property. These run on `net10.0-windows` and must execute on an STA +thread (wrap each test body in `StaThread.Run(...)`). + +When adding a scalar `IParsable` type that should be bindable: + +1. Add a property of the type to `PersonViewModel` in + `StrongTypes.Wpf.TestApp` (declare any witness type, e.g. an + `IBounds`, there too). +2. Add a binding-test class mirroring `PositiveIntBindingTests`. Bind a + `TextBox` to the property and cover, at minimum: **OneWay** displays + the current value as text; **TwoWay** with valid input updates the + source; **TwoWay** with input that violates the invariant leaves the + source unchanged and sets `Validation.GetHasError(textBox)` (the + `Create` / `Parse` `ArgumentException` surfaces as a `ValidationError` + via `ValidatesOnExceptions`); and, for numeric types, **TwoWay** with + non-numeric input does the same.