Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,72 @@ Rules:
globally yet, so add `#nullable enable` at the top of each new file.
- Do **not** return `Option<T>` from new code.

## Adding a new strong type — integration checklist

A strong type is only "done" when it is wired through **every** package
that the rest of the library already supports for its shape. This list is
the single place that enumerates those wiring points; miss one and the
type works in isolation but silently breaks for consumers (no DB
converter, a `{}` OpenAPI schema, no WPF binding, …). Walk it top to
bottom for every new type, and tick only what applies to that type's
shape.

**Core — always** (`src/StrongTypes/<Feature>/`)

- [ ] The type, following the `TryCreate` / `Create` pattern above.
- [ ] JSON converter. For a numeric wrapper this is just
`[NumericWrapper]` + `[JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))]`
on the `partial struct` — the source generator and factory do the
rest. Other shapes ship a hand-written `JsonConverter`.
- [ ] `AsX` / `ToX` (or equivalent) extension methods in the feature
folder.

**EF Core** (`src/StrongTypes.EfCore/`) — if the type can be stored in a column

- [ ] `StrongTypesConvention` — add the type to **both** `IsStrongType`
and `ResolveConverter` so the value converter is attached during
property discovery.
- [ ] `UnwrapMethodCallTranslator` — add the type's generated
`<Type>Extensions` to `UnwrapMethodDefinitions` so `.Unwrap()`
translates to a bare column in LINQ. (Every source-generated wrapper
gets an `Unwrap`; if you skip this the convention still stores the
type but server-side predicates on `.Unwrap()` throw.)

**Analyzers** (`src/StrongTypes.Analyzers/`) — if the type is EF-Core-storable

- [ ] `MissingEfCorePackageAnalyzer` — recognize the type so the
"reference the EfCore package" diagnostic fires for consumers who
map it without the package.

**OpenAPI — both pipelines** (`src/StrongTypes.OpenApi.*`) — if it appears in a request/response

- [ ] `StrongTypeSchemaTypes` (Core) — a detection helper and a branch in
`ResolveWireType` returning the underlying wire type.
- [ ] A **Microsoft** schema transformer **and** a **Swashbuckle** schema
filter, each registered in that package's `Startup.AddStrongTypes`.
A single-bound numeric wrapper just adds a row to
`NumericWrapperKinds`; a fixed/range type follows `Digit` /
`BoundedInt` (paint both bounds + set the inline marker).
- [ ] Verify the document: generate it for both pipelines and confirm the
property carries the expected keywords and that **no `{}` wrapper
component leaks** into `components.schemas`. Only add a
`StrongTypesComponentSchemaFiller` (Microsoft) entry if the framework
actually dedups your type into an unpainted component — primitive-
painted types usually inline and need nothing.

**WPF** (`src/StrongTypes.Wpf/`) — if the type is a scalar `IParsable<T>` value worth binding to a `TextBox`

- [ ] `StrongTypesTypeDescriptionProvider.TryCreateConverter` — add the
type so WPF's binding pipeline finds a `TypeConverter`. (Non-scalar
shapes like `Maybe<T>` / `Result<T, TError>` don't apply.)

**Tests** — see [`testing.md`](testing.md) for which test projects each
shape requires and how to write each kind.

**Docs** — main [`readme.md`](readme.md), the affected package readmes,
and the `Skill/` (catalog row + reference) — see
[`CONTRIBUTING.md`](CONTRIBUTING.md) and "Skill — keep it in sync" below.

## Tests

All testing rules — unit, API integration, OpenAPI integration, and
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ wasted work if the design needs to change.
- Match the conventions in [`CLAUDE.md`](CLAUDE.md) — it covers folder
layout, the `TryCreate` / `Create` pattern, comment style, and line
wrapping rules. Those rules apply to every contributor.
- Adding a new strong type? Follow the **"Adding a new strong type —
integration checklist"** in [`CLAUDE.md`](CLAUDE.md). It enumerates
every package the type must be wired through (EF Core, both OpenAPI
pipelines, WPF, analyzers) so it isn't left half-supported.
- PRs are squash-merged.

## Testing
Expand Down
1 change: 1 addition & 0 deletions Skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ demand when about to write code against that surface.
| ------------------------------------------------------------------- | ------------------------------------ | ---------------------------------- |
| `NonEmptyString` | non-null, non-empty, non-whitespace | `references/nonemptystring.md` |
| `Positive<T>` / `NonNegative<T>` / `Negative<T>` / `NonPositive<T>` | sign constraint on any `INumber<T>` | `references/numeric.md` |
| `BoundedInt<TBounds>` | `int` in a closed `[Min, Max]` range | `references/numeric.md` |
| `NonEmptyEnumerable<T>` / `INonEmptyEnumerable<T>` | at least one element | `references/nonemptyenumerable.md` |
| `Digit` | a single `'0'`–`'9'` character | `references/parsing.md` |

Expand Down
61 changes: 61 additions & 0 deletions Skill/references/numeric.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,67 @@ underlying primitive. `Positive<int>` on the wire is `42`, not
`{ "Value": 42 }`. Invalid values (`0` for `Positive<int>`, `-1` for
`NonNegative<int>`, …) fail with `JsonException` at deserialization.

## `BoundedInt<TBounds>` — a closed `[Min, Max]` range

When the invariant is a range rather than a sign, use `BoundedInt<TBounds>`:
an `int` constrained to a closed `[Min, Max]` range carried by a **witness
type**. The bounds live on the type, so the rule travels with every
signature — `BoundedInt<PageSizeBounds>` *is* a 1..100 integer.

```csharp
public readonly struct PageSizeBounds : IBounds<int>
{
public static int Min => 1;
public static int Max => 100;
}

BoundedInt<PageSizeBounds>? p = BoundedInt<PageSizeBounds>.TryCreate(input); // null if outside [1,100]
BoundedInt<PageSizeBounds> p2 = BoundedInt<PageSizeBounds>.Create(input); // throws if outside
BoundedInt<PageSizeBounds>? p3 = input.AsBounded<PageSizeBounds>(); // null if outside
BoundedInt<PageSizeBounds> p4 = input.ToBounded<PageSizeBounds>(); // throws if outside

void GetUsers(BoundedInt<PageSizeBounds> pageSize) { … } // 1..100 enforced at the boundary
```

- Both endpoints are **inclusive**. `Create` outside the range throws with a
message naming the bounds (`"… must be between 1 and 100 (inclusive)…"`).
- `default(BoundedInt<TBounds>)` wraps `TBounds.Min`, so the default still
satisfies the invariant.
- You get the same free surface as the sign wrappers (equality, comparison,
implicit `→ int`, `.Value` / `.Unwrap()`, JSON round-trip, `.Min` / `.Max`
extensions) **except** `Sum` — a bounded range is not closed under addition.
- Write the witness as a small `readonly struct` with `=> literal` getters so
the JIT can inline the bounds.
- OpenAPI: renders as `{ "type": "integer", "format": "int32", "minimum": Min,
"maximum": Max }`; EF Core stores it as a plain `int` column.

## Defining your own validated wrapper

`Positive<T>`, `BoundedInt<TBounds>`, and the rest are just `partial struct`s
tagged with `[NumericWrapper]`. The source generator emits all the equality,
comparison, conversion, `Create`, operator, and JSON boilerplate; the JSON
converter factory recognises **any** struct carrying the attribute. To add a
new validated numeric wrapper, write only the `Value` property and `TryCreate`:

```csharp
[NumericWrapper(InvariantDescription = "even")]
[JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))]
public readonly partial struct EvenInt
{
public int Value { get; }
private EvenInt(int value) { Value = value; }

public static EvenInt? TryCreate(int value)
=> value % 2 == 0 ? new EvenInt(value) : null;
}
```

That's the whole file. `Create` (throwing `"Value must be even, …"`), `==`,
`<`, `IEquatable<int>`, `implicit operator int`, `ToString`, JSON
round-tripping, and `EvenIntExtensions.Min` / `.Max` / `.Unwrap` are all
generated from the attribute. The EfCore and OpenAPI packages pick it up
automatically — no per-type registration.

## Modelling tips

```csharp
Expand Down
1 change: 1 addition & 0 deletions Skill/references/openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ app.UseSwaggerUI();
| `NonNegative<T>` | underlying primitive with `minimum: 0` |
| `Negative<T>` | underlying primitive with `exclusiveMaximum: 0` (3.1) or `maximum: 0, exclusiveMaximum: true` (3.0) |
| `NonPositive<T>` | underlying primitive with `maximum: 0` |
| `BoundedInt<TBounds>` | `{ "type": "integer", "format": "int32", "minimum": Min, "maximum": Max }` (witness bounds) |
| `NonEmptyEnumerable<T>` / `INonEmptyEnumerable<T>` | `{ "type": "array", "minItems": 1, "items": <T schema> }` |
| `Maybe<T>` | `{ "type": "object", "properties": { "Value": <T schema> } }` |
| `IEnumerable<T>` where `T` is a strong-type wrapper | `{ "type": "array", "items": <T schema> }` (no `minItems` — element schema only) |
Expand Down
3 changes: 2 additions & 1 deletion Skill/references/wpf.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ UI dependency, so the wiring lives here.

Call `this.UseStrongTypes()` once in `App.OnStartup`. One call covers
every strong type, including every closed instantiation of the
generic numeric wrappers (`Positive<int>`, `Negative<decimal>`, …) —
generic numeric wrappers (`Positive<int>`, `Negative<decimal>`,
`BoundedInt<PageSizeBounds>`, …) —
the package installs a `TypeDescriptionProvider` that synthesises the
converter on demand the first time WPF asks for it.

Expand Down
46 changes: 46 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ StrongTypes adds small, focused types that make everyday code safer and more exp
- [Helpful Types](#helpful-types)
- [`NonEmptyString`](#nonemptystring)
- [Numeric wrappers: `Positive<T>`, `NonNegative<T>`, `Negative<T>`, `NonPositive<T>`](#numeric-wrappers)
- [`BoundedInt<TBounds>`](#boundedinttbounds)
- [Defining your own validated wrapper](#defining-your-own-validated-wrapper)
- [What you get for free](#what-you-get-for-free)
- [JSON serialization](#json-serialization)
- [EF Core persistence](#ef-core-persistence)
Expand Down Expand Up @@ -104,6 +106,50 @@ All defaults (e.g. `default(Positive<T>)`) still satisfy their invariants (e.g.

[↑ Back to contents](#contents)

### `BoundedInt<TBounds>`

A 32-bit integer constrained to a closed range `[Min, Max]`. The bounds are not values you pass at every call site — they live on a small witness type, so the rule travels with the type and the validity of `pageSize` is part of its signature:

```csharp
public readonly struct PageSizeBounds : IBounds<int>
{
public static int Min => 1;
public static int Max => 100;
}

BoundedInt<PageSizeBounds>? p = BoundedInt<PageSizeBounds>.TryCreate(input); // null if invalid
BoundedInt<PageSizeBounds> p2 = BoundedInt<PageSizeBounds>.Create(input); // throws if invalid
BoundedInt<PageSizeBounds>? p3 = input.AsBounded<PageSizeBounds>(); // null if invalid
BoundedInt<PageSizeBounds> p4 = input.ToBounded<PageSizeBounds>(); // throws if invalid

void GetUsers(BoundedInt<PageSizeBounds> pageSize) { … } // 1..100 enforced at the boundary
```

Both endpoints are inclusive. `default(BoundedInt<TBounds>)` wraps `TBounds.Min` so it always satisfies the invariant. There is no `Sum` extension — bounded ranges are not closed under addition.

[↑ Back to contents](#contents)

### Defining your own validated wrapper

`Positive<T>`, `BoundedInt<TBounds>`, and the rest are just `partial struct`s decorated with `[NumericWrapper]`. The library's source generator emits all the equality, comparison, conversion, and `Create` boilerplate; the JSON converter factory recognizes any type that carries the attribute. To add your own validated numeric wrapper, write the `Value` property and `TryCreate` and tag the struct:

```csharp
[NumericWrapper(InvariantDescription = "even")]
[JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))]
public readonly partial struct EvenInt
{
public int Value { get; }
private EvenInt(int value) { Value = value; }

public static EvenInt? TryCreate(int value)
=> value % 2 == 0 ? new EvenInt(value) : null;
}
```

That is the whole file. The generated `Create`, `==`, `<`, `IEquatable<int>`, `implicit operator int`, `ToString`, JSON round-tripping, and `EvenIntExtensions.Min` / `.Max` / `.Unwrap` come from the attribute alone.

[↑ Back to contents](#contents)

### What you get for free

Every strong type in this library implements the full set of equality and comparison interfaces, so you can drop them into dictionaries, sorted collections, LINQ `OrderBy`, and equality checks without writing any boilerplate:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,41 @@ public class SampleContext : DbContext
Assert.NotEmpty(diagnostics.WhereId(MissingEfCorePackageAnalyzer.DiagnosticId));
}

[Fact]
public async Task Detects_bounded_int_wrapper()
{
const string source = """
using Microsoft.EntityFrameworkCore;
using StrongTypes;

namespace Sample;

public readonly struct PageSizeBounds : IBounds<int>
{
public static int Min => 1;
public static int Max => 100;
}

public class Entity
{
public int Id { get; set; }
public BoundedInt<PageSizeBounds> PageSize { get; set; }
}

public class SampleContext : DbContext
{
public DbSet<Entity> Entities { get; set; } = null!;
}
""";

var diagnostics = await AnalyzerTester.RunAsync(
new MissingEfCorePackageAnalyzer(),
source,
TestReferences.With(TestReferences.EntityFrameworkCore));

Assert.NotEmpty(diagnostics.WhereId(MissingEfCorePackageAnalyzer.DiagnosticId));
}

[Fact]
public async Task Reports_at_dbset_entity_property_and_dbcontext_locations()
{
Expand Down
5 changes: 3 additions & 2 deletions src/StrongTypes.Analyzers/MissingEfCorePackageAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public sealed class MissingEfCorePackageAnalyzer : DiagnosticAnalyzer
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Kalicz.StrongTypes.EfCore ships value converters and the Unwrap() LINQ translator needed for EF Core to round-trip NonEmptyString, Positive<T>, NonNegative<T>, Negative<T>, and NonPositive<T> to a database column. Without the package, EF Core infers the wrapper as an owned entity type and fails at model-build time.",
description: "Kalicz.StrongTypes.EfCore ships value converters and the Unwrap() LINQ translator needed for EF Core to round-trip NonEmptyString, Positive<T>, NonNegative<T>, Negative<T>, NonPositive<T>, and BoundedInt<TBounds> 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
Expand All @@ -47,7 +47,8 @@ public sealed class MissingEfCorePackageAnalyzer : DiagnosticAnalyzer
"StrongTypes.Positive`1",
"StrongTypes.NonNegative`1",
"StrongTypes.Negative`1",
"StrongTypes.NonPositive`1");
"StrongTypes.NonPositive`1",
"StrongTypes.BoundedInt`1");

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using StrongTypes.Api.Entities;
using StrongTypes.Api.IntegrationTests.Infrastructure;
using Xunit;

namespace StrongTypes.Api.IntegrationTests.Tests;

[Collection(IntegrationTestCollection.Name)]
public sealed class BoundedIntEntityTests(TestWebApplicationFactory factory)
: EntityTests<BoundedIntEntityTests, BoundedIntEntity, BoundedInt<PageSizeBounds>, BoundedInt<PageSizeBounds>?, int>(factory),
IEntityTestData<int>
{
protected override string RoutePrefix => "bounded-int-entities";
protected override BoundedInt<PageSizeBounds> Create(int raw) => BoundedInt<PageSizeBounds>.Create(raw);
protected override int FirstValid => 5;
protected override int UpdatedValid => 50;

// Both ends of the inclusive 1..100 range plus interior values.
public static TheoryData<int> ValidInputs => new() { 1, 5, 50, 99, 100 };

// Just-outside both bounds and the extremes.
public static TheoryData<int> InvalidInputs => new() { 0, 101, -1, int.MinValue, int.MaxValue };
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,29 @@ public async Task Negative_UnwrapArithmetic_TranslatesToSql(string provider)
Assert.Contains(large.Id, ids);
}

[Theory, MemberData(nameof(Providers))]
public async Task BoundedInt_UnwrapArithmetic_TranslatesToSql(string provider)
{
SkipIfSqlServerUnavailable(provider);

var small = BoundedIntEntity.Create(BoundedInt<PageSizeBounds>.Create(3), null);
var large = BoundedIntEntity.Create(BoundedInt<PageSizeBounds>.Create(70), null);
SqlDb.Add(small); SqlDb.Add(large);
PgDb.Add(small); PgDb.Add(large);
await SqlDb.SaveChangesAsync(Ct);
await PgDb.SaveChangesAsync(Ct);

var set = provider == "sql-server" ? SqlDb.BoundedIntEntities : PgDb.BoundedIntEntities;

var ids = await set
.Where(e => (e.Id == small.Id || e.Id == large.Id) && (long)e.Value.Unwrap() * 2 > 10)
.Select(e => e.Id)
.ToListAsync(Ct);

Assert.DoesNotContain(small.Id, ids);
Assert.Contains(large.Id, ids);
}

[Theory, MemberData(nameof(Providers))]
public async Task NonPositive_UnwrapArithmetic_TranslatesToSql(string provider)
{
Expand Down
10 changes: 10 additions & 0 deletions src/StrongTypes.Api/Controllers/BoundedIntEntityController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using StrongTypes.Api.Data;
using StrongTypes.Api.Entities;

namespace StrongTypes.Api.Controllers;

[ApiController]
[Route("bounded-int-entities")]
public sealed class BoundedIntEntityController(SqlServerDbContext sqlCtx, PostgreSqlDbContext pgCtx)
: StructTypeEntityControllerBase<BoundedIntEntity, BoundedInt<PageSizeBounds>>(sqlCtx, pgCtx);
1 change: 1 addition & 0 deletions src/StrongTypes.Api/Data/PostgreSqlDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public class PostgreSqlDbContext(DbContextOptions<PostgreSqlDbContext> options)
{
public DbSet<NonEmptyStringEntity> NonEmptyStringEntities { get; set; }
public DbSet<EmailEntity> EmailEntities { get; set; }
public DbSet<BoundedIntEntity> BoundedIntEntities { get; set; }

public DbSet<PositiveIntEntity> PositiveIntEntities { get; set; }
public DbSet<NonNegativeIntEntity> NonNegativeIntEntities { get; set; }
Expand Down
1 change: 1 addition & 0 deletions src/StrongTypes.Api/Data/SqlServerDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public class SqlServerDbContext(DbContextOptions<SqlServerDbContext> options) :
{
public DbSet<NonEmptyStringEntity> NonEmptyStringEntities { get; set; }
public DbSet<EmailEntity> EmailEntities { get; set; }
public DbSet<BoundedIntEntity> BoundedIntEntities { get; set; }

public DbSet<PositiveIntEntity> PositiveIntEntities { get; set; }
public DbSet<NonNegativeIntEntity> NonNegativeIntEntities { get; set; }
Expand Down
9 changes: 9 additions & 0 deletions src/StrongTypes.Api/Entities/Entities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ public sealed class NonEmptyStringEntity : EntityBase<NonEmptyStringEntity, NonE

public sealed class EmailEntity : EntityBase<EmailEntity, MailAddress, MailAddress?>;

/// <summary>Witness giving <see cref="BoundedInt{TBounds}"/> a 1..100 page-size range.</summary>
public readonly struct PageSizeBounds : IBounds<int>
{
public static int Min => 1;
public static int Max => 100;
}

public sealed class BoundedIntEntity : EntityBase<BoundedIntEntity, BoundedInt<PageSizeBounds>, BoundedInt<PageSizeBounds>?>;

public sealed class PositiveIntEntity : EntityBase<PositiveIntEntity, Positive<int>, Positive<int>?>;
public sealed class NonNegativeIntEntity : EntityBase<NonNegativeIntEntity, NonNegative<int>, NonNegative<int>?>;
public sealed class NegativeIntEntity : EntityBase<NegativeIntEntity, Negative<int>, Negative<int>?>;
Expand Down
Loading