diff --git a/CLAUDE.md b/CLAUDE.md index 0d8812eb..7fb82785 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,48 +27,19 @@ feature's folder: - `src/StrongTypes/Strings/NonEmptyString.cs` - `src/StrongTypes/Strings/NonEmptyStringExtensions.cs` - `src/StrongTypes/Strings/NonEmptyStringJsonConverter.cs` -- `src/StrongTypes/Try/TryExtensions.cs` +- `src/StrongTypes/Maybe/MaybeExtensions.cs` Extensions that *produce* a feature type go into that feature's folder -even when the input type is from somewhere else (e.g. `ToTry` on `T?` -lives under `Try/`, because the result — not the receiver — is what +even when the input type is from somewhere else (e.g. `ToMaybe` on `T?` +lives under `Maybe/`, because the result — not the receiver — is what defines the slice). Namespaces stay flat at `StrongTypes` regardless of folder nesting. Folder structure is for humans; the namespace is one flat shelf. The same applies to the test project — keep tests in `namespace StrongTypes.Tests` even when they live in subfolders, to avoid shadowing type names (e.g., a -nested `StrongTypes.Tests.Try` namespace would hide the `StrongTypes.Try` -class from sibling files). - -## Migration from _Old - -The codebase is mid-migration. Anything under a folder or filename suffixed with -`_Old` is legacy and should not be modified or extended. New code goes in -non-suffixed folders (e.g., new string types go in `src/StrongTypes/Strings/`). - -When you rework an _Old type, treat the rewrite as a real review — drop the -`_Old` suffix, read the entire file, and improve anything that is wrong or -outdated, not just what the task names. Do not port code verbatim. - -**Dealing with the _Old file being replaced:** - -- If the old file is *entirely* about the type being reworked (e.g., - `NonEmptyString_Old.cs`), delete it. The new file is the replacement. -- If the old file mixes multiple concerns (e.g., `StringExtensions_Old.cs` - contains extensions for both `string` and `NonEmptyString`), leave the - `_Old` file in place and create a new non-suffixed file containing *only* - the reworked pieces. The remaining legacy surface stays where it was until - its own migration pass. -- In either case, minimally patch remaining `_Old` call sites only as much as - is needed to keep the build green. - -**Other rules:** - -- Do **not** take a dependency on other _Old types (e.g., `Option`). Prefer - modern BCL primitives (nullable reference types, `Result`-shaped APIs, etc.). -- Do not delete _Old files unless explicitly asked or unless the rule above - applies. +nested `StrongTypes.Tests.Maybe` namespace would hide the +`StrongTypes.Maybe` type from sibling files). ## Validated types — the TryCreate / Create pattern @@ -87,14 +58,13 @@ public static NonEmptyString Create(string? value) Rules: - The validation logic lives in `TryCreate` only. `Create` delegates. - Constructors are `private` — callers must go through the factories. -- Use nullable reference types. The library project does not enable them - globally yet, so add `#nullable enable` at the top of each new file. -- Do **not** return `Option` from new code. +- Use nullable reference types. ## Tests -All testing rules — unit, API integration, OpenAPI integration, and -analyzer tests — live in [`testing.md`](testing.md). **Read that file +All testing rules — unit, API / ASP.NET Core / OpenAPI integration, +configuration binding, WPF, and analyzer tests — live in +[`testing.md`](testing.md). **Read that file before writing or modifying a test, and before writing any code that will need tests** (a new strong type, a converter, an analyzer, an API endpoint, …). It is the single source of truth; do not infer testing @@ -158,10 +128,6 @@ that a host may opt into skipping it when the amd64-only image won't start [`testing.md`](testing.md). Absent the opt-in, a SQL Server that fails to start is always a hard failure, never a silent skip. -Current state: uses plain `string` / `string?`. Strong-type converters -(EF Core value converters, JSON converters) will be wired in once the -parallel work on those lands. - ## Comments — XML and `//` **XML comments** (`/// `, ``, ``, …) are for diff --git a/Skill/SKILL.md b/Skill/SKILL.md index c2b4a5b7..a5842d16 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -7,10 +7,12 @@ description: Write idiomatic Kalicz.StrongTypes code (NonEmptyString, numeric wr Library of focused C# value wrappers (`NonEmptyString`, `Positive`, `NonEmptyEnumerable`, …) and algebraic types (`Maybe`, -`Result`) that push invariants into the type system. Every -wrapper ships a `System.Text.Json` converter, so invalid input fails at -deserialization before any endpoint code runs, and a `TypeConverter`, so -the same invariant validates `appsettings.json` as it binds. +`Result`) that push invariants into the type system. The +wrappers ship `System.Text.Json` converters (`Digit` and `Result` +are the exceptions), so invalid input fails at deserialization before any +endpoint code runs, and the scalar wrappers (`NonEmptyString`, `Email`, +`Digit`, the numerics) carry a `TypeConverter`, so the same invariant +validates `appsettings.json` as it binds. Per-type detail lives in `references/*.md` — load the relevant file on demand when about to write code against that surface. @@ -29,7 +31,7 @@ demand when about to write code against that surface. Add packages only when the host project actually hits that stack: -- **Configuration** — when a non-nullable reference property (`NonEmptyString`, `Email`, or a plain `string`) sits on an options class. Binding works without it (every wrapper carries a `TypeConverter`); the package only stops an absent key leaving that property null. Skip it if every reference property is nullable or has a default, or if you already use `[Required]`. See `references/configuration.md`. +- **Configuration** — when a non-nullable reference property (`NonEmptyString`, `Email`, or a plain `string`) sits on an options class. Binding works without it (the scalar wrappers carry a `TypeConverter`); the package only stops an absent key leaving that property null. Skip it if every reference property is nullable or has a default, or if you already use `[Required]`. See `references/configuration.md`. - **EfCore** — only if EF Core is in use. - **FsCheck** — only for property-based test projects. - **OpenApi.Microsoft** vs **OpenApi.Swashbuckle** — pick **one**, matching the spec generator the app already wires up. They are not interchangeable. `references/openapi.md` covers both pipelines. @@ -42,14 +44,15 @@ Quick scan of what the library ships. Per-type detail (factories, full API surface, edge cases) lives in the linked reference — load it on demand when about to write code against that surface. -**Validated wrappers** (invalid input → `null` from `AsX`, exception from `ToX`) +**Validated wrappers** (invalid input → `null` from `TryCreate` / `AsX`, exception from `Create` / `ToX`) | Type | Invariant | Reference | | ------------------------------------------------------------------- | ------------------------------------ | ---------------------------------- | | `NonEmptyString` | non-null, non-empty, non-whitespace | `references/nonemptystring.md` | +| `Email` | a valid e-mail address, ≤ 254 chars | `references/email.md` | | `Positive` / `NonNegative` / `Negative` / `NonPositive` | sign constraint on any `INumber` | `references/numeric.md` | | `NonEmptyEnumerable` / `INonEmptyEnumerable` | at least one element | `references/nonemptyenumerable.md` | -| `Digit` | a single `'0'`–`'9'` character | `references/parsing.md` | +| `Digit` | a single decimal digit, `0`–`9` | `references/parsing.md` | | `FiniteInterval` / `Interval` / `IntervalFrom` / `IntervalUntil` | ordered endpoints, `Start <= End`, bounds inclusive by default with per-bound `startInclusive`/`endInclusive` opt-out; the variant fixes which endpoints are bounded | `references/intervals.md` | **Algebraic types** (no validation; carry a value or an alternative) @@ -194,7 +197,7 @@ instead of unwrapping eagerly.) ## JSON — zero setup -Every wrapper except `Result` carries `[JsonConverter(...)]`. +Every wrapper except `Result` and `Digit` carries `[JsonConverter(...)]`. Consequences: - No `JsonSerializerOptions.Converters.Add(...)` calls. It just works. diff --git a/Skill/references/collections.md b/Skill/references/collections.md index 6e62b730..377fe90c 100644 --- a/Skill/references/collections.md +++ b/Skill/references/collections.md @@ -17,8 +17,8 @@ library. - `Concat(params T[] items)` and `Concat(params IEnumerable[] others)` — flatten a few extras into a sequence without `.Concat(new[] { ... })`: ```csharp - var all = existing.Concat(1, 2, 3); - var all = existing.Concat(list1, list2, list3); + NonEmptyEnumerable all = existing.Concat(1, 2, 3); + NonEmptyEnumerable all = existing.Concat(list1, list2, list3); ``` - `Flatten()` on `IEnumerable>` — an alias for @@ -79,9 +79,6 @@ Exception? agg = list.Aggregate(); // IReadOnlyList ``` -This is what `Result.Aggregate(...)` uses under the hood when `TError` -is `Exception`. - ## Boolean helper ```csharp diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index 3b47704d..cdd614ec 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -1,7 +1,7 @@ # Configuration and options binding Strong types bind from `IConfiguration` / `IOptions` with **no setup** — -every wrapper carries a `TypeConverter`, which is what `ConfigurationBinder` +every scalar wrapper carries a `TypeConverter`, which is what `ConfigurationBinder` uses to turn a config string into a typed value. Put the wrapper straight on the options class: @@ -113,7 +113,7 @@ An options class in an assembly with `disable` declares no on it is enforced. Analyzer **ST0004** flags a plain `Bind` / `Configure` that would leave a non-nullable wrapper null, -with a code fix that rewrites the call. It reports only this library's own wrappers, never a plain +with a code fix that rewrites a `Bind` call (a flagged `Configure` gets the diagnostic only). It reports only this library's own wrappers, never a plain `string`, so it sees less than the check it points you at. It also stays quiet for a property already carrying `[Required]`, which genuinely covers that case. diff --git a/Skill/references/efcore.md b/Skill/references/efcore.md index 6bdd1a06..40c3d8c3 100644 --- a/Skill/references/efcore.md +++ b/Skill/references/efcore.md @@ -85,7 +85,7 @@ Equality, null checks, ordering, and grouping work directly on the wrapper: ```csharp -var needle = "alice".ToNonEmpty(); +NonEmptyString needle = "alice".ToNonEmpty(); var user = await db.Users.SingleOrDefaultAsync(u => u.Name == needle); var withNickname = await db.Users.Where(u => u.Nickname != null).ToListAsync(); diff --git a/Skill/references/email.md b/Skill/references/email.md new file mode 100644 index 00000000..115dfb72 --- /dev/null +++ b/Skill/references/email.md @@ -0,0 +1,48 @@ +# `Email` + +A validated e-mail address wrapping `System.Net.Mail.MailAddress`, capped at +254 characters (the RFC 5321 deliverable limit, `Email.MaxLength`). A sealed +reference type; equality is case-insensitive on the full address. + +## Construction + +```csharp +Email? maybe = Email.TryCreate(input); // null when invalid, too long, or null +Email email = Email.Create(input); // throws ArgumentException when invalid + +// The string extensions produce the BCL MailAddress instead: +MailAddress? parsed = input.AsEmail(); // null when invalid +MailAddress strict = input.ToEmail(); // throws when invalid +``` + +`MailAddress` converts to `Email` implicitly (`Email e = mailAddress;`), and +`Email` converts implicitly to both `string` (the address) and `MailAddress`, +so it passes straight into APIs that take either. + +## What you get + +- `Value` — the underlying `MailAddress` (use it for `.User` / `.Host`). +- `Address` — the address string; `ToString()` returns the same. +- Case-insensitive equality (`==` / `!=`) against `Email`, `MailAddress`, + `string`, and `NonEmptyString`, and `CompareTo` for ordering. +- `IParsable`, a JSON converter, and a `TypeConverter`, so JSON + bodies, `IConfiguration` binding, and WPF/WinForms two-way binding all + work with no setup. + +## JSON + +Serialises as a plain JSON string (the address). An invalid or too-long +string throws `JsonException`; JSON `null` yields a null reference even for +a non-nullable declaration — nullability is compile-time only. + +## Integrations + +- **EF Core** — maps to a string column via `Kalicz.StrongTypes.EfCore`; + plain `MailAddress` properties are converted too (`references/efcore.md`). +- **OpenAPI** — both adapters render + `{ "type": "string", "format": "email", "minLength": 1, "maxLength": 254 }` + (`references/openapi.md`). +- **Options binding** — a non-nullable `Email` left null by a missing key is + caught by `BindStrongTypes()` (`references/configuration.md`). +- **FsCheck** — `Generators` ships `Email` / `NullableEmail` / `MaybeEmail` + arbitraries (`references/fscheck.md`). diff --git a/Skill/references/intervals.md b/Skill/references/intervals.md index 3cefbfd1..c0f478b4 100644 --- a/Skill/references/intervals.md +++ b/Skill/references/intervals.md @@ -72,7 +72,7 @@ to `true`: `IntervalFrom.Create(1, null, endInclusive: false)` equals - Implicit widening to the less-constrained variants (see below). ```csharp -var interval = Interval.Create(start, end); +Interval interval = Interval.Create(start, end); string label = interval switch { @@ -160,16 +160,16 @@ keeps the endpoints the receiver already guarantees — intersecting with a `Interval?`. ```csharp -var booked = FiniteInterval.Create(10, 20); -var query = IntervalFrom.Create(15, null); +FiniteInterval booked = FiniteInterval.Create(10, 20); +IntervalFrom query = IntervalFrom.Create(15, null); bool clash = booked.Overlaps(query); // true FiniteInterval? shared = booked.GetOverlap(query); // [15, 20] booked.GetOverlap(FiniteInterval.Create(30, 40)); // null — disjoint FiniteInterval.Create(0, 5).GetOverlap(FiniteInterval.Create(5, 9)); // [5, 5] — inclusive bounds touch -var morning = FiniteInterval.Create(9, 12, endInclusive: false); -var afternoon = FiniteInterval.Create(12, 17, endInclusive: false); +FiniteInterval morning = FiniteInterval.Create(9, 12, endInclusive: false); +FiniteInterval afternoon = FiniteInterval.Create(12, 17, endInclusive: false); morning.Overlaps(afternoon); // false — [9, 12) stops short of noon ``` @@ -192,7 +192,7 @@ a `FiniteInterval` (half-open, `[midnight, next midnight)`), and `DateTime.ToDateOnly()` drops a moment's time of day. ```csharp -var stay = FiniteInterval.Create(new DateTime(2026, 7, 1, 14, 0, 0), new DateTime(2026, 7, 4, 10, 0, 0)); +FiniteInterval stay = FiniteInterval.Create(new DateTime(2026, 7, 1, 14, 0, 0), new DateTime(2026, 7, 4, 10, 0, 0)); stay.Contains(new DateOnly(2026, 7, 4)); // true — the stay reaches into that day stay.ToDateInterval(); // FiniteInterval [2026-07-01, 2026-07-04] diff --git a/Skill/references/map.md b/Skill/references/map.md index 6704707c..0e1c0755 100644 --- a/Skill/references/map.md +++ b/Skill/references/map.md @@ -30,7 +30,7 @@ string? upper = maybeText.Map(s => s.ToUpperInvariant()); int? parsed = input.Map(s => int.TryParse(s, out var n) ? n : (int?)null); // Async -int? await = await maybeId.MapAsync(id => _store.CountAsync(id)); +int? counted = await maybeId.MapAsync(id => _store.CountAsync(id)); ``` Overloads cover the four combinations of value-type / reference-type input diff --git a/Skill/references/maybe.md b/Skill/references/maybe.md index 624399eb..5d0ca665 100644 --- a/Skill/references/maybe.md +++ b/Skill/references/maybe.md @@ -84,7 +84,7 @@ Maybe m = users.SafeSingle(u => u.Id == target); Maybe m = users.SafeLast(); Maybe hi = scores.SafeMax(); Maybe lo = scores.SafeMin(); -Maybe worst = results.SafeMin(r => r.Value); +Maybe lowest = results.SafeMin(r => r.Score); // min of the projection // Drop Nones in one shot. IEnumerable values = maybes.Values(); diff --git a/Skill/references/nonemptyenumerable.md b/Skill/references/nonemptyenumerable.md index 7f983f1a..4fb4df18 100644 --- a/Skill/references/nonemptyenumerable.md +++ b/Skill/references/nonemptyenumerable.md @@ -41,8 +41,8 @@ These operations keep the non-empty guarantee and return - `Select(...)`, including the indexed overload. - `SelectMany(...)` *when the inner result is itself non-empty*. -- `Distinct()`, `Distinct(IEqualityComparer)`. -- `Concat(params T[])`, `Concat(IEnumerable)`, both on +- `Distinct()`. +- `Concat(params ReadOnlySpan)`, `Concat(IEnumerable)`, both on `NonEmptyEnumerable` and `INonEmptyEnumerable`. - `Flatten()` on `INonEmptyEnumerable>`. - `T head.Concat(params IEnumerable[] tails)` — builds a @@ -74,8 +74,8 @@ T maxBy = list.MaxBy(x => x.Key); T minBy = list.MinBy(x => x.Key); ``` -All of these have overloads on both `NonEmptyEnumerable` and -`INonEmptyEnumerable`, so chains on either receiver type work. +All of these accept both `NonEmptyEnumerable` and +`INonEmptyEnumerable` as the receiver, so chains on either type work. ## Covariance diff --git a/Skill/references/nonemptystring.md b/Skill/references/nonemptystring.md index 338f09e0..cb8d25e5 100644 --- a/Skill/references/nonemptystring.md +++ b/Skill/references/nonemptystring.md @@ -43,8 +43,8 @@ plain `string` because a substring could be empty. - `StartsWith(...)`, `EndsWith(...)` overloads mirroring `string`. - Implicit conversion to `string` — you can pass a `NonEmptyString` to any `string` parameter without `.Value`. -- Full equality / comparison operators against `NonEmptyString` *and* - `string` — no `.Value` needed for `==`, `!=`, `<`, `<=`, `>`, `>=`. +- `==` / `!=` against both `NonEmptyString` and `string`, `<` / `<=` / `>` / + `>=` against `NonEmptyString`, and `CompareTo(string)` — all without `.Value`. ## `Unwrap()` — the EF-Core marker @@ -111,6 +111,7 @@ public record User(NonEmptyString Name, NonEmptyString? Nickname); ## JSON `[JsonConverter(typeof(NonEmptyStringJsonConverter))]` is attached on the -type. Serialises as a plain JSON string. Deserialising `""`, a -whitespace-only string, or `null` (into the non-nullable form) throws -`JsonException`. No `JsonSerializerOptions` registration required. +type. Serialises as a plain JSON string. Deserialising `""` or a +whitespace-only string throws `JsonException`; JSON `null` yields a null +reference even for the non-nullable declaration — nullability is +compile-time only. No `JsonSerializerOptions` registration required. diff --git a/Skill/references/numeric.md b/Skill/references/numeric.md index 15673ea6..9f913fb9 100644 --- a/Skill/references/numeric.md +++ b/Skill/references/numeric.md @@ -106,8 +106,8 @@ the implicit conversion (or `.Value` / `.Unwrap()`) and re-wrap explicitly when you need the invariant back: ```csharp -Positive a = 3; -Positive b = 5; +Positive a = 3.ToPositive(); +Positive b = 5.ToPositive(); int sum = a + b; // implicit → int Positive wrapped = sum.ToPositive(); // re-wrap; throws if invariant broke diff --git a/Skill/references/openapi.md b/Skill/references/openapi.md index 62b7bf77..b5c736be 100644 --- a/Skill/references/openapi.md +++ b/Skill/references/openapi.md @@ -123,8 +123,8 @@ In 3.0, `exclusiveMinimum` / `exclusiveMaximum` emit as booleans paired with `minimum` / `maximum`; in 3.1 they emit as numeric values. Both forms are produced correctly — the version setting is the only knob. -Swashbuckle 10.x emits 3.0 by default; switch via its own options -(`SwaggerGeneratorOptions.OpenApiVersion`) if you need 3.1. +Swashbuckle 10.x emits 3.0 by default; switch via the middleware options +(`app.UseSwagger(o => o.OpenApiVersion = …)`) if you need 3.1. ## UI diff --git a/Skill/references/parsing.md b/Skill/references/parsing.md index 13d3a1ee..5e9ae6ca 100644 --- a/Skill/references/parsing.md +++ b/Skill/references/parsing.md @@ -70,13 +70,6 @@ foreach (var flag in (Roles.Reader | Roles.Admin).GetFlags()) { ... } `InvalidOperationException` if the enum is not `[Flags]` — so a typo at declaration fails on first use, not silently. -There's also a typed converter pair for generic code: - -```csharp -long raw = EnumExtensions.ToLong(value); -Roles back = EnumExtensions.FromLong(raw); -``` - ## String parsers The open-generic enum variant (useful when you only know the enum type diff --git a/Skill/references/result.md b/Skill/references/result.md index 56b04ceb..e5ece0bb 100644 --- a/Skill/references/result.md +++ b/Skill/references/result.md @@ -109,7 +109,7 @@ Result a = name.AsNonEmpty().ToResult("name required"); Result b = name.AsNonEmpty().ToResult(() => BuildMessage()); // Result (Exception-flavoured) -Result c = name.AsNonEmpty().ToResult(); // throws default Exception on null +Result c = name.AsNonEmpty().ToResult(); // null → error branch holding an ArgumentNullException Result d = name.AsNonEmpty().ToResult(new ArgumentException()); // custom exception Result e = name.AsNonEmpty().ToResult(() => new Ex("...")); // factory ``` diff --git a/docs/diagrams/package-layout-dark.svg b/docs/diagrams/package-layout-dark.svg index b5d5c945..417996a3 100644 --- a/docs/diagrams/package-layout-dark.svg +++ b/docs/diagrams/package-layout-dark.svg @@ -1,4 +1,4 @@ - + diff --git a/docs/diagrams/package-layout.svg b/docs/diagrams/package-layout.svg index e78074b4..fb7cc3e6 100644 --- a/docs/diagrams/package-layout.svg +++ b/docs/diagrams/package-layout.svg @@ -1,4 +1,4 @@ - + diff --git a/readme.md b/readme.md index aa016ca3..6e765d94 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ [![NuGet version](https://img.shields.io/nuget/v/Kalicz.StrongTypes?label=nuget)](https://www.nuget.org/packages/Kalicz.StrongTypes/) [![Downloads](https://img.shields.io/nuget/dt/Kalicz.StrongTypes?label=downloads)](https://www.nuget.org/packages/Kalicz.StrongTypes/) [![License](https://img.shields.io/github/license/KaliCZ/StrongTypes)](https://github.com/KaliCZ/StrongTypes/blob/main/license.txt) -StrongTypes adds small, focused types that make everyday code safer and more expressive. Every type ships with `System.Text.Json` converters out of the box, so invalid JSON fails at deserialization, and a `TypeConverter`, so the same invariant validates `appsettings.json` as it binds and WPF two-way bindings work with no setup. The types can be stored directly in EF Core entities via the EfCore package, and OpenAPI documentation is supported through the Microsoft or Swashbuckle OpenAPI packages — see [Packages](#packages) below. +StrongTypes adds small, focused types that make everyday code safer and more expressive. The types ship with `System.Text.Json` converters out of the box, so invalid JSON fails at deserialization, and the scalar wrappers carry a `TypeConverter`, so the same invariant validates `appsettings.json` as it binds and WPF two-way bindings work with no setup. The types can be stored directly in EF Core entities via the EfCore package, and OpenAPI documentation is supported through the Microsoft or Swashbuckle OpenAPI packages — see [Packages](#packages) below. > 🤖 Letting Claude Code or Codex write code in a project that uses > StrongTypes? See [Use with Claude or Codex](#use-with-claude-or-codex) @@ -108,10 +108,10 @@ All defaults (e.g. `default(Positive)`) still satisfy their invariants (e.g. ### 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: +Every strong type in this library implements value-based equality, and the ordered ones (`NonEmptyString`, `Digit`, and the numeric wrappers) add the full comparison surface, so you can drop them into dictionaries, sorted collections, LINQ `OrderBy`, and equality checks without writing any boilerplate: - `IEquatable` and the `==` / `!=` operators -- `IComparable`, `IComparable`, and the `<`, `<=`, `>`, `>=` operators +- `IComparable`, `IComparable`, and the `<`, `<=`, `>`, `>=` operators (on the ordered types) - `GetHashCode` and `Equals(object?)` overrides consistent with value-based equality - A sensible `ToString()` that returns the underlying value @@ -119,13 +119,13 @@ Equality and comparison also work directly against the underlying value — ther ```csharp -bool stringEquality1 = NonEmptyString.Create("Alice") == "Alice"; // true - implicit operator -bool stringEquality2 = name.CompareTo("Alice") == "Alice"; // true - explicit operator overload +bool stringEquality1 = NonEmptyString.Create("Alice") == "Alice"; // true - == overload against string +bool stringEquality2 = name.CompareTo("Alice") == 0; // true - CompareTo overload against string -bool intEquality1 = 2 == Positive.Create(2); // true - implicit operator -bool intEquality2 = Positive.Create(2) == 2; // true - explicit operator overload +bool intEquality1 = 2 == Positive.Create(2); // true - == overload against the number +bool intEquality2 = Positive.Create(2) == 2; // true - == overload against the number -bool order = Positive.Create(4) > 2; // true - explicit operator overload +bool order = Positive.Create(4) > 2; // true - > overload against the number // Same for the other types and equality methods ``` @@ -133,17 +133,17 @@ bool order = Positive.Create(4) > 2; // true - expl ### JSON serialization -All strong types ship with `System.Text.Json` converters attached via `[JsonConverter]` — no converter registration and no custom `JsonSerializerOptions` required. The format is the same as the underlying primitive (`"hello"`, `42`, …), and invalid input surfaces as a `JsonException`. +The strong types ship with `System.Text.Json` converters attached via `[JsonConverter]` — no converter registration and no custom `JsonSerializerOptions` required. The format is the same as the underlying primitive (`"hello"`, `42`, …), and invalid input surfaces as a `JsonException`. `Maybe` has a special format of serialization, so Some serializes into `{ "Value": xxx }` and None into `{ "Value": null }`. -`Result` (and `Result`) has no JSON converter I don't think you want to serialize that. +`Result` (and `Result`) has no JSON converter — I don't think you want to serialize that. [↑ Back to contents](#contents) ### Configuration binding -All strong types ship a `TypeConverter` attached via `[TypeConverter]`, which is what `ConfigurationBinder` uses — so a wrapper sits directly on an options class and its invariant becomes the config validation rule. A bad value fails at startup with the path, the value, and the reason: +The scalar strong types (`NonEmptyString`, `Email`, `Digit`, and the numeric wrappers) ship a `TypeConverter` attached via `[TypeConverter]`, which is what `ConfigurationBinder` uses — so a wrapper sits directly on an options class and its invariant becomes the config validation rule. A bad value fails at startup with the path, the value, and the reason: ``` InvalidOperationException: Failed to convert configuration value '-5' at 'Retry:MaxRetries' @@ -194,7 +194,7 @@ Pick the one that matches the generator your app already uses. They're not inter ### WPF MVVM binding -Nothing to install, nothing to call. WPF resolves `string → T` through `TypeDescriptor`, and every strong type carries a `[TypeConverter]`, so two-way bindings just work: +Nothing to install, nothing to call. WPF resolves `string → T` through `TypeDescriptor`, and the scalar strong types carry a `[TypeConverter]`, so two-way bindings just work: ```xml -All extensions (`Select`, `Concat`, `Max`, `Last`, …) have overloads on both the concrete type and the interface, so either receiver type works in a chain. +All extensions (`Select`, `Concat`, `Max`, `Last`, …) accept both the concrete type and the interface as the receiver, so either type works in a chain. [↑ Back to contents](#contents) @@ -296,7 +296,7 @@ A readonly struct with a condition `Start <= End`. Both are **inclusive by defau | `FiniteInterval` | `T` | `T` | a fully-bounded range | | `IntervalFrom` | `T` | `T?` | "from X" (end optional) | | `IntervalUntil` | `T?` | `T` | "until Y" (start optional) | -| `Interval` | `T?` | `T?` | both optional (but one must be present) | +| `Interval` | `T?` | `T?` | both optional (may be fully unbounded) | Same factory pattern; `TryCreate` / `Create` reject `Start > End`. `Start = End` is rejected unless both are inclusive. @@ -310,6 +310,7 @@ IntervalFrom openEnded = IntervalFrom.Create(today, null); // "from to `Contains` answers membership honoring each bound's inclusivity. ```csharp +FiniteInterval range = FiniteInterval.Create(1, 10); bool inRange = range.Contains(10); // true — bounds are inclusive by default bool atShiftEnd = time.Contains(17); // false — created with endInclusive: false ``` @@ -479,11 +480,11 @@ MailAddress? email = text is null ? null : new MailAddress(text); ```csharp MailAddress? email = text.Map(t => new MailAddress(t)); int? doubled = maybeInt.Map(x => x * 2); -someResult? something = featureFlagEnabled.MapTrue(CallSomeService); +SomeResult? something = featureFlagEnabled.MapTrue(CallSomeService); // instead of -someResult? something = null; +SomeResult? something = null; if (featureFlagEnabled) - someResult = CallSomeService(); + something = CallSomeService(); ``` So *you don't need `Maybe` just to compose an optional logic*. With `Map` in the toolbox, plain `T?` covers most cases. Save `Maybe` for the cases where nullability can't express what you need — see the HTTP PATCH example below. @@ -683,7 +684,7 @@ public Result CreateOrder(OrderData data) Result paymentResult = Pay(data.Payment); if (paymentResult.Error is { } e) { - logger.Log("Failed to make a payment for order {OrderId} because of {ErrorReason}.", data.Id, e); + logger.LogError("Failed to make a payment for order {OrderId} because of {ErrorReason}.", data.Id, e); return OrderError.PaymentFailed; // Implicit operator } @@ -787,7 +788,7 @@ Result[], string> ParseOrderQuantities(IEnumerable inputs) | Package | Purpose | Readme | | --- | --- | --- | -| [`Kalicz.StrongTypes`](https://www.nuget.org/packages/Kalicz.StrongTypes/) | Core types: `NonEmptyString`, `Positive` / `NonNegative` / `Negative` / `NonPositive`, `NonEmptyEnumerable`, `Maybe`, `Result`, plus `System.Text.Json` converters. | (this readme) | +| [`Kalicz.StrongTypes`](https://www.nuget.org/packages/Kalicz.StrongTypes/) | Core types: `NonEmptyString`, `Email`, `Digit`, `Positive` / `NonNegative` / `Negative` / `NonPositive`, the interval types, `NonEmptyEnumerable`, `Maybe`, `Result`, plus the `System.Text.Json` converters and `TypeConverter`s. | (this readme) | | [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when a property declared non-nullable is null once bound, which an absent key otherwise leaves silently. Binding itself needs no package; this is only about the key that isn't there. | [readme](src/StrongTypes.Configuration/readme.md) | | [`Kalicz.StrongTypes.EfCore`](https://www.nuget.org/packages/Kalicz.StrongTypes.EfCore/) | EF Core value converters + `DbContext` extension for round-tripping the wrappers through scalar columns. | [readme](src/StrongTypes.EfCore/readme.md) | | [`Kalicz.StrongTypes.FsCheck`](https://www.nuget.org/packages/Kalicz.StrongTypes.FsCheck/) | FsCheck `Arbitrary` generators for property-based (generative) testing of code that takes or returns the wrappers. | [readme](src/StrongTypes.FsCheck/readme.md) | diff --git a/src/StrongTypes.Analyzers.Tests/AddEfCorePackageCodeFixProviderTests.cs b/src/StrongTypes.Analyzers.Tests/AddEfCorePackageCodeFixProviderTests.cs index 8324d8be..2eb194c9 100644 --- a/src/StrongTypes.Analyzers.Tests/AddEfCorePackageCodeFixProviderTests.cs +++ b/src/StrongTypes.Analyzers.Tests/AddEfCorePackageCodeFixProviderTests.cs @@ -4,12 +4,6 @@ namespace StrongTypes.Analyzers.Tests; -/// -/// Behaviour tests for . The fix operates on a csproj -/// file, not a Roslyn document, so tests materialize a temp directory, point an -/// at it, run the fix, and inspect the XML -/// on disk afterwards. -/// public class AddEfCorePackageCodeFixProviderTests : IDisposable { private readonly string _tempDir; @@ -60,7 +54,6 @@ public async Task Adds_package_reference_into_existing_item_group() Assert.Contains(packageReferences, p => (string?)p.Attribute("Include") == MissingEfCorePackageAnalyzer.EfCorePackageId); - // New reference should have been placed inside the existing ItemGroup (not a new one). var itemGroups = doc.Descendants("ItemGroup").ToArray(); Assert.Single(itemGroups); } @@ -85,7 +78,6 @@ public async Task Creates_new_item_group_when_none_contains_package_references() Assert.Equal(MissingEfCorePackageAnalyzer.EfCorePackageId, (string?)newRef.Attribute("Include")); Assert.Equal(AddEfCorePackageCodeFixProvider.EfCorePackageVersion, (string?)newRef.Attribute("Version")); - // The original csproj had no ItemGroup at all, so the fix must have added exactly one. Assert.Single(doc.Descendants("ItemGroup")); } @@ -107,7 +99,6 @@ public async Task Idempotent_when_package_reference_already_exists() var fixes = await CodeFixTester.RegisterFixesAsync(new AddEfCorePackageCodeFixProvider(), csproj); await CodeFixTester.ApplyAsync(Assert.Single(fixes)); - // No duplicate entry, and the file is byte-for-byte unchanged. var doc = XDocument.Load(csproj); Assert.Single(doc.Descendants("PackageReference")); Assert.Equal(before, File.ReadAllText(csproj)); @@ -137,9 +128,7 @@ public async Task Match_on_package_id_is_case_insensitive() [Fact] public async Task NoOp_when_csproj_file_is_missing() { - // Point at a path that doesn't exist on disk. The fix must still register (it has no way - // to check disk state during registration) but running it should not create the file or - // throw. + // The fix is offered even for a missing file — registration can't do IO to check. var csproj = Path.Combine(_tempDir, "Missing.csproj"); var fixes = await CodeFixTester.RegisterFixesAsync(new AddEfCorePackageCodeFixProvider(), csproj); @@ -156,7 +145,6 @@ public async Task Advertises_the_expected_diagnostic_id_and_fix_all_provider() Assert.Contains(MissingEfCorePackageAnalyzer.DiagnosticId, provider.FixableDiagnosticIds); Assert.NotNull(provider.GetFixAllProvider()); - // Sanity-check the action's title so refactors of the message catch this test. var csproj = WriteCsproj(""" diff --git a/src/StrongTypes.Analyzers.Tests/AddOpenApiPackageCodeFixProviderTests.cs b/src/StrongTypes.Analyzers.Tests/AddOpenApiPackageCodeFixProviderTests.cs index 57b72f84..687f93fc 100644 --- a/src/StrongTypes.Analyzers.Tests/AddOpenApiPackageCodeFixProviderTests.cs +++ b/src/StrongTypes.Analyzers.Tests/AddOpenApiPackageCodeFixProviderTests.cs @@ -4,12 +4,6 @@ namespace StrongTypes.Analyzers.Tests; -/// -/// Behaviour tests for . Mirrors the -/// EfCore code-fix tests — point an -/// at a temp csproj, run the fix, and assert the XML on disk afterwards. Both diagnostic -/// IDs (ST0002 / ST0003) are exercised so each picks the right package. -/// public class AddOpenApiPackageCodeFixProviderTests : IDisposable { private readonly string _tempDir; diff --git a/src/StrongTypes.Analyzers.Tests/Infrastructure/AnalyzerTester.cs b/src/StrongTypes.Analyzers.Tests/Infrastructure/AnalyzerTester.cs index 9e65023a..fad86158 100644 --- a/src/StrongTypes.Analyzers.Tests/Infrastructure/AnalyzerTester.cs +++ b/src/StrongTypes.Analyzers.Tests/Infrastructure/AnalyzerTester.cs @@ -5,10 +5,6 @@ namespace StrongTypes.Analyzers.Tests.Infrastructure; -/// -/// Drives a against an in-memory compilation and returns the -/// diagnostics it produced. -/// internal static class AnalyzerTester { public static async Task> RunAsync( @@ -32,7 +28,7 @@ public static async Task> RunAsync( /// /// A source missing a reference yields no diagnostics rather than an error, so every - /// Silent_ test would pass for the wrong reason. Fail loudly instead. + /// Silent_ test would pass for the wrong reason. /// private static void AssertCompiles(CSharpCompilation compilation) { diff --git a/src/StrongTypes.Analyzers.Tests/Infrastructure/CodeFixTester.cs b/src/StrongTypes.Analyzers.Tests/Infrastructure/CodeFixTester.cs index dfac8878..d31ee3a2 100644 --- a/src/StrongTypes.Analyzers.Tests/Infrastructure/CodeFixTester.cs +++ b/src/StrongTypes.Analyzers.Tests/Infrastructure/CodeFixTester.cs @@ -6,11 +6,9 @@ namespace StrongTypes.Analyzers.Tests.Infrastructure; /// -/// Drives a against a real csproj on disk. The EfCore package code -/// fix bypasses Roslyn's document model and writes to the csproj directly, so the standard -/// `CSharpCodeFixTest` scaffolding (which diffs state) can't observe it — -/// we need to point a real at a temp file, invoke the registered -/// , and assert against the file contents afterwards. +/// Drives a against a real csproj on disk. The package code fixes +/// bypass Roslyn's document model and write to the csproj directly, so the standard +/// `CSharpCodeFixTest` scaffolding (which diffs state) can't observe them. /// internal static class CodeFixTester { @@ -62,9 +60,7 @@ public static async Task> RegisterFixesAsync( public static async Task ApplyAsync(CodeAction action) { - // The provider swaps `createChangedSolution` for a no-op return; the side effect is the - // disk write done inside that delegate, not a Solution diff. Still, driving through the - // public API is the right integration point — that's what the IDE does. + // The fix's effect is the csproj write inside the delegate, not the returned operations. _ = await action.GetOperationsAsync(CancellationToken.None); } } diff --git a/src/StrongTypes.Analyzers.Tests/Infrastructure/DocumentCodeFixTester.cs b/src/StrongTypes.Analyzers.Tests/Infrastructure/DocumentCodeFixTester.cs index bf64db34..742e8e66 100644 --- a/src/StrongTypes.Analyzers.Tests/Infrastructure/DocumentCodeFixTester.cs +++ b/src/StrongTypes.Analyzers.Tests/Infrastructure/DocumentCodeFixTester.cs @@ -31,7 +31,6 @@ public static async Task> RegisterFixesAsync( return registered; } - /// Applies the first offered fix and returns the resulting source. public static async Task ApplySingleFixAsync( DiagnosticAnalyzer analyzer, CodeFixProvider provider, diff --git a/src/StrongTypes.Analyzers.Tests/Infrastructure/TestReferences.cs b/src/StrongTypes.Analyzers.Tests/Infrastructure/TestReferences.cs index 71af632a..6b6bb13b 100644 --- a/src/StrongTypes.Analyzers.Tests/Infrastructure/TestReferences.cs +++ b/src/StrongTypes.Analyzers.Tests/Infrastructure/TestReferences.cs @@ -46,7 +46,8 @@ internal static class TestReferences public static readonly MetadataReference StrongTypesConfiguration = MetadataReference.CreateFromFile(typeof(global::StrongTypes.Configuration.OptionsBuilderExtensions).Assembly.Location); - /// What an options-binding source needs to compile: IServiceCollection, OptionsBuilder<T>, IConfiguration, the Bind/Configure extensions ST0004 matches on, [Required], and [TypeConverter] — which ST0004 reads to know where the binder stops recursing. + /// What an options-binding source needs to compile. [TypeConverter] is included + /// because ST0004 reads it to know where the binder stops recursing. public static readonly MetadataReference[] OptionsStack = [ MetadataReference.CreateFromFile(typeof(global::Microsoft.Extensions.DependencyInjection.IServiceCollection).Assembly.Location), diff --git a/src/StrongTypes.Analyzers.Tests/MissingEfCorePackageAnalyzerTests.cs b/src/StrongTypes.Analyzers.Tests/MissingEfCorePackageAnalyzerTests.cs index 6e481886..03f6e144 100644 --- a/src/StrongTypes.Analyzers.Tests/MissingEfCorePackageAnalyzerTests.cs +++ b/src/StrongTypes.Analyzers.Tests/MissingEfCorePackageAnalyzerTests.cs @@ -4,11 +4,6 @@ namespace StrongTypes.Analyzers.Tests; -/// -/// Behaviour tests for (ST0001). Each test assembles a -/// minimal source that either should or should not trip the analyzer, drives it through the real -/// Roslyn pipeline, and asserts the resulting diagnostics. -/// public class MissingEfCorePackageAnalyzerTests { private const string DbContextWithNonEmptyStringEntity = """ @@ -198,9 +193,6 @@ public class SampleContext : DbContext [Fact] public async Task Reports_at_dbset_entity_property_and_dbcontext_locations() { - // Single DbContext + single DbSet + entity with one wrapper property should produce - // exactly three diagnostics: one at the DbSet property, one at the entity property, - // one at the DbContext class declaration. var diagnostics = await AnalyzerTester.RunAsync( new MissingEfCorePackageAnalyzer(), DbContextWithNonEmptyStringEntity, diff --git a/src/StrongTypes.Analyzers.Tests/MissingOpenApiPackageAnalyzerTests.cs b/src/StrongTypes.Analyzers.Tests/MissingOpenApiPackageAnalyzerTests.cs index 7aa40f71..d88cfff4 100644 --- a/src/StrongTypes.Analyzers.Tests/MissingOpenApiPackageAnalyzerTests.cs +++ b/src/StrongTypes.Analyzers.Tests/MissingOpenApiPackageAnalyzerTests.cs @@ -3,11 +3,6 @@ namespace StrongTypes.Analyzers.Tests; -/// -/// Behaviour tests for (ST0002 / ST0003). Each test -/// assembles a minimal source that should or should not trip the analyzer, drives it through the -/// real Roslyn pipeline, and asserts the resulting diagnostics. -/// public class MissingOpenApiPackageAnalyzerTests { private const string DtoWithNonEmptyStringProperty = """ diff --git a/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs b/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs index e29e8281..07c419d1 100644 --- a/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs +++ b/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs @@ -5,9 +5,8 @@ namespace StrongTypes.Analyzers.Tests; /// -/// Behaviour tests for (ST0004). Each source -/// opts into nullable reference types, because required-ness for a reference wrapper is read from -/// the annotation and the test compilation does not enable it project-wide. +/// Each test source opts into nullable reference types, because required-ness for a reference +/// wrapper is read from the annotation and the test compilation does not enable it project-wide. /// public class UnvalidatedStrongTypeOptionsAnalyzerTests { @@ -44,9 +43,6 @@ public static void Register(IServiceCollection services, IConfiguration config) private static Task> RunAsync(string source) => AnalyzerTester.RunAsync(new UnvalidatedStrongTypeOptionsAnalyzer(), source, TestReferences.With(TestReferences.OptionsStack)); - // ── Fires ─────────────────────────────────────────────────────────── - - /// A non-nullable reference wrapper is the one an absent key leaves null — the state its type forbids. [Theory] [InlineData(BindRegistration)] [InlineData(ConfigureRegistration)] @@ -72,7 +68,6 @@ public async Task Reports_every_unguarded_property_in_one_diagnostic() Assert.Contains("Contact", diagnostic.GetMessage(), StringComparison.Ordinal); } - /// A wrapper nested in a child options class is left null by the same absent key, so the nudge has to reach it. [Fact] public async Task Fires_for_a_wrapper_nested_in_a_child_options_class() { @@ -132,9 +127,7 @@ public static void Register(IServiceCollection services, IConfiguration config) Assert.Contains("Endpoints.Host", diagnostic.GetMessage(), StringComparison.Ordinal); } - // ── Silent ────────────────────────────────────────────────────────── - - /// A [TypeConverter] type binds from a scalar, so the binder never reaches the wrapper inside it — flagging it would invent a diagnostic for a property configuration never touches. + /// A [TypeConverter] type binds from a scalar, so the binder never reaches the wrapper inside it. [Fact] public async Task Silent_for_a_wrapper_inside_a_type_that_binds_from_a_scalar() { @@ -243,7 +236,7 @@ public async Task Silent_when_every_wrapper_is_nullable() Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); } - /// A struct wrapper cannot be null, so an absent key leaves it at a default the type is happy to hold — there is no contradiction to report. + /// A struct wrapper cannot be null, so an absent key leaves it at a default the type is happy to hold. [Theory] [InlineData(" public Positive MaxRetries { get; set; }")] [InlineData(" public Positive? MaxRetries { get; set; }")] @@ -277,7 +270,6 @@ public async Task Silent_for_a_get_only_property() Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); } - /// Nothing to say when the project never binds options — the analyzer must no-op rather than fire on the declaration alone. [Fact] public async Task Silent_when_the_options_type_is_never_bound() { diff --git a/src/StrongTypes.Analyzers/AddEfCorePackageCodeFixProvider.cs b/src/StrongTypes.Analyzers/AddEfCorePackageCodeFixProvider.cs index e9fdbd56..70efc2ea 100644 --- a/src/StrongTypes.Analyzers/AddEfCorePackageCodeFixProvider.cs +++ b/src/StrongTypes.Analyzers/AddEfCorePackageCodeFixProvider.cs @@ -14,15 +14,12 @@ namespace StrongTypes.Analyzers; /// /// Code fix for : appends /// <PackageReference Include="Kalicz.StrongTypes.EfCore" …/> -/// to the project's csproj. Implemented as a file-system side effect rather -/// than a document edit because PackageReference lives in a csproj, -/// which Roslyn doesn't model as a first-class document. +/// to the project's csproj. /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddEfCorePackageCodeFixProvider))] [Shared] public sealed class AddEfCorePackageCodeFixProvider : CodeFixProvider { - // Kept in one place so bumping the EfCore version is a one-line change. public const string EfCorePackageVersion = "0.3.0"; public override ImmutableArray FixableDiagnosticIds { get; } = @@ -37,10 +34,6 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context) { return Task.CompletedTask; } - // RS1035 forbids file IO in analyzers; a code fix that installs a - // NuGet package intrinsically has to touch the csproj, so existence - // checking + XML round-trip happens inside the CodeAction delegate, - // suppressed at the call sites below. context.RegisterCodeFix( CodeAction.Create( title: $"Add PackageReference to {MissingEfCorePackageAnalyzer.EfCorePackageId}", @@ -64,9 +57,6 @@ private static Task AddPackageReferenceAsync(Project project, Cancella return Task.FromResult(project.Solution); } - // Idempotency: do nothing if a PackageReference for the EfCore package - // is already present (covers the edge case where the diagnostic fires - // on a stale cache and the user has already installed manually). var existing = doc.Descendants() .Where(e => e.Name.LocalName == "PackageReference") .FirstOrDefault(e => string.Equals( @@ -78,9 +68,6 @@ private static Task AddPackageReferenceAsync(Project project, Cancella return Task.FromResult(project.Solution); } - // Prefer to slot the new PackageReference into an existing ItemGroup - // that already holds PackageReferences — matches how tooling like - // `dotnet add package` shapes the file. var targetItemGroup = doc.Descendants() .Where(e => e.Name.LocalName == "ItemGroup" && e.Elements().Any(c => c.Name.LocalName == "PackageReference")) @@ -101,8 +88,7 @@ private static Task AddPackageReferenceAsync(Project project, Cancella } doc.Save(csprojPath); - // We return the original solution unchanged — the IDE picks up the - // csproj change on its own and restores the package on the next build. + // Returning the solution unchanged is correct — the fix is the csproj write, which the IDE picks up on its own. return Task.FromResult(project.Solution); } #pragma warning restore RS1035 diff --git a/src/StrongTypes.Analyzers/AddOpenApiPackageCodeFixProvider.cs b/src/StrongTypes.Analyzers/AddOpenApiPackageCodeFixProvider.cs index 68158600..85d4b37d 100644 --- a/src/StrongTypes.Analyzers/AddOpenApiPackageCodeFixProvider.cs +++ b/src/StrongTypes.Analyzers/AddOpenApiPackageCodeFixProvider.cs @@ -14,9 +14,7 @@ namespace StrongTypes.Analyzers; /// /// Code fix for : appends the /// matching <PackageReference Include="Kalicz.StrongTypes.OpenApi.*" …/> -/// to the project's csproj. Which package depends on the diagnostic id — -/// ST0002 → Kalicz.StrongTypes.OpenApi.Microsoft, -/// ST0003 → Kalicz.StrongTypes.OpenApi.Swashbuckle. +/// to the project's csproj. /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddOpenApiPackageCodeFixProvider))] [Shared] diff --git a/src/StrongTypes.Analyzers/MissingEfCorePackageAnalyzer.cs b/src/StrongTypes.Analyzers/MissingEfCorePackageAnalyzer.cs index ba2c503b..cfe01e04 100644 --- a/src/StrongTypes.Analyzers/MissingEfCorePackageAnalyzer.cs +++ b/src/StrongTypes.Analyzers/MissingEfCorePackageAnalyzer.cs @@ -11,19 +11,8 @@ namespace StrongTypes.Analyzers; /// Fires when a project references Microsoft.EntityFrameworkCore and maps /// an entity that carries a StrongTypes wrapper property, but doesn't reference /// the Kalicz.StrongTypes.EfCore package that supplies the value -/// converters and LINQ translator. Without it, EF Core tries to treat the -/// wrapper as an owned entity type and blows up at model-build time with a -/// "no suitable constructor" error. +/// converters and LINQ translator. /// -/// -/// The diagnostic is raised on three locations so any of them surfaces the fix: -/// -/// the DbSet<T> property declaration, -/// each strong-type property on the mapped entity, -/// the DbContext class itself — that's where -/// .UseStrongTypes() will be called once the package is installed. -/// -/// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class MissingEfCorePackageAnalyzer : DiagnosticAnalyzer { @@ -40,8 +29,6 @@ public sealed class MissingEfCorePackageAnalyzer : DiagnosticAnalyzer description: "Kalicz.StrongTypes.EfCore ships value converters and the Unwrap() LINQ translator needed for EF Core to round-trip NonEmptyString, Email, 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.", helpLinkUri: "https://www.nuget.org/packages/Kalicz.StrongTypes.EfCore"); - // Canonical names in metadata form so compiled generics match - // (`StrongTypes.Positive\`1`, etc.). private static readonly ImmutableHashSet StrongTypeMetadataNames = ImmutableHashSet.Create( "StrongTypes.NonEmptyString", "StrongTypes.Email", @@ -78,9 +65,6 @@ public override void Initialize(AnalysisContext context) return; } - // Aggregate state across symbol visits so we can emit diagnostics on - // the shared DbContext class once, even though we discover the - // triggering entity through its DbSet property. var reportedDbContexts = new HashSet(SymbolEqualityComparer.Default); var reportedEntityProperties = new HashSet(SymbolEqualityComparer.Default); @@ -111,14 +95,11 @@ public override void Initialize(AnalysisContext context) return; } - // 1) Point at the DbSet declaration — the immediate cause site. foreach (var location in property.Locations) { symbolContext.ReportDiagnostic(Diagnostic.Create(Rule, location, entityType.Name)); } - // 2) Point at each strong-type property on the entity — so a - // developer reading the entity definition also sees the hint. foreach (var entityProperty in strongTypeProperties) { if (!reportedEntityProperties.Add(entityProperty)) @@ -131,9 +112,7 @@ public override void Initialize(AnalysisContext context) } } - // 3) Point at the DbContext class itself — that's where the - // developer will ultimately call UseStrongTypes() once they - // install the package. + // Also report on the DbContext class — that's where UseStrongTypes() goes once the package is installed. if (reportedDbContexts.Add(containingContext)) { foreach (var location in containingContext.Locations) @@ -187,7 +166,6 @@ private static List CollectStrongTypeProperties(INamedTypeSymbo private static bool IsStrongType(ITypeSymbol type) { - // Unwrap Nullable for value types so Positive? matches. if (type is INamedTypeSymbol nt && nt.IsGenericType && nt.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T) { type = nt.TypeArguments[0]; diff --git a/src/StrongTypes.Analyzers/MissingOpenApiPackageAnalyzer.cs b/src/StrongTypes.Analyzers/MissingOpenApiPackageAnalyzer.cs index ddeec336..2f56ce86 100644 --- a/src/StrongTypes.Analyzers/MissingOpenApiPackageAnalyzer.cs +++ b/src/StrongTypes.Analyzers/MissingOpenApiPackageAnalyzer.cs @@ -10,10 +10,6 @@ namespace StrongTypes.Analyzers; /// (Microsoft.AspNetCore.OpenApi or Swashbuckle.AspNetCore) and /// exposes any public property whose CLR type is a StrongTypes wrapper, but /// doesn't reference the matching Kalicz.StrongTypes.OpenApi.* adapter. -/// Without the adapter the generator describes the wrappers by their raw CLR -/// shape — NonEmptyString as { Value }, Positive<int> -/// as a wrapper object — and generated clients see nonsense schemas that -/// don't match the wire JSON the converters actually emit. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class MissingOpenApiPackageAnalyzer : DiagnosticAnalyzer diff --git a/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs b/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs index ea0d151f..80440db7 100644 --- a/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs +++ b/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs @@ -106,7 +106,6 @@ private static SyntaxNode AddUsing(SyntaxNode root) .UsingDirective(SyntaxFactory.ParseName(ConfigurationNamespace)) .WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed); - // Keep the file's usings sorted rather than appending to the end. var insertAt = unit.Usings.TakeWhile(u => string.CompareOrdinal(u.Name?.ToString(), ConfigurationNamespace) < 0).Count(); return unit.WithUsings(unit.Usings.Insert(insertAt, directive)); } diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs index bbe839b3..77faef03 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs @@ -6,13 +6,6 @@ namespace StrongTypes.Api.IntegrationTests.Infrastructure; -/// -/// Generic base for any . Supplies -/// the HTTP Client, both DbContexts, the current CancellationToken, and the -/// generic helper that reads from a -/// . HTTP route/body helpers live on the -/// EntityCrudTestsBase subclass that actually uses them. -/// public abstract class IntegrationTestBase(TestWebApplicationFactory factory) : IDisposable where TEntity : class, IEntity where T : notnull @@ -27,21 +20,12 @@ public abstract class IntegrationTestBase(TestWebApplicat protected DbSet SqlSet => SqlDb.Set(); protected DbSet PgSet => PgDb.Set(); - /// - /// Whether the SQL Server provider is backed by a real SQL Server on this host. - /// only on a local host that opted into skipping SQL Server. - /// Guard every SQL-Server-specific assertion with this. - /// + /// False only when the host opted into skipping SQL Server — gate SQL-Server-only assertions on this; see testing.md. protected bool SqlServerAvailable => factory.SqlServerAvailable; protected static CancellationToken Ct => TestContext.Current.CancellationToken; - /// - /// Asserts the persisted row matches in both providers: PostgreSQL always, and - /// SQL Server only when it is available on this host. When SQL Server is skipped - /// its in-memory stub is not asserted against — it does not exercise the real - /// wire path, so a match there would be a false pass. - /// + /// Skipped SQL Server is not asserted against — a match on the in-memory stub would be a false pass. protected async Task AssertEntity(Guid id, T expectedValue, TNullable expectedNullableValue) { await AssertEntity(PgSet, id, expectedValue, expectedNullableValue); @@ -51,10 +35,6 @@ protected async Task AssertEntity(Guid id, T expectedValue, TNullable expectedNu } } - /// - /// Skips a provider-parametrized test for the sql-server provider when - /// SQL Server is unavailable on this host; a no-op for any other provider. - /// protected void SkipIfSqlServerUnavailable(string provider) => Assert.SkipWhen(provider == "sql-server" && !SqlServerAvailable, "SQL Server is not available on this host."); diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestCollection.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestCollection.cs index 8ca31b90..8a979429 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestCollection.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestCollection.cs @@ -2,10 +2,7 @@ namespace StrongTypes.Api.IntegrationTests.Infrastructure; -/// -/// xUnit collection definition. All test classes tagged [Collection(Name)] -/// share the same TestWebApplicationFactory instance (containers start once). -/// +/// One shared TestWebApplicationFactory per run — the containers boot once for the whole suite. [CollectionDefinition(Name)] public sealed class IntegrationTestCollection : ICollectionFixture { diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index 009a646c..432ac6ea 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -13,25 +13,14 @@ namespace StrongTypes.Api.IntegrationTests.Infrastructure; /// -/// Shared fixture that boots the database containers once per test collection, -/// overrides the connection strings via configuration (so Program.cs's DbContext -/// registrations pick them up unchanged), and creates the schema via EnsureCreated. +/// Overrides connection strings via configuration so Program.cs's DbContext registrations are +/// exercised unchanged. SQL Server skipping rules live in testing.md. /// -/// -/// PostgreSQL is required everywhere. SQL Server is required too — except on a -/// host that cannot run the amd64-only mssql/server image (e.g. an ARM64 -/// dev box), where it can be skipped via an explicit opt-in. See -/// for what callers must guard, and the -/// STRONGTYPES_SKIP_SQLSERVER env var below for the gate. Absent the -/// opt-in the container is started, and any failure to start is a hard failure -/// of the whole test run, never a silent skip. -/// public sealed class TestWebApplicationFactory : WebApplicationFactory, IAsyncLifetime { private const string DockerGroupLabel = "com.docker.compose.project"; private const string DockerGroupName = "StrongTypes"; - // Opt-in to skip SQL Server entirely (the container is never started). private const string SkipSqlServerEnvVar = "STRONGTYPES_SKIP_SQLSERVER"; private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromSeconds(45); @@ -45,23 +34,15 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory, .Build(); /// - /// Whether the SQL Server container is up and backed by a real SQL Server. - /// only on a host that opted into skipping via the - /// env var, where the container is never started. Tests must skip every - /// SQL-Server-specific assertion when this is ; the - /// in-memory stub that keeps the dual-write API booting does not exercise - /// the real SQL Server wire path. + /// False only when the host opted into skipping SQL Server — gate every SQL-Server-only + /// assertion on it; the stand-in in-memory stub does not exercise the real wire path. /// public bool SqlServerAvailable { get; private set; } async ValueTask IAsyncLifetime.InitializeAsync() { - // Skipping SQL Server is opt-in; absent the flag the container is started - // and any failure to start is a hard crash (see StartContainerAsync). SqlServerAvailable = Environment.GetEnvironmentVariable(SkipSqlServerEnvVar) != "1"; - // PostgreSQL is mandatory on every host; SQL Server too unless skipped. - // Start them concurrently — a start failure or timeout on either throws. var startups = new List { StartContainerAsync(_pgContainer, "PostgreSQL") }; if (SqlServerAvailable) { @@ -107,10 +88,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) if (!SqlServerAvailable) { - // Swap SQL Server for an in-memory stub so the dual-write endpoints - // still boot. Drop every registration keyed by SqlServerDbContext - // first — otherwise the original UseSqlServer call survives in its - // options-configuration service and collides with the stub provider. + // Drop the original registrations first — the UseSqlServer options service otherwise + // survives and collides with the stub provider. builder.ConfigureServices(services => { var stale = services diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Collections/CollectionJsonTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Collections/CollectionJsonTests.cs index cf1df5e3..e2c46f3d 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Collections/CollectionJsonTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Collections/CollectionJsonTests.cs @@ -7,19 +7,9 @@ namespace StrongTypes.Api.IntegrationTests.Tests.ApiTests.Collections; /// -/// End-to-end tests for the JSON wire contract of collection-shaped request/response -/// DTOs. Every test POSTs a JSON body to a round-trip endpoint and inspects the -/// response — this exercises both halves of the JSON pipeline (STJ -/// deserialization + ASP.NET Core validation on the way in, and STJ serialization -/// on the way out) against a DTO wired with a deliberately broad matrix of property -/// shapes: -/// -/// IEnumerable<T> — vanilla collection, no invariant. -/// IEnumerable<T?> — vanilla collection, element may be null. -/// NonEmptyEnumerable<T> — StrongTypes collection, count ≥ 1. -/// NonEmptyEnumerable<T?> — StrongTypes collection, count ≥ 1, element may be null. -/// -/// Element types cover the four categories: plain value (int), strong value +/// POST round-trips so both halves of the JSON pipeline are exercised (STJ deserialization + +/// validation in, STJ serialization out), across the collection-shape matrix on the DTOs with +/// one element type per category: plain value (int), strong value /// (Positive<int>), plain reference (string), strong reference /// (NonEmptyString). /// @@ -68,7 +58,7 @@ private static string[] StringArray(JsonElement parent, string property) => .ToArray(); // ─────────────────────────────────────────────────────────────────── - // int — plain value type. Element null is only reachable for T? = int?. + // int — plain value type // ─────────────────────────────────────────────────────────────────── [Fact] @@ -91,7 +81,6 @@ public async Task Int_AllValid_RoundTrips() [Fact] public async Task Int_IEnumerableEmpty_Allowed() { - // IEnumerable has no count invariant — an empty array is valid on the wire. var echoed = await PostOk(IntEndpoint, new { enumerable = Array.Empty(), @@ -133,8 +122,6 @@ public async Task Int_NonEmptyNullableEmpty_Returns400() [Fact] public async Task Int_NullElementInNonNullableIEnumerable_Returns400() { - // STJ can't assign null to an int, so deserializing [null] into IEnumerable - // fails at the element level and the controller returns 400. var status = await PostStatus(IntEndpoint, new { enumerable = new int?[] { 1, null, 3 }, @@ -159,7 +146,7 @@ public async Task Int_NullElementInNonNullableNonEmpty_Returns400() } // ─────────────────────────────────────────────────────────────────── - // Positive — strong type (struct). Element null reachable for T? only. + // Positive — strong value type // ─────────────────────────────────────────────────────────────────── [Fact] @@ -212,8 +199,6 @@ public async Task Positive_InvalidElementInNonEmpty_Returns400(int invalid) [Fact] public async Task Positive_InvalidElementInNullableSlot_Returns400() { - // Even in the nullable slot, a non-null value must still satisfy Positive's - // invariant — null is a legit absence, 0 is a rule violation. var status = await PostStatus(PositiveIntEndpoint, new { enumerable = new[] { 1 }, @@ -240,7 +225,6 @@ public async Task Positive_NonEmptyEmpty_Returns400() [Fact] public async Task Positive_NullElementInNonNullableIEnumerable_Returns400() { - // Positive is a struct, so STJ rejects JSON null for the element type. var status = await PostStatus(PositiveIntEndpoint, new { enumerable = new int?[] { 1, null, 3 }, @@ -252,8 +236,7 @@ public async Task Positive_NullElementInNonNullableIEnumerable_Returns400() } // ─────────────────────────────────────────────────────────────────── - // string — plain reference type. Element null is the interesting case: - // NRT annotations erase at runtime, so we document the observed behavior. + // string — plain reference type // ─────────────────────────────────────────────────────────────────── [Fact] @@ -289,11 +272,8 @@ public async Task String_NonEmptyEmpty_Returns400() [Fact] public async Task String_NullElementInNonNullableIEnumerable_PassesThrough() { - // Known limitation: NRT annotations erase at runtime, and ASP.NET Core's - // nullable-annotation validator doesn't recurse into generic type arguments - // — so `[null]` for `IEnumerable` deserializes successfully and the - // list ends up containing a null reference. This mirrors what plain C# without - // StrongTypes does: `var l = new List(); l.Add(null!);` compiles fine. + // Known limitation being pinned: NRT annotations erase at runtime and ASP.NET Core's + // nullable validation does not recurse into generic type arguments, so the null sails through. var echoed = await PostOk(StringEndpoint, new { enumerable = new string?[] { "a", null, "c" }, @@ -308,10 +288,7 @@ public async Task String_NullElementInNonNullableIEnumerable_PassesThrough() [Fact] public async Task String_NullElementInNonNullableNonEmpty_PassesThrough() { - // Same caveat as String_NullElementInNonNullableIEnumerable_PassesThrough: - // the library can't distinguish NonEmptyEnumerable from - // NonEmptyEnumerable at runtime, so it accepts nulls. Consistent - // with what plain C# allows, but worth documenting as a known gap. + // Same erasure limitation as the IEnumerable case above. var echoed = await PostOk(StringEndpoint, new { enumerable = new[] { "x" }, @@ -324,9 +301,7 @@ public async Task String_NullElementInNonNullableNonEmpty_PassesThrough() } // ─────────────────────────────────────────────────────────────────── - // NonEmptyString — strong reference type. Same null-erasure caveat applies, - // but this is where it stings most: a null in the list defeats the type's - // "non-null" guarantee. Flagged below where it occurs. + // NonEmptyString — strong reference type // ─────────────────────────────────────────────────────────────────── [Fact] @@ -351,8 +326,6 @@ public async Task NonEmptyString_AllValid_RoundTrips() [InlineData(" ")] public async Task NonEmptyString_InvalidElementInIEnumerable_Returns400(string invalid) { - // NonEmptyString's own converter rejects whitespace — the JsonException bubbles - // up through the collection converter and ASP.NET turns it into a 400. var status = await PostStatus(NonEmptyStringEndpoint, new { enumerable = new[] { "a", invalid, "c" }, @@ -394,7 +367,6 @@ public async Task NonEmptyString_NonEmptyEmpty_Returns400() [Fact] public async Task NonEmptyString_NullInNullableSlot_RoundTrips() { - // Nullable slot explicitly allows null — this is the intended use. var echoed = await PostOk(NonEmptyStringEndpoint, new { enumerable = new[] { "a" }, @@ -410,17 +382,9 @@ public async Task NonEmptyString_NullInNullableSlot_RoundTrips() [Fact] public async Task NonEmptyString_NullElementInNonNullableIEnumerable_PassesThrough_KnownGap() { - // ⚠ Known gap: `IEnumerable` with [null] deserializes to a list - // containing a null reference, even though NonEmptyString's type contract is - // "non-null". The NonEmptyString converter returns null for JSON null (by design, - // to support NonEmptyString?), NRT annotations erase at runtime so the library - // can't distinguish `NonEmptyString` from `NonEmptyString?`, and ASP.NET's - // nullable validation doesn't walk into generic type arguments. Result: the - // null element sails through. - // - // This is consistent with what plain C# without StrongTypes allows — the NRT - // annotation is advisory at runtime — but it is genuinely weaker than what - // a consumer reading `NonEmptyEnumerable` would expect. + // Known gap being pinned: the converter maps JSON null through (by design, for + // NonEmptyString?) and NRT erasure hides NonEmptyString vs NonEmptyString? at runtime, + // so the null sails through the "non-null" contract. var echoed = await PostOk(NonEmptyStringEndpoint, new { enumerable = new string?[] { "a", null, "c" }, @@ -435,7 +399,7 @@ public async Task NonEmptyString_NullElementInNonNullableIEnumerable_PassesThrou [Fact] public async Task NonEmptyString_NullElementInNonNullableNonEmpty_PassesThrough_KnownGap() { - // ⚠ Same gap as NonEmptyString_NullElementInNonNullableIEnumerable_PassesThrough_KnownGap. + // Same gap as NonEmptyString_NullElementInNonNullableIEnumerable_PassesThrough_KnownGap. var echoed = await PostOk(NonEmptyStringEndpoint, new { enumerable = new[] { "a" }, diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Emails/MailAddressEntityTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Emails/MailAddressEntityTests.cs index ae45617a..4736703e 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Emails/MailAddressEntityTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Emails/MailAddressEntityTests.cs @@ -15,8 +15,6 @@ public sealed class MailAddressEntityTests(TestWebApplicationFactory factory) protected override string FirstValid => "alice@example.com"; protected override string UpdatedValid => "bob@example.org"; - // MailAddress isn't IComparable; the column stores the address string, so the - // database orders by that. Mirror it here for the OrderBy assertion. protected override IComparer ValueComparer => Comparer.Create((left, right) => string.CompareOrdinal(left.Address, right.Address)); diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityCrudTestsBase.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityCrudTestsBase.cs index 01b1a1d3..e4739332 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityCrudTestsBase.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityCrudTestsBase.cs @@ -82,9 +82,8 @@ protected async Task Get(string url) return await response.Content.ReadFromJsonAsync(Ct); } - // T → TNullable bridge. For struct T with TNullable = Nullable, boxing then unboxing to - // Nullable is a supported CLR conversion; for class T with TNullable = T? it is identity. - // default! is null in both shapes. + // (TNullable)(object) looks unsound, but unboxing to Nullable is a supported CLR conversion + // for struct T and identity for class T. protected static TNullable ToNullable(T value) => (TNullable)(object)value!; protected static TNullable NullNullable => default!; @@ -104,10 +103,8 @@ public async Task ValidValueWithNullNullable_PersistsInBothDatabases() await AssertEntity(created.Id, ValidValue, NullNullable); } - // Value is non-nullable, so a null Value is a 400. The error key is mechanism-dependent: a - // struct (or a reference converter that rejects null) fails at parse time -> "$.value"; a - // reference converter that maps null through trips the post-binding implicit-required check - // -> the C# name "Value". Accept the field key with or without the "$." prefix either way. + // The error key is mechanism-dependent — parse-time converter failure keys "$.value", the + // implicit-required check keys "Value" (see testing.md). [Fact] public async Task NullValue_ReturnsBadRequest() { @@ -191,9 +188,7 @@ public async Task Update_ClearsNullableValueToNullInBothDatabases() } // ── Patch ──────────────────────────────────────────────────────────── - // Wire semantics per field: - // Value — null/absent ⇒ skip; non-null ⇒ update. - // NullableValue — null/absent ⇒ skip; { Value: x } ⇒ set x; {} or { Value: null } ⇒ clear. + // Wire semantics per field: see StructEntityPatchRequest. [Fact] public async Task Patch_EmptyBody_LeavesBothFieldsUnchanged() @@ -281,11 +276,7 @@ public async Task Patch_NonExistentId_ReturnsNotFound() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - /// - /// Asserts a 400 carrying a well-formed ValidationProblemDetails (RFC 9457 - /// problem+json with a non-empty errors map) and returns the errors - /// object so callers can assert specific keys. - /// + /// Asserts a 400 problem+json response and returns its errors object for key-specific assertions. protected static async Task AssertValidationProblem(HttpResponseMessage response) { Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.NullableConverter.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.NullableConverter.cs index 92ac41d3..d9643ba4 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.NullableConverter.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.NullableConverter.cs @@ -8,21 +8,12 @@ namespace StrongTypes.Api.IntegrationTests.Tests; -/// -/// Exercises the EF Core value converter for the nullable strong type -/// (NullableValue) directly against both providers: read-back through the -/// converter, NULL/value filtering, ordering, and rejecting a stored value -/// that violates the invariant. Lives on the shared base so every concrete -/// subclass runs it — i.e. it covers every nullable strong type (nullable -/// NonEmptyString, nullable Positive<int>, …). -/// +/// EF Core value-converter coverage for the nullable slot; on the shared base so every nullable strong type runs it. public abstract partial class EntityTests { /// - /// Orders two wrapped values the way the database orders the stored column, for - /// the OrderBy assertion. Override when the type's default CLR comparison - /// differs from its stored order — e.g. MailAddress, which isn't - /// and is stored as its address string. + /// Orders values the way the database orders the stored column; override when the CLR default + /// differs — e.g. MailAddress, not and stored as its address string. /// protected virtual IComparer ValueComparer => Comparer.Default; diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs index 023271c8..6e7106b2 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs @@ -6,12 +6,7 @@ namespace StrongTypes.Api.IntegrationTests.Tests; -/// -/// Compile-time contract every scalar entity test class must satisfy. static abstract -/// members force each concrete subclass to supply the lists — a -/// missing one is a build error, not a silently skipped test. xUnit resolves the actual -/// members by reflection on the concrete type at discovery time. -/// +/// Each concrete test class must supply the data lists — a missing one is a build error, not a silently skipped test. public interface IEntityTestData { static abstract TheoryData ValidInputs { get; } @@ -38,10 +33,8 @@ public abstract partial class EntityTests(T where TEntity : class, IEntity where T : notnull { - /// Wraps a raw wire-format value in the strong type. protected abstract T Create(TWire raw); - /// Baseline valid value seeding the shared create / get / update / PATCH suite. protected abstract TWire FirstValid { get; } /// Update/PATCH target; must differ from . @@ -74,11 +67,8 @@ public async Task ValidInput_PersistsInBothDatabases(TWire value) } // ── Create: invalid ────────────────────────────────────────────────── - // Each invalid value is tested in both slots independently; the other slot holds FirstValid - // so the test isolates the one field being checked. A malformed non-null value fails inside - // the JSON converter while positioned on the property, so System.Text.Json keys the error by - // its path ("$.value" / "$.nullableValue") — the raw framework key, since this harness does - // not call AddStrongTypes to normalize it. + // "$.value" / "$.nullableValue" are the raw System.Text.Json path keys — this harness does + // not call AddStrongTypes to normalize them (see testing.md). [Theory] [MemberData(InvalidInputsMember)] diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/InternalBackingPropertyTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/InternalBackingPropertyTests.cs index b1befd9e..2b355e48 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/InternalBackingPropertyTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/InternalBackingPropertyTests.cs @@ -8,12 +8,9 @@ namespace StrongTypes.Api.IntegrationTests.Tests; /// -/// Issue #112: a nullable strong type held in a non-public EF-mapped backing -/// property must round-trip and be queryable with its value converter wired -/// automatically (no manual HasConversion). Were the converter missing, -/// the model would fail to build ("could not be mapped"). Runs against both -/// providers. The entity is outside the IEntity shape, so this test drives -/// the DbContexts directly rather than through IntegrationTestBase. +/// Issue #112: a nullable strong type in a non-public EF-mapped backing property round-trips +/// and is queryable with its value converter wired automatically (no manual HasConversion). +/// The entity is outside the IEntity shape, so this drives the DbContexts directly. /// [Collection(IntegrationTestCollection.Name)] public sealed class InternalBackingPropertyTests(TestWebApplicationFactory factory) : IDisposable diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/FiniteIntervalEntityApiTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/FiniteIntervalEntityApiTests.cs index 56336242..635948ce 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/FiniteIntervalEntityApiTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/FiniteIntervalEntityApiTests.cs @@ -22,10 +22,8 @@ public sealed class FiniteIntervalEntityApiTests(TestWebApplicationFactory facto protected override object StartAfterEndBody => new { Start = 10, End = 1 }; - // Both endpoints are required; a null endpoint is a 400. protected override object? NullRequiredEndpointBody => new { Start = (int?)null, End = 5 }; - // ...and so is omitting one entirely (here End). protected override object? OmittedRequiredEndpointBody => new { Start = 1 }; [Fact] diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/InternalBackingIntervalPropertyTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/InternalBackingIntervalPropertyTests.cs index c12c82bb..1769eca0 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/InternalBackingIntervalPropertyTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/InternalBackingIntervalPropertyTests.cs @@ -8,14 +8,10 @@ namespace StrongTypes.Api.IntegrationTests.Tests; /// -/// Issue #112 for intervals: a nullable interval in a non-public EF-mapped -/// backing property must round-trip with the two-endpoint-column shape wired -/// automatically, and its shadow discriminator must keep a null property -/// distinct from an unbounded interval on read. Without the convention's -/// complex-property hook the nullable interval loses its discriminator and a -/// stored null is indistinguishable from (-∞, ∞). Runs against both -/// providers; the entity is outside the IEntity shape, so this drives the -/// DbContexts directly rather than through IntegrationTestBase. +/// Issue #112 for intervals: a nullable interval in a non-public EF-mapped backing property +/// round-trips with the two-endpoint-column shape wired automatically, keeping a stored +/// null distinct from an unbounded interval. The entity is outside the IEntity +/// shape, so this drives the DbContexts directly. /// [Collection(IntegrationTestCollection.Name)] public sealed class InternalBackingIntervalPropertyTests(TestWebApplicationFactory factory) : IDisposable diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalColumnEntityTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalColumnEntityTests.cs index 477d349a..ca03399b 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalColumnEntityTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalColumnEntityTests.cs @@ -7,12 +7,10 @@ namespace StrongTypes.Api.IntegrationTests.Tests; /// -/// EF-level round-trip suite for the two-scalar-column persistence shape -/// (HasIntervalColumns → EF Core complex type). The wire/JSON path is -/// identical to the JSON-column entities and already covered by -/// ; what differs here is -/// storage, so these go straight through EF Core against both providers and -/// also assert the interval really maps to two columns rather than one. +/// EF-level suite for the two-scalar-column persistence shape (HasIntervalColumns). +/// The wire path is identical to the JSON-column entities and covered by +/// ; only storage differs, so these go +/// straight through EF Core. /// public abstract class IntervalColumnEntityTestsBase(TestWebApplicationFactory factory) : IntegrationTestBase(factory) diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalColumnMappingMatrixTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalColumnMappingMatrixTests.cs index 05836c3c..aa64a058 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalColumnMappingMatrixTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalColumnMappingMatrixTests.cs @@ -8,17 +8,12 @@ namespace StrongTypes.Api.IntegrationTests.Tests; /// -/// Two-column persistence matrix for the explicit HasIntervalColumns entry -/// point across all four interval variants, each for a non-nullable and a nullable -/// property. Every config round-trips through the real endpoint columns on both -/// providers and asserts the two-column shape (Start/End, discriminator on the -/// nullable form, no flag columns). Endpoint filter/order lives on the -/// subclass (via -/// ) since it needs typed -/// endpoint access; the column translation itself is variant-agnostic and covered for -/// the other variants by IntervalFilterTests. The remaining shapes are covered -/// alongside: the convention two-column default by , -/// and the single JSON column by . +/// Persistence matrix for the explicit HasIntervalColumns entry point: all four interval +/// variants, each non-nullable and nullable, round-trip through the real endpoint columns and +/// assert the two-column shape. Endpoint filter/order lives on +/// since it needs typed endpoint +/// access; the translation is variant-agnostic and covered for the other variants by +/// IntervalFilterTests. /// public abstract class IntervalColumnMappingMatrixTestsBase(TestWebApplicationFactory factory) : IntegrationTestBase(factory) diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalConventionMappingTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalConventionMappingTests.cs index 73d06783..19187786 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalConventionMappingTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalConventionMappingTests.cs @@ -8,16 +8,9 @@ namespace StrongTypes.Api.IntegrationTests.Tests; /// -/// The UseStrongTypes() convention auto-maps an interval property to two -/// endpoint columns by default — no explicit HasIntervalColumns — with a -/// shadow discriminator on the nullable form, and an explicit -/// HasIntervalJsonConversion opts into the single JSON column instead. -/// Model-shape assertions only, against offline relational models: model building -/// never opens a connection, so no connection string is configured and no -/// containers are needed. Real-server round-tripping and ordering are covered by -/// against -/// Testcontainers; the InMemory provider is unusable even here because it maps any -/// struct as a scalar, hiding the complex default. +/// Model-shape assertions against offline relational models — model building never opens a +/// connection, so no containers are needed. The InMemory provider would not do even for this: +/// it maps any struct as a scalar, hiding the complex-type default. /// public class IntervalConventionMappingTests { diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalEntityApiTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalEntityApiTests.cs index f67dd2c6..216f3cd4 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalEntityApiTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalEntityApiTests.cs @@ -23,8 +23,6 @@ public sealed class IntervalEntityApiTests(TestWebApplicationFactory factory) protected override object StartAfterEndBody => new { Start = 10, End = 1 }; - // Both endpoints are optional, so there is no required-endpoint rejection. - [Fact] public async Task OpenEndedInterval_SerializesAbsentEndpointsAsJsonNull() { @@ -41,7 +39,6 @@ public async Task OpenEndedInterval_SerializesAbsentEndpointsAsJsonNull() [Fact] public async Task EmptyObject_IsAcceptedAsUnboundedInterval() { - // Both endpoints optional, so omitting both keys is the unbounded interval. var response = await Client.PostAsJsonAsync( "/interval-entities", new { value = new { }, nullableValue = (object?)null }, Ct); Assert.Equal(HttpStatusCode.Created, response.StatusCode); diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalFromEntityApiTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalFromEntityApiTests.cs index fa437c56..7607a48d 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalFromEntityApiTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalFromEntityApiTests.cs @@ -19,9 +19,7 @@ public sealed class IntervalFromEntityApiTests(TestWebApplicationFactory factory protected override object StartAfterEndBody => new { Start = 10, End = 1 }; - // Start is required; a null Start is a 400. protected override object? NullRequiredEndpointBody => new { Start = (int?)null, End = 5 }; - // ...and so is omitting Start entirely. (Omitting End is valid — it's optional.) protected override object? OmittedRequiredEndpointBody => new { End = 10 }; } diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalUntilEntityApiTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalUntilEntityApiTests.cs index 5c110e9a..33952d88 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalUntilEntityApiTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/Intervals/IntervalUntilEntityApiTests.cs @@ -19,9 +19,7 @@ public sealed class IntervalUntilEntityApiTests(TestWebApplicationFactory factor protected override object StartAfterEndBody => new { Start = 10, End = 1 }; - // End is required; a null End is a 400. protected override object? NullRequiredEndpointBody => new { Start = 5, End = (int?)null }; - // ...and so is omitting End entirely. (Omitting Start is valid — it's optional.) protected override object? OmittedRequiredEndpointBody => new { Start = 1 }; } diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/BindingTestAsserts.cs b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/BindingTestAsserts.cs index 10ff46ca..2b9d633b 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/BindingTestAsserts.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/BindingTestAsserts.cs @@ -7,12 +7,7 @@ namespace StrongTypes.Api.IntegrationTests.Tests; internal static class BindingTestAsserts { - /// - /// Asserts the response is a 400 with a ValidationProblemDetails - /// payload whose errors map contains an entry for - /// (case-insensitive — header names use - /// kebab-case while query/form fields use camelCase). - /// + /// The field match is case-insensitive: error keys echo the wire casing (kebab-case headers vs camelCase fields). internal static async Task AssertValidationProblem(HttpResponseMessage response, string expectedField) { Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/FormBindingTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/FormBindingTests.cs index 55724f87..43f95650 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/FormBindingTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/FormBindingTests.cs @@ -8,12 +8,6 @@ namespace StrongTypes.Api.IntegrationTests.Tests; -/// -/// Verifies that strong types round-trip through MVC's [FromForm] -/// model binding for both required and nullable variants of the wrapped -/// types, and that invalid form fields produce a -/// ValidationProblemDetails 400. -/// [Collection(IntegrationTestCollection.Name)] public sealed class FormBindingTests(TestWebApplicationFactory factory) : IDisposable { diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/HeaderBindingTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/HeaderBindingTests.cs index 7c71e159..933501d2 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/HeaderBindingTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/HeaderBindingTests.cs @@ -8,12 +8,6 @@ namespace StrongTypes.Api.IntegrationTests.Tests; -/// -/// Verifies that strong types round-trip through MVC's [FromHeader] -/// model binding for both required and nullable variants of the wrapped -/// types, and that invalid header values produce a -/// ValidationProblemDetails 400. -/// [Collection(IntegrationTestCollection.Name)] public sealed class HeaderBindingTests(TestWebApplicationFactory factory) : IDisposable { @@ -110,9 +104,8 @@ public async Task FromHeader_MissingRequiredHeader_Returns400() [Fact] public async Task FromHeader_EmptyNullableHeader_ShortCircuitsToNull() { - // Confirms the same NullableConverter quirk applies to headers as - // it does to query / form: a present-but-empty value on a nullable - // parsable type binds to null without invoking TryParse. + // Same NullableConverter quirk as FromQuery: a present-but-empty value on a nullable + // type binds to null without invoking TryParse. using var request = new HttpRequestMessage(HttpMethod.Get, "/binding-probe/header"); request.Headers.Add("X-Name", "Dana"); request.Headers.Add("X-Count", "13"); diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/QueryBindingTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/QueryBindingTests.cs index fad075b6..2e222b8a 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/QueryBindingTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/QueryBindingTests.cs @@ -8,13 +8,6 @@ namespace StrongTypes.Api.IntegrationTests.Tests; -/// -/// Verifies that strong types round-trip through MVC's query-string model -/// binding — both [FromQuery] and the minimal-API-style implicit -/// query binding — and that invalid input lands in the -/// branch with a -/// ValidationProblemDetails payload. -/// [Collection(IntegrationTestCollection.Name)] public sealed class QueryBindingTests(TestWebApplicationFactory factory) : IDisposable { @@ -89,9 +82,6 @@ public async Task FromQuery_InvalidRequired_Returns400ProblemDetails(string quer [InlineData("nullableEmail=not-an-email", "nullableEmail")] public async Task FromQuery_NonEmptyInvalidNullable_Returns400ProblemDetails(string nullableQuery, string expectedField) { - // A nullable param that's *present with a non-empty invalid value* - // still has to fail — nullable means the slot may be omitted, not - // that any garbage is OK. var response = await _client.GetAsync($"/binding-probe/query?{ValidQuery}&{nullableQuery}", Ct); await AssertValidationProblem(response, expectedField); @@ -100,10 +90,8 @@ public async Task FromQuery_NonEmptyInvalidNullable_Returns400ProblemDetails(str [Fact] public async Task FromQuery_EmptyNullable_BindsAsNull() { - // MVC's NullableConverter short-circuits empty strings to null for - // nullable types — TryParse never runs. This is framework behaviour, - // not a strong-types behaviour: documented here so a regression - // (e.g. a custom binder that flips it to 400) is intentional. + // MVC's NullableConverter short-circuits an empty value to null before TryParse runs — + // framework behaviour, not the strong types'. var response = await _client.GetAsync($"/binding-probe/query?{ValidQuery}&nullableName=", Ct); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -116,10 +104,6 @@ public async Task FromQuery_EmptyNullable_BindsAsNull() [InlineData("name=Alice&count=1&digit=0", "email")] public async Task FromQuery_MissingRequiredReferenceType_Returns400ProblemDetails(string query, string expectedField) { - // Reference-type strong types (NonEmptyString, Email) produce a model - // binding error for an omitted query parameter — there's no value to - // bind and null isn't assignable to the non-nullable parameter, so MVC - // surfaces a ValidationProblemDetails entry keyed by the parameter name. var response = await _client.GetAsync($"/binding-probe/query?{query}", Ct); await AssertValidationProblem(response, expectedField); @@ -130,13 +114,8 @@ public async Task FromQuery_MissingRequiredReferenceType_Returns400ProblemDetail [InlineData("name=Alice&count=1&email=alice@example.com")] public async Task FromQuery_MissingRequiredValueType_BindsToDefault(string query) { - // Value-type strong types (Positive, Digit are structs) silently - // bind to default(T) when the query parameter is omitted — MVC's - // TryParseModelBinder doesn't run for a missing source value, and no - // [BindRequired] attribute is present, so the action sees an - // invariant-violating default rather than a 400. Documented here so - // the contract is explicit; fixing this would need a custom binder - // that flags missing required structs. + // An omitted struct parameter silently binds to an invariant-violating default(T): + // TryParseModelBinder never runs for a missing source value and nothing is [BindRequired]. var response = await _client.GetAsync($"/binding-probe/query?{query}", Ct); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/RouteBindingTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/RouteBindingTests.cs index eeddd93d..d74d41d8 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/RouteBindingTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/BindingTests/RouteBindingTests.cs @@ -8,12 +8,7 @@ namespace StrongTypes.Api.IntegrationTests.Tests; -/// -/// Verifies that strong types round-trip through MVC's [FromRoute] -/// model binding. Route segments are required by definition, so only the -/// required-value path applies; missing-segment cases are 404s (route-match -/// failure) rather than 400s (binding failure). -/// +/// Route segments are required by definition, so only the required-value path applies here. [Collection(IntegrationTestCollection.Name)] public sealed class RouteBindingTests(TestWebApplicationFactory factory) : IDisposable { diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Emails/MailAddressFilterTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Emails/MailAddressFilterTests.cs index cbc4cb26..38be245e 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Emails/MailAddressFilterTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Emails/MailAddressFilterTests.cs @@ -6,15 +6,7 @@ namespace StrongTypes.Api.IntegrationTests.Tests; -/// -/// Verifies MailAddress.Unwrap() (plus equality and EF.Functions.Like) -/// translates to server-side SQL on both providers when applied to the -/// 's column. Tests query -/// the directly so the LINQ translator is what we -/// exercise — no HTTP plumbing in the way. Each test seeds rows with a -/// unique local-part prefix and scopes assertions to those rows so it -/// tolerates accumulated state from sibling tests on the shared fixture. -/// +/// Queries go through the DbContext directly — the LINQ translator is under test, not the HTTP pipeline. [Collection(IntegrationTestCollection.Name)] public sealed class MailAddressFilterTests(TestWebApplicationFactory factory) : IntegrationTestBase(factory) @@ -24,8 +16,7 @@ public sealed class MailAddressFilterTests(TestWebApplicationFactory factory) private DbSet Set(string provider) => provider == "sql-server" ? SqlSet : PgSet; - // Unique per-test local-part prefix so each test's assertions are - // isolated from other tests' rows on the collection-scoped database. + // Isolates each test's rows on the collection-shared database. private string Prefix { get; } = $"flt-{Guid.NewGuid():N}-"; private async Task Seed(string localPart, MailAddress? nullableValue) @@ -87,8 +78,6 @@ public async Task UnwrapContains_TranslatesToSql(string provider) Assert.DoesNotContain(noMatch, ids); } - // The load-bearing case: EF.Functions.Like must translate when applied - // to Unwrap() of the MailAddress column. [Theory, MemberData(nameof(Providers))] public async Task EfFunctionsLike_TranslatesToSql(string provider) { diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Intervals/IntervalFilterTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Intervals/IntervalFilterTests.cs index 89b51f81..5c63c6d6 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Intervals/IntervalFilterTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Intervals/IntervalFilterTests.cs @@ -6,11 +6,8 @@ namespace StrongTypes.Api.IntegrationTests.Tests; /// -/// Verifies endpoint access on intervals translates to server-side SQL on both -/// providers, in both persistence shapes: two endpoint columns -/// (HasIntervalColumns, an EF Core complex type) and the default single -/// JSON column (translated to a JSON path lookup). Queries are scoped to the -/// rows each test seeds, since the collection shares one database. +/// Endpoint access on intervals translates to server-side SQL in both persistence shapes. +/// Queries are scoped to the rows each test seeds — the collection shares one database. /// [Collection(IntegrationTestCollection.Name)] public sealed class IntervalFilterTests(TestWebApplicationFactory factory) diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Intervals/IntervalReadValidationTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Intervals/IntervalReadValidationTests.cs index 49d552f5..7bf9547e 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Intervals/IntervalReadValidationTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Intervals/IntervalReadValidationTests.cs @@ -8,11 +8,9 @@ namespace StrongTypes.Api.IntegrationTests.Tests; /// -/// Verifies the read-validation guarantee for every interval variant: a stored row -/// violating the wrapper's invariant (planted via raw SQL, bypassing the strong types) -/// throws on materialization instead of producing an invalid interval — in both -/// persistence shapes (two endpoint columns and a single JSON column), on both providers. -/// Variant-specific invariants (a required endpoint) are covered on the concrete subclasses. +/// The read-validation guarantee: a stored row violating the wrapper's invariant (planted via +/// raw SQL — the strong types cannot produce one) throws on materialization, in both +/// persistence shapes. Variant-specific invariants live on the concrete subclasses. /// public abstract class IntervalReadValidationTestsBase(TestWebApplicationFactory factory) : IntegrationTestBase(factory) diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs index 6bdb43fd..fcb26648 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs @@ -6,18 +6,10 @@ namespace StrongTypes.Api.IntegrationTests.Tests; /// -/// Verifies Unwrap() on the source-generated numeric wrappers translates -/// to a plain-int column reference, so LINQ predicates on the raw value -/// (arithmetic, Math.Abs, etc.) evaluate server-side on both providers. -/// One representative case per wrapper type — the translator logic is identical -/// across shapes, so we don't need to matrix every arithmetic operator. -/// -/// Integration tests share a single database across the collection, so other -/// suites may have seeded rows containing int.MaxValue/int.MinValue -/// into the same table. We therefore cast to long inside arithmetic -/// predicates — SQL Server does not guarantee short-circuit evaluation, so any -/// predicate that multiplies a 32-bit column would otherwise overflow on those -/// extreme-value rows before the ID filter gets a chance to exclude them. +/// One representative arithmetic case per wrapper — the translation is identical across shapes. +/// Predicates cast to long because sibling suites seed int.MaxValue/MinValue +/// rows into the shared tables and SQL Server does not short-circuit, so 32-bit arithmetic +/// would overflow on those rows before the ID filter excludes them. /// [Collection(IntegrationTestCollection.Name)] public sealed class NumericUnwrapFilterTests(TestWebApplicationFactory factory) diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Strings/NonEmptyStringFilterTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Strings/NonEmptyStringFilterTests.cs index 6e8b0d6a..7c19cbd8 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Strings/NonEmptyStringFilterTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Strings/NonEmptyStringFilterTests.cs @@ -5,15 +5,7 @@ namespace StrongTypes.Api.IntegrationTests.Tests; -/// -/// Verifies NonEmptyString.Unwrap() (plus equality, null checks, and -/// ordering on the strong type) translates to server-side SQL on both -/// providers. Tests query the directly — the LINQ -/// translator is what we're exercising, no HTTP plumbing in the way. -/// Each test seeds rows with a unique prefix and scopes assertions to those -/// rows so it tolerates accumulated state from sibling tests on the shared -/// fixture. -/// +/// Queries go through the DbContext directly — the LINQ translator is under test, not the HTTP pipeline. [Collection(IntegrationTestCollection.Name)] public sealed class NonEmptyStringFilterTests(TestWebApplicationFactory factory) : IntegrationTestBase(factory) @@ -23,8 +15,7 @@ public sealed class NonEmptyStringFilterTests(TestWebApplicationFactory factory) private DbSet Set(string provider) => provider == "sql-server" ? SqlSet : PgSet; - // Unique per-test prefix so each test's assertions are isolated from - // other tests' rows on the collection-scoped database. + // Isolates each test's rows on the collection-shared database. private string Prefix { get; } = $"flt-{Guid.NewGuid():N}-"; private async Task Seed(string value, NonEmptyString? nullableValue) @@ -174,8 +165,6 @@ public async Task UnwrapEndsWith_TranslatesToSql(string provider) Assert.DoesNotContain(noMatch, ids); } - // The load-bearing case from the issue: EF.Functions.Like must translate - // when applied to Unwrap() of a strong-type column. [Theory, MemberData(nameof(Providers))] public async Task EfFunctionsLike_TranslatesToSql(string provider) { diff --git a/src/StrongTypes.Api/Controllers/BindingProbeController.cs b/src/StrongTypes.Api/Controllers/BindingProbeController.cs index de5178ba..ef09b58c 100644 --- a/src/StrongTypes.Api/Controllers/BindingProbeController.cs +++ b/src/StrongTypes.Api/Controllers/BindingProbeController.cs @@ -3,13 +3,9 @@ namespace StrongTypes.Api.Controllers; /// -/// Exercises every non-body model-binding source supported by ASP.NET Core MVC -/// ([FromQuery], [FromRoute], [FromHeader], [FromForm], -/// and implicit query/route binding) for the strong types that have a -/// TryParse path. Each endpoint accepts a required and a nullable -/// variant of every wrapped type so round-trip tests cover both branches. -/// [FromRoute] is required-only — route segments are required by HTTP -/// semantics; the optional / nullable equivalent is [FromQuery]. +/// Each endpoint takes a required and a nullable variant of every wrapped type so binding tests +/// cover both branches. [FromRoute] is required-only — route segments are required by +/// HTTP semantics. /// [ApiController] [Route("binding-probe")] diff --git a/src/StrongTypes.Api/Controllers/CollectionJsonController.cs b/src/StrongTypes.Api/Controllers/CollectionJsonController.cs index c10c8699..2bd907b9 100644 --- a/src/StrongTypes.Api/Controllers/CollectionJsonController.cs +++ b/src/StrongTypes.Api/Controllers/CollectionJsonController.cs @@ -4,11 +4,8 @@ namespace StrongTypes.Api.Controllers; /// -/// Collection endpoints for the JSON-contract integration-test suite. Each POST -/// returns the deserialized DTO verbatim — exercising both halves of the round -/// trip (STJ deserialization + ASP.NET Core validation on the way in, STJ -/// serialization on the way out). No persistence: collections aren't part of the -/// EF Core story yet, and these tests are purely about wire-format handling. +/// Each POST echoes the deserialized DTO back verbatim, exercising both halves of the JSON +/// round trip. No persistence — collections are not part of the EF Core story yet. /// [ApiController] [Route("collections")] diff --git a/src/StrongTypes.Api/Controllers/EntityControllerBase.cs b/src/StrongTypes.Api/Controllers/EntityControllerBase.cs index cfa2fc13..463db2b2 100644 --- a/src/StrongTypes.Api/Controllers/EntityControllerBase.cs +++ b/src/StrongTypes.Api/Controllers/EntityControllerBase.cs @@ -4,13 +4,6 @@ namespace StrongTypes.Api.Controllers; -/// -/// Shared infrastructure for the struct- and reference-typed controller bases: -/// the two accessors and a helper that flushes both -/// s. Concrete controllers do not derive from this -/// directly — they pick -/// or . -/// public abstract class EntityControllerBase( SqlServerDbContext sqlCtx, PostgreSqlDbContext pgCtx) : ControllerBase diff --git a/src/StrongTypes.Api/Controllers/MailAddressEntityController.cs b/src/StrongTypes.Api/Controllers/MailAddressEntityController.cs index 7b30c4c3..2436a29a 100644 --- a/src/StrongTypes.Api/Controllers/MailAddressEntityController.cs +++ b/src/StrongTypes.Api/Controllers/MailAddressEntityController.cs @@ -7,11 +7,9 @@ namespace StrongTypes.Api.Controllers; /// -/// Bespoke controller for : requests and responses are -/// shaped in (so the wrapper's JSON converter enforces the -/// 254-character cap and addr-spec parse on the way in) but the entity itself -/// stores the BCL — the validation contract belongs -/// at the wire boundary, not on every read. +/// Requests and responses are shaped in while the entity stores the BCL +/// — the validation contract belongs at the wire boundary, not on +/// every read. /// [ApiController] [Route("mail-address-entities")] @@ -77,12 +75,6 @@ public async Task Patch(Guid id, ReferenceEntityPatchRequest - /// DTO for the response. Custom shape (rather than a generic - /// ) because the entity stores a - /// reference but the wire surface is the - /// wrapper. - /// public sealed record MailAddressEntityDto(Guid Id, Email Value, Email? NullableValue); private static MailAddressEntityDto ToDto(MailAddressEntity entity) => diff --git a/src/StrongTypes.Api/Controllers/ReferenceTypeEntityControllerBase.cs b/src/StrongTypes.Api/Controllers/ReferenceTypeEntityControllerBase.cs index cac9e7fc..4cf9576e 100644 --- a/src/StrongTypes.Api/Controllers/ReferenceTypeEntityControllerBase.cs +++ b/src/StrongTypes.Api/Controllers/ReferenceTypeEntityControllerBase.cs @@ -5,11 +5,6 @@ namespace StrongTypes.Api.Controllers; -/// -/// Controller base for entities whose is a reference -/// type. TNullable is fixed to T? (the same reference annotated -/// nullable), so PATCH conversions are direct null-checks with no boxing. -/// public abstract class ReferenceTypeEntityControllerBase( SqlServerDbContext sqlCtx, PostgreSqlDbContext pgCtx) : EntityControllerBase(sqlCtx, pgCtx) diff --git a/src/StrongTypes.Api/Controllers/StructTypeEntityControllerBase.cs b/src/StrongTypes.Api/Controllers/StructTypeEntityControllerBase.cs index 33aee40f..b5fe1901 100644 --- a/src/StrongTypes.Api/Controllers/StructTypeEntityControllerBase.cs +++ b/src/StrongTypes.Api/Controllers/StructTypeEntityControllerBase.cs @@ -5,12 +5,6 @@ namespace StrongTypes.Api.Controllers; -/// -/// Controller base for entities whose is a struct. -/// TNullable is fixed to T? (i.e. Nullable<T>), which -/// lets every conversion in PATCH be a direct nullable-value access — no boxing -/// or runtime casts. -/// public abstract class StructTypeEntityControllerBase( SqlServerDbContext sqlCtx, PostgreSqlDbContext pgCtx) : EntityControllerBase(sqlCtx, pgCtx) diff --git a/src/StrongTypes.Api/Data/IntervalEntityConfiguration.cs b/src/StrongTypes.Api/Data/IntervalEntityConfiguration.cs index 95a71210..6e4fe4ff 100644 --- a/src/StrongTypes.Api/Data/IntervalEntityConfiguration.cs +++ b/src/StrongTypes.Api/Data/IntervalEntityConfiguration.cs @@ -4,7 +4,10 @@ namespace StrongTypes.Api.Data; -/// Maps every interval-mapping configuration under test, identically for both DbContexts: the *IntervalEntity set opts into the single JSON column, and the ExplicitColumns* set calls HasIntervalColumns explicitly across all four variants. The parallel *ColumnsEntity set stays unconfigured on purpose — it exercises the UseStrongTypes() two-endpoint-column default. +/// +/// Applied identically by both DbContexts. The *ColumnsEntity set is deliberately not +/// configured here — it exercises the UseStrongTypes() two-endpoint-column default. +/// internal static class IntervalEntityConfiguration { public static void ConfigureIntervalEntities(this ModelBuilder modelBuilder) diff --git a/src/StrongTypes.Api/Entities/Entities.cs b/src/StrongTypes.Api/Entities/Entities.cs index 385c0f8b..ffeb722a 100644 --- a/src/StrongTypes.Api/Entities/Entities.cs +++ b/src/StrongTypes.Api/Entities/Entities.cs @@ -49,7 +49,7 @@ public sealed class IntervalColumnsEntity : EntityBase, IntervalFrom?>; public sealed class IntervalUntilColumnsEntity : EntityBase, IntervalUntil?>; -// Explicit HasIntervalColumns entry point across all four variants; see IntervalEntityConfiguration. +// Explicit HasIntervalColumns entry point; see IntervalEntityConfiguration. public sealed class ExplicitColumnsFiniteIntervalEntity : EntityBase, FiniteInterval?>; public sealed class ExplicitColumnsIntervalFromEntity : EntityBase, IntervalFrom?>; public sealed class ExplicitColumnsIntervalUntilEntity : EntityBase, IntervalUntil?>; diff --git a/src/StrongTypes.Api/Entities/EntityBase.cs b/src/StrongTypes.Api/Entities/EntityBase.cs index 76054d3c..89372377 100644 --- a/src/StrongTypes.Api/Entities/EntityBase.cs +++ b/src/StrongTypes.Api/Entities/EntityBase.cs @@ -1,11 +1,5 @@ namespace StrongTypes.Api.Entities; -/// -/// Shared state and factory for concrete -/// implementations. Concrete entities just declare their generic arguments; this -/// base supplies the Id/Value/NullableValue storage and the -/// static Create required by the interface. -/// public abstract class EntityBase : IEntity where TSelf : EntityBase, new() where T : notnull @@ -14,11 +8,7 @@ public abstract class EntityBase : IEntity - /// Public surface for the interface's default Update: lets callers - /// invoke entity.Update(...) directly on a concrete type without - /// casting through . - /// + /// Shadows the interface's default Update so callers don't have to cast to the interface. public void Update(T value, TNullable nullableValue) { Value = value; diff --git a/src/StrongTypes.Api/Entities/IEntity.cs b/src/StrongTypes.Api/Entities/IEntity.cs index 073cd698..574e2aa9 100644 --- a/src/StrongTypes.Api/Entities/IEntity.cs +++ b/src/StrongTypes.Api/Entities/IEntity.cs @@ -5,12 +5,7 @@ public interface IEntity Guid Id { get; } } -/// -/// Shape every integration-test entity follows: an identifier plus a required -/// value and an optional -/// value. A single interface covers both reference and value strong types by -/// letting callers choose (typically T?). -/// +/// TNullable is its own parameter so one interface spans value and reference wrappers — each supplies its form of T?. public interface IEntity : IEntity where TSelf : IEntity where T : notnull diff --git a/src/StrongTypes.Api/Entities/InternalBackingIntervalEntity.cs b/src/StrongTypes.Api/Entities/InternalBackingIntervalEntity.cs index b950cc39..ede232bf 100644 --- a/src/StrongTypes.Api/Entities/InternalBackingIntervalEntity.cs +++ b/src/StrongTypes.Api/Entities/InternalBackingIntervalEntity.cs @@ -3,13 +3,10 @@ namespace StrongTypes.Api.Entities; /// -/// The interval analog of (issue #112): a -/// nullable interval held in a non-public EF-mapped backing property. EF does -/// not discover non-public members by convention, so -/// is mapped explicitly as a complex property in ; the -/// StrongTypes convention must then give it the two-endpoint-column shape — -/// including the shadow discriminator that keeps a null property distinct -/// from an unbounded interval — automatically. +/// The interval analog of (issue #112). EF does not discover +/// non-public members, so maps explicitly; +/// the convention must still shape it as endpoint columns with the shadow discriminator that +/// keeps null distinct from an unbounded interval. /// public sealed class InternalBackingIntervalEntity { diff --git a/src/StrongTypes.Api/Models/CollectionJsonModels.cs b/src/StrongTypes.Api/Models/CollectionJsonModels.cs index 6e9b8e34..66ac5fd8 100644 --- a/src/StrongTypes.Api/Models/CollectionJsonModels.cs +++ b/src/StrongTypes.Api/Models/CollectionJsonModels.cs @@ -1,13 +1,8 @@ namespace StrongTypes.Api.Models; /// -/// Request DTOs for the JSON-contract integration tests, exercising every combination of -/// {, } × -/// {non-nullable element, nullable element}, for four representative element types -/// (plain value, strong value, plain reference, strong reference). The integration-test -/// suite posts JSON, the controller returns the DTO back, and the tests assert both -/// the deserialize and serialize halves of the round trip — plus how ASP.NET Core + -/// STJ handle each kind of malformed input. +/// Every combination of {, } × +/// {non-nullable element, nullable element}, for one representative element type per category. /// public sealed record IntCollectionsRequest( IEnumerable Enumerable, diff --git a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/BindingTests/NonEmptyEnumerableBindingTests.cs b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/BindingTests/NonEmptyEnumerableBindingTests.cs index 0d0988d3..e8e48f3f 100644 --- a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/BindingTests/NonEmptyEnumerableBindingTests.cs +++ b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/BindingTests/NonEmptyEnumerableBindingTests.cs @@ -8,10 +8,6 @@ namespace StrongTypes.AspNetCore.IntegrationTests.Tests.BindingTests; -/// -/// Verifies the MVC binders shipped in Kalicz.StrongTypes.AspNetCore -/// for non-empty collections of strong types across non-body binding sources. -/// public sealed class NonEmptyEnumerableBindingTests(AspNetCoreTestApiFactory factory) : IClassFixture, IDisposable { private readonly HttpClient _client = factory.CreateClient(new WebApplicationFactoryClientOptions diff --git a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/JsonBodyErrorKeyTests.cs b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/JsonBodyErrorKeyTests.cs index 0aca4161..595e0769 100644 --- a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/JsonBodyErrorKeyTests.cs +++ b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/JsonBodyErrorKeyTests.cs @@ -42,7 +42,6 @@ public JsonBodyErrorKeyTests(NormalizedJsonErrorKeysFactory normalized, RawJsonE private const string NonEmptyString = "/json-body-probe/non-empty-string"; private const string PositiveInt = "/json-body-probe/positive-int"; - // (endpoint, body, key without normalization, key with normalization [PascalCase default]) private static readonly (string Endpoint, string Json, string RawKey, string NormalizedKey)[] Cases = [ // Reference type: malformed non-null fails in the converter -> $.value. @@ -52,7 +51,7 @@ private static readonly (string Endpoint, string Json, string RawKey, string Nor (NonEmptyString, """{"value":null,"nullableValue":"ok"}""", "Value", "Value"), // Struct type: invariant failure -> converter throws while positioned -> $.value. (PositiveInt, """{"value":0,"nullableValue":5}""", "$.value", "Value"), - // Struct type: type mismatch and null now also report $.value (converter rethrow). + // Struct type: type mismatch and null also report $.value (converter rethrow). (PositiveInt, """{"value":"abc","nullableValue":5}""", "$.value", "Value"), (PositiveInt, """{"value":null,"nullableValue":5}""", "$.value", "Value"), (PositiveInt, """{"value":5,"nullableValue":0}""", "$.nullableValue", "NullableValue"), @@ -92,9 +91,7 @@ public async Task InvalidJsonBody_ReportsExpectedErrorKey( } // The target the default (PascalCase) normalization is matching: data-annotation - // errors are keyed by the C# property name (Value, Email), with no $. path. - // Strong-type body errors under PascalCase normalization land on the same keys, - // so the two surfaces agree. + // errors are keyed by the C# property name, with no $. path. [Fact] public async Task DataAnnotationErrors_AreKeyedByPascalCasePropertyName() { diff --git a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/JsonValidationErrorKeyNormalizerTests.cs b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/JsonValidationErrorKeyNormalizerTests.cs index 17a052fc..2622e717 100644 --- a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/JsonValidationErrorKeyNormalizerTests.cs +++ b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/JsonValidationErrorKeyNormalizerTests.cs @@ -5,7 +5,6 @@ namespace StrongTypes.AspNetCore.IntegrationTests.Tests; public sealed class JsonValidationErrorKeyNormalizerTests { [Theory] - // Flat property, every casing. [InlineData("$.value", JsonErrorKeyCasing.PascalCase, "Value")] [InlineData("$.value", JsonErrorKeyCasing.CamelCase, "value")] [InlineData("$.value", JsonErrorKeyCasing.StripOnly, "value")] @@ -17,7 +16,6 @@ public sealed class JsonValidationErrorKeyNormalizerTests [InlineData("$.items[0].name", JsonErrorKeyCasing.StripOnly, "items[0].name")] // Root array element has no leading name to re-case. [InlineData("$[0]", JsonErrorKeyCasing.PascalCase, "[0]")] - // Bare root collapses to empty. [InlineData("$", JsonErrorKeyCasing.PascalCase, "")] // Keys without the JSON root come from model binding and pass through untouched. [InlineData("Value", JsonErrorKeyCasing.CamelCase, "Value")] diff --git a/src/StrongTypes.AspNetCore/JsonValidationErrorKeyNormalizer.cs b/src/StrongTypes.AspNetCore/JsonValidationErrorKeyNormalizer.cs index 1cab7531..59798df6 100644 --- a/src/StrongTypes.AspNetCore/JsonValidationErrorKeyNormalizer.cs +++ b/src/StrongTypes.AspNetCore/JsonValidationErrorKeyNormalizer.cs @@ -8,18 +8,14 @@ namespace StrongTypes.AspNetCore; /// Items[0].Name) MVC uses for model-binding and data-annotation errors. /// /// -/// The path carries the JSON wire name; the target validation key uses the C# -/// property name. The two differ only in casing for the common case, so casing -/// is applied per segment. There is no metadata at this layer, so a custom -/// [JsonPropertyName] that is not just a re-cased property name cannot be -/// recovered. +/// There is no metadata at this layer, so a custom [JsonPropertyName] that is not just a +/// re-cased property name cannot be recovered. /// internal static class JsonValidationErrorKeyNormalizer { /// - /// Returns the normalized key. Keys that do not start with the JSON root - /// token ($) come from model binding, not the body, and pass through - /// unchanged. + /// Keys that do not start with the JSON root token ($) come from model binding, not + /// the body, and pass through unchanged. /// public static string Normalize(string key, JsonErrorKeyCasing casing) { @@ -40,8 +36,6 @@ public static string Normalize(string key, JsonErrorKeyCasing casing) private static string ApplyCasing(string segment, JsonErrorKeyCasing casing) { - // A segment may carry array indexers (e.g. "items[0]"); only the leading - // name is re-cased, the bracketed suffix is left untouched. var bracket = segment.IndexOf('['); var name = bracket < 0 ? segment : segment[..bracket]; if (name.Length == 0) return segment; diff --git a/src/StrongTypes.AspNetCore/NonEmptyEnumerableModelBinder.cs b/src/StrongTypes.AspNetCore/NonEmptyEnumerableModelBinder.cs index 8a139a85..25a33d21 100644 --- a/src/StrongTypes.AspNetCore/NonEmptyEnumerableModelBinder.cs +++ b/src/StrongTypes.AspNetCore/NonEmptyEnumerableModelBinder.cs @@ -68,11 +68,9 @@ private static StringValues ReadRawValues(ModelBindingContext bindingContext) var bindingSource = bindingContext.BindingSource; if (bindingSource is not null && bindingSource.CanAcceptDataFrom(BindingSource.Header)) { - // Headers don't flow through the value provider chain — only HeaderModelBinder - // sees them. Read them ourselves so the FieldName lookup matches what - // [FromHeader(Name = "...")] specified. Multiple values arrive either as - // repeated header lines (StringValues with multiple entries) or as a - // comma-separated single line — accept both. + // Headers don't flow through the value provider chain, so read them ourselves; the + // FieldName lookup matches what [FromHeader(Name = "...")] specified. Multiple values + // arrive as repeated header lines or a comma-separated single line — accept both. var headers = bindingContext.HttpContext.Request.Headers; if (!headers.TryGetValue(bindingContext.FieldName, out var values)) return StringValues.Empty; diff --git a/src/StrongTypes.AspNetCore/NonEmptyEnumerableModelBinderProvider.cs b/src/StrongTypes.AspNetCore/NonEmptyEnumerableModelBinderProvider.cs index ced44fde..2a7adb09 100644 --- a/src/StrongTypes.AspNetCore/NonEmptyEnumerableModelBinderProvider.cs +++ b/src/StrongTypes.AspNetCore/NonEmptyEnumerableModelBinderProvider.cs @@ -4,7 +4,6 @@ namespace StrongTypes.AspNetCore; /// Resolves an for action parameters typed as . -/// The binder reads raw values from the request source (header / route / query / form), parses each via the element type's , and wraps the result via ; an empty source surfaces as a binding error. public sealed class NonEmptyEnumerableModelBinderProvider : IModelBinderProvider { public IModelBinder? GetBinder(ModelBinderProviderContext context) diff --git a/src/StrongTypes.AspNetCore/StringElementParser.cs b/src/StrongTypes.AspNetCore/StringElementParser.cs index 3d1147f9..053b494b 100644 --- a/src/StrongTypes.AspNetCore/StringElementParser.cs +++ b/src/StrongTypes.AspNetCore/StringElementParser.cs @@ -5,14 +5,14 @@ namespace StrongTypes.AspNetCore; /// Parses a single wire string into via . -/// Discovered by reflection once per closed type. Works for any with a public static TryParse(string, IFormatProvider?, out T) — every BCL primitive in net7+ and every Kalicz.StrongTypes wrapper. +/// Works for any with a public static TryParse(string, IFormatProvider?, out T) — every BCL primitive in net7+ and every Kalicz.StrongTypes wrapper. internal static class StringElementParser { private delegate bool TryParseDelegate(string? s, IFormatProvider? provider, out T result); private static readonly TryParseDelegate? s_tryParse = ResolveTryParse(); - /// True when exposes the IParsable shape; false otherwise (no parser available — the caller surfaces a binding error). + /// True when exposes the IParsable shape. public static bool IsSupported => s_tryParse is not null; public static bool TryParse(string value, out T result) diff --git a/src/StrongTypes.AspNetCore/StrongTypes.AspNetCore.csproj b/src/StrongTypes.AspNetCore/StrongTypes.AspNetCore.csproj index e150504f..04f63092 100644 --- a/src/StrongTypes.AspNetCore/StrongTypes.AspNetCore.csproj +++ b/src/StrongTypes.AspNetCore/StrongTypes.AspNetCore.csproj @@ -12,7 +12,7 @@ StrongTypes.AspNetCore KaliCZ Copyright © 2026 KaliCZ - ASP.NET Core MVC integration for Kalicz.StrongTypes: model binders for NonEmptyEnumerable<T> and Maybe<T> from [FromForm] (the primary use), [FromQuery], [FromHeader], or [FromRoute]; plus normalization of JSON request-body validation error keys (e.g. "$.value" to "Value") so they match data-annotation and model-binding keys. For [FromBody] both wrappers already round-trip via the JSON converters in Kalicz.StrongTypes. Single registration call: services.AddStrongTypes(). + ASP.NET Core MVC integration for Kalicz.StrongTypes: a model binder for NonEmptyEnumerable<T> from [FromForm] (the primary use), [FromQuery], [FromHeader], or [FromRoute]; plus normalization of JSON request-body validation error keys (e.g. "$.value" to "Value") so they match data-annotation and model-binding keys. For [FromBody] the wrappers already round-trip via the JSON converters in Kalicz.StrongTypes. Single registration call: services.AddStrongTypes(). StrongTypes, AspNetCore, ModelBinding, NonEmptyEnumerable, Maybe MIT false diff --git a/src/StrongTypes.AspNetCore/StrongTypesServiceCollectionExtensions.cs b/src/StrongTypes.AspNetCore/StrongTypesServiceCollectionExtensions.cs index 95dcc8e5..e6b9722b 100644 --- a/src/StrongTypes.AspNetCore/StrongTypesServiceCollectionExtensions.cs +++ b/src/StrongTypes.AspNetCore/StrongTypesServiceCollectionExtensions.cs @@ -14,8 +14,6 @@ public static class StrongTypesServiceCollectionExtensions /// and, unless turned off via , normalizes JSON /// request-body validation error keys (see ). /// - /// The service collection to configure. - /// Optional configuration callback. public static IServiceCollection AddStrongTypes( this IServiceCollection services, Action? configure = null) @@ -29,11 +27,9 @@ public static IServiceCollection AddStrongTypes( mvc.ModelBinderProviders.Insert(0, new NonEmptyEnumerableModelBinderProvider()); }); - // Wrap the default factory and rewrite the keys at request time, reading - // the flag from DI so tests (and consumers) can flip it per app instance. - // PostConfigure runs after the framework's own Configure that installs the - // default factory, so wrapping it doesn't depend on AddStrongTypes being - // called after AddControllers. + // The options are resolved from DI at request time so the flag can differ per app instance. + // PostConfigure runs after the framework's Configure that installs the default factory, + // so this doesn't depend on AddStrongTypes being called after AddControllers. services.PostConfigure(api => { var inner = api.InvalidModelStateResponseFactory; diff --git a/src/StrongTypes.Benchmarks/GetFlagsStrategies.cs b/src/StrongTypes.Benchmarks/GetFlagsStrategies.cs index 88e5a06a..aa158106 100644 --- a/src/StrongTypes.Benchmarks/GetFlagsStrategies.cs +++ b/src/StrongTypes.Benchmarks/GetFlagsStrategies.cs @@ -7,9 +7,8 @@ namespace StrongTypes.Benchmarks; /// -/// Standalone reimplementations of GetFlags using four different caching -/// strategies. Each takes the pre-built cache as input so the benchmark -/// measures only the hot-path cost, not cache construction. +/// Standalone reimplementations of GetFlags, each taking its pre-built cache as input so the +/// benchmark measures only the hot-path cost, not cache construction. /// internal static class GetFlagsStrategies { diff --git a/src/StrongTypes.Benchmarks/IntervalOperationBenchmarks.cs b/src/StrongTypes.Benchmarks/IntervalOperationBenchmarks.cs index db74d1e7..bb12b419 100644 --- a/src/StrongTypes.Benchmarks/IntervalOperationBenchmarks.cs +++ b/src/StrongTypes.Benchmarks/IntervalOperationBenchmarks.cs @@ -3,8 +3,7 @@ namespace StrongTypes.Benchmarks; -// The interval types are structs, so these operate on the stack; MemoryDiagnoser confirms zero heap allocation -// even for the bridging Contains, which builds a temporary day window. +// The bridging Contains(DateOnly) builds a temporary day window, so it is the one case that could allocate. [MemoryDiagnoser] public class IntervalOperationBenchmarks { diff --git a/src/StrongTypes.Benchmarks/IntervalPersistenceBenchmarks.cs b/src/StrongTypes.Benchmarks/IntervalPersistenceBenchmarks.cs index 1aeab774..70ea9d6c 100644 --- a/src/StrongTypes.Benchmarks/IntervalPersistenceBenchmarks.cs +++ b/src/StrongTypes.Benchmarks/IntervalPersistenceBenchmarks.cs @@ -6,10 +6,7 @@ namespace StrongTypes.Benchmarks; -// Guards interval persistence parity: a convention-mapped interval (two endpoint columns, -// validated in its constructor on read) against a hand-rolled entity storing the two endpoints as -// plain columns, over in-memory SQLite so any per-row cost shows instead of being lost under a real -// database's I/O. Interval reads carry no materialization interceptor, so they should track plain. +// In-memory SQLite so any per-row interval cost shows instead of being lost under a real database's I/O. public sealed class PlainRow { diff --git a/src/StrongTypes.Benchmarks/NonIntervalReadBenchmarks.cs b/src/StrongTypes.Benchmarks/NonIntervalReadBenchmarks.cs index c60ea103..730bdef1 100644 --- a/src/StrongTypes.Benchmarks/NonIntervalReadBenchmarks.cs +++ b/src/StrongTypes.Benchmarks/NonIntervalReadBenchmarks.cs @@ -6,8 +6,7 @@ namespace StrongTypes.Benchmarks; -// A plain entity with no interval and no strong type. Guards that UseStrongTypes adds no per-row read -// overhead to entities it never touches — it registers no materialization interceptor. +// Guards that UseStrongTypes adds no per-row read overhead to entities it never touches. public sealed class ScalarRow { public int Id { get; set; } diff --git a/src/StrongTypes.Configuration/readme.md b/src/StrongTypes.Configuration/readme.md index 83b0c9a4..fa40e011 100644 --- a/src/StrongTypes.Configuration/readme.md +++ b/src/StrongTypes.Configuration/readme.md @@ -13,8 +13,8 @@ builder.Services.AddOptions() ## The problem [`Kalicz.StrongTypes`](https://www.nuget.org/packages/Kalicz.StrongTypes/) needs no package to -bind: every wrapper carries a `TypeConverter`, so values bind and invalid ones throw with the -invariant's own message. **This package is only about the key that isn't there.** +bind: every scalar wrapper carries a `TypeConverter`, so values bind and invalid ones throw with +the invariant's own message. **This package is only about the key that isn't there.** A wrapper's invariant constrains every value it can hold. It cannot make the binder assign one — the binder reaches in through reflection and never calls `Create` — and for an absent key it assigns @@ -32,6 +32,9 @@ binding an absent key *succeeds*, it just doesn't assign, so nothing is raised. doesn't either — it's a compile-time rule and the binder's reflection walks past it. `[Required]` does work, but only if you remember it on every property. +The analyzer that ships with `Kalicz.StrongTypes` (ST0004) flags a plain `Bind(...)` that would +leave a non-nullable wrapper null, with a code fix that rewrites the call to `BindStrongTypes(...)`. + ## What it does Fails when a property **declared non-nullable** is null after binding. Your nullable reference diff --git a/src/StrongTypes.EfCore/IntervalEfCoreExtensions.cs b/src/StrongTypes.EfCore/IntervalEfCoreExtensions.cs index 90879e96..490b70b4 100644 --- a/src/StrongTypes.EfCore/IntervalEfCoreExtensions.cs +++ b/src/StrongTypes.EfCore/IntervalEfCoreExtensions.cs @@ -15,10 +15,6 @@ namespace StrongTypes.EfCore; public static class IntervalEfCoreExtensions { /// Maps the interval property to a single JSON column. The same converter handles all four interval types and re-validates the Start <= End invariant on read. - /// The entity type. - /// The interval struct type. - /// The entity-type builder. - /// A lambda selecting the interval property. public static PropertyBuilder HasIntervalJsonConversion( this EntityTypeBuilder entity, Expression> propertyExpression) @@ -27,22 +23,16 @@ public static PropertyBuilder HasIntervalJsonConversionMaps the interval property to a single JSON column. The same converter handles all four interval types and re-validates the Start <= End invariant on read. - /// The interval struct type. - /// The property builder. public static PropertyBuilder HasIntervalJsonConversion(this PropertyBuilder property) where TInterval : struct => property.HasConversion(new IntervalJsonValueConverter()); /// Maps the nullable interval property to a single JSON column; a null property maps to a NULL column. The same converter handles all four interval types and re-validates the Start <= End invariant on read. - /// The interval struct type. - /// The property builder. public static PropertyBuilder HasIntervalJsonConversion(this PropertyBuilder property) where TInterval : struct => property.HasConversion(new IntervalJsonValueConverter()); /// Maps the interval property to two scalar columns (Start and End) as an EF Core complex type, so each endpoint is its own queryable, indexable column. The endpoint columns are nullable exactly when the variant's endpoint is (FiniteInterval → both required; IntervalFrom → end nullable; and so on). Inclusivity is not stored: both bounds read back inclusive, and an exclusive bound is dropped on save. - /// The entity type. - /// The interval struct type. /// The entity-type builder. /// A lambda selecting the interval property. /// Column name for the start endpoint; defaults to Start. @@ -62,8 +52,6 @@ public static ComplexPropertyBuilder HasIntervalColumnsMaps the nullable interval property to two scalar columns plus a shadow discriminator column that keeps a null property distinct from an interval whose stored endpoints are all NULL (a fully-unbounded ). Inclusivity is not stored: both bounds read back inclusive. - /// The entity type. - /// The interval struct type. /// The entity-type builder. /// A lambda selecting the interval property. /// Column name for the start endpoint; defaults to Start. @@ -83,7 +71,6 @@ public static ComplexPropertyBuilder HasIntervalColumns( return complexProperty; } - // Two-column storage carries no inclusivity; both bounds read back inclusive. private static void IgnoreInclusivityFlags(ComplexPropertyBuilder builder) { builder.Ignore("StartInclusive"); diff --git a/src/StrongTypes.EfCore/IntervalJsonValueConverter.cs b/src/StrongTypes.EfCore/IntervalJsonValueConverter.cs index b26ca9c6..c4d43658 100644 --- a/src/StrongTypes.EfCore/IntervalJsonValueConverter.cs +++ b/src/StrongTypes.EfCore/IntervalJsonValueConverter.cs @@ -5,7 +5,6 @@ namespace StrongTypes.EfCore; /// EF Core value converter that round-trips an interval through a single JSON-encoded column. Supports , , , and . -/// The interval type. public sealed class IntervalJsonValueConverter : ValueConverter where TInterval : struct { diff --git a/src/StrongTypes.EfCore/MailAddressValueConverter.cs b/src/StrongTypes.EfCore/MailAddressValueConverter.cs index a3a2933f..30454c71 100644 --- a/src/StrongTypes.EfCore/MailAddressValueConverter.cs +++ b/src/StrongTypes.EfCore/MailAddressValueConverter.cs @@ -3,7 +3,7 @@ namespace StrongTypes.EfCore; -/// EF Core value converter that round-trips through a plain column. Strong-type validation belongs at the wire boundary; once an address has been accepted by the converter on the way in, the column stores the BCL primitive directly. +/// EF Core value converter that round-trips through a plain column. public sealed class MailAddressValueConverter : ValueConverter { public MailAddressValueConverter() diff --git a/src/StrongTypes.EfCore/NumericStrongTypeValueConverter.cs b/src/StrongTypes.EfCore/NumericStrongTypeValueConverter.cs index 8532d9b8..0ecbad4d 100644 --- a/src/StrongTypes.EfCore/NumericStrongTypeValueConverter.cs +++ b/src/StrongTypes.EfCore/NumericStrongTypeValueConverter.cs @@ -11,11 +11,7 @@ public sealed class NumericStrongTypeValueConverter : ValueConverte where TWrapper : struct where T : struct { - // Expression trees are compiled once per (TWrapper, T) pair and reused by - // every ValueConverter instance. EF Core keeps converter instances around - // for the lifetime of the model, but the model is sometimes rebuilt - // (different DbContext types, design-time tooling), so caching here costs - // one JIT per (TWrapper, T) instead of one per ValueConverter ctor call. + // Built once per (TWrapper, T): model rebuilds construct new converter instances, which would otherwise rebuild these. private static readonly Expression> s_toProvider = BuildToProvider(); private static readonly Expression> s_toModel = BuildToModel(); diff --git a/src/StrongTypes.EfCore/StrongTypesConvention.cs b/src/StrongTypes.EfCore/StrongTypesConvention.cs index 40875868..4fffe7bf 100644 --- a/src/StrongTypes.EfCore/StrongTypesConvention.cs +++ b/src/StrongTypes.EfCore/StrongTypesConvention.cs @@ -20,9 +20,6 @@ public void ProcessEntityTypeAdded( { var entityType = entityTypeBuilder.Metadata; var clrType = entityType.ClrType; - // Guard: if EF ever tried to add a strong-type itself as an entity - // type (e.g. it leaked from somewhere), don't walk its members — - // NonEmptyString's private Value property shouldn't become a column. if (IsStrongType(clrType)) { return; @@ -53,8 +50,6 @@ public void ProcessEntityTypeAdded( } } - // ProcessEntityTypeAdded only scans public properties; a non-public mapped - // property (an internal/private DDD backing field) reaches the convention here. public void ProcessPropertyAdded( IConventionPropertyBuilder propertyBuilder, IConventionContext context) @@ -66,7 +61,6 @@ public void ProcessPropertyAdded( } } - // The complex-property counterpart of ProcessPropertyAdded, for a non-public interval backing property. public void ProcessComplexPropertyAdded( IConventionComplexPropertyBuilder propertyBuilder, IConventionContext context) @@ -104,9 +98,7 @@ private static void ConfigureIntervalComplexType(IConventionComplexTypeBuilder? private static bool IsStrongType(Type clrType) => ResolveConverter(clrType) is not null || IntervalTypes.IsInterval(clrType); - // EF Core applies ValueConverter only to non-null values, so the same - // converter instance works for both Wrapper and Nullable (and for - // NonEmptyString vs NonEmptyString?, which are the same CLR type anyway). + // EF Core applies a ValueConverter only to non-null values, so one instance serves both Wrapper and Nullable. private static ValueConverter? ResolveConverter(Type clrType) { var unwrapped = Nullable.GetUnderlyingType(clrType) ?? clrType; diff --git a/src/StrongTypes.EfCore/StrongTypesDbContextOptionsExtension.cs b/src/StrongTypes.EfCore/StrongTypesDbContextOptionsExtension.cs index bff3f80b..8f536240 100644 --- a/src/StrongTypes.EfCore/StrongTypesDbContextOptionsExtension.cs +++ b/src/StrongTypes.EfCore/StrongTypesDbContextOptionsExtension.cs @@ -38,7 +38,6 @@ public override void PopulateDebugInfo(IDictionary debugInfo) => public static class StrongTypesDbContextOptionsBuilderExtensions { /// Enables automatic value conversion of strong-type properties, two-endpoint-column mapping of interval properties, and server-side translation of Unwrap() and interval Start/End access in LINQ. Interval read validation (a stored row violating Start <= End throws when materialized) is intrinsic to the interval types and needs no registration. - /// The options builder to configure. public static DbContextOptionsBuilder UseStrongTypes(this DbContextOptionsBuilder optionsBuilder) { if (optionsBuilder.Options.FindExtension() is null) diff --git a/src/StrongTypes.EfCore/UnwrapMethodCallTranslator.cs b/src/StrongTypes.EfCore/UnwrapMethodCallTranslator.cs index 94480a03..6c69624e 100644 --- a/src/StrongTypes.EfCore/UnwrapMethodCallTranslator.cs +++ b/src/StrongTypes.EfCore/UnwrapMethodCallTranslator.cs @@ -12,9 +12,6 @@ public sealed class UnwrapMethodCallTranslator( ISqlExpressionFactory sqlExpressionFactory, IRelationalTypeMappingSource typeMappingSource) : IMethodCallTranslator { - // Every strong-type's extensions class exposes a static Unwrap(this Self) → - // underlying. Non-generic for NonEmptyString (hand-written), generic with a - // single type parameter for the numeric wrappers (source-generated). private static readonly HashSet UnwrapMethodDefinitions = [ UnwrapOn(typeof(NonEmptyStringExtensions)), diff --git a/src/StrongTypes.FsCheck/Generators.cs b/src/StrongTypes.FsCheck/Generators.cs index da4bd4b7..80fdafcb 100644 --- a/src/StrongTypes.FsCheck/Generators.cs +++ b/src/StrongTypes.FsCheck/Generators.cs @@ -9,7 +9,6 @@ public static class Generators { #region NonEmptyString - /// Arbitrary values. public static Arbitrary NonEmptyString { get; } = Arb.From(ArbMap.Default.ArbFor().Generator .Where(s => !string.IsNullOrWhiteSpace(s)) @@ -75,7 +74,6 @@ from tld in Gen.Elements("com", "net", "org", "io", "dev") #region Positive - /// Arbitrary of . public static Arbitrary> PositiveInt { get; } = Arb.From(ArbMap.Default.ArbFor().Generator .Where(i => i > 0) @@ -97,7 +95,6 @@ from tld in Gen.Elements("com", "net", "org", "io", "dev") #region Negative - /// Arbitrary of . public static Arbitrary> NegativeInt { get; } = Arb.From(ArbMap.Default.ArbFor().Generator .Where(i => i < 0) @@ -119,7 +116,6 @@ from tld in Gen.Elements("com", "net", "org", "io", "dev") #region NonNegative - /// Arbitrary of . public static Arbitrary> NonNegativeInt { get; } = Arb.From(ArbMap.Default.ArbFor().Generator .Where(i => i >= 0) @@ -141,7 +137,6 @@ from tld in Gen.Elements("com", "net", "org", "io", "dev") #region NonPositive - /// Arbitrary of . public static Arbitrary> NonPositiveInt { get; } = Arb.From(ArbMap.Default.ArbFor().Generator .Where(i => i <= 0) @@ -206,7 +201,6 @@ select IntervalUntil.Create(null, e, endInclusive: inclusive)), #region NonEmptyEnumerable - /// Arbitrary of . public static Arbitrary> NonEmptyEnumerableInt { get; } = Arb.From(Gen.NonEmptyListOf(ArbMap.Default.ArbFor().Generator) .Select(list => NonEmptyEnumerable.CreateRange(list))); diff --git a/src/StrongTypes.FsCheck/readme.md b/src/StrongTypes.FsCheck/readme.md index f58a7a8f..1b1fa6d1 100644 --- a/src/StrongTypes.FsCheck/readme.md +++ b/src/StrongTypes.FsCheck/readme.md @@ -4,9 +4,9 @@ FsCheck arbitraries for [Kalicz.StrongTypes](https://www.nuget.org/packages/Kalicz.StrongTypes). Lets you write property tests against code that takes or returns `NonEmptyString`, -`Digit`, `Positive`, `NonNegative`, `Negative`, `NonPositive`, `Maybe`, -and `NonEmptyEnumerable` without hand-rolling generators that re-derive each -type's invariants. +`Email`, `Digit`, `Positive`, `NonNegative`, `Negative`, `NonPositive`, +`Maybe`, `NonEmptyEnumerable`, the interval types, and `Result` +without hand-rolling generators that re-derive each type's invariants. ## Install @@ -47,6 +47,7 @@ Scalar strong types ship three shapes: the type itself, its nullable form | Type | `T` | `T?` | `Maybe` | | -------------------- | --------------- | ----------------------- | ----------------------- | | `NonEmptyString` | `NonEmptyString`| `NullableNonEmptyString`| `MaybeNonEmptyString` | +| `Email` | `Email` | `NullableEmail` | `MaybeEmail` | | `Digit` | `Digit` | `NullableDigit` | `MaybeDigit` | | `Positive` | `PositiveInt` | `NullablePositiveInt` | `MaybePositiveInt` | | `Negative` | `NegativeInt` | `NullableNegativeInt` | `MaybeNegativeInt` | @@ -56,6 +57,10 @@ Scalar strong types ship three shapes: the type itself, its nullable form Apart from the above, you also get: - `NonEmptyEnumerableInt` — `NonEmptyEnumerable` +- `FiniteIntervalInt`, `IntervalInt`, `IntervalFromInt`, `IntervalUntilInt` — + the four interval shapes over `int`, honoring each type's invariant and + mixing bound inclusivity +- `ResultIntString` (`Result`) and `ResultInt` (`Result`) - `Maybe` for common primitives: `MaybeBool`, `MaybeInt`, `MaybeLong`, `MaybeDouble`, `MaybeChar`, `MaybeString`, `MaybeGuid` — all with ~5% `None`. diff --git a/src/StrongTypes.OpenApi.Core.Tests/StrongTypeInlinerScopingTests.cs b/src/StrongTypes.OpenApi.Core.Tests/StrongTypeInlinerScopingTests.cs index 69ff9a4f..2c8cd390 100644 --- a/src/StrongTypes.OpenApi.Core.Tests/StrongTypeInlinerScopingTests.cs +++ b/src/StrongTypes.OpenApi.Core.Tests/StrongTypeInlinerScopingTests.cs @@ -5,11 +5,8 @@ namespace StrongTypes.OpenApi.Core.Tests; /// -/// The storage-component cleanup that follows inlining only touches the -/// known generated storage types (e.g. MailAddress behind Email) and only -/// when nothing references them — it must never reach for a consumer's own -/// schema, and must keep a storage type a consumer still uses. Pinned -/// directly on the inliner, independent of either HTTP pipeline. +/// The storage-component cleanup after inlining touches only the known generated storage types (e.g. MailAddress +/// behind Email) — never a consumer's own schema. /// public sealed class StrongTypeInlinerScopingTests { @@ -40,7 +37,6 @@ public void Keeps_Storage_Component_A_Consumer_Still_References() var document = DocumentWith( ("Email", InlineableEmail()), ("MailAddress", ObjectWith(("address", String()))), - // A consumer DTO that uses MailAddress directly, unrelated to Email. ("ContactDto", ObjectWith(("addr", new OpenApiSchemaReference("MailAddress"))))); StrongTypeInliner.Inline(document); diff --git a/src/StrongTypes.OpenApi.Core/Inlining/StrongTypeInliner.cs b/src/StrongTypes.OpenApi.Core/Inlining/StrongTypeInliner.cs index e3daf48a..a2a2bc8b 100644 --- a/src/StrongTypes.OpenApi.Core/Inlining/StrongTypeInliner.cs +++ b/src/StrongTypes.OpenApi.Core/Inlining/StrongTypeInliner.cs @@ -4,15 +4,10 @@ namespace StrongTypes.OpenApi.Core; /// -/// Walks an and replaces every $ref to -/// a strong-type wrapper component with the wrapper's wire body, merging any -/// caller-supplied annotations attached at the use site (the allOf:[ref] -/// + maxLength/maxItems/etc. shape that the property-annotation -/// transformers emit). Both wrapper components and use-site allOf wrappers -/// are identified by the vendor extension -/// — the inliner does not match on schema names. After all references are -/// inlined, the wrapper components themselves are dropped from -/// components.schemas. +/// Replaces every $ref to a strong-type wrapper component (identified by the +/// vendor extension) with the wrapper's wire body, +/// merging any annotations attached at the use site, then drops the wrapper components +/// from components.schemas. /// public static class StrongTypeInliner { @@ -28,15 +23,10 @@ public static void Inline(OpenApiDocument document, ILogger? logger = null) } if (inlineable.Count == 0) return; - // `Referenced` accumulates every $ref the walk leaves in place (i.e. - // not inlined) so we can tell afterwards which components are still in - // use. The HashSet is shared across the by-value context copies. var ctx = new RewriteContext(inlineable, new HashSet(StringComparer.Ordinal), logger); - // Walk every component (including the inlineable bodies themselves) - // so refs to other inlineable wrappers nested inside an array - // wrapper's `items` get resolved before we use the wrapper as a - // template at use sites. + // Rewrite the inlineable bodies themselves too, so refs nested inside a wrapper's + // items are already resolved when the wrapper is used as a template at use sites. foreach (var (_, schema) in schemas) { if (schema is OpenApiSchema concrete) RewriteSchema(concrete, ctx); @@ -54,17 +44,10 @@ public static void Inline(OpenApiDocument document, ILogger? logger = null) RemoveUnreferencedStorageComponents(schemas, ctx.Referenced); } - // Storage types that a strong-type wrapper exposes as a public property - // and that the generator therefore registers as their own component - // (e.g. Email.Value is a System.Net.Mail.MailAddress → a "MailAddress" - // component). Once the wrapper is inlined and dropped, nothing references - // these any more and downstream tools (openapi-typescript) emit them as - // dead noise. Extend this list when a new wrapper drags in another type. + // Components the generator registers for storage types a wrapper exposes as a property + // (e.g. Email.Value → MailAddress); orphaned once the wrapper is inlined. private static readonly string[] GeneratedStorageComponentNames = ["MailAddress"]; - // Drop a known storage component only when no surviving $ref points at it, - // so a consumer that genuinely uses the type (e.g. a DTO with a - // MailAddress property of its own) keeps its component. private static void RemoveUnreferencedStorageComponents(IDictionary schemas, HashSet referenced) { foreach (var name in GeneratedStorageComponentNames) @@ -251,10 +234,7 @@ private static OpenApiSchema MergeUseSiteWithWrapper(OpenApiSchema useSite, Open { var result = CloneWireShape(wrapper); - // A nullable member (`NonEmptyString?`, `Positive?`, …) carries the - // null bit on the use-site wrapper; the wrapper component is non-nullable. - // Carry it onto the inlined wire shape so the published contract keeps the - // member's nullability instead of silently dropping it. + // The null bit lives on the use-site wrapper (the component is non-nullable), so carry it onto the inlined shape. if (SchemaPaint.IsNullable(useSite)) SchemaPaint.MarkNullable(result); if (useSite.MinLength is { } minL) SchemaPaint.TightenMinLength(result, minL); diff --git a/src/StrongTypes.OpenApi.Core/NumericWrapperKinds.cs b/src/StrongTypes.OpenApi.Core/NumericWrapperKinds.cs index aaa97d4f..bbcff78e 100644 --- a/src/StrongTypes.OpenApi.Core/NumericWrapperKinds.cs +++ b/src/StrongTypes.OpenApi.Core/NumericWrapperKinds.cs @@ -1,24 +1,10 @@ namespace StrongTypes.OpenApi.Core; -/// -/// A single numeric bound: an inclusive or exclusive endpoint plus a flag -/// for which side it sits on. -/// public readonly record struct NumericBound(decimal Value, bool Exclusive, bool IsLower); -/// -/// One row per numeric strong-type wrapper, pairing its CLR generic -/// definition with the numeric bound it imposes. -/// public sealed record NumericWrapperKind(Type GenericDefinition, NumericBound Bound); -/// -/// Single source of truth for the four numeric wrappers -/// (, , -/// , ) — used by the -/// schema-time painters in both pipelines to dispatch on the CLR generic -/// definition. -/// +/// Single source of truth for the numeric strong-type wrappers and the bound each imposes. public static class NumericWrapperKinds { public static readonly IReadOnlyList All = diff --git a/src/StrongTypes.OpenApi.Core/NumericWrapperPainter.cs b/src/StrongTypes.OpenApi.Core/NumericWrapperPainter.cs index 36c8fc88..931a77c8 100644 --- a/src/StrongTypes.OpenApi.Core/NumericWrapperPainter.cs +++ b/src/StrongTypes.OpenApi.Core/NumericWrapperPainter.cs @@ -2,12 +2,7 @@ namespace StrongTypes.OpenApi.Core; -/// -/// Paints a schema as the underlying primitive of a numeric strong-type -/// wrapper, plus its single bound. Shared by the Microsoft and Swashbuckle -/// schema-time painters and by the document-time component filler so all -/// three pipelines emit the same wire shape. -/// +/// Paints a schema as the underlying primitive of a numeric strong-type wrapper, plus its single bound. public static class NumericWrapperPainter { public static void Paint(OpenApiSchema schema, Type underlying, NumericBound bound) diff --git a/src/StrongTypes.OpenApi.Core/PrimitiveSchemaMap.cs b/src/StrongTypes.OpenApi.Core/PrimitiveSchemaMap.cs index 5a40cb66..533c345b 100644 --- a/src/StrongTypes.OpenApi.Core/PrimitiveSchemaMap.cs +++ b/src/StrongTypes.OpenApi.Core/PrimitiveSchemaMap.cs @@ -2,10 +2,6 @@ namespace StrongTypes.OpenApi.Core; -/// -/// Shared lookup from a CLR primitive type to the -/// + format pair its wire schema uses. -/// public static class PrimitiveSchemaMap { public readonly record struct Info(JsonSchemaType Type, string? Format); diff --git a/src/StrongTypes.OpenApi.Core/SchemaPaint.cs b/src/StrongTypes.OpenApi.Core/SchemaPaint.cs index 4402c6cb..6f8771b1 100644 --- a/src/StrongTypes.OpenApi.Core/SchemaPaint.cs +++ b/src/StrongTypes.OpenApi.Core/SchemaPaint.cs @@ -4,26 +4,10 @@ namespace StrongTypes.OpenApi.Core; -/// -/// Painter primitives shared by the Microsoft and Swashbuckle adapters. -/// Reflecting a strong-type produces a CLR-shaped object schema (e.g. -/// { "type": "object", "properties": { "Value": ... } }) which -/// doesn't match the wire form. The painters call -/// to drop that shape, then use the Tighten* / Set*IfAbsent -/// helpers to write the wire primitive's keywords without overwriting -/// stricter caller-supplied values (e.g. [StringLength(3)] on a -/// wins over the wrapper's minLength: 1). -/// +/// Schema-keyword helpers that write a wrapper's wire form without overwriting stricter caller-supplied values. public static class SchemaPaint { - /// - /// Strips the CLR-wrapper-derived shape (sub-properties, - /// allOf/oneOf/anyOf, additionalProperties, - /// items) so the painter can write the wire primitive over the top. - /// Caller-set bounds and annotations (minLength, pattern, - /// minimum, description, example, default, …) - /// are left alone. - /// + /// Strips the CLR-wrapper-derived structural shape, leaving caller-set bounds and annotations alone. public static void ClearWrapperShape(OpenApiSchema schema) { schema.Properties?.Clear(); @@ -36,21 +20,9 @@ public static void ClearWrapperShape(OpenApiSchema schema) schema.Items = null; } - /// - /// True when the schema's type carries the null bit — the - /// wire encoding of a nullable member. Serialized as nullable: true - /// in OpenAPI 3.0 and as a "null" member of the type array - /// in 3.1. - /// public static bool IsNullable(OpenApiSchema schema) => schema.Type is { } type && type.HasFlag(JsonSchemaType.Null); - /// - /// Adds the null bit to the schema's type while preserving - /// the existing primitive bits. Idempotent; the OpenAPI serializer maps - /// the bit to nullable: true (3.0) or a "null"-typed union - /// member (3.1) for the document version in force. - /// public static void MarkNullable(OpenApiSchema schema) => schema.Type = (schema.Type ?? JsonSchemaType.Null) | JsonSchemaType.Null; @@ -82,28 +54,24 @@ public static void TightenMaxItems(OpenApiSchema schema, int ceiling) schema.MaxItems = ceiling; } - /// Sets pattern only when the schema doesn't already carry one. public static void SetPatternIfAbsent(OpenApiSchema schema, string pattern) { if (!string.IsNullOrEmpty(schema.Pattern)) return; schema.Pattern = pattern; } - /// Sets format only when the schema doesn't already carry one. public static void SetFormatIfAbsent(OpenApiSchema schema, string format) { if (!string.IsNullOrEmpty(schema.Format)) return; schema.Format = format; } - /// Sets description only when the schema doesn't already carry one. public static void SetDescriptionIfAbsent(OpenApiSchema schema, string description) { if (!string.IsNullOrEmpty(schema.Description)) return; schema.Description = description; } - /// Sets default only when the schema doesn't already carry one. public static void SetDefaultIfAbsent(OpenApiSchema schema, JsonNode @default) { if (schema.Default is not null) return; diff --git a/src/StrongTypes.OpenApi.Core/StrongTypeInlineMarker.cs b/src/StrongTypes.OpenApi.Core/StrongTypeInlineMarker.cs index ed7019bc..d7baf6e7 100644 --- a/src/StrongTypes.OpenApi.Core/StrongTypeInlineMarker.cs +++ b/src/StrongTypes.OpenApi.Core/StrongTypeInlineMarker.cs @@ -4,11 +4,8 @@ namespace StrongTypes.OpenApi.Core; /// -/// Vendor extension that flags a schema as having been produced by the -/// StrongTypes OpenAPI adapters. The inliner uses this marker — not the -/// schema's component name or shape — to decide which schemas it owns and -/// is therefore allowed to inline. The marker is stripped by the inliner, -/// so it never reaches the published document. +/// Vendor extension flagging a schema as produced by the StrongTypes OpenAPI adapters; +/// the inliner keys on it and strips it, so it never reaches the published document. /// public static class StrongTypeInlineMarker { diff --git a/src/StrongTypes.OpenApi.Core/StrongTypeSchemaTypes.cs b/src/StrongTypes.OpenApi.Core/StrongTypeSchemaTypes.cs index e0da8ded..9957cad7 100644 --- a/src/StrongTypes.OpenApi.Core/StrongTypeSchemaTypes.cs +++ b/src/StrongTypes.OpenApi.Core/StrongTypeSchemaTypes.cs @@ -61,10 +61,9 @@ public static bool TryGetMaybeValue(Type? clrType, out Type valueType) } /// - /// Resolves the CLR types of an interval's Start and End - /// endpoints, reflecting each variant's nullability: a required endpoint is - /// the bare endpoint type, an optional one its . - /// Both keys are always present on the wire regardless of nullability. + /// Resolves the CLR types of an interval's Start and End endpoints, + /// reflecting each variant's nullability: a required endpoint is the bare endpoint + /// type, an optional one its . /// public static bool TryGetIntervalEndpoints(Type? clrType, out Type startType, out Type endType) { diff --git a/src/StrongTypes.OpenApi.Core/WrapperAnnotationApplier.cs b/src/StrongTypes.OpenApi.Core/WrapperAnnotationApplier.cs index 43edfe71..54c577b0 100644 --- a/src/StrongTypes.OpenApi.Core/WrapperAnnotationApplier.cs +++ b/src/StrongTypes.OpenApi.Core/WrapperAnnotationApplier.cs @@ -6,15 +6,10 @@ namespace StrongTypes.OpenApi.Core; /// -/// Layers caller-supplied data-annotations -/// (, , -/// , , …) -/// onto a schema painted for a strong-type wrapper. Bounds are tightened -/// via the helpers so caller-stricter values -/// stack on top of the wrapper's own floor/ceiling without weakening -/// either side. Returns true when the wrapper type was recognised -/// and the attribute pass ran (regardless of whether any individual -/// attribute matched). +/// Layers caller-supplied data-annotations onto a schema painted for a strong-type wrapper, +/// tightening bounds so stricter caller values win without weakening the wrapper's own. +/// returns true when the wrapper type was recognised, +/// regardless of whether any individual attribute matched. /// public static class WrapperAnnotationApplier { diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/BindingSchemaAsserts.cs b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/BindingSchemaAsserts.cs index 29119652..719cb628 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/BindingSchemaAsserts.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/BindingSchemaAsserts.cs @@ -5,16 +5,8 @@ namespace StrongTypes.OpenApi.IntegrationTests.Helpers; /// -/// Centralised wire-shape assertions for the strong-type wrappers, used -/// by body, parameter, and form-property tests alike — the wrapper's -/// wire shape doesn't depend on where it's bound. Every helper -/// deep-compares against a literal JSON snapshot via -/// so an unexpected keyword fails the test -/// instead of silently passing. -/// -/// Both Microsoft.AspNetCore.OpenApi and Swashbuckle emit form-body -/// property keys in PascalCase (matching the C# property name), so -/// callers pass the name as declared in the source. +/// Both Microsoft.AspNetCore.OpenApi and Swashbuckle emit form-body property keys in PascalCase (matching the C# +/// property name), so form-property helpers take the name as declared in the source. /// internal static class BindingSchemaAsserts { @@ -27,9 +19,6 @@ internal static void AssertFormPropertyNonEmptyStringSchema(JsonElement formSche => AssertNonEmptyStringSchema(GetFormProperty(formSchema, propertyName)); // ── Positive ──────────────────────────────────────────────────── - // Splits by OpenAPI version: 3.0 encodes the exclusive bound as - // {minimum:0, exclusiveMinimum:true} (boolean pair); 3.1 as - // {exclusiveMinimum:0} (numeric). internal static void AssertPositiveIntSchema(JsonElement schema, OpenApiVersion version) => AssertJsonEquals(schema, version switch @@ -51,9 +40,7 @@ internal static void AssertFormPropertyDigitSchema(JsonElement formSchema, strin => AssertDigitSchema(GetFormProperty(formSchema, propertyName)); // ── Other numeric wrappers ────────────────────────────────────────── - // Inclusive-bound shapes (NonNegative, NonPositive) don't depend on - // OpenAPI version — there's no exclusive boundary to encode. Exclusive - // shapes (Negative) split the same way Positive does. + // Inclusive bounds encode identically in 3.0 and 3.1; only exclusive bounds need a version. internal static void AssertNonNegativeLongSchema(JsonElement schema) => AssertJsonEquals(schema, """{"type":"integer","format":"int64","minimum":0}"""); @@ -66,11 +53,7 @@ internal static void AssertNegativeDoubleSchema(JsonElement schema, OpenApiVersi _ => throw new ArgumentOutOfRangeException(nameof(version), version, null), }); - // Both pipelines emit `format: double` for `decimal`. There's no - // standard OpenAPI format for the BCL decimal type, so they fall - // through to the closest numeric format. Pinned here to surface a - // future change in pipeline behaviour rather than to bless the - // mapping as ideal. + // There is no standard OpenAPI format for decimal, so both pipelines fall back to format: double. internal static void AssertNonPositiveDecimalSchema(JsonElement schema) => AssertJsonEquals(schema, """{"type":"number","format":"double","maximum":0}"""); @@ -110,23 +93,10 @@ internal static void AssertFormPropertyEmailSchema(JsonElement formSchema, strin private static JsonElement GetFormProperty(JsonElement formSchema, string propertyName) => formSchema.GetProperty("properties").GetProperty(propertyName); - /// - /// Asserts that the named property in a form-body / object schema's - /// properties map deep-equals the literal JSON snapshot. Thin - /// composition of + - /// — exists because tests that pin every - /// property of a form body had repeated this exact two-step inline. - /// + /// Asserts the named property in the schema's properties map deep-equals the literal JSON snapshot. internal static void AssertSchema(JsonElement formSchema, string propertyName, string expectedJson) => AssertJsonEquals(GetFormProperty(formSchema, propertyName), expectedJson); - /// - /// Asserts that a [FromForm] request-body schema is a clean - /// { type: object, properties: { … } } shape — no top-level - /// allOf / anyOf / oneOf / $ref, a - /// properties map present, and exactly the expected set of - /// property names (order is irrelevant). - /// internal static void AssertFormBodyHasObjectShape(JsonElement formSchema, params string[] expectedPropertyNames) { Assert.False(formSchema.TryGetProperty("allOf", out _), "form body should not be wrapped in a top-level allOf"); diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/ComponentSchemas.cs b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/ComponentSchemas.cs index a4d0d755..f043f490 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/ComponentSchemas.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/ComponentSchemas.cs @@ -2,17 +2,9 @@ namespace StrongTypes.OpenApi.IntegrationTests.Helpers; -/// -/// Operations on the components.schemas section of an OpenAPI -/// document — used to pin which wrapper components survive the -/// inliner pass and which are expected to be removed. -/// internal static class ComponentSchemas { - /// - /// Returns the names of every schema in components.schemas, - /// or an empty array when the section is absent. - /// + /// Returns the names of every schema in components.schemas, or an empty array when the section is absent. internal static string[] ReadComponentSchemaNames(JsonElement doc) { if (!doc.TryGetProperty("components", out var components)) return []; @@ -20,11 +12,7 @@ internal static string[] ReadComponentSchemaNames(JsonElement doc) return schemas.EnumerateObject().Select(p => p.Name).ToArray(); } - /// - /// Returns the set of components.schemas names that some - /// $ref in the document points at. A component name absent - /// from this set is an orphan — defined but never referenced. - /// + /// Returns the set of components.schemas names that some $ref in the document points at. internal static HashSet ReadReferencedSchemaNames(JsonElement doc) { var referenced = new HashSet(StringComparer.Ordinal); @@ -55,10 +43,8 @@ private static void CollectRefs(JsonElement element, HashSet referenced) } /// - /// Identifies a component schema name as one of the wrapper types - /// the inliner is expected to remove. Recognises both the Microsoft - /// prefix style (PositiveOf…, MaybeOf…) and the - /// Swashbuckle suffix style (…Positive, …Maybe). + /// Wrapper generics are named per pipeline: Microsoft's prefix style (PositiveOf…), Swashbuckle's suffix + /// style (…Positive). /// internal static bool IsInlineableWrapperName(string name) { diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/ExclusiveBounds.cs b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/ExclusiveBounds.cs index 1ccbd3b1..29f6c65d 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/ExclusiveBounds.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/ExclusiveBounds.cs @@ -4,21 +4,11 @@ namespace StrongTypes.OpenApi.IntegrationTests.Helpers; /// -/// Version-strict assertions for exclusive numeric bounds. The two -/// OpenAPI versions encode an exclusive bound differently: -/// 3.0 → minimum: <n> paired with exclusiveMinimum: true -/// (boolean), 3.1 → exclusiveMinimum: <n> (numeric, no -/// companion minimum). These helpers pin the encoding for the -/// version under test; an emission in the other version's form fails -/// the assertion. +/// The two OpenAPI versions encode an exclusive bound differently: 3.0 as minimum: n paired with a boolean +/// exclusiveMinimum: true, 3.1 as a numeric exclusiveMinimum: n with no companion minimum. /// internal static class ExclusiveBounds { - /// - /// Asserts the schema carries an exclusive lower bound equal to - /// , encoded in the form required by - /// . - /// internal static void AssertExclusiveLowerBound(JsonElement schema, decimal expected, OpenApiVersion version) { Assert.True( @@ -40,11 +30,6 @@ internal static void AssertExclusiveLowerBound(JsonElement schema, decimal expec } } - /// - /// Asserts the schema carries an exclusive upper bound equal to - /// , encoded in the form required by - /// . - /// internal static void AssertExclusiveUpperBound(JsonElement schema, decimal expected, OpenApiVersion version) { Assert.True( @@ -66,12 +51,6 @@ internal static void AssertExclusiveUpperBound(JsonElement schema, decimal expec } } - /// - /// Asserts an exclusive lower bound equal to - /// is reachable from any layer of via - /// $ref/allOf/oneOf/anyOf, encoded in the form required by - /// . Fails if no layer carries the bound. - /// internal static void AssertExclusiveLowerBoundReachable(JsonElement doc, JsonElement schema, decimal expected, OpenApiVersion version) { foreach (var layer in SchemaWalk.WalkSchemaLayers(doc, schema, version)) diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/NullableUnwrap.cs b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/NullableUnwrap.cs index 3cf6d6e2..2d7fe819 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/NullableUnwrap.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/NullableUnwrap.cs @@ -5,35 +5,22 @@ namespace StrongTypes.OpenApi.IntegrationTests.Helpers; /// -/// Version-aware nullable-wrapper unwrapping. ASP.NET Core OpenAPI -/// pipelines emit a property's CLR-level nullability in several -/// shapes (oneOf+nullable:true, anyOf+{type:"null"}, -/// single-element allOf, or no wrapper at all when the -/// pipeline relies solely on required). This helper walks -/// past whichever wrapper is present and returns the inner schema -/// for downstream navigation, asserting strict version markers -/// along the way. +/// The pipelines emit a property's nullability in several shapes — a oneOf/anyOf union with a null +/// branch, a flat nullable: true (3.0) or "null" type-array member (3.1), a single-element allOf, +/// or no wrapper at all when nullability rides solely on required. /// internal static class NullableUnwrap { /// - /// True iff is the OpenAPI 3.0 null marker: - /// a singleton object { "nullable": true } with no other - /// keywords. Pins the branch as encoding "or null" inside a - /// oneOf/anyOf union; the singleton check excludes - /// nullable schemas that also carry their own constraints. + /// True iff is exactly the singleton { "nullable": true } — a nullable schema + /// carrying its own constraints is not a null marker. /// internal static bool IsNullBranch3_0(JsonElement branch) => branch.TryGetProperty("nullable", out var n) && n.ValueKind == JsonValueKind.True && branch.EnumerateObject().Count() == 1; - /// - /// True iff is the OpenAPI 3.1 null marker: - /// a singleton object { "type": "null" } with no other - /// keywords. The 3.1 spec dropped the nullable keyword and - /// uses a null-typed branch instead. - /// + /// True iff is exactly the singleton { "type": "null" }. internal static bool IsNullBranch3_1(JsonElement branch) => branch.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String @@ -41,25 +28,13 @@ internal static bool IsNullBranch3_1(JsonElement branch) => && branch.EnumerateObject().Count() == 1; /// - /// True iff is the null marker for the - /// document's declared OpenAPI version. Dispatches strictly: a 3.0 - /// document accepts only the 3.0 marker, a 3.1 document accepts - /// only the 3.1 marker, so cross-version contamination surfaces as - /// an unhandled branch rather than silently passing through. + /// Dispatches strictly — a document accepts only its own version's null marker — so cross-version contamination + /// surfaces instead of silently passing. /// internal static bool IsNullBranch(JsonElement branch, OpenApiVersion version) => version == OpenApiVersion.V3_1 ? IsNullBranch3_1(branch) : IsNullBranch3_0(branch); - /// - /// Walks past the nullable wrapper layer (whichever form the - /// pipeline used) and returns the inner schema. Asserts the - /// version-marker partition first: a 3.0 schema must not carry the - /// nullable keyword nowhere; a 3.1 schema must not carry a - /// {"type":"null"} branch in any union. Returns the schema - /// unchanged when the property has no wrapper layer (Swashbuckle's - /// typical form for value-typed and same-shape-as-non-nullable - /// cases, where nullability is encoded solely via required). - /// + /// Returns the schema behind its nullable wrapper, unchanged when no wrapper layer is present. internal static JsonElement UnwrapNullableProperty(JsonElement schema, OpenApiVersion version) { AssertVersionMarkers(schema, version); @@ -76,21 +51,8 @@ internal static JsonElement UnwrapNullableProperty(JsonElement schema, OpenApiVe } /// - /// Asserts the property actually encodes T? nullability — in - /// whichever shape the pipeline uses for — and - /// returns the inner wire schema with the null marker stripped, ready for - /// a strict deep-compare against the wrapper's non-null wire form. - /// - /// Unlike , which tolerates a missing - /// marker (returning the schema unchanged), this fails when no marker is - /// found — so it pins the bug fix: a nullable wrapper must keep its - /// nullability instead of silently dropping it. Accepted shapes: - /// - /// 3.0 flat (Swashbuckle): { …wire, "nullable": true }. - /// 3.0 union (Microsoft): { "oneOf": [ …wire ], "nullable": true }. - /// 3.1 union (Microsoft): { "oneOf": [ {"type":"null"}, …wire ] }. - /// 3.1 flat: { "type": ["X","null"], … }. - /// + /// Asserts the property encodes T? nullability and returns the wire schema with the null marker stripped — + /// unlike , a property carrying no null marker fails. /// internal static JsonElement AssertNullableAndUnwrap(JsonElement schema, OpenApiVersion version) { diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/OpenApiVersion.cs b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/OpenApiVersion.cs index ecd7861c..6ee84a4b 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/OpenApiVersion.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/OpenApiVersion.cs @@ -1,9 +1,3 @@ namespace StrongTypes.OpenApi.IntegrationTests.Helpers; -/// -/// The OpenAPI specification version a pipeline emits. Controls -/// version-strict assertions across the helper layer — exclusive -/// numeric bounds, null-branch markers, and the version-marker -/// contamination check on nullable union wrappers. -/// public enum OpenApiVersion { V3_0, V3_1 } diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaNavigation.cs b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaNavigation.cs index 77126a85..2c60adca 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaNavigation.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaNavigation.cs @@ -4,19 +4,11 @@ namespace StrongTypes.OpenApi.IntegrationTests.Helpers; /// -/// Navigation primitives over an OpenAPI document. Each method asserts a -/// specific shape and returns the inner layer; the choice of method at -/// the call site is itself an assertion. Composing them (e.g. -/// FollowRef(doc, UnwrapNullableProperty(x))) expresses the full -/// expected wire structure of a property's schema. +/// Each navigator asserts a specific shape and returns the inner layer, so the choice of method at a call site is +/// itself an assertion. /// internal static class SchemaNavigation { - /// - /// Resolves a strict $ref to the referenced component schema. - /// Fails the test if is not an object with - /// a $ref targeting #/components/schemas/<name>. - /// internal static JsonElement FollowRef(JsonElement doc, JsonElement schema) { Assert.Equal(JsonValueKind.Object, schema.ValueKind); @@ -29,10 +21,8 @@ internal static JsonElement FollowRef(JsonElement doc, JsonElement schema) } /// - /// Walks past shape-agnostic indirection layers and returns the - /// underlying schema. Follows $ref and single-element - /// allOf; never walks a nullable union (those are - /// version-specific and should be unwrapped explicitly). + /// Follows $ref and single-element allOf; never a nullable union — those are version-specific and + /// must be unwrapped explicitly. /// internal static JsonElement Resolve(JsonElement doc, JsonElement schema) { @@ -58,17 +48,11 @@ internal static JsonElement Resolve(JsonElement doc, JsonElement schema) } } - /// Returns the request-body schema for the given path/method. internal static JsonElement RequestSchema(JsonElement doc, string path, string method = "post") => doc.GetProperty("paths").GetProperty(path).GetProperty(method) .GetProperty("requestBody").GetProperty("content") .GetProperty("application/json").GetProperty("schema"); - /// - /// Returns the schema attached to the named parameter on the given - /// path/method. Fails the test if the parameter (or its schema) is - /// missing. - /// internal static JsonElement ParameterSchema(JsonElement doc, string path, string parameterName, string method = "get") { var operation = doc.GetProperty("paths").GetProperty(path).GetProperty(method); @@ -85,12 +69,7 @@ internal static JsonElement ParameterSchema(JsonElement doc, string path, string return default; } - /// - /// Returns the form request-body schema for the given path/method. Form - /// bodies are encoded as application/x-www-form-urlencoded or - /// multipart/form-data — both are accepted; the test uses - /// whichever the pipeline emitted. - /// + /// Accepts either form content type — the pipelines differ in which they emit. internal static JsonElement FormRequestSchema(JsonElement doc, string path, string method = "post") { var content = doc.GetProperty("paths").GetProperty(path).GetProperty(method) @@ -106,14 +85,9 @@ internal static JsonElement FormRequestSchema(JsonElement doc, string path, stri return default; } - /// Returns the schema for a named property of an object schema. internal static JsonElement Property(JsonElement schema, string propertyName) => schema.GetProperty("properties").GetProperty(propertyName); - /// - /// Asserts the schema is fully inlined: keywords sit directly on the - /// schema object, not behind a $ref or an allOf. - /// internal static void AssertInlineSchema(JsonElement schema) { Assert.Equal(JsonValueKind.Object, schema.ValueKind); @@ -122,14 +96,8 @@ internal static void AssertInlineSchema(JsonElement schema) } /// - /// Asserts that deep-equals - /// — same property-name set on every - /// object, same array length and order, same primitive values - /// (numbers compared as so 1 matches - /// 1.0). Property order on objects is ignored. This pins the - /// full emitted shape so that an unexpected keyword (a wrong - /// format, a stray nullable, some new x-… - /// annotation, …) fails the test instead of silently passing. + /// Strict deep-equality: the same property-name set on every object, same array length and order, numbers + /// compared as so 1 matches 1.0. /// internal static void AssertJsonEquals(JsonElement actual, string expectedJson) { @@ -137,13 +105,7 @@ internal static void AssertJsonEquals(JsonElement actual, string expectedJson) AssertJsonEqualsCore(doc.RootElement, actual, path: "$"); } - // OpenAPI 3.0 keywords whose default value is `false`. Pipelines vary - // on whether they emit them when set to the default — Swashbuckle's - // serializer writes `exclusiveMinimum: false` alongside an inclusive - // bound, Microsoft's omits it. Both are wire-equivalent to absent; - // the comparator treats them as such so the strict deep-compare still - // catches stray *meaningful* keywords without complaining about - // serialization noise. + // Swashbuckle emits these 3.0 keywords at their false default where Microsoft omits them; both are wire-equivalent to absent. private static readonly HashSet s_falseDefaultKeywords = new(StringComparer.Ordinal) { "exclusiveMinimum", @@ -186,8 +148,7 @@ private static void AssertJsonEqualsCore(JsonElement expected, JsonElement actua case JsonValueKind.True: case JsonValueKind.False: case JsonValueKind.Null: - // Kinds already verified equal above and these have no - // payload — the kind IS the value. + // the kind is the whole value, and kinds were already compared break; } } diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaValueReader.cs b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaValueReader.cs index 42fd017b..9a05bfe6 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaValueReader.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaValueReader.cs @@ -3,10 +3,7 @@ namespace StrongTypes.OpenApi.IntegrationTests.Helpers; /// -/// Typed value extraction from JSON-schema keywords. Each reader returns -/// null (or false for booleans) when the keyword is absent -/// or has an unexpected JSON kind, so call sites can use the result -/// directly in assertions without a precondition check. +/// Each reader returns null (false for booleans) when the keyword is absent or has an unexpected JSON kind. /// internal static class SchemaValueReader { @@ -28,10 +25,7 @@ internal static class SchemaValueReader internal static bool BoolOrFalse(JsonElement schema, string propertyName) => schema.TryGetProperty(propertyName, out var v) && v.ValueKind == JsonValueKind.True; - /// - /// Returns the names listed in the schema's required array, - /// or an empty array when the keyword is absent or not an array. - /// + /// Returns the names in the schema's required array, or an empty array when the keyword is absent. internal static string[] ReadRequiredArray(JsonElement schema) { if (!schema.TryGetProperty("required", out var req) || req.ValueKind != JsonValueKind.Array) diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaWalk.cs b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaWalk.cs index 48d002b7..f2cda8a1 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaWalk.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Helpers/SchemaWalk.cs @@ -3,21 +3,14 @@ namespace StrongTypes.OpenApi.IntegrationTests.Helpers; /// -/// Walks a property schema across $ref, allOf, -/// oneOf, and anyOf layers (skipping the version-strict -/// null branches that encode T?) and pulls keyword values -/// reachable anywhere in the chain. Annotation-propagation -/// assertions don't care whether the pipeline inlined the merged -/// schema or layered the caller's bounds via $ref + allOf; -/// they care that the bounds are reachable. +/// Annotation-propagation assertions pin that bounds are reachable, not how the pipeline encoded them (inline merged +/// schema vs. layered $ref + allOf): the collectors walk every reachable layer and return the tightest +/// applicable bound, or null when no layer carries the keyword. /// internal static class SchemaWalk { /// - /// Yields every schema layer reachable from - /// by following $ref and union/composition keywords. - /// Skips the null marker for ; a - /// cross-version null marker is not skipped, so contamination + /// Skips only the null marker for — a cross-version marker is walked, so contamination /// surfaces downstream rather than silently passing through. /// internal static IEnumerable WalkSchemaLayers(JsonElement doc, JsonElement schema, OpenApiVersion version) @@ -56,12 +49,6 @@ internal static IEnumerable WalkSchemaLayers(JsonElement doc, JsonE } } - /// - /// Returns the largest integer value of - /// reachable from , or null if no - /// layer carries the keyword. Useful for floors that can be - /// tightened by an outer layer (e.g. minLength). - /// internal static int? CollectMaxInt(JsonElement doc, JsonElement schema, string keyword, OpenApiVersion version) { int? best = null; @@ -74,12 +61,6 @@ internal static IEnumerable WalkSchemaLayers(JsonElement doc, JsonE return best; } - /// - /// Returns the smallest integer value of - /// reachable from , or null if no - /// layer carries the keyword. Useful for ceilings that can be - /// tightened by an outer layer (e.g. maxLength). - /// internal static int? CollectMinInt(JsonElement doc, JsonElement schema, string keyword, OpenApiVersion version) { int? best = null; @@ -92,13 +73,6 @@ internal static IEnumerable WalkSchemaLayers(JsonElement doc, JsonE return best; } - /// - /// Returns the first string value of - /// reachable from , or null if no - /// layer carries the keyword. For string keywords like - /// format, pattern, and description where - /// any reachable value is the value the caller observes. - /// internal static string? CollectFirstString(JsonElement doc, JsonElement schema, string keyword, OpenApiVersion version) { foreach (var layer in WalkSchemaLayers(doc, schema, version)) @@ -109,12 +83,6 @@ internal static IEnumerable WalkSchemaLayers(JsonElement doc, JsonE return null; } - /// - /// Returns the largest inclusive-lower-bound (minimum) - /// reachable from . The tightest applicable - /// floor wins, modelling how a layered caller bound stacks on top - /// of a wrapper's own floor. - /// internal static decimal? CollectMaxLowerBound(JsonElement doc, JsonElement schema, OpenApiVersion version) { decimal? best = null; @@ -125,11 +93,6 @@ internal static IEnumerable WalkSchemaLayers(JsonElement doc, JsonE return best; } - /// - /// Returns the smallest inclusive-upper-bound (maximum) - /// reachable from . The tightest applicable - /// ceiling wins. - /// internal static decimal? CollectMinUpperBound(JsonElement doc, JsonElement schema, OpenApiVersion version) { decimal? best = null; diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/MicrosoftTestApi31Factory.cs b/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/MicrosoftTestApi31Factory.cs index 46c6eb52..cf134686 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/MicrosoftTestApi31Factory.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/MicrosoftTestApi31Factory.cs @@ -7,13 +7,6 @@ namespace StrongTypes.OpenApi.IntegrationTests.Infrastructure; -/// -/// Microsoft test app forced to emit OpenAPI 3.1 instead of the entry-point's -/// default of 3.0. Re-runs the same shared shape contract so any divergence -/// between the two encodings (numeric exclusiveMinimum in 3.1 vs the -/// minimum+exclusiveMinimum:true pair in 3.0) shows up as a -/// per-version test failure. -/// public sealed class MicrosoftTestApi31Factory : WebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/MicrosoftTestApiFactory.cs b/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/MicrosoftTestApiFactory.cs index 738e232e..85238146 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/MicrosoftTestApiFactory.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/MicrosoftTestApiFactory.cs @@ -3,9 +3,4 @@ namespace StrongTypes.OpenApi.IntegrationTests.Infrastructure; -/// -/// In-process host for the Microsoft test app. Used by the Microsoft side of -/// the shared OpenAPI assertion suite so the JSON spec it inspects is -/// unambiguously the Microsoft.AspNetCore.OpenApi pipeline's output. -/// public sealed class MicrosoftTestApiFactory : WebApplicationFactory; diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/SwashbuckleTestApiFactory.cs b/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/SwashbuckleTestApiFactory.cs index bc72e8a3..df621feb 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/SwashbuckleTestApiFactory.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Infrastructure/SwashbuckleTestApiFactory.cs @@ -3,9 +3,4 @@ namespace StrongTypes.OpenApi.IntegrationTests.Infrastructure; -/// -/// In-process host for the Swashbuckle test app. Used by the Swashbuckle side -/// of the shared OpenAPI assertion suite so the JSON spec it inspects is -/// unambiguously the Swashbuckle pipeline's output. -/// public sealed class SwashbuckleTestApiFactory : WebApplicationFactory; diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/MicrosoftOpenApi31DocumentTests.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/MicrosoftOpenApi31DocumentTests.cs index 7bb011fa..b87b3e74 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/MicrosoftOpenApi31DocumentTests.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/MicrosoftOpenApi31DocumentTests.cs @@ -4,13 +4,6 @@ namespace StrongTypes.OpenApi.IntegrationTests.Tests; -/// -/// Runs the shared OpenAPI shape contract against the Microsoft test app -/// configured to emit OpenAPI 3.1. The 3.1 encoding of exclusive numeric -/// bounds (exclusiveMinimum: 0 as a number) differs from the 3.0 -/// encoding (minimum: 0, exclusiveMinimum: true); both are valid wire -/// shapes and both must round-trip the wrapper contract. -/// public sealed class MicrosoftOpenApi31DocumentTests(MicrosoftTestApi31Factory factory) : OpenApiDocumentTestsBase(factory.CreateClient()), IClassFixture { diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/MicrosoftOpenApiDocumentTests.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/MicrosoftOpenApiDocumentTests.cs index 70d4ffcf..9135c292 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/MicrosoftOpenApiDocumentTests.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/MicrosoftOpenApiDocumentTests.cs @@ -4,11 +4,6 @@ namespace StrongTypes.OpenApi.IntegrationTests.Tests; -/// -/// Runs the shared OpenAPI shape contract against the Microsoft test app, -/// wired to Microsoft.AspNetCore.OpenApi (AddOpenApi()) + -/// Kalicz.StrongTypes.OpenApi.Microsoft's schema transformers. -/// public sealed class MicrosoftOpenApiDocumentTests(MicrosoftTestApiFactory factory) : OpenApiDocumentTestsBase(factory.CreateClient()), IClassFixture { diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Annotations.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Annotations.cs index db35e249..b9791339 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Annotations.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Annotations.cs @@ -5,29 +5,8 @@ namespace StrongTypes.OpenApi.IntegrationTests.Tests; -// Annotation propagation — when a property annotated with -// [StringLength], [RegularExpression], [Range], [MaxLength] has a -// strong-type wrapper as its CLR type, the caller's bounds must -// reach the wire. Without help, the generators describe the property -// as just `{ "$ref": ".../NonEmptyString" }` and drop every -// annotation on the floor — the very thing this whole package -// exists to prevent. -// -// The property-level pass each pipeline runs is allowed to either -// inline the merged schema on the property or layer the caller's -// bounds via `$ref` + `allOf`. The collectors below walk both forms -// and report the tightest applicable bound, so the assertions here -// pin only the externally observable contract — not which encoding -// the pipeline happens to use. -// -// Each wrapper-typed test below has a primitive-typed sibling that -// pins the same wire keywords on a plain `string` / `int` / `string[]` -// carrying the same annotations. The sibling serves as a baseline: -// each pipeline's underlying annotation handling must natively produce -// these keywords on a primitive-typed property for the matching -// wrapper-typed assertion to be meaningful. If a primitive-typed test -// fails, the failure is in the pipeline's own annotation-mapping step, -// not in our wrapper paint. +// Each wrapper-typed test has a primitive-typed sibling pinning the same keywords on a plain string/int/array, so a +// failing sibling attributes the fault to the pipeline's own annotation mapping rather than the wrapper paint. public abstract partial class OpenApiDocumentTestsBase { [Fact] @@ -37,11 +16,6 @@ public async Task Property_Email_With_StringLength_Tightens_MaxLength_Below_Wrap var body = FollowRef(doc, RequestSchema(doc, "/annotated-texts")); var annotatedEmail = Property(body, "annotatedEmail"); - // Caller's [StringLength(100)] is tighter than the Email wrapper's - // own maxLength: 254 — the merged shape must surface 100. The - // wrapper's minLength: 1 still floors the lower bound, and the - // wrapper's format: email reaches the wire on pipelines that - // honour it on the wrapper component schema. Assert.Equal(1, CollectMaxInt(doc, annotatedEmail, "minLength", Version)); Assert.Equal(100, CollectMinInt(doc, annotatedEmail, "maxLength", Version)); Assert.Equal("email", CollectFirstString(doc, annotatedEmail, "format", Version)); @@ -66,8 +40,6 @@ public async Task Property_NonEmptyString_With_StringLength_Floors_To_Wrapper_Mi var body = FollowRef(doc, RequestSchema(doc, "/annotated-texts")); var email = Property(body, "email"); - // [StringLength(254)] sets only an upper bound; the wrapper's - // floor of 1 must remain. Assert.Equal(1, CollectMaxInt(doc, email, "minLength", Version)); Assert.Equal(254, CollectMinInt(doc, email, "maxLength", Version)); Assert.Equal("^[^@]+@[^@]+$", CollectFirstString(doc, email, "pattern", Version)); @@ -80,10 +52,6 @@ public async Task Property_NonEmptyString_With_EmailAddress_Carries_Wrapper_MinL var body = FollowRef(doc, RequestSchema(doc, "/annotated-texts")); var contactEmail = Property(body, "contactEmail"); - // Wrapper's minLength: 1 always reaches the wire. The [EmailAddress] - // format keyword only reaches it on pipelines that honour the - // attribute on string properties; Microsoft.AspNetCore.OpenApi - // drops it across every slot (see Email for the always-format type). Assert.Equal(1, CollectMaxInt(doc, contactEmail, "minLength", Version)); Assert.Equal(IsEmailAddressFormatIgnored ? null : "email", CollectFirstString(doc, contactEmail, "format", Version)); } @@ -194,8 +162,6 @@ public async Task Property_Positive_Int_With_Range_Across_Floor_Lets_Wrapper_Flo var body = FollowRef(doc, RequestSchema(doc, "/annotated-numbers")); var range = Property(body, "rangeAcrossFloor"); - // [Range(-5, 5)] would loosen the lower bound to -5, but the - // wrapper's exclusiveMinimum:0 floor wins. Upper bound 5 stays. AssertExclusiveLowerBoundReachable(doc, range, 0m, Version); Assert.Equal(5m, CollectMinUpperBound(doc, range, Version)); } @@ -207,9 +173,6 @@ public async Task Property_Positive_Int_With_Exclusive_Range_At_Wrapper_Floor_St var body = FollowRef(doc, RequestSchema(doc, "/annotated-numbers")); var atFloor = Property(body, "exclusiveAtFloor"); - // [Range(0, 5, MinimumIsExclusive = true)] and the wrapper both - // describe `> 0`; the merged shape is a single exclusive 0, - // never over-tightened to `> 1` or similar. AssertExclusiveLowerBoundReachable(doc, atFloor, 0m, Version); Assert.Equal(5m, CollectMinUpperBound(doc, atFloor, Version)); } @@ -221,10 +184,7 @@ public async Task Property_Positive_Int_With_Inclusive_Range_Just_Above_Wrapper_ var body = FollowRef(doc, RequestSchema(doc, "/annotated-numbers")); var aboveFloor = Property(body, "inclusiveJustAboveFloor"); - // [Range(1, 5)] is `>= 1`, strictly tighter than the wrapper's - // `> 0` numerically (the schema language doesn't know int and - // would admit 0.5 under `> 0`), so the inclusive 1 wins and the - // exclusive form drops out. + // >= 1 is strictly tighter than the wrapper's > 0: JSON Schema bounds are real-valued, so > 0 alone admits 0.5. Assert.Equal(1m, CollectMaxLowerBound(doc, aboveFloor, Version)); Assert.Equal(5m, CollectMinUpperBound(doc, aboveFloor, Version)); } @@ -236,10 +196,7 @@ public async Task Property_Positive_Int_With_Range_Below_Wrapper_Floor_Keeps_Wra var body = FollowRef(doc, RequestSchema(doc, "/annotated-numbers")); var below = Property(body, "rangeBelowFloor"); - // [Range(-10, -5)] on Positive is unsatisfiable: the wrapper's - // exclusive 0 floor and the caller's upper bound -5 carve out an - // empty set. Both bounds reach the wire as-is — the pipeline does - // not detect or reject the contradiction. + // The merged bounds are unsatisfiable (> 0 with maximum -5); the pipeline doesn't detect the contradiction. AssertExclusiveLowerBoundReachable(doc, below, 0m, Version); Assert.Equal(-5m, CollectMinUpperBound(doc, below, Version)); } @@ -263,9 +220,7 @@ public async Task Property_String_With_StringLength_Only_Carries_MaxLength_And_P var body = FollowRef(doc, RequestSchema(doc, "/annotated-texts")); var emailRaw = Property(body, "emailRaw"); - // [StringLength(254)] sets only an upper bound; both pipelines write - // minLength: 0 verbatim from the attribute. The wrapper sibling - // floors that to its own minLength: 1 (asserted on the wrapper test). + // Both pipelines write [StringLength]'s implicit MinimumLength = 0 verbatim as minLength: 0. Assert.Equal(0, CollectMaxInt(doc, emailRaw, "minLength", Version)); Assert.Equal(254, CollectMinInt(doc, emailRaw, "maxLength", Version)); Assert.Equal("^[^@]+@[^@]+$", CollectFirstString(doc, emailRaw, "pattern", Version)); diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Collections.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Collections.cs index 4b2807f3..4ed506e6 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Collections.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Collections.cs @@ -61,16 +61,6 @@ public async Task NonEmptyEnumerable_Of_Positive_Int_Composes_With_Numeric_Trans AssertExclusiveLowerBound(items, 0m, Version); } - // ─────────────────────────────────────────────────────────────────── - // Collection shapes — every CLR collection shape carrying a strong-type - // element must expose `items` with the element's wire schema. The - // `/collections/shapes` endpoint declares one property per shape, all - // typed as `Positive` so the items schema must carry - // `exclusiveMinimum: 0`. Each shape is asserted independently so the - // failure surface tells us which shapes the underlying pipeline (and - // any items-backfill transformer it relies on) actually covers. - // ─────────────────────────────────────────────────────────────────── - [Theory] [InlineData("asEnumerable")] [InlineData("asIList")] @@ -95,13 +85,6 @@ public async Task Collection_Shape_Of_Positive_Int_Renders_As_Array_With_Integer AssertExclusiveLowerBound(items, 0m, Version); } - // ─────────────────────────────────────────────────────────────────── - // Dictionary shapes — the wire form for a CLR dictionary keyed by a - // primitive is an OpenAPI object with `additionalProperties`. Each - // dictionary property below carries a `Positive` value, so the - // value-schema position must encode `exclusiveMinimum: 0`. - // ─────────────────────────────────────────────────────────────────── - [Theory] [InlineData("asIDictionary")] [InlineData("asDictionaryIntKey")] diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Components.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Components.cs index 3a294324..6b802de9 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Components.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Components.cs @@ -7,11 +7,6 @@ namespace StrongTypes.OpenApi.IntegrationTests.Tests; public abstract partial class OpenApiDocumentTestsBase { - // Required-array contract — strong-type wrapper properties land in - // the `required` array iff their primitive equivalent does. Whatever - // the underlying pipeline produces for `string`, `int`, `string?`, - // `[Required] string`, `required string` etc. is what it must produce - // for the matching `NonEmptyString` / `Positive` property. [Theory] [InlineData("plain", "plainRaw")] [InlineData("nullable", "nullableRaw")] @@ -28,11 +23,6 @@ public async Task Required_Membership_Matches_Underlying_Primitive(string strong Assert.Equal(required.Contains(rawName), required.Contains(strongName)); } - // Components cleanup — every inlineable wrapper (NonEmptyString and - // the numeric/array generics) must be removed from - // components.schemas after the inliner runs. Maybe is the one - // intentional exception: its object-shaped wire form is worth - // keeping as a named component. [Fact] public async Task Inlineable_Wrapper_Components_Are_Removed_From_Components_Schemas() { @@ -41,8 +31,6 @@ public async Task Inlineable_Wrapper_Components_Are_Removed_From_Components_Sche Assert.DoesNotContain("NonEmptyString", schemaNames); - // Microsoft prefix style and Swashbuckle suffix style: any name - // that begins or ends with one of the wrapper roots must be gone. foreach (var name in schemaNames) { Assert.False( @@ -51,12 +39,8 @@ public async Task Inlineable_Wrapper_Components_Are_Removed_From_Components_Sche } } - // Inlining a wrapper whose storage type is itself an object (e.g. - // Email, backed by System.Net.Mail.MailAddress) makes the pipeline - // register that nested storage type as its own component. Once the - // wrapper is inlined and dropped, nothing references the nested - // component any more, so the inliner must GC it — leaving it behind - // is contract drift (openapi-typescript emits an unused interface). + // Inlining a wrapper orphans its generated storage component (e.g. MailAddress behind Email); an orphan left + // behind leaks into consumer codegen as an unused type. [Fact] public async Task No_Component_Schema_Is_Left_Unreferenced_After_Inlining() { diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Composition.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Composition.cs index d1fa0a96..46762451 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Composition.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Composition.cs @@ -6,22 +6,13 @@ namespace StrongTypes.OpenApi.IntegrationTests.Tests; -// Transitive composition — when one strong type wraps another, the -// outer transformer must not swallow the inner's bounds. A -// Maybe> needs minimum:0/exclusiveMinimum:true on its -// inner Value, not just type:integer; a Maybe needs -// minLength:1; a NonEmptyEnumerable> needs both the array -// bound and the Maybe wrapper with its own inner bound. +// When one strong type wraps another, the outer transformer must not swallow the inner's bounds. public abstract partial class OpenApiDocumentTestsBase { [Fact] public async Task Maybe_T_Renders_As_Wrapper_Object_With_Value_Property() { - // The PATCH request for a numeric entity carries `Maybe? NullableValue`. - // The converter writes {"Value": x} or {"Value": null} and reads {} as None, - // so the schema must describe an object with a non-required Value property — - // a deep-equal against the literal shape catches any spurious `required: ["Value"]` - // alongside the rest of the wrapper's wire form. + // The Maybe converter reads {} as None, so the schema must not require Value. var doc = await GetDocumentAsync(); var body = FollowRef(doc, RequestSchema(doc, "/positive-int-entities/{id}", method: "patch")); var nullableValue = Resolve(doc, UnwrapNullableProperty(Property(body, "nullableValue"), Version)); diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Intervals.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Intervals.cs index 26db4efa..04fbf933 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Intervals.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.Intervals.cs @@ -90,8 +90,7 @@ private static void AssertIntervalObject(JsonElement schema, params string[] req Assert.Equal(requiredEndpoints.ToHashSet(), RequiredSet(schema)); } - // An all-optional variant lists nothing required; the keyword may then be - // omitted entirely, so treat absent as the empty set. + // The required keyword is omitted entirely when nothing is required, so absent reads as the empty set. private static HashSet RequiredSet(JsonElement schema) => schema.TryGetProperty("required", out var r) ? r.EnumerateArray().Select(e => e.GetString()!).ToHashSet() @@ -103,12 +102,8 @@ private static void AssertBoundFlag(JsonElement schema) Assert.False(schema.TryGetProperty("default", out _), "inclusivity-on-omission is converter-dependent, so the schema must not pin a default"); } - // Tolerates every integer encoding the pipelines emit: Swashbuckle's - // type:"integer", Microsoft 3.1's type:["integer","string"(,"null")], and - // Microsoft 3.0's pattern-only plain-int form (no type keyword at all). - // `format: int32` is the one marker present on all of them. Nullability is - // read from whichever marker the version uses (nullable:true vs a "null" - // member of the type array). + // The pipelines emit three integer encodings — Swashbuckle's type:"integer", Microsoft 3.1's type:["integer","string"], + // and Microsoft 3.0's pattern-only form with no type keyword at all; format: int32 is the one marker common to all. private void AssertIntegerEndpoint(JsonElement schema, bool nullable) { Assert.Equal("int32", schema.GetProperty("format").GetString()); @@ -129,7 +124,6 @@ private void AssertIntegerEndpoint(JsonElement schema, bool nullable) nullableEncoded = schema.TryGetProperty("nullable", out var n) && n.ValueKind == JsonValueKind.True; } - // A 3.1 document must not use the 3.0 nullable:true marker, and vice versa. if (Version == OpenApiVersion.V3_1) Assert.False(schema.TryGetProperty("nullable", out _), "3.1 must not emit the nullable keyword"); diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.NonBodyBinding.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.NonBodyBinding.cs index 488accd5..9c50d003 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.NonBodyBinding.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.NonBodyBinding.cs @@ -6,13 +6,6 @@ namespace StrongTypes.OpenApi.IntegrationTests.Tests; -/// -/// Asserts the OpenAPI parameter / form-body schemas the pipelines emit for -/// strong-type parameters bound from non-body sources -/// ([FromQuery], [FromRoute], [FromHeader], -/// [FromForm]). Required and nullable variants of each wrapped type -/// are exercised separately so a regression on either branch shows up. -/// public abstract partial class OpenApiDocumentTestsBase { // ── [FromQuery] ────────────────────────────────────────────────────── @@ -129,15 +122,8 @@ public async Task FromHeader_NullablePositiveInt_RendersAsExclusivePositive() // ── [FromForm] ─────────────────────────────────────────────────────── /// - /// Pins the aggregate form-body shape and every per-field schema for - /// an all-wrapper form. Swashbuckle's stock form-body assembler emits - /// a nameless { allOf: [<each>] } whenever every field - /// is component-typed; NonBodyStrongTypeOperationFilter - /// rebuilds it as { type: object, properties: { … } }. - /// Asserting both the structural shape and each property's wire form - /// in the same test catches a regression in the reshape pass (a - /// missing property surfaces as a slot lookup failure) and a - /// regression in the per-type painters (a slot's contents differ). + /// Swashbuckle's stock form-body assembler emits a nameless { allOf: [ … ] } when every field is + /// component-typed; NonBodyStrongTypeOperationFilter rebuilds it as { type: object, properties: { … } }. /// [Fact] public async Task FromForm_AllWrappers_FormBody_RendersObjectWithEveryPropertyInItsWireShape() @@ -157,17 +143,8 @@ public async Task FromForm_AllWrappers_FormBody_RendersObjectWithEveryPropertyIn } // ── Caller annotations on non-body slots ───────────────────────────── - // Strong-type slots bound from query/form should merge caller-supplied - // data-annotations the same way JSON-body properties do — e.g. a - // [StringLength(50)] on a [FromQuery] NonEmptyString must reach the - // wire as maxLength: 50 alongside the wrapper's own minLength: 1. - // - // Each wrapper-typed test has a primitive-typed sibling that pins the - // baseline: the framework natively surfaces the annotation on the - // primitive, so the wrapper test only has teeth when the primitive - // baseline carries the keyword. If a pipeline ever stops surfacing the - // annotation on the primitive, the baseline fails first and the - // wrapper failure is correctly attributed to the framework, not us. + // Each wrapper-typed test has a primitive-typed sibling pinning the baseline, so a missing keyword attributes to + // the pipeline's own annotation handling rather than the wrapper merge. [Fact] public async Task FromQuery_PlainString_With_StringLength_Carries_MaxLength() @@ -231,16 +208,6 @@ public async Task FromForm_PositiveInt_With_Range_Carries_Both_Bounds() } // ── Diverse form-body shapes ───────────────────────────────────────── - // The aggregate-shape test above pins the all-wrapper form body. The - // three tests below exercise the form-body reshape on more diverse - // payloads: a primitives-only form (Swashbuckle emits a clean - // properties map natively), an all-wrappers form where multiple - // NonEmptyStrings each carry a different annotation - // (RegularExpression, Url, StringLength) alongside Email, - // Positive, and a NonEmptyEnumerable of a numeric wrapper, and a - // mixed form combining both kinds with caller annotations on each. - // Each test pins every property's full schema so a regression on - // either pipeline shows up as a per-property diff. [Fact] public async Task FromForm_SimpleTypes_FormBody_RendersAllPropertiesWithTheirAnnotations() diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.cs index aa7019c1..db232767 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.cs @@ -5,19 +5,7 @@ namespace StrongTypes.OpenApi.IntegrationTests.Tests; -/// -/// The OpenAPI spec contract every strong-type wrapper must satisfy, regardless -/// of which generator produced the document. The Microsoft.AspNetCore.OpenApi -/// and Swashbuckle pipelines emit the same logical schema (modulo formatting, -/// ordering, and component naming); a single shared assertion suite pins the -/// shape and is run twice — once per concrete subclass — so any divergence -/// shows up as a per-generator failure. -/// -/// The suite is split into feature-scoped partials (Strings, Numerics, -/// Collections, Composition, Annotations, Components) that all compile into -/// this one type — so each subclass still inherits every test, regardless of -/// which file declared it. -/// +/// The shared OpenAPI shape-contract suite — see "OpenAPI integration tests" in testing.md. public abstract partial class OpenApiDocumentTestsBase(HttpClient client) : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; @@ -26,32 +14,11 @@ public abstract partial class OpenApiDocumentTestsBase(HttpClient client) : IDis protected abstract string DocumentUrl { get; } - /// - /// The OpenAPI spec version the pipeline under test emits. Exclusive-bound - /// helpers in pin the version's - /// encoding strictly: 3.0 must emit minimum: <n> + - /// exclusiveMinimum: true (boolean pair), 3.1 must emit - /// exclusiveMinimum: <n> (numeric). Cross-version output - /// fails the test rather than silently passing. - /// protected abstract OpenApiVersion Version { get; } - // Per-feature flags below name a specific keyword the underlying - // pipeline doesn't natively write for the matching annotation, even - // on a primitive-typed property. The corresponding tests run on every - // pipeline; when the flag is set, they assert the keyword is *absent* - // rather than skipping — so the suite documents the framework's - // actual behaviour and catches it if the framework starts honouring - // the annotation. Each flag is set in the per-pipeline subclass. /// - /// True when the pipeline drops the - /// from string slots — i.e. neither plain string nor - /// properties decorated with - /// [EmailAddress] reach the wire as format: email. - /// Microsoft.AspNetCore.OpenApi never emits the keyword from the - /// attribute on any string slot (body / query / route / header / form); - /// Swashbuckle does. The always-an-email wrapper is - /// independent — it paints format: email on every pipeline. + /// True when the pipeline drops [EmailAddress]'s format: email from string slots — the Email + /// wrapper is independent and paints the format on every pipeline. /// protected virtual bool IsEmailAddressFormatIgnored => false; diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/SwashbuckleOpenApiDocumentTests.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/SwashbuckleOpenApiDocumentTests.cs index d24c3900..86adae03 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/SwashbuckleOpenApiDocumentTests.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/SwashbuckleOpenApiDocumentTests.cs @@ -4,11 +4,6 @@ namespace StrongTypes.OpenApi.IntegrationTests.Tests; -/// -/// Runs the shared OpenAPI shape contract against the Swashbuckle test app, -/// wired to Swashbuckle.AspNetCore (AddSwaggerGen()) + -/// Kalicz.StrongTypes.OpenApi.Swashbuckle's schema filters. -/// public sealed class SwashbuckleOpenApiDocumentTests(SwashbuckleTestApiFactory factory) : OpenApiDocumentTestsBase(factory.CreateClient()), IClassFixture { diff --git a/src/StrongTypes.OpenApi.Microsoft/Binding/NonBodyStrongTypeOperationTransformer.cs b/src/StrongTypes.OpenApi.Microsoft/Binding/NonBodyStrongTypeOperationTransformer.cs index 21d04a2c..0bb78dbe 100644 --- a/src/StrongTypes.OpenApi.Microsoft/Binding/NonBodyStrongTypeOperationTransformer.cs +++ b/src/StrongTypes.OpenApi.Microsoft/Binding/NonBodyStrongTypeOperationTransformer.cs @@ -9,24 +9,10 @@ namespace StrongTypes.OpenApi.Microsoft; /// -/// Repaints schemas attached to non-body parameters ([FromQuery], -/// [FromRoute], [FromHeader]) and to the per-field schemas -/// of [FromForm] request bodies. Microsoft.AspNetCore.OpenApi only -/// fires on JSON-body schemas; for -/// every other slot the parameter's CLR type is inspected via the -/// exposed on the operation context -/// and the wire shape is written directly. Caller annotations attached at -/// the slot (e.g. [StringLength] on a [FromQuery] parameter -/// or [Range] on a [FromForm] property) are layered on top -/// via so non-body slots merge -/// annotations the same way JSON-body properties do. -/// -/// Body schemas are out of scope by construction: this transformer only -/// dispatches on / / -/// / , -/// and the form path is gated to multipart/form-data / -/// application/x-www-form-urlencoded. JSON request/response bodies -/// are reached neither by source nor by content type. +/// Repaints schemas attached to non-body slots — [FromQuery]/[FromRoute]/[FromHeader] +/// parameters and [FromForm] fields — because Microsoft.AspNetCore.OpenApi only fires +/// on JSON-body schemas. Caller annotations attached +/// at the slot are layered on top, matching the JSON-body behavior. /// internal sealed class NonBodyStrongTypeOperationTransformer : IOpenApiOperationTransformer { @@ -86,11 +72,7 @@ private static void RewriteFormProperty(OpenApiOperation operation, ApiParameter if (media.Schema is not OpenApiSchema formSchema) continue; if (formSchema.Properties is not { Count: > 0 } properties) continue; - // Microsoft.AspNetCore.OpenApi keys form-body properties by the - // C# property name (PascalCase), matching ApiParameterDescription.Name. - // No camelCase / case-insensitive fallback because this pipeline - // doesn't produce those — and a fallback would risk binding to - // the wrong slot if a user DTO ever has near-collisions. + // Form-body properties are keyed by the C# property name (PascalCase), matching ApiParameterDescription.Name. if (!properties.ContainsKey(pd.Name)) continue; properties[pd.Name] = PaintSlot(properties[pd.Name], clrType, pd); } @@ -98,11 +80,7 @@ private static void RewriteFormProperty(OpenApiOperation operation, ApiParameter private static IOpenApiSchema PaintSlot(IOpenApiSchema? existing, Type clrType, ApiParameterDescription pd) { - // Mutate the existing schema when possible so any keywords the - // pipeline already wrote (description, default, caller-applied - // [StringLength] on the IParsable string overload, …) survive the - // wrapper paint. Falls back to a fresh schema only when the slot - // currently holds an OpenApiSchemaReference or is null. + // Mutate the existing schema so keywords the pipeline already wrote survive the wrapper paint. var schema = existing as OpenApiSchema ?? new OpenApiSchema(); if (!TryPaintWireShape(schema, clrType)) return existing ?? schema; @@ -166,8 +144,6 @@ private static bool TryPaintWireShape(OpenApiSchema schema, Type clrType) private static IReadOnlyList GetSlotAttributes(ApiParameterDescription pd) { - // For a flattened [FromForm] complex model, ModelMetadata pinpoints - // the property; reflect on it to read the property's own attributes. if (pd.ModelMetadata is { ContainerType: { } containerType, PropertyName: { } propertyName }) { var prop = containerType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); @@ -175,18 +151,13 @@ private static IReadOnlyList GetSlotAttributes(ApiParameterDescriptio return prop.GetCustomAttributes(inherit: true).OfType().ToArray(); } - // For a method parameter (query/path/header), the attributes live on - // the ParameterInfo. if (pd.ParameterDescriptor is ControllerParameterDescriptor cpd) return cpd.ParameterInfo.GetCustomAttributes(inherit: true).OfType().ToArray(); return []; } - // ApiParameterDescription.Type lies for strong types implementing IParsable — - // it reports the string overload's input, hiding the wrapper. ModelMetadata.ModelType - // exposes the actual CLR type for both parameter slots and for properties of a - // flattened [FromForm] model. + // Not pd.Type: it lies for IParsable strong types, reporting the string overload's input instead of the wrapper. private static Type ResolveParameterClrType(ApiParameterDescription pd) => pd.ModelMetadata.ModelType; } diff --git a/src/StrongTypes.OpenApi.Microsoft/Collections/StrongTypeCollectionShapeTransformer.cs b/src/StrongTypes.OpenApi.Microsoft/Collections/StrongTypeCollectionShapeTransformer.cs index ffcef379..a06825c3 100644 --- a/src/StrongTypes.OpenApi.Microsoft/Collections/StrongTypeCollectionShapeTransformer.cs +++ b/src/StrongTypes.OpenApi.Microsoft/Collections/StrongTypeCollectionShapeTransformer.cs @@ -4,18 +4,9 @@ namespace StrongTypes.OpenApi.Microsoft; -// ASP.NET Core's schema generation cannot infer the element/value schema -// for a CLR collection whose element type carries a custom JsonConverter -// (every strong-type wrapper does), so the generated schema is left with -// no `items` for arrays and no `additionalProperties` for dictionaries. -// This transformer fills those positions in for any type the serializer -// recognises as Enumerable or Dictionary, regardless of the concrete CLR -// shape (`List`, `T[]`, `FrozenSet`, `IDictionary<,>`, -// `FrozenDictionary<,>`, `SortedList<,>`, …). Dictionaries whose value -// type is a primitive bypass this hook — the framework inlines a -// (broken) schema for them directly. That is a Microsoft.AspNetCore -// .OpenApi defect, not a strong-types concern, and is left to upstream -// to fix. +// ASP.NET Core's schema generation leaves `items` / `additionalProperties` empty when the +// element type carries a custom JsonConverter (every strong-type wrapper does); fill them in. +// Dictionaries with primitive value types never reach this hook — a Microsoft.AspNetCore.OpenApi defect. public sealed class StrongTypeCollectionShapeTransformer : IOpenApiSchemaTransformer { public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) diff --git a/src/StrongTypes.OpenApi.Microsoft/Inlining/StrongTypeInliningDocumentTransformer.cs b/src/StrongTypes.OpenApi.Microsoft/Inlining/StrongTypeInliningDocumentTransformer.cs index 6c6bdc33..4a169004 100644 --- a/src/StrongTypes.OpenApi.Microsoft/Inlining/StrongTypeInliningDocumentTransformer.cs +++ b/src/StrongTypes.OpenApi.Microsoft/Inlining/StrongTypeInliningDocumentTransformer.cs @@ -7,13 +7,7 @@ namespace StrongTypes.OpenApi.Microsoft; /// -/// Runs after every other transformer so -/// the wire shape of and the numeric strong- -/// type wrappers is inlined at every property/parameter/items position -/// and their components disappear from components.schemas. The -/// caller's data-annotations (already attached to use sites by -/// ) are preserved -/// through the merge. +/// Runs ; must run after every other transformer so use-site annotations are already attached. /// internal sealed class StrongTypeInliningDocumentTransformer : IOpenApiDocumentTransformer { diff --git a/src/StrongTypes.OpenApi.Microsoft/Intervals/IntervalSchemaTransformer.cs b/src/StrongTypes.OpenApi.Microsoft/Intervals/IntervalSchemaTransformer.cs index 603e53f2..ff6e47ea 100644 --- a/src/StrongTypes.OpenApi.Microsoft/Intervals/IntervalSchemaTransformer.cs +++ b/src/StrongTypes.OpenApi.Microsoft/Intervals/IntervalSchemaTransformer.cs @@ -43,8 +43,6 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC // No schema default: which inclusivity an omitted flag implies is the applied converter's call, not the type's. private static OpenApiSchema BoundFlagSchema() => new() { Type = JsonSchemaType.Boolean }; - // An endpoint is required exactly when its type is the bare value type; an - // optional endpoint is Nullable, and the converter lets its key be omitted. private static HashSet RequiredEndpoints(Type startType, Type endType) { var required = new HashSet(StringComparer.Ordinal); diff --git a/src/StrongTypes.OpenApi.Microsoft/MicrosoftSchemaNaming.cs b/src/StrongTypes.OpenApi.Microsoft/MicrosoftSchemaNaming.cs index 21b194a3..9b6e53fd 100644 --- a/src/StrongTypes.OpenApi.Microsoft/MicrosoftSchemaNaming.cs +++ b/src/StrongTypes.OpenApi.Microsoft/MicrosoftSchemaNaming.cs @@ -3,13 +3,8 @@ namespace StrongTypes.OpenApi.Microsoft; -// Microsoft.AspNetCore.OpenApi's default schema-id strategy. Primitives are -// named by their C# keyword ("int", "long", "decimal", …) and generic -// wrappers compose with an "Of" infix — so `Positive` becomes the -// component name "PositiveOfint". Both the document-time component filler -// (which parses these names back into wire schemas) and the property- -// annotation transformer (which rebuilds component names from CLR types -// to find the parent type) depend on this convention. +// Mirrors Microsoft.AspNetCore.OpenApi's default schema-id strategy: primitives use their +// C# keyword and generic wrappers compose with an "Of" infix (Positive → "PositiveOfint"). internal static class MicrosoftSchemaNaming { private static readonly Dictionary s_numericPrefixByDefinition = new() diff --git a/src/StrongTypes.OpenApi.Microsoft/PropertyAnnotationSchemaTransformer.cs b/src/StrongTypes.OpenApi.Microsoft/PropertyAnnotationSchemaTransformer.cs index 1f8044f4..8eaa1709 100644 --- a/src/StrongTypes.OpenApi.Microsoft/PropertyAnnotationSchemaTransformer.cs +++ b/src/StrongTypes.OpenApi.Microsoft/PropertyAnnotationSchemaTransformer.cs @@ -8,27 +8,9 @@ namespace StrongTypes.OpenApi.Microsoft; /// -/// Re-applies caller-supplied data-annotations ([StringLength], -/// [Range], [RegularExpression], [Description], …) -/// to properties whose CLR type is a strong-type wrapper. Without this -/// pass, a property like [StringLength(50)] NonEmptyString Name -/// renders as a bare $ref to the NonEmptyString component -/// and silently drops the caller's maxLength: 50. We layer the -/// caller's bound onto the property position via allOf: -/// -/// "name": { -/// "allOf": [ { "$ref": "#/components/schemas/NonEmptyString" } ], -/// "maxLength": 50 -/// } -/// -/// The component schema ({ "type": "string", "minLength": 1 }) is -/// painted by and is not -/// touched here — only the property position is. After -/// collapses the allOf+ref, the -/// merged shape on the wire is -/// { type: string, minLength: 1, maxLength: 50 }. Also normalises -/// each parent schema's required set to the C# nullability of its -/// properties. +/// Re-applies caller-supplied data-annotations to properties whose CLR type is a strong-type +/// wrapper: such a property renders as a bare $ref, which silently drops the caller's +/// bounds, so they are layered onto the property position via allOf. /// internal sealed class PropertyAnnotationSchemaTransformer : IOpenApiDocumentTransformer { @@ -78,8 +60,6 @@ private static void ApplyToParent(OpenApiSchema parent, Type parentType) continue; } - // OpenApiSchemaReference (or any other IOpenApiSchema implementation): - // wrap it so we can layer the bounds on top. var refWrapper = new OpenApiSchema { AllOf = [propSchema] }; if (WrapperAnnotationApplier.TryApply(refWrapper, clrProperty.PropertyType, attrs)) { @@ -89,9 +69,7 @@ private static void ApplyToParent(OpenApiSchema parent, Type parentType) } } - // A "bare ref" position emerges as an OpenApiSchema whose only meaningful - // signal is the Reference pointer. Wrapping in `allOf:[ref]` is the - // standard way to layer caller bounds on top of it. + // The pipeline renders a bare $ref property position as an otherwise-empty concrete schema. private static bool IsBareRefSchema(OpenApiSchema schema) { if (schema.Type is not null) return false; @@ -115,9 +93,6 @@ private static string ResolveJsonName(PropertyInfo property) private static Dictionary BuildComponentToTypeMap(IReadOnlyList groups) { - // Microsoft's default schema-id strategy is the simple CLR type name (or - // a generic-sanitised form). Map component names to CLR types by - // walking every reachable parameter / return type the API exposes. var map = new Dictionary(StringComparer.Ordinal); var seen = new HashSet(); @@ -146,12 +121,7 @@ private static void Visit(Type? type, Dictionary map, HashSet` → "FooOfBar" - // `Foo>` → "FooOfBarOfBaz" - // primitive types → C# keyword (int, string, …) — that's what shows - // up in component names like "PositiveOfint". + // Mimics Microsoft.AspNetCore.OpenApi's default schema-id strategy (Foo → "FooOfBar", primitives → C# keyword). private static string ComputeSchemaName(Type type) { if (MicrosoftSchemaNaming.GetPrimitiveKeyword(type) is { } keyword) return keyword; diff --git a/src/StrongTypes.OpenApi.Microsoft/Startup.cs b/src/StrongTypes.OpenApi.Microsoft/Startup.cs index a3d9320f..32fefe60 100644 --- a/src/StrongTypes.OpenApi.Microsoft/Startup.cs +++ b/src/StrongTypes.OpenApi.Microsoft/Startup.cs @@ -17,7 +17,6 @@ public static class StrongTypesOpenApiExtensions /// the generated OpenAPI document as the JSON shape their converters /// actually produce. /// - /// The OpenAPI options being configured. public static OpenApiOptions AddStrongTypes(this OpenApiOptions options) { options.AddSchemaTransformer(); diff --git a/src/StrongTypes.OpenApi.Microsoft/StrongTypesComponentSchemaFiller.cs b/src/StrongTypes.OpenApi.Microsoft/StrongTypesComponentSchemaFiller.cs index b794b563..750b5f65 100644 --- a/src/StrongTypes.OpenApi.Microsoft/StrongTypesComponentSchemaFiller.cs +++ b/src/StrongTypes.OpenApi.Microsoft/StrongTypesComponentSchemaFiller.cs @@ -4,13 +4,8 @@ namespace StrongTypes.OpenApi.Microsoft; -// The framework's schema-deduplication pass copies repeated wrapper schemas -// (Maybe, Positive, …) into components.schemas with a fresh -// OpenApiSchema instance — that copy doesn't pick up our schema-transformer -// mutations on certain wrapper types, leaving the component as `{}`. This -// document transformer runs after deduplication and reverses each -// strong-type-derived component name back into the wire schema the converter -// actually emits, so referenced schemas match the inline ones. +// The framework's schema-deduplication pass copies wrapper schemas into components.schemas +// without our schema-transformer mutations, leaving `{}`; repaint each from its component name. internal sealed class StrongTypesComponentSchemaFiller : IOpenApiDocumentTransformer { public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) @@ -39,14 +34,7 @@ private static bool TryPaint(string name, OpenApiSchema schema, IDictionary components) { - // Name-only matching would happily trample a user DTO that happens - // to be called `Email` or `MaybeOfInt32`. Every wrapper we paint is - // a primitive (string / integer / number / array) or — for - // `MaybeOf` — an object with a single `Value` property. An object - // schema with multiple/non-`Value` properties under our reserved - // name is a user DTO, not ours; bail. Returning null also makes - // ResolveInner fall through to a `$ref` so nested references to a - // colliding user DTO point at their schema instead of ours. + // Bail when the name is already taken by a user DTO (e.g. a class named "Email") so we don't trample its schema. if (components.TryGetValue(name, out var existing) && existing is OpenApiSchema concrete && LooksLikeUserDto(concrete)) @@ -117,8 +105,7 @@ private static IOpenApiSchema ResolveInner(string innerName, IDictionary` → underlying primitive with `exclusiveMinimum: 0` - `NonNegative` → underlying primitive with `minimum: 0` - `Negative` → underlying primitive with `exclusiveMaximum: 0` diff --git a/src/StrongTypes.OpenApi.Swashbuckle/Binding/NonBodyStrongTypeOperationFilter.cs b/src/StrongTypes.OpenApi.Swashbuckle/Binding/NonBodyStrongTypeOperationFilter.cs index e609b284..db961240 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/Binding/NonBodyStrongTypeOperationFilter.cs +++ b/src/StrongTypes.OpenApi.Swashbuckle/Binding/NonBodyStrongTypeOperationFilter.cs @@ -10,30 +10,11 @@ namespace StrongTypes.OpenApi.Swashbuckle; /// -/// Layers caller-supplied data-annotations -/// ([StringLength], [Range], [RegularExpression], …) -/// onto strong-type wrappers that arrive at non-body slots — -/// [FromQuery] / [FromRoute] / [FromHeader] -/// parameters and the per-field schemas of [FromForm] request -/// bodies. The wrapper's wire shape itself is painted by the per-type -/// schema filters ( et al); -/// without this pass, caller bounds attached at the slot would be -/// dropped — Swashbuckle's -/// only sees the parent type's properties map and never reaches -/// the parameter slots or the form-body's allOf entries. -/// -/// Body schemas are out of scope by construction: this filter only -/// dispatches on / -/// / / -/// , and the form path is gated to -/// multipart/form-data / application/x-www-form-urlencoded. -/// -/// The form path also reshapes Swashbuckle's { allOf: [<each-field>] } -/// request body — emitted whenever every form field is component-typed — -/// back into a proper { type: object, properties: { … } } map keyed -/// by the form-field names from the s. -/// Without that, consumers see a nameless allOf and can't tell which schema -/// belongs to which field. +/// Layers caller-supplied data-annotations onto strong-type wrappers at non-body slots — +/// [FromQuery]/[FromRoute]/[FromHeader] parameters and [FromForm] +/// fields — which never reaches. Also reshapes +/// the { allOf: [<each-field>] } form body Swashbuckle emits for component-typed +/// fields into a { type: object, properties: { … } } map keyed by field name. /// public sealed class NonBodyStrongTypeOperationFilter(ILogger? logger = null) : IOperationFilter { @@ -101,20 +82,6 @@ private static void MergeFormPropertyAnnotations(OpenApiOperation operation, Api } } - /// - /// Replaces Swashbuckle's all-component { allOf: [<each-field>] } - /// form-body schema (and the hybrid { allOf: [wrappers, {primitives}] } - /// shape Swashbuckle emits for mixed forms) with a flat - /// { type: object, properties: { … } } map keyed by each form - /// field's . Per-property - /// schemas come from the schema generator: wrappers stay as - /// $refs to their components (or inline schemas for - /// collection-shaped wrappers Swashbuckle inlines anyway), and - /// primitives are generated with their so - /// Swashbuckle's own data-annotation pipeline applies - /// [StringLength], [Range], etc. directly to the - /// emitted schema. - /// private static void ReshapeFormAllOfIntoProperties( OpenApiOperation operation, IList descriptions, @@ -140,13 +107,8 @@ private static void ReshapeFormAllOfIntoProperties( var clrType = ResolveParameterClrType(pd); - // For primitives, hand Swashbuckle the form record's - // PropertyInfo so its generator surfaces caller annotations - // (`[StringLength]`, `[Range]`, …) directly. For wrappers - // we want a clean $ref — `MergeFormPropertyAnnotations` - // layers slot annotations on top via WrapperAnnotationApplier, - // and passing MemberInfo here would risk double-emission - // without the inline marker the inliner needs. + // MemberInfo makes Swashbuckle apply caller annotations itself — right for primitives; + // wrapper slots get theirs from MergeFormPropertyAnnotations, so a copy here would double-emit. MemberInfo? memberInfo = null; if (!StrongTypeSchemaTypes.IsInlineable(clrType)) memberInfo = ResolveFormPropertyMember(pd); @@ -187,18 +149,13 @@ private static IOpenApiSchema ApplyAnnotations(IOpenApiSchema? slot, Type clrTyp var attributes = GetSlotAttributes(pd); if (attributes.Count == 0) return slot ?? new OpenApiSchema(); - // Wrapper-typed slots arrive at this filter as `OpenApiSchemaReference` - // ($ref to the wrapper component painted by the per-type schema - // filter). Mirror PropertyAnnotationSchemaFilter's body-side trick: - // wrap the ref in `allOf:[]+annotations` and mark with the - // inline marker so StrongTypeInliner collapses it back to a flat - // shape later. Inline OpenApiSchema slots can be mutated directly. if (slot is OpenApiSchema concrete) { WrapperAnnotationApplier.TryApply(concrete, clrType, attributes); return concrete; } + // A $ref slot can't carry annotations directly; wrap it in allOf + marker so StrongTypeInliner collapses the pair later. if (slot is OpenApiSchemaReference) { var wrapper = new OpenApiSchema { AllOf = [slot] }; @@ -228,9 +185,6 @@ private static IReadOnlyList GetSlotAttributes(ApiParameterDescriptio return []; } - // ApiParameterDescription.Type lies for strong types implementing IParsable — - // it reports the string overload's input, hiding the wrapper. ModelMetadata.ModelType - // exposes the actual CLR type for both parameter slots and for properties of a - // flattened [FromForm] model. + // Not pd.Type: it lies for IParsable strong types, reporting the string overload's input instead of the wrapper. private static Type ResolveParameterClrType(ApiParameterDescription pd) => pd.ModelMetadata.ModelType; } diff --git a/src/StrongTypes.OpenApi.Swashbuckle/Collections/NonEmptyEnumerableSchemaFilter.cs b/src/StrongTypes.OpenApi.Swashbuckle/Collections/NonEmptyEnumerableSchemaFilter.cs index b8fc47d3..7ac2c819 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/Collections/NonEmptyEnumerableSchemaFilter.cs +++ b/src/StrongTypes.OpenApi.Swashbuckle/Collections/NonEmptyEnumerableSchemaFilter.cs @@ -5,10 +5,8 @@ namespace StrongTypes.OpenApi.Swashbuckle; /// -/// Layers minItems: 1 onto the schema for -/// and . -/// Swashbuckle already emits the array shape (the type implements -/// ); this filter only adds the non-empty bound. +/// Layers minItems: 1 onto the schema for and +/// ; Swashbuckle already emits the array shape. /// public sealed class NonEmptyEnumerableSchemaFilter : ISchemaFilter { diff --git a/src/StrongTypes.OpenApi.Swashbuckle/Inlining/StrongTypeInliningDocumentFilter.cs b/src/StrongTypes.OpenApi.Swashbuckle/Inlining/StrongTypeInliningDocumentFilter.cs index d5133d4a..7865582a 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/Inlining/StrongTypeInliningDocumentFilter.cs +++ b/src/StrongTypes.OpenApi.Swashbuckle/Inlining/StrongTypeInliningDocumentFilter.cs @@ -5,15 +5,7 @@ namespace StrongTypes.OpenApi.Swashbuckle; -/// -/// Runs after every other filter so the -/// wire shape of and the numeric strong-type -/// wrappers is inlined at every property/parameter/items position and -/// their components disappear from components.schemas. The -/// caller's data-annotations (already attached to use sites by -/// ) are preserved through -/// the merge. -/// +/// Runs ; must run after every other filter so use-site annotations are already attached. public sealed class StrongTypeInliningDocumentFilter(ILogger? logger = null) : IDocumentFilter { public void Apply(OpenApiDocument document, DocumentFilterContext context) => StrongTypeInliner.Inline(document, logger); diff --git a/src/StrongTypes.OpenApi.Swashbuckle/Intervals/IntervalSchemaFilter.cs b/src/StrongTypes.OpenApi.Swashbuckle/Intervals/IntervalSchemaFilter.cs index cb107fa3..0dc61306 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/Intervals/IntervalSchemaFilter.cs +++ b/src/StrongTypes.OpenApi.Swashbuckle/Intervals/IntervalSchemaFilter.cs @@ -45,8 +45,6 @@ public void Apply(IOpenApiSchema schema, SchemaFilterContext context) // No schema default: which inclusivity an omitted flag implies is the applied converter's call, not the type's. private static OpenApiSchema BoundFlagSchema() => new() { Type = JsonSchemaType.Boolean }; - // An endpoint is required exactly when its type is the bare value type; an - // optional endpoint is Nullable, and the converter lets its key be omitted. private static HashSet RequiredEndpoints(Type startType, Type endType) { var required = new HashSet(StringComparer.Ordinal); diff --git a/src/StrongTypes.OpenApi.Swashbuckle/PropertyAnnotationSchemaFilter.cs b/src/StrongTypes.OpenApi.Swashbuckle/PropertyAnnotationSchemaFilter.cs index e12b7303..88d2a10f 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/PropertyAnnotationSchemaFilter.cs +++ b/src/StrongTypes.OpenApi.Swashbuckle/PropertyAnnotationSchemaFilter.cs @@ -8,32 +8,11 @@ namespace StrongTypes.OpenApi.Swashbuckle; /// -/// Re-applies caller-supplied data-annotations ([StringLength], -/// [Range], [RegularExpression], [Description], …) -/// to properties whose CLR type is a strong-type wrapper. Without this -/// filter, a property like [StringLength(50)] NonEmptyString Name -/// renders as a bare $ref to the NonEmptyString component -/// and silently drops the caller's maxLength: 50. We layer the -/// caller's bound onto the property position via allOf: -/// -/// "name": { -/// "allOf": [ { "$ref": "#/components/schemas/NonEmptyString" } ], -/// "maxLength": 50 -/// } -/// -/// The component schema ({ "type": "string", "minLength": 1 }) is -/// painted by and is not touched -/// here — only the property position is. After -/// collapses the allOf+ref, the -/// merged shape on the wire is { type: string, minLength: 1, maxLength: 50 }. -/// The wrapper-typed surface matches whatever Swashbuckle natively supports -/// for the equivalent primitive-typed property — no more, no less. -/// -/// The same property position is also where a nullable wrapper -/// (NonEmptyString?, Positive<int>?, …) gets its null -/// marker: Swashbuckle drops the member's nullability when it renders the -/// property as a bare $ref, so this filter records it on the use-site -/// wrapper for the inliner to carry onto the merged wire shape. +/// Re-applies caller-supplied data-annotations to properties whose CLR type is a strong-type +/// wrapper: such a property renders as a bare $ref, which silently drops the caller's +/// bounds, so they are layered onto the property position via allOf. The same wrapper +/// also records a nullable member's null bit, which Swashbuckle likewise drops from a bare +/// $ref. /// public sealed class PropertyAnnotationSchemaFilter : ISchemaFilter { @@ -55,11 +34,7 @@ public void Apply(IOpenApiSchema schema, SchemaFilterContext context) var isNullable = IsNullableMember(clrProperty, nullability); - // Swashbuckle renders a wrapper-typed property as a bare `$ref` to - // the wrapper component, which in OpenAPI 3.0 cannot carry the - // member's caller annotations *or* its nullability. Only act when - // there is something to layer on — a caller annotation or a `T?` - // member; otherwise leave the bare `$ref` for the inliner. + // Only act when there is something to layer on; otherwise leave the bare $ref for the inliner. OpenApiSchema? source = null; if (clrProperty.GetCustomAttributes(inherit: true).OfType().Any()) source = context.SchemaGenerator.GenerateSchema(surrogate, context.SchemaRepository, memberInfo: clrProperty) as OpenApiSchema; diff --git a/src/StrongTypes.OpenApi.Swashbuckle/Startup.cs b/src/StrongTypes.OpenApi.Swashbuckle/Startup.cs index d6860ccb..f9e4eded 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/Startup.cs +++ b/src/StrongTypes.OpenApi.Swashbuckle/Startup.cs @@ -18,7 +18,6 @@ public static class StrongTypesSwashbuckleExtensions /// the generated Swagger document as the JSON shape their converters /// actually produce. /// - /// The Swagger generator options being configured. public static SwaggerGenOptions AddStrongTypes(this SwaggerGenOptions options) { options.SchemaFilter(); diff --git a/src/StrongTypes.OpenApi.Swashbuckle/readme.md b/src/StrongTypes.OpenApi.Swashbuckle/readme.md index f5471e80..56c8ad28 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/readme.md +++ b/src/StrongTypes.OpenApi.Swashbuckle/readme.md @@ -34,6 +34,8 @@ app.UseSwaggerUI(); ## What it does - `NonEmptyString` → `{ "type": "string", "minLength": 1 }` +- `Email` → `{ "type": "string", "format": "email", "minLength": 1, "maxLength": 254 }` +- `Digit` → `{ "type": "integer", "format": "int32", "minimum": 0, "maximum": 9 }` - `Positive` → underlying primitive with `minimum: 0, exclusiveMinimum: true` - `NonNegative` → underlying primitive with `minimum: 0` - `Negative` → underlying primitive with `maximum: 0, exclusiveMaximum: true` diff --git a/src/StrongTypes.OpenApi.TestApi.Microsoft/Program.cs b/src/StrongTypes.OpenApi.TestApi.Microsoft/Program.cs index 50804fc2..0f233177 100644 --- a/src/StrongTypes.OpenApi.TestApi.Microsoft/Program.cs +++ b/src/StrongTypes.OpenApi.TestApi.Microsoft/Program.cs @@ -3,10 +3,8 @@ namespace StrongTypes.OpenApi.TestApi.Microsoft; -// Distinct entry-point class so this assembly does not emit a `Program` type -// into the global namespace — that would collide with the Swashbuckle test -// app's `Program` when both projects are referenced by the integration-test -// assembly. +// Named entry point (not top-level statements): a global `Program` would collide with the +// Swashbuckle test app's when the integration-test assembly references both. public class MicrosoftTestApiEntryPoint { public static void Main(string[] args) diff --git a/src/StrongTypes.OpenApi.TestApi.Shared/AnnotatedRequests.cs b/src/StrongTypes.OpenApi.TestApi.Shared/AnnotatedRequests.cs index b017472e..c85e3790 100644 --- a/src/StrongTypes.OpenApi.TestApi.Shared/AnnotatedRequests.cs +++ b/src/StrongTypes.OpenApi.TestApi.Shared/AnnotatedRequests.cs @@ -3,13 +3,8 @@ namespace StrongTypes.OpenApi.TestApi.Shared; -// Wire-level contract for the annotation-preservation tests. The generators -// translate these data-annotations into OpenAPI bounds; our filter must not -// wipe them when painting the strong-type wire shape. Each annotated wrapper -// property is paired with a sibling on the matching primitive type so the -// tests can pin the same wire keywords on both surfaces — confirming that -// the wrapper-typed surface mirrors what the underlying pipeline natively -// writes for a primitive-typed property. +// Each annotated wrapper property is paired with a primitive `*Raw` twin so the tests can pin +// the same wire keywords on both surfaces. public sealed record AnnotatedTextsRequest( [property: StringLength(50, MinimumLength = 3)] [property: RegularExpression("^[a-zA-Z0-9_]+$")] @@ -59,9 +54,6 @@ public sealed record AnnotatedTextsRequest( NonEmptyString Description, - // Email type with caller annotations — caller's [StringLength(100)] is - // tighter than the wrapper's own maxLength: 254, so the wire schema - // must reflect the caller's bound. [property: StringLength(100)] Email AnnotatedEmail, @@ -84,12 +76,8 @@ public sealed record AnnotatedTagsRequest( [property: MaxLength(10)] NonEmptyEnumerable Tags, [property: MaxLength(10)] string[] TagsRaw); -// Required-array parity contract. Each row pairs a strong-type variant -// with its primitive equivalent across the four "is this property -// required?" inputs the framework cares about: plain non-nullable, plain -// nullable, [Required], the C# `required` keyword. Tests assert each -// strong-type's `required` membership matches its primitive twin — -// whatever the underlying pipeline emits, the wrapper must mirror it. +// Pairs each required-ness variant with a primitive `*Raw` twin; tests assert the two have +// identical `required` membership. #pragma warning disable CS8618 // Non-nullable property is uninitialized — STJ assigns it. public sealed record RequiredVariantsRequest { diff --git a/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs b/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs index b56e8636..c0988dbc 100644 --- a/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs +++ b/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs @@ -128,12 +128,8 @@ public sealed class NestedStrongTypesController : ControllerBase } /// -/// Exercises non-body model-binding sources for strong-type parameters, so the -/// generated OpenAPI document can be asserted against. Mirrors the shape of -/// StrongTypes.Api.Controllers.BindingProbeController: each endpoint -/// accepts a required and a nullable variant of every wrapped type so the -/// schema tests cover both branches. [FromRoute] is required-only -/// because route segments are required by HTTP semantics. +/// Exercises non-body binding sources for strong-type parameters. [FromRoute] is +/// required-only because route segments are required by HTTP semantics. /// [ApiController] [Route("binding-probe")] @@ -169,14 +165,8 @@ public IActionResult FromHeader( [HttpPost("form")] public IActionResult FromForm([FromForm] BindingProbeFormRequest request) => Ok(); - // Annotated variants — pin that caller annotations attached at a non-body - // strong-type slot (parameter or form property) reach the wire schema. - - // Each annotated wrapper-typed parameter has a primitive-typed sibling - // carrying the same annotation. The sibling is the baseline: a pipeline - // that doesn't surface the annotation on the primitive obviously can't - // surface it on the wrapper either, so the wrapper tests only have - // teeth when the primitive sibling carries the keyword. + // Each annotated wrapper parameter has a primitive sibling with the same annotation: the + // wrapper assertion only has teeth when the pipeline surfaces the keyword on the primitive. [HttpGet("query-annotated")] public IActionResult FromQueryAnnotated( [FromQuery, StringLength(50)] NonEmptyString name, @@ -185,25 +175,13 @@ public IActionResult FromQueryAnnotated( [FromQuery, Range(5, 100)] int plainCount) => Ok(); - // Form bodies are split into two uniform requests — one all wrappers, - // one all primitives — to keep the per-pipeline form-body shape - // consistent. The `NonBodyStrongTypeOperationFilter` reshapes - // Swashbuckle's `{ allOf: [] }` form-body schema (emitted - // whenever every field is component-typed) back into a proper - // `{ properties: { … } }` map, so consumers see field names on both - // pipelines. + // Two uniform requests — all wrappers vs. all primitives — so each pins one of Swashbuckle's form-body shapes. [HttpPost("form-annotated")] public IActionResult FromFormAnnotated([FromForm] AnnotatedBindingProbeFormRequest request) => Ok(); [HttpPost("form-annotated-plain")] public IActionResult FromFormAnnotatedPlain([FromForm] PlainAnnotatedBindingProbeFormRequest request) => Ok(); - // The three endpoints below cover the form-body reshape on diverse - // payload shapes: a primitives-only form (Swashbuckle emits a proper - // properties map natively), an all-wrappers form with a mix of - // annotations on multiple wrappers of the same type, and a mixed form - // exercising both kinds together with caller annotations on each. - [HttpPost("form-simple-types")] public IActionResult FromFormSimpleTypes([FromForm] SimpleTypesFormRequest request) => Ok(); diff --git a/src/StrongTypes.OpenApi.TestApi.Swashbuckle/Program.cs b/src/StrongTypes.OpenApi.TestApi.Swashbuckle/Program.cs index e9452471..577d3299 100644 --- a/src/StrongTypes.OpenApi.TestApi.Swashbuckle/Program.cs +++ b/src/StrongTypes.OpenApi.TestApi.Swashbuckle/Program.cs @@ -3,9 +3,8 @@ namespace StrongTypes.OpenApi.TestApi.Swashbuckle; -// Distinct entry-point class so this assembly does not emit a `Program` type -// into the global namespace — that would collide with the Microsoft test app's -// `Program` when both projects are referenced by the integration-test assembly. +// Named entry point (not top-level statements): a global `Program` would collide with the +// Microsoft test app's when the integration-test assembly references both. public class SwashbuckleTestApiEntryPoint { public static void Main(string[] args) diff --git a/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs b/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs index 68f9cd4a..dd0eff74 100644 --- a/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs +++ b/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs @@ -332,9 +332,7 @@ public static string EmitExtensions(Model m) private static void EmitUnwrap(StringBuilder sb, string self, string underlying, string methodTypeParams, string methodConstraints) { - // Marker method for EF Core translation: the translator rewrites this - // call to access the underlying column directly. At runtime it just - // returns Value, but the point is to make the LINQ intent translatable. + // Marker for the EF Core translator, which rewrites this call to the underlying column. sb.Append(" public static ").Append(underlying).Append(" Unwrap").Append(methodTypeParams) .Append("(this ").Append(self).Append(" value)").Append(methodConstraints).AppendLine(" => value.Value;"); } diff --git a/src/StrongTypes.Tests/Collections/NonEmptyEnumerableExtensionsTests.cs b/src/StrongTypes.Tests/Collections/NonEmptyEnumerableExtensionsTests.cs index 0ed29611..2be53efd 100644 --- a/src/StrongTypes.Tests/Collections/NonEmptyEnumerableExtensionsTests.cs +++ b/src/StrongTypes.Tests/Collections/NonEmptyEnumerableExtensionsTests.cs @@ -61,7 +61,6 @@ public void Select_WithIndex_SeesZeroBasedIndex(NonEmptyEnumerable list) [Property] public void Select_InterfaceAndConcreteOverloads_Agree(NonEmptyEnumerable list) { - // Concrete overload vs. interface overload with a concrete backing — same result. INonEmptyEnumerable asInterface = list; Assert.Equal(list.Select(i => i * 2), asInterface.Select(i => i * 2)); Assert.Equal(list.Select((x, i) => x + i), asInterface.Select((x, i) => x + i)); @@ -70,8 +69,6 @@ public void Select_InterfaceAndConcreteOverloads_Agree(NonEmptyEnumerable l [Fact] public void Select_InterfaceOverload_HandlesNonConcreteImplementation() { - // A hand-rolled INonEmptyEnumerable forces the interface overload's fallback - // path (no NonEmptyEnumerable to dispatch to). INonEmptyEnumerable custom = new CustomNonEmpty([10, 20, 30]); Assert.Equal(new[] { 11, 21, 31 }, custom.Select(i => i + 1)); Assert.Equal(new[] { 10, 21, 32 }, custom.Select((x, i) => x + i)); @@ -86,8 +83,6 @@ public void Distinct_MatchesLinqDistinct(NonEmptyEnumerable list) [Property] public void SelectMany_EmitsAtLeastHeadSequence(NonEmptyEnumerable list) { - // Each input element produces a 2-element non-empty sequence — the - // flattened length is therefore exactly 2 * input length. var flattened = list.SelectMany(i => NonEmptyEnumerable.Create(i, i)); Assert.Equal(list.Count * 2, flattened.Count); } @@ -147,8 +142,6 @@ public void Flatten_MultipleInners_ConcatsInOrder() [Property] public void Flatten_MatchesLinqSelectMany(NonEmptyEnumerable list) { - // Wrap every element in a singleton non-empty list and flatten back — - // should round-trip the original elements exactly. var nested = list.Select(x => NonEmptyEnumerable.Create(x)); Assert.Equal(list, nested.Flatten()); } @@ -156,7 +149,6 @@ public void Flatten_MatchesLinqSelectMany(NonEmptyEnumerable list) [Property] public void Flatten_LengthEqualsSumOfInnerCounts(NonEmptyEnumerable list) { - // Duplicate each element into a pair — flat length is exactly 2x input length. var nested = list.Select(x => NonEmptyEnumerable.Create(x, x)); var flat = nested.Flatten(); Assert.Equal(list.Count * 2, flat.Count); @@ -196,15 +188,13 @@ public void Concat_NullTail_Throws() [Fact] public void Concat_MixedTailTypes_AllWork() { - // Arrays, lists, NonEmptyEnumerable, and other ICollection-shaped sources - // are all valid tails — verify the common combinations end-to-end. IEnumerable array = new[] { 2, 3 }; IEnumerable list = new List { 4, 5 }; IEnumerable nonEmpty = NonEmptyEnumerable.Create(6, 7); IEnumerable hashSet = new HashSet { 8, 9 }; var result = 1.Concat(array, list, nonEmpty, hashSet); - // HashSet ordering isn't guaranteed — sort the last-two slice for a stable assert. + // HashSet ordering isn't guaranteed — hence the OrderBy. Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7 }, result.Take(7)); Assert.Equal(new[] { 8, 9 }, result.Skip(7).OrderBy(x => x)); } @@ -212,8 +202,6 @@ public void Concat_MixedTailTypes_AllWork() [Fact] public void Concat_LazyIteratorTail_Works() { - // Enumerable.Where is a LINQ iterator with no pre-computable count — - // still expands correctly into the output. IEnumerable lazy = new[] { 10, 20, 30 }.Where(_ => true); var result = 1.Concat(lazy); Assert.Equal(new[] { 1, 10, 20, 30 }, result); @@ -383,11 +371,7 @@ public void Aggregate_MatchesLinq(NonEmptyEnumerable list) [Property] public void Average_MatchesLinq(NonEmptyEnumerable list) { - // Non-empty guarantees Average is defined — implementation uses INumber - // which does integer division for int, so compare against the same arithmetic. - // Skip inputs whose sum would overflow int: Average throws checked, but the - // comparison baseline `Sum()` also throws, so the generator already shrinks - // around that. Use long for the expected to sidestep it cleanly. + // Int Average does integer division and throws checked on overflow — hence the integer baseline and the skip. long expected = 0; foreach (var v in list) expected += v; if (expected > int.MaxValue || expected < int.MinValue) return; @@ -404,8 +388,7 @@ public void Average_Overflow_Throws() [Fact] public void Average_Double_DoesNotThrow() { - // Floating-point has no overflow concept — `checked` is a no-op, Infinity - // is the defined answer. Guard the behavior so nobody "fixes" this later. + // Floating-point has no overflow: `checked` is a no-op and Infinity is the defined answer. var list = NonEmptyEnumerable.Create(double.MaxValue, double.MaxValue); Assert.Equal(double.PositiveInfinity, list.Average()); } @@ -421,11 +404,7 @@ public void Aggregation_InterfaceOverload_HandlesNonConcreteImplementation() Assert.Equal(3, custom.Average()); } - /// - /// Minimal implementation backed by an array — - /// lets the interface-overload tests force the fallback path that doesn't dispatch to - /// . - /// + /// Forces the interface overloads onto their fallback path — no to dispatch to. private sealed class CustomNonEmpty(T[] items) : INonEmptyEnumerable { public T this[int index] => items[index]; diff --git a/src/StrongTypes.Tests/Collections/NonEmptyEnumerableJsonConverterTests.cs b/src/StrongTypes.Tests/Collections/NonEmptyEnumerableJsonConverterTests.cs index c9d89d3b..ea799e2a 100644 --- a/src/StrongTypes.Tests/Collections/NonEmptyEnumerableJsonConverterTests.cs +++ b/src/StrongTypes.Tests/Collections/NonEmptyEnumerableJsonConverterTests.cs @@ -44,10 +44,7 @@ public void Read_NonArray_Throws() [Fact] public void Read_NullElement_OfNullableReferenceType_IsKept() { - // The type's invariant is Count >= 1, not "no null elements". For reference T, - // `NonEmptyEnumerable` and `NonEmptyEnumerable` erase to the same - // runtime type, so the converter can't enforce non-null without also breaking - // the legitimate nullable case — and the factories already allow nulls through. + // The invariant is Count >= 1, not element content — and erase identically, so nulls cannot be rejected. var list = JsonSerializer.Deserialize>("""["a",null,"c"]"""); Assert.NotNull(list); Assert.Equal(new[] { "a", null, "c" }, list); @@ -56,8 +53,6 @@ public void Read_NullElement_OfNullableReferenceType_IsKept() [Fact] public void Read_SingleNullElement_IsValid() { - // `[null]` has one element, which satisfies Count >= 1. The element happens to be - // null — that's a content concern, not a non-empty concern. var list = JsonSerializer.Deserialize>("[null]"); Assert.NotNull(list); Assert.Single(list); @@ -67,9 +62,6 @@ public void Read_SingleNullElement_IsValid() [Fact] public void Read_NullableValueType_AllowsNulls() { - // int? is literally the nullable-int type — null is a valid value. Rejecting it - // would make `NonEmptyEnumerable` unusable for any wire format that encodes - // a "known absence" as JSON null. var list = JsonSerializer.Deserialize>("[1,null,3]"); Assert.NotNull(list); Assert.Equal(new int?[] { 1, null, 3 }, list); @@ -98,9 +90,6 @@ public void Read_NonArrayToken_Throws(string json) [Fact] public void Read_OfPositiveInt_InvalidInner_Throws() { - // The inner Positive converter rejects non-positive numbers — its JsonException - // must bubble through the outer array converter so an invalid element can never - // sneak into a NonEmptyEnumerable>. Assert.Throws(() => JsonSerializer.Deserialize>>("[1,-5,3]")); @@ -132,13 +121,6 @@ public void Write_Null_EmitsJsonNull() } // ── Roundtrip ─────────────────────────────────────────────────────── - // - // Round-trip tests are string-anchored: we start from a canonical JSON string, - // deserialize it, re-serialize, and assert the bytes are identical. That proves - // the wire contract survives a pass through the converter in both directions and - // that serialization is idempotent. Specific hand-written examples document the - // canonical form; the property tests derive the canonical form from a generated - // value and then assert the same string-stability property. [Theory] [InlineData("[1]")] @@ -162,8 +144,6 @@ public void Roundtrip_NonEmptyString_FromCanonicalJson(string json) [Property] public void Roundtrip_Int(NonEmptyEnumerable list) { - // The generated value supplies a canonical JSON string via the first serialize; - // deserializing and re-serializing must reproduce that string unchanged. var canonical = JsonSerializer.Serialize(list); var back = JsonSerializer.Deserialize>(canonical); Assert.Equal(canonical, JsonSerializer.Serialize(back)); @@ -193,8 +173,6 @@ public void Read_OfNonEmptyString_UsesInnerConverter() [Fact] public void Read_OfNonEmptyString_InvalidInner_Throws() { - // The inner NonEmptyString converter rejects whitespace, and its JsonException - // must propagate through the outer array converter. Assert.Throws(() => JsonSerializer.Deserialize>("""["a"," "]""")); } @@ -209,9 +187,7 @@ public void Write_OfNonEmptyString_UsesInnerConverter() } // ── Interface (INonEmptyEnumerable) ────────────────────────────── - // STJ matches converters by exact declared type, so the factory has to register against - // both the concrete class and the interface. Before interface support, deserializing - // INonEmptyEnumerable threw NotSupportedException ("collection type is abstract"). + // STJ matches converters by the exact declared type, so the interface needs its own registration. [Fact] public void Interface_Read_Array_DeserializesToNonEmpty() diff --git a/src/StrongTypes.Tests/Collections/NonEmptyEnumerableTests.cs b/src/StrongTypes.Tests/Collections/NonEmptyEnumerableTests.cs index bb4ec112..71d41356 100644 --- a/src/StrongTypes.Tests/Collections/NonEmptyEnumerableTests.cs +++ b/src/StrongTypes.Tests/Collections/NonEmptyEnumerableTests.cs @@ -29,8 +29,6 @@ public void Create_MultipleElements() [Fact] public void CreateRange_HeadPrependedToTailSequence() { - // Covers the former Of(T head, IEnumerable tail) shape. Collection expressions - // also express this naturally: `NonEmptyEnumerable list = [1, .. tail];`. IEnumerable tail = Enumerable.Range(2, 3); var list = NonEmptyEnumerable.CreateRange(tail.Prepend(1)); Assert.Equal(new[] { 1, 2, 3, 4 }, list); @@ -73,7 +71,6 @@ public void Create_EmptySequence_Throws() [Fact] public void TryCreate_ReturnsSameInstance_ForExistingNonEmpty() { - // Idempotent wrap: re-wrapping an already non-empty enumerable doesn't allocate. var original = NonEmptyEnumerable.Create(1, 2, 3); var wrapped = NonEmptyEnumerable.TryCreateRange((IEnumerable)original); Assert.Same(original, wrapped); @@ -82,8 +79,6 @@ public void TryCreate_ReturnsSameInstance_ForExistingNonEmpty() [Fact] public void TryCreate_ReadOnlyListOnly_UsesIndexerCopy() { - // Exercises the IReadOnlyList branch — a source that implements IReadOnlyList - // but not ICollection, so LINQ's ToArray fast path wouldn't catch it. var source = new ReadOnlyListOnly([10, 20, 30]); var list = NonEmptyEnumerable.TryCreateRange(source); Assert.NotNull(list); @@ -97,11 +92,7 @@ public void TryCreate_EmptyReadOnlyListOnly_ReturnsNull() Assert.Null(NonEmptyEnumerable.TryCreateRange(empty)); } - /// - /// Minimal that deliberately does not implement - /// , so the TryCreateRange indexer-copy branch is - /// the only path that can handle it without falling back to LINQ's dynamic-growth loop. - /// + /// Deliberately not , forcing TryCreateRange onto its indexer-copy branch. private sealed class ReadOnlyListOnly(T[] items) : IReadOnlyList { public T this[int index] => items[index]; @@ -185,8 +176,7 @@ public void CollectionExpression_Spread_BuildsNonEmpty() [Fact] public void CollectionExpression_Empty_Throws() { - // The compiler has no way to statically reject `[]` for a non-empty type, so the - // invariant is enforced at runtime — an empty collection expression throws. + // The compiler cannot statically reject `[]`, so the invariant is enforced at runtime. Assert.Throws(() => { NonEmptyEnumerable list = []; @@ -199,8 +189,6 @@ public void CollectionExpression_Empty_Throws() [Fact] public void Create_CopiesInputArray_SoMutationDoesNotLeak() { - // If the factory kept the input buffer, later edits would mutate the list — - // the whole point of a read-only list is that callers can't do that. var source = new[] { 1, 2, 3 }; var list = NonEmptyEnumerable.Create(source); source[0] = 999; diff --git a/src/StrongTypes.Tests/ComponentModel/TypeConverterTests.cs b/src/StrongTypes.Tests/ComponentModel/TypeConverterTests.cs index 4dc2976c..83daac31 100644 --- a/src/StrongTypes.Tests/ComponentModel/TypeConverterTests.cs +++ b/src/StrongTypes.Tests/ComponentModel/TypeConverterTests.cs @@ -63,7 +63,6 @@ public void Converter_MalformedNumber_ThrowsFormatException() => // ── Culture ───────────────────────────────────────────────────────── - /// The converter parses in the culture it is handed, so a decimal comma is a decimal point's equal. [Fact] public void Converter_ParsesInTheSuppliedCulture() { diff --git a/src/StrongTypes.Tests/Emails/EmailTests.cs b/src/StrongTypes.Tests/Emails/EmailTests.cs index d70f65b7..66968e18 100644 --- a/src/StrongTypes.Tests/Emails/EmailTests.cs +++ b/src/StrongTypes.Tests/Emails/EmailTests.cs @@ -9,10 +9,7 @@ namespace StrongTypes.Tests; public class EmailTests { // ── Validation contract — every Email entry point agrees ────────────── - // Email.Create / Email.TryCreate / string.ToEmail / string.AsEmail are - // four faces of the same validation rule. Asserting all four per case - // keeps them lock-stepped and stops a wrapper from drifting away from - // the canonical contract. + // Every case asserts all four entry points so a wrapper cannot drift from the canonical rule. [Theory] [InlineData(null)] @@ -33,14 +30,12 @@ public void Invalid_AllEntryPointsReject(string? input) [Fact] public void MaxLength_BoundaryAcceptedJustOverRejected() { - // Local-part of 244 chars + "@x.com" (6 chars) = 250 chars, valid. var ok = new string('a', Email.MaxLength - 6) + "@x.com"; Assert.NotNull(Email.TryCreate(ok)); Assert.NotNull(ok.AsEmail()); Assert.Equal(ok, Email.Create(ok).Address); Assert.Equal(ok, ok.ToEmail().Address); - // One past the cap. var tooLong = new string('a', Email.MaxLength) + "@x.com"; Assert.Null(Email.TryCreate(tooLong)); Assert.Null(tooLong.AsEmail()); @@ -60,9 +55,7 @@ public void Valid_AllEntryPointsWrapAddress(Email seed) } // ── MailAddress.Create / TryCreate (extension block) ────────────────── - // BCL shims, deliberately distinct from the Email entry points: they - // throw FormatException and do NOT enforce Email.MaxLength. Callers - // that want the cap go through Email.Create or string.ToEmail. + // Deliberately distinct from the Email entry points: FormatException, and no Email.MaxLength cap. [Fact] public void MailAddressCreate_Valid_WrapsAddress() => @@ -253,8 +246,7 @@ public void Equals_String_Null_False(Email email) => Assert.False(email.Equals((string?)null)); // ── Comparison ──────────────────────────────────────────────────────── - // Ordinal-ignore-case on the address, matching Equals so CompareTo == 0 - // agrees with equality. + // Ordinal-ignore-case, matching Equals so CompareTo == 0 agrees with equality. [Property] public void CompareTo_SameAddress_Zero(Email email) => diff --git a/src/StrongTypes.Tests/Intervals/IntervalGeneratorCoverageTests.cs b/src/StrongTypes.Tests/Intervals/IntervalGeneratorCoverageTests.cs index 3213cbb2..52df6e35 100644 --- a/src/StrongTypes.Tests/Intervals/IntervalGeneratorCoverageTests.cs +++ b/src/StrongTypes.Tests/Intervals/IntervalGeneratorCoverageTests.cs @@ -3,9 +3,7 @@ namespace StrongTypes.Tests; -// Guards the weighted Gen.Frequency branches in the interval arbitraries: a -// regression that stopped emitting (say) the unbounded case would silently -// shrink coverage of every property test that consumes these generators. +// A generator branch that stops emitting would silently shrink every consuming property test's coverage. public class IntervalGeneratorCoverageTests { [Fact] diff --git a/src/StrongTypes.Tests/Intervals/IntervalJsonConverterTests.cs b/src/StrongTypes.Tests/Intervals/IntervalJsonConverterTests.cs index 30dd80dd..8f397d43 100644 --- a/src/StrongTypes.Tests/Intervals/IntervalJsonConverterTests.cs +++ b/src/StrongTypes.Tests/Intervals/IntervalJsonConverterTests.cs @@ -145,12 +145,10 @@ public void NamingPolicy_IsHonoured() [Fact] public void Read_OmittedRequiredEndpoint_Throws() { - // FiniteInterval requires both endpoints. Assert.Throws(() => JsonSerializer.Deserialize>("""{"Start":1}""")); Assert.Throws(() => JsonSerializer.Deserialize>("""{"End":1}""")); - // IntervalFrom requires Start; IntervalUntil requires End. Assert.Throws(() => JsonSerializer.Deserialize>("""{"End":10}""")); Assert.Throws(() => @@ -160,17 +158,14 @@ public void Read_OmittedRequiredEndpoint_Throws() [Fact] public void Read_OmittedOptionalEndpoint_DefaultsToNull() { - // Both endpoints optional: an empty object is the unbounded interval. var open = JsonSerializer.Deserialize>("{}"); Assert.Null(open.Start); Assert.Null(open.End); - // Omitting the optional upper bound is open-ended. var from = JsonSerializer.Deserialize>("""{"Start":1}"""); Assert.Equal(1, from.Start); Assert.Null(from.End); - // Omitting the optional lower bound is open-started. var until = JsonSerializer.Deserialize>("""{"End":10}"""); Assert.Null(until.Start); Assert.Equal(10, until.End); @@ -227,9 +222,7 @@ public void InvariantViolation_Message_IsHumanReadable() Assert.DoesNotContain("`", ex.Message); } - // A type mismatch in an endpoint is rethrown path-less (inner exception - // preserved) so System.Text.Json can reattach the property path — the fix - // that keeps the API error key at "$.value" rather than the document root. + // An endpoint type mismatch is rethrown path-less, inner preserved, so System.Text.Json can reattach the property path. [Fact] public void EndpointTypeMismatch_ThrowsJsonExceptionPreservingInner() { diff --git a/src/StrongTypes.Tests/Maybe/MaybeDtoJsonTests.cs b/src/StrongTypes.Tests/Maybe/MaybeDtoJsonTests.cs index e1520176..b24ebd62 100644 --- a/src/StrongTypes.Tests/Maybe/MaybeDtoJsonTests.cs +++ b/src/StrongTypes.Tests/Maybe/MaybeDtoJsonTests.cs @@ -3,13 +3,7 @@ namespace StrongTypes.Tests; -/// -/// Round-trip coverage for DTOs whose properties are -/// and ? — the realistic request-body shape. Each case -/// asserts both directions against a single canonical JSON string: reading -/// the string produces the expected DTO, and serializing the DTO produces -/// the same string back. -/// +/// DTOs whose properties are and ? — the realistic request-body shape. public class MaybeDtoJsonTests { private sealed record IntDto(Maybe Value, Maybe? NullableValue); diff --git a/src/StrongTypes.Tests/Maybe/MaybeExtensionsTests.cs b/src/StrongTypes.Tests/Maybe/MaybeExtensionsTests.cs index bc773ee8..07eff9d5 100644 --- a/src/StrongTypes.Tests/Maybe/MaybeExtensionsTests.cs +++ b/src/StrongTypes.Tests/Maybe/MaybeExtensionsTests.cs @@ -88,8 +88,7 @@ public async Task FlatMapAsync_Some_AwaitsAndBinds() [Fact] public async Task Match_WithAsyncLambdas_Some() { - // Match with R = Task covers what MatchAsync used to do — the - // async lambdas just return Task, Match returns Task, await unwraps. + // Match with R = Task is why no dedicated MatchAsync exists. var result = await Maybe.Some(5).Match( async x => { await Task.Yield(); return x + 1; }, async () => { await Task.Yield(); return -1; }); diff --git a/src/StrongTypes.Tests/Maybe/MaybeJsonConverterTests.cs b/src/StrongTypes.Tests/Maybe/MaybeJsonConverterTests.cs index 026b780f..67fcc968 100644 --- a/src/StrongTypes.Tests/Maybe/MaybeJsonConverterTests.cs +++ b/src/StrongTypes.Tests/Maybe/MaybeJsonConverterTests.cs @@ -84,12 +84,6 @@ public void Write_ReferenceType_Some() } // ── Roundtrip ─────────────────────────────────────────────────────── - // - // Round-trip tests are string-anchored: start from a canonical JSON string, - // deserialize, re-serialize, and assert the bytes are identical. Specific - // hand-written examples document the canonical form; the property tests derive - // the canonical form from a generated value and then assert the same - // string-stability property. [Theory] [InlineData("""{"Value":42}""")] @@ -147,8 +141,7 @@ public void Write_MaybeOfNonEmptyString_UsesItsConverter() [Fact] public void Read_MaybeOfNonEmptyString_InvalidValue_Throws() { - // The inner NonEmptyString converter rejects whitespace/empty, and that - // JsonException must surface — we don't want a silent fallback to None. + // An invalid inner value must throw, not silently fall back to None. Assert.Throws(() => JsonSerializer.Deserialize>("""{"Value":" "}""")); } @@ -178,8 +171,6 @@ public void Write_MaybeOfPositiveInt_Some() [Fact] public void Read_MaybeOfPositiveInt_InvalidValue_Throws() { - // Zero violates the Positive invariant — the inner converter throws - // and that exception propagates through the Maybe converter. Assert.Throws(() => JsonSerializer.Deserialize>>("""{"Value":0}""")); diff --git a/src/StrongTypes.Tests/Maybe/MaybeTests.cs b/src/StrongTypes.Tests/Maybe/MaybeTests.cs index 95bedaef..22bc4f0d 100644 --- a/src/StrongTypes.Tests/Maybe/MaybeTests.cs +++ b/src/StrongTypes.Tests/Maybe/MaybeTests.cs @@ -89,11 +89,6 @@ public void IsPattern_EmptyReferenceType_DoesNotMatch() } // ── Value-null-iff-None invariant (one per extension class) ───────── - // - // These property tests pin down the single most important guarantee of - // the extension-property design: Value is null exactly when the Maybe - // is None, non-null exactly when it is Some. Covers both extension - // classes (struct T and class T). [Property] public void MaybeStruct_Value_NullIffNone(Maybe m) @@ -241,8 +236,7 @@ public void Foreach_Empty_IteratesZeroTimes() [Property] public void Equality_Reflexive(Maybe m) { - // Not "m == m" — CS1718 warns on self-comparison via operator. The - // Equals/Assert.Equal paths go through IEquatable.Equals directly. + // Not "m == m" — CS1718 warns on operator self-comparison. Assert.True(m.Equals(m)); Assert.Equal(m, m); } @@ -281,12 +275,7 @@ public void Equality_HashCodesMatch_WhenEqual() Assert.Equal(Maybe.None.GetHashCode(), Maybe.None.GetHashCode()); } - // Cross-closed-generic pairs (e.g. Maybe vs Maybe) - // always compare unequal through Equals(object). Keeping Equals(object) strict - // preserves its symmetry contract — we can't teach bare `string` or bare `int` - // to see a Maybe on the other side, so we don't pretend the relationship - // exists in one direction. Typed IEquatable and operator == remain available - // for ergonomic same-T comparisons. + // Equals(object) stays strict to preserve symmetry — bare string/int can never see a Maybe on the other side. [Fact] public void Equality_DifferentClosedGenerics_AlwaysUnequal() { diff --git a/src/StrongTypes.Tests/Numbers/NegativeExtensionsTests.cs b/src/StrongTypes.Tests/Numbers/NegativeExtensionsTests.cs index 79ef96eb..08df3ed4 100644 --- a/src/StrongTypes.Tests/Numbers/NegativeExtensionsTests.cs +++ b/src/StrongTypes.Tests/Numbers/NegativeExtensionsTests.cs @@ -16,8 +16,7 @@ public void Sum_MatchesUnderlyingSum_OrThrowsOnOverflow(Negative[] values) { if (values.Length == 0) return; // empty is covered by a dedicated fact - // Widen to long so we can detect overflow without repeating the - // implementation's checked arithmetic. + // Widen to long to detect overflow without repeating the implementation's checked arithmetic. long expected = values.Sum(n => (long)n.Value); if (expected < int.MinValue) { diff --git a/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs index def69c3c..e887bc6b 100644 --- a/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs +++ b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs @@ -41,7 +41,6 @@ public void ToString_MatchesTheUnderlyingValue(string? format, string expected) Assert.Equal(price.Value.ToString(format, American), price.ToString(format, American)); } - /// The wrapper must never disagree with the value it wraps, for any format the underlying type accepts. [Property] public void ToString_NeverDivergesFromTheUnderlyingValue(int value) { diff --git a/src/StrongTypes.Tests/Numbers/NumericStrongTypeJsonConverterTests.cs b/src/StrongTypes.Tests/Numbers/NumericStrongTypeJsonConverterTests.cs index c608b0a8..5213cfdb 100644 --- a/src/StrongTypes.Tests/Numbers/NumericStrongTypeJsonConverterTests.cs +++ b/src/StrongTypes.Tests/Numbers/NumericStrongTypeJsonConverterTests.cs @@ -17,9 +17,7 @@ public void Deserialize_InvalidValue_ThrowsJsonException(string json) Assert.Throws(() => JsonSerializer.Deserialize>(json)); } - // Every invalid kind must surface under the property's path ("$.Value"), not - // the document root — the nested Deserialize would otherwise lose position - // on type-mismatch/null. Locks the converter rethrow that keeps the path. + // Guards the converter's path-preserving rethrow — a nested Deserialize would otherwise report the document root. [Theory] [InlineData("""{"Value":"abc"}""")] [InlineData("""{"Value":null}""")] diff --git a/src/StrongTypes.Tests/Result/ResultAccessTests.cs b/src/StrongTypes.Tests/Result/ResultAccessTests.cs index 3f6f60c3..efad0aeb 100644 --- a/src/StrongTypes.Tests/Result/ResultAccessTests.cs +++ b/src/StrongTypes.Tests/Result/ResultAccessTests.cs @@ -8,11 +8,7 @@ namespace StrongTypes.Tests; public class ResultAccessTests { // ── Branch accessors across the struct/class matrix ──────────────── - // - // Four tests cover each combination of struct/class in the T and - // TError slots, so every closed instantiation of the extension - // classes (ResultSuccess{Struct,Class}Extensions / ResultError…) is - // exercised. + // One test per struct/class combination in the T and TError slots — each hits a different extension class. [Property] public void SuccessAccess_ValueType_ReturnsValue(int value) @@ -55,12 +51,6 @@ public void ErrorAccess_ValueType_ReturnsValue(int error) } // ── Null-iff-opposite-branch invariants (one per extension class) ─── - // - // Each property test covers one of the four extension classes — - // ResultSuccess{Struct,Class}Extensions and ResultError{Struct,Class}Extensions - // — and asserts Value is non-null exactly when its branch is active, null - // exactly when the other branch is. Done as property tests so branch - // coverage isn't hostage to one hand-picked value. [Property] public void SuccessAccess_StructT_NonNullOnSuccess_NullOnError(int successValue, string errorValue) @@ -131,16 +121,10 @@ public void ErrorAccess_ClassError_NonNullOnError_NullOnSuccess(int successValue } // ── Invariants over an arbitrary Result ────────────── - // - // These exercise the generator directly: the test takes whatever - // Result the arbitrary produces and asserts the invariants that - // hold on every instance regardless of which branch it landed on. [Property] public void BranchInvariants_HoldOnArbitraryResult(Result r) { - // Every Result is in exactly one branch; the four accessors - // agree on which branch that is. Assert.NotEqual(r.IsSuccess, r.IsError); Assert.Equal(r.IsSuccess, r.Success.HasValue); Assert.Equal(r.IsError, r.Error is not null); diff --git a/src/StrongTypes.Tests/Result/ResultAggregateTests.cs b/src/StrongTypes.Tests/Result/ResultAggregateTests.cs index e30832e2..0f1c64e9 100644 --- a/src/StrongTypes.Tests/Result/ResultAggregateTests.cs +++ b/src/StrongTypes.Tests/Result/ResultAggregateTests.cs @@ -427,8 +427,7 @@ public void AggregateEnumerable_AllSuccess_ReturnsValuesInOrder(int[] values) [Property] public void AggregateEnumerable_AllErrors_ReturnsErrorsInOrder(string[] errors) { - // FsCheck can generate null entries inside the array; filter since our - // TError : notnull constraint rejects those. + // FsCheck can generate null entries; TError : notnull rejects those. var clean = errors.Where(e => e is not null).ToArray(); var results = clean.Select(e => (Result)e); diff --git a/src/StrongTypes.Tests/Result/ResultCatchTests.cs b/src/StrongTypes.Tests/Result/ResultCatchTests.cs index df9c85d9..9b03ce36 100644 --- a/src/StrongTypes.Tests/Result/ResultCatchTests.cs +++ b/src/StrongTypes.Tests/Result/ResultCatchTests.cs @@ -47,8 +47,7 @@ public void Catch_OperationCanceledException_CapturedWhenPropagateFalse() [Fact] public void Catch_TaskCanceledException_PropagatesByDefault() { - // TaskCanceledException derives from OperationCanceledException, so - // the same propagation rule covers it. + // TaskCanceledException derives from OperationCanceledException, so the same propagation rule covers it. Assert.Throws(() => Result.Catch(() => throw new TaskCanceledException())); } @@ -75,8 +74,6 @@ public void CatchTyped_MatchingException_IsCaptured() [Fact] public void CatchTyped_ReturnsNarrowedResultType() { - // Return type flows the chosen TException through — caller gets - // Result, not the base Result. Result r = Result.Catch(() => 1); Assert.True(r.IsSuccess); @@ -92,8 +89,7 @@ public void CatchTyped_NonMatchingException_Propagates() [Fact] public void CatchTyped_WithNonOceType_LetsOceEscape() { - // Double-guarded: OCE doesn't match InvalidOperationException anyway, - // and the default propagateCancellation=true would rethrow it first. + // Double-guarded: OCE doesn't match InvalidOperationException, and propagateCancellation=true would rethrow it first anyway. Assert.Throws(() => Result.Catch(() => throw new OperationCanceledException())); } @@ -101,8 +97,7 @@ public void CatchTyped_WithNonOceType_LetsOceEscape() [Fact] public void CatchTyped_WithExceptionType_PropagatesOceByDefault() { - // TException = Exception would normally catch OCE, but - // propagateCancellation=true rethrows it first. + // TException = Exception would normally catch OCE, but propagateCancellation=true rethrows it first. Assert.Throws(() => Result.Catch(() => throw new OperationCanceledException())); } diff --git a/src/StrongTypes.Tests/Result/ResultFlatMapTests.cs b/src/StrongTypes.Tests/Result/ResultFlatMapTests.cs index bea1696f..0b223a85 100644 --- a/src/StrongTypes.Tests/Result/ResultFlatMapTests.cs +++ b/src/StrongTypes.Tests/Result/ResultFlatMapTests.cs @@ -95,9 +95,7 @@ public async Task FlatMapAsync_Error_ShortCircuits(string error) public async Task FlatMapAsync_OnResultOfT_ReturnsResultOfT() { Result r = 5; - // Explicit because Task is invariant, so the lambda's - // `Task>` can't be inferred against the shadow's - // `Task>` parameter without help. + // Explicit : Task is invariant, so the lambda's return can't be inferred against the shadow overload. var bound = await r.FlatMapAsync(async x => { await Task.Yield(); diff --git a/src/StrongTypes.Tests/Result/ResultFromNullableTests.cs b/src/StrongTypes.Tests/Result/ResultFromNullableTests.cs index b478d666..f132dbed 100644 --- a/src/StrongTypes.Tests/Result/ResultFromNullableTests.cs +++ b/src/StrongTypes.Tests/Result/ResultFromNullableTests.cs @@ -78,11 +78,7 @@ public void ToResult_StructType_Null_WithCustomErrorType() } // ── The ambiguity concern: Func returning Exception ──────────────── - // - // Both overloads could match — the non-generic `Func` form - // and the generic `Func` form with TError=Exception. The - // former is more specific on the parameter type, so overload resolution - // picks it and the return is Result, not Result. + // Func matches both overloads; the non-generic form is more specific, so it wins and returns Result. [Fact] public void ToResult_RefType_FuncReturningException_PicksSingleParamOverload() @@ -103,10 +99,7 @@ public void ToResult_StructType_FuncReturningException_PicksSingleParamOverload( [Fact] public void ToResult_RefType_FuncReturningExceptionSubclass_PicksTwoParamOverload() { - // Documented overload-resolution behaviour: Func - // is a more specific parameter type than Func, so the two-param - // overload (E = InvalidOperationException) wins. See the XML doc on - // ResultFromNullableExtensions.ToResult for the workaround. + // A subclass lambda is more specific than Func, so the two-param overload wins — see the ToResult XML doc for the workaround. string? val = null; var r = val.ToResult(() => new InvalidOperationException("x")); Assert.IsType>(r); @@ -115,10 +108,7 @@ public void ToResult_RefType_FuncReturningExceptionSubclass_PicksTwoParamOverloa [Fact] public void ToResult_RefType_ExceptionCast_PicksSingleParamOverload() { - // Workaround: casting the lambda result to Exception makes the lambda's - // inferred return type Exception, so V1 (Func) and V2 - // (Func with E=Exception) have identical parameter types and the - // non-generic V1 is preferred. + // The workaround: the cast makes the lambda's inferred type Exception, so the non-generic overload is preferred. string? val = null; var r = val.ToResult(() => (Exception)new InvalidOperationException("x")); Assert.IsType>(r); @@ -183,9 +173,7 @@ public void ToResult_StructType_EagerEnum_HasValue_Succeeds(int value) [Fact] public void ToResult_RefType_EagerPicksTwoParamForSubclassException() { - // Same overload-resolution wart as the lazy form: an eager - // InvalidOperationException argument binds to the two-param overload - // (more-specific parameter type wins) and yields Result. + // Same overload-resolution wart as the lazy form: the more specific parameter type wins. string? val = null; var r = val.ToResult(new InvalidOperationException("x")); Assert.IsType>(r); diff --git a/src/StrongTypes.Tests/Result/ResultPartitionTests.cs b/src/StrongTypes.Tests/Result/ResultPartitionTests.cs index c64d7594..734bba2b 100644 --- a/src/StrongTypes.Tests/Result/ResultPartitionTests.cs +++ b/src/StrongTypes.Tests/Result/ResultPartitionTests.cs @@ -57,8 +57,8 @@ public void PartitionMatch_Void_InvokesBothCallbacks() IReadOnlyList? successes = null; IReadOnlyList? errors = null; items.PartitionMatch( - success: ss => successes = ss, - error: es => errors = es); + successes: ss => successes = ss, + errors: es => errors = es); Assert.Equal(new[] { 1 }, successes); Assert.Equal(new[] { "a" }, errors); } @@ -68,8 +68,8 @@ public void PartitionMatch_Void_EmptyInput_StillInvokesBothCallbacks() { var invocations = 0; System.Array.Empty>().PartitionMatch( - success: _ => invocations++, - error: _ => invocations++); + successes: _ => invocations++, + errors: _ => invocations++); Assert.Equal(2, invocations); } @@ -80,8 +80,8 @@ public void PartitionMatch_Projection_ConcatenatesSuccessesThenErrors() { var items = new Result[] { 1, "a", 2, "b" }; var projected = items.PartitionMatch( - success: ss => ss.Select(s => $"ok:{s}"), - error: es => es.Select(e => $"err:{e}")); + successes: ss => ss.Select(s => $"ok:{s}"), + errors: es => es.Select(e => $"err:{e}")); Assert.Equal(new[] { "ok:1", "ok:2", "err:a", "err:b" }, projected); } } diff --git a/src/StrongTypes.Tests/Result/ResultTests.cs b/src/StrongTypes.Tests/Result/ResultTests.cs index 8b6dcace..95e0f4db 100644 --- a/src/StrongTypes.Tests/Result/ResultTests.cs +++ b/src/StrongTypes.Tests/Result/ResultTests.cs @@ -76,8 +76,6 @@ public void ImplicitFromException_OnSingleParam_ReturnsResultOfT() [Fact] public void ImplicitConversions_EnableBareReturnStatements() { - // Demonstrates the ergonomic the whole design targets: method bodies - // return the raw value or the raw error, no factory call required. static Result ParseInt(string s) => int.TryParse(s, out var n) ? n : new FormatException(s); @@ -116,8 +114,6 @@ public void Equality_DifferentBranches_AreNotEqual(int value, string error) [Fact] public void Equality_ResultOfT_AndTwoParam_CompareStructurally() { - // Both carry the same (success, 5) state; equality walks the branch - // payloads regardless of which closed generic the instance came from. Result a = 5; Result b = 5; Assert.True(a.Equals(b)); diff --git a/src/StrongTypes.Tests/Result/ResultThrowIfErrorTests.cs b/src/StrongTypes.Tests/Result/ResultThrowIfErrorTests.cs index 7e28c282..92b2c0b3 100644 --- a/src/StrongTypes.Tests/Result/ResultThrowIfErrorTests.cs +++ b/src/StrongTypes.Tests/Result/ResultThrowIfErrorTests.cs @@ -37,7 +37,6 @@ public void ThrowIfError_Default_PreservesOriginalStackTrace() Result r = captured!; var thrown = Assert.Throws(() => r.ThrowIfError()); - // The original frame must still be reachable in the rethrown stack. Assert.Contains(nameof(ThrowIfError_Default_PreservesOriginalStackTrace), thrown.StackTrace); } @@ -82,10 +81,7 @@ public void ThrowIfError_Custom_Error_InvokesConverterAndThrows(string errorMess [Property] public void ThrowIfError_Aggregate_Success_ReturnsValue(int value) { - // Implicit conversion from the int success value works because T is a - // concrete type; the TError interface branch of the implicit operator - // doesn't apply (C# forbids interface source types), so the aggregate - // error case constructs via the factory instead. + // C# forbids implicit conversion from an interface TError, so the error cases below construct via the factory. Result> r = value; Assert.Equal(value, r.ThrowIfError()); } @@ -118,9 +114,7 @@ public void ThrowIfError_Aggregate_MultipleErrors_WrapsInAggregateException() [Fact] public void ParameterlessOverload_ChosenOnResultOfT_NoConverterNeeded() { - // Compile-time check: calling ThrowIfError() with no args on a - // Result must bind to the default overload, not the generic one - // (which would require a Func). + // The real check is compile-time: the no-arg call must bind to the default overload. Result r = 42; int value = r.ThrowIfError(); Assert.Equal(42, value); diff --git a/src/StrongTypes.Tests/Strings/NonEmptyStringCollectionTests.cs b/src/StrongTypes.Tests/Strings/NonEmptyStringCollectionTests.cs index 1b605fdc..77554871 100644 --- a/src/StrongTypes.Tests/Strings/NonEmptyStringCollectionTests.cs +++ b/src/StrongTypes.Tests/Strings/NonEmptyStringCollectionTests.cs @@ -20,9 +20,7 @@ public void Indexer_MatchesValueIndexer(NonEmptyString s) } } - // Load-bearing per the issue: BCL [MaxLength] reflects on Count after the - // `value is string` check fails. Adding Count makes the bare BCL attribute - // work without consumers shipping a custom shim. + // BCL [MaxLength] reflects on Count once its `value is string` check fails — that fallback is what these pin. [Fact] public void MaxLengthAttribute_WiredToCount_PassesWhenWithinLimit() { diff --git a/src/StrongTypes.Tests/Strings/NonEmptyStringExtensionsTests.cs b/src/StrongTypes.Tests/Strings/NonEmptyStringExtensionsTests.cs index 2743a9bf..c3db128f 100644 --- a/src/StrongTypes.Tests/Strings/NonEmptyStringExtensionsTests.cs +++ b/src/StrongTypes.Tests/Strings/NonEmptyStringExtensionsTests.cs @@ -10,8 +10,7 @@ public class NonEmptyStringExtensionsTests { private enum Day { Mon, Tue } - // NonEmptyString parsing just delegates to string parsing. Check one - // value of each overload is enough to catch a wiring regression. + // Parsing delegates to string parsing, so one value per overload is enough to catch a wiring regression. [Property] public void AsInt_RoundTrips(int value) => diff --git a/src/StrongTypes.Tests/Strings/StringAsExtensionsTests.cs b/src/StrongTypes.Tests/Strings/StringAsExtensionsTests.cs index cade930a..301362ef 100644 --- a/src/StrongTypes.Tests/Strings/StringAsExtensionsTests.cs +++ b/src/StrongTypes.Tests/Strings/StringAsExtensionsTests.cs @@ -123,8 +123,6 @@ public void AsEnum_IsCaseSensitiveByDefault() [Fact] public void AsEnum_AcceptsCommaSeparatedFlagCombinations() => - // AsEnum now just delegates to Enum.TryParse, which accepts - // comma-separated flag names for [Flags] enums. Assert.Equal(Permission.Read | Permission.Write, "Read, Write".AsEnum()); [Fact] diff --git a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs index 23b0959b..a04c4cbc 100644 --- a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs @@ -44,10 +44,8 @@ public void DisplaysAndRoundTripsInTheBindingCulture(string cultureName, double } /// - /// Write-back parses in the binding culture. A separator from the wrong culture is silently - /// swallowed as digit grouping — "9876,5" under en-US binds to 98765, a valid value - /// and no error — so the culture must match the input. Text that is no number, or that breaks the - /// invariant, raises a validation error and leaves the source unchanged (a null expected). + /// A separator from the wrong culture is silently swallowed as digit grouping — "9876,5" under + /// en-US binds to 98765, a valid value and no error — so the culture must match the input. /// [Theory] [InlineData("en-US", "9876.5", 9876.5)] @@ -84,7 +82,6 @@ public void WriteBackParsesInTheBindingCulture(string cultureName, string text, }); } - /// With no ConverterCulture the binding falls back to the element's Language, still never the host. [Theory] [InlineData("en-US")] [InlineData("de-DE")] diff --git a/src/StrongTypes.Wpf.Tests/StaThread.cs b/src/StrongTypes.Wpf.Tests/StaThread.cs index d2dcf914..9de580d8 100644 --- a/src/StrongTypes.Wpf.Tests/StaThread.cs +++ b/src/StrongTypes.Wpf.Tests/StaThread.cs @@ -7,7 +7,7 @@ namespace StrongTypes.Wpf.Tests; -/// Runs an action on a fresh thread set to Single-Threaded Apartment (STA) mode. WPF's DependencyObject infrastructure (and therefore every TextBox, Binding, etc. these tests construct) requires the calling thread to be STA; xUnit worker threads are Multi-Threaded Apartment by default, which would throw on new TextBox(). +/// WPF's DependencyObject infrastructure requires an STA thread; xUnit worker threads are MTA. internal static class StaThread { public static void Run(Action body) => Run(() => { body(); return null; }); diff --git a/src/StrongTypes/Booleans/BooleanExtensions.cs b/src/StrongTypes/Booleans/BooleanExtensions.cs index 3ca98920..791d9719 100644 --- a/src/StrongTypes/Booleans/BooleanExtensions.cs +++ b/src/StrongTypes/Booleans/BooleanExtensions.cs @@ -6,16 +6,12 @@ namespace StrongTypes; public static class BooleanExtensions { /// Logical implication (!condition || consequence). - /// The antecedent. - /// The consequent. Evaluated eagerly. - /// true when is false, or when both operands are true. [Pure] public static bool Implies(this bool condition, bool consequence) => !condition || consequence; /// Logical implication with a deferred consequent. /// The antecedent. /// Invoked only when is true. - /// true when is false, or when returns true. [Pure] public static bool Implies(this bool condition, Func consequence) => !condition || consequence(); } diff --git a/src/StrongTypes/Booleans/BooleanMapExtensions.cs b/src/StrongTypes/Booleans/BooleanMapExtensions.cs index 3cebd659..010f776e 100644 --- a/src/StrongTypes/Booleans/BooleanMapExtensions.cs +++ b/src/StrongTypes/Booleans/BooleanMapExtensions.cs @@ -7,64 +7,52 @@ namespace StrongTypes; public static class BooleanMapToStructExtensions { /// Projects true into a value; otherwise returns null. - /// The value type produced by . /// The flag being mapped. /// Invoked only when is true. - /// The result of when is true; otherwise null. [Pure] public static T? MapTrue(this bool value, Func map) where T : struct => value ? map() : null; /// Projects true into a value that may itself be null. - /// The value type produced by . /// The flag being mapped. /// Invoked only when is true; may return null. - /// The result of when is true; otherwise null. [Pure] public static T? MapTrue(this bool value, Func map) where T : struct => value ? map() : null; /// Projects false into a value; otherwise returns null. - /// The value type produced by . /// The flag being mapped. /// Invoked only when is false. - /// The result of when is false; otherwise null. [Pure] public static T? MapFalse(this bool value, Func map) where T : struct => value ? null : map(); /// Projects false into a value that may itself be null. - /// The value type produced by . /// The flag being mapped. /// Invoked only when is false; may return null. - /// The result of when is false; otherwise null. [Pure] public static T? MapFalse(this bool value, Func map) where T : struct => value ? null : map(); /// Asynchronously projects true into a value; otherwise returns null. - /// The value type produced by . /// The flag being mapped. /// Awaited only when is true. public static async Task MapTrueAsync(this bool value, Func> map) where T : struct => value ? await map() : null; /// Asynchronously projects true into a value that may itself be null. - /// The value type produced by . /// The flag being mapped. /// Awaited only when is true; may yield null. public static async Task MapTrueAsync(this bool value, Func> map) where T : struct => value ? await map() : null; /// Asynchronously projects false into a value; otherwise returns null. - /// The value type produced by . /// The flag being mapped. /// Awaited only when is false. public static async Task MapFalseAsync(this bool value, Func> map) where T : struct => value ? null : await map(); /// Asynchronously projects false into a value that may itself be null. - /// The value type produced by . /// The flag being mapped. /// Awaited only when is false; may yield null. public static async Task MapFalseAsync(this bool value, Func> map) where T : struct @@ -74,32 +62,26 @@ public static class BooleanMapToStructExtensions public static class BooleanMapToClassExtensions { /// Projects true into a reference that may itself be null. - /// The reference type produced by . /// The flag being mapped. /// Invoked only when is true; may return null. - /// The result of when is true; otherwise null. [Pure] public static T? MapTrue(this bool value, Func map) where T : class => value ? map() : null; /// Projects false into a reference that may itself be null. - /// The reference type produced by . /// The flag being mapped. /// Invoked only when is false; may return null. - /// The result of when is false; otherwise null. [Pure] public static T? MapFalse(this bool value, Func map) where T : class => value ? null : map(); /// Asynchronously projects true into a reference that may itself be null. - /// The reference type produced by . /// The flag being mapped. /// Awaited only when is true; may yield null. public static async Task MapTrueAsync(this bool value, Func> map) where T : class => value ? await map() : null; /// Asynchronously projects false into a reference that may itself be null. - /// The reference type produced by . /// The flag being mapped. /// Awaited only when is false; may yield null. public static async Task MapFalseAsync(this bool value, Func> map) where T : class diff --git a/src/StrongTypes/Collections/IEnumerableExtensions.cs b/src/StrongTypes/Collections/IEnumerableExtensions.cs index 69bf9322..df34e0e9 100644 --- a/src/StrongTypes/Collections/IEnumerableExtensions.cs +++ b/src/StrongTypes/Collections/IEnumerableExtensions.cs @@ -7,9 +7,6 @@ namespace StrongTypes; public static partial class IEnumerableExtensions { /// Drops null entries and unwraps the remaining values. - /// The underlying value type. - /// The sequence to filter. - /// The non-null values from as a sequence. [Pure] public static IEnumerable ExceptNulls(this IEnumerable source) where T : struct @@ -18,9 +15,6 @@ public static IEnumerable ExceptNulls(this IEnumerable source) } /// Drops null references from the sequence. - /// The reference type. - /// The sequence to filter. - /// The non-null references from . [Pure] public static IEnumerable ExceptNulls(this IEnumerable source) where T : class @@ -28,10 +22,6 @@ public static IEnumerable ExceptNulls(this IEnumerable source) return source.Where(item => item is not null)!; } - /// Returns with removed. - /// The element type. - /// The sequence to filter. - /// The values to exclude. [Pure] public static IEnumerable Except(this IEnumerable source, params T[] excludedItems) => Enumerable.Except(source, excludedItems); diff --git a/src/StrongTypes/Collections/IEnumerableExtensions_Concatenating.cs b/src/StrongTypes/Collections/IEnumerableExtensions_Concatenating.cs index 1dc6afc7..2f62ac90 100644 --- a/src/StrongTypes/Collections/IEnumerableExtensions_Concatenating.cs +++ b/src/StrongTypes/Collections/IEnumerableExtensions_Concatenating.cs @@ -6,18 +6,10 @@ namespace StrongTypes; public static partial class IEnumerableExtensions { - /// Appends to . - /// The element type. - /// The starting sequence. - /// Elements appended after . [Pure] public static IEnumerable Concat(this IEnumerable first, params T[] items) => Enumerable.Concat(first, items); - /// Appends each of to in order. - /// The element type. - /// The starting sequence. - /// Sequences concatenated after . [Pure] public static IEnumerable Concat(this IEnumerable first, params IEnumerable[] others) => Enumerable.Concat(first, others.SelectMany(o => o)); diff --git a/src/StrongTypes/Collections/IEnumerableExtensions_Flattening.cs b/src/StrongTypes/Collections/IEnumerableExtensions_Flattening.cs index 5b9646bb..e1c31e7c 100644 --- a/src/StrongTypes/Collections/IEnumerableExtensions_Flattening.cs +++ b/src/StrongTypes/Collections/IEnumerableExtensions_Flattening.cs @@ -6,9 +6,6 @@ namespace StrongTypes; public static partial class IEnumerableExtensions { - /// Concatenates the inner sequences of . - /// The element type. - /// The outer sequence. [Pure] public static IEnumerable Flatten(this IEnumerable> source) => source.SelectMany(i => i); diff --git a/src/StrongTypes/Collections/IEnumerableExtensions_Null.cs b/src/StrongTypes/Collections/IEnumerableExtensions_Null.cs index d10712cc..aeab4eb7 100644 --- a/src/StrongTypes/Collections/IEnumerableExtensions_Null.cs +++ b/src/StrongTypes/Collections/IEnumerableExtensions_Null.cs @@ -7,37 +7,22 @@ namespace StrongTypes; public static partial class IEnumerableExtensions { - /// Returns , or an empty sequence when it is null. - /// The element type. - /// The sequence that may be null. [Pure] public static IEnumerable OrEmptyIfNull(this IEnumerable? source) => source ?? Enumerable.Empty(); - /// Returns , or an empty array when it is null. - /// The element type. - /// The array that may be null. [Pure] public static T[] OrEmptyIfNull(this T[]? source) => source ?? Array.Empty(); - /// Returns , or a new empty list when it is null. - /// The element type. - /// The list that may be null. [Pure] public static List OrEmptyIfNull(this List? source) => source ?? new List(); - /// Returns , or an empty list when it is null. - /// The element type. - /// The list that may be null. [Pure] public static IReadOnlyList OrEmptyIfNull(this IReadOnlyList? source) => source ?? Array.Empty(); - /// Returns , or an empty collection when it is null. - /// The element type. - /// The collection that may be null. [Pure] public static ICollection OrEmptyIfNull(this ICollection? source) => source ?? Array.Empty(); diff --git a/src/StrongTypes/Collections/IEnumerableExtensions_Partition.cs b/src/StrongTypes/Collections/IEnumerableExtensions_Partition.cs index f91d6db4..a2ebaab4 100644 --- a/src/StrongTypes/Collections/IEnumerableExtensions_Partition.cs +++ b/src/StrongTypes/Collections/IEnumerableExtensions_Partition.cs @@ -7,10 +7,6 @@ namespace StrongTypes; public static partial class IEnumerableExtensions { /// Splits by , preserving relative order within each partition. - /// The element type. - /// The sequence to partition. - /// Tested against each element. - /// Passing holds items for which returned true; Violating holds the rest. [Pure] public static (IReadOnlyList Passing, IReadOnlyList Violating) Partition( this IEnumerable source, diff --git a/src/StrongTypes/Collections/IEnumerableExtensions_Types.cs b/src/StrongTypes/Collections/IEnumerableExtensions_Types.cs index 4c6bd6f7..dc7c3949 100644 --- a/src/StrongTypes/Collections/IEnumerableExtensions_Types.cs +++ b/src/StrongTypes/Collections/IEnumerableExtensions_Types.cs @@ -9,33 +9,20 @@ namespace StrongTypes; public static partial class IEnumerableExtensions { /// Materializes as a read-only list (by copying). - /// The element type. - /// The sequence to materialize. /// is null. [Pure] public static IReadOnlyList ToReadOnlyList(this IEnumerable source) => source.ToArray(); /// Returns as a read-only list, copying only when it is not already one. - /// The element type. - /// The sequence to adapt. [DebuggerStepThrough, Pure] public static IReadOnlyList AsReadOnlyList(this IEnumerable source) => source as IReadOnlyList ?? source.ToArray(); - /// Returns typed as a read-only list. - /// The element type. - /// The array to adapt. [DebuggerStepThrough, Pure] public static IReadOnlyList AsReadOnlyList(this T[] source) => source; - /// Returns typed as a read-only list. - /// The element type. - /// The list to adapt. [DebuggerStepThrough, Pure] public static IReadOnlyList AsReadOnlyList(this List source) => source; - /// Returns typed as a read-only list. - /// The element type. - /// The non-empty sequence to adapt. [DebuggerStepThrough, Pure] public static IReadOnlyList AsReadOnlyList(this INonEmptyEnumerable source) => source; @@ -43,14 +30,10 @@ public static partial class IEnumerableExtensions public static IReadOnlyList AsReadOnlyList(this IReadOnlyList source) => source; /// Returns as a , copying only when it is not already one. - /// The element type. - /// The sequence to adapt. [DebuggerStepThrough, Pure] public static List AsList(this IEnumerable source) => source as List ?? source.ToList(); /// Returns as an array, copying only when it is not already one. - /// The element type. - /// The sequence to adapt. [DebuggerStepThrough, Pure] public static T[] AsArray(this IEnumerable source) => source as T[] ?? source.ToArray(); } diff --git a/src/StrongTypes/Collections/INonEmptyEnumerable.cs b/src/StrongTypes/Collections/INonEmptyEnumerable.cs index a5d2e358..0eae7b8b 100644 --- a/src/StrongTypes/Collections/INonEmptyEnumerable.cs +++ b/src/StrongTypes/Collections/INonEmptyEnumerable.cs @@ -4,7 +4,6 @@ namespace StrongTypes; /// A read-only sequence guaranteed to contain at least one element. -/// The element type. [JsonConverter(typeof(NonEmptyEnumerableJsonConverterFactory))] public interface INonEmptyEnumerable : IReadOnlyList { diff --git a/src/StrongTypes/Collections/NonEmptyEnumerable.cs b/src/StrongTypes/Collections/NonEmptyEnumerable.cs index 80fd063d..87211206 100644 --- a/src/StrongTypes/Collections/NonEmptyEnumerable.cs +++ b/src/StrongTypes/Collections/NonEmptyEnumerable.cs @@ -14,8 +14,6 @@ namespace StrongTypes; public static class NonEmptyEnumerable { /// Wraps as a , or returns null when the sequence is null or empty. - /// The element type. - /// The source sequence. [Pure] public static NonEmptyEnumerable? TryCreateRange(IEnumerable? values) => values switch @@ -40,8 +38,6 @@ private static NonEmptyEnumerable IndexerCopy(IReadOnlyList list) } /// Wraps as a . - /// The element type. - /// The source sequence. /// is null or empty. [Pure] public static NonEmptyEnumerable CreateRange(IEnumerable? values) @@ -49,8 +45,6 @@ public static NonEmptyEnumerable CreateRange(IEnumerable? values) ?? throw new ArgumentException("You cannot create NonEmptyEnumerable from a null or empty sequence.", nameof(values)); /// Creates a from the supplied elements. Also backs the collection-expression syntax (NonEmptyEnumerable<int> list = [1, 2, 3];). - /// The element type. - /// The elements to wrap. /// is empty. [Pure] public static NonEmptyEnumerable Create(params ReadOnlySpan values) @@ -62,7 +56,6 @@ public static NonEmptyEnumerable Create(params ReadOnlySpan values) } /// A read-only list of guaranteed to contain at least one element. -/// The element type. /// Construct via , , or a collection expression (NonEmptyEnumerable<int> list = [1, 2, 3];). [JsonConverter(typeof(NonEmptyEnumerableJsonConverterFactory))] [CollectionBuilder(typeof(NonEmptyEnumerable), nameof(NonEmptyEnumerable.Create))] @@ -98,8 +91,7 @@ internal static NonEmptyEnumerable FromValidatedArray(T[] values) IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - /// Returns the elements as a . - /// The span must not outlive the enumerable. + /// Returns the elements as a that must not outlive the enumerable. public ReadOnlySpan AsSpan() => _values; #region ICollection — read-only implementation diff --git a/src/StrongTypes/Collections/NonEmptyEnumerableExtensions.cs b/src/StrongTypes/Collections/NonEmptyEnumerableExtensions.cs index e997e7eb..52756945 100644 --- a/src/StrongTypes/Collections/NonEmptyEnumerableExtensions.cs +++ b/src/StrongTypes/Collections/NonEmptyEnumerableExtensions.cs @@ -6,19 +6,14 @@ namespace StrongTypes; -/// Extension methods that produce or consume . public static class NonEmptyEnumerableExtensions { /// Wraps as a , or returns null when the sequence is null or empty. - /// The element type. - /// The sequence to wrap. [Pure] public static NonEmptyEnumerable? AsNonEmpty(this IEnumerable? source) => NonEmptyEnumerable.TryCreateRange(source); /// Wraps as a . - /// The element type. - /// The sequence to wrap. /// is null or empty. [Pure] public static NonEmptyEnumerable ToNonEmpty(this IEnumerable? source) @@ -147,9 +142,6 @@ public static NonEmptyEnumerable Flatten(this INonEmptyEnumerablePrepends to the concatenation of in order. - /// The element type. - /// The leading element. - /// Sequences concatenated after . /// or any element of it is null. [Pure] public static NonEmptyEnumerable Concat(this T head, params IEnumerable[] tails) @@ -200,9 +192,6 @@ public static NonEmptyEnumerable Reverse(this INonEmptyEnumerable sourc } /// Takes the first elements, or the full source when exceeds source.Count. - /// The element type. - /// The sequence to take from. - /// The positive number of elements to take. [Pure] public static NonEmptyEnumerable Take(this INonEmptyEnumerable source, Positive count) { @@ -217,9 +206,6 @@ public static NonEmptyEnumerable Take(this INonEmptyEnumerable source, } /// Takes the first elements, or the full source when exceeds source.Count. - /// The element type. - /// The sequence to take from. - /// The number of elements to take. /// is not positive. [Pure] public static NonEmptyEnumerable Take(this INonEmptyEnumerable source, int count) @@ -272,9 +258,6 @@ public static T Aggregate(this INonEmptyEnumerable source, Func f return Enumerable.Aggregate(source, func); } - /// Returns the arithmetic mean of the sequence. - /// A numeric type. - /// The sequence to average. /// The running sum overflows . [Pure] public static T Average(this INonEmptyEnumerable source) where T : INumber diff --git a/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs index 47f112e0..27ff9142 100644 --- a/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs +++ b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs @@ -7,7 +7,6 @@ namespace StrongTypes; /// Converts between and via , honouring the culture it is handed. -/// A strong type that implements . /// Invalid input surfaces as whatever T.Parse throws — for a Kalicz.StrongTypes wrapper, the naming the broken invariant. public sealed class ParsableTypeConverter : TypeConverter where T : IParsable diff --git a/src/StrongTypes/Digits/Digit.cs b/src/StrongTypes/Digits/Digit.cs index 27ba923f..8493068a 100644 --- a/src/StrongTypes/Digits/Digit.cs +++ b/src/StrongTypes/Digits/Digit.cs @@ -30,7 +30,6 @@ private Digit(byte value) public static implicit operator int(Digit d) => d.Value; /// Wraps the decimal value of , or returns null when it is not a decimal digit character. - /// The character to parse. [Pure] public static Digit? TryCreate(char value) { @@ -43,7 +42,6 @@ private Digit(byte value) } /// Wraps the decimal value of . - /// The character to parse. /// is not a decimal digit character. [Pure] public static Digit Create(char value) diff --git a/src/StrongTypes/Digits/DigitExtensions.cs b/src/StrongTypes/Digits/DigitExtensions.cs index 65fad66c..4845a83f 100644 --- a/src/StrongTypes/Digits/DigitExtensions.cs +++ b/src/StrongTypes/Digits/DigitExtensions.cs @@ -7,12 +7,10 @@ namespace StrongTypes; public static class DigitExtensions { /// Returns a wrapping , or null when it is not a decimal digit character. - /// The character to parse. [Pure] public static Digit? AsDigit(this char value) => Digit.TryCreate(value); /// Returns the decimal digits in , in order. A null input yields an empty sequence. - /// The string to scan. [Pure] public static IEnumerable FilterDigits(this string? value) { diff --git a/src/StrongTypes/Emails/Email.cs b/src/StrongTypes/Emails/Email.cs index f7500223..e3f50713 100644 --- a/src/StrongTypes/Emails/Email.cs +++ b/src/StrongTypes/Emails/Email.cs @@ -23,20 +23,17 @@ public sealed class Email : public const int MaxLength = 254; /// Wraps an already-parsed without re-validating. Skips the check — callers that need the cap enforced should go through or . - /// The mail address to wrap. public Email(MailAddress value) { Value = value; } - /// The parsed address. Use email.Value.Address for the raw string, email.Value.User for the local-part, and email.Value.Host for the domain. public MailAddress Value { get; } /// The address as it appeared on the wire. public string Address => Value.Address; /// Wraps , or returns null when it is null, blank, longer than , or rejected by . - /// The candidate address. [Pure] public static Email? TryCreate(string? value) { @@ -47,7 +44,6 @@ public Email(MailAddress value) } /// Wraps . - /// The candidate address. /// is null, blank, longer than , or not a valid email address. [Pure] public static Email Create(string? value) => diff --git a/src/StrongTypes/Emails/EmailExtensions.cs b/src/StrongTypes/Emails/EmailExtensions.cs index cfb6e2ab..4520dcee 100644 --- a/src/StrongTypes/Emails/EmailExtensions.cs +++ b/src/StrongTypes/Emails/EmailExtensions.cs @@ -7,7 +7,6 @@ namespace StrongTypes; public static class EmailExtensions { /// Parses under the full contract (non-blank, at most characters, parseable as an addr-spec) and returns the resulting , or null on failure. - /// The candidate address. [Pure] public static MailAddress? AsEmail(this string? s) { @@ -17,7 +16,6 @@ public static class EmailExtensions } /// Parses under the full contract and returns the resulting . - /// The candidate address. /// is null, blank, longer than , or not a valid addr-spec. [Pure] public static MailAddress ToEmail(this string? s) => @@ -26,18 +24,13 @@ public static MailAddress ToEmail(this string? s) => nameof(s)); /// Returns the underlying address string of . - /// The mail address. /// StrongTypes.EfCore translates this call in LINQ expressions to the underlying string column, letting callers write e.Email.Unwrap().Contains("alice") against SQL. [Pure] public static string Unwrap(this MailAddress mailAddress) => mailAddress.Address; - // C# 14 extension block: lets callers write MailAddress.Create / MailAddress.TryCreate - // alongside the BCL's existing static surface, matching the Create / TryCreate shape - // every StrongTypes wrapper uses. extension(MailAddress) { /// Constructs a from . Thin alias for new MailAddress(address). - /// The candidate address. /// is null. /// is empty. /// is not a valid addr-spec. @@ -45,7 +38,6 @@ public static MailAddress ToEmail(this string? s) => public static MailAddress Create(string address) => new(address); /// Wraps the BCL as a TryCreate returning null on failure. - /// The candidate address. [Pure] public static MailAddress? TryCreate(string? address) => MailAddress.TryCreate(address, out var result) ? result : null; diff --git a/src/StrongTypes/Enums/EnumExtensions.cs b/src/StrongTypes/Enums/EnumExtensions.cs index 01206335..4a0b0ef8 100644 --- a/src/StrongTypes/Enums/EnumExtensions.cs +++ b/src/StrongTypes/Enums/EnumExtensions.cs @@ -8,18 +8,15 @@ namespace StrongTypes; -/// Extensions over types: factories, declared-values metadata, and flag decomposition. public static class EnumExtensions { extension(TEnum source) where TEnum : struct, Enum { - /// Parses into a . /// The name or numeric value to parse. /// does not match a defined name or value. [Pure] public static TEnum Parse(string value) => Enum.Parse(value); - /// Parses into a . /// The name or numeric value to parse. /// When true, the name comparison is case-insensitive. /// does not match a defined name or value. @@ -37,7 +34,6 @@ public static class EnumExtensions [Pure] public static TEnum? TryParse(string? value, bool ignoreCase) => Enum.TryParse(value, ignoreCase, out var v) ? v : null; - /// Parses into a . /// The name or numeric value to parse. /// does not match a defined name or value. [Pure] @@ -48,7 +44,6 @@ public static class EnumExtensions [Pure] public static TEnum? TryCreate(string? value) => Enum.TryParse(value, out var v) ? v : null; - /// All declared values of . [Pure] public static TEnum[] AllValues => EnumMeta.Values; @@ -67,8 +62,7 @@ public static class EnumExtensions [Pure] public IReadOnlyList GetFlags() { - // Access FlagValues first so non-[Flags] enums throw even when - // the receiver is zero. + // Access FlagValues first so non-[Flags] enums throw even when the receiver is zero. var flags = FlagEnumMeta.FlagValues; var bits = FlagEnumMeta.ToLong(source); @@ -91,10 +85,7 @@ public IReadOnlyList GetFlags() } } -// Split in two so non-flag enums never pay for the flag-related state: -// reflecting for [Flags], compiling the ToLong/FromLong conversions, and -// allocating the lazy caches. Touching AllValues on a plain enum only -// cctors EnumMeta; FlagEnumMeta's cctor fires only when flag APIs are used. +// Split in two so plain enums never trigger FlagEnumMeta's flag-related static initialization. internal static class EnumMeta where TEnum : struct, Enum { public static readonly TEnum[] Values = Enum.GetValues(); @@ -107,18 +98,12 @@ internal static class FlagEnumMeta where TEnum : struct, Enum private static readonly bool HasFlagsAttribute = typeof(TEnum).IsDefined(typeof(FlagsAttribute), inherit: false); - // FlagValues is a reference: a plain ??= is enough. A race can run - // ScanForFlagValues more than once, but the scan is deterministic so - // last-write-wins is benign, and .NET guarantees the array's writes - // are visible before its reference is published. + // Benign race: ScanForFlagValues is deterministic, so last-write-wins on the reference is safe. private static TEnum[]? _flagValues; [Pure] public static TEnum[] FlagValues => _flagValues ??= ScanForFlagValues(); - // FlagsCombined is a TEnum (up to 8 bytes; not atomic on 32-bit) and - // default(TEnum) == 0 is a valid computed result, so we can't use - // the value itself as a freshness marker. A separate bool gives us - // that marker; LazyInitializer handles the DCL and release barrier. + // default(TEnum) == 0 is a valid result and TEnum writes are not atomic, so the value cannot mark its own initialization. private static TEnum _flagsCombined; private static bool _flagsCombinedReady; private static object? _flagsCombinedLock; @@ -127,14 +112,7 @@ internal static class FlagEnumMeta where TEnum : struct, Enum ref _flagsCombined, ref _flagsCombinedReady, ref _flagsCombinedLock, OrAllFlagValues ); - // Validation is done inside the factory (not at each getter call) so a - // well-formed flag enum pays only the LazyInitializer fast path on - // subsequent reads. Factory throws propagate through LazyInitializer - // without caching, so non-flag enums still throw on every access. - // - // BitOperations.IsPow2 treats negatives as non-flags, which is what we - // want: a power of two is by definition positive, so a sign-extended - // high bit on a signed underlying type is excluded. + // BitOperations.IsPow2 rejects sign-extended negatives — a high sign bit is deliberately not a flag. private static TEnum[] ScanForFlagValues() { if (!HasFlagsAttribute) @@ -154,8 +132,7 @@ private static TEnum OrAllFlagValues() private static Func CompileToLong() { - // (long)(TUnderlying)value — unchecked widening, sign-extending - // through the underlying integral type. + // Unchecked widening — sign-extends through the underlying integral type. var param = Expression.Parameter(typeof(TEnum), "v"); var underlying = Enum.GetUnderlyingType(typeof(TEnum)); var body = Expression.Convert(Expression.Convert(param, underlying), typeof(long)); @@ -164,8 +141,7 @@ private static Func CompileToLong() private static Func CompileFromLong() { - // (TEnum)(TUnderlying)bits — unchecked narrowing; truncates when - // the underlying type is smaller than long. + // Unchecked narrowing — truncates when the underlying type is smaller than long. var param = Expression.Parameter(typeof(long), "bits"); var underlying = Enum.GetUnderlyingType(typeof(TEnum)); var body = Expression.Convert(Expression.Convert(param, underlying), typeof(TEnum)); diff --git a/src/StrongTypes/Exceptions/ExceptionEnumerableExtensions.cs b/src/StrongTypes/Exceptions/ExceptionEnumerableExtensions.cs index dcd19a0b..54ca7de9 100644 --- a/src/StrongTypes/Exceptions/ExceptionEnumerableExtensions.cs +++ b/src/StrongTypes/Exceptions/ExceptionEnumerableExtensions.cs @@ -9,7 +9,6 @@ namespace StrongTypes; public static class ExceptionEnumerableExtensions { /// Returns a single aggregating , or null when the sequence is empty. - /// The exceptions to aggregate. public static Exception? Aggregate(this IEnumerable source) => source switch { @@ -21,7 +20,6 @@ public static class ExceptionEnumerableExtensions }; /// Returns a single aggregating , or null when the list is empty. - /// The exceptions to aggregate. [Pure] public static Exception? Aggregate(this IReadOnlyList source) => source.Count switch @@ -32,7 +30,6 @@ public static class ExceptionEnumerableExtensions }; /// Returns a single aggregating . - /// The exceptions to aggregate. [Pure] public static Exception Aggregate(this INonEmptyEnumerable source) => source.Count switch diff --git a/src/StrongTypes/Intervals/FiniteInterval.cs b/src/StrongTypes/Intervals/FiniteInterval.cs index 66988355..7ee53474 100644 --- a/src/StrongTypes/Intervals/FiniteInterval.cs +++ b/src/StrongTypes/Intervals/FiniteInterval.cs @@ -7,7 +7,6 @@ namespace StrongTypes; /// An interval bounded on both sides. Start and End are non-nullable and the invariant Start <= End always holds. Endpoints are inclusive by default; an endpoint created with startInclusive: false / endInclusive: false is excluded from membership. Equal endpoints form a single-value interval and require both endpoints inclusive. -/// The endpoint type. [JsonConverter(typeof(IntervalJsonConverterFactory))] public readonly struct FiniteInterval : IEquatable> where T : struct, IComparable diff --git a/src/StrongTypes/Intervals/Interval.cs b/src/StrongTypes/Intervals/Interval.cs index d28425f0..c36a17b7 100644 --- a/src/StrongTypes/Intervals/Interval.cs +++ b/src/StrongTypes/Intervals/Interval.cs @@ -7,7 +7,6 @@ namespace StrongTypes; /// An interval where both endpoints are optional. The invariant Start <= End holds whenever both endpoints are present; either or both may be null. Endpoints are inclusive by default; an endpoint created with startInclusive: false / endInclusive: false is excluded from membership. Equal endpoints form a single-value interval and require both endpoints inclusive. -/// The endpoint type. /// The deconstructor enables pattern matching over the four nullability cases via (null, null), (null, { } end), ({ } start, null), ({ } start, { } end). [JsonConverter(typeof(IntervalJsonConverterFactory))] public readonly struct Interval : IEquatable> diff --git a/src/StrongTypes/Intervals/IntervalDateExtensions.cs b/src/StrongTypes/Intervals/IntervalDateExtensions.cs index 810612bf..a6fa6c2c 100644 --- a/src/StrongTypes/Intervals/IntervalDateExtensions.cs +++ b/src/StrongTypes/Intervals/IntervalDateExtensions.cs @@ -4,7 +4,6 @@ namespace StrongTypes; -/// Bridges and values and the intervals over them. public static class IntervalDateExtensions { /// The number of calendar days the interval contains; an excluded endpoint day is not counted. diff --git a/src/StrongTypes/Intervals/IntervalFrom.cs b/src/StrongTypes/Intervals/IntervalFrom.cs index 8e9f1088..5bcd3d86 100644 --- a/src/StrongTypes/Intervals/IntervalFrom.cs +++ b/src/StrongTypes/Intervals/IntervalFrom.cs @@ -7,7 +7,6 @@ namespace StrongTypes; /// An interval whose lower endpoint is required and whose upper endpoint is optional. The invariant Start <= End holds whenever End is present. Endpoints are inclusive by default; an endpoint created with startInclusive: false / endInclusive: false is excluded from membership. Equal endpoints form a single-value interval and require both endpoints inclusive. -/// The endpoint type. [JsonConverter(typeof(IntervalJsonConverterFactory))] public readonly struct IntervalFrom : IEquatable> where T : struct, IComparable diff --git a/src/StrongTypes/Intervals/IntervalJsonConverterFactory.cs b/src/StrongTypes/Intervals/IntervalJsonConverterFactory.cs index a865c0a0..17a56ec9 100644 --- a/src/StrongTypes/Intervals/IntervalJsonConverterFactory.cs +++ b/src/StrongTypes/Intervals/IntervalJsonConverterFactory.cs @@ -19,7 +19,6 @@ public sealed class IntervalJsonConverterFactory : JsonConverterFactory public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => s_converterCache.GetOrAdd(typeToConvert, static t => CreateInner(t, IntervalBoundMode.Stored, IntervalBoundMode.Stored)); - // Backs IntervalJsonConverter: the same reader/writer with the bounds pinned to fixed modes. internal static JsonConverter CreateBoundConverter(IntervalBoundMode startMode, IntervalBoundMode endMode) where TInterval : struct => (JsonConverter)CreateInner(typeof(TInterval), startMode, endMode); @@ -40,8 +39,7 @@ private sealed class Inner(IntervalBoundMode startMode, // The arity-suffixed CLR name ("FiniteInterval`1") would leak into client-facing validation errors. private static readonly string s_name = typeof(TInterval).Name.Split('`')[0]; - // An endpoint is required exactly when its type is the bare value type; - // an optional endpoint is Nullable, so omitting its key means "null". + // An endpoint is required exactly when its type is the bare (non-Nullable) value type. private static readonly bool s_startRequired = Nullable.GetUnderlyingType(typeof(TStart)) is null; private static readonly bool s_endRequired = Nullable.GetUnderlyingType(typeof(TEnd)) is null; @@ -60,7 +58,6 @@ private static Func BuildGetter(string propertyName) return Expression.Lambda>(access, param).Compile(); } - // Whether the endpoint is present. A required endpoint always is; an optional (Nullable) one is when it HasValue. private static Func BuildPresence(string propertyName, bool required) { if (required) @@ -106,8 +103,6 @@ public override TInterval Read(ref Utf8JsonReader reader, Type typeToConvert, Js { if (reader.TokenType == JsonTokenType.EndObject) { - // A missing key is fine for an optional endpoint (it stays null); - // only a required endpoint must be present. if (!sawStart && s_startRequired) { throw new JsonException($"{s_name} requires the '{startName}' property."); @@ -116,7 +111,6 @@ public override TInterval Read(ref Utf8JsonReader reader, Type typeToConvert, Js { throw new JsonException($"{s_name} requires the '{endName}' property."); } - // A pinned bound ignores whatever the payload carried and takes the configured inclusivity. return s_tryCreate(start, end, ResolveRead(startMode, startInclusive), ResolveRead(endMode, endInclusive)) ?? throw new JsonException( $"{s_name} requires '{startName}' to be less than or equal to '{endName}', with equal endpoints only when both are inclusive."); @@ -156,11 +150,7 @@ public override TInterval Read(ref Utf8JsonReader reader, Type typeToConvert, Js throw new JsonException($"Unterminated JSON object while reading {s_name}."); } - // The nested Deserialize loses the property position, so a failure (null - // for a required endpoint, a type mismatch) surfaces with the document - // root as its path. Rethrow path-less and the serializer reattaches the - // correct path (e.g. "$.value") — matching the numeric converter and the - // error-key contract codified in #106. + // Rethrown path-less so the serializer reattaches the failing property's path — the error-key contract in #106. private static TEndpoint ReadEndpoint( ref Utf8JsonReader reader, JsonSerializerOptions options, string property) { diff --git a/src/StrongTypes/Intervals/IntervalUntil.cs b/src/StrongTypes/Intervals/IntervalUntil.cs index 126cd8b2..2116e727 100644 --- a/src/StrongTypes/Intervals/IntervalUntil.cs +++ b/src/StrongTypes/Intervals/IntervalUntil.cs @@ -7,7 +7,6 @@ namespace StrongTypes; /// An interval whose upper endpoint is required and whose lower endpoint is optional. The invariant Start <= End holds whenever Start is present. Endpoints are inclusive by default; an endpoint created with startInclusive: false / endInclusive: false is excluded from membership. Equal endpoints form a single-value interval and require both endpoints inclusive. -/// The endpoint type. [JsonConverter(typeof(IntervalJsonConverterFactory))] public readonly struct IntervalUntil : IEquatable> where T : struct, IComparable diff --git a/src/StrongTypes/Maybe/Maybe.cs b/src/StrongTypes/Maybe/Maybe.cs index e98e22a2..47ca2f61 100644 --- a/src/StrongTypes/Maybe/Maybe.cs +++ b/src/StrongTypes/Maybe/Maybe.cs @@ -7,7 +7,6 @@ namespace StrongTypes; /// A value that is either Some(T) or None. -/// The wrapped type. /// Unwrap with the extension-property pattern: if (maybe.Value is {} v). [JsonConverter(typeof(MaybeJsonConverterFactory))] public readonly struct Maybe : @@ -38,14 +37,8 @@ private Maybe(T value) public static readonly Maybe None = default; - // Lets callers write the untyped `Maybe.None` and have the compiler bind it to - // any closed `Maybe` from context — same pattern Nullable's null literal - // gets from the language. public static implicit operator Maybe(MaybeNone _) => default; - // Lets a bare T flow into a Maybe slot — most useful inside collection - // expressions like `Maybe[] xs = [1, 2, Maybe.None, 4]`, where each - // numeric literal converts to Maybe.Some(literal). public static implicit operator Maybe(T value) => Some(value); #region Match / Map / FlatMap / Where @@ -143,8 +136,7 @@ public int CompareTo(T? other) public static bool operator >(Maybe left, T right) => left.CompareTo(right) > 0; public static bool operator >=(Maybe left, T right) => left.CompareTo(right) >= 0; - // (T, Maybe) ordering inverts the sign because we delegate to the same - // CompareTo on the right operand. + // Sign inverted: delegates to CompareTo on the right operand. public static bool operator <(T left, Maybe right) => right.CompareTo(left) > 0; public static bool operator <=(T left, Maybe right) => right.CompareTo(left) >= 0; public static bool operator >(T left, Maybe right) => right.CompareTo(left) < 0; @@ -162,9 +154,6 @@ public int CompareTo(T? other) /// Factory helpers for with inferred type arguments. public static class Maybe { - /// Wraps as .Some. - /// The wrapped type. - /// The value to wrap. [Pure] public static Maybe Some(T value) where T : notnull => Maybe.Some(value); diff --git a/src/StrongTypes/Maybe/MaybeCollectionExtensions.cs b/src/StrongTypes/Maybe/MaybeCollectionExtensions.cs index 71116147..390bdc17 100644 --- a/src/StrongTypes/Maybe/MaybeCollectionExtensions.cs +++ b/src/StrongTypes/Maybe/MaybeCollectionExtensions.cs @@ -5,23 +5,17 @@ namespace StrongTypes; -/// Extensions bridging and . public static class MaybeCollectionExtensions { #region IEnumerable → Maybe /// Returns the first element satisfying , or None when no element matches. - /// The element type. - /// The sequence to scan. - /// Tested against each element. [Pure] public static Maybe SafeFirst(this IEnumerable source, Func predicate) where T : notnull => source.Where(predicate).SafeFirst(); /// Returns the first element of , or None when the sequence is empty. - /// The element type. - /// The sequence to scan. [Pure] public static Maybe SafeFirst(this IEnumerable source) where T : notnull { @@ -33,17 +27,12 @@ public static Maybe SafeFirst(this IEnumerable source) where T : notnul } /// Returns the last element satisfying , or None when no element matches. - /// The element type. - /// The sequence to scan. - /// Tested against each element. [Pure] public static Maybe SafeLast(this IEnumerable source, Func predicate) where T : notnull => source.Where(predicate).SafeLast(); /// Returns the last element of , or None when the sequence is empty. - /// The element type. - /// The sequence to scan. [Pure] public static Maybe SafeLast(this IEnumerable source) where T : notnull { @@ -54,17 +43,12 @@ public static Maybe SafeLast(this IEnumerable source) where T : notnull } /// Returns the single element satisfying , or None when zero or more than one match. - /// The element type. - /// The sequence to scan. - /// Tested against each element. [Pure] public static Maybe SafeSingle(this IEnumerable source, Func predicate) where T : notnull => source.Where(predicate).SafeSingle(); /// Returns the single element of , or None when the sequence has zero or more than one element. - /// The element type. - /// The sequence to scan. [Pure] public static Maybe SafeSingle(this IEnumerable source) where T : notnull { @@ -75,18 +59,12 @@ public static Maybe SafeSingle(this IEnumerable source) where T : notnu } /// Projects each element with and returns the maximum, or None when the sequence is empty. - /// The element type. - /// The projected type. - /// The sequence to scan. - /// Projects each element. [Pure] public static Maybe SafeMax(this IEnumerable source, Func selector) where TValue : notnull => source.Select(selector).SafeMax(); /// Returns the maximum element, or None when the sequence is empty. - /// The element type. - /// The sequence to scan. [Pure] public static Maybe SafeMax(this IEnumerable source) where T : notnull { @@ -102,18 +80,12 @@ public static Maybe SafeMax(this IEnumerable source) where T : notnull } /// Projects each element with and returns the minimum, or None when the sequence is empty. - /// The element type. - /// The projected type. - /// The sequence to scan. - /// Projects each element. [Pure] public static Maybe SafeMin(this IEnumerable source, Func selector) where TValue : notnull => source.Select(selector).SafeMin(); /// Returns the minimum element, or None when the sequence is empty. - /// The element type. - /// The sequence to scan. [Pure] public static Maybe SafeMin(this IEnumerable source) where T : notnull { @@ -133,8 +105,6 @@ public static Maybe SafeMin(this IEnumerable source) where T : notnull #region Values (flatten IEnumerable> to its populated values) /// Extracts the underlying values from every populated , dropping empties. - /// The wrapped type. - /// The sequence of . [Pure] public static IEnumerable Values(this IEnumerable> source) where T : notnull => source.Where(m => m.HasValue).Select(m => m.InternalValue); diff --git a/src/StrongTypes/Maybe/MaybeExtensions.cs b/src/StrongTypes/Maybe/MaybeExtensions.cs index 3f85e258..45ff9678 100644 --- a/src/StrongTypes/Maybe/MaybeExtensions.cs +++ b/src/StrongTypes/Maybe/MaybeExtensions.cs @@ -4,11 +4,7 @@ namespace StrongTypes; -// C# 14 extension members: `Maybe.Value` surfaces the underlying value as a -// nullable (`Nullable` for structs, `T?` for references), enabling the -// `if (maybe.Value is {} v)` pattern to unwrap in one expression. The two branches -// live in separate static classes because the generated `get_Value` signatures -// collide when placed in the same containing type. +// Two static classes because the generated `get_Value` signatures collide when placed in the same containing type. public static class MaybeStructValueExtensions { extension(Maybe m) where T : struct diff --git a/src/StrongTypes/Maybe/MaybeJsonConverter.cs b/src/StrongTypes/Maybe/MaybeJsonConverter.cs index 2b2fa292..1ba78415 100644 --- a/src/StrongTypes/Maybe/MaybeJsonConverter.cs +++ b/src/StrongTypes/Maybe/MaybeJsonConverter.cs @@ -53,8 +53,7 @@ public override Maybe Read(ref Utf8JsonReader reader, Type typeToConvert, Jso else { var value = JsonSerializer.Deserialize(ref reader, options); - // T : notnull at declaration, but the wire is honest — only - // wrap in Some when the parsed value is actually non-null. + // The wire can still deliver null despite T : notnull; only a real value becomes Some. if (value is not null) result = Maybe.Some(value); } diff --git a/src/StrongTypes/Nullables/NullableMapExtensions.cs b/src/StrongTypes/Nullables/NullableMapExtensions.cs index 827b8788..316de4b4 100644 --- a/src/StrongTypes/Nullables/NullableMapExtensions.cs +++ b/src/StrongTypes/Nullables/NullableMapExtensions.cs @@ -8,8 +8,6 @@ namespace StrongTypes; public static class NullableMapToStructExtensions { /// Applies when is present; otherwise returns null. - /// The source value type. - /// The result value type. /// The nullable input. /// Invoked only when has a value. [return: NotNullIfNotNull(nameof(value))] @@ -20,8 +18,6 @@ public static class NullableMapToStructExtensions => value.HasValue ? map(value.Value) : null; /// Applies when is present; otherwise returns null. The mapper may itself return null. - /// The source value type. - /// The result value type. /// The nullable input. /// Invoked only when has a value; may return null. [Pure] @@ -31,8 +27,6 @@ public static class NullableMapToStructExtensions => value.HasValue ? map(value.Value) : null; /// Applies when is non-null; otherwise returns null. - /// The source reference type. - /// The result value type. /// The nullable input. /// Invoked only when is non-null. [return: NotNullIfNotNull(nameof(value))] @@ -43,8 +37,6 @@ public static class NullableMapToStructExtensions => value is not null ? map(value) : null; /// Applies when is non-null; otherwise returns null. The mapper may itself return null. - /// The source reference type. - /// The result value type. /// The nullable input. /// Invoked only when is non-null; may return null. [Pure] @@ -54,8 +46,6 @@ public static class NullableMapToStructExtensions => value is not null ? map(value) : null; /// Awaits when is present; otherwise returns null. - /// The source value type. - /// The result value type. /// The nullable input. /// Awaited only when has a value. [return: NotNullIfNotNull(nameof(value))] @@ -66,8 +56,6 @@ public static class NullableMapToStructExtensions => value.HasValue ? await map(value.Value) : null; /// Awaits when is present; otherwise returns null. The mapper may itself yield null. - /// The source value type. - /// The result value type. /// The nullable input. /// Awaited only when has a value; may yield null. [Pure] @@ -77,8 +65,6 @@ public static class NullableMapToStructExtensions => value.HasValue ? await map(value.Value) : null; /// Awaits when is non-null; otherwise returns null. - /// The source reference type. - /// The result value type. /// The nullable input. /// Awaited only when is non-null. [return: NotNullIfNotNull(nameof(value))] @@ -89,8 +75,6 @@ public static class NullableMapToStructExtensions => value is not null ? await map(value) : null; /// Awaits when is non-null; otherwise returns null. The mapper may itself yield null. - /// The source reference type. - /// The result value type. /// The nullable input. /// Awaited only when is non-null; may yield null. [Pure] @@ -103,8 +87,6 @@ public static class NullableMapToStructExtensions public static class NullableMapToClassExtensions { /// Applies when is present; otherwise returns null. The mapper may itself return null. - /// The source value type. - /// The result reference type. /// The nullable input. /// Invoked only when has a value; may return null. [Pure] @@ -114,8 +96,6 @@ public static class NullableMapToClassExtensions => value.HasValue ? map(value.Value) : null; /// Applies when is non-null; otherwise returns null. The mapper may itself return null. - /// The source reference type. - /// The result reference type. /// The nullable input. /// Invoked only when is non-null; may return null. [Pure] @@ -125,8 +105,6 @@ public static class NullableMapToClassExtensions => value is not null ? map(value) : null; /// Awaits when is present; otherwise returns null. The mapper may itself yield null. - /// The source value type. - /// The result reference type. /// The nullable input. /// Awaited only when has a value; may yield null. [Pure] @@ -136,8 +114,6 @@ public static class NullableMapToClassExtensions => value.HasValue ? await map(value.Value) : null; /// Awaits when is non-null; otherwise returns null. The mapper may itself yield null. - /// The source reference type. - /// The result reference type. /// The nullable input. /// Awaited only when is non-null; may yield null. [Pure] diff --git a/src/StrongTypes/Numbers/Negative.cs b/src/StrongTypes/Numbers/Negative.cs index bb428b0f..6df1c911 100644 --- a/src/StrongTypes/Numbers/Negative.cs +++ b/src/StrongTypes/Numbers/Negative.cs @@ -6,7 +6,6 @@ namespace StrongTypes; /// A numeric value guaranteed to be strictly less than T.Zero. -/// The underlying numeric type. /// Construct via or Create. default(Negative<T>) wraps -T.One and satisfies the invariant. [NumericWrapper(InvariantDescription = "negative", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] @@ -26,7 +25,6 @@ private Negative(T offset) public T Value => _offset - T.One; /// Wraps , or returns null when it is not strictly less than zero. - /// The number to validate. [Pure] public static Negative? TryCreate(T value) { diff --git a/src/StrongTypes/Numbers/NonNegative.cs b/src/StrongTypes/Numbers/NonNegative.cs index ccc8f192..fd0dc0f4 100644 --- a/src/StrongTypes/Numbers/NonNegative.cs +++ b/src/StrongTypes/Numbers/NonNegative.cs @@ -6,7 +6,6 @@ namespace StrongTypes; /// A numeric value guaranteed to be greater than or equal to T.Zero. -/// The underlying numeric type. /// Construct via or Create. default(NonNegative<T>) wraps T.Zero and satisfies the invariant. [NumericWrapper(InvariantDescription = "non-negative", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] @@ -23,7 +22,6 @@ private NonNegative(T value) public T Value { get; } /// Wraps , or returns null when it is less than zero. - /// The number to validate. [Pure] public static NonNegative? TryCreate(T value) { diff --git a/src/StrongTypes/Numbers/NonPositive.cs b/src/StrongTypes/Numbers/NonPositive.cs index 51a99e10..1de55dde 100644 --- a/src/StrongTypes/Numbers/NonPositive.cs +++ b/src/StrongTypes/Numbers/NonPositive.cs @@ -6,7 +6,6 @@ namespace StrongTypes; /// A numeric value guaranteed to be less than or equal to T.Zero. -/// The underlying numeric type. /// Construct via or Create. default(NonPositive<T>) wraps T.Zero and satisfies the invariant. [NumericWrapper(InvariantDescription = "non-positive", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] @@ -23,7 +22,6 @@ private NonPositive(T value) public T Value { get; } /// Wraps , or returns null when it is greater than zero. - /// The number to validate. [Pure] public static NonPositive? TryCreate(T value) { diff --git a/src/StrongTypes/Numbers/NumberExtensions.cs b/src/StrongTypes/Numbers/NumberExtensions.cs index 1cac1f30..b98931f3 100644 --- a/src/StrongTypes/Numbers/NumberExtensions.cs +++ b/src/StrongTypes/Numbers/NumberExtensions.cs @@ -6,91 +6,65 @@ namespace StrongTypes; public static class NumberExtensions { /// Wraps as , or returns null when it is not strictly greater than zero. - /// The underlying numeric type. - /// The number to validate. [Pure] public static Positive? AsPositive(this T value) where T : INumber => Positive.TryCreate(value); /// Wraps as , or returns null when it is less than zero. - /// The underlying numeric type. - /// The number to validate. [Pure] public static NonNegative? AsNonNegative(this T value) where T : INumber => NonNegative.TryCreate(value); /// Wraps as , or returns null when it is not strictly less than zero. - /// The underlying numeric type. - /// The number to validate. [Pure] public static Negative? AsNegative(this T value) where T : INumber => Negative.TryCreate(value); /// Wraps as , or returns null when it is greater than zero. - /// The underlying numeric type. - /// The number to validate. [Pure] public static NonPositive? AsNonPositive(this T value) where T : INumber => NonPositive.TryCreate(value); /// Wraps as . - /// The underlying numeric type. - /// The number to validate. /// is not strictly greater than zero. [Pure] public static Positive ToPositive(this T value) where T : INumber => Positive.Create(value); /// Wraps as . - /// The underlying numeric type. - /// The number to validate. /// is less than zero. [Pure] public static NonNegative ToNonNegative(this T value) where T : INumber => NonNegative.Create(value); /// Wraps as . - /// The underlying numeric type. - /// The number to validate. /// is not strictly less than zero. [Pure] public static Negative ToNegative(this T value) where T : INumber => Negative.Create(value); /// Wraps as . - /// The underlying numeric type. - /// The number to validate. /// is greater than zero. [Pure] public static NonPositive ToNonPositive(this T value) where T : INumber => NonPositive.Create(value); /// Divides by , or returns null when is zero. - /// The dividend. - /// The divisor. [Pure] public static decimal? Divide(this int a, decimal b) => b == 0 ? null : a / b; /// Divides by , or returns null when is zero. - /// The dividend. - /// The divisor. [Pure] public static decimal? Divide(this decimal a, decimal b) => b == 0 ? null : a / b; /// Divides by , or returns when is zero. - /// The dividend. - /// The divisor. - /// Fallback returned when is zero. [Pure] public static decimal SafeDivide(this int a, decimal b, decimal otherwise = 0) => a.Divide(b) ?? otherwise; /// Divides by , or returns when is zero. - /// The dividend. - /// The divisor. - /// Fallback returned when is zero. [Pure] public static decimal SafeDivide(this decimal a, decimal b, decimal otherwise = 0) => a.Divide(b) ?? otherwise; diff --git a/src/StrongTypes/Numbers/NumericStrongTypeJsonConverterFactory.cs b/src/StrongTypes/Numbers/NumericStrongTypeJsonConverterFactory.cs index d5f1427f..cb85593e 100644 --- a/src/StrongTypes/Numbers/NumericStrongTypeJsonConverterFactory.cs +++ b/src/StrongTypes/Numbers/NumericStrongTypeJsonConverterFactory.cs @@ -20,9 +20,7 @@ public sealed class NumericStrongTypeJsonConverterFactory : JsonConverterFactory 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). + // Safe to share across JsonSerializerOptions instances: Inner holds no per-options state. private static readonly ConcurrentDictionary s_converterCache = new(); public override bool CanConvert(Type typeToConvert) => @@ -67,9 +65,7 @@ public override TWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, Jso } catch (JsonException ex) { - // The nested Deserialize loses the property position, so its - // path surfaces as the document root. Rethrow path-less and the - // serializer reattaches the correct path (e.g. "$.value"). + // Rethrown path-less so the serializer reattaches the failing property's path. throw new JsonException($"The JSON value could not be converted to {typeof(TWrapper).Name}.", ex); } diff --git a/src/StrongTypes/Numbers/Positive.cs b/src/StrongTypes/Numbers/Positive.cs index 4e1fad2b..860c4809 100644 --- a/src/StrongTypes/Numbers/Positive.cs +++ b/src/StrongTypes/Numbers/Positive.cs @@ -6,7 +6,6 @@ namespace StrongTypes; /// A numeric value guaranteed to be strictly greater than T.Zero. -/// The underlying numeric type. /// Construct via or Create. default(Positive<T>) wraps T.One and satisfies the invariant. [NumericWrapper(InvariantDescription = "positive", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] @@ -26,7 +25,6 @@ private Positive(T offset) public T Value => _offset + T.One; /// Wraps , or returns null when it is not strictly greater than zero. - /// The number to validate. [Pure] public static Positive? TryCreate(T value) { diff --git a/src/StrongTypes/Result/Result.cs b/src/StrongTypes/Result/Result.cs index 4f9318d1..97446154 100644 --- a/src/StrongTypes/Result/Result.cs +++ b/src/StrongTypes/Result/Result.cs @@ -6,14 +6,8 @@ namespace StrongTypes; /// A value that is either a success carrying or an error carrying . -/// The success type. -/// The error type. /// Construct through the implicit conversions (return value; / return error;) or the factory. Unwrap with the extension-property pattern: if (result.Success is {} s) or if (result.Error is {} e). -// Note: we can't also declare IEquatable and IEquatable — the C# -// compiler rejects that combination (CS0695) because the two interfaces unify -// when T equals TError at a closed type. The Equals(T) / Equals(TError) -// methods below still provide the same call-site ergonomic without the -// interface implementations. +// IEquatable and IEquatable cannot also be declared — they unify when T equals TError at a closed type (CS0695). public class Result : IEquatable> where T : notnull where TError : notnull @@ -78,10 +72,6 @@ public async Task> MapAsync(Func> f) where U : n IsSuccess ? await f(InternalValue) : InternalError; /// Transforms both branches in one call (bimap). - /// The mapped success type. - /// The mapped error type. - /// Applied when this is a success. - /// Applied when this is an error. [Pure] public Result Map(Func success, Func error) where U : notnull @@ -133,13 +123,11 @@ public bool Equals(Result? other) } /// Returns true when this is a success whose value equals . - /// The value to compare against. [Pure] public bool Equals(T? other) => IsSuccess && other is not null && EqualityComparer.Default.Equals(InternalValue, other); /// Returns true when this is an error whose value equals . - /// The error to compare against. [Pure] public bool Equals(TError? other) => IsError && other is not null && EqualityComparer.Default.Equals(InternalError, other); @@ -164,7 +152,6 @@ public override string ToString() => IsSuccess } /// Shorthand for Result<T, Exception>; chained Map/FlatMap calls stay as rather than decaying to the two-parameter form. -/// The success type. public sealed class Result : Result where T : notnull { @@ -182,10 +169,7 @@ internal Result(Exception error) : base(error) { } public new async Task> MapAsync(Func> f) where U : notnull => IsSuccess ? await f(InternalValue) : InternalError; - // FlatMap's parameter matches the base signature so `new` can hide it; the - // callback may return any `Result` (including `Result` - // itself via inheritance). The inner value is re-wrapped as `Result` - // because the callback may hand back a base-class instance. + // Parameter matches the base signature so `new` hides it; re-wrapped because the callback may return a base-class instance. [Pure] public new Result FlatMap(Func> f) where U : notnull { @@ -206,30 +190,16 @@ internal Result(Exception error) : base(error) { } /// Factory helpers for and . public static partial class Result { - /// Wraps as a successful . - /// The success type. - /// The success payload. [Pure] public static Result Success(T value) where T : notnull => new(value); - /// Wraps as a failed . - /// The success type. - /// The captured exception. [Pure] public static Result Error(Exception error) where T : notnull => new(error); - /// Wraps as a successful . - /// The success type. - /// The error type. - /// The success payload. [Pure] public static Result Success(T value) where T : notnull where TError : notnull => new(value); - /// Wraps as a failed . - /// The success type. - /// The error type. - /// The error payload. [Pure] public static Result Error(TError error) where T : notnull where TError : notnull => new(error); diff --git a/src/StrongTypes/Result/ResultAccessExtensions.cs b/src/StrongTypes/Result/ResultAccessExtensions.cs index 59bf2add..f1a9b03e 100644 --- a/src/StrongTypes/Result/ResultAccessExtensions.cs +++ b/src/StrongTypes/Result/ResultAccessExtensions.cs @@ -1,10 +1,6 @@ namespace StrongTypes; -// C# 14 extension members: `Result.Success` and `.Error` surface the -// branch values as nullables (`Nullable` for structs, `T?` for references), -// enabling the `if (result.Success is {} s)` pattern to unwrap in one -// expression. Split into four classes because the generated accessor signatures -// would otherwise collide between the struct- and class-constrained branches. +// Four classes because the generated accessor signatures would otherwise collide between the struct- and class-constrained branches. public static class ResultSuccessStructExtensions { diff --git a/src/StrongTypes/Result/ResultAggregate.cs b/src/StrongTypes/Result/ResultAggregate.cs index f04d2ecf..d25fa187 100644 --- a/src/StrongTypes/Result/ResultAggregate.cs +++ b/src/StrongTypes/Result/ResultAggregate.cs @@ -7,11 +7,6 @@ namespace StrongTypes; public static partial class Result { /// Combines results into a tuple of values on all-success, or an array of every collected error otherwise. - /// The first success type. - /// The second success type. - /// The shared error type. - /// The first result. - /// The second result. [Pure] public static Result<(T1, T2), TError[]> Aggregate( Result r1, Result r2) @@ -27,13 +22,6 @@ public static partial class Result } /// On all-success invokes ; otherwise returns all collected errors. - /// The first success type. - /// The second success type. - /// The combined success type. - /// The shared error type. - /// The first result. - /// The second result. - /// Invoked with both successful values. [Pure] public static Result Aggregate( Result r1, Result r2, @@ -266,9 +254,6 @@ public static Result AggregateAggregates any number of results, collecting every error. The sequence is fully drained whether or not an error is seen. - /// The success type. - /// The error type. - /// The results to aggregate. [Pure] public static Result Aggregate( IEnumerable> results) @@ -287,15 +272,6 @@ public static Result Aggregate( } /// On all-success invokes ; otherwise passes the collected errors through . - /// The first success type. - /// The second success type. - /// The combined success type. - /// The incoming error type. - /// The mapped error type. - /// The first result. - /// The second result. - /// Invoked with both successful values. - /// Folds the collected errors into a single . [Pure] public static Result Aggregate( Result r1, Result r2, diff --git a/src/StrongTypes/Result/ResultFlattenExtensions.cs b/src/StrongTypes/Result/ResultFlattenExtensions.cs index b7120002..3c3aca51 100644 --- a/src/StrongTypes/Result/ResultFlattenExtensions.cs +++ b/src/StrongTypes/Result/ResultFlattenExtensions.cs @@ -6,9 +6,6 @@ namespace StrongTypes; public static class ResultFlattenExtensions { /// Collapses a nested when both levels share the same error type. - /// The success type. - /// The shared error type. - /// The nested result. [Pure] public static Result Flatten(this Result, TError> nested) where T : notnull @@ -16,18 +13,12 @@ public static Result Flatten(this Result => nested.IsSuccess ? nested.InternalValue : nested.InternalError; /// Collapses a of , preserving the single-parameter form. - /// The success type. - /// The nested result. [Pure] public static Result Flatten(this Result, Exception> nested) where T : notnull => nested.IsSuccess ? nested.InternalValue : nested.InternalError; /// Collapses a nested whose inner and outer error types are different subtypes. Both errors upcast to . - /// The success type. - /// The inner exception type. - /// The outer exception type. - /// The nested result. [Pure] public static Result Flatten( this Result, TOuterException> nested) diff --git a/src/StrongTypes/Result/ResultFromNullableExtensions.cs b/src/StrongTypes/Result/ResultFromNullableExtensions.cs index 034e8827..d1d410df 100644 --- a/src/StrongTypes/Result/ResultFromNullableExtensions.cs +++ b/src/StrongTypes/Result/ResultFromNullableExtensions.cs @@ -5,39 +5,25 @@ namespace StrongTypes; public static class ResultFromNullableExtensions { - // ── Reference type receivers ─────────────────────────────────────── - /// Lifts a nullable reference into a . A null value becomes an with no ParamName. - /// The reference type. - /// The nullable input. [Pure] public static Result ToResult(this T? value) where T : class => value is { } v ? v : new ArgumentNullException(); /// Lifts a nullable reference into a . A null value becomes . - /// The reference type. - /// The nullable input. - /// The exception to emit when is null. [Pure] public static Result ToResult(this T? value, Exception error) where T : class => value is { } v ? v : error; /// Lifts a nullable reference into a . A null value invokes . - /// The reference type. - /// The nullable input. - /// Produces the exception when is null. [Pure] public static Result ToResult(this T? value, Func error) where T : class => value is { } v ? v : error(); /// Lifts a nullable reference into a with an eager custom error. - /// The reference type. - /// The error type. - /// The nullable input. - /// The error to emit when is null. /// A lambda returning a specific subtype binds here rather than to . Cast to to force the single-parameter form. [Pure] public static Result ToResult(this T? value, TError error) @@ -46,18 +32,12 @@ public static Result ToResult(this T? value, TError error) => value is { } v ? v : error; /// Lifts a nullable reference into a with a lazy custom error. - /// The reference type. - /// The error type. - /// The nullable input. - /// Produces the error when is null. [Pure] public static Result ToResult(this T? value, Func error) where T : class where TError : notnull => value is { } v ? v : error(); - // ── Value type receivers ─────────────────────────────────────────── - /// [Pure] public static Result ToResult(this T? value) diff --git a/src/StrongTypes/Result/ResultPartitionExtensions.cs b/src/StrongTypes/Result/ResultPartitionExtensions.cs index a48a2920..1fc48e41 100644 --- a/src/StrongTypes/Result/ResultPartitionExtensions.cs +++ b/src/StrongTypes/Result/ResultPartitionExtensions.cs @@ -8,9 +8,6 @@ namespace StrongTypes; public static class ResultPartitionExtensions { /// Splits a sequence of into successes and errors. Relative order is preserved within each partition. - /// The success type. - /// The error type. - /// The sequence of results. [Pure] public static (IReadOnlyList Successes, IReadOnlyList Errors) Partition( this IEnumerable> source) @@ -30,40 +27,29 @@ public static (IReadOnlyList Successes, IReadOnlyList Errors) Partiti return (successes, errors); } - /// Partitions the sequence, then invokes on the successes and on the errors. Both callbacks are invoked even when their partition is empty. - /// The success type. - /// The error type. - /// The sequence of results. - /// Invoked with all successes. - /// Invoked with all errors. + /// Partitions the sequence, then invokes on the successes and on the errors. Both callbacks are invoked even when their partition is empty. public static void PartitionMatch( this IEnumerable> source, - Action> success, - Action> error) + Action> successes, + Action> errors) where T : notnull where TError : notnull { - var (successes, errors) = source.Partition(); - success(successes); - error(errors); + var (successList, errorList) = source.Partition(); + successes(successList); + errors(errorList); } /// Partitions the sequence, projects each partition through the matching callback, and returns the concatenated results in successes-then-errors order. - /// The success type. - /// The error type. - /// The projected element type. - /// The sequence of results. - /// Projects the successes. - /// Projects the errors. [Pure] public static R[] PartitionMatch( this IEnumerable> source, - Func, IEnumerable> success, - Func, IEnumerable> error) + Func, IEnumerable> successes, + Func, IEnumerable> errors) where T : notnull where TError : notnull { - var (successes, errors) = source.Partition(); - return success(successes).Concat(error(errors)).ToArray(); + var (successList, errorList) = source.Partition(); + return successes(successList).Concat(errors(errorList)).ToArray(); } } diff --git a/src/StrongTypes/Result/ResultThrowIfErrorExtensions.cs b/src/StrongTypes/Result/ResultThrowIfErrorExtensions.cs index 32d1c40e..710dc825 100644 --- a/src/StrongTypes/Result/ResultThrowIfErrorExtensions.cs +++ b/src/StrongTypes/Result/ResultThrowIfErrorExtensions.cs @@ -8,9 +8,6 @@ namespace StrongTypes; public static class ResultThrowIfErrorExtensions { /// Returns the success value, or rethrows the captured exception preserving its original stack trace. - /// The success type. - /// The error type, constrained to . - /// The result to unwrap. public static T ThrowIfError(this Result r) where T : notnull where TError : Exception @@ -21,10 +18,6 @@ public static T ThrowIfError(this Result r) } /// Returns the success value, or throws the exception produced by . - /// The success type. - /// The error type. - /// The result to unwrap. - /// Maps the error to an . public static T ThrowIfError( this Result r, Func toException) @@ -37,8 +30,6 @@ public static T ThrowIfError( } /// Returns the success value, or throws. A single captured exception is rethrown (stack preserved); multiple exceptions are wrapped in an . - /// The success type. - /// The result to unwrap. public static T ThrowIfError(this Result> r) where T : notnull { diff --git a/src/StrongTypes/Strings/NonEmptyString.cs b/src/StrongTypes/Strings/NonEmptyString.cs index 39fc29ec..aaaf188b 100644 --- a/src/StrongTypes/Strings/NonEmptyString.cs +++ b/src/StrongTypes/Strings/NonEmptyString.cs @@ -37,7 +37,6 @@ private NonEmptyString(string value) public static explicit operator NonEmptyString(string s) => Create(s); /// Wraps , or returns null when it is null, empty, or whitespace. - /// The string to validate. [Pure] public static NonEmptyString? TryCreate(string? value) { @@ -50,7 +49,6 @@ private NonEmptyString(string value) } /// Wraps . - /// The string to validate. /// is null, empty, or whitespace. [Pure] public static NonEmptyString Create(string? value) diff --git a/src/StrongTypes/Strings/NonEmptyStringExtensions.cs b/src/StrongTypes/Strings/NonEmptyStringExtensions.cs index 43cafac2..1edb8171 100644 --- a/src/StrongTypes/Strings/NonEmptyStringExtensions.cs +++ b/src/StrongTypes/Strings/NonEmptyStringExtensions.cs @@ -8,7 +8,6 @@ namespace StrongTypes; public static class NonEmptyStringExtensions { /// Returns the underlying value. - /// The wrapper. /// StrongTypes.EfCore translates this call in LINQ expressions to the underlying string column, letting callers write e.Value.Unwrap().Contains("foo") against SQL. [Pure] public static string Unwrap(this NonEmptyString s) => s.Value; diff --git a/src/StrongTypes/Strings/NonEmptyStringJsonConverter.cs b/src/StrongTypes/Strings/NonEmptyStringJsonConverter.cs index facb7d80..3859db8e 100644 --- a/src/StrongTypes/Strings/NonEmptyStringJsonConverter.cs +++ b/src/StrongTypes/Strings/NonEmptyStringJsonConverter.cs @@ -8,8 +8,7 @@ public class NonEmptyStringJsonConverter : JsonConverter { public override NonEmptyString? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - // JSON null is the legitimate null case; let the caller decide whether - // that's allowed by the target field's nullability. + // JSON null passes through; the target member's nullability decides whether it is allowed. if (reader.TokenType == JsonTokenType.Null) return null; var value = reader.GetString(); diff --git a/src/StrongTypes/Strings/StringExtensions.cs b/src/StrongTypes/Strings/StringExtensions.cs index 23fa6cac..7cbe119e 100644 --- a/src/StrongTypes/Strings/StringExtensions.cs +++ b/src/StrongTypes/Strings/StringExtensions.cs @@ -8,7 +8,6 @@ namespace StrongTypes; public static class StringExtensions { /// Wraps as a , or returns null when it is null, empty, or whitespace. - /// The string to validate. [Pure] public static NonEmptyString? AsNonEmpty(this string? s) => NonEmptyString.TryCreate(s); @@ -61,7 +60,6 @@ public static class StringExtensions Guid.TryParseExact(s, format, out var value) ? value : null; /// Parses as a member of , or returns null when parsing fails. - /// The enum type. /// The name or numeric value to parse. /// When true, the name comparison is case-insensitive. [Pure] @@ -70,7 +68,6 @@ public static class StringExtensions Enum.TryParse(s, ignoreCase, out var v) ? v : null; /// Wraps as a . - /// The string to validate. /// is null, empty, or whitespace. [Pure] public static NonEmptyString ToNonEmpty(this string? s) => NonEmptyString.Create(s); @@ -153,7 +150,6 @@ public static TimeSpan ToTimeSpan(this string? s, IFormatProvider? format = null public static Guid ToGuidExact(this string? s, string format = "D") => Guid.ParseExact(s!, format); /// Parses as a member of . - /// The enum type. /// The name or numeric value to parse. /// When true, the name comparison is case-insensitive. /// is null. diff --git a/testing.md b/testing.md index 672a5be7..03594f52 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` | +| Carries a `TypeConverter` (every wrapper does) | `StrongTypes.Tests` (`ComponentModel/`) | | Ships an analyzer or code fix | `StrongTypes.Analyzers.Tests` | ## Unit tests — `StrongTypes.Tests` @@ -27,10 +28,11 @@ the whole input space. - Write `[Property]` tests from `FsCheck.Xunit` and let an `Arbitrary` cover the input space. Register arbitraries on a test class via `[Properties(Arbitrary = new[] { typeof(Generators) })]`. -- All shared arbitraries live in a single `Generators` class at - `src/StrongTypes.Tests/Generators.cs`. Add new arbitraries there rather - than creating per-feature generator classes — one shelf so tests can - grab anything with a single `[Properties(Arbitrary = new[] { typeof(Generators) })]` +- All shared arbitraries live in the shipped `Generators` class at + `src/StrongTypes.FsCheck/Generators.cs`, which the test project consumes + via project reference. Add new arbitraries there rather than creating + per-feature generator classes — one shelf so tests can grab anything + with a single `[Properties(Arbitrary = new[] { typeof(Generators) })]` attribute. Weight branches with `Gen.Frequency` when one branch is the common case and the other needs only occasional coverage (e.g. 90% populated, 10% null). @@ -180,11 +182,13 @@ inspecting the type), so a reference + struct representative is enough. Verifies the schema each type produces in **both** supported OpenAPI stacks: `Microsoft.AspNetCore.OpenApi` and Swashbuckle. The tests share `OpenApiDocumentTestsBase`, an `abstract partial` class split across -feature-scoped files (`*.Strings.cs`, `*.Numerics.cs`, -`*.Collections.cs`, `*.Composition.cs`, `*.Annotations.cs`, -`*.Components.cs`). Two concrete subclasses -(`MicrosoftOpenApiDocumentTests`, `SwashbuckleOpenApiDocumentTests`) run -the whole suite once per pipeline. +feature-scoped files (`*.Strings.cs`, `*.Emails.cs`, `*.Numerics.cs`, +`*.Collections.cs`, `*.Intervals.cs`, `*.Composition.cs`, +`*.Annotations.cs`, `*.Components.cs`, `*.NonBodyBinding.cs`). Three +concrete subclasses (`MicrosoftOpenApiDocumentTests`, +`MicrosoftOpenApi31DocumentTests`, `SwashbuckleOpenApiDocumentTests`) run +the whole suite once per pipeline — and, for Microsoft, once per OpenAPI +version. When adding a type that appears in API contracts: @@ -219,6 +223,60 @@ the shared `OpenApiDocumentTestsBase` suite so it's verified end-to-end on each). The integration suite stays HTTP/JSON-only and does not reference Core. +## Configuration binding tests + +Plain binding — a wrapper converting from a configuration string — is +unit-tested in `StrongTypes.Tests/ComponentModel/`: `TypeConverterTests` +for the converter itself, `ConfigurationBindingTests` for the +`ConfigurationBinder` / `IOptions` path, and `UnconfiguredOptionsTests` +pinning what an absent key leaves behind. A new wrapper adds its rows +there. + +`StrongTypes.Configuration.Tests` covers the +`Kalicz.StrongTypes.Configuration` package — `BindStrongTypes()` and its +null-property walk: + +- Build the section from an inline JSON string (`AddJsonStream`) and + resolve `IOptions.Value` through a real `ServiceCollection` — no + host, no config files. +- Assert failures via `OptionsValidationException.Failures`, and pin the + complete failure message verbatim at least once — it is user-facing + surface. +- Cover both directions of every claim: the checked shape fails when its + key is absent, and the exempt shapes (nullable, value type, initialised + default) bind without complaint. +- The walk is type-agnostic (wrappers are leaves), so a new strong type + needs no tests here — new tests are about *shapes* of options classes + (nesting, collections, dictionaries, annotations). +- `StrongTypes.Configuration.Tests.NullableDisabled` supplies options + classes from an assembly with `disable`, compiled + exactly as a real unannotated consumer would be. Add shapes there; the + tests asserting on them stay in the main project. + +## WPF binding tests — `StrongTypes.Wpf.Tests` + +Proves two-way WPF binding works off the core package's +`[TypeConverter]`s alone: the project references `StrongTypes` and the +`StrongTypes.Wpf.TestApp` sample — deliberately nothing else, because +there is no WPF package to install. + +- The project targets `net10.0-windows`. The Ubuntu `build` job compiles + it but skips it at `dotnet test`; the suite runs in the dedicated + `test-wpf` workflow on `windows-latest`. +- Run every test body through `StaThread.Run(...)` — WPF requires an STA + thread and xUnit workers are MTA. The helper also shuts the thread's + `Dispatcher` down; without that, leaked foreground threads make the + test host exit non-zero after all tests pass. +- `TestSetup`'s module initializer creates the one `Application` the + binding engine needs — never construct another in a test. +- A binding's culture is `ConverterCulture ?? element.Language`, never + the host thread's. Anything culture-sensitive follows + `CultureBindingTests`: run each case under several host cultures and + assert the host is ignored. +- Binding resolves through `TypeDescriptor` and is type-agnostic, so + representative types are enough — don't mirror the per-type API matrix + here. + ## Analyzer tests — `StrongTypes.Analyzers.Tests` For every diagnostic and code fix, write both a "reports the diagnostic" @@ -229,5 +287,6 @@ through the real Roslyn pipeline using `AnalyzerTester` / …) so the analyzer sees realistic compilation context. 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. +is present, and is **silent** in every configuration that legitimately +doesn't need it — the required reference already present, the property +already guarded by `[Required]`, and so on.