diff --git a/.claude/skills/weaving-review/SKILL.md b/.claude/skills/weaving-review/SKILL.md index 4ccef63..12854b3 100644 --- a/.claude/skills/weaving-review/SKILL.md +++ b/.claude/skills/weaving-review/SKILL.md @@ -1,6 +1,6 @@ --- name: weaving-review -description: Review a change to CodoMetis.TypeKit for code that compiles and silently does less than it appears to — a type that is not generated, an analyzer that stops firing, a generated entry point that bypasses Create, an Option/Result that leaks its content or reads as success, a satellite that falls back to default behaviour. Use when changing Option/Result, an aspect, the transitive fabric, AspectOrder, the analyzer, the EF converter/convention/translators, the OpenAPI transformers, or package references — and before cutting a release. +description: Review a change to CodoMetis.TypeKit for code that compiles and silently does less than it appears to — a type that is not generated, an analyzer that stops firing, a generated entry point that bypasses Create, an Option/Result that leaks its content or reads as success, a satellite that falls back to default behaviour. Use when changing Option/Result, an aspect, the transitive fabric, AspectOrder, the analyzer, the EF converter/convention/translators, the OpenAPI transformers, closed sets, `Patch`, the fault responses, the Serilog or Testing package, or package references — and before cutting a release. --- # Weaving review @@ -23,6 +23,8 @@ does, and what they expected. nested type, a generic type, and a type in a project that reaches the package only transitively. - A type the fabric skips compiles cleanly. Is there a test or a diagnostic that would notice? + An **abstract** type with a marker, and a name that is a C# keyword (`namespace Shop.@event`, a + member `@class`), were both measured once as silent or as a Metalama crash rather than a CMTK error. - A project that references only `CodoMetis.TypeKit` gets no aspects at all. Does CMTK0002 ("marker without `.Generators`") still fire there? - Is `AspectOrder` still complete? A new aspect class missing from it runs in an unspecified @@ -69,15 +71,26 @@ likely. match the snapshot? A translator that stops matching makes EF throw at best, or evaluate on the client at worst. - Is the comparer consistent with the type's equality? +- Does a translator check that the column's converter is **TypeKit's**? A user's own + `HasConversion` stores through their converter; a `.Value` translated as if TypeKit's were there + queries the wrong representation and answers wrong rows. +- Does a hand-written **`CompareTo`** reach the query? EF translates a comparison or an ordering to + the column's order, not the seam's. +- Is an integer key generated on add **wherever EF generates the wrapped type's**: every integer + type EF generates, the root of a hierarchy (TPH, TPT), on every provider the tests run? ## 5. OpenAPI - For a value object used only as a property, as a list element, as a dictionary value and as a route/query parameter: what schema does the document actually contain? Look at the emitted JSON, not at whether the transformer ran. A schema transformer that registered, compiled and - never ran for a property-only type has been measured before (plan §7). + never ran for a property-only type has been measured before ([OpenAPI](../../../design/openapi.md)). - Is the schema the **underlying type's** schema (format included: `uuid`, `date`, `int64`), and does it match what the JSON converter writes? +- Can two types get **one component name**? A name built from a nesting chain (`Order.Status` → + `OrderStatus`) meets a top-level type of that name, and one component then describes both. +- Is every name a client matches on (a fault's qualified name, a component) unique for nested types + of one simple name? ## 6. Option and Result @@ -88,7 +101,7 @@ likely. a branch? - Does every new combinator (`Map`, `Bind`, `Tap`, async variants) preserve the error or None unchanged, and avoid invoking the callback on it? -- Is the change inside the non-goals in plan §9 (no Either, no effects, no LanguageExt drift)? +- Is the change inside the non-goals in [Option and Result](../../../design/option-result.md) (no Either, no effects, no LanguageExt drift)? ## 7. Packaging @@ -99,3 +112,26 @@ likely. - Do the EF and ASP.NET satellites depend on `CodoMetis.TypeKit` only, never on `.Generators`? - Does the consumer smoke test build a project that references the packages **transitively**, the case the fabric spike proved and a refactor could lose? + +## 8. Closed sets, `Patch`, faults, and the run-time satellites + +- **Closed sets:** does anything generated that the set's own static field initializers can read + (`All`, `Parse`, `Name`) answer from a half-built set? Is every member found across partial files, + in declaration order? Does every entry point (JSON, EF, binding, the type converter, `IParsable`) + read ordinally, with ignoring case an overload the caller asks for? +- **`Patch`:** does `default` still read as Absent, null as Null, and does a value object inside a + patch still go through its own converter? Is anything new registered so that Native AOT finds it + without `MakeGenericType`? +- **Faults:** can a `Fault` be compared with a bare reason by any path (an operator, a + conversion, `Equals`, a collection's `Contains`, a test assertion), where a fault with a detail + then never equals it? +- **Serilog:** does a value object or a closed set log as what it prints, and does the policy honour + the logger's own limits (`ToMaximumStringLength`)? +- **Testing:** does an assertion's message print content, and does it survive every type name + (nested, generic, nested in a generic)? +- **Analyzer Errors freeze** ([decision 34](../../../design/adr/0034-errors-fixed-within-major-version-warnings.md)): + an Error that misses a form misses it for the whole major version, and one that reports correct + code breaks every consumer's build on upgrade. Check the neighbours of each form a rule reaches: + `??` beside `?:`, `ConfigureAwait`/`WaitAsync` on a held task beside `await held`, a tuple or a + dictionary beside a collection, an extension block, a generic attribute, a static abstract or + inherited interface implementation. diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b2437e3..322b22f 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -94,7 +94,7 @@ jobs: run: dotnet pack CodoMetis.TypeKit.slnx -c Release --no-build -o dist # The smoke test publishes a consumer with Native AOT, which links with the platform's clang - # and zlib (docs/plan.md §11). + # and zlib (design/native-aot.md). - name: Native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev @@ -143,7 +143,7 @@ jobs: global-json-file: global.json # The smoke test publishes a consumer with Native AOT, which links with the platform's clang - # and zlib (docs/plan.md §11). + # and zlib (design/native-aot.md). - name: Native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a029ed..ffe3c3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -136,7 +136,7 @@ jobs: run: dotnet pack CodoMetis.TypeKit.slnx -c Release --no-build -o dist # The smoke test publishes a consumer with Native AOT, which links with the platform's clang - # and zlib (docs/plan.md §11). + # and zlib (design/native-aot.md). - name: Native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev diff --git a/.gitignore b/.gitignore index efac126..1ac58d1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db # Benchmark runs: the numbers worth keeping go into benchmarks/README.md BenchmarkDotNet.Artifacts/ + +# The codebase-memory MCP server's local graph artifact: rebuilt per checkout, never committed +.codebase-memory/ diff --git a/AGENTS.md b/AGENTS.md index f033f2c..e6cca90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,9 @@ that are written as one line and generated at compile time by Metalama. - **Satellites** map value objects to EF Core columns, where `.Value` works in LINQ as it does in memory, and to OpenAPI schemas. -The packages are being built phase by phase: **read [docs/plan.md](docs/plan.md) before changing -anything.** It holds the package map, the phase order and the decisions. +**Read [design/plan.md](design/plan.md) before changing anything.** It is the map of `design/`: the +[roadmap](design/roadmap.md), the [decision records](design/adr/README.md), one dated file per +decision with its evidence, and a design note per area. This file is the canonical agent guide. There is no CLAUDE.md, on purpose. @@ -40,12 +41,15 @@ This file is the canonical agent guide. There is no CLAUDE.md, on purpose. - Central package management (`Directory.Packages.props`). The versions of shipping references are floors for consumers, so raising one is a release decision. `spikes/` opts out of it and pins what each spike measured. -- `test/CodoMetis.TypeKit.Samples` holds every C# block of the READMEs as compiled code, between - `// sample: {README}/{name}` and `// end sample`, beside tests that check the outputs the blocks - state in comments. `ReadmeSampleTests` (conventions) holds each block to exactly one region. +- `test/CodoMetis.TypeKit.Samples` holds every C# block of the READMEs and of the `docs/` pages as + compiled code, between `// sample: {page}/{name}` and `// end sample`, beside tests that check the + outputs the blocks state in comments. `{page}` is `README`, a package id, or a docs page's file name + without `.md`. `ReadmeSampleTests` (conventions) holds each block to exactly one region. - `spikes/` holds experiments that decided something. Each one has a README with the question, the answer and the evidence. Spikes are not in the main solution and are never packed. -- `docs/` holds the plan and the design notes. +- `docs/` holds the topic pages a consumer reads, one per topic, each linked from the root README's + "Documentation" table; `design/` holds the roadmap, the decision records and the design notes, + which no test compiles but whose links the conventions check. Never hard-code a positional path (`../../`) to reach the repo root. Walk up to the `CodoMetis.TypeKit.slnx` marker instead. @@ -63,6 +67,45 @@ The SDK is pinned in `global.json` (10.0.4xx band, `latestPatch`). Tests run on Platform (xunit.v3 4.x), so `dotnet test` takes `--solution`/`--project`, and TRX output is `--report-xunit-trx`. The build treats warnings as errors, including CS1591 on shipping projects. +## Finding code + +The codebase-memory MCP server keeps a code graph of this repository, the project +`CodoMetis.TypeKit`. It is the fastest way into the code, and it misses whole kinds of reference +here, so it leads and the source decides. + +- **The graph first** to locate a symbol, list a type's implementers, trace callers and callees, and + size a change's impact (`search_graph`, `trace_path`, `query_graph`, `get_code_snippet`). +- **Then the source and its tests**, read before concluding or editing. An edge is a lead. +- **Text search** (`git grep`, `search_code`) for literals, messages, diagnostic ids, + configuration, documentation, and what the generators emit. +- **An empty result proves nothing.** Fall back to text search when the graph is unavailable, stale + or silent, and never conclude "unused", "no other caller" or "safe to change" from it alone. + Measured on 2026-10-13, the graph: + - finds types, interfaces, enums and ordinary methods, a type's implementers through generic + interfaces (84 of the Samples' 88 value objects), and the callers of an ordinary method across + files; + - has no node for a member of a C# 14 `extension(...)` block, which holds most of the `Option` and + `Result` extensions (`Choose`, `FirstOrNone`, `Transpose`, `Sequence`, the continuations of a + `Task>`) and `AsText`, and puts the calls inside one on the file; + - has nothing Metalama generates: none of the tests' 222 `.From(` calls has an edge, nor has a call + to `TryFrom`, `FromKnownGood`, `Parse` or a converter, and a template an aspect introduces by + `nameof` looks unreferenced. Read woven code as the Generators README's "Reading the generated + code" says; + - misses a reference that is not a call: a DI registration, a type argument, `typeof`, an attribute; + - resolves a call on a generic instance poorly (`Option.Match` has one resolved caller, where + the repository writes `.Match(` about 200 times across its types) and guesses wrong callers: a low-confidence `suffix_match` edge, or a same-named method of another API + (Metalama's `Diagnostics.Report` listed as calling `GeneratedCode.Report`). Overloads share one + node. +- **Refresh it**, since it does not follow the working tree: `index_repository` with the repository + root as `repo_path` and `name: "CodoMetis.TypeKit"`, at the start of a session and after a branch + switch, a rebase, a merge or a pull (about 6 s). Without `name` it starts a second project under a + derived name. `index_status` reports the checkout's HEAD, not the commit the graph was built from, + so refresh rather than check. +- It indexes `spikes/` and leaves out `.claude/`, `bin` and `obj`, so an agent in a worktree under + `.claude/worktrees/` reads the main checkout's graph, not its own: index the worktree under a name + of its own and delete that project when done, or search the source. `.codebase-memory/`, the + tool's local artifact, is ignored by git. + ## Rules that are easy to break - **`CodoMetis.TypeKit` stays Metalama-free.** Nothing in the base package may reference @@ -91,7 +134,7 @@ Platform (xunit.v3 4.x), so `dotnet test` takes `--solution`/`--project`, and TR `JsonSerializer` call that takes only options, in the packages or in woven code: a typed call (`GeneratedValueObjectAttribute.Accept`, `GeneratedJson.TypeInfo`) or an interface instead. An `[UnconditionalSuppressMessage]` needs a measured reason, and the smoke test's `aot` consumer is - the measurement (docs/plan.md §11). + the measurement ([design/native-aot.md](design/native-aot.md)). - **Aspects stay internal.** Their public surface is what they generate, and nothing else. - **A value object's JSON is its wrapped type's, byte for byte**, under whatever options the application uses, values and dictionary keys alike (`JsonParityTests`). Never write or read the @@ -104,18 +147,22 @@ Platform (xunit.v3 4.x), so `dotnet test` takes `--solution`/`--project`, and TR introduce is CMTK1011. A new generated member joins that check, or a hand-written one is kept silently. - **Diagnostic ids (`CMTK`) are public contract.** Never renumber a shipped rule, and never ship a new rule at Error severity in a minor version. -- **A README's C# block is compiled code.** Change it and its region in +- **A README's or a docs page's C# block is compiled code.** Change it and its region in `test/CodoMetis.TypeKit.Samples` together, or `ReadmeSampleTests` fails; the region's scaffolding (the types and variables it assumes) stays outside the markers. An output a block states in a comment (SQL, JSON, a string) is read from the README by the test beside the region, so correct the comment, never the test. A block that cannot compile on purpose says `` above its fence; an analyzer diagnostic in a comment is no reason, the sample suppresses it around the region. + A page in `docs/` is compiled by being there and must be linked from the root README's + "Documentation" table; a package README is the reference for its package, a docs page the guide for + a topic, and the same code block is never in both. - **Silent is worse than broken.** A value object that compiles without being generated, an analyzer that stops firing, or a converter that materialises `default` are this repo's characteristic failures. Prefer a diagnostic or an exception to a plausible result. ## Workflow -1. Read `docs/plan.md` and the relevant spike README before starting. +1. Read `design/plan.md`, the roadmap, the decision records and the design note for the area you + touch, and the relevant spike README, before starting. A decision is changed by a new record. 2. Run `dotnet test` after each change. 3. **Prove every fix by reverting it.** See `.claude/skills/verify-the-guard`. The same applies to a new analyzer test or a convention test: seed the defect it claims to catch. diff --git a/CHANGELOG.md b/CHANGELOG.md index fec0219..321a420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,51 @@ A version's heading reads `## — Unreleased` until the release is pre the release date. The release workflow refuses a tag whose section still says `Unreleased`, and the section is the GitHub release's notes. +## 2.0.0 — 2026-10-14 + +Still `net10.0` and Metalama 2026.1. What builds or runs differently, and what to write instead: +[Migrating from 1.5 to 2.0](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/migrating-to-2.md). + +### Breaking + +- CMTK0003, a dropped `Result` or `Option`, is an Error, and reports more forms (`Wait()`, loops that never read, `??`, tuples). +- `FirstOrNone`/`FirstOrError` on an `IQueryable` run in the query (a `Func` variable still reads every row); `LastOrNone`/`LastOrError` on a query no longer compile. +- CMTK1022 refuses an abstract value object; CMTK1005 refuses one over `object` or `System.Enum`, or named as a generated member. +- A generated `TryParse` lets an exception from `Create` through instead of answering `false`. +- EF Core refuses `.Value` on a column with the application's own converter, and ordering by a hand-written `CompareTo`, in queries and query filters. +- EF Core: value-object keys over `sbyte`, `ushort`, `uint` and `ulong` are generated as EF generates the plain type, so a migration may alter the column. +- OpenAPI: a component name claimed by two types throws. +- Async combinators throw `ArgumentNullException` for a delegate that returns no task. +- `FaultProblem` takes any `notnull` fault type. + +### Added + +- **Closed sets** (`IClosedSet`): a fixed set of named instances with `Match`, `Parse`, JSON, EF Core, OpenAPI and Serilog support; CMTK1013 to CMTK1021 refuse what cannot be generated. +- **Fault responses a client can act on**: `Fault`, fault sets with a kind per member, `FaultResponse`, `WithFaultStatus`/`[FaultStatus]` per endpoint, group or controller, `ToHttpResult(success)` and `IFaultFeature`. +- **`Patch`**: a JSON Merge Patch field (absent, null or a value; `T` is `notnull`, so `Patch` is a clearable string), with `Map`, `Traverse`, OpenAPI and test assertions. +- **`Result.Catch`/`CatchAsync`**: an exception as a result; a cancellation passes through, a timeout is the error. +- **Text guards**: `AsText()` and `AsIdentifier()` trim, compose to NFC (`AsText`) and refuse invisible characters. +- **Type constraints**: `[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]` (CMTK0010) and `[RequireParameterlessConstructor]` (CMTK0011, with a code fix). +- **CMTK0012**: a closed set's `Match` called by position, with a code fix. +- **CMTK0013**: a closed set's member written outside its declaration, after it was named. +- **`Option`**: async steps and `Task>` continuations, `SingleOrNone`, `ElementAtOrNone`, `Choose`, `Transpose`, `Traverse`; EF Core's `FirstOrNoneAsync` and `SingleOrNoneAsync`. +- **`TraverseAsync`** on a sequence: one at a time, cancellable. +- **Docs**: topic pages under `docs/`, every code block compiled. + +### Changed + +- The OpenAPI problem schema describes `fault`: regenerate clients. +- CMTK0003 no longer reports a chain of taps on a result the caller holds. +- EF Core: a `char` key stays the application's to assign; a default `ulong` key throws `OverflowException` at `Add` (use `ValueGeneratedNever()`). +- Testing failures name a nested type with its containing types. +- `CodoMetis.TypeKit.Generators` depends on `Metalama.Framework` 2026.1.30 or later (was 2026.1.29). + +### Fixed + +- A value object marked `[Obsolete(error: true)]` builds. +- A default value object over a reference type formats as `""` instead of throwing. +- Where the host inlines enums, a nullable value object over one admits null in the OpenAPI document. + ## 1.5.1 — 2026-10-11 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12700cc..ff3d4e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,18 +5,44 @@ instead. Please don't open a public issue for those. ## Getting set up +A machine needs: + +- **The .NET SDK that `global.json` names**, or a later patch of its feature band. Other bands and + previews are not used, so `dotnet --version` in the repository root must print a version of that + band. The pin moves with .NET's servicing releases; when it does, install the new patch. Install + Microsoft's build, from [dot.net](https://dotnet.microsoft.com/download) or, on macOS, the + `dotnet-sdk` Homebrew cask. Homebrew's `dotnet` formula trails the releases, and its Native AOT + publish failed to link (`ld: library 'ssl' not found`); `brew shellenv` puts it first on `PATH`, so + put `/usr/local/share/dotnet` before it, or `brew unlink dotnet`. +- **Docker, running** (Docker Desktop, OrbStack, Colima). The PostgreSQL round-trip tests start a + container through Testcontainers; without Docker those fail with `DockerUnavailableException`, + and every other test passes. +- **The platform's native toolchain** for the smoke test, which publishes two consumers with Native + AOT, a web host and a console application with EF Core: Xcode's command-line tools on macOS + (`xcode-select --install`), `clang` and `zlib1g-dev` on Debian and Ubuntu. + +An IDE started from the Dock or a launcher does not read the shell's profile: point its .NET CLI path +at the same SDK (Rider: Settings, Build, Execution, Deployment, Toolset and Build). + +After an SDK upgrade, a build that fails with MSB4242 ("workload set … missing manifests") still has +the old SDK's workload set. Run `sudo dotnet workload update` in the repository root, where +`global.json` selects the SDK it updates; elsewhere it updates whichever SDK is newest there. + ```bash dotnet build CodoMetis.TypeKit.slnx dotnet test --solution CodoMetis.TypeKit.slnx ./test/consumer-smoke-test.sh # packs the packages and builds throwaway consumers against them ``` -The EF Core tests start a PostgreSQL container, so the suite needs Docker. The smoke test publishes -one consumer with Native AOT, which needs the platform's native toolchain: clang (Xcode's command-line -tools on macOS; `clang` and `zlib1g-dev` on Debian and Ubuntu). Shipping projects live -under `src/` and test projects under `test/`. -[AGENTS.md](AGENTS.md) is the architecture guide and [docs/plan.md](docs/plan.md) the current -roadmap. Read both before changing `Option`/`Result`, an aspect, the fabric, the analyzer or a +Dependabot raises the GitHub Actions weekly and the test projects' packages monthly, except +Metalama, EF Core, ASP.NET Core and Roslyn. Those and the SDK pin are raised by hand: the shipping +references among them are floors every consumer restores against (`Directory.Packages.props`), so +raising one is a release decision. + +Shipping projects live under `src/` and test projects under `test/`; the topic pages a consumer reads are in `docs/`, each +compiled like the READMEs, and the plan is in `design/`. +[AGENTS.md](AGENTS.md) is the architecture guide and [design/plan.md](design/plan.md) the map of the +design notes, the decision records and the roadmap. Read both before changing `Option`/`Result`, an aspect, the fabric, the analyzer or a satellite. ## The quality bar diff --git a/CodoMetis.TypeKit.slnx b/CodoMetis.TypeKit.slnx index 752bec8..0f481b2 100644 --- a/CodoMetis.TypeKit.slnx +++ b/CodoMetis.TypeKit.slnx @@ -2,6 +2,7 @@ + @@ -23,7 +24,14 @@ + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index 31b0814..4e8b881 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.5.1 + 2.0.0 CaffeinatedCoder Copyright (c) 2026 CaffeinatedCoder MIT @@ -43,7 +43,7 @@ --> true - 1.5.0 + 1.5.1 diff --git a/Directory.Packages.props b/Directory.Packages.props index 1f4e81c..bcebbd9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,8 +7,8 @@ one is a release decision, not a dependency update. --> - - + + + + + - + + + + + diff --git a/README.md b/README.md index fb3be29..5d8829b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ # CodoMetis.TypeKit -Strong types for .NET 10: `Option` and `Result` types that cannot be misused, and value objects -that are written as one line and generated at compile time. +Strong types for .NET 10, with one set of rules from the request to the database. Value objects and +closed sets (smart enums) are written as one line and generated at compile time. A value object's one +`Create` is what JSON, route binding, parsing and `TryFrom` apply, and the fault it returns reaches an +HTTP problem response as a typed `Result`, with nothing to translate between libraries on the way. +EF Core and the OpenAPI document see the type it wraps. `Option` and `Result` cannot be misused: no +`.Value`, no `default` in source, no ignored result. [![.NET](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/actions/workflows/dotnet.yml/badge.svg)](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/actions/workflows/dotnet.yml) [![NuGet](https://img.shields.io/nuget/v/CodoMetis.TypeKit.svg)](https://www.nuget.org/packages/CodoMetis.TypeKit) @@ -24,22 +28,43 @@ factories, value equality, a JSON converter, `IParsable`, `IFormattable`, `IComp A value object can wrap a primitive, a `Guid`, a date, an enum, a `Uri`, a NodaTime type or a type of your own, and it can be declared inside another type. +A closed set, the smart enum, is declared the same way, one line per member: + +```csharp +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} +``` + +The generators add `All`, parsing by name, JSON as the name, an OpenAPI string enum and a text +column in EF Core, with nothing registered per set. `Match` takes one required delegate per member, +so a member added later fails every call until it is handled. + +Beside the generated types, the base package holds: + +- `Patch`, a field of a JSON Merge Patch: absent, null or a value. +- `Result.Catch`, which turns an exception into a result. +- `AsText()` and `AsIdentifier()`, for text a person wrote and a value another system issued. +- `[Sealed]`, `[Concrete]` and `[RequireParameterlessConstructor]`, constraints C# has no `where` + for, checked by the analyzers. + ## Packages | Package | Role | Metalama | |---|---|---| -| [CodoMetis.TypeKit](src/CodoMetis.TypeKit/README.md) | `Option`, `Result`, `Result`, the value-object contracts, and the analyzers that guard them | no | -| [CodoMetis.TypeKit.Analyzers](src/CodoMetis.TypeKit.Analyzers/README.md) | CMTK0001–CMTK0009: no `default` of a value object, an `Option` or a `Result`, written in source or handed out by `FirstOrDefault` and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values. In Razor components too. Arrives with the base package | no | +| [CodoMetis.TypeKit](src/CodoMetis.TypeKit/README.md) | `Option`, `Result`, `Result`, the value-object contracts, the text guards `AsText()` and `AsIdentifier()`, and the analyzers that guard them | no | +| [CodoMetis.TypeKit.Analyzers](src/CodoMetis.TypeKit.Analyzers/README.md) | CMTK0001–CMTK0013: no `default` of a value object, an `Option` or a `Result`, written in source or handed out by `FirstOrDefault` and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values; no type argument that breaks what its parameter declares with `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`; no type without the constructor its host will look for; no closed-set `Match` bound by position; no closed-set member written after it was named. In Razor components too. Arrives with the base package | no | | [CodoMetis.TypeKit.Generators](src/CodoMetis.TypeKit.Generators/README.md) | The compile-time generation, in the project that declares value objects | yes | | [CodoMetis.TypeKit.EntityFrameworkCore](src/CodoMetis.TypeKit.EntityFrameworkCore/README.md) | Value objects as columns with nothing registered per type, and `.Value` in LINQ | no | | [CodoMetis.TypeKit.AspNetCore](src/CodoMetis.TypeKit.AspNetCore/README.md) | Value objects in the OpenAPI document, with the schema of the type they wrap; a `Result` as the response, a fault as problem details | no | | [CodoMetis.TypeKit.Serilog](src/CodoMetis.TypeKit.Serilog/README.md) | Value objects logged as what they print, also where Serilog destructures them, so a value masked by its `ToString()` stays masked | no | -| [CodoMetis.TypeKit.Testing](src/CodoMetis.TypeKit.Testing/README.md) | Test assertions for `Option` and `Result` that hand back the value or the error, and fail saying what the outcome was. No test framework dependency | no | +| [CodoMetis.TypeKit.Testing](src/CodoMetis.TypeKit.Testing/README.md) | Test assertions for `Option`, `Result` and `Patch` that hand back the value or the error, and fail saying what the outcome was. No test framework dependency | no | The base package is the cheapest one: someone who only wants `Option` and `Result` never receives a -code generator by accident. Taking Metalama on is a named choice, made once, in the domain project, -and it needs no Metalama license key: the generators build on Metalama's Open Source edition, in this -repository and in yours. +code generator by accident. Taking Metalama on is a named choice, made once, in the domain project. The EF Core and ASP.NET Core packages work at run time against the interfaces, so a host that maps value objects from a referenced domain assembly needs no generator itself. @@ -92,6 +117,31 @@ db.Customers.Where(c => c.Email.Value.EndsWith("@example.com")); // WHERE services.AddOpenApi(options => options.AddTypeKit()); // OrderId: {"type":"string","format":"uuid"} ``` +## Documentation + +The package READMEs above are the reference for each package: what it adds, its setup, and the +rules it holds to, which is also where every exception message and analyzer help link lands. The +pages below are the guides, one topic each, written around examples. Every C# block in them is +compiled and its stated outputs are tested, as the READMEs' are. + +| Page | Covers | +|---|---| +| [Value objects](docs/value-objects.md) | Declaring one, struct or class, identifiers, validated text and numbers, faults, which factory to call, your own members, generic code over value objects | +| [Closed sets](docs/closed-sets.md) | Smart enums: declaring one, data on its members, `Match`, JSON and dictionary keys, EF Core columns, configuration, logging, what the generators refuse, when an `enum` is still right | +| [Option](docs/option.md) | Absence as a type: creating, matching, combining, converting at the boundary, and the complete public surface | +| [Result](docs/result.md) | Outcomes as a type: faults, pipelines, async chains, collecting results, an exception as an outcome, errors across layers, and the complete public surface | +| [Written text](docs/text.md) | `AsText()` and `AsIdentifier()`: what each cleans and refuses, a value object over either, a required and an optional field | +| [Type constraints](docs/type-constraints.md) | `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` on a type parameter, `[RequireParameterlessConstructor]` and its code fix | +| [Merge patches](docs/merge-patches.md) | `Patch`: absent, null or a value, applying a patch, value objects inside one, writing one, the OpenAPI document, source-generated contexts | +| [JSON](docs/json.md) | What a value object writes and reads, refusals, stored documents, `Option` and `Result` at the wire, a converter of your own, source-generated contexts | +| [EF Core](docs/ef-core.md) | Columns, keys, conventions, queries and the SQL they become, seed data, reading back without validation, SQLite in tests | +| [ASP.NET Core](docs/aspnet-core.md) | Binding in minimal APIs and MVC, what a refused value answers, a `Result` as a response, fault responses a client can act on, the OpenAPI document, problem details | +| [Native AOT](docs/native-aot.md) | What is measured, the project settings, JSON contexts, OpenAPI, EF Core's compiled model, configuration binding, discovery without reflection | +| [Logging and secrets](docs/logging-and-secrets.md) | A value that must not be printed, what still carries it, Serilog, Microsoft.Extensions.Logging, refusals that never quote the input | +| [Testing](docs/testing.md) | Assertions for `Option`, `Result` and `Patch`, table-driven tests of `Create`, EF Core on SQLite, endpoints on the test server | +| [Migrating from 1.5 to 2.0](docs/migrating-to-2.md) | What builds or runs differently: CMTK0003 as an error, a query's `FirstOrNone` in the database, names that 2.0 adds, fault responses, Native AOT | +| [How it compares](docs/comparison.md) | What to choose instead for a need TypeKit does not meet, and the detail tables against the value-object, result and smart-enum libraries, versioned and dated | + ## What it holds to - **No `.Value` on `Option` or `Result`.** The content is reached through `Match`, `TryGetValue` @@ -106,8 +156,10 @@ services.AddOpenApi(options => options.AddTypeKit()); // Orde the generated `TryFrom`, `FromKnownGood`, JSON converter, parsing and type converter all apply it. The fault you return from `Create` is what each of them reports: the `JsonException`, the `FormatException` and the `FromKnownGood` exception all name your `EmailFault.NoAt`. The two - validation-free paths, reading a database column and reading JSON the application stored itself, - are explicit, named, and unreachable from input. + validation-free paths are explicit and named: the EF Core satellite reads a database column + through the materializer, and `StoredJsonConverterFactory` reads JSON the application stored + itself. Register that factory only on the options you read your own JSON with, never on those + that read a request. - **No factory throws on input by accident.** A validated value object has no `From`. Its ways in are `Create`, which returns a `Result`, `TryFrom`, which returns an `Option`, and `FromKnownGood`, whose name says the caller vouches for the value and whose exception blames the call site. @@ -115,30 +167,34 @@ services.AddOpenApi(options => options.AddTypeKit()); // Orde rule, or the wrapped type it could not read, and carries no inner exception that quotes the text; `FromKnownGood` names the caller's expression, and `Option` and `Result` print nothing. So a value that is a secret does not reach a message or a log through this package, as long as your fault - type does not carry it. -- **Swapping a primitive for a value object changes no contract.** Its JSON is the wrapped type's, - byte for byte, under your options; its column is the wrapped type's, a key over an integer still an - identity column; its OpenAPI schema is the one ASP.NET publishes for the wrapped type. Only what - the value object refuses changes. + type does not carry it. The exception is System.Text.Json's own: a refused dictionary key is in + the `JsonException`'s `Path`, and so in MVC's 400 + ([Logging and secrets](docs/logging-and-secrets.md#refusals-never-quote-the-input)). +- **Swapping a primitive for a value object keeps the contract.** Its JSON is the wrapped type's + under your options, apart from a `DateTime`, which is written as UTC, and number handling set by an + attribute on the containing type, which no converter sees + ([JSON](docs/json.md#the-wrapped-types-json)); its column is the wrapped type's, a key over an + integer still an identity column; its OpenAPI schema is the one ASP.NET publishes for the wrapped + type. What changes is what the value object refuses, a JSON `null` for a struct value object + included, and under a source-generated context an enum-backed value object needs its enum listed + ([JSON](src/CodoMetis.TypeKit.Generators/README.md#json)). - **Loud failures.** A declaration that cannot be generated is a build error naming the - declaration, CMTK1000 to CMTK1012, never a type with nothing in it. The analyzers make `default` + declaration, CMTK1000 to CMTK1022, never a type with nothing in it. The analyzers make `default` of a value object, an `Option` or a `Result` an error. - **Discovery by interface.** The EF Core and OpenAPI satellites recognise a value object by the - attribute the generators put beside `IValueObject<,>`, whose type arguments are constrained to it, - never by a name, a namespace or an assembly prefix, and never by `GetInterfaces()`, which trimming - breaks. + attribute the generators put beside `IValueObject<,>`, whose type arguments are constrained to that + interface. Renaming a type or a namespace changes nothing, and trimming keeps the attribute. - **Native AOT.** The run-time packages build with the trim and AOT analyzers on, and a consumer with value objects, source-generated JSON, OpenAPI and EF Core is published with Native AOT and run on every CI build. Each package's README says what, if anything, Native AOT asks of you. ## How it compares -Good libraries exist for both halves of TypeKit, and some needs are better met by one of them. What -follows was read from each library's source at the release named, on 28 September 2026, including the -next major where one is in preview. If something here is wrong or has changed, please open an issue. - -### Choose TypeKit when +Good libraries exist for both halves of TypeKit. TypeKit fits when: +- You want the value objects, the outcomes and the HTTP and database adapters to share one model: + the fault `Email.Create` returns is the error of a `Result` pipeline and then the problem response, + without mapping between a validation library, a result library and an adapter. - You build on .NET 10 with System.Text.Json, EF Core and ASP.NET Core, and want value objects that change no contract: the JSON, the column and the OpenAPI schema stay the wrapped type's, with nothing registered per type, and `.Value` works inside a LINQ query. @@ -149,44 +205,9 @@ next major where one is in preview. If something here is wrong or has changed, p ignored result reported, no silent `{}` on the wire. - You publish with Native AOT. -### Choose something else when - -| If you need | Consider | Because | -|---|---|---| -| .NET Framework, .NET Standard or a .NET before 10 | Vogen for value objects; CSharpFunctionalExtensions, ErrorOr or FluentResults for results | TypeKit targets .NET 10 only; these ship for .NET Standard 2.0 | -| Newtonsoft.Json, Dapper, LINQ to DB, MongoDB, Orleans, ServiceStack or protobuf-net for value objects | Vogen | It ships an opt-in integration for each. TypeKit covers System.Text.Json, EF Core and ASP.NET Core | -| A plain Roslyn source generator in the build rather than Metalama | Vogen or Thinktecture.Runtime.Extensions | TypeKit's generators run on Metalama (open source, no license key) in the project that declares value objects | -| Value objects of several members, smart enums or discriminated unions | Thinktecture.Runtime.Extensions | It has `[ComplexValueObject]`, `[SmartEnum]` and `[Union]`. A TypeKit value object wraps exactly one value | -| Several errors per failure, of typed kinds | ErrorOr | An `ErrorOr` carries a list of errors. A TypeKit `Result` carries one `TError`, which can be a list of your own | -| Errors with reasons, success messages and causes | FluentResults | Its results carry reasons, errors and successes, and an error can name what caused it | -| A functional-programming framework: `Either`, `Fin`, effects, immutable collections | LanguageExt (4.4.9; 5.0 in beta) | TypeKit's `Option` and `Result` are deliberately small, with no effect or validation types | -| Unions of arbitrary types | OneOf | `OneOf` is general-purpose; TypeKit has only `Option` and `Result` | -| A `Result` on the wire | CSharpFunctionalExtensions | It ships opt-in System.Text.Json converters for its `Result`. TypeKit refuses to serialize `Option` and `Result`, by design | -| Only your own closed set of outcomes, on .NET 11 | C#'s union types | They arrive with .NET 11 | -| Years of production use behind the library | Any of the above | TypeKit 1.0.0 is new | - -### Value objects in detail - -| | TypeKit 1.0.0 | Vogen 8.0.7 (9.0.0-beta.2 the same) | Thinktecture.Runtime.Extensions 10.5.0 | StronglyTypedId 1.0.0-beta08 | -|---|---|---|---|---| -| Declared by | Implementing `IValue` or `IValidatedValue<…>` on a `partial record` | `[ValueObject]` on a partial type | `[ValueObject]`, `[ComplexValueObject]` or `[SmartEnum]` on a partial type | `[StronglyTypedId]` on a partial struct | -| Validation | `Create` returns a `Result`, and every generated way in applies it | `Validate` returns a `Validation`; `From` throws | A `ValidateFactoryArguments` partial method | None | -| EF Core | `UseTypeKit()` maps every value object, nothing per type; `.Value` in a query translates to the column | A generated converter per value object, which you register; comparing with a primitive in a query is an error (VOG034) | Packages per EF Core version (8, 9, 10) | Through a template of your own | -| OpenAPI | The schema ASP.NET publishes for the wrapped type | A generated schema transformer, type and format from a built-in table; Swashbuckle support | Swashbuckle filters | None built in | -| JSON | System.Text.Json, the wrapped type's JSON byte for byte | System.Text.Json by default; Newtonsoft.Json and more, opt-in | System.Text.Json, Newtonsoft.Json, MessagePack | System.Text.Json (more through templates) | -| Targets | `net10.0` | `netstandard2.0`, .NET Framework projects included | `net8.0`, `net9.0` | `netstandard2.0` | -| Generated by | Metalama, in the declaring project | A Roslyn source generator | Roslyn source generators | A Roslyn source generator | -| License | MIT | Apache-2.0 | BSD-3-Clause | MIT | - -### Option and Result in detail - -| | TypeKit 1.0.0 | LanguageExt 4.4.9 (5.0.0-beta-77) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | -|---|---|---|---|---| -| Reaching the value | `Match`, `TryGetValue` and the combinators; no `.Value` | `Match`, `IfNone` and the like; no public `.Value` on `Option` | `.Value`, which throws on a failure | `.Value`, which returns `default` on an error | -| A `default` instance | `Option`: `None`. `Result`: throws when used, and is an error in source (CMTK0001) | `Option`: `None` | `Maybe`: none. `Result`: a success holding `default(T)` | A success holding `default(T)` | -| An ignored result | Reported (CMTK0003) | No analyzer | No analyzer of its own | No analyzer | -| System.Text.Json | Refused in both directions, rather than written as `{}` | No converter of its own | Opt-in converters | No converter for the type | -| Targets | `net10.0` | `netstandard2.0` (5.0 beta: `net10.0`) | `netstandard2.0`, `net6.0`, `net8.0` | `netstandard2.0`, `net8.0`, `net10.0` | +What to choose instead for the needs TypeKit does not meet, and the detail tables against Vogen, +Thinktecture.Runtime.Extensions, StronglyTypedId, LanguageExt, CSharpFunctionalExtensions, ErrorOr, +Ardalis.SmartEnum and Intellenum, versioned and dated, are on [How it compares](docs/comparison.md). ## Built with Metalama @@ -196,24 +217,26 @@ references `CodoMetis.TypeKit.Generators` and in every project that references t object is declared by implementing its interface, with no attribute and nothing registered. Templates write the generated members as ordinary C#, which a build can write out for you to read ([Reading the generated code](src/CodoMetis.TypeKit.Generators/README.md#reading-the-generated-code)). -A declaration that cannot be generated fails the build with its name on it, CMTK1000 to CMTK1012, +A declaration that cannot be generated fails the build with its name on it, CMTK1000 to CMTK1022, instead of compiling to less than it appears to. And Metalama runs the analyzers on the code it transformed, which is how they reach Razor components. -Metalama is in `CodoMetis.TypeKit.Generators` alone. `CodoMetis.TypeKit`, the analyzers and both -satellites do not reference it, so a project that only wants `Option` and `Result` never receives +Metalama is in `CodoMetis.TypeKit.Generators` alone. `CodoMetis.TypeKit`, the analyzers and the +four satellites do not reference it, so a project that only wants `Option` and `Result` never receives it. It is open source under the MIT license, and building with it needs no license key, here or in your projects. Thanks to the Metalama team. ## Status -1.0.0 is the first release. The packages share one version and follow Semantic Versioning: +2.0.0 is the current version; 1.0.0, of 28 September 2026, was the first release, and +[Migrating from 1.5 to 2.0](docs/migrating-to-2.md) lists what builds or runs differently since 1.5. +The packages share one version and follow Semantic Versioning: the public API, the generated members of a value object, and the analyzer ids and severities are the contract. A new analyzer rule arrives as a warning or a suggestion in a minor version, and becomes an error only in a major one. -[CHANGELOG.md](CHANGELOG.md) lists what each version changed. The plan, the decisions and their -evidence are in [docs/plan.md](docs/plan.md), and the measurements that decided the design are in -[spikes/](spikes/). +[CHANGELOG.md](CHANGELOG.md) lists what each version changed. The design notes, the decision records +and the roadmap are under [design/](design/plan.md), and the measurements that decided the design are +in [spikes/](spikes/). ## Building diff --git a/SECURITY.md b/SECURITY.md index 9784329..69a93d4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,7 +15,7 @@ subject. Helpful to include, as far as you have it: - The package and version (`CodoMetis.TypeKit`, `.Analyzers`, `.Generators`, - `.EntityFrameworkCore`, `.AspNetCore`) + `.EntityFrameworkCore`, `.AspNetCore`, `.Serilog`, `.Testing`) - The value object declaration involved, and whether the value came from application code or from parsed input (JSON, route, query string, database) - A minimal reproduction @@ -41,8 +41,9 @@ for going public, whatever the fix status. Reporters are credited unless they as ## Supported versions -Security fixes land on the latest minor version of the latest major version, 1.x at present. The -packages share one version, so a fix in any of them is a release of all of them. +Security fixes land on the latest minor version of the latest major version, 2.x at present; 1.x +gets none once 2.0.0 is released, and [Migrating from 1.5 to 2.0](docs/migrating-to-2.md) lists what +the move changes. The packages share one version, so a fix in any of them is a release of all of them. ### If this project stops being maintained diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..cf367da --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/caffeinatedcoder/codometis.typekit", + "public_key": "pk_DZ1KMuxb1Q651t4CnyA6E" +} diff --git a/design/adr/0001-license-mit.md b/design/adr/0001-license-mit.md new file mode 100644 index 0000000..99cd1e0 --- /dev/null +++ b/design/adr/0001-license-mit.md @@ -0,0 +1,8 @@ +# 0001. License: MIT + +Date: 2026-09-26 +Status: accepted + +Context: 2026-09-26. + + diff --git a/design/adr/0002-name-codometis-typekit.md b/design/adr/0002-name-codometis-typekit.md new file mode 100644 index 0000000..d905b0d --- /dev/null +++ b/design/adr/0002-name-codometis-typekit.md @@ -0,0 +1,9 @@ +# 0002. Name: `CodoMetis.TypeKit` + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +, replacing `CodoMetis.ValueObjects`, which was too + narrow. Package map in [Packages](../packages.md). diff --git a/design/adr/0003-analyzer-ids-cmtk.md b/design/adr/0003-analyzer-ids-cmtk.md new file mode 100644 index 0000000..2c1ef5c --- /dev/null +++ b/design/adr/0003-analyzer-ids-cmtk.md @@ -0,0 +1,9 @@ +# 0003. Analyzer ids: `CMTK` + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +Ids are public contract: a rule keeps its id even if it + later moves to another package. diff --git a/design/adr/0004-metalama-2026-1-x-latest-stable.md b/design/adr/0004-metalama-2026-1-x-latest-stable.md new file mode 100644 index 0000000..470b521 --- /dev/null +++ b/design/adr/0004-metalama-2026-1-x-latest-stable.md @@ -0,0 +1,15 @@ +# 0004. Metalama 2026.1.x (latest stable) + +Date: 2026-09-26 +Status: accepted + +Context: 2026-09-26. + +, not the 2027.0 preview. + - Aspect state keeps `IDurableRef`, which exists in 2026.1. + - `[Durable]` on the `_value` template placeholder is omitted, because it is 2027.0-only, and + is added back on upgrade. + - A 2026.1-built package works in a 2027.0 consumer (fabric spike finding 11). + - Once 2027.0 is stable, upgrading is a planned task: MIT maintenance covers only the latest + `YYYY.N`. + - **Metalama is the generator technology for this project**, not a candidate for replacement. diff --git a/design/adr/0005-option-result.md b/design/adr/0005-option-result.md new file mode 100644 index 0000000..fc3358d --- /dev/null +++ b/design/adr/0005-option-result.md @@ -0,0 +1,9 @@ +# 0005. Option/Result + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +: pure `Option`/`Result`, not a LanguageExt-style framework. + They ship in the base package. No `Try`/`Unit` ([Option and Result](../option-result.md)). diff --git a/design/adr/0006-result-shape.md b/design/adr/0006-result-shape.md new file mode 100644 index 0000000..b9d8dc0 --- /dev/null +++ b/design/adr/0006-result-shape.md @@ -0,0 +1,9 @@ +# 0006. `Result` shape + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +: `Result` (value first), `readonly record struct`, + private fields, non-positional ([Option and Result](../option-result.md)). diff --git a/design/adr/0007-analyzer-own-package.md b/design/adr/0007-analyzer-own-package.md new file mode 100644 index 0000000..68926df --- /dev/null +++ b/design/adr/0007-analyzer-own-package.md @@ -0,0 +1,8 @@ +# 0007. Analyzer as its own package + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +, reaching consumers through the base package ([Analyzers](../analyzers.md)). diff --git a/design/adr/0008-uninitialized-result-throws.md b/design/adr/0008-uninitialized-result-throws.md new file mode 100644 index 0000000..feda2ad --- /dev/null +++ b/design/adr/0008-uninitialized-result-throws.md @@ -0,0 +1,8 @@ +# 0008. An uninitialized `Result` throws + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +instead of reporting `default(TError)` ([Option and Result](../option-result.md)). diff --git a/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md b/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md new file mode 100644 index 0000000..a9aa19e --- /dev/null +++ b/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md @@ -0,0 +1,9 @@ +# 0009. EF mapping through an additive `IRelationalTypeMappingSourcePlugin` + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +, not a + replaced `IValueConverterSelector` or a pre-convention scan ([EF Core](../ef-core.md), spikes/EfMapping). diff --git a/design/adr/0010-stored-json-storedjsonconverterfactory.md b/design/adr/0010-stored-json-storedjsonconverterfactory.md new file mode 100644 index 0000000..15238ed --- /dev/null +++ b/design/adr/0010-stored-json-storedjsonconverterfactory.md @@ -0,0 +1,6 @@ +# 0010. Stored JSON through `StoredJsonConverterFactory` + +Date: 2026-09-27 +Status: accepted + +in the base package (2026-09-27, [EF Core](../ef-core.md)). diff --git a/design/adr/0011-openapi-schema-transformer.md b/design/adr/0011-openapi-schema-transformer.md new file mode 100644 index 0000000..86604ef --- /dev/null +++ b/design/adr/0011-openapi-schema-transformer.md @@ -0,0 +1,8 @@ +# 0011. OpenAPI through one schema transformer + +Date: 2026-09-27 +Status: accepted + +that fills a value object's schema in from the +wrapped type's, as ASP.NET publishes it (2026-09-27, [OpenAPI](../openapi.md), spikes/OpenApiSchemas), not a document +transformer and not a format table. diff --git a/design/adr/0012-asp0020-documented-suppressed.md b/design/adr/0012-asp0020-documented-suppressed.md new file mode 100644 index 0000000..9448584 --- /dev/null +++ b/design/adr/0012-asp0020-documented-suppressed.md @@ -0,0 +1,9 @@ +# 0012. ASP0020 is documented, not suppressed + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, [OpenAPI](../openapi.md). + +: the package cannot suppress it, and +a blanket suppression would hide the check for every other type. diff --git a/design/adr/0013-first-release-s-rule-set-cmtk0001.md b/design/adr/0013-first-release-s-rule-set-cmtk0001.md new file mode 100644 index 0000000..91ef1d4 --- /dev/null +++ b/design/adr/0013-first-release-s-rule-set-cmtk0001.md @@ -0,0 +1,12 @@ +# 0013. The first release's rule set is CMTK0001–CMTK0009 + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Analyzers](../analyzers.md); CMTK0009 added the same +day, at Warning, for the calls that hand out a default. + +CMTK0004 ships at Error +in 1.0.0, the one release where a new Error rule needs no major version; CMTK0003, CMTK0005 and +CMTK0006 at Warning, CMTK0006 once its fix was measured; CMTK0007 at Info, reporting only a +value that comes straight from a parameter. diff --git a/design/adr/0014-context7-json.md b/design/adr/0014-context7-json.md new file mode 100644 index 0000000..54a4a61 --- /dev/null +++ b/design/adr/0014-context7-json.md @@ -0,0 +1,14 @@ +# 0014. A `context7.json` links the repository to its Context7 entry + +Date: 2026-10-13 +Status: accepted + +Context: taken undated in the plan, before the first release, as "add it once the repo is public"; +revised 2026-10-13. + +The repository has been public since v1.0.0 (2026-09-28), and Context7 indexes it as +`/caffeinatedcoder/codometis.typekit`, 459 code snippets from the READMEs and the topic pages on +2026-10-13. The sibling repositories, CodoMetis.ValueRanges and EFCore.ComplexIndexes, carry a +`context7.json` at their root naming their Context7 URL and the account's public key, which links the +repository to the entry so that its documentation is re-indexed from source. This one carries the +same, with its own URL. The key is a public one, already in both siblings. diff --git a/design/adr/0015-option-result-wire-types.md b/design/adr/0015-option-result-wire-types.md new file mode 100644 index 0000000..9ee9017 --- /dev/null +++ b/design/adr/0015-option-result-wire-types.md @@ -0,0 +1,10 @@ +# 0015. `Option`/`Result` are not wire types + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, [Option and Result](../option-result.md). + +: a `[JsonConverter]` on every exported +struct of the base package throws `NotSupportedException` in both directions. Not a lossless +converter, and not an analyzer. diff --git a/design/adr/0016-native-aot-wherever-platform-allows.md b/design/adr/0016-native-aot-wherever-platform-allows.md new file mode 100644 index 0000000..dfe1dd3 --- /dev/null +++ b/design/adr/0016-native-aot-wherever-platform-allows.md @@ -0,0 +1,11 @@ +# 0016. Native AOT wherever the platform allows it + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, [Native AOT](../native-aot.md). + +: the run-time packages build with +`IsAotCompatible`, the woven code is published with Native AOT in the smoke test, and a path that +needs reflection or dynamic code is rewritten rather than annotated or suppressed. EF Core itself +stays experimental under Native AOT; the satellite adds nothing to what EF requires. diff --git a/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md b/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md new file mode 100644 index 0000000..cecf344 --- /dev/null +++ b/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md @@ -0,0 +1,13 @@ +# 0017. Run-time discovery reads `GeneratedValueObjectAttribute` + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, [Discovery](../discovery.md), [Native AOT](../native-aot.md). + +, +which the generators put on every value object, not `GetInterfaces()`. Measured under Native AOT: +the interface was gone from every value object reached through a property, and the OpenAPI +satellite described each as `{}`. The attribute's constraints are the interfaces, so what a value +object is has not changed; its `Accept` hands the two types to a visitor as type arguments, which +is how the EF satellite makes a converter without `MakeGenericType`. diff --git a/design/adr/0018-name-per-concept.md b/design/adr/0018-name-per-concept.md new file mode 100644 index 0000000..dc08c35 --- /dev/null +++ b/design/adr/0018-name-per-concept.md @@ -0,0 +1,23 @@ +# 0018. One name per concept + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, before the first release, when renaming still costs nothing. + +- What a value object holds is *wrapped*: `GeneratedValueObjectAttribute.WrappedType` and + `TranslatedAsWrappedValueAttribute`, not "underlying", and not `ValueType`, which in .NET means + a struct and here returned `typeof(string)` for a string-backed value object. +- A value object without rules is *plain*: `IPlainValueObject`, which has `From`. It was + `IValueWrapper`, which named what every value object does. +- The value object's own type parameter is `TSelf` on the self-referencing contracts, as in + `IParsable`, and `TValueObject` where a helper or attribute names one. Never `TValue`, + which `KnownGood` used for the value object while "value" means the wrapped value everywhere + else. +- Delegate parameters: `onSome`/`onNone`, `onSuccess`/`onError`, `selector`, `predicate`, + `action`, as LINQ names them. They were `fnSome`, `map`, `fn`, `check` and `defaultProvider`. +- Namespaces follow who uses a type ([Packages](../packages.md)). The helpers only generated code calls moved to + `CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense, and `Accepted` and `KnownGood` + became one `GeneratedFactories` whose methods name the exception each entry point throws. +- Internally: `ValueObjectAspect` (was the implementation aspect), `ValueObjectContractAspect`, + `ValueObjectCompanionAspect`, `ValueObjectKind.Plain`. diff --git a/design/adr/0019-concise-implicit.md b/design/adr/0019-concise-implicit.md new file mode 100644 index 0000000..4e3f13d --- /dev/null +++ b/design/adr/0019-concise-implicit.md @@ -0,0 +1,24 @@ +# 0019. Concise, not implicit + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, [Option and Result](../option-result.md). + +Markers where a target type exists, explicit type +arguments where none does. +- One word for success: `Result.Success(...)` beside `Result<…>.Success(...)`, the `Success` + markers, `ResultState.Success` and `onSuccess`, as `Option.Some` sits beside `Option`. + `Result.Ok` was the one place that said it differently. +- `Option.None()` returns a marker that converts to any `Option`; `Option.None()` stays for + `var`. The analyzer forbids `default(Option)`, so every None needed its type argument. +- `MapError` on both shapes, for crossing layers, which needed + `Match>(x => x, e => Result.Error(…))`; and `Bind(Func>)`, a + command after a query, which replaces the collapsing `Match(_ => Result.Ok(), e => e)`, a + conversion wearing `Match`'s name. +- `Option.ToResult(error)` keeps the value (`Result`); it returned `Result`, + dropping it. `Coalesce` is `Or`, beside `OrDefault()` and `OrNull()`. +- Not added: an implicit `T` → `Option`, since a null would throw exactly where a reader + expects `None`; a bare error → `Result` (the ambiguity above); a shipped + `using static`. A lambda that returns a bare value and `Result.Error(...)` still needs its type + argument, because C# infers a lambda's return type from its body alone; the README says so. diff --git a/design/adr/0020-net10-0-only.md b/design/adr/0020-net10-0-only.md new file mode 100644 index 0000000..fba17f6 --- /dev/null +++ b/design/adr/0020-net10-0-only.md @@ -0,0 +1,10 @@ +# 0020. net10.0 only + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +The satellites cannot go lower (EF Core 10 and +Microsoft.AspNetCore.OpenApi 10 require it), the contracts use static abstract interface members, +which rules out netstandard2.0, and .NET 8's support ends on 2026-11-10. diff --git a/design/adr/0021-first-release-1-0-0.md b/design/adr/0021-first-release-1-0-0.md new file mode 100644 index 0000000..2774268 --- /dev/null +++ b/design/adr/0021-first-release-1-0-0.md @@ -0,0 +1,11 @@ +# 0021. The first release is 1.0.0 + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +, not 0.1.0. What the reasoning above says of "the +first release" (CMTK0004 at Error, refusals that can be lifted later) holds for 1.0.0 unchanged. +From then on the public API, the generated surface and the rule ids and severities follow +Semantic Versioning, and package validation compares every pack with 1.0.0. diff --git a/design/adr/0022-registration-extensions-live-framework-s-namespaces.md b/design/adr/0022-registration-extensions-live-framework-s-namespaces.md new file mode 100644 index 0000000..d22ee12 --- /dev/null +++ b/design/adr/0022-registration-extensions-live-framework-s-namespaces.md @@ -0,0 +1,12 @@ +# 0022. Registration extensions live in the framework's namespaces + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +: `UseTypeKit()` in +`Microsoft.EntityFrameworkCore`, `AddEntityFrameworkTypeKit()` and `OpenApiOptions.AddTypeKit()` in +`Microsoft.Extensions.DependencyInjection`, as EF's providers and `AddOpenApi` do, so the one line a +host adds needs no `using` (the docs audit found every setup sample missing one). Types stay in +`CodoMetis.TypeKit.*`. Moving them after 1.0.0 would break every host. diff --git a/design/adr/0023-value-object-key-over-integer-generated.md b/design/adr/0023-value-object-key-over-integer-generated.md new file mode 100644 index 0000000..27da149 --- /dev/null +++ b/design/adr/0023-value-object-key-over-integer-generated.md @@ -0,0 +1,9 @@ +# 0023. A value-object key over an integer is generated on add; one over a `Guid` is assigned + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [EF Core](../ef-core.md). + +Parity with the wrapped key where only the database can make one, and the +application's `OrderId.New()` where it can. diff --git a/design/adr/0024-nested-value-objects-named-after-nesting.md b/design/adr/0024-nested-value-objects-named-after-nesting.md new file mode 100644 index 0000000..37d1ee6 --- /dev/null +++ b/design/adr/0024-nested-value-objects-named-after-nesting.md @@ -0,0 +1,8 @@ +# 0024. Nested value objects are named after their nesting chain in the OpenAPI document + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [OpenAPI](../openapi.md). + +, as their companion classes are. diff --git a/design/adr/0025-analyzer-s-roslyn-floor-stays-5.md b/design/adr/0025-analyzer-s-roslyn-floor-stays-5.md new file mode 100644 index 0000000..937bbaa --- /dev/null +++ b/design/adr/0025-analyzer-s-roslyn-floor-stays-5.md @@ -0,0 +1,12 @@ +# 0025. The analyzer's Roslyn floor stays at 5.0.0 + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +, the compiler of the 10.0.1xx SDK band, +which is serviced for all of .NET 10 and is what Linux distributions build. Measured: built on +5.9.0 (SDK 10.0.4xx) and run by the 5.0.0 compiler, the analyzer is not loaded (CS9057, a warning) +and `default(Option)` compiles. `AnalyzerPackagingTests` reads the packed analyzer's +references. The build-time `Microsoft.CodeAnalysis.Analyzers` follows the SDK (5.9.0). diff --git a/design/adr/0026-value-object-s-json-wrapped-type.md b/design/adr/0026-value-object-s-json-wrapped-type.md new file mode 100644 index 0000000..5f3b105 --- /dev/null +++ b/design/adr/0026-value-object-s-json-wrapped-type.md @@ -0,0 +1,9 @@ +# 0026. A value object's JSON is its wrapped type's, byte for byte + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Aspects](../aspects.md). + +, under whatever +options the application uses: swapping a primitive for a value object changes no wire format. diff --git a/design/adr/0027-no-refusal-quotes-input.md b/design/adr/0027-no-refusal-quotes-input.md new file mode 100644 index 0000000..9a2b178 --- /dev/null +++ b/design/adr/0027-no-refusal-quotes-input.md @@ -0,0 +1,10 @@ +# 0027. No refusal quotes the input + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Aspects](../aspects.md). + +, not even one the wrapped type raises: every +generated `Parse`, type converter and JSON read replaces the wrapped type's exception with one that +names the value object and the wrapped type. diff --git a/design/adr/0028-seams-declared-everything-else-refused.md b/design/adr/0028-seams-declared-everything-else-refused.md new file mode 100644 index 0000000..c36f9bc --- /dev/null +++ b/design/adr/0028-seams-declared-everything-else-refused.md @@ -0,0 +1,13 @@ +# 0028. Seams are declared, everything else refused + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Aspects](../aspects.md). + +: `TryFrom`, `FromKnownGood`, +`Revalidate`, `CompareTo(TSelf)` and `ToString()` may be written by hand and are kept; any other +member or attribute the generators introduce is CMTK1011, never kept silently. Since phase 11 so +is an explicit implementation of an interface they implement, a hand-written equality, and +`PrintMembers` without `ToString()`, declared or inherited from a base record, whose `sealed` +`ToString()` is the seam; state besides the wrapped value is CMTK1012. diff --git a/design/adr/0029-only-result-terror-converts-bool.md b/design/adr/0029-only-result-terror-converts-bool.md new file mode 100644 index 0000000..da3371d --- /dev/null +++ b/design/adr/0029-only-result-terror-converts-bool.md @@ -0,0 +1,11 @@ +# 0029. Only `Result` converts to `bool` + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Option and Result](../option-result.md). + +On a valued result the conversion +said whether the operation succeeded where a reader expects the value: `if (await +IsEmailTakenAsync(email))` over `Result` took the branch for `Success(false)`. The +same reason keeps a bare error from converting into one. Match it, or compare `State`. diff --git a/design/adr/0030-asynchronous-callbacks-return-task.md b/design/adr/0030-asynchronous-callbacks-return-task.md new file mode 100644 index 0000000..e9adf07 --- /dev/null +++ b/design/adr/0030-asynchronous-callbacks-return-task.md @@ -0,0 +1,11 @@ +# 0030. Asynchronous callbacks return `Task` + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Option and Result](../option-result.md). + +`TapErrorAsync(Func)` sits +beside `Action`, as `TapAsync` already did. It ships in 1.0 because adding it later +rebinds existing calls. No `ValueTask` overloads: beside the `Task` ones every `async` lambda +becomes ambiguous (CS0121, measured). diff --git a/design/adr/0031-no-extension-object-unconstrained-t.md b/design/adr/0031-no-extension-object-unconstrained-t.md new file mode 100644 index 0000000..fac087d --- /dev/null +++ b/design/adr/0031-no-extension-object-unconstrained-t.md @@ -0,0 +1,10 @@ +# 0031. No extension on `object` or on an unconstrained `T` + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Option and Result](../option-result.md). + +: it is offered on every +expression of every file that imports the namespace, and collides with anyone else's. +`No_extension_member_extends_every_type` holds it. diff --git a/design/adr/0032-value-object-holds-wrapped-value-alone.md b/design/adr/0032-value-object-holds-wrapped-value-alone.md new file mode 100644 index 0000000..bbec6a2 --- /dev/null +++ b/design/adr/0032-value-object-holds-wrapped-value-alone.md @@ -0,0 +1,11 @@ +# 0032. A value object holds its wrapped value alone, and its equality is that value's + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Aspects](../aspects.md). + +Everything generated (JSON, parsing, the type converter, the EF column, comparison) carries +the wrapped value, so other state (CMTK1012), a hand-written equality or an explicit +implementation beside a generated member (CMTK1011, CMTK1008) would make two views of one value +disagree. Normalise in `Create` instead. diff --git a/design/adr/0033-analyzers-run-code-metalama-transformed.md b/design/adr/0033-analyzers-run-code-metalama-transformed.md new file mode 100644 index 0000000..b043b14 --- /dev/null +++ b/design/adr/0033-analyzers-run-code-metalama-transformed.md @@ -0,0 +1,10 @@ +# 0033. The analyzers run on the code Metalama transformed + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Analyzers](../analyzers.md). + +, through a +`MetalamaTransformedCodeAnalyzer` item in the analyzer package's `buildTransitive` props. The +namespace covers every rule, a future one included. diff --git a/design/adr/0034-errors-fixed-within-major-version-warnings.md b/design/adr/0034-errors-fixed-within-major-version-warnings.md new file mode 100644 index 0000000..c03e1d7 --- /dev/null +++ b/design/adr/0034-errors-fixed-within-major-version-warnings.md @@ -0,0 +1,11 @@ +# 0034. Errors are fixed within a major version; Warnings may learn + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [Analyzers](../analyzers.md). + +A new Error, or +an Error that reports more, waits for a major version. A Warning or Info may report more forms in +a minor version. The CMTK0003, CMTK0008 and CMTK0009 gaps the second review found were closed +before 1.0.0 was tagged all the same, so the rules ship as complete as the review could make them. diff --git a/design/adr/0035-sqlite-s-autoincrement-application-s-line.md b/design/adr/0035-sqlite-s-autoincrement-application-s-line.md new file mode 100644 index 0000000..42bda72 --- /dev/null +++ b/design/adr/0035-sqlite-s-autoincrement-application-s-line.md @@ -0,0 +1,12 @@ +# 0035. SQLite's autoincrement is the application's line, not the convention's + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [EF Core](../ef-core.md). + +EF's +SQLite provider decides by the CLR type, and the only other way in is an annotation typed by the +SQLite assembly: setting it means naming the provider or writing a foreign annotation into every +other provider's model. The README documents `UseAutoincrement()`; `MigrationSnapshotTests` fails +once EF fixes the check, which is when the note can go. diff --git a/design/adr/0036-nullable-value-object-admits-null-openapi.md b/design/adr/0036-nullable-value-object-admits-null-openapi.md new file mode 100644 index 0000000..b8d6213 --- /dev/null +++ b/design/adr/0036-nullable-value-object-admits-null-openapi.md @@ -0,0 +1,9 @@ +# 0036. A nullable value object admits null in the OpenAPI document wherever it appears + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [OpenAPI](../openapi.md). + +: as a property, as a container's element (`oneOf: [null, component]`, ASP.NET's own form), +and inlined (null added to `type`, or `oneOf` around an inlined enum). diff --git a/design/adr/0037-refusal-s-message-landing-page.md b/design/adr/0037-refusal-s-message-landing-page.md new file mode 100644 index 0000000..22e60bb --- /dev/null +++ b/design/adr/0037-refusal-s-message-landing-page.md @@ -0,0 +1,11 @@ +# 0037. A refusal's message is its landing page + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +: an exception has no help link, so every +deliberate run-time refusal says what to do instead and ends with a link to its subsection of the +base README's "Why does this throw?", and every generator build error with one to the Build errors +table. The links are held to the README's headings by tests. Nothing of the input is added. diff --git a/design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md b/design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md new file mode 100644 index 0000000..a4a7826 --- /dev/null +++ b/design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md @@ -0,0 +1,18 @@ +# 0038. A `Result` becomes a response through an explicit `ToHttpResult()`, its faults mapped in the host + +Date: 2026-10-11 +Status: accepted + +Context: 2026-10-11, phase 12, spikes/ResultHttp. + +The return type is ASP.NET's +`Results, FaultProblem>`, so ASP.NET describes the success and the Request Delegate +Generator needs nothing of ours; a handler returning the `Result` itself would document a `Result`. +The fault type is an **enum**: its members are what a mapping can be checked against when it is +registered, and what the document lists. A fault of another shape is `MapError`ed to one at the +endpoint. The extensions live beside the framework's (`ToHttpResult` in `Microsoft.AspNetCore.Http`, +`AddFaultResponses` in `Microsoft.Extensions.DependencyInjection`, [decision 22](0022-registration-extensions-live-framework-s-namespaces.md)). A missing mapping +stops the host from starting (a startup filter, which comes with the first `AddFaultResponses`), a +value no member names is refused rather than answered, and the README's mapping ends in a throwing +discard arm: the registration calls it for every member, so a missed member still fails at startup, +and without the arm C# reports CS8524. diff --git a/design/adr/0039-value-object-s-fault-does-become.md b/design/adr/0039-value-object-s-fault-does-become.md new file mode 100644 index 0000000..04a65bd --- /dev/null +++ b/design/adr/0039-value-object-s-fault-does-become.md @@ -0,0 +1,14 @@ +# 0039. A value object's fault does not become a validation error + +Date: 2026-10-11 +Status: accepted + +Context: 2026-10-11, phase 12, +spikes/ValidationFaults. + +ASP.NET Core 10's validation never sees a value that failed to bind, a +refused route or query value has lost its fault, and MVC already reports a refused body with the +converter's message. What remained, a minimal-API body, needs `ThrowOnBadRequest` for the whole +host, an exception handler in its pipeline, and the fault carried as data by a new public type in +the base package: too much host-wide change for one of four positions. Reopen it if ASP.NET hands +a binding failure to a hook without `ThrowOnBadRequest`. diff --git a/design/adr/0040-codometis-typekit-testing-holds-assertions-hand.md b/design/adr/0040-codometis-typekit-testing-holds-assertions-hand.md new file mode 100644 index 0000000..1acaa36 --- /dev/null +++ b/design/adr/0040-codometis-typekit-testing-holds-assertions-hand.md @@ -0,0 +1,17 @@ +# 0040. `CodoMetis.TypeKit.Testing` holds assertions that hand back the content + +Date: 2026-10-11 +Status: accepted + +Context: 2026-10-11, phase 12. + +`ShouldBeSome()`, `ShouldBeSuccess()` and `ShouldBeError()` return the value or the error, so the +test compares it with the assertions it already uses, and the package depends on no test framework: +a failure is a `TypeKitAssertionException`, which every runner reports. A failure names the error +it found and never prints a value, as `Option` and `Result` do not; an uninitialized result fails +every assertion as uninitialized, since `default(TError)` would otherwise read as an expected +first-member fault. **No generators**: one that goes through `Create` is a single filter over the +test's own property-based library (`Gen.String.Where(s => Email.TryFrom(s).IsSome())` in CsCheck), +and shipping it would tie the package to one such library. The package is new in 1.5.0, so its +project clears the package-validation baseline while that is 1.0.0, a condition that lapses on its +own when the baseline moves. diff --git a/design/adr/0041-sensitive-value-object-declares-tostring-serilog.md b/design/adr/0041-sensitive-value-object-declares-tostring-serilog.md new file mode 100644 index 0000000..fb23858 --- /dev/null +++ b/design/adr/0041-sensitive-value-object-declares-tostring-serilog.md @@ -0,0 +1,20 @@ +# 0041. A sensitive value object is one that declares `ToString()`; Serilog gets a satellite + +Date: 2026-10-11 +Status: accepted + +Context: 2026-10-11, +phase 12, spikes/SensitiveValues. + +The seam already masks every formatting and +Microsoft.Extensions.Logging path, so a marker would generate the one line a user writes, and is +not added. Serilog's `{@x}` read the public `Value` of every value object, masked or not: +`CodoMetis.TypeKit.Serilog` registers one destructuring policy, `Destructure.WithTypeKit()`, that +logs a value object as a scalar of its `ToString()`, recognised by `GeneratedValueObjectAttribute`. +Every value object is then a string in the event, as Microsoft.Extensions.Logging writes it, rather +than its wrapped value's JSON type: what it prints is the one rule, and the policy cannot tell a +masked `ToString()` from the generated one. Serilog's floor is 4.0.0, the first of the current +major, and the tests run on it. A classification on the type is advised against: the logging +generator refuses the method (LOGGEN035). + +Still open: diff --git a/design/adr/0042-2-0-0-next-release-bundles.md b/design/adr/0042-2-0-0-next-release-bundles.md new file mode 100644 index 0000000..8894435 --- /dev/null +++ b/design/adr/0042-2-0-0-next-release-bundles.md @@ -0,0 +1,14 @@ +# 0042. 2.0.0 is the next release, and it bundles the 1.x candidates + +Date: 2026-10-12 +Status: accepted + +Context: 2026-10-12, phase 13. + +The +fault responses, `Patch` and the smart-enum family ship together in the major that already +waits for Metalama 2027.0 stable, [decision 34](0034-errors-fixed-within-major-version-warnings.md)'s promotions and C# 15 unions. A 1.6.0 first was the +alternative. Every part is additive, so it would have been a minor version, but the fault mapping +would have been designed on enums with `FaultKind` attributes read by reflection, then rebuilt +once smart enums exist. The cost is time: nothing ships before the later of .NET 11's GA and +Metalama 2027.0 stable. diff --git a/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md b/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md new file mode 100644 index 0000000..9304f83 --- /dev/null +++ b/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md @@ -0,0 +1,22 @@ +# 0043. Exceptions, type-argument constraints, a constructor guarantee and the text guards join 2.0.0 + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +The goals were reopened and four candidates weighed. `Try` stays +out, since it is `Result` renamed, and `Result.Catch` takes its one job; `Unit` +stays out, since `Result` is the value-less shape. Constraints a type parameter cannot +declare (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`) ship as attributes and an analyzer +rule, not as an aspect, because the violation is at a use site that need not compile with +Metalama. `[EnsureParameterlessConstructor]` (shipped as `[RequireParameterlessConstructor]`) ships as an attribute, an analyzer rule and a code +fix, not as an inheritable aspect, because its value is the error and an introduced constructor +would demand `partial` of every implementer and be silent without Metalama. The written-text +guards ship as string extensions and a fault in the base package, with no value object and no +`.Text` package: the base package cannot declare one for the generators, a hand-written one is +what CMTK0002 refuses, a woven one in a package of its own costs a private generators reference, +a boundary exemption, a baseline override and a smoke consumer for one line per field, and the +type cannot be refined (CMTK1005) or told apart from another field of the same type (CMTK0008). +Public aspects are allowed in principle ([Aspects](../aspects.md)) and none exists. Clock-type analyzers and +decimal JSON converters stay out. diff --git a/design/adr/0044-2-0-0-net10-0-metalama.md b/design/adr/0044-2-0-0-net10-0-metalama.md new file mode 100644 index 0000000..90e8f6e --- /dev/null +++ b/design/adr/0044-2-0-0-net10-0-metalama.md @@ -0,0 +1,17 @@ +# 0044. 2.0.0 is `net10.0` on Metalama 2026.1; the version number names the API, never the runtime + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +Dropping a target framework is a break of its own, so a `net11.0`-only +2.0.0 would have left every consumer on the .NET 10 LTS line (supported to November 2028) on +1.5.x, without the fault responses, the closed sets or `Patch`, for a .NET 11 that is STS. The +breaks 2.0.0 actually has, [decision 34](0034-errors-fixed-within-major-version-warnings.md)'s promotions and two rules at Error, are breaks on +`net10.0`, and none of its parts needs C# 15 unions. A `net11.0` target is additive and joins a +2.x minor when a feature needs it; unions in the public surface would be two contracts under one +version, so they and the `net10.0` drop are 3.0.0, timed to .NET 12 LTS. Metalama 2027.0 joins +the first release after it is stable, minor or major as the fabric measurement says. The +`2.0.0-preview.N` branch of 2026-10-12 becomes a preview of 2.0.0 itself from `main`; the .NET 11 +measurement is a 3.0.0 spike with no release attached. diff --git a/design/adr/0045-topic-pages-docs-compiled-like-readmes.md b/design/adr/0045-topic-pages-docs-compiled-like-readmes.md new file mode 100644 index 0000000..4e58ad5 --- /dev/null +++ b/design/adr/0045-topic-pages-docs-compiled-like-readmes.md @@ -0,0 +1,22 @@ +# 0045. Topic pages in `docs/`, compiled like the READMEs; the plan in `design/` + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase +13. + +The package READMEs stay the reference for each package, since nuget.org shows them and every +exception message and analyzer help link anchors into them. Guides, one topic each and written +around examples, are `docs/.md`, listed in the root README's "Documentation" table, which +`ReadmeSampleTests` reads: every page in `docs/` is compiled by being there (its samples are +`{file name}/{name}` in `test/CodoMetis.TypeKit.Samples/Docs`), must be linked from the table, +and the table links only pages that exist. The same code block is never in a README and a page, +since a block is compiled from exactly one region. A page's public-surface table (`option.md`, +`result.md`) is checked against the types by reflection, so a member added without a row fails. +The plan and the design notes moved to `design/`, so that `docs/` holds only what a consumer +reads and nothing there is exempt from compilation; `design/` is a solution folder. Eleven pages +came first; `merge-patches.md` and `closed-sets.md` joined them once the docs branch merged into +2.0.0 (2026-10-13). The fault responses are in the ASP.NET Core and closed-set pages, no public +aspect is planned ([decision 43](0043-exceptions-type-argument-constraints-constructor-guarantee.md) allows one, and none exists), and `migrating-to-2.md` lists what +builds or runs differently for 1.5 code. diff --git a/design/adr/0046-timeout-caught-result-catch-error.md b/design/adr/0046-timeout-caught-result-catch-error.md new file mode 100644 index 0000000..97dc9cd --- /dev/null +++ b/design/adr/0046-timeout-caught-result-catch-error.md @@ -0,0 +1,16 @@ +# 0046. A timeout caught by `Result.Catch` is the error + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +Every overload let +an `OperationCanceledException` through, and `HttpClient` throws one over a `TimeoutException` +when its `Timeout` elapses, so `CatchAsync(() => client.GetAsync(…))` threw on the most common +failure of the call the README shows it with, and a sample test that +raced a 100 ms timeout against a refused port failed whenever the timeout won (measured). A +timeout is the call failing, not the caller cancelling: an `OperationCanceledException` whose +`InnerException` is a `TimeoutException` is caught, every other one still passes through. No +`CancellationToken` overload: the shape tells the two apart without one, and a token cancelled by +the caller still surfaces as cancellation. diff --git a/design/adr/0047-firstornone-query-runs-query.md b/design/adr/0047-firstornone-query-runs-query.md new file mode 100644 index 0000000..43f504f --- /dev/null +++ b/design/adr/0047-firstornone-query-runs-query.md @@ -0,0 +1,15 @@ +# 0047. `FirstOrNone` on a query runs in the query + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +The `IEnumerable` +overloads were the only ones, so `db.Orders.FirstOrNone(o => o.Id == id)` read the table and ran +the predicate in memory, with no warning (reproduced: no `WHERE`, no `LIMIT`). `IQueryable` +overloads take an expression and `Take(1)`, a break only for a predicate the provider cannot +translate, which now throws: a major's to make. `LastOrNone` and `LastOrError` on a query are +`[Obsolete(error: true)]`, as `Catch`'s task trap is, since a query has no last element until it +is ordered. EF's `FirstOrNoneAsync` lives in the satellite, in EF's namespace; under Native AOT +neither is precompiled (measured), which the EF README says with the call-site form to use. diff --git a/design/adr/0048-intended-break-suppressed-alone-baseline-stays.md b/design/adr/0048-intended-break-suppressed-alone-baseline-stays.md new file mode 100644 index 0000000..15fcdd4 --- /dev/null +++ b/design/adr/0048-intended-break-suppressed-alone-baseline-stays.md @@ -0,0 +1,14 @@ +# 0048. An intended break is suppressed alone; the baseline stays + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +The plan +left the choice to the first break package validation reported, and it came with the fault +responses: `FaultProblem` takes `notnull` where it took `struct, Enum` (CP0021), so that a +closed set can be a fault type. Each intended break gets its own entry in the project's +`CompatibilitySuppressions.xml` (generated with `-p:ApiCompatGenerateSuppressionFile=true`, then +read before it is committed), and the 1.5.1 baseline keeps comparing every other member, so an +unintended break still fails the pack. Letting the baseline lapse for 2.0.0 would have passed one. diff --git a/design/adr/0049-fault-responses-client-can-act.md b/design/adr/0049-fault-responses-client-can-act.md new file mode 100644 index 0000000..c96bee9 --- /dev/null +++ b/design/adr/0049-fault-responses-client-can-act.md @@ -0,0 +1,28 @@ +# 0049. Fault responses a client can act on + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13, spikes/FaultResponses. + +The five +parts ship as proposed, with these choices measured or made on the way: +- **A kind's status is fixed**: `NotFound` 404, `Conflict` 409, `Invalid` 422 (RFC 9110 + §15.5.21, a well-formed request a rule refuses), `Forbidden` 403, `Unavailable` 503. No + host-wide table: a mapping of the host's own, calling `FaultResponse.For(kind)` for the members + it keeps, is the escape hatch, as [decision 38](0038-result-becomes-response-explicit-tohttpresult-faults.md)'s switch is. `FaultKind` starts at 1, so a + `default` kind is refused where the set is mapped. +- **`Fault`'s fault is its `Reason`**, not its `Kind` as first proposed: a member's + `FaultKind` is its `Kind`, and `fault.Kind.Kind` would have read as a stutter. A blank detail is + refused; `null` says there is none. +- **A description is the `detail`**: RFC 9457 §3.1.4 has `detail` help the client correct the + problem, and §3.1.3 keeps `title` the same for every occurrence of a type, which a status's own + title already is. An occurrence's detail replaces the member's description. +- **`IFault` is non-generic**, beside `IFaultSet`, so the satellite reads a member's kind + with an interface check whatever the fault type, and `Description` defaults to `null`. +- **Overloads by constraint live in two classes**, since C# forbids two methods that differ in + constraints alone; with the type arguments inferred, the one whose constraint fails drops out. +- **The document lists problem types in the response's description**, and `fault` stays a string + on the shared problem schema; a per-operation enum is not offered. +- **An MVC action names its member in an attribute**, `[FaultStatus(nameof(…), 422)]`, + since an attribute takes only constants; a name no member has stops the host. diff --git a/design/adr/0050-warnings-cmtk0003-alone-becomes-error-2.md b/design/adr/0050-warnings-cmtk0003-alone-becomes-error-2.md new file mode 100644 index 0000000..eecf49b --- /dev/null +++ b/design/adr/0050-warnings-cmtk0003-alone-becomes-error-2.md @@ -0,0 +1,25 @@ +# 0050. Of the Warnings, CMTK0003 alone becomes an Error in 2.0.0 + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +Decision 34 +lets a major raise a Warning; the bar for raising one is that every report can be answered in the +code by a form that says what is meant, never by a suppression alone, and that its reach is +complete, since an Error cannot learn within the major. CMTK0003 meets both: `_ =`, a cast or a +loop variable named `_` says a drop is meant, and its reach is a return type rather than a list +of methods, complete once its 1.x gaps were closed (`Wait`, `Task.WaitAll`, a loop that never +reads its results, a lazy sequence materialised). The others stay. CMTK0005's array filled in a +loop and CMTK0006's member set in a second step (`OnInitialized`, `InitializeAsync`, a private +field `required` cannot reach) have only a suppression to say so. CMTK0008's shared identities +are answered by a cast an IDE reports as redundant, and its four known gaps would be frozen for +2.x. CMTK0009's reach is a list of framework methods, which the `net11.0` target of a 2.x minor +may lengthen, and a sequence checked for emptiness first is correct. CMTK0007 reports test +theories. The 1.5.1 baseline's verdict, over all seven packages: under package validation's +default rules one difference, CP0021 on `FaultProblem` ([decision 48](0048-intended-break-suppressed-alone-baseline-stays.md)); with the +attribute and parameter-name rules on as well, 29 compiler-emitted attributes (state machines +renumbered by members added before them, nullable-context bytes) and no renamed parameter. The +analyzer's severity and the query overloads' binding are behaviour no compatibility check reads; +the changelog states each. diff --git a/design/adr/0051-closed-set-parses-ignoring-case-only.md b/design/adr/0051-closed-set-parses-ignoring-case-only.md new file mode 100644 index 0000000..0b7bb56 --- /dev/null +++ b/design/adr/0051-closed-set-parses-ignoring-case-only.md @@ -0,0 +1,16 @@ +# 0051. A closed set parses ignoring case only when asked, and refuses members that differ only in case + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +The ordinal parse stays the default and the only one JSON, the +column, binding and `IParsable` use, since what is stored or sent is the name as it is. A header +or a query string a client typed is read with `ignoreCase: true` at the call, as +Ardalis.SmartEnum's `FromName(name, ignoreCase)` is, and not through a per-set setting, which +would change what the wire accepts without a word at the call site. With the overload, two +members whose names differ only in case are one name to the parse, so CMTK1021 refuses them at +the declaration, where it already refused the first-letter case (`Open`, `open`) that gave +`Match` one parameter twice. The peers meet such pairs at run time: Ardalis throws on the first +ignore-case lookup, Thinktecture on the first `Get`, and Intellenum answers the first declared. diff --git a/design/adr/0052-cause-on-fault-joins-2-1.md b/design/adr/0052-cause-on-fault-joins-2-1.md new file mode 100644 index 0000000..90eac24 --- /dev/null +++ b/design/adr/0052-cause-on-fault-joins-2-1.md @@ -0,0 +1,21 @@ +# 0052. A `Cause` on `Fault` joins 2.1 + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, after the FluentResults comparison was re-read for 2.0.0. + +A `Fault` is one reason from a fixed set and a sentence about the occurrence. What a caught +exception was is lost on the way to it: `Result.Catch` makes the exception the whole error, and the +`MapError` that turns it into the fault an endpoint answers drops it, so a log of the refusal cannot +say what failed underneath. A cause nested under an error is the one FluentResults capability a +TypeKit user plausibly misses; success messages and a list of reasons stay outside what a `Result` +here is ([decision 5](0005-option-result.md), [Option and Result](../option-result.md)). + +In 2.1, additive: an optional `Exception? Cause` on `Fault`, set through +`Fault.Of(reason, detail, cause)`, kept beside the reason and the detail for the log and the fault +feature on the request, and never written to the problem, whose `detail` stays the application's +sentence and whose body names no exception. Equality stays the reason's and the detail's, so a +fault with a cause still equals the same fault without one where a test compares them; the cause +is for the log. A `MapError` from a `Result` that keeps the exception as the cause is +the pattern the docs show. Not a chain: one cause, which may itself be an aggregate exception. diff --git a/design/adr/0053-fault-made-by-fault-of-alone.md b/design/adr/0053-fault-made-by-fault-of-alone.md new file mode 100644 index 0000000..6608f04 --- /dev/null +++ b/design/adr/0053-fault-made-by-fault-of-alone.md @@ -0,0 +1,18 @@ +# 0053. A `Fault` is made by `Fault.Of` alone + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [Option and Result](../option-result.md). + +`Fault` had an implicit conversion from its reason, and the first review's fix refused +`fault == reason`, `!=` and `Equals(TFault)`. The conversion still reached a comparison everywhere +else: `Fault? f == F.NotFound`, `List>.Contains(F.NotFound)`, a `HashSet`, +`EqualityComparer>.Default.Equals(f, F.NotFound)`, and Testing's +`ShouldBeError(F.NotFound)`, each compiling and false whenever the fault carried a detail (measured). +The conversion is gone; `Fault.Of(reason)` and `Fault.Of(reason, detail)` make a fault. The refused +operators and `Equals(TFault)` stay, since without them `==` is CS0019, which does not say to +compare `Reason`, and `fault.Equals(reason)` binds to `Equals(object)` and is always false. +`Array.IndexOf(array, reason)` still compiles, through `Array.IndexOf(Array, object)`, as +`object.Equals` does; nothing in the type can refuse it. `Fault` is new in 2.0.0, so this +breaks nothing that shipped. diff --git a/design/adr/0054-abstract-value-object-refused.md b/design/adr/0054-abstract-value-object-refused.md new file mode 100644 index 0000000..a40079d --- /dev/null +++ b/design/adr/0054-abstract-value-object-refused.md @@ -0,0 +1,15 @@ +# 0054. An abstract type with a value-object marker is refused (CMTK1022) + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [Aspects](../aspects.md). + +The fabric selected only non-abstract types, so `abstract partial record A : IValue;` +compiled with no member and no diagnostic, and a value held as `A`, of a sealed type derived from +it, was written as `{}` (measured). That is the repository's characteristic failure, a type that +compiles without being generated. CMTK1022 refuses it at Error, reported by the fabric on the +abstract type. A new Error ships only in a major version +([decision 34](0034-errors-fixed-within-major-version-warnings.md)), and 2.0.0 is one; a type that +built against 1.5.1 with such a declaration generated nothing then either. An abstract closed set +was already CMTK1014 (not sealed). A sealed type derived from an abstract one is still generated. diff --git a/design/adr/0055-component-name-claimed-twice-refused.md b/design/adr/0055-component-name-claimed-twice-refused.md new file mode 100644 index 0000000..7c7ab60 --- /dev/null +++ b/design/adr/0055-component-name-claimed-twice-refused.md @@ -0,0 +1,21 @@ +# 0055. A component name two types claim is refused; a qualified fault name follows the nesting chain + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [OpenAPI](../openapi.md). + +[Decision 24](0024-nested-value-objects-named-after-nesting.md) names a nested type's component after +its nesting chain, `Order.Id` as `OrderId`. A nested closed set `Order.Status` beside a top-level +`OrderStatus` then got one component, which described whichever ASP.NET met first, and the property +typed `Order.Status` referenced the other's names (measured, on the JIT and under Native AOT). CMTK1007 +catches the value-object case within one compilation only, through the companion classes; a closed +set has none, and two assemblies never meet at compile time. Decision 24's names stay, since value +objects' component names shipped in 1.x. Generating a document in which a name this package gave is +also another type's throws an `InvalidOperationException` naming both, per document. Two types this +package does not rename stay ASP.NET's business. + +For the same reason, `FaultResponseOptions.QualifiedFaultNames` names an enum after its nesting +chain with dots, `Billing.Fault.NotFound`, as a closed set's `QualifiedName` does; it had used the +simple name, so `Billing.Fault` and `Shipping.Fault` sent one value. The fault responses are new in +2.0.0. diff --git a/design/adr/0056-value-translated-only-through-typekit-converter.md b/design/adr/0056-value-translated-only-through-typekit-converter.md new file mode 100644 index 0000000..0fb5329 --- /dev/null +++ b/design/adr/0056-value-translated-only-through-typekit-converter.md @@ -0,0 +1,17 @@ +# 0056. `.Value` is translated only on a column TypeKit's converter stores + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [EF Core](../ef-core.md). + +The `.Value`, `GetValue()` and `ValueOrNull()` translators re-typed any value-object column as the +wrapped type. A column the application maps with its own converter stores something else: with +`HasConversion(c => "C:" + c.Value, …)`, `t.Code.Value.StartsWith("a")` became `LIKE 'a%'` and +answered no row where memory answered one, and a quantity stored as text compared as a number +(measured on SQLite, through `HasConversion`, a converter type and `ConfigureConventions`). The +translators now act only where the operand's to-provider expression is +`ValueObjectConverter<,>.ProviderValue`, composed with an enum's own converter where the wrapped type +has one; anywhere else they decline and EF refuses the query. The expression is read rather than the +converter's type, since a compiled model recreates the converters (measured). A query that answered +wrong rows in 1.5.1 now throws, which is the change in behaviour 2.0.0 lists. diff --git a/design/adr/0057-hand-written-ordering-refused-in-query.md b/design/adr/0057-hand-written-ordering-refused-in-query.md new file mode 100644 index 0000000..3dc06cf --- /dev/null +++ b/design/adr/0057-hand-written-ordering-refused-in-query.md @@ -0,0 +1,18 @@ +# 0057. A query that orders by a hand-written `CompareTo` is refused + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [EF Core](../ef-core.md), +[decision 28](0028-seams-declared-everything-else-refused.md). + +A value object may declare `CompareTo(TSelf)`, and the generated operators follow it. EF drops the +operator method and translates `<`, `OrderBy` and `Max` to the column's order, so a reversed +`Priority` answered the opposite of memory (measured on SQLite). Nothing can translate an arbitrary +C# ordering, so the query is refused rather than answered: the comparable aspect marks a type whose +ordering is hand-written with `[HandWrittenOrdering]` (`CodoMetis.TypeKit.CompilerServices`, +hidden; written by hand it is CMTK1011), and `UseTypeKit()` registers an `IQueryExpressionInterceptor` +that refuses the comparison operators, `CompareTo`/`Compare`, `OrderBy`/`ThenBy` and their descending +forms, `Order`, `MinBy`/`MaxBy` and `Min`/`Max` on such a type, nullable included, naming the value +object and saying to order by `.Value` where the column's order is meant. The interceptor sees the +LINQ tree before translation with the types intact (measured). diff --git a/design/adr/0058-integer-key-follows-ef-rule.md b/design/adr/0058-integer-key-follows-ef-rule.md new file mode 100644 index 0000000..872e7fd --- /dev/null +++ b/design/adr/0058-integer-key-follows-ef-rule.md @@ -0,0 +1,17 @@ +# 0058. A value-object key is generated wherever EF generates its wrapped type's + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; refines +[decision 23](0023-value-object-key-over-integer-generated.md). + +Decision 23 named `int`, `long` and `short`. EF generates every type its `IsInteger()` names except +`byte`: also `sbyte`, `uint`, `ulong`, `ushort` and `char`, so an `IValue` key stored 0 where a +plain `uint` key was generated (measured). The convention follows EF's rule exactly, `char` +included, whose own key SQLite refuses (`TEXT … AUTOINCREMENT`) as it refuses a plain `char` key. +Parity covers the hierarchy too: SQLite re-decided a TPH root's key from its CLR type when the +mapping strategy was set, after the convention had run, and stored 0; the convention now runs after +the hierarchy-mapping conventions, and a TPC key stays generated only where the provider gives it a +sequence, as a plain `int` key does (measured on SQLite and Npgsql; SQL Server's TPC read from EF's +source). diff --git a/design/adr/0059-text-guard-refuses-what-it-cannot-normalize.md b/design/adr/0059-text-guard-refuses-what-it-cannot-normalize.md new file mode 100644 index 0000000..66d46d0 --- /dev/null +++ b/design/adr/0059-text-guard-refuses-what-it-cannot-normalize.md @@ -0,0 +1,42 @@ +# 0059. The text guards refuse what they cannot normalize, and `AsText()` throws where nothing composes + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; refines +[decision 43](0043-exceptions-type-argument-constraints-constructor-guarantee.md), which shipped the +text guards. Measured on SDK 10.0.401, runtime 10.0.12 (Unicode 16 tables), osx-arm64 with ICU. + +The guards promise that no string makes them throw and that a value is kept in one spelling. Three +inputs broke that, and each is now refused or refused to run, never answered as if nothing happened: + +- **`U+FFFE`.** `string.Normalize` throws on an unpaired surrogate and on `U+FFFE`, the byte-order + mark read in the wrong byte order, and on nothing else: a sweep of every scalar value through + `AsText()` found `U+FFFE` alone (`U+FFFF`, `U+FDD0`, `U+1FFFE` pass). It is `Hidden` in both + guards, as an unpaired surrogate already was, and the sweep is a test. +- **A long run of combining marks.** Composition reorders a run of marks by their classes in time + that grows with the square of the run: a letter, n marks of one class and n of another took 0.25 s at + n = 10,000, 4.1 s at 40,000 and 64 s at 160,000, where ten million plain characters take 0.23 s, + and the guide runs `AsText()` before a length check. A run of more than 30 marks (Mn, Mc, Me) is + `Hidden` in both guards, counted in the linear scan before anything is composed. Thirty is the + Stream-Safe Text Format's bound (UAX #15). Every character of a non-zero combining class, and every + one whose canonical decomposition starts with one, is a mark (checked against Unicode 17's data), so + the bound bounds the reordering; counting all marks, not only those of a non-zero class, refuses a + little more and needs no class table the runtime does not expose. +- **Globalization-invariant mode.** There `string.Normalize` returns its input unchanged, and + `AsText()` answered `Zoe` with a combining diaeresis as a success, uncomposed, and kept `U+212B` + as it came: one name, two spellings, silently. `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT` does not set + the `System.Globalization.Invariant` switch, so `AsText()` asks once whether a decomposed letter + composes, and throws `PlatformNotSupportedException` where it does not, on its first call whatever + the string, its message naming the mode and the way out: the mode off and ICU available, both, since + ICU alone does nothing while the mode is on. Not a fallback to `AsIdentifier()`'s behaviour, which + is the silent answer itself, and not a `TextFault`, which describes a string and not the process. + `AsIdentifier()` composes nothing and is unaffected, but is no replacement: it refuses the joiners + written text needs and folds no space, so written text has no guard in the mode. + +Two corrections to what is refused rode along. The Khmer inherent vowels (`U+17B4`, `U+17B5`) and the +Khitan filler (`U+16FE4`) render nothing and are default-ignorable, but are marks, so no category test +reached them: they are `Hidden` now. The prepended concatenation marks (`U+0600` to `U+0605`, `U+06DD`, +`U+070F`, `U+0890`, `U+0891`, `U+08E2`, `U+110BD`, `U+110CD`) are format characters by category, yet +each is drawn, spanning the digits after it, so refusing them refused Arabic and Syriac text the guard +says it keeps: they are kept, also with nothing after them, since one alone draws itself. diff --git a/design/adr/0060-value-object-over-closed-set-generated-in-full.md b/design/adr/0060-value-object-over-closed-set-generated-in-full.md new file mode 100644 index 0000000..3c7f09d --- /dev/null +++ b/design/adr/0060-value-object-over-closed-set-generated-in-full.md @@ -0,0 +1,28 @@ +# 0060. A value object over a closed set is generated in full wherever the set is declared + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [Aspects](../aspects.md), [Closed sets](../closed-sets.md). + +`ClosedSetAspect` ran last in `AspectOrder`, after the value-object aspects, which therefore did not +see what it introduces into a set of their own project. Over `sealed partial class SetA : +IClosedSet` declared there, `readonly partial record struct OverSet : IValue` was +generated without `Parse`, `TryParse` and `IParsable`, with the base `TypeConverter` rather +than its own, and with a hand-written `Parse` kept silently, while its JSON worked (measured). Over the +same set in a referenced project it got all of them. + +`ClosedSetAspect` runs first now. The value-object aspects see the set's `IParsable`, `Parse` and +`[TypeConverter]` and build the value object's parsing and type converter on them, so a value object +over a set is the same wherever the set is declared: a struct or a record class, over a nested set, +validated through `Create`, ordinal, refusing without quoting the input, CMTK1011 on a hand-written +`Parse` (`ClosedSetWrappingTests`, `BuildOutcomeTests`; with the old order they fail). The closed-set +aspect reads only the set's own declaration (fields, constructors, initializer text, methods, +attributes, interfaces), none of which the value-object aspects introduce, so its output does not +change. + +The alternative was to refuse the declaration with CMTK1005, as a value object over a value object +of its own project is refused. That refusal exists because one aspect class never sees what its own +sibling instances introduce, which no order can change. A closed set is a different aspect class, so +an order fixes it, and refusing a declaration that can be generated in full would have taken a +working use (an identifier that is one of a set's members) for no reason the user could act on. diff --git a/design/adr/0061-scope-s-fault-status-applies-where-fault-type-answered.md b/design/adr/0061-scope-s-fault-status-applies-where-fault-type-answered.md new file mode 100644 index 0000000..3ae73a7 --- /dev/null +++ b/design/adr/0061-scope-s-fault-status-applies-where-fault-type-answered.md @@ -0,0 +1,47 @@ +# 0061. A scope's fault status applies where its fault type is answered + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [decision 49](0049-fault-responses-client-can-act.md); +[OpenAPI](../openapi.md). + +Decision 49 gave an endpoint `WithFaultStatus` and an MVC action `[FaultStatus]`, and the +startup check refused a status on an endpoint that answers with no fault of its type. Both also reach +every endpoint of a scope: a route group's `WithFaultStatus`, and the attribute on a controller class. +A group or controller that held a health check beside its fault endpoints then did not start, since +the health check carried the status too (measured by the review). + +A status declared on one endpoint or action stays strict: that endpoint must answer with the fault +type, or the host does not start. A status declared on a scope applies to the endpoints of that scope +that answer with its fault type and leaves the others alone. A scope is a route group, any +convention builder that covers several endpoints (the one `MapControllers()` returns), or a +controller class; a controller that derives from a base class with the attribute is a scope of its +own. The host does not start when no endpoint of the status's own scope answers with the fault type. +The unknown-member and error-status refusals are unchanged, and so is the order: the last status in +ASP.NET's metadata wins, an endpoint's own over its group's, an inner group's over an outer group's, +an action's over its controller's, and `MapControllers()`'s builder over every attribute, since +ASP.NET puts its metadata last. + +Its own scope, not the host: the check exists for a status that misleads, set on the wrong group or +left behind when the endpoints that needed it moved. Satisfied by any endpoint of the host that +answers with the fault type, which some other group usually does, it would pass almost every such +status. + +A declaration is one instance: `WithFaultStatus` adds one object, which the builder's convention puts +on every endpoint of the scope, and MVC reads a controller's attributes once per controller and puts +the same instances on each of its actions (measured on ASP.NET Core 10.0.12: shared across a +controller's actions and an action's two routes, separate for two controllers deriving from one +base, stable across reads of the endpoints). The check groups statuses by instance, by reference, so +the scope comes from the endpoints that carry it; an endpoint's own status is a scope of one. +`FaultStatusScopeTests` holds each case. + +Rejected: +- **Strict everywhere**, the rule before: a group or a controller cannot hold a health check, a + version endpoint or anything else that reports no fault, and a host splits its routes by what they + return. +- **No check for a status a scope gave**: a stale status on a group is then silent, and silent is + what this package refuses. + +Not refused: a group with no endpoint carries its status nowhere, and a scope's status that each of +its matching endpoints replaces with its own is never used. diff --git a/design/adr/0062-asynchronous-callbacks-run-where-awaited-task-completed.md b/design/adr/0062-asynchronous-callbacks-run-where-awaited-task-completed.md new file mode 100644 index 0000000..faa04ba --- /dev/null +++ b/design/adr/0062-asynchronous-callbacks-run-where-awaited-task-completed.md @@ -0,0 +1,35 @@ +# 0062. An asynchronous callback runs where the task it waited for completed + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [Option and Result](../option-result.md), +[decision 30](0030-asynchronous-callbacks-return-task.md). + +Every `await` in the base package is `ConfigureAwait(false)`, about fifty of them: the continuations +of a pending `Option` or `Result`, `TraverseAsync`, `CatchAsync`, and the asynchronous forms on a known +result, which await the task their callback returns. Measured with a `SynchronizationContext` +installed by the caller: `await SlowResult().MapAsync(x => …)` ran the selector without the caller's +context while the result was still pending, and on it, inline, when the task had already completed; +the caller's own `await` returned to its context either way. In a Blazor Server component or a WPF +window, a callback that touches state bound to the renderer or the dispatcher therefore works or fails +by timing. + +`ConfigureAwait(false)` stays, and the behaviour is the documented contract: a callback runs where the +task it waits for completed, at once on the caller's thread and context when that task had already +completed, and otherwise wherever it completed, without the caller's context. In a UI or a Blazor +component, await the task and call the synchronous combinator on what it answers, or marshal inside +the callback. + +Dropping it would resume every continuation on the caller's context, and a caller that blocks on a +chain with `.Result`, `.Wait()` or `GetAwaiter().GetResult()` on a single-threaded context, which +works today, would hang: the continuation is posted to the thread that is waiting for it. Measured +with a caller's context that never runs what is posted to it, as a blocked UI thread does not: every +member that returns a task completes with `ConfigureAwait(false)`, and with it removed from one +continuation that continuation is posted and the chain never completes. A context-capturing overload +beside each member would double the surface for what the caller's own `await` before a synchronous +combinator already does. + +`AsynchronousContextTests` holds every public member of the base package that returns a task to both +halves, completeness-checked by reflection: a callback after a pending task does not run on the +caller's context, one after a completed task runs on it, and nothing posts to it. diff --git a/design/adr/0063-char-keys-application-assigned-ulong-documented.md b/design/adr/0063-char-keys-application-assigned-ulong-documented.md new file mode 100644 index 0000000..3bf9c0d --- /dev/null +++ b/design/adr/0063-char-keys-application-assigned-ulong-documented.md @@ -0,0 +1,44 @@ +# 0063. A key over `char` is the application's to assign; a default key over `ulong` throws at `Add` + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [EF Core](../ef-core.md); refines +[decision 58](0058-integer-key-follows-ef-rule.md). + +Decision 58 declared a value-object key generated wherever EF declares a key of its wrapped type +generated, and called that parity. It is narrower than parity. EF separates declaring a key generated +(`ValueGenerated`) from supplying what generates it, which the provider decides (an identity, a +sequence), and it picks the generator that fills a key at `Add` by the property's CLR type, else by its +converter's provider type, which for a value object is the column's. Where the provider stores the +wrapped type through a converter of its own, a value object's default key gets another generator than +a plain key. Measured with EF Core 10.0.12, Npgsql 10.0.3 and SQL Server's provider 10.0.12: the models +and `Add` on all three providers, the tables and inserts on PostgreSQL 18.1 (no SQL Server here). + +| Key over | PostgreSQL | SQL Server | SQLite | +|---|---|---|---| +| `char` | `character(1)`, identity; refused with 22023 at creation and in the `AlterColumn` from a 1.5.1 snapshot, for a plain key too | `nvarchar(1)`, no identity; the value object's default key got a permanent character of a GUID string at `Add` (`9`, `b`), a plain one a temporary value | `TEXT`; a plain key's `AUTOINCREMENT` refused at creation, the value object's key a temporary value | +| `ulong` | `numeric(20,0)`, no identity; the value object's default key throws `OverflowException` at `Add`, a plain one gets a temporary `ulong` and fails at insert (23502) | `decimal(20,0)`, identity; the value object's default key throws `OverflowException` at `Add`, a plain one gets a temporary `ulong` | `INTEGER`; numbered 1, 2 | + +**A key over `char` is never declared generated**, on any provider, as 1.5.1 left it. No provider +generates one, and declared generated it was the one case where the value object did worse than a +plain key without a word: on SQL Server the invented character was stored. This stops the invented +value and checks nothing more: a default `'\0'` is stored as it is, and a second such row fails on the +primary key. The application assigns the key; an explicit `ValueGeneratedOnAdd()` or a value generator +of its own still applies (convention precedence). + +**A key over `ulong` stays declared generated**, as a plain one is, which keeps SQL Server's identity +column. It fails loudly, at `Add`, where the column is a `decimal`: EF's temporary `decimal` is negative, +and converting it back to `ulong` throws. Making `Add` give it a temporary `ulong` needs a value +generator per key. A delegate given to `HasValueGenerator` is refused by `dbcontext optimize` ("has a +value generator configured. Use 'HasValueGeneratorFactory' to configure the value generator factory +type"), a factory type would have to be public for the compiled model's code to name it, and either +takes precedence over the providers' HiLo, which they select only for a property without one. A type +mapping's own factory, which comes after HiLo, has no public setter on the provider's mapping a value +object's is composed onto. The workaround is `ValueGeneratedNever()` and a key the application assigns. + +`KeyGenerationTests` holds both: a `char` key is `Never` on the three providers and `Add` leaves a +default key as it is (with the key declared generated again, SQL Server's test failed with `b`, the +others' with a temporary value), the application's own configuration still applies, and a default +`ulong` key throws at `Add` on SQL Server and PostgreSQL while `ValueGeneratedNever()` takes an +assigned one. diff --git a/design/adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md b/design/adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md new file mode 100644 index 0000000..5889289 --- /dev/null +++ b/design/adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md @@ -0,0 +1,25 @@ +# 0064. A fault's name reaches the client; causes it must not tell apart are one fault + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [decision 38](0038-result-becomes-response-explicit-tohttpresult-faults.md), +[decision 49](0049-fault-responses-client-can-act.md). + +Every problem `ToHttpResult()` writes carries `fault`, its member's name ([decision 38](0038-result-becomes-response-explicit-tohttpresult-faults.md), +since 1.5), for a member without a description too, and the document describes it +([decision 49](0049-fault-responses-client-can-act.md)). The review named the case that matters: a +sign-in that answers 401 both for an unknown user and for a wrong password still says which, and so +which accounts exist. + +For 2.0.0 the name stays in every fault response, and the package READMEs and `docs/` say it is part +of the response's contract. Where two causes must not be told apart, the boundary maps them to one +member of its own fault type before `ToHttpResult()` (`MapError(_ => LoginFault.InvalidCredentials)`); +the AspNetCore README shows it as compiled code, and `SignInSampleTests` holds that the two then +answer alike. It needs no API, and it puts the choice where the meaning is: the boundary's fault type +says what the client may know. + +An opt-in that withholds the name for chosen members or a whole fault type would be additive, and +joins 2.1. Rejected for 2.0.0: withholding it by default, since `fault` is what a client matches on +and 1.5's responses carried it; and withholding it for a member without a description, since a +description is for people and says nothing about whether the name may be known. diff --git a/design/adr/0065-2-0-0-ships-without-preview.md b/design/adr/0065-2-0-0-ships-without-preview.md new file mode 100644 index 0000000..87b8e1f --- /dev/null +++ b/design/adr/0065-2-0-0-ships-without-preview.md @@ -0,0 +1,16 @@ +# 0065. 2.0.0 ships without a preview + +Date: 2026-10-14 +Status: accepted + +Context: 2026-10-14, preparing the 2.0.0 release; [decision 44](0044-2-0-0-net10-0-metalama.md). + +Decision 44 turned the `2.0.0-preview.N` branch into a preview of 2.0.0 itself from `main`, and the +roadmap held `2.0.0-preview.1` on nuget.org, then the consumer smoke test against it, as a step +before the tag. It had been on hold since 2026-10-13. The user dropped it: 2.0.0 is tagged without a +preview, and no `2.0.0-preview.N` is published. + +What the preview would have measured, the packed packages installed into throwaway consumers on +Native AOT among them, the consumer smoke test already measures against the packed feed, in CI on +every pull request to `main` and again in the release workflow before the approval gate. The rest of +decision 44 stands. diff --git a/design/adr/README.md b/design/adr/README.md new file mode 100644 index 0000000..e65c213 --- /dev/null +++ b/design/adr/README.md @@ -0,0 +1,74 @@ +# Decisions + +One record per decision, numbered as the plan numbered them, in the order they were taken, each with +its date and the evidence that settled it. A decision is changed by a new one that names it, never by +editing the old; a record that a later one replaces says `superseded by` and the number. The notes +per area ([design](../plan.md)) link to the records they rest on. + +| # | Decision | Date | Status | +|---|---|---|---| +| [0001](0001-license-mit.md) | License: MIT | 2026-09-26 | accepted | +| [0002](0002-name-codometis-typekit.md) | Name: `CodoMetis.TypeKit` | 2026-09-27 | accepted | +| [0003](0003-analyzer-ids-cmtk.md) | Analyzer ids: `CMTK` | 2026-09-27 | accepted | +| [0004](0004-metalama-2026-1-x-latest-stable.md) | Metalama 2026.1.x (latest stable) | 2026-09-26 | accepted | +| [0005](0005-option-result.md) | Option/Result | 2026-09-27 | accepted | +| [0006](0006-result-shape.md) | `Result` shape | 2026-09-27 | accepted | +| [0007](0007-analyzer-own-package.md) | Analyzer as its own package | 2026-09-27 | accepted | +| [0008](0008-uninitialized-result-throws.md) | An uninitialized `Result` throws | 2026-09-27 | accepted | +| [0009](0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md) | EF mapping through an additive `IRelationalTypeMappingSourcePlugin` | 2026-09-27 | accepted | +| [0010](0010-stored-json-storedjsonconverterfactory.md) | Stored JSON through `StoredJsonConverterFactory` | 2026-09-27 | accepted | +| [0011](0011-openapi-schema-transformer.md) | OpenAPI through one schema transformer | 2026-09-27 | accepted | +| [0012](0012-asp0020-documented-suppressed.md) | ASP0020 is documented, not suppressed | 2026-09-27 | accepted | +| [0013](0013-first-release-s-rule-set-cmtk0001.md) | The first release's rule set is CMTK0001–CMTK0009 | 2026-09-28 | accepted | +| [0014](0014-context7-json.md) | A `context7.json` links the repository to its Context7 entry | 2026-10-13 | accepted | +| [0015](0015-option-result-wire-types.md) | `Option`/`Result` are not wire types | 2026-09-27 | accepted | +| [0016](0016-native-aot-wherever-platform-allows.md) | Native AOT wherever the platform allows it | 2026-09-27 | accepted | +| [0017](0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md) | Run-time discovery reads `GeneratedValueObjectAttribute` | 2026-09-27 | accepted | +| [0018](0018-name-per-concept.md) | One name per concept | 2026-09-27 | accepted | +| [0019](0019-concise-implicit.md) | Concise, not implicit | 2026-09-27 | accepted | +| [0020](0020-net10-0-only.md) | net10.0 only | 2026-09-28 | accepted | +| [0021](0021-first-release-1-0-0.md) | The first release is 1.0.0 | 2026-09-28 | accepted | +| [0022](0022-registration-extensions-live-framework-s-namespaces.md) | Registration extensions live in the framework's namespaces | 2026-09-28 | accepted | +| [0023](0023-value-object-key-over-integer-generated.md) | A value-object key over an integer is generated on add; one over a `Guid` is assigned | 2026-09-28 | accepted | +| [0024](0024-nested-value-objects-named-after-nesting.md) | Nested value objects are named after their nesting chain in the OpenAPI document | 2026-09-28 | accepted | +| [0025](0025-analyzer-s-roslyn-floor-stays-5.md) | The analyzer's Roslyn floor stays at 5.0.0 | 2026-09-28 | accepted | +| [0026](0026-value-object-s-json-wrapped-type.md) | A value object's JSON is its wrapped type's, byte for byte | 2026-09-28 | accepted | +| [0027](0027-no-refusal-quotes-input.md) | No refusal quotes the input | 2026-09-28 | accepted | +| [0028](0028-seams-declared-everything-else-refused.md) | Seams are declared, everything else refused | 2026-09-28 | accepted | +| [0029](0029-only-result-terror-converts-bool.md) | Only `Result` converts to `bool` | 2026-09-28 | accepted | +| [0030](0030-asynchronous-callbacks-return-task.md) | Asynchronous callbacks return `Task` | 2026-09-28 | accepted | +| [0031](0031-no-extension-object-unconstrained-t.md) | No extension on `object` or on an unconstrained `T` | 2026-09-28 | accepted | +| [0032](0032-value-object-holds-wrapped-value-alone.md) | A value object holds its wrapped value alone, and its equality is that value's | 2026-09-28 | accepted | +| [0033](0033-analyzers-run-code-metalama-transformed.md) | The analyzers run on the code Metalama transformed | 2026-09-28 | accepted | +| [0034](0034-errors-fixed-within-major-version-warnings.md) | Errors are fixed within a major version; Warnings may learn | 2026-09-28 | accepted | +| [0035](0035-sqlite-s-autoincrement-application-s-line.md) | SQLite's autoincrement is the application's line, not the convention's | 2026-09-28 | accepted | +| [0036](0036-nullable-value-object-admits-null-openapi.md) | A nullable value object admits null in the OpenAPI document wherever it appears | 2026-09-28 | accepted | +| [0037](0037-refusal-s-message-landing-page.md) | A refusal's message is its landing page | 2026-09-28 | accepted | +| [0038](0038-result-becomes-response-explicit-tohttpresult-faults.md) | A `Result` becomes a response through an explicit `ToHttpResult()`, its faults mapped in the host | 2026-10-11 | accepted | +| [0039](0039-value-object-s-fault-does-become.md) | A value object's fault does not become a validation error | 2026-10-11 | accepted | +| [0040](0040-codometis-typekit-testing-holds-assertions-hand.md) | `CodoMetis.TypeKit.Testing` holds assertions that hand back the content | 2026-10-11 | accepted | +| [0041](0041-sensitive-value-object-declares-tostring-serilog.md) | A sensitive value object is one that declares `ToString()`; Serilog gets a satellite | 2026-10-11 | accepted | +| [0042](0042-2-0-0-next-release-bundles.md) | 2.0.0 is the next release, and it bundles the 1.x candidates | 2026-10-12 | accepted | +| [0043](0043-exceptions-type-argument-constraints-constructor-guarantee.md) | Exceptions, type-argument constraints, a constructor guarantee and the text guards join 2.0.0 | 2026-10-13 | accepted | +| [0044](0044-2-0-0-net10-0-metalama.md) | 2.0.0 is `net10.0` on Metalama 2026.1; the version number names the API, never the runtime | 2026-10-13 | accepted | +| [0045](0045-topic-pages-docs-compiled-like-readmes.md) | Topic pages in `docs/`, compiled like the READMEs; the plan in `design/` | 2026-10-13 | accepted | +| [0046](0046-timeout-caught-result-catch-error.md) | A timeout caught by `Result.Catch` is the error | 2026-10-13 | accepted | +| [0047](0047-firstornone-query-runs-query.md) | `FirstOrNone` on a query runs in the query | 2026-10-13 | accepted | +| [0048](0048-intended-break-suppressed-alone-baseline-stays.md) | An intended break is suppressed alone; the baseline stays | 2026-10-13 | accepted | +| [0049](0049-fault-responses-client-can-act.md) | Fault responses a client can act on | 2026-10-13 | accepted | +| [0050](0050-warnings-cmtk0003-alone-becomes-error-2.md) | Of the Warnings, CMTK0003 alone becomes an Error in 2.0.0 | 2026-10-13 | accepted | +| [0051](0051-closed-set-parses-ignoring-case-only.md) | A closed set parses ignoring case only when asked, and refuses members that differ only in case | 2026-10-13 | accepted | +| [0052](0052-cause-on-fault-joins-2-1.md) | A `Cause` on `Fault` joins 2.1 | 2026-10-13 | accepted | +| [0053](0053-fault-made-by-fault-of-alone.md) | A `Fault` is made by `Fault.Of` alone | 2026-10-13 | accepted | +| [0054](0054-abstract-value-object-refused.md) | An abstract type with a value-object marker is refused (CMTK1022) | 2026-10-13 | accepted | +| [0055](0055-component-name-claimed-twice-refused.md) | A component name two types claim is refused; a qualified fault name follows the nesting chain | 2026-10-13 | accepted | +| [0056](0056-value-translated-only-through-typekit-converter.md) | `.Value` is translated only on a column TypeKit's converter stores | 2026-10-13 | accepted | +| [0057](0057-hand-written-ordering-refused-in-query.md) | A query that orders by a hand-written `CompareTo` is refused | 2026-10-13 | accepted | +| [0058](0058-integer-key-follows-ef-rule.md) | A value-object key is generated wherever EF generates its wrapped type's | 2026-10-13 | accepted | +| [0059](0059-text-guard-refuses-what-it-cannot-normalize.md) | The text guards refuse what they cannot normalize, and `AsText()` throws where nothing composes | 2026-10-13 | accepted | +| [0060](0060-value-object-over-closed-set-generated-in-full.md) | A value object over a closed set is generated in full wherever the set is declared | 2026-10-13 | accepted | +| [0061](0061-scope-s-fault-status-applies-where-fault-type-answered.md) | A scope's fault status applies where its fault type is answered | 2026-10-13 | accepted | +| [0062](0062-asynchronous-callbacks-run-where-awaited-task-completed.md) | An asynchronous callback runs where the task it waited for completed | 2026-10-13 | accepted | +| [0063](0063-char-keys-application-assigned-ulong-documented.md) | A key over `char` is the application's to assign; a default key over `ulong` throws at `Add` | 2026-10-13 | accepted | +| [0064](0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md) | A fault's name reaches the client; causes it must not tell apart are one fault | 2026-10-13 | accepted | +| [0065](0065-2-0-0-ships-without-preview.md) | 2.0.0 ships without a preview | 2026-10-14 | accepted | diff --git a/design/analyzers.md b/design/analyzers.md new file mode 100644 index 0000000..fd2fbbc --- /dev/null +++ b/design/analyzers.md @@ -0,0 +1,80 @@ +# Analyzer package and rule set + +**Packaging (decided 2026-09-27).** `CodoMetis.TypeKit.Analyzers` (netstandard2.0; analyzers and +code fixes under `analyzers/dotnet/cs`). `CodoMetis.TypeKit` takes a package dependency on it. +- **Why separate:** rules and fixes can ship without a base version bump, and a consumer can take + a newer analyzer by referencing it directly. The code-fix assembly (which needs Workspaces) + stays out of a runtime library package. +- **Why still a dependency of the base package:** fabric spike consequence A. Whoever can see the + interfaces must get the guard. +- **Measured 2026-09-27 (SDK 10.0.401),** packed and consumed from a local feed: + - A project that references only `CodoMetis.TypeKit` gets CMTK0001 and CMTK0002, and so does a + project that reaches it only through another project. + - With the default `exclude="Build,Analyzers"` on the dependency, the analyzer **still** loads, + because the SDK takes analyzers from every package in the restore graph. `PrivateAssets="none"` + is kept so the dependency says what is intended instead of relying on that. + - What does drop it: a project reference with `ReferenceOutputAssembly="false"` emits **no + dependency at all**. The reference is therefore an ordinary one with `Private="false"`. +- **Guards:** `AnalyzerPackagingTests` packs both packages and reads the nuspec and the package + layout, the code-fix assembly beside the analyzer's included. The consumer smoke test (phase 6) + asserts CMTK0001 fires, directly and transitively. + +Attributes stay in `CodoMetis.TypeKit`. The analyzer resolves them and the interfaces with +`GetTypeByMetadataName` and compares symbols ([Discovery](discovery.md)), so it does not reference the base package. +Inside the analyzer, rules that react to attributes are kept apart from value-object rules, so the +split stays mechanical if a generic analyzer package is ever wanted. + +**Scope.** Code that compiles but is wrong about value objects, `Result`/`Option`, types that +forbid `default`, and what a type declares by attribute because C# cannot express it (a constraint +on a type parameter, a constructor a host needs). General linting (clock types, style) stays out, since Meziantou and Roslynator +exist. + +| Id | Rule | Severity | Status | +|---|---|---|---| +| CMTK0001 | No `default`/`default(T)`/`new()`/`new T()` of a value object or a `[RequireCustomInitialization]` type. An operand of `==`/`!=`, or an argument of a call whose name has the word `Equal`, `Equals` or `Compare` or ends in `Be` (`ThrowIfEqual`, `Assert.NotEqual`, `ShouldNotBe`), is exempt: a guard check is the only defence against the defaults the rule cannot see. So is a `default` assigned to an `out` parameter of a method returning `bool`, the Try pattern. Where a sibling branch does not bind (same-project value objects), the target type is taken from the enclosing conditional, switch arm or collection | Error | shipped in 1.0.0 | +| CMTK0002 | Type implements `IValue<>`/`IValidatedValue<,,>`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so it is never woven | Error | shipped in 1.0.0 | +| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver, and so is a chain of them on one (`r.Tap(log).TapError(warn)`, `(await pending).Tap(log)`, `pending.WaitAsync(t).TapAsync(log)`), measured as an Error's false report before 2.0.0 was tagged; so is such a tap converted to a plain `Task` or waited for (`Task Log() => pending.TapAsync(log);`, `held.TapAsync(log).Wait()`), on a `Result` and on a `Task` of one, which an independent review found reported on 2026-10-14. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)), its 1.x gaps closed first | +| CMTK0004 | `IValueObjectMaterializer<,>.Materialize`, `ValueObjectConverter<,>.Materialize` or a hand-written value object's public `Materialize`, called or referenced (expression trees included). The satellite's own call is compiled in the satellite; EF's compiled model is generated code, whose diagnostics are not reported (measured: reported, it is six errors in the smoke test's AOT consumer). `StoredJsonConverterFactory` goes through `IStoredJsonConverterSource`, not `Materialize` | Error | shipped in 1.0.0 | +| CMTK0005 | `new T[n]`, `stackalloc T[n]`, `GC.AllocateUninitializedArray/AllocateArray` or `Array.Resize` of a no-default struct: every new slot starts as `default`. A constant length of zero (for `Array.Resize` and the `GC` methods too) or initial elements are silent | Warning | shipped in 1.0.0 | +| CMTK0006 | Field or auto-property of a no-default struct type in a class that no initializer, `required` or constructor sets; a non-public parameterless constructor (EF, serializers) and a record's copy constructor are exempt. Measured 2026-09-28 on the repository's EF entities and request types: 12 of 12 reports on settable properties of a type with only the implicit constructor, each a real way to a default. `required`, the fix the message names where it compiles, works with EF's compiled model, precompiled queries and Native AOT (the smoke test rerun with it). A `[SetsRequiredMembers]` constructor must assign the `required` members it promises | Warning | shipped in 1.0.0 | +| CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | +| CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | +| CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | +| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too, unless its C# constraints leave it no breaking argument: `struct`/`unmanaged` or a bound proving `[Sealed]` proves `[Sealed]`; `new()` or whatever proves `[Sealed]` proves `[Concrete]`; a bound that is or proves a record proves `[Record]` (CS8865); nothing but the attribute proves `[Abstract]`. `[Concrete]` accepts an enum, a delegate and `dynamic`, as `new()` and `struct` do for an enum. A default interface method reached through a derived interface carries its own constraint once: it was its own implementation there, and the rule recursed until the compiler's stack overflowed (an independent review, 2026-10-14). Both settled 2026-10-13 before the tag, measured as false reports (an enum, a delegate, `dynamic`; six forwarding forms) with no miss among their neighbours. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | +| CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor in any of its parts gets none (CS8862), nor, since 2026-10-14, one whose implicit `base()` binds to nothing (CS7036, CS0122, CS0121), which a review found the fix writing. It writes its own trivia and asks no formatter, which laid out the whole type again and wrote the machine's line endings: doc comments, attributes, comments and the members' layout stay, added lines take the members' indentation and the brace's line ending, a one-line type gets the constructor on its line | Error | built 2026-10-13 for 2.0.0 (phase 13) | +| CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | +| CMTK0013 | A closed set's member written outside its declaration: an assignment, compound or `??=`, a deconstruction, an increment, a `ref` or `out` argument, or a writable `ref` taken to it, each also through a `ref` conditional (`(flag ? ref Open : ref Closed) = x`, found by the review of the rule itself). A `ref readonly` local or field reassigned to a member reads it and is silent (a false report found by the weaving review, 2026-10-14). C# allows such a write only in the set's static constructor and static field initializers, which run before (initializers) or after (the constructor's body) the generated initializer that names the members and fills `All`; either way an instance answered to another member's name, `Match` took another's branch, and `All` disagreed with `Parse`. An analyzer, not the aspect: Metalama 2026.1's code model gives no operations of a constructor body, and a textual scan would miss a deconstruction or a `ref` | Error | built 2026-10-14 for 2.0.0, a major, after a review reproduced the swap against the packed generators | +| — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | + +**Rules that read calls see the source before weaving (measured 2026-09-28).** Metalama runs +analyzers on the unwoven source, where a call to a member the generators introduce into a value +object of the same project does not bind. CMTK0003 recognises `X.TryFrom(…)` and `x.Revalidate()`, +CMTK0007 `X.FromKnownGood(…)` (a `using static` call included) and CMTK0009 `X.TryFrom(…).OrDefault()` +by the generated member's name on a validated value object's type, which binds; the names are pinned +by the generated-surface snapshot. CMTK0001 takes a `default`'s type from the enclosing conditional or +collection when a sibling does not bind, and CMTK0006 resolves an unbound `this(…)` to the one +constructor it can mean. A local declared `var` from an unbound call, and CMTK0008 over such locals, +stay out of reach. The build-outcome consumer holds every rule to a value object of its own project, +since the verifier tests never weave. + +**Generated code (decided 2026-09-28).** The rules analyse generated code and report from it only +where a location maps (`#line`) to a `.razor` or `.cshtml` file: skipping generated code, as Roslyn +does by default, silenced every rule inside Razor components, CMTK0001 included. Anything else +generated stays unreported, which keeps EF's compiled model out of CMTK0004 (the smoke test's AOT +consumer is the measurement). `GeneratedCode.Report` is the one reporting path. CMTK0010 called +`ReportDiagnostic` itself until 2.0.0's pre-tag review, and so reported from every generated file; +`GeneratedCodeTests` holds each rule to the contract in a `.razor` and a `.cshtml` mapping, and +reads the analyzers' sources for a direct `ReportDiagnostic`, so a rule added later is held too. + +**Transformed code (decided 2026-09-28, [decision 33](adr/0033-analyzers-run-code-metalama-transformed.md)).** Where Metalama compiles a project, source +generators run after its transformation, so the Razor compiler's output is not in the source an +analyzer sees, and neither are the members the aspects introduce. The analyzer package's +`buildTransitive` props lists the analyzers' namespace as a `MetalamaTransformedCodeAnalyzer`, and +Metalama's compiler runs them on the transformed code. The IDE ignores the item, so live analysis +still runs on the source, where the name-based forms above remain the reach. + +**Release discipline.** Keep `AnalyzerReleases.Shipped/Unshipped.md` tracking (RS2008). A new rule +ships at Warning or Info in a minor version and is raised to Error only in a major. Consumers +build with warnings as errors, so a new Error rule in a minor version would break their builds on +update. Within a major version an Error never reports more than it did; a Warning or Info may learn +more forms in a minor version ([decision 34](adr/0034-errors-fixed-within-major-version-warnings.md), stated in the analyzer README). diff --git a/design/aspects.md b/design/aspects.md new file mode 100644 index 0000000..4bc3055 --- /dev/null +++ b/design/aspects.md @@ -0,0 +1,278 @@ +# Aspects + +- **Aspect-class count.** Eleven classes: ten for value objects and `ClosedSetAspect`, whose design is + its own note ([Closed sets](closed-sets.md)). The Metalama Open Source edition (MIT) has no aspect-class + limit (pricing page, 2026-09-26). The unlicensed CI build confirms it (phase 6). +- **Entry points.** `ValueObjectAspect` introduces three private helpers on every value + object, and they are the only way the JSON, parsing and type-converter aspects create an + instance: `__FromJson` (a refusal throws `JsonException`), `__FromText` (`FormatException`) and + `__TryFromText` (`false`). A plain value object constructs directly; a validated one calls + `Create` through `GeneratedFactories` in the base package. The exception is the explicit + `IValueObjectMaterializer.Materialize`, by contract. `MinValue`/`MaxValue` are only generated for + a plain value object. +- **Declarations that cannot be generated** are errors, so no type is left half-generated: + CMTK1000/1001/1002 (not `partial`, not a record, a struct not `readonly`), CMTK1003 (more than one + marker), CMTK1004 (a validated marker whose first type argument is another type), CMTK1005 (a + generic value object, one that derives from another value object, a wrapped type that is not + a class, struct or enum, or one that is a value object: itself, one that reaches it again, or + any other, see below), CMTK1006 (a record class that is not `sealed`: a derived record compares + equal only to its own type, which is not value equality), CMTK1007 (the name of the + `GetValue`/`ValueOrNull` class is taken, see below), CMTK1008 (a comparison member beside the + seam, see below), CMTK1009 (a hand-written constructor, see below), CMTK1022 (an abstract type, + reported by the fabric, [decision 54](adr/0054-abstract-value-object-refused.md)). +- **No hand-written constructor (decided 2026-09-27).** A value object's one constructor is the + generated private one, and a hand-written instance constructor is CMTK1009. The implementation + aspect answers it before introducing its own, since it reads only its target: `Constructors` minus + the implicitly declared ones, measured to catch every form (the generated one's signature, any + other, one chained to the generated one, a struct's `X()`, a record's copy constructor, a + positional record's parameter list) and to leave the compiler's own and a static constructor + alone. The type's own members can still call the private constructor, as `Create` must: that is + the one trusted seam into a validated value object, stated on `IValidatedValue` and in SECURITY.md. +- **`Revalidate()` (decided 2026-09-28)** on a validated value object returns `Create(Value)`: today's + rules applied to an instance that was rebuilt without them, by the EF satellite or + `StoredJsonConverterFactory`. It is how an application finds the stored values a rule added later + refuses, which the validation-free read path otherwise hides by design. It returns what `Create` + returns, normalisation included, and a type that declares its own keeps it. +- **The companion class** holding `GetValue()`/`ValueOrNull()` sits at namespace level, as + extension methods must, and is named after the whole nesting chain: `Order.Id` gets + `OrderIdExtensions`. Named after the value object alone, `Order.Id` and `Customer.Id` both asked + for `IdExtensions` and Metalama crashed (LAMA0001). A name that a declared type or another value + object's companion already has is CMTK1007, naming it. +- **NodaTime.** Only `ValueObjectJsonAspect` handles NodaTime types, and it must not refer to + NodaTime at compile time, which would force NodaTime on every consumer. It resolves the types + with `TypeFactory.TryGetType` from the consumer's compilation, compares them by symbol, and uses + NodaTime's converters (values and dictionary keys) only when + NodaTime.Serialization.SystemTextJson is referenced too, and only where the options have no + converter of their own for the type, which wins as it does for the wrapped type. Parsing reaches + NodaTime types through their `[TypeConverter]` (`TypeDescriptor.RegisterType` and + `GetConverterFromRegisteredType`, the trim-safe lookup). +- **Explicit `IParsable`.** `bool` and `char` implement their parsing interfaces explicitly, so the + generated code calls the wrapped type's `TryParse` through `GeneratedParsing`, whose constrained + type parameters reach an explicit implementation. `Parse` is `TryParse` and a throw (below). +- **Parsing culture (decided 2026-09-27).** In the generated `Parse`/`TryParse` a null + `IFormatProvider` means the invariant culture, unlike the BCL, because the generated `ToString()` + is invariant: what it writes must read back the same wherever the process runs, and with the + current culture "1.5" was parsed as 15 in de-DE. A provider that is given is used as given. The + type converter already did this. The text is the wrapped type's own default text, so it is as + lossless as that, no more (measured 2026-10-13): `Parse(x.ToString(), null)` gives `x` back for the + numbers, `Guid`, `DateOnly`, `TimeSpan`, `BigInteger`, `Version` and `Uri`, and not for `TimeOnly` + (`03:04` for 03:04:05.123), `DateTime` (fraction and `Kind`) and `DateTimeOffset` (fraction), whose + invariant short patterns drop them; the type converter and interpolation write the same text. The + docs say so and point at `"O"` and the JSON form. +- **Formatting culture (decided 2026-09-27),** the counterpart: a null provider means the invariant + culture in the generated `ToString(format, provider)`, both `TryFormat`s and `IConvertible`. + Interpolation, `string.Format` and `Convert.ToString` pass null, and with the current culture + `$"{amount}"` was "1,5" in de-DE while `amount.ToString()` was "1.5". Display text in a culture + passes it: `string.Create(culture, $"{amount}")`. +- **The wrapped value's JSON contract** comes from `GeneratedJson.TypeInfo(options, builtIn)` + (decided 2026-09-27, [Native AOT](native-aot.md)): the options' own contract when their resolver has one, so a host's + number handling and converters apply, and otherwise made in the serializer's order (a converter on + the options, the type's `[JsonConverter]`, the built-in converter the aspect names by its property + type). A source-generated context never sees what a value object wraps, and serializing through + the options needs reflection. A dictionary key goes through the same contract's converter. +- **The bytes are the wrapped type's (decided 2026-09-28, [decision 26](adr/0026-value-object-s-json-wrapped-type.md)).** Every wrapped-type family + writes and reads its values and keys through `GeneratedJsonPlan`, the converter the options have + for `T` or else the built-in (or NodaTime's) one: exactly what the serializer writes for a `T`, so a + host's converter, number handling, enum converter and `DictionaryKeyPolicy` apply, and swapping a + primitive for a value object changes no JSON. A `[JsonNumberHandling]` on the containing type or + the property is the exception (measured 2026-10-13): the serializer applies it to the numbers it + writes itself, the plan reads only the options' and the wrapped type's contract, and a converter + cannot see its caller's attribute, so the docs say to set it on the options or the context. The + earlier per-family readers and writers wrote + `TimeOnly` as `"13:45:30.0000000"` and `DateTime` keys with seven fractional digits, ignored a + host's converter for `Guid`, `DateTime` and integer keys, and skipped the key policy. + `JsonParityTests` compares a value object with its wrapped type for 14 families under five option + sets, as a value, a property and a key. +- **The wrapped value is written and read by its converter directly** (decided 2026-09-28), wherever + that is what the serializer would do: a value converter, and, for a number type only, no number + handling that changes the output (`WriteAsString`, named literals) or, for a string token, the + input. Otherwise through the serializer, which alone applies number handling. A nested + `JsonSerializer.Serialize`/`Deserialize` per value cost 35–40% (measured). The plan is kept per + converter instance for the options it last saw; a static plan per wrapped type cost 8% on writes. + The enum's built-in converter is made once per plan: made per call it allocated 4.7 KB per value. +- **A default instance over a reference type formats as `""`** (decided 2026-10-13). It is not supposed + to exist (CMTK0001), but EF Core, MVC and serializers make one, and it holds null. Its `ToString()` + over a string was already `""`; over a `Uri`, a `Version` or an `IPAddress` it, interpolation, + `IFormattable`, both `TryFormat`s and the type converter threw `NullReferenceException`, as did + `Convert.ToString` and `IConvertible` over a string. Every generated format answers `""` now (the + span formats write nothing), and `IConvertible` over a reference type goes through `Convert` with the + value as an object, which is the value's own conversion and, for null, Convert's: `""`, a number's + default, `TypeCode.Empty` (`DefaultInstanceFormattingTests`). Harmless rather than loud, because a + default instance already printed `""` over a string and the BCL prints a null that way. +- **Formatting and comparison never box** (decided 2026-09-28). The wrapped type's `ToString`, + `TryFormat` and UTF-8 `TryFormat` are called through `GeneratedFormatting`, a constrained generic + call that also reaches an explicit implementation, and comparison through `Comparer.Default`, + which the JIT specialises per type and, for an enum, compares the underlying values. Casting the + value to the interface boxed it; an optimising JIT hid that for some types and not for others + (enums), so the allocation tests run on a Debug build, where it never does. +- **JSON reads never parse by rules of their own.** A value and a key are read by the plan's + converter, which accepts exactly what the serializer accepts for the wrapped type. A `Parse` in + the generated code once let `FormatException` escape, which a minimal API answers with 500 rather + than 400, and read number keys with `NumberStyles.Any` ("1,000" as 1000). +- **What the wrapped type cannot read is refused without quoting it (decided 2026-09-28, decision + 27).** The BCL's and NodaTime's parse messages quote the input ("The input string 'SECRET' was not + in a correct format."), and a malformed NodaTime JSON value carried it in an inner exception. A + generated `Parse` is the wrapped type's `TryParse` and a `FormatException` naming the value object + and the wrapped type; where there is no `TryParse` (a static `Parse`, a string constructor, a type + converter) the exception is replaced, not wrapped; a JSON read throws `JsonException` the same way. + An overflow is a `FormatException` too. `UnreadableInputTests` feeds a secret through every strategy + and entry point and searches the whole exception chain. Out of reach: `JsonException.Path`, which + the serializer fills with an unreadable dictionary key, and ASP.NET's own binding messages. +- **`TryParse` catches the wrapped type's refusal alone (decided 2026-10-13).** Over a type with no + `TryParse` of its own (a type converter, a static `Parse`, a string constructor) the generated + `TryParse` wrapped the inner parse and `Create` in a bare `catch`, so an exception `Create` threw was + a `false` there, while `Parse`, and `TryParse` over an `int`, let it through. It catches only the + `FormatException` the inner parse throws for unreadable text (`GeneratedParsing.Unreadable`), around + the inner parse alone (`ThrowingCreateTests`). +- **`Uri` parses relative or absolute (decided 2026-09-28)**, as JSON and `UriTypeConverter` read it: + `new Uri(s)` made "/orders/7" a `file://` URI on Unix and threw for "orders/7". +- **A hand-written `ToString()` is a seam (decided 2026-09-28, [decision 28](adr/0028-seams-declared-everything-else-refused.md)).** It was replaced + silently, so a value object that hid a secret printed it. It is kept, and then no formatting + interface (`IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`) is generated, since + interpolation would reach those first; the type converter and JSON still write the wrapped value. +- **More declarations refused (decided 2026-09-28).** Each failed as a Metalama error naming nothing + the user wrote, or not at all: + - CMTK1010: a value object declared in more than one `partial` part. Metalama 2026.1 (and 2027.0 + preview) writes the override of the record's synthesized `ToString` into every part (CS0111 as + LAMA0611); no other advice overrides it (LAMA0041, LAMA0509, LAMA0500 measured). A part a source + generator adds is not in `Sources` and does not count. The repro for an upstream report is in the + phase 10 notes below. + - CMTK1011: a hand-written member or attribute the generators introduce (`From`, `Value`, + `Parse`/`TryParse`, `MinValue`/`MaxValue`, `[JsonConverter]`, `[TypeConverter]`, the interfaces), + checked before anything is introduced, as CMTK1009 is; the seams (`TryFrom`, `FromKnownGood`, + `Revalidate`, `CompareTo(TSelf)`, `ToString()`) stay allowed. Some of these were introduced with + `OverrideStrategy.Ignore` and kept silently: a hand-written `Parse` became the binding entry + point. `Create` implemented explicitly is CMTK1011 too, since the generated code calls + `{Type}.Create`. Since phase 11 also a hand-written `Equals(TSelf)`/`GetHashCode()`/ + `IEquatable.Equals`, `PrintMembers` without `ToString()`, an explicit implementation of an + interface the generators implement (CMTK1008 for the comparison interfaces), and a declared + `IConvertible`. + - `Create` is declared once, public: an explicit one beside it is CMTK1011, since a generic + `T.Create` would reach a second rule set. A base record's members count as the value object's + own: its `sealed` `ToString()` is the seam; an unsealed one, its `PrintMembers`, its explicit + implementations of the generated interfaces and an `IConvertible` on it are CMTK1011, or CMTK1008 + for comparison. Members the compiler generated on a record from another assembly do not count. + - CMTK1012: state besides the wrapped value, an instance field, auto-property, `required` member or + field-like event, declared or inherited. JSON, parsing and the materializer carried the wrapped + value alone while equality compared the rest. Computed and static members stay allowed. In a + referenced assembly a base's private fields are invisible, so a writable property counts there. + - CMTK1005 adds a generic wrapped type, tuples included (its equality is not value equality: a + `List` compares by reference), a value object named `Value` (CS0542), and a `file`-local one + (LAMA0001, which failed the whole project). +- **Build failures that named nothing the user wrote (decided 2026-10-13, the pre-tag review).** Each + was LAMA0611 or a bare compiler error: + - A value object named as any other member the generators introduce (`From`, `TryFrom`, + `FromKnownGood`, `Revalidate`, `Parse`, `TryParse`, `TryFormat`, `MinValue`, `MaxValue`, + `CompareTo`, the private helpers) is CMTK1005, whatever it wraps, so the rule is one list + (`ValueObjectDeclaration.GeneratedMemberNames`); a closed set named as one of its generated members + (`Name`, `All`, `Match`, ...) is CMTK1021, a second definition of that id with the set's own message. + Both were CS0542 inside the generated code. + - `IValue` is CMTK1005: `Enum.Parse(string)` was taken for a static `Parse` of its + own (CS0411). The static-`Parse` strategy now takes only a non-generic one. + - An `[Obsolete(error: true)]` on a value object, or on a type it is nested in, made the companion + class's use of it CS0619. It is made to work, not refused: the companion class carries the nearest + `[Obsolete]`, and C# reports no use of an obsolete type inside an obsolete declaration. Nothing else + generated names the value object outside it. A closed set is generated inside itself and built. + - A hand-written `operator ==`/`!=` between two of the type is CMTK1011 beside C#'s own CS0111, which + names only `op_Equality`; it is a hand-written equality like `Equals(TSelf)`. +- **A `DateTime` is UTC on the wire (decided 2026-09-27).** Written and read, value and key, through + `GeneratedJson.AsUtc`: a `Local` value converts, and an `Unspecified` one (an offset-less string, a + column without a time zone) is taken as UTC with its digits kept. `ToUniversalTime()` read it as + server-local, so 12:00 became 10:00Z on a server at +02:00 and 12:00Z on CI. The guards switch the + process to a +05:30 zone, since CI runs in UTC. +- **`From(null)` throws** `ArgumentNullException` for a reference-type wrapped value, as + `Option.Some(null)` does. It wrapped a null that `Value` promises it never holds. +- **Enums** parse by name through `Enum.TryParse`, with a `FormatException` for an unknown name. + `Convert.ChangeType` cannot make an enum from a string, so the former `IConvertible` strategy + threw for every input while the type advertised `IParsable`. Nothing else that is convertible + lacks `ISpanParsable`, so that strategy is gone. +- **String comparison is ordinal**, so ordering agrees with the record's equality. Culture + comparison sorts "a" before "B" and ignores a zero-width space, and a `SortedSet` then dropped a + value a `HashSet` kept. +- **A nullable wrapped type is CMTK1005.** The markers' `notnull` constraint is only a warning + (CS8714), and `IValue` then failed inside the generated code as LAMA0611/0612. +- **`object` as the wrapped type is CMTK1005 (decided 2026-10-13).** The serializer writes an `object` + by the run-time type of what it holds; the plan calls the `object` converter directly, which does + not, so `OverObject.From(5)` was written as `{}` where an `object` property holding 5 is written as + 5. Measured beside it: an interface (`IDisposable`, a `[JsonPolymorphic]` one) and an abstract class + (a plain one, an abstract record, a `[JsonPolymorphic]` one), and `ValueType` and `Array`, are written and + read exactly as the serializer writes and reads the declared type, refusals included, so they stay + allowed. +- **A value object never wraps a value object (decided 2026-09-27).** CMTK1005, answered in the + fabric because it reads the wrapped types (see "An aspect never scans its namespace" below). + - **A cycle** (`sealed partial record SelfWrap : IValue`, or A wraps B wraps A) has no + finite form: the fallback JSON converter serializes the wrapped value through the options, + which is its own converter again, and a schema walk never ends. As a record class it compiled + without a word (spikes/OpenApiSchemas finding 16), as a struct it failed inside the generated + code (CS0523 as LAMA0611). The message names the chain: `it wraps itself (A -> B -> A)`. + - **Any other value object** (`Outer : IValue`) terminates, but its surface depended on + where the inner one was declared. Over one from a referenced project it got every interface; + over one from its own project it silently lacked `IParsable`, `IComparable`, `ISpanFormattable`, + `IConvertible`, `IMinMaxValue` and the type converter, and sorting it threw: the aspects of one + layer do not see what their sibling instances introduce (measured 2026-09-27). The message + names what to wrap instead (`wrap 'Guid' instead`); a validated value object applies the inner + one's rules in its `Create`. Refused rather than made to work: a surface that follows the + project layout is the silent failure this repo exists to prevent, and lifting a refusal later + breaks nobody, while adding one after 1.0.0 would. + - **A closed set as the wrapped type is not refused** (decided 2026-10-13, + [decision 60](adr/0060-value-object-over-closed-set-generated-in-full.md)), wherever it is declared. + Over a set of its own project `OverSet : IValue` lacked `Parse`, `TryParse`, `IParsable` and + its own type converter while its JSON worked, because `ClosedSetAspect` ran last, after the + value-object aspects. It is a different aspect class, so ordering fixes what ordering cannot for a + value object over a value object: `ClosedSetAspect` runs first, and the value-object aspects see the + set's `IParsable`, `Parse` and `[TypeConverter]` as they see a referenced set's. + - The satellites keep their own refusal ([OpenAPI](openapi.md)): a value object declared by hand, with the + interfaces and `GeneratedValueObjectAttribute<,>` the satellites read ([Discovery](discovery.md)), can still wrap itself. +- **Every JSON key goes through the wrapped type's own converter**, which knows the type's key + format where it has one (an enum by name, a `Uri` as its text) and throws `NotSupportedException` + where it has none. Writing the serialized value as the property name gave a `Uri` key quotes inside + its quotes and an enum key its number, and neither read back. +- **A record class sorts null first.** Its generated `CompareTo` and comparison operators take a + nullable operand, as `IComparable.CompareTo(T?)` and the record's own `==` do, and treat null + as smallest, where they threw `NullReferenceException`. +- **Ordering follows the wrapped type.** `CompareTo` delegates to the wrapped type's, ordinal for a + string, so it agrees with equality exactly where the wrapped type's own does. A custom wrapped + type whose `CompareTo` returns 0 for values its `Equals` tells apart makes a `SortedSet` drop one, + of the value object and of the wrapped type alike (measured 2026-09-27). That is the wrapped + type's contract to keep, and the marker interfaces' documentation says so. +- **One comparison seam (decided 2026-09-27).** A hand-written `CompareTo(TSelf)` is kept, and the + object overload, the operators and the interfaces are derived from it, as `TryFrom` is for the + factories. Any other hand-written comparison member is CMTK1008: an operator failed the aspect + (LAMA0500, naming no fix), and an object overload was kept silently beside a generated generic + one that need not agree with it. +- **An aspect never scans its namespace (decided 2026-09-27, + [spikes/ConcurrentNamespaceTypes](../spikes/ConcurrentNamespaceTypes/README.md)).** What owns + a companion name is answered in the fabric, before any introduction, and reaches the aspect + through its constructor and the aspect state. Anything that has to look beyond the aspect's own + target goes the same way. +- **Never look up the code model by name (decided 2026-09-28, same spike).** Compile-time code + finds a type or member by enumerating a collection and comparing names: no `OfName`, no + `OfExactSignature`/`OfCompatibleSignature`, no string indexer on `Fields`/`Properties`/`Events`. + Metalama builds a collection's by-name index lazily and without a lock, and runs the fabric's + factory, like the instances of one aspect layer, in parallel on one code model. An `OfName` that + overlapped another caller completing the collection returned nothing for a declared + `TakenNameExtensions`, and the build compiled a second one (CS0260) or Metalama failed the aspect + (LAMA0531). In the fabric that missed CMTK1007 in 13 of 300 builds; the move there on 2026-09-27 + had measured 0 in 48 by chance and blamed sibling introductions, which was wrong. With concurrent + build off: 0 in 200. Enumerating, which takes the collection's lock: 0 in 600, and 4 in 150 + again with it reverted. The race has no deterministic test, so `CodeModelLookupTests` + (conventions) fails on any such lookup in `.Generators`. +- **Metalama 2026.1.** Aspect state uses `IDurableRef`, which exists in 2026.1. `[Durable]` on the + `_value` template placeholder is 2027.0-only and stays out until the upgrade ([decision 4](adr/0004-metalama-2026-1-x-latest-stable.md)). Build + each aspect on 2026.1 as it lands, and use no 2027.0-only API. +- **`AspectOrder`** lives in `CodoMetis.TypeKit.Generators`: `ClosedSetAspect` first, then + `ValueObjectAspect`, which leaves the state the other value-object aspects read + ([decision 60](adr/0060-value-object-over-closed-set-generated-in-full.md)). The closed-set aspect reads + nothing the value-object aspects introduce: the set's own fields, constructors, initializer text, + methods, attributes and interfaces, none of which they touch, and no member's data type (checked + 2026-10-13). Open point: ordering against a consumer's own aspects (fabric spike D). +- **Not in scope:** a `Try`/`Unit` type ([Option and Result](option-result.md) non-goals; an exception enters a result through + `Result.Catch`, phase 13), clock-type analyzers, and decimal JSON converters (no aspect needs them, + checked in phase 3). The text guards joined phase 13 on 2026-10-13, as string extensions in the + base package. +- **Public aspects (decided 2026-10-13, [decision 43](adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md)).** The value-object aspects are internal: the + fabric applies them, and their surface is what they generate. An aspect a consumer applies by hand + may be public when the aspect is the feature, with the attribute and its diagnostics as its + surface. None ships yet: the first candidate, a parameterless-constructor guarantee, went to the + analyzer and a code fix (phase 13), because its value is the error, which has to appear in a + project that never compiles with Metalama. diff --git a/design/closed-sets.md b/design/closed-sets.md new file mode 100644 index 0000000..fc56e88 --- /dev/null +++ b/design/closed-sets.md @@ -0,0 +1,71 @@ +# Closed sets + +A closed set is a `sealed partial class` implementing `IClosedSet` (or `IFaultSet`), +whose members are its `public static readonly` fields of its own type. `ClosedSetAspect`, applied +by the same transitive fabric as the value-object aspects and first in `AspectOrder`, generates the +rest. Measured in [spikes/ClosedSets](../spikes/ClosedSets/README.md); shipped in 2.0.0 +([decision 42](adr/0042-2-0-0-next-release-bundles.md), [decision 44](adr/0044-2-0-0-net10-0-metalama.md)). +The consumer's guide is [docs/closed-sets.md](../docs/closed-sets.md). + +## What is generated, and what must stay true + +- **Generated:** `Name`, `QualifiedName` (the nesting chain with dots, `Order.Status.Draft`), `All` + in declaration order, `Match` with one required delegate per member, `TryParse`/`Parse` (ordinal, + each with an `ignoreCase` overload), `IParsable` explicitly, `ToString()` unless written by + hand (the one seam), a nested JSON converter and type converter, `GeneratedClosedSetAttribute` + for run-time discovery, and a private constructor where none is declared. +- **Members are singletons.** Equality is reference equality, `Parse` answers the member itself, and + nothing outside the type can make an instance (CMTK1016, CMTK1017). A record is refused (CMTK1020): + `with` would copy a member into an instance that is none. +- **Names come after the field initializers.** An initializer injected before the static constructor + (`InitializerKind.BeforeTypeConstructor`) assigns each member's name and builds `All`, so a member + needs no `nameof`, and the parts of a set split across files are all initialised when it runs. + The cost is a window: a static field initializer of the set itself runs before it, and anything + generated that it reads there is refused rather than answered from a half-built set. A member + written in the static constructor's body would hold an instance already named as another, and one + written in a field initializer would give one instance two names, all of which compiles: the + analyzer refuses every write to a member outside its declaration (CMTK0013, decided 2026-10-14 + before the tag, after a review found `Open = Closed` turning `Closed.Match` to the open branch). +- **`All` is a wrapper, never the array.** Cast to `IList` or `T[]`, the array was writable, and + a member replaced there was listed while `Parse` answered the original. +- **Ordinal unless asked** ([decision 51](adr/0051-closed-set-parses-ignoring-case-only.md)): JSON, + the EF column, binding and `IParsable` read the name as stored or sent; `ignoreCase: true` is an + overload the caller opts into. Two members whose names differ only in case are refused (CMTK1021), + since they are one name to that overload and, where the first letter differs, one `Match` + parameter. CMTK1021 compares by each transform itself, ordinal ignoring case for the overload and + the parameters' own lower-casing of the first letter for `Match`: they differ for the Kelvin sign + (U+212A), which only the second takes for `K` (decided 2026-10-13, after a LAMA0611). +- **No refusal quotes the input** ([decision 27](adr/0027-no-refusal-quotes-input.md)): an unknown + name is a `FormatException` or a `JsonException` naming the set, and its message links the + README's entry ([decision 37](adr/0037-refusal-s-message-landing-page.md)). + +## Where each satellite meets a set + +| Where | How | Held by | +|---|---|---| +| JSON | The name, as a value and as a dictionary key; no naming policy renames it, since a renamed name would not read back | `ClosedSetTests` | +| EF Core | A text column of the name through `ClosedSetConverter`, found by the type-mapping plugin through the discovery attribute, as a value object is ([EF Core](ef-core.md)); an unknown stored name throws when read | `ClosedSetMappingTests` | +| OpenAPI | A string enum of the names, as a component named after the nesting chain ([decision 24](adr/0024-nested-value-objects-named-after-nesting.md)), in every position a parameter or a property can take; a nullable property admits null, inlined too ([decision 36](adr/0036-nullable-value-object-admits-null-openapi.md)) | `ClosedSetSchemaTests`, `ClosedSetBindingTests` | +| Fault responses | An `IFaultSet` member carries a `FaultKind` and an optional description, and `AddFaultResponses()` maps every member by kind ([decision 49](adr/0049-fault-responses-client-can-act.md)) | `FaultResponseTests` | +| Serilog | Logged as what it prints, never destructured into its constant data ([decision 41](adr/0041-sensitive-value-object-declares-tostring-serilog.md)) | the Serilog tests | + +## Diagnostics + +CMTK1013 to CMTK1021, all Errors, each reported on the declaration it names; the table with the +fixes is in the [Generators README](../src/CodoMetis.TypeKit.Generators/README.md). A set the aspect +cannot complete is never left half-generated. + +## Wrapped by a value object + +A value object over a set, `IValue`, is generated in full wherever the set is declared +([decision 60](adr/0060-value-object-over-closed-set-generated-in-full.md)): `ClosedSetAspect` runs +first, so the value-object aspects see the set's `IParsable`, `Parse` and `[TypeConverter]` and build +the value object's parsing and type converter on them. Last, it ran after them, and over a set of the +same project the value object had no parsing and the base `TypeConverter`. Held by +`ClosedSetWrappingTests` (a struct, a record class, a nested set, a validated one, and a set of a +referenced project) and by CMTK1011 still refusing a hand-written `Parse` there. + +## Open + +- A stable external name for a member, apart from its C# identifier, waits for a spike + ([roadmap](roadmap.md)): parsing, JSON, EF, OpenAPI and the fault identifiers would change at once. diff --git a/design/discovery.md b/design/discovery.md new file mode 100644 index 0000000..f36f9c5 --- /dev/null +++ b/design/discovery.md @@ -0,0 +1,21 @@ +# Discovery is by interface + +No namespace strings, assembly-name prefixes or type-name lists anywhere. A name-based check +passes in tests and breaks for the first consumer who renames something. + +| Where | How | +|---|---| +| Analyzer, value-object rules | `CompilationStartAction` resolves `CodoMetis.TypeKit.ValueObjects.IValue`1` / `IValidatedValue`3` with `GetTypeByMetadataName` and compares `OriginalDefinition` with `SymbolEqualityComparer`. If `CodoMetis.TypeKit` is absent, nothing is registered | +| Analyzer, `[RequireCustomInitialization]` | The attribute symbol is resolved the same way | +| Analyzer, CMTK0002 | The compilation references an assembly whose name is exactly `CodoMetis.TypeKit.Generators`, ignoring case as assembly names do. That is the package's own id, which a consumer cannot rename, and a prefix or suffix match never counts. The assembly is not strong-named, so the name is the part of its identity to compare; the version must not be | +| EF Core | EF asks the type-mapping plugin per CLR type, and it answers for any type carrying `GeneratedValueObjectAttribute` ([EF Core](ef-core.md)). There is no type scan and no assembly filter | +| OpenAPI | The transformer reads `GeneratedValueObjectAttribute<,>` on the schema's `JsonTypeInfo.Type`, its element type, or a parameter's type or model metadata ([OpenAPI](openapi.md)). There is no referenced-assembly walk | +| Generators, closed sets | The fabric selects a class that implements `IClosedSet`, directly or through `IFaultSet` or another interface, comparing each implemented interface's definition with the symbol of `IClosedSet<>` ([Closed sets](closed-sets.md)). A member is a public static readonly field of the set's own type, found by its type, never by a name | +| Analyzer, closed-set rules | `IClosedSet`1` is resolved with `GetTypesByMetadataName` beside the value-object markers, and a set is a type whose interfaces' `OriginalDefinition` is one of those symbols (CMTK0002, CMTK0012, CMTK0013) | +| Run time, closed sets | EF Core's type-mapping plugin, the OpenAPI schema transformer and the Serilog policy read `GeneratedClosedSetAttribute` on the type, whose type argument is constrained to `IClosedSet`; `Accept` hands the set to generic code as a type argument, so there is no `GetInterfaces()` and no `MakeGenericType` | + +Run-time code that holds only a `Type` reads the attribute rather than calling `GetInterfaces()` +([decision 17](adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md)). Its type arguments are constrained to `IValueObject` and +`IValueObjectMaterializer`, so the interface still decides what a value object is; the +attribute is how a type says so after trimming, which removes an interface nothing uses and keeps +custom attributes. diff --git a/design/ef-core.md b/design/ef-core.md new file mode 100644 index 0000000..815e768 --- /dev/null +++ b/design/ef-core.md @@ -0,0 +1,126 @@ +# EF Core + +- **Materializer.** An interface in `CodoMetis.TypeKit.ValueObjects`: + `IValueObjectMaterializer { static abstract TSelf Materialize(T value); }`. The aspect + implements it **explicitly**, so it is invisible on the type's public surface and reachable only + through a constrained generic. Documented contract: *skips validation; for values this + application wrote itself; never call it on input.* CMTK0004 enforces who may call it. +- **One pair of conversions.** `ValueObjectConverter : ValueConverter`, public so a + property can also name it explicitly. Its expressions call its own public static + `ProviderValue` and `Materialize`: an expression tree cannot call a static abstract member + directly (CS8927), and EF's compiled model writes the expressions out as C# in the application's + assembly, where an internal helper did not compile ([Native AOT](native-aot.md)). `UseTypeKit()` composes a plain + `ValueConverter` over the same expressions, the type the compiled model recreates, because a + precompiled query casts a property's converter to its design-time type. +- **No per-type comparer.** A `readonly record struct` already has value equality, and EF's default + comparer uses it. A test pins that it agrees with comparing `.Value`. +- **Application (decided 2026-09-27, spikes/EfMapping).** `optionsBuilder.UseTypeKit()` registers + an options extension. The name is deliberately not value-object specific, so a later `Option` + column mapping can join it. It adds an `IRelationalTypeMappingSourcePlugin` that answers any + `IValueObject` with the provider's mapping for `T` and the converter composed onto it. + EF consults it per CLR type wherever it maps one: property discovery, keys, foreign keys, + primitive-collection elements, query parameters. The lookup's facets are passed on. The plugin + composes the JSON reader/writer itself, with both types known through the attribute's visitor: + EF's own composition uses `MakeGenericType`, which it refuses under Native AOT, where a precompiled + query still maps a value-object parameter at run time. + - Not a replaced `IValueConverterSelector`: it maps the same, but a second library replacing the + selector (as strongly-typed-id guides recommend) would take it away, and the resulting model + error does not name the cause. Plugins are additive. This was first decided the other way on + an unmeasured assumption, and reversed by the spike the same day. + - Not a pre-convention: `Properties()` needs the list of types up front, which means a scan. + - An application that builds EF's internal service provider calls + `AddEntityFrameworkTypeKit()` on that service collection instead. +- **Translators.** `ValueObjectMemberTranslatorPlugin` / `ValueObjectMethodCallTranslatorPlugin` + are registered by the same options extension, so a consumer wires nothing else. A column + operand is **re-typed** as the wrapped type, keeping the column's store type, rather than cast: + `Convert` produced `o."Code"::text = 'ABC'`, which PostgreSQL discards but which is not a no-op + everywhere (on SQL Server, a cast to `nvarchar(max)` can stop an index seek). Any other operand + is still converted. The method translator acts only on a method whose signature is the unwrap, + from a value object (or its `Nullable`) to what it wraps: the attribute is public, and a + hand-marked `Length(this ProbeCode)` was translated as the column (`o."Code" = '3'`). Any other + method carrying it is left to EF, which refuses the call. + - **Only on TypeKit's converter (decided 2026-10-13, [decision 56](adr/0056-value-translated-only-through-typekit-converter.md)).** + A column the application maps with its own `HasConversion` stores its own representation, and + `.Value` re-typed as the wrapped type queried the wrong one: `StartsWith("a")` against `"C:abc"` + answered no row. The translators act only where the operand's to-provider expression is + `ValueObjectConverter<,>.ProviderValue`, composed with an enum's converter where there is one, + which holds in a compiled model too; anywhere else EF refuses the query. + - **A hand-written ordering is refused in a query (decided 2026-10-13, [decision 57](adr/0057-hand-written-ordering-refused-in-query.md)).** + EF translates a comparison, an ordering and `Min`/`Max` to the column's order, not to a + hand-written `CompareTo`. The comparable aspect marks such a type `[HandWrittenOrdering]`, and an + `IQueryExpressionInterceptor` that `UseTypeKit()` registers refuses those operators on it, + `Queryable`'s and `Enumerable`'s over a navigation alike, before translation, naming the value + object; ordering by `.Value` stays allowed. EF adds a query filter after the interceptor has seen + the query, so `HasQueryFilter(r => r.Rank > From(2))` kept the column's rows (2.0.0's review); a + model-finalized convention refuses such a filter, named or not, naming the entity type, when the + model is built (and so at `dbcontext optimize` and `migrations add`). A comparison in a filter that + reads neither the entity nor a query is left alone: EF evaluates it in memory, by `CompareTo` + (measured: `Floor > From(2)` on the context kept the rows memory keeps). + - **The element of a primitive collection is converted, not re-typed (decided 2026-09-28).** EF + infers a collection's element mapping from the columns that read its table expression (`unnest`, + `json_each`, `OPENJSON`, `VALUES`), and a re-typed element column handed it the wrapped type's + mapping for a collection of value objects: `o.Tags.Any(t => t.Value == "x")` and + `ids.Any(i => i.Value == o.Id.Value)` threw during translation on PostgreSQL and SQLite. EF 10's + `ColumnExpression` no longer knows its table, so the element column is recognised by the name + every relational provider gives it, `value`; the name only chooses between two correct + translations, and a table column named `Value` gets the cast (`t.value::text = 'x'`). +- **Keys over an integer are generated on add (decided 2026-09-28, [decision 23](adr/0023-value-object-key-over-integer-generated.md)).** EF's value + generation convention reads the property's CLR type, so a value-object key was never generated: an + entity added without one was stored as 0, and switching an `int` key to a value object dropped its + identity column. A convention plugin marks a single-column, non-foreign key over the integer types + EF generates (`int`, `long`, `short`, `sbyte` and the unsigned ones, as EF's `IsInteger()` minus + `byte` and `char`) as generated on add, at convention precedence. It runs after the last + hierarchy-mapping and shared-table convention: SQLite re-decides a key from its CLR type when the + root's mapping strategy is set, which reset a TPH root's key to not generated, and a TPC key stays + generated only where the provider gave it a sequence, as for a plain `int` + ([decision 58](adr/0058-integer-key-follows-ef-rule.md)). The sequence is read in the first table + the key is mapped to, as the providers' own conventions read it: an abstract TPC root has no table + of its own, and reading the root's table took the generation away (2.0.0's review: on PostgreSQL + every entity was stored with 0). The model tests build each shape for SQLite, Npgsql and SQL + Server, an abstract TPC root included. Where the provider has no identity for the type, a plain key + is not generated either, and neither is the value object's (measured on PostgreSQL 18: a `ulong` is + `numeric(20,0)`, strategy none). From a 1.5.1 snapshot, the `AlterColumn` that declares a key + generated adds the identity to a key over `sbyte`, `ushort` or `uint` on PostgreSQL; SQL Server's + migrations generator refuses it for those and `ulong` ("To change the IDENTITY property of a column, + the column needs to be dropped and recreated"), as for any key whose identity changes. A key over a + `Guid` is left alone: the application assigns `OrderId.New()`, and a generated key that is already + set makes EF take a new entity reached through a navigation for an existing one. + - **Declared generated is not parity (decided 2026-10-13, [decision 63](adr/0063-char-keys-application-assigned-ulong-documented.md)).** + The convention declares a key generated (`ValueGenerated`); the provider supplies the mechanism, + and EF picks the generator that fills a key at `Add` by its CLR type, else by its converter's + provider type, which for a value object is the column's. Where the provider stores the wrapped + type through a converter of its own, a value object's default key therefore gets another + generator than a plain key. Two consequences, measured on all three providers' models and `Add`, + and on PostgreSQL 18's inserts: + - **A key over `char` is never declared generated.** No provider generates one: PostgreSQL refuses + an identity on `character(1)` (22023, at creation and in the `AlterColumn` from a 1.5.1 + snapshot), SQLite refuses the `TEXT … AUTOINCREMENT` column of a plain key, and a plain key on + SQL Server (`nvarchar(1)`, no identity) reaches the insert with only a temporary value, which EF + leaves out (read from the model; no SQL Server here). On SQL Server the value object's key, declared generated, got a permanent + character of a GUID string at `Add`, which was stored. It is `Never`, as in 1.5.1; an explicit + `ValueGeneratedOnAdd()` or value generator still applies. A default `'\0'` is stored as it is. + - **A default key over `ulong` throws `OverflowException` at `Add`** on SQL Server and PostgreSQL, + whose column is a `decimal`: EF's temporary `decimal` is negative. A plain `ulong` key gets a + temporary `ulong`. It stays declared generated, since it fails loudly and a fix needs a value + generator per key that a compiled model can write and that leaves HiLo alone; the workaround is + `ValueGeneratedNever()` and an assigned key, which a test pins with the exception. +- **Conventions for the wrapped type do not reach a value object.** `ConfigureConventions` + (`Properties()`) matches the property's CLR type; the README says to configure the value + object (`Properties()`). Measured 2026-09-28 for precision, maximum length, column type and + an enum conversion. +- **Stored JSON (decided 2026-09-27).** The generated JSON converter applies `Create` ([Aspects](aspects.md)), which + is right for input and wrong for JSON the application stored itself (an event store, a document + column): a rule added later would make old documents unreadable. `StoredJsonConverterFactory`, in + the base package because it is a System.Text.Json concern, is the opt-in for such a store's + `JsonSerializerOptions`. It hands out the generated converter in a mode only it can create + (a private constructor), so stored documents are read in exactly the format they were written + in, dictionary keys included. SECURITY.md lists it beside the EF satellite as the two + validation-free paths. +- **How far the translation is unique, measured 2026-09-27** + ([spikes/ValueTranslation](../spikes/ValueTranslation/README.md)): + - `.Value` in a query predicate fails in Vogen 8.0.7 and in plain EF Core 10. Thinktecture + 10.5.0 has no public `.Value`. + - Querying the underlying value through a **conversion-operator cast** works in plain EF Core + 10, and that is how Thinktecture users get it. + - README claim: "`.Value` works in LINQ as it does in memory". Never "the only library that can + query by the underlying value". diff --git a/design/native-aot.md b/design/native-aot.md new file mode 100644 index 0000000..78bdf5f --- /dev/null +++ b/design/native-aot.md @@ -0,0 +1,44 @@ +# Native AOT + +**Decided 2026-09-27 ([decision 16](adr/0016-native-aot-wherever-platform-allows.md)), measured by publishing consumers with Native AOT** (SDK 10.0.401, +ILCompiler 10.0.12, osx-arm64), JSON through source-generated contexts, OpenAPI in a slim web +host, EF Core through `dotnet ef dbcontext optimize --precompile-queries --nativeaot`. + +- **What the analyzers can and cannot see.** `IsAotCompatible` runs the trim and AOT analyzers on + the packages' own code, and warnings are errors. The generators' product is compiled in the + consumer, where only the consumer's publish sees it, and the analyzers cannot tell that a run-time + path still works: the two worst findings (stored JSON validated, every schema `{}`) produced no + warning at build time on the JIT. So the smoke test publishes with Native AOT and runs the binary. +- **Trimming removes interfaces nothing uses.** ILCompiler keeps an interface on a type only if the + interface's definition is used somewhere; `typeof(IValueObject<,>)` does not count. Neither + `[DynamicallyAccessedMembers(Interfaces)]` on the interface nor on the value object kept it (both + measured). Custom attributes are kept, which is why discovery reads + `GeneratedValueObjectAttribute<,>` ([decision 17](adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md)). +- **And keeps them once anything uses the interface.** The attribute's own constraint does, and so + does the EF satellite's converter: with the attribute generated, a `GetInterfaces()` lookup found + `IValueObject<,>` again under Native AOT, and without it the same host described every value object + as `{}` (both measured). That is ILCompiler's behaviour, not a contract, so the smoke test cannot + catch a return to `GetInterfaces()` at run time; the analyzers' IL2070, an error in the packages' + build, does. +- **JSON.** The generated converter's wrapped value goes through `GeneratedJson.TypeInfo` ([Aspects](aspects.md)), so a + context lists the value objects and never what they wrap. A value object wrapping a type the + serializer has no converter for (a class of the application's) still needs that type in the + context, and the serializer's refusal names it. +- **OpenAPI.** The host's context lists what its value objects wrap ([OpenAPI](openapi.md)). +- **EF Core** is experimental under Native AOT and needs a compiled model and precompiled queries; + its `DbContext` constructors require dynamic code. With the satellite, a value object maps, writes, + queries by `.Value`, binds as a parameter and reads a stored value without validation, in the + native binary. EF limitations met on the way, not ours to fix: + - the compiled model cannot write the sentinel of a **struct value object wrapping a reference + type** (`default(Code)` converts to `null`, and EF then needs a literal for the struct itself). + Such a value object on an entity is a `sealed partial record` instead; + - EF 10's precompiled queries cast an entity to an internal interface, which does not compile for + a **sealed** entity class. +- **Globalization-invariant mode** ([decision 59](adr/0059-text-guard-refuses-what-it-cannot-normalize.md)). + Native AOT templates set `InvariantGlobalization`, and there `string.Normalize` returns its input + unchanged (measured with `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`, which leaves the + `System.Globalization.Invariant` switch unset). `AsText()` asks once whether a decomposed letter + composes, and throws `PlatformNotSupportedException` where it does not; `InvariantGlobalizationTests` + runs the test assembly again in that mode. The smoke test's consumers do not call `AsText()`. +- **Third-party packages** report their own warnings (NodaTime.Serialization.SystemTextJson 1.4.0, + EF Core). The smoke test asserts only that none names a CodoMetis package or a woven value object. diff --git a/design/openapi.md b/design/openapi.md new file mode 100644 index 0000000..802f9fd --- /dev/null +++ b/design/openapi.md @@ -0,0 +1,66 @@ +# OpenAPI + +**Decided 2026-09-27, measured in [spikes/OpenApiSchemas](../spikes/OpenApiSchemas/README.md)** +with Microsoft.AspNetCore.OpenApi 10.0.12. An earlier measurement had found that a schema +transformer never reached a type that only appears as a property, and repaired such types in a +document transformer. It does not hold in 10.0.12: one schema transformer reaches every JSON +position, and the change lands in the hoisted component. + +- **The schema is the wrapped type's, as ASP.NET publishes it for this host**, asked of ASP.NET + through `context.GetOrCreateSchemaAsync(typeof(T))`. It therefore follows the host's JSON options + (number handling, enum converter) exactly as the generated converter does, and the document's + other transformers (a NodaTime transformer describes a value object wrapping `Instant` too). No + format table of our own. +- **Keywords are filled in, never the object replaced**: the transformer receives the instance ASP.NET + hoists into the value object's own component, so `OrderId` stays a named component. What the + value object's schema already says is kept. ASP.NET's `Metadata` (the reference id) and the + schema-identity keywords are never copied; copying `Metadata` made every use a reference to the + wrapped type's component. A keyword is assigned only when the wrapped schema has one. +- **Containers**: ASP.NET drops `items`/`additionalProperties` for a converter-backed element before + any transformer runs; the value object's own schema is put back, only where missing. +- **Parameters** bound through the generated `TryParse` reach the transformer as `string`, ASP.NET's + placeholder for any parsable type, which the wrapped type's schema replaces. Minimal APIs name the + value object in the parameter's `Type`, MVC in `ModelMetadata.ModelType`; the parameter descriptor + names the container for an MVC `[FromQuery]` object and is not used. Each parameter then publishes + exactly what a parameter of the wrapped type publishes. +- **A value object that reaches itself** through what it wraps is an `InvalidOperationException` + naming the chain, not a stack overflow. +- **Source-generated JSON** ([Native AOT](native-aot.md)): ASP.NET builds the wrapped type's schema from the host's JSON + contract for it, and a source-generated context has none for a type the host never serializes. + The transformer then refuses with an `InvalidOperationException` naming the value object and the + `[JsonSerializable(typeof(Guid))]` to add, rather than the serializer's message about a Guid. A + resolver of our own cannot be added: the host's options are read-only by then. +- **Component names (decided 2026-09-28, [decision 24](adr/0024-nested-value-objects-named-after-nesting.md)).** ASP.NET names a component after the type's + simple name, so `Shop.Id` and `Stock.Id` shared one, and a stock id wrapping an `int` was documented + as a uuid. `AddTypeKit()` wraps `CreateSchemaReferenceId`: a nested value object that got ASP.NET's + default name is named after its nesting chain (`ShopId`, as its companion class is `ShopIdExtensions`); + a name the host's own delegate chose, or `null` for an inlined value object, is kept. A name that + is also another type's, `Order.Status` beside a top-level `OrderStatus`, refuses the document + ([decision 55](adr/0055-component-name-claimed-twice-refused.md)). +- **Fault responses** ([decision 49](adr/0049-fault-responses-client-can-act.md)): an operation + transformer adds a problem response per status an endpoint answers its fault type with, referring to + the problem schema the host's `CreateSchemaReferenceId` names (the shared `ProblemDetails` + component, or an inline schema). That schema gains an optional `fault` string, the member a client + matches on, whose description says which form the host writes (`QualifiedFaultNames`) and that only + a problem answering a fault carries it; what a schema already says of `fault` is kept. ASP.NET's own + problem responses refer to the same component (measured, 10.0.12: whichever endpoint comes first, + and a second document without a fault response keeps it bare); an inline schema of theirs is another + instance and stays bare. A document without a fault response is ASP.NET's, byte for byte. +- **Known gaps, documented rather than fixed:** a validation attribute on a value-object property + lands in the shared component, as ASP.NET does for any referenced schema (rules belong in `Create`); + a property absent from a request body stays `default`, since System.Text.Json calls no converter + for it (`required`, `RespectRequiredConstructorParameters`); a query, header or form value absent + from an MVC request reaches the action as `default` for a struct value object, MVC's rule for any + non-nullable struct, where a minimal API answers 400 (`[BindRequired]`, or a nullable parameter; + measured 2026-10-13, MvcBindingTests); a minimal API answers a refused body + with an empty 400; MVC's own binding message quotes the input, as it does for a `Guid`. +- **ASP0020** ([decision 12](adr/0012-asp0020-documented-suppressed.md)): a minimal-API route parameter whose value object is declared in the + same project fails the build, because the route analyzer reads the source before Metalama weaves + `IParsable` in. Binding is correct at run time and in the request delegate generator. A + `DiagnosticSuppressor` has no effect under Metalama's compiler, and Metalama's own suppression is + scoped to the aspect's targets, so the package README documents the pragma instead. + +Vogen 8.0.7 generates a schema transformer for Microsoft.AspNetCore.OpenApi too, from a table of +wrapped types (checked in its source at the tag, 2026-09-28; the 2026-09-27 note here said +Swashbuckle only). The README describes what the package does and claims nothing about other +libraries. diff --git a/design/option-result.md b/design/option-result.md new file mode 100644 index 0000000..446657f --- /dev/null +++ b/design/option-result.md @@ -0,0 +1,137 @@ +# Option and Result + +`Result`, `Result` and `Option`, with the `Option.None()`, `Result.Success()` and +`Result.Error()` markers ([decision 19](adr/0019-concise-implicit.md)), ship in `CodoMetis.TypeKit`. The value-object contracts +depend on them: `IValidatedValue.Create` returns a `Result`, and the generated `TryFrom` returns an +`Option`. + +**Non-goals.** No Either, no Validation applicative, no effect or IO types, no higher-kinded type +emulation, no immutable collections. No `Try` type: it is `Result` under another name, +and an exception enters through `Result.Catch` (phase 13, [decision 43](adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md)). No `Unit`: `Result` +is the value-less shape. Anything that turns this into a second LanguageExt is out. + +**Shape, decided 2026-09-27.** +- **`Result`, value first.** Error-first (`Either e a`) exists in Haskell and Scala + only because a generic type there can be filled in from the left alone, which C# cannot do. + Rust, F#, Swift, DotNext, CSharpFunctionalExtensions and the csharplang unions design note all + put the value first. `IValidatedValue` does too. +- **`readonly record struct`, non-positional, private fields.** The synthesized equality is + correct (`State` included). The synthesized `ToString` prints only public members: + `Option { }` and `Result { State = Error }`. So it never prints a value, in line with + `FromKnownGood`'s message. **Never make these types positional:** positional parameters become public + properties, which reintroduces `.Value` and puts the value into `ToString`. +- **The markers** ([decision 19](adr/0019-concise-implicit.md)): `Option.None()`, `Result.Success()`, `Result.Success(x)` and + `Result.Error(e)` return a small struct that converts implicitly to whatever `Option`/`Result` + the target is, so a method returns one without spelling out type arguments. They keep their + content internal too, so they print `Success { }` and `Error { }`. Only the implicit conversions + read it. Both shapes accept both result markers: `Result` converts from `Result.Error(e)` + as well as from a bare error, so a method can `return Result.Error(fault);` whichever shape it + returns. No combinator takes a marker-returning lambda: `Bind(x => Result.Success(x))` was `Map` + under another name, and is a compile error now. +- **`Result` converts from a bare value, never from a bare error** (measured + 2026-09-27). With both conversions, `Result r = 5;` compiles and is an **error**: + `int` is the more specific source type, so C# picks the error conversion. The READMEs had shown + `return Fault.X;` in valued methods, which does not compile; the consumer smoke test found it. +- **`Option.Some(null)` throws.** `notnull` is an annotation the runtime does not enforce, and a + `Some` over null reported a value it could not hand out. `Map` and the zips go through `Some`, + so a selector that returns null throws too. +- **So do `Result`'s factories.** Both shapes and both markers constrain their content to + `notnull`, and `Success(null)`/`Error(null)`/`Result.Success(null)`/`Result.Error(null)` throw + `ArgumentNullException`: a result over null handed it out of `TryGetValue`/`TryGetError` despite + `[NotNullWhen]`. `Map`, `MapError`, `Bind` and the conversions go through them. +- **Every delegate is checked before a branch is picked**, in `Option`, both `Result` shapes and + their extensions: a null for the branch not taken passed until the other outcome first arrived. + `NullDelegateTests` holds every delegate parameter of the assembly to a case run on both branches. +- **`Result.Match` hands the error to its error branch.** The only overload took a + parameterless `onError`, so `TryGetError` was the only way to the error. The parameterless one + stays, for a branch that does not need it. +- **`[DebuggerDisplay]`** on `Option` and both `Result` shapes shows the content in the debugger, + where it is what someone stepping through wants to see. The debugger is not a log. +- **`Result` has no `AsEnumerable`.** A sequence of zero or one units says no more than + the `bool` conversion. +- **`Fault` (2.0.0)** carries a reason from a fixed set and an optional sentence about one + occurrence. It is made by `Fault.Of` alone, and comparing it with a bare reason is refused, since a + fault with a detail never equals one + ([decision 53](adr/0053-fault-made-by-fault-of-alone.md)); `Reason` is what a caller compares. + A `Cause` beside them joins 2.1 ([decision 52](adr/0052-cause-on-fault-joins-2-1.md)). +- **Pipelines (decided 2026-09-28).** `MapAsync`/`BindAsync` take an asynchronous selector on a + result, and every combinator of both shapes continues a `Task>` (`MapAsync`, `BindAsync`, + `MapErrorAsync`, `TapAsync`), so a chain is awaited once, at its end; a completeness test holds + every combinator to a continuation. `Match` ends a chain after that `await`. Each continuation + checks its delegate before awaiting, so a null one is reported even when the pending result fails. + `Zip` combines two to six results, `Sequence` and `Traverse` a sequence of them. All stop at the + first error, in argument or sequence order: collecting errors is the validation applicative the + non-goals exclude. A zip still inspects every argument, so an uninitialized one after an error + throws. Callbacks return `Task` ([decision 30](adr/0030-asynchronous-callbacks-return-task.md)): `TapAsync` and `TapErrorAsync` take a + `Func<…, Task>` beside the `Action`, so an `async` lambda is awaited; a `ValueTask` one needs + `.AsTask()`. +- **A callback runs where the task it waited for completed (decided 2026-10-13, + [decision 62](adr/0062-asynchronous-callbacks-run-where-awaited-task-completed.md)).** Every `await` + in the package is `ConfigureAwait(false)`, so nothing resumes on the caller's + `SynchronizationContext` and its awaits cannot deadlock a caller blocked on a chain. The price, measured: a + callback after a pending task runs without the caller's context, one after a completed task runs at + once on it, so in a UI or a Blazor component a callback that touches dispatcher-bound state works or + fails by timing. That is documented rather than changed: await the task and call the synchronous + combinator, or marshal inside the callback. `AsynchronousContextTests` holds every public member + that returns a task to it. + +**Decided 2026-09-27: an uninitialized `Result` throws.** +- A `default` result is `State == Uninitialized`. If the branching members tested only + `State == Success`, it would take the **error** branch with `default(TError)`. +- For an enum fault that is the first member (e.g. `Blank`): a plausible, wrong reason. For a + reference-type error it is `null`, despite `[NotNullWhen(false)]`. +- The analyzer blocks `default` in source, but array elements, class fields (CMTK0005/0006) and + reflection still produce such instances. +- **Decision:** every member that picks a branch (`Match`, `Map`, `Bind`, `Tap`, `TapAsync`, + `TapErrorAsync`, `TryGetValue`/`TryGetError`, `AsEnumerable`, the `bool` conversion of + `Result`) throws + `InvalidOperationException` on `Uninitialized`. That is a loud failure instead of a fabricated + fault. `State`, equality and `ToString` stay safe to call. + +**Decided 2026-09-27: `Option`/`Result` are not wire types.** +- Measured: with private state and no converter, System.Text.Json wrote `{}` for a `Some` and read + it back as `None`, and wrote `{"State":1}` for a success and read it back as `Uninitialized`. + Nothing raised. +- **Decision:** `NotWireTypeJsonConverterFactory`, on `Option`, both `Result` shapes and the + `Success`/`Success`/`Error` markers, throws `NotSupportedException` from `Read` and `Write`, + naming the type and the alternative and never the content. `HandleNull` is on, so a JSON `null` + gets the same message, and a dictionary key does too. That is what the serializer itself does for + `System.Type`. It hands every type one converter typed `object`, which the serializer wraps: a + converter per type needed `MakeGenericType`, and under Native AOT that failed for these structs + with the runtime's message instead of this one ([Native AOT](native-aot.md)). +- A serialized shape says absent with `T?`; `ToOption()`/`OrNull()` convert at the boundary; a + result is matched to a response or a document. An application that wants a wire format registers + its own converter on its options, which takes precedence over the type's attribute, in a + source-generated context too (measured). +- **Not a lossless converter** (`Some(x)` as `x`, `None` as `null`): its bytes are identical to + `T?`, so it would only move the CLR type onto the shape the design keeps it off, and a `Result` + has no sensible wire shape at all. +- **Not an analyzer:** whether a type is serialized is a property of the application's roots and + options, which the package cannot see. An application can add a shape test of its own. +- **Known gap:** the serializer calls no converter for a property that is absent from the document, + so it stays `default`. `required` or `RespectRequiredConstructorParameters` closes it, and the + README says so. +- Completeness: `NotWireTypeTests` walks every exported struct of the assembly, so a struct added + later refuses too, or is exempted on purpose. + +**Why not an existing package** (checked 2026-09-26). The criteria: no public `.Value`/`.Error`, +a generic `TError`, `default` is not success, maintained. +- **Funcky 3.6.0** is closest (`[NonDefaultable]` enforced by an analyzer, no `.Value`), but its + `Result` fixes the error type to `Exception`. +- **nlkl/Optional** has the right Option shape but no Result, and no release since 2018. +- **CSharpFunctionalExtensions, DotNext and FluentResults** expose a throwing `.Value`. +- **ErrorOr, Remora.Results and Ardalis.Result** expose an unguarded one. With ErrorOr, + CSharpFunctionalExtensions and Remora, `default(Result<…>)` reads as success. +- **JSON** (checked 2026-09-27): CSharpFunctionalExtensions 3.7.0 ships opt-in converters that write + a `Result` as a DTO with `IsSuccess`/`Error`/`Value`, for passing outcomes between services; + LanguageExt (v5 still beta) ships none; ErrorOr, OneOf and FluentResults ship none and serialize + their public properties as they are. None refuses. + +C# 15 `union` types (in .NET 11 preview) ship no standard `Option`/`Result`, and they need .NET 11 +anyway, while this repo targets net10.0. + +**Against the value-object libraries** (checked 2026-09-27): +- Vogen validation returns Ok/Invalid with a plain string, and its `TryFrom` is `bool` + `out`. +- Thinktecture returns a typed `ValidationError` but ships no Result type. +- TypeKit's value objects return `Result` from `Create` and `Option` from + `TryFrom`. That is the reason Option/Result and the value objects ship together. diff --git a/design/packages.md b/design/packages.md new file mode 100644 index 0000000..ed181ef --- /dev/null +++ b/design/packages.md @@ -0,0 +1,38 @@ +# Packages + +| Package | Depends on | Metalama | Contents | +|---|---|---|---| +| `CodoMetis.TypeKit` | Analyzers (flows, [Analyzers](analyzers.md)) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault` and `Fault.Of`; `Patch`, `PatchState` and `PatchJsonConverterFactory`; `Result.Catch`/`CatchAsync`; the text guards `AsText()`/`AsIdentifier()` (`StringTextExtensions`) and `TextFault`; the type-argument constraint attributes `[Abstract]`, `[Concrete]`, `[Sealed]`, `[Record]`; `RequireCustomInitializationAttribute`, `RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([Discovery](discovery.md), [Aspects](aspects.md), [Native AOT](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `GeneratedClosedSetAttribute<>`, `IValueObjectVisitor<>`, `IClosedSetVisitor<>`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `GeneratedJsonPlan<>`, `GeneratedClosedSets`, `IStoredJsonConverterSource` | +| `CodoMetis.TypeKit.Analyzers` | — | no | Roslyn analyzers and code fixes ([Analyzers](analyzers.md)). Nobody references it directly: it reaches every consumer through the base package | +| `CodoMetis.TypeKit.Generators` | TypeKit, Metalama.Framework 2026.1.x (flows) | yes | Internal aspects (the value-object family and `ClosedSetAspect`), internal `TransitiveProjectFabric`, `AspectOrder`. Future generated type families join this package too: one fabric, one aspect order | +| `CodoMetis.TypeKit.EntityFrameworkCore` | TypeKit, EF Core Relational | no | Generic converter, convention, `.Value` / `GetValue` / `ValueOrNull` translators, `UseTypeKit()` | +| `CodoMetis.TypeKit.AspNetCore` | TypeKit, Microsoft.AspNetCore.OpenApi | no | `AddTypeKit()`: one OpenAPI schema transformer ([OpenAPI](openapi.md)); `ToHttpResult()`, `AddFaultResponses`, `FaultProblem`, `FaultResponseOptions`, `IFaultFeature` ([decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md), [decision 49](adr/0049-fault-responses-client-can-act.md)) | +| `CodoMetis.TypeKit.Serilog` | TypeKit, Serilog 4.0.0 | no | `Destructure.WithTypeKit()`: one destructuring policy that logs every value object as its `ToString()` ([decision 41](adr/0041-sensitive-value-object-declares-tostring-serilog.md)) | +| `CodoMetis.TypeKit.Testing` | TypeKit | no | `ShouldBeSome()`/`ShouldBeNone()`, `ShouldBeSuccess()`/`ShouldBeError()`, and the `Patch` assertions: test assertions that hand back the content, with no test framework dependency ([decision 40](adr/0040-codometis-typekit-testing-holds-assertions-hand.md)) | + +**The bare name is the cheapest package.** Someone who only wants `Option`/`Result` references +`CodoMetis.TypeKit` and never receives Metalama by accident. Taking Metalama on is always an +explicit, named choice (`.Generators`). The EF and ASP.NET packages depend on the base package, +not on `.Generators`. They work at run time against the interfaces, so a host that maps value +objects from a referenced domain assembly never needs Metalama itself. + +**Namespaces:** + +| Namespace | Holds | +|---|---| +| `CodoMetis.TypeKit` | `Option`, `Result`, `ResultState`, the markers, `NotWireTypeJsonConverterFactory`, `RequireCustomInitializationAttribute`, `GuidValueExtensions` (`New()` is call-site vocabulary, like `Option`) | +| `CodoMetis.TypeKit` (2.0.0) | `Fault`, `Patch`, `PatchState`, `PatchJsonConverterFactory`, `StringTextExtensions`, `TextFault`, the constraint attributes, `RequireParameterlessConstructorAttribute`, `ConstructorAccessibility` | +| `CodoMetis.TypeKit.ClosedSets` | `IClosedSet<>`, `IFault`, `IFaultSet<>`, `FaultKind`: what a user declares a closed set or a fault set with | +| `CodoMetis.TypeKit.ValueObjects` | Every contract a user declares or constrains on: `IValue`, `IValidatedValue`, `IValueObject`, `IPlainValueObject`, `IValueObjectMaterializer`, and `StoredJsonConverterFactory` | +| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `GeneratedClosedSetAttribute`, `IValueObjectVisitor`, `IClosedSetVisitor`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `GeneratedJsonPlan`, `GeneratedClosedSets`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | +| `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`, `ClosedSetConverter`; the plugins, internal | +| `CodoMetis.TypeKit.AspNetCore` | the schema transformers, internal; `FaultProblem`, `FaultResponse`, `FaultResponseOptions`, `FaultStatusAttribute`, `IFaultFeature` | +| `Microsoft.AspNetCore.Http` | `ToHttpResult()`, for a `Result` and for a closed set's fault (`TypeKitHttpResultExtensions`, `TypeKitClosedSetHttpResultExtensions`), beside ASP.NET's own `Results` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `Microsoft.AspNetCore.Builder` | `WithFaultStatus()`, beside `WithMetadata` and the other endpoint conventions ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `CodoMetis.TypeKit.Serilog` | the destructuring policy, internal | +| `Serilog` | `WithTypeKit()`, beside Serilog's own configuration methods ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `CodoMetis.TypeKit.Testing` | the assertions and `TypeKitAssertionException` | +| `Microsoft.EntityFrameworkCore` | `UseTypeKit()`, beside the providers' `UseNpgsql`, and `FirstOrNoneAsync()`/`SingleOrNoneAsync()`, beside EF's `FirstOrDefaultAsync` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `Microsoft.Extensions.DependencyInjection` | `AddEntityFrameworkTypeKit()`, `OpenApiOptions.AddTypeKit()` and `AddFaultResponses()`, beside `AddEntityFrameworkNpgsql` and `AddOpenApi` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | + +The internal aspects live in `CodoMetis.TypeKit.Generators`. Users never type that namespace. diff --git a/design/patch.md b/design/patch.md new file mode 100644 index 0000000..71ec0f8 --- /dev/null +++ b/design/patch.md @@ -0,0 +1,70 @@ +# Patch + +`Patch` is one field of a JSON Merge Patch (RFC 7396): absent, null or a value. It is in the base +package, is the one wire type there, and is hand-written, not generated. Measured in +[spikes/MergePatch](../spikes/MergePatch/README.md); shipped in 2.0.0 +([decision 42](adr/0042-2-0-0-next-release-bundles.md)). The consumer's guide is +[docs/merge-patches.md](../docs/merge-patches.md). + +## What must stay true + +- **`default` is Absent.** System.Text.Json calls no converter for a property it does not find, so + the state a missing property leaves is the struct's `default`. It is the one `default` in the + package that means something, so CMTK0001, CMTK0005 and CMTK0006 leave `Patch` alone + (`PatchIsDefaultableTests`). +- **A wire type beside `Option`, which is not one** ([decision 15](adr/0015-option-result-wire-types.md)): + an option says whether a value exists, a patch whether a request mentioned a field. `ToOption()` + crosses from one to the other, and nothing converts silently. +- **Reading never refuses a null.** Null is a state of the wire format whatever `T` is; whether a + clear is accepted is the application's rule, said in `Match`. A value object inside a patch is + read by its own converter, so its rule applies there as anywhere else. +- **A refusal is at the patched property's path.** The converter reads the value with a nested + `JsonSerializer.Deserialize`, which sets the path within that value, `$`, and the serializer keeps + a path it finds set, so a client was told `$` for a bad field. The converter throws the refusal + again without a path, and the serializer sets the property's, `$.quantity` or `$[1].quantity`, as + for a property of `T`. The nested sentence is kept, a value object's own refusal among them, less + the location the serializer appended to its default sentence, which named `$`. Inside a patched + object the path ends at the patched property, since a converter cannot extend the serializer's + path (`PatchTests`). +- **`T` is `notnull`**, as `Option`'s is: a value is never null, a clear being the Null state. + Unconstrained, a `Patch` handed its value to `Match`, `Tap`, `Map` and `Traverse` as a + `string?`, so `p => p.Trim()` warned (CS8602) and `Traverse(Email.Create)` did not match (CS8622). + Handing the non-null `T` to a `Patch` and a `Patch` alike cannot be written + (measured 2026-10-13): overload resolution ignores nullability, so one member serves both; over + `Patch where T : class` a `Patch` receiver is CS8620, two blocks over `Patch` and + `Patch` lower to one signature (CS0111), and `[DisallowNull]` on a delegate's parameter does not + reach a lambda's (CS8602, CS8622). So the declaration is warned instead, once (CS8714), and a + `Patch`, which has no contract under a source-generated context either, with it + (`PatchNullabilityTests`). +- **Writing an absent patch is refused**, since a converter cannot leave a property out; + `[JsonIgnore(Condition = WhenWritingDefault)]` leaves it out instead. +- **`ToString()` never prints the value**, as for `Option` and `Result`. + +## The converter, and Native AOT + +A converter over an open `T` is the hard part. `MakeGenericType` plus `Activator` has no code under +Native AOT, a static field read by reflection is trimmed, and an open generic `[JsonConverter]` is +refused by the source generator (SYSLIB1220), all measured. So each closed `Patch`'s static +constructor registers its converter with `PatchJsonConverterFactory`, which runs the constructor +with `RuntimeHelpers.RunClassConstructor` for the type it is asked for. That call is the one +`[UnconditionalSuppressMessage]` (IL2059), its reason the spike's measurement and the smoke test's +`aot` consumer. The registry is a `ConcurrentDictionary`. + +Under a source-generated context the generator never sees `T` behind the converter: a primitive, a +string, a `Guid`, a date or a `Uri` gets the serializer's built-in contract, a value object its own +converter, and anything else must be listed in the context, where the serializer's own message +names what is missing. + +## Where each satellite meets a patch + +| Where | How | Held by | +|---|---|---| +| OpenAPI | The argument's schema, inlined, admitting null, never required, in 3.1 and 3.0 | `PatchSchemaTests` | +| Testing | Assertions per state that hand back the value | `PatchAssertionTests` | +| Serilog | Logs its state only | the Serilog tests | +| EF Core | Not mapped: a patch is the shape of a request, applied to an entity with its own types | — | + +## Not in scope + +A deep merge (`Patch
` replaces the address; nested patches are `Patch`), and +ASP.NET Core's JSON Patch (RFC 6902), which is a list of operations. diff --git a/design/plan.md b/design/plan.md new file mode 100644 index 0000000..49dbf50 --- /dev/null +++ b/design/plan.md @@ -0,0 +1,27 @@ +# Design + +The design of CodoMetis.TypeKit, split by how fast each part changes. Before changing anything, read +this page, the [roadmap](roadmap.md), the [decisions' index](adr/README.md) and the note for the area +you touch; the rest is reference. + +| Page | Holds | Changes | +|---|---|---| +| [Roadmap](roadmap.md) | What is open: before the tag, 2.1, 2.x with a spike first, 3.0.0 | as work is taken or done | +| [Decisions](adr/README.md) | One record per decision, dated, with the evidence that settled it; a decision is changed by a new one that names it | append only | +| [Packages](packages.md) | The package map, what each depends on, and the namespaces | rarely | +| [Discovery is by interface](discovery.md) | Why nothing is found by name, and how the analyzer and the satellites find a value object | with the area | +| [EF Core](ef-core.md) | The mapping plugin, the convention, the translators, keys and collections | with the area | +| [Aspects](aspects.md) | The aspects, the fabric, the seams, what the generators refuse | with the area | +| [Closed sets](closed-sets.md) | The closed-set aspect, what must stay true of a set, and where each satellite meets one | with the area | +| [Patch](patch.md) | `Patch`'s three states, its converter under Native AOT, and where each satellite meets one | with the area | +| [Test strategy](test-strategy.md) | What is tested where, and the guards that hold the repository to its rules | with the area | +| [OpenAPI](openapi.md) | The schema transformers and what was measured in the document | with the area | +| [Option and Result](option-result.md) | Their shape, the non-goals, the combinators and the async forms | with the area | +| [Analyzer package and rule set](analyzers.md) | Packaging, the rule table with severities, and the limits of each rule | with the area | +| [Native AOT](native-aot.md) | What is measured, how, and what each package asks of a consumer | with the area | + +The spikes that decided something are in [spikes/](../spikes/), each with a README stating the +question, the answer and the evidence. A spike's README names the decision it led to. + +Until 2026-10-13 all of this was one file, `design/plan.md`, with a phase log that git holds +(`git show 4237abb:design/plan.md`) and the changelog summarises; links into its sections land here. diff --git a/design/roadmap.md b/design/roadmap.md new file mode 100644 index 0000000..16cc743 --- /dev/null +++ b/design/roadmap.md @@ -0,0 +1,55 @@ +# Roadmap + +What is open, and nothing else. Done work is in the [changelog](../CHANGELOG.md) and in git. Each +item names the decision that scoped it. + +## 2.0.0, before the tag + +- The release: merge so the help links' headings exist on `main`, and tag `v2.0.0`. The changelog's + section is dated and the analyzer rows are shipped; the release workflow and `ChangelogTests` check + both. + +## 2.1, additive + +- An optional `Cause` on `Fault`, an exception kept beside the reason and the detail for the + log and the fault feature, never written to the problem; `Fault.Of(reason, detail, cause)`, and a + `MapError` from a `Result` that keeps the exception ([decision 52](adr/0052-cause-on-fault-joins-2-1.md)). +- An opt-in that withholds the `fault` name from the response for chosen members or a whole fault + type, so two causes can share a status without a mapping at the boundary; additive + ([decision 64](adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md)). +- CMTK0009, a Warning, which may learn ([decision 34](adr/0034-errors-fixed-within-major-version-warnings.md)), + reports `FirstOrNone`, `SingleOrNone` and `FirstOrError` on an `IQueryable` given a + `Func` or a method group, which binds to the sequence overload and reads every row. +- Documentation for generated members, so IntelliSense shows them: it needs Metalama to copy a + template's XML comment to the member it introduces, or to take one through the builder, measured + absent in 2026.1.29 and 2027.0.3-preview (`spikes/IntroducedMemberDocs`). Ask upstream; then + document every generated member and test a consumer's XML documentation file. +- A default value-object key over `ulong` generated as a plain one is, which needs a per-key value + generator a compiled model can write ([decision 63](adr/0063-char-keys-application-assigned-ulong-documented.md)). +- CMTK0003: `WhenAny` over held tasks; an async expression-bodied member in the IDE, where Metalama has + not run; and decide whether a tap on a property or an indexer, and a result a cache's `GetOrAdd` + still holds, are drops, which it reports today. +- CMTK0010's cost: `GetSymbolInfo` on every generic name, about 0.5 s on a 6.7k-line file. +- Generators: a C# keyword in a namespace or a member name fails with LAMA0041, not a CMTK error. +- Run time: the Serilog policy ignores `ToMaximumStringLength`; an exception error prints its whole + `ToString()` in a Testing message, and there are no async assertions; a closed set's `.Name` in a + query is not translated (it throws); the closed-set JSON read allocates a string per token; + `CatchAsync` over a `ValueTask` and `TraverseAsync` on `Option` and `Patch`. + +## 2.x, with a spike first + +- A stable external name for a closed-set member, apart from its C# identifier: parsing, JSON, EF, + OpenAPI and the fault identifiers at once (proposed in the review of 2026-10-13). +- The Metalama 2027.0 upgrade ([decision 4](adr/0004-metalama-2026-1-x-latest-stable.md)): a minor if a consumer's own aspects beside the packed + generators still build and weave, measured as the fabric spike measured 2026.1 against the 2027.0 + preview, and a major otherwise; `[Durable]` returns to the `_value` placeholder with it. +- A `net11.0` target beside `net10.0`, in a 2.x minor when a feature needs it ([decision 44](adr/0044-2-0-0-net10-0-metalama.md)): EF Core 11 + and Microsoft.AspNetCore.OpenApi 11 in the satellites, and a Native AOT smoke run per target. + +## 3.0.0 + +- C# 15 unions and the `net10.0` drop, timed to .NET 12 LTS (November 2027) or to the end of .NET + 10's life, whichever its consumers need ([decision 44](adr/0044-2-0-0-net10-0-metalama.md)). The compiler-made union types have a public + `object? Value` and are serialized by System.Text.Json, so `Option` and `Result` take part in their + pattern matching only after a spike shows neither returns, on a branch of its own with no release + attached. diff --git a/design/test-strategy.md b/design/test-strategy.md new file mode 100644 index 0000000..7ceb167 --- /dev/null +++ b/design/test-strategy.md @@ -0,0 +1,40 @@ +# Test strategy + +1. **Behaviour.** Every public member of `Option`/`Result` and every generated member has a + behaviour test. Generated members are tested against woven probe types, never against code + written by hand in the test assembly. +2. **Generated surface.** A small tool reflects over the woven probe types. For each value object + it renders the public members, implemented interfaces and attributes as sorted text, which is + committed and asserted. An aspect change that adds or loses a member then shows up in review + instead of shipping silently. Package validation against the last release (1.5.1, for 2.0.0) + guards the hand-written surface, and an intended break is suppressed alone + ([decision 48](adr/0048-intended-break-suppressed-alone-baseline-stays.md)). +3. **Generated SQL.** `ToQueryString()` snapshots for `.Value`, `GetValue`, `ValueOrNull`, + `StartsWith`, equality, and `Contains` over a list of ids. +4. **OpenAPI.** A probe host's emitted document, asserted per shape against a control that uses + the wrapped type directly, so the tests follow what ASP.NET publishes for that type ([OpenAPI](openapi.md)). +5. **Performance.** Allocation tests hold the generated members to what the wrapped type allocates, + and `benchmarks/` measures time against the wrapped types. The benchmarks are not a gate: they are + run before a release and when a template changes. +6. **README and docs samples** (2026-09-28; the `docs/` pages 2026-10-13, [decision 45](adr/0045-topic-pages-docs-compiled-like-readmes.md)). Every C# + block of the READMEs and of the topic pages in `docs/` is a region in + `test/CodoMetis.TypeKit.Samples`, which references the shipping projects as a consumer does, with + the analyzer on. `ReadmeSampleTests` requires each block to equal exactly one region and each region + to be shown, so a sample that stops compiling fails the build and an edit on one side fails the test. + A docs page is discovered by being in `docs/`, and the root README's "Documentation" table has to + link it. + An output stated in a comment is asserted against the README's own comment, found by the expression + the test evaluates. A pre-release audit had found samples that did not compile (a bare fault as a + `Result`, `quantity.Value;` as a statement, an ellipsis as a lambda body), CMTK0006 on the + EF entity sample, and SQL the query does not produce; the consumer smoke test had found the first + once before ([Option and Result](option-result.md)). Seeded: a README or sample edited alone, the bare fault back in both, the committed + READMEs, a changed SQL, JSON or string output, an opt-out on a compiled block or without a reason, an + unlabelled fence, and either parser finding nothing each fail their test. +7. **Build outcomes** (2026-10-13). A declaration the generators refuse is an error, which no compiling + project can observe, so `BuildOutcomeTests` builds throwaway consumers against the generators and the + analyzer and reads their errors: every refusal by id and message, and no LAMA error, which would + name nothing the user wrote. A compiler error in a consumer hides every LAMA0611 inside a + declaration's own file (measured with a seeded generic `Parse`: reported alone, unseen beside a + CS0535), so the refused closed sets, which do not implement their interface, are built apart from + the value objects, whose build must have no compiler error, and a declaration whose error is C#'s own + (CS0111) has a consumer of its own (`EqualityOperatorBuildTests`). diff --git a/docs/aspnet-core.md b/docs/aspnet-core.md new file mode 100644 index 0000000..80263d3 --- /dev/null +++ b/docs/aspnet-core.md @@ -0,0 +1,200 @@ +# ASP.NET Core + +A value object binds from a route, a query, a header or a body through the members the generators +add, so an endpoint takes `CustomerId` where it took `Guid`, and every way in applies `Create`. +`CodoMetis.TypeKit.AspNetCore` adds what the host needs beyond that: the value object's schema in +the OpenAPI document, and a `Result` as the response. The +[package README](../src/CodoMetis.TypeKit.AspNetCore/README.md) is the reference for both; this page +follows a request through a minimal API and an MVC controller. + +```csharp +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.AspNetCore.Http.HttpResults; +``` + +```csharp +public readonly partial record struct CustomerId : IValue; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value.Trim()) : Result.Error(EmailFault.NoAt); +} + +public enum PageFault { NotPositive, TooLarge } + +public readonly partial record struct PageSize : IValidatedValue +{ + public static Result Create(int value) => + value <= 0 ? Result.Error(PageFault.NotPositive) : value > 100 ? Result.Error(PageFault.TooLarge) : new PageSize(value); +} + +public enum CustomerFault { NotFound, EmailTaken } + +public sealed record RegisterCustomer(Email Email, string Name); + +public sealed record CustomerDto(CustomerId Id, Email Email, string Name); +``` + +## Setup + +```csharp +builder.Services.AddOpenApi(options => options.AddTypeKit()); // the document: every value object as the schema of what it wraps + +builder.Services.AddFaultResponses(fault => fault switch // a Result as a response: the status per fault +{ + CustomerFault.NotFound => StatusCodes.Status404NotFound, + CustomerFault.EmailTaken => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), +}); +``` + +Nothing is registered per value object, and the host does not reference the generators. The +`Microsoft.AspNetCore.OpenApi` reference has to be direct, at 10.0.12 or later +([the README says why](../src/CodoMetis.TypeKit.AspNetCore/README.md#setup)). + +## Binding in a minimal API + +```csharp +app.MapGet("/customers/{id}", (CustomerId id) => customers.Find(id).ToHttpResult()); // a route parameter, through the generated TryParse +app.MapGet("/customers", (Email? email, PageSize? page) => customers.List(email, page)); // query parameters, optional +app.MapPost("/customers", (RegisterCustomer body) => customers.Register(body).ToHttpResult()); // a body, through the generated JSON converter +``` + +Route, query and header parameters bind through the generated `TryParse`, with the invariant +culture; a body through the generated JSON converter. A value object declared in the same project as +the endpoint trips ASP0020 on a route parameter, since the route analyzer reads the source before the +generators add `IParsable`; the binding is right, and the README shows the pragma that says so. + +## What a refused value answers + +| Request | Answer | +|---|---| +| `GET /customers/0199aaaa-…` | 200, or the problem the fault maps to | +| `GET /customers/not-an-id` | 400: `TryParse` refused the route value | +| `GET /customers?page=500` | 400: `Create` refused it, through `TryParse` | +| `POST /customers` with `"email": "nobody"` | 400: the `JsonException` names `Email` and `NoAt`, never the text, and is logged; the body is empty unless the host adds `UseStatusCodePages()` | +| `POST /customers` without `email` | 200 with a default `Email`, unless `RespectRequiredConstructorParameters` is set, then 400 | +| `GET /mvc/customers` without `page` | 400, since the action marks `page` `[BindRequired]`; without it, the action gets a default `PageSize` | + +The last two rows are the ones to act on. System.Text.Json calls no converter for a property the body +lacks, so it stays `default`, an instance that passed no factory. The README's +[Binding](../src/CodoMetis.TypeKit.AspNetCore/README.md#binding) shows the two lines that make both +minimal APIs and MVC answer 400 instead. + +MVC does the same with a query, header or form value the request lacks: for a non-nullable struct, a +struct value object as much as a `Guid` or an `int`, it binds nothing, keeps the model state valid +and calls the action with `default`. A minimal API answers 400, and MVC already requires a closed set +or a class value object, as any non-nullable reference type. Mark a struct value object's parameter +`[BindRequired]` (`Microsoft.AspNetCore.Mvc.ModelBinding`), as the controller below does for `page`, +and MVC answers 400; or make it nullable where it is optional. + +## Binding in MVC + +```csharp +[ApiController] +[Route("mvc/customers")] +public sealed class CustomersController(ICustomers customers) : ControllerBase +{ + [HttpGet("{id}")] + public Results, FaultProblem> Get([FromRoute] CustomerId id) => customers.Find(id).ToHttpResult(); // the generated TypeConverter binds it + + [HttpGet] + public IReadOnlyList List([FromQuery] Email? email, [FromQuery, BindRequired] PageSize page) => customers.List(email, page); // without [BindRequired], no page is a default PageSize + + [HttpPost] + public Results, FaultProblem> Post(RegisterCustomer body) => customers.Register(body).ToHttpResult(); +} +``` + +MVC binds a route or query value object through the generated `TypeConverter`, and a body through +the JSON converter as a minimal API does. The difference is the 400: MVC answers a refused body with +its validation problem details, whose message is the `JsonException`'s, so the client reads +`Email refused the JSON value (NoAt)` and never the text. The one exception is a value object used +as a dictionary key: System.Text.Json puts the key in the `JsonException`'s `Path`, and MVC names the +error after it (`"$.items.SE"`), so keep a secret out of a dictionary's keys +([Logging and secrets](logging-and-secrets.md#refusals-never-quote-the-input)). + +## A Result as a response + +```csharp +public interface ICustomers +{ + Result Find(CustomerId id); + + Result Register(RegisterCustomer request); + + IReadOnlyList List(Email? email, PageSize? page); +} +``` + +`ToHttpResult()` answers a success with `200 OK` and its value, `204 No Content` for a +`Result`, and a fault with RFC 9457 problem details, whose status the host mapped in `Setup` +and whose `fault` member names the fault. The domain declares the fault type; only the host +references ASP.NET. A handler's outcome can be read without a host: + +```csharp +var response = customers.Find(CustomerId.New()).ToHttpResult(); + +bool notFound = response.Result is FaultProblem { Fault: CustomerFault.NotFound }; // true +``` + +```json +{ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5", "title": "Not Found", "status": 404, "fault": "NotFound", "traceId": "…" } +``` + +A host whose endpoint answers with a fault type nobody mapped does not start, and the exception names +the endpoint. `AddFaultResponses` calls `AddProblemDetails()`, so a `CustomizeProblemDetails` of the +host's own reaches these problems: + +```csharp +builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["service"] = "customers"); +``` + +A response can carry more, each part opt-in, so the default above stays as it is: a `type` and a +`title` per fault, a closed set as the fault type, answered by its members' kinds and descriptions, +a sentence per occurrence in a `Fault`, a status per endpoint, route group or controller +(applied to those of their endpoints that answer with the fault type), the endpoint's own success +such as a `201 Created`, and the fault on the request for a `CustomizeProblemDetails`. The README's +[Mapping faults to statuses](../src/CodoMetis.TypeKit.AspNetCore/README.md#mapping-faults-to-statuses) +and the sections after it show each. + +Every problem names its fault in `fault`, for every member, with a description or without one: the +name reaches the client as the status does. Where two causes must not be told apart, an unknown user +and a wrong password, which would say which accounts exist, map them to one member before +`ToHttpResult()`, as the README's +[Faults a client must not tell apart](../src/CodoMetis.TypeKit.AspNetCore/README.md#faults-a-client-must-not-tell-apart) +shows. + +## The document + +With `AddTypeKit()`, every value object is the schema ASP.NET publishes for the type it wraps, +wherever it appears. One in a body keeps a component named after it; a route or query parameter is +described inline, as ASP.NET describes a parameter of the wrapped type, so the `id` below is a `uuid` +string with no component of its own. The fault responses are listed on every +operation that returns the fault type: + +```json +"/customers/{id}": { "get": { + "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], + "responses": { + "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CustomerDto" } } } }, + "404": { "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } }, + "409": { "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } } +``` + +The `ProblemDetails` component those responses refer to describes `fault` too, as an optional string, +so a client generated from the document has the member it matches on. + +A rule belongs in `Create`, not on the property: a validation attribute on a value-object property +lands in the value object's shared component and then applies to every other use. + +## Native AOT + +The transformer and `ToHttpResult()` are trim- and AOT-safe, and the smoke test publishes a host with +both. What the host's `JsonSerializerContext` has to list for the document, and for the problem +details, is in [Native AOT](native-aot.md#openapi). diff --git a/docs/closed-sets.md b/docs/closed-sets.md new file mode 100644 index 0000000..7164607 --- /dev/null +++ b/docs/closed-sets.md @@ -0,0 +1,195 @@ +# Closed sets + +An `enum` names its members and nothing more: C# only warns when a `switch` expression over one +misses a member and says nothing for a `switch` statement, a member carries no data, and on the wire +it is a number unless a converter says otherwise. A closed set, a +smart enum, is a sealed class whose every instance is a named member declared as a field of it, with +whatever data and behaviour the members need; the generators add the rest. The +[generators' README](../src/CodoMetis.TypeKit.Generators/README.md#closed-sets) is its reference; +this page is about using one. The examples import `CodoMetis.TypeKit.ClosedSets`. + +## Declaring one + +```csharp +public sealed partial class Shipping : IClosedSet +{ + public static readonly Shipping Standard = new(days: 5, fee: 0m); + public static readonly Shipping Express = new(days: 1, fee: 9.90m); + public static readonly Shipping Pickup = new(days: 0, fee: 0m); + + private Shipping(int days, decimal fee) { Days = days; Fee = fee; } + + public int Days { get; } + public decimal Fee { get; } + + public DateOnly ArrivesBy(DateOnly ordered) => ordered.AddDays(Days); +} +``` + +A member is a `public static readonly` field of the set's own type, created with `new` where it is +declared, and its name is the field's. The set is a `sealed partial class` whose constructors are +private, so no code outside it can make an instance: members are singletons, and equality is +reference equality. What belongs to a member lives on it, as `Fee` and `ArrivesBy` do; a set without +data declares no constructor, and the generators add a private one. + +```csharp +string name = Shipping.Express.Name; // "Express" +string qualified = Shipping.Express.QualifiedName; // "Shipping.Express" +string all = string.Join(" ", Shipping.All.Select(s => s.Name)); // "Standard Express Pickup" +bool same = Shipping.Parse("Express") == Shipping.Express; // true, members are singletons +bool known = Shipping.TryParse("Drone", out _); // false, no such member +bool typed = Shipping.TryParse("express", ignoreCase: true, out _); // true, when asked: a header or a query string as a client typed it +Shipping.Parse("Drone"); // FormatException naming Shipping and member, never the name +``` + +`All` holds the members in declaration order, read-only: no cast to `IList` or an array writes it. `QualifiedName` keeps two sets that share a member +name apart in one log or one client. `ToString()` is the name, unless the set writes its own, which +is kept, for a label, while the name stays what JSON, parsing and the column use. `Parse` and +`TryParse` compare names ordinally, and with `ignoreCase: true` accept any casing. JSON, the column, +`IParsable` and a bound route or query parameter stay ordinal, since what is stored or sent is the +name as it is: a header or a query string a client typed in any casing is bound as a `string` and +parsed with `ignoreCase: true`. Two members whose names differ only in case are refused (CMTK1021), +so there is one member to find. + +## Deciding by member + +`Match` takes one delegate per member, each named after it, and a member added later fails every +call until it is handled, where a `switch` over an `enum` at most warns: + +```csharp +string label = shipping.Match( + standard: () => "Ships within a week", + express: () => "Arrives tomorrow", + pickup: () => "Ready at the counter"); +``` + +Every argument is named. By position, they would follow the declaration order, and reordering two +members would swap their branches without a compile error, so the analyzer reports an argument by +position (CMTK0012), and its code fix names them. A member whose lower-cased name is a keyword is +escaped where it is written: a member `New` is `@new:`. `Match` refuses a null delegate before it +picks a branch. Where every member's answer is data, a property on the member says it once, as `Fee` +does, and no call has to list the members at all. + +## On the wire + +```csharp +public sealed record Quote(Shipping Method, Dictionary Fees); +``` + +```csharp +string json = JsonSerializer.Serialize(new Quote(Shipping.Express, new() { [Shipping.Standard] = 0m, [Shipping.Express] = 9.90m })); // {"Method":"Express","Fees":{"Standard":0,"Express":9.90}} +JsonSerializer.Deserialize("""{"Method":"Drone","Fees":{}}"""); // JsonException naming Shipping and member, never the name +``` + +A member is its name, as a value and as a dictionary key, and reads back as the member itself. No +naming policy renames it, since a renamed name would not read back. A JSON `null` reads as `null`, +as for any class, unless the serializer's `RespectNullableAnnotations` refuses it for a property that +is not nullable. In the OpenAPI document, with `AddTypeKit()`, a set is a string enum of its names, +`{"enum":["Standard","Express","Pickup"],"type":"string"}`, as a property, a parameter or a +collection's element, with nothing registered per set. A minimal-API route or query parameter binds +through the generated `TryParse`, ordinally, and an unknown name is a 400; a set declared in the same project as the endpoint trips ASP0020 on a route parameter, as a +value object does +([why, and the pragma](../src/CodoMetis.TypeKit.AspNetCore/README.md#a-route-parameter-declared-in-the-same-project)). + +## In the database + +```csharp +public sealed class Order +{ + public int Id { get; set; } + public Shipping Method { get; set; } = Shipping.Standard; + public List Offered { get; set; } = []; +} +``` + +```csharp +db.Orders.Where(o => o.Method == Shipping.Express); // WHERE o."Method" = 'Express' +``` + +With `UseTypeKit()`, a set is a text column of the member's name, in a table, in a primitive +collection of names (`text[]` on PostgreSQL, a JSON array elsewhere) and in a JSON column, with +nothing registered per set. A name the +set no longer has throws when it is read, rather than reading as null, and an instance that is no +member throws when it is written. Renaming a member renames what is stored, so the column's values +migrate with it. `ClosedSetConverter` names the converter for an explicit configuration. A +compiled model and Native AOT map a set as they map a value object, with the EF Core limitations the +[satellite's README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#compiled-models-and-native-aot) +lists. + +## Configuration and parsing + +```csharp +public sealed class CheckoutOptions +{ + public Shipping Default { get; set; } = Shipping.Standard; +} +``` + +```csharp +IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Checkout:Default"] = "Express" }).Build(); +string chosen = configuration.GetSection("Checkout").Get()!.Default.Name; // "Express" +``` + +The configuration binder reads a set through its generated type converter, as MVC and anything else +that reads a string through `TypeDescriptor` does. The binder's source generator, which Native AOT +turns on, reads no type converter: there, bind the name as a `string` and `Parse` it, as +[Native AOT](native-aot.md#configuration-binding) shows for a value object. A set also implements +`IParsable`, explicitly, so generic code parses one through `T.Parse`. + +A value object can wrap a set, `IValue`, declared in the same project or another: it +parses, converts and serializes by the member's name, as the set does, and a validated one applies its +`Create` to the member. + +## In logs + +`ToString()` is the name, so Microsoft.Extensions.Logging logs a member as `Express`. Serilog +destructures an object by its public properties, so `{@Quote}` would log a member's `Days` and `Fee` +with it; `Destructure.WithTypeKit()` (CodoMetis.TypeKit.Serilog) logs a member as what it prints, its +name or its own `ToString()`, as it does a value object ([Logging and secrets](logging-and-secrets.md)). + +## As a fault type + +A closed set whose members are faults declares `IFaultSet` in place of `IClosedSet`: +each member carries a `FaultKind`, `NotFound`, `Conflict`, `Invalid`, `Forbidden` or `Unavailable`, +nothing HTTP-specific, and optionally a `Description`, a sentence a person reads, both through its +private constructor. In CodoMetis.TypeKit.AspNetCore, `AddFaultResponses()` answers every +member by its kind, with its description as the problem's `detail`, so the domain names its faults +and the host maps nothing by hand +([the satellite's README](../src/CodoMetis.TypeKit.AspNetCore/README.md#a-closed-set-as-the-fault-type)). +Any other closed set is a fault type too, with a mapping of its own. A sentence about one occurrence +travels beside the member, as a `Fault` with a detail; structured data per occurrence is the +next section's case. + +## What the generators refuse + +A declaration they cannot complete fails the build, naming it; the generators' +[Build errors](../src/CodoMetis.TypeKit.Generators/README.md#build-errors) table has the full text. + +| The set | Error | +|---|---| +| is not `partial` | CMTK1013 | +| is not `sealed`: a set with subtypes is not closed | CMTK1014 | +| declares no member | CMTK1015 | +| has a constructor that is not private | CMTK1016 | +| has another static member of its own type: a property, a field that is not `readonly`, or a field that is not public, which would hold an instance that is no member | CMTK1017 | +| is generic, or nested in a generic type | CMTK1018 | +| has a member not created with `new` where it is declared: `Legacy = Open` would give one instance two names | CMTK1019 | +| is a record: `with` would copy a member into an instance that is none | CMTK1020 | +| has a member named as a generated member, or two whose names differ only in case (`Open`, `open`), which collide in `Match`'s parameters and in `TryParse(name, ignoreCase: true)`, or two whose `Match` parameters would have one name (a Kelvin sign and a `K`), or is itself named as a generated member (`Name`, `Match`): a member cannot have the name of its type | CMTK1021 | +| writes a generated member by hand, `ToString()` apart | CMTK1011 | +| writes a member outside its declaration, in its static constructor or another field's initializer: `Open = Closed` would leave `Open` holding the instance named `Closed` | CMTK0013 | +| is compiled without CodoMetis.TypeKit.Generators | CMTK0002 | + +The set's own code can still call its private constructor beside its members, and such an instance +has no name: `Name`, `ToString()`, `Match`, JSON and the column throw for it, rather than carry a +null ([why](../src/CodoMetis.TypeKit/README.md#an-instance-that-is-not-a-member-of-its-set)). + +## When an `enum` is still right + +- **Flags.** A combination of members, `Read | Write`, is an `enum`'s; a closed set's instance is + exactly one member. +- **A number another system defines**, a protocol code or a column you do not own: a set stores and + sends its names. +- **Data that varies per occurrence.** A member's data is constant, declared once; a refusal that + carries what was refused (`OutsidePeriod(date)`) is a discriminated union's job, which C# 15 unions + take up, a question for 3.0.0. diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000..672ac71 --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,66 @@ +# How it compares + +Good libraries exist for both halves of TypeKit, and some needs are better met by one of them. What +follows was read from each library's source at the release named, on 28 September 2026 for value +objects, `Option` and `Result`, including the next major where one is in preview, and on 13 October +2026 for closed sets. If something here is wrong or has changed, please open an issue. + +The reasons to choose TypeKit are in the [README](../README.md#how-it-compares); this page is the +other side, and the detail. + +## Choose something else when + +| If you need | Consider | Because | +|---|---|---| +| .NET Framework, .NET Standard or a .NET before 10 | Vogen for value objects; CSharpFunctionalExtensions, ErrorOr or FluentResults for results | TypeKit targets .NET 10 only; these ship for .NET Standard 2.0 | +| Newtonsoft.Json, Dapper, LINQ to DB, MongoDB, Orleans, ServiceStack or protobuf-net for value objects | Vogen | It ships an opt-in integration for each. TypeKit covers System.Text.Json, EF Core and ASP.NET Core | +| A plain Roslyn source generator in the build rather than Metalama | Vogen or Thinktecture.Runtime.Extensions | TypeKit's generators run on Metalama (open source, no license key) in the project that declares value objects | +| Value objects of several members, or discriminated unions | Thinktecture.Runtime.Extensions | It has `[ComplexValueObject]` and `[Union]`. A TypeKit value object wraps exactly one value; its closed sets are the smart enums | +| Several errors per failure, of typed kinds | ErrorOr | An `ErrorOr` carries a list of errors. A TypeKit `Result` carries one `TError`, which can be a list of your own | +| A cause nested under an error, metadata per occurrence, or success messages | FluentResults | Its results carry reasons, errors and successes, an error can name what caused it, and both take metadata. A TypeKit `Result` carries one `TError`: a `Fault` is one reason from a fixed set with a sentence about the occurrence, a closed-set fault carries a kind and constant data, and `Catch` carries an exception as the whole error; nothing attaches a cause to a fault, and a success carries only its value | +| A functional-programming framework: `Either`, `Fin`, effects, immutable collections | LanguageExt (4.4.9; 5.0 in beta) | TypeKit's `Option` and `Result` are deliberately small, with no effect or validation types | +| Unions of arbitrary types | OneOf | `OneOf` is general-purpose; TypeKit has only `Option` and `Result` | +| A `Result` on the wire | CSharpFunctionalExtensions | It ships opt-in System.Text.Json converters for its `Result`. TypeKit refuses to serialize `Option` and `Result`, by design | +| A smart enum whose JSON or column is a number, or whose wire name differs from its C# identifier | Ardalis.SmartEnum, Intellenum or Thinktecture.Runtime.Extensions | A TypeKit closed set is its member's name everywhere, and the name is the field's | +| Only your own closed set of outcomes, on .NET 11 | C#'s union types | They arrive with .NET 11 | +| Years of production use behind the library | Any of the above | TypeKit is new: 1.0.0 shipped in September 2026 | + +## Value objects in detail + +| | TypeKit 2.0.0 | Vogen 8.0.7 (the 9.0.0-beta.2 tag the same) | Thinktecture.Runtime.Extensions 10.5.0 | StronglyTypedId 1.0.0-beta08 | +|---|---|---|---|---| +| Declared by | Implementing `IValue` or `IValidatedValue<…>` on a `partial record` | `[ValueObject]` on a partial type | `[ValueObject]`, `[ComplexValueObject]` or `[SmartEnum]` on a partial type | `[StronglyTypedId]` on a partial struct | +| Validation | `Create` returns a `Result`, and every generated way in applies it | `Validate` returns a `Validation`; `From` throws | A `ValidateFactoryArguments` partial method | None | +| EF Core | `UseTypeKit()` maps every value object, nothing per type; `.Value` in a query translates to the column | A generated converter per value object, which you register; comparing with a primitive in a query is an error (VOG034) | Packages per EF Core version (8, 9, 10) | Through a template of your own | +| OpenAPI | The schema ASP.NET publishes for the wrapped type | A generated schema transformer, type and format from a built-in table; Swashbuckle support | Swashbuckle filters | None built in | +| JSON | System.Text.Json, the wrapped type's JSON byte for byte | System.Text.Json by default; Newtonsoft.Json and more, opt-in | System.Text.Json, Newtonsoft.Json, MessagePack | System.Text.Json (more through templates) | +| Targets | `net10.0` | `netstandard2.0`, .NET Framework projects included | `net8.0`, `net9.0` | `netstandard2.0` | +| Generated by | Metalama, in the declaring project | A Roslyn source generator | Roslyn source generators | A Roslyn source generator | +| License | MIT | Apache-2.0 | BSD-3-Clause | MIT | + +## Option and Result in detail + +| | TypeKit 2.0.0 | LanguageExt 4.4.9 (5.0.0-beta-77) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | +|---|---|---|---|---| +| Reaching the value | `Match`, `TryGetValue` and the combinators; no `.Value` | `Match`, `IfNone` and the like; no public `.Value` on `Option` | `.Value`, which throws on a failure | `.Value`, which returns `default` on an error | +| A `default` instance | `Option`: `None`. `Result`: throws when used, and is an error in source (CMTK0001) | `Option`: `None` | `Maybe`: none. `Result`: a success holding `default(T)` | A success holding `default(T)` | +| An ignored result | Reported (CMTK0003) | No analyzer | No analyzer of its own | No analyzer | +| System.Text.Json | Refused in both directions, rather than written as `{}` | No converter of its own | Opt-in converters | No converter for the type | +| Targets | `net10.0` | `netstandard2.0` (5.0 beta: `net10.0`) | `netstandard2.0`, `net6.0`, `net8.0` | `netstandard2.0`, `net8.0`, `net10.0` | + +## Closed sets in detail + +| | TypeKit 2.0.0 | Ardalis.SmartEnum 8.2.0 | Intellenum 2.0.1 | Thinktecture.Runtime.Extensions 10.5.1 | +|---|---|---|---|---| +| Declared by | Implementing `IClosedSet` on a `sealed partial class`, one `public static readonly` field per member | Deriving from `SmartEnum`, each member `new("Name", value)` | `[Intellenum]` on a partial class; members as fields, `[Member]` attributes or a static constructor | `[SmartEnum]` on a partial class, each member `new("KEY")` | +| Data on a member | Properties, set through the private constructor | The subclass's own fields | One underlying value; richer data through a custom underlying type | Declared properties, which become constructor parameters | +| Deciding by member | `Match`, one required delegate per member, every argument named (CMTK0012) | `When(…).Then(…)`; nothing requires every member | A `switch` over `.Value` with generated constants; nothing requires every member | Generated `Switch` and `Map`, every member a required argument | +| Parsing by name | `Parse` and `TryParse`, ordinal; `ignoreCase: true` on request, and two members differing only in case refused at build (CMTK1021) | `FromName` and `TryFromName`, ordinal; `ignoreCase` on request, a case clash thrown at the first such lookup | `FromName` and `TryFromName`, ordinal; `ignoreCase` on request, the first declared answers | `Get` and `TryGet`, ignoring case by default; a case clash thrown at the first lookup | +| JSON | System.Text.Json, the name, nothing registered | A separate package per serializer, a converter attribute per type; the name or the value | Generated into your assembly; the value, as an `enum` writes | A separate package per serializer; the key | +| EF Core | `UseTypeKit()`, a text column of the name, nothing per set | A separate package, a convention or per model; the value | An opt-in generated converter, registered per property; the value | A separate package per EF version, `UseThinktectureValueConverters()`; the key | +| OpenAPI | `AddTypeKit()`, a string enum of the names | None built in | None built in | A Swashbuckle package | +| Analyzers | CMTK1013–1021 refuse a declaration that cannot be generated, CMTK0012 an argument by position, CMTK0013 a member written after it was named | None | INTELLENUM001–030 | TTRESG rules, with refactorings | +| A wire name apart from the identifier | No: the name is the field's | Yes, the constructor's name | Yes, `new("STD", 1)` | Yes, the key | +| Targets | `net10.0` | `netstandard2.0`, `net6.0` to `net8.0` | `netstandard2.0` | `net8.0`, `net9.0` | +| Generated by | Metalama, in the declaring project | Nothing: a run-time base class | A Roslyn source generator | Roslyn source generators | +| License | MIT | MIT | Apache-2.0 | BSD-3-Clause | diff --git a/docs/ef-core.md b/docs/ef-core.md new file mode 100644 index 0000000..b000f99 --- /dev/null +++ b/docs/ef-core.md @@ -0,0 +1,229 @@ +# EF Core + +`CodoMetis.TypeKit.EntityFrameworkCore` maps every value object to a column of the type it wraps, +with nothing registered per type, and makes `.Value` work in a LINQ query as it does in memory. The +project that hosts the `DbContext` references this package and the base package; the generators stay +in the domain project. The [package README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md) +is the reference: setup, what maps, the provider notes. This page walks through a model. + +```csharp +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.EntityFrameworkCore; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +``` + +```csharp +public readonly partial record struct InvoiceNo : IValue; + +public readonly partial record struct CustomerId : IValue; + +public readonly partial record struct Amount : IValue; + +public readonly partial record struct Currency : IValue; + +public readonly partial record struct IssuedOn : IValue; + +public readonly partial record struct Discount : IValue; + +public enum ReferenceFault { Blank, NotUpperCase } + +public readonly partial record struct Reference : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(ReferenceFault.Blank); + if (value != value.ToUpperInvariant()) return Result.Error(ReferenceFault.NotUpperCase); // a rule added after rows were written + + return new Reference(value); + } +} +``` + +## The model + +```csharp +public class Invoice +{ +#pragma warning disable CMTK0006 // the database assigns the key on insert + public InvoiceNo No { get; set; } // key: bigint, generated by the database +#pragma warning restore CMTK0006 + public required CustomerId Customer { get; set; } // uuid + public required Reference Reference { get; set; } // text, applies Create on input and is read back without it + public required Amount Total { get; set; } // numeric(18,2), from ConfigureConventions + public required Currency Currency { get; set; } // character varying(3), from ConfigureConventions + public required IssuedOn IssuedOn { get; set; } // date + public Discount? Discount { get; set; } // integer, nullable +} + +public sealed class BillingDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Invoices => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder builder) + { + builder.Properties().HavePrecision(18, 2); // by the value object's type: EF applies a convention by CLR type, so Properties() would miss it + builder.Properties().HaveMaxLength(3); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(invoice => invoice.No); + modelBuilder.Entity().HasData(new Invoice // seed data takes value objects as it takes primitives + { + No = InvoiceNo.From(1), + Customer = CustomerId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), + Reference = Reference.FromKnownGood("INV-1"), + Total = Amount.From(10m), + Currency = Currency.From("EUR"), + IssuedOn = IssuedOn.From(new DateOnly(2026, 1, 1)), + }); + } +} +``` + +Every property maps as a scalar of the wrapped type, nullable where the property is, and a facet +configured on it, a precision, a length, a column type, is kept. `required` is what CMTK0006 asks +for: a value object that nothing assigns would be a `default` instance, and the key is the one +property the database fills in, which the pragma says. A model-wide convention is configured by the +value object's type, since EF applies `ConfigureConventions` by CLR type. + +## Keys + +A single-column key over an integer is declared generated on add, as a key of that integer type is, +and the provider generates it as it would a plain one: an identity column on PostgreSQL and SQL +Server, so switching an `int` key to a value object there changes no schema. Two exceptions. A key +over `char` is the application's to assign on every provider, since none generates one; a default +`'\0'` is stored as it is, so assign a meaningful character. A key over `ulong` added with its default +value throws `OverflowException` at `Add` on SQL Server and PostgreSQL, whose column is a `decimal`; +configure `ValueGeneratedNever()` on it and assign it. A key over +a `Guid` is the application's to assign, `Id = OrderId.New()`, a version 7 +`Guid` that sorts by creation time; for EF to generate it, configure `ValueGeneratedOnAdd()`. The root +of a TPH or TPT hierarchy is generated too; under TPC, as for an `int` key, only where the provider has +a sequence for the hierarchy (PostgreSQL, SQL Server), not on SQLite. A composite +key of value objects is `HasKey(x => new { x.Order, x.Line })`, as for primitives. + +```csharp +await using var connection = new SqliteConnection("Data Source=:memory:"); // one connection keeps the in-memory database alive +await connection.OpenAsync(); +await using var db = new BillingDb(new DbContextOptionsBuilder().UseSqlite(connection).UseTypeKit().Options); +await db.Database.EnsureCreatedAsync(); + +var invoice = new Invoice +{ + Customer = CustomerId.New(), + Reference = Reference.FromKnownGood("INV-2"), + Total = Amount.From(250m), + Currency = Currency.From("EUR"), + IssuedOn = IssuedOn.From(new DateOnly(2026, 10, 13)), +}; +db.Invoices.Add(invoice); +await db.SaveChangesAsync(); + +long assigned = invoice.No.Value; // 2, assigned by the database; the seeded invoice is 1 +``` + +SQLite fills such a key in, but its provider marks a key `AUTOINCREMENT` only for an integer CLR +type, so a model that migrates on SQLite configures `UseAutoincrement()` on each value-object key +([the README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#what-maps) has the detail). +`EnsureCreated`, as above, needs nothing. + +## Queries + +`.Value`, `GetValue()` and `ValueOrNull()` translate to the column itself, so the wrapped type's own +operations are available on it, and a value object compares to a parameter or a column directly. The +SQL below is PostgreSQL's, pinned by a test: + +```csharp +db.Invoices.Where(i => i.Total.Value >= 100m && i.Currency == eur); // WHERE i."Total" >= 100.0 AND i."Currency" = @eur +db.Invoices.Where(i => i.Reference.Value.EndsWith("-1")); // WHERE i."Reference" LIKE '%-1' +db.Invoices.Where(i => i.IssuedOn.Value.Year == 2026); // WHERE date_part('year', i."IssuedOn")::int = 2026 +db.Invoices.Where(i => i.Discount.ValueOrNull() == null); // WHERE i."Discount" IS NULL +db.Invoices.Where(i => customers.Contains(i.Customer)); // WHERE i."Customer" = ANY (@customers) +db.Invoices.OrderByDescending(i => i.IssuedOn).ThenBy(i => i.No); +db.Invoices.Select(i => new { i.No, Total = i.Total.Value }); // the wrapped value projected +db.Invoices.GroupBy(i => i.Currency).Select(g => new { Currency = g.Key, Sum = g.Sum(i => i.Total.Value) }); // an aggregate over the column +``` + +A table's column is re-typed, not cast, so an index on it still serves the query. The element of a +primitive collection, a property in a JSON column and a parameter are converted, which may add a +cast. A property with a converter of the application's own does not hold `.Value`, so `.Value` on it +is refused ("could not be translated"); comparing the value object itself still works. + +Ordering in SQL follows the column's collation, while the generated comparison in memory is ordinal +for a string, as for any string column. A value object with its own `CompareTo(TSelf)` cannot be +compared, ordered, or taken the `Min` or `Max` of in a query, since the database orders the column by +the wrapped value: such a query is refused, naming the value object, and `.Value` says the wrapped +value's order is meant (`OrderBy(i => i.Priority.Value)`). A query filter that does the same is +refused when the model is built. + +## Reading back without validation + +A column is read through the value object's materializer, not through `Create`: a rule added later +must not make the rows written before it unreadable, and what the application wrote is trusted by +contract. Input still goes through `Create`, nothing in the package is reachable from a request, and +`Materialize` called anywhere else is CMTK0004. `Revalidate()` finds the rows today's rules refuse: + +```csharp +await db.Database.ExecuteSqlAsync($"UPDATE Invoices SET Reference = 'inv-2' WHERE No = 2"); // a row from before the upper-case rule +db.ChangeTracker.Clear(); + +var read = await db.Invoices.SingleAsync(i => i.No == InvoiceNo.From(2)); // maps, without Create +string verdict = read.Reference.Revalidate().Match(_ => "valid", fault => $"refused: {fault}"); // "refused: NotUpperCase" +string stored = read.Reference.Value; // "inv-2", as the row holds it +``` + +What to do with such a row is the application's decision: a migration that rewrites it, a report, or +a `Create` applied to the stored value and saved back. + +## Option and Result + +Neither is a column. EF cannot map an `Option` or a `Result` property, and the model says so when it +is built. An optional value is `T?` on the entity, `Discount?` above, and `ToOption()` lifts it where +the domain wants an option: + +```csharp +Option discount = read.Discount.ToOption(); // None +``` + +An element of a query as an option is `FirstOrNone`, `SingleOrNone` or `FirstOrNoneAsync`, which let +the database run the condition and read one row ([the EF README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#an-option-for-nothing-found)). +Only a condition written as a lambda at the call, or held as an `Expression>`, runs there: +a `Func` variable or a method group binds the synchronous ones to their overloads on a sequence, +which read every row of the table and run the condition in memory, without a warning. + +## Compiled models + +EF Core runs under Native AOT only through a compiled model and precompiled queries, and +`UseTypeKit()` works there: value objects map through the compiled model, `.Value` translates in +precompiled queries, a value-object parameter binds, and a key over an integer is generated by the +database, in the native binary. Five limitations of EF's own are described in the +[README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#compiled-models-and-native-aot). Two +a single project meets first: precompiled queries compile their project without the generators' +bodies, so the queries live in a project that references the one declaring the value objects, and a +collection of converted elements, a `List`, cannot be read by a precompiled query. Three +reach a value object: a struct value object that wraps a reference type cannot be a property of a +compiled model, since EF cannot write its sentinel, so such a value object on an entity is a `sealed +partial record`; a sealed entity class does not compile in precompiled queries; and precompiled +queries need the converter `UseTypeKit()` composes, not one named in `HasConversion`. The commands +and the project settings are in [Native AOT](native-aot.md#ef-core). + +## SQLite in tests + +The block under [Keys](#keys) is the whole setup: one open `SqliteConnection` to `:memory:` keeps the +database alive for the test, `UseTypeKit()` on the options, `EnsureCreated` for the schema. EF's +in-memory provider is not an option: it has no relational type mapping, so nothing is mapped there. +The mapping is tested in this repository on SQLite and PostgreSQL, and every wrapped-type family makes +a round trip through a real PostgreSQL. + +## If something does not map + +- **A struct value object is stored as it is, or a class one fails the model**: the provider has no + relational type mapping. EF's in-memory provider does that; use SQLite. +- **NU1605 on restore**: a direct reference to an EF Core package below 10.0.12. Raise it. +- **A convention does not reach a value object**: it was configured by the wrapped type; + configure it by the value object's type. +- **Every migration repeats an `AlterColumn` on SQLite**: an integer key without `UseAutoincrement()`. +- **A `default` instance reaches `SaveChanges`**: a property nothing set. CMTK0006 points at it; + `required` closes it. diff --git a/docs/json.md b/docs/json.md new file mode 100644 index 0000000..93dda6f --- /dev/null +++ b/docs/json.md @@ -0,0 +1,202 @@ +# JSON + +A value object's JSON is its wrapped type's under whatever options the application uses, as a value +and as a dictionary key; the one addition is a `DateTime`, written as UTC (below). The generated converter reads and writes through the +serializer's own contract for the wrapped type, so replacing a `Guid` property by an `OrderId` +changes no document, and only what the value object refuses changes. `Option` and `Result` are not +wire types and refuse System.Text.Json in both directions. The +[Generators README](../src/CodoMetis.TypeKit.Generators/README.md#json) and the +[base README](../src/CodoMetis.TypeKit/README.md#not-wire-types) are the reference; this page is the +set of situations. + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +``` + +```csharp +public readonly partial record struct Quantity : IValue; + +public readonly partial record struct Weekday : IValue; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); +} + +public sealed record Shipment(Quantity Boxes, Weekday Day, Dictionary Plan); + +public sealed record Primitives(int Boxes, DayOfWeek Day, Dictionary Plan); +``` + +## The wrapped type's JSON + +```csharp +var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; +var shipment = new Shipment(Quantity.From(3), Weekday.From(DayOfWeek.Friday), new() { [Weekday.From(DayOfWeek.Friday)] = Quantity.From(3) }); +var primitives = new Primitives(3, DayOfWeek.Friday, new() { [DayOfWeek.Friday] = 3 }); + +string json = JsonSerializer.Serialize(shipment, options); // {"boxes":3,"day":"Friday","plan":{"Friday":3}} +bool parity = json == JsonSerializer.Serialize(primitives, options); // true, the same bytes as the primitives +Shipment quoted = JsonSerializer.Deserialize("""{"boxes":"3","day":"Friday","plan":{}}""", options)!; // "3" read as 3: the web defaults allow a quoted number +``` + +Whatever the options say about the wrapped type applies: a converter registered for it, number +handling, an enum converter, a dictionary key policy. A test in this repository compares the two +for every wrapped-type family under several sets of options. One addition of the converter's own: a +`DateTime` is written as UTC, a `Local` one converted and an `Unspecified` one taken as UTC with its +digits kept, so a stored instant reads back as the instant it was. + +One exception: number handling given by an attribute rather than by the options. A +`[JsonNumberHandling]` on a record or class applies to the number properties the serializer writes +itself, and a value object's converter cannot see it. Under +`[JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)]`, +`record Counted(int Count)` writes `{"Count":"5"}` and reads `"5"`, while `record Counted(Quantity Count)` +writes `{"Count":5}` and refuses `"5"` with a `JsonException`, which ASP.NET Core answers with 400. On +a value-object property the serializer refuses the attribute outright (`InvalidOperationException`: +it is valid only on a number). Set the number handling on the options, or on a source-generated +context's `[JsonSourceGenerationOptions(NumberHandling = ...)]`: both reach the value object. + +## Refusals + +Every generated way in applies `Create`, and a refusal names the value object and the rule, or the +wrapped type it could not read, never the text, which can be a secret: + +```csharp +JsonSerializer.Deserialize("\"nobody\""); // JsonException naming Email and NoAt, never the text +JsonSerializer.Deserialize("\"many\""); // JsonException naming Quantity and Int32, with no inner exception that quotes it +JsonSerializer.Deserialize("null"); // JsonException naming Quantity and null, refused for a struct +``` + +The `JsonException` still carries its path, so a host knows which property. ASP.NET Core answers it +with 400; what the body of that response says is in [ASP.NET Core](aspnet-core.md#what-a-refused-value-answers). + +## Null and missing + +A JSON `null` is refused for a struct value object; a `sealed partial record` reads it as `null`, as +any class does, since the serializer calls no converter for it. A property the document lacks is not +refused either: the serializer calls no converter for an absent property and leaves it `default`, +which for a value object is an instance that passed no factory. + +```csharp +public sealed record Parcel(Quantity Boxes, Email? Contact); +``` + +```csharp +var parcel = JsonSerializer.Deserialize("""{"Contact":null}""")!; +int boxes = parcel.Boxes.Value; // 0, a missing property calls no converter and stays default + +var strict = new JsonSerializerOptions { RespectRequiredConstructorParameters = true }; +JsonSerializer.Deserialize("""{"Contact":null}""", strict); // JsonException naming Parcel and Boxes +``` + +So a shape says which value objects it needs: `required` on a property, or +`RespectRequiredConstructorParameters` on the options for a record's constructor parameters, as +above. `RespectNullableAnnotations` makes a `null` refused for a class value object whose property +is not declared nullable. CMTK0006 points at a property of a value object's type that nothing sets. + +## Stored documents + +The generated converter applies today's `Create`, which is right for input and wrong for a document +the application stored before a rule existed. `StoredJsonConverterFactory`, registered on that +store's options and nowhere near input, reads a value object without its rules, as EF Core reads a +column; `Revalidate()` then says which stored values today's rules refuse. + +```csharp +public enum CodeFault { NotUpperCase } + +public readonly partial record struct ProductCode : IValidatedValue +{ + public static Result Create(string value) => + value == value.ToUpperInvariant() ? new ProductCode(value) : Result.Error(CodeFault.NotUpperCase); // the rule added after "abc" was stored +} +``` + +```csharp +var store = new JsonSerializerOptions { Converters = { new StoredJsonConverterFactory() } }; + +ProductCode stored = JsonSerializer.Deserialize("\"abc\"", store); // read without the rules +string state = stored.Revalidate().Match(_ => "still valid", fault => $"now refused: {fault}"); // "now refused: NotUpperCase" +JsonSerializer.Deserialize("\"abc\""); // JsonException naming ProductCode and NotUpperCase, the same document as input +``` + +## Option and Result at the wire + +Neither serializes. System.Text.Json refuses both, in both directions, with a `NotSupportedException` +that names the type and the alternative; without the refusal a `Some` would be written as `{}` and +read back as `None`, with nothing raised. A shape says absent with `T?`, and the option lives between +the shapes ([Option](option.md#at-the-boundary)); a result is matched to a response or a document +([ASP.NET Core](aspnet-core.md#a-result-as-a-response)). + +```csharp +public sealed record Profile(Option Nickname); +``` + +```csharp +JsonSerializer.Serialize(new Profile(Option.Some("Annie"))); // NotSupportedException naming Option and nullable +``` + +A converter registered on the options takes precedence over the refusal, so an application that +wants `Option` on the wire writes one, closed over each `T` it needs, and registers it on those +options only: + +```csharp +public sealed class OptionAsNullableConverter : JsonConverter> where T : notnull +{ + public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null + ? Option.None() + : Option.Some(JsonSerializer.Deserialize(ref reader, (JsonTypeInfo)options.GetTypeInfo(typeof(T)))!); // the typed overload: no reflection, so Native AOT keeps it + + public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options) + { + if (value.TryGetValue(out var content)) JsonSerializer.Serialize(writer, content, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); + else writer.WriteNullValue(); + } +} +``` + +```csharp +var wire = new JsonSerializerOptions { Converters = { new OptionAsNullableConverter() } }; + +string written = JsonSerializer.Serialize(new Profile(Option.Some("Annie")), wire); // {"Nickname":"Annie"} +string absent = JsonSerializer.Serialize(new Profile(Option.None()), wire); // {"Nickname":null} +Profile back = JsonSerializer.Deserialize("""{"Nickname":null}""", wire)!; // None +``` + +A converter per closed type rather than a factory: a factory would build the converter for each `T` +by `MakeGenericType`, which Native AOT cannot always honour. A property absent from the document +still reaches no converter and stays `default`, a `None`, so `required` is what says the shape must +mention it. A source-generated context cannot describe a shape with an `Option?` property at all +and fails when it builds the contract, so declare `T?` there. + +## Source-generated contexts + +With a `JsonSerializerContext`, which Native AOT requires, list the value objects or the types that +hold them, not what they wrap: the generated converter builds the wrapped type's contract itself, +from a converter on the options, the type's own `[JsonConverter]` or the serializer's built-in one. +An enum-backed value object is the one exception, since a context's `UseStringEnumConverter` does not +reach an enum the context does not list: + +```csharp +[JsonSerializable(typeof(Shipment))] +internal partial class ShipmentJson : JsonSerializerContext; + +[JsonSerializable(typeof(Shipment))] +[JsonSerializable(typeof(DayOfWeek))] // listed, so the string enum converter reaches what Weekday wraps +[JsonSourceGenerationOptions(UseStringEnumConverter = true)] +internal partial class NamedShipmentJson : JsonSerializerContext; +``` + +```csharp +string numbered = JsonSerializer.Serialize(shipment, ShipmentJson.Default.Shipment); // {"Boxes":3,"Day":5,"Plan":{"Friday":3}} +string named = JsonSerializer.Serialize(shipment, NamedShipmentJson.Default.Shipment); // {"Boxes":3,"Day":"Friday","Plan":{"Friday":3}} +``` + +What a context must list for the OpenAPI document is a different question, answered in +[Native AOT](native-aot.md#json-contexts). diff --git a/docs/logging-and-secrets.md b/docs/logging-and-secrets.md new file mode 100644 index 0000000..63ced4d --- /dev/null +++ b/docs/logging-and-secrets.md @@ -0,0 +1,122 @@ +# Logging and secrets + +A value object over a secret, a token, a key, a PIN, is a string on its way to a log the moment it +is interpolated, destructured or quoted in an exception. Three things keep it out: a value object +prints what its `ToString()` says and nothing else reaches a string path; `Option` and `Result` print +nothing of their content; and no refusal, in JSON, parsing, `FromKnownGood` or the type converter, +quotes the input. The [Generators README](../src/CodoMetis.TypeKit.Generators/README.md#a-value-that-must-not-be-printed) +and the [Serilog README](../src/CodoMetis.TypeKit.Serilog/README.md) are the reference. The examples +import `CodoMetis.TypeKit` and `CodoMetis.TypeKit.ValueObjects`. + +## A value that must not be printed + +Declare `ToString()`. It is kept, and then no formatting interface is generated, so interpolation, +`string.Format`, `Convert.ToString`, a record that holds it and every Microsoft.Extensions.Logging +path reach it too, `[LogProperties]` included. + +```csharp +public readonly partial record struct AccessToken : IValue +{ + public override string ToString() => "AccessToken(***)"; // kept; no formatting interface is generated, so every string path reaches this +} + +public sealed record Session(AccessToken Token, string Name); +``` + +```csharp +var token = AccessToken.From("tok-123"); + +string interpolated = $"token {token}"; // "token AccessToken(***)" +string converted = Convert.ToString(token, CultureInfo.InvariantCulture)!; // "AccessToken(***)" +string record = new Session(token, "Ann").ToString(); // Session { Token = AccessToken(***), Name = Ann } +string json = JsonSerializer.Serialize(token); // "tok-123", a conversion, not display +``` + +Still carrying the value, since they are conversions and not display: its JSON, the type converter +and the database column; and the debugger, which shows `ToString()` but expands `Value`. A `Session` +written as JSON carries the token, so the shape that is logged is not the shape that is sent. + +## Serilog + +Serilog destructures an object, `{@Session}` or a record holding a value object, by reading its +public properties, and `Value` is public: without the satellite the token above is logged as +`{ Value: "tok-123" }`, past the `ToString()`. `Destructure.WithTypeKit()` logs every value object as +what it prints, masked or not, and nothing is registered per type: + +```csharp +using Serilog; +``` + +```csharp +var events = new ListLogSink(); // an ILogEventSink of your own that keeps the rendered messages +using var logger = new LoggerConfiguration() + .Destructure.WithTypeKit() + .WriteTo.Sink(events) + .CreateLogger(); + +logger.Information("Opened {@Session}", new Session(token, "Ann")); +string logged = events.Messages.Single(); // Opened {"Token":"AccessToken(***)","Name":"Ann","$type":"Session"} +``` + +An `OrderId` is then `"0199aaaa-…"` in the event rather than `{ Value: "0199aaaa-…" }`, and a list +or dictionary of value objects a list or dictionary of strings, which is what the host's queries +expect. + +## Microsoft.Extensions.Logging + +Every path prints `ToString()`: a message template, the logging source generator, `[LogProperties]`. + +```csharp +using var factory = LoggerFactory.Create(logging => logging.AddProvider(sink)); // sink: a provider of your own that keeps the formatted messages +ILogger log = factory.CreateLogger("sessions"); + +log.LogInformation("Opened for {Token}", token); +string message = sink.Messages.Single(); // "Opened for AccessToken(***)" +``` + +Microsoft.Extensions.Compliance classifies a logging method's parameter, not its type: a +`DataClassificationAttribute` on the value object's type makes the logging generator report +LOGGEN035 and generate nothing for the method. Classify the parameter. + +## Refusals never quote the input + +```csharp +public enum PinFault { TooShort } + +public readonly partial record struct Pin : IValidatedValue +{ + public static Result Create(string value) => + value.Length >= 4 ? new Pin(value) : Result.Error(PinFault.TooShort); +} +``` + +```csharp +Pin.Parse("12", null); // FormatException naming Pin and TooShort, never the digits +JsonSerializer.Deserialize("\"12\""); // JsonException naming Pin and TooShort + +var typed = "12"; +Pin.FromKnownGood(typed); // InvalidOperationException naming typed and TooShort, the expression and not what it held + +string printed = Option.Some(token).ToString(); // nothing of the token: an option prints no content, and neither does a result +``` + +A refusal names the value object and the rule, or the wrapped type it could not read, and carries no +inner exception that quotes the text: the wrapped type's own `FormatException` would. What reaches a +log through this package is therefore the type and the fault. The fault's text is yours, so keep the +input out of it ([Value objects](value-objects.md#faults)), and a `FromKnownGood` given a literal names +the literal, since the literal is the expression. ASP.NET's own refusal of a route or query value +quotes the input, as it does for a `Guid`: in MVC's model-state message ("The value '…' is not +valid."), which reaches the response, and in a minimal API's log entry and developer exception page. +A body refused by the JSON converter does not +([ASP.NET Core](aspnet-core.md#what-a-refused-value-answers)), with one exception that is +System.Text.Json's: a value object used as a dictionary key is read from the property name, and the +`JsonException` for a refused or unreadable key carries the key in its `Path` (`$.items.SE`), which +MVC uses as the error's name in its 400. Keep a secret out of a dictionary's keys. + +## What a fault response tells the client + +A fault answered with `ToHttpResult()` names its member in the problem's `fault`, for every member, +whatever its status or description: the name is meant for the client. Two refusals the client must +not tell apart, an unknown user and a wrong password, therefore become one fault before the response, +as [ASP.NET Core](../src/CodoMetis.TypeKit.AspNetCore/README.md#faults-a-client-must-not-tell-apart) +shows; the domain keeps both for its log. diff --git a/docs/merge-patches.md b/docs/merge-patches.md new file mode 100644 index 0000000..c53b322 --- /dev/null +++ b/docs/merge-patches.md @@ -0,0 +1,203 @@ +# Merge patches + +A PATCH endpoint that takes JSON Merge Patch (RFC 7396) reads each field as one of three things: not +mentioned, so it stays as it is; `null`, so it is cleared; or a value, which replaces it. +System.Text.Json reads a missing property and a `null` alike, because it calls no converter for a +property it does not find, so every such endpoint rebuilt the difference by hand. `Patch` is a +field that keeps the three apart. The [base README](../src/CodoMetis.TypeKit/README.md#merge-patches) +is its reference; this page is about using it: reading a body, applying it, the value objects inside +it, writing one, the OpenAPI document and source-generated contexts. The examples import +`CodoMetis.TypeKit`, `System.Text.Json` and `System.Text.Json.Serialization`. + +## Three states + +```csharp +public enum EmailFault { NoAt } + +public sealed partial record Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); +} + +public sealed record ContactPatch(Patch Email, Patch Phone, Patch Floor); +``` + +```csharp +ContactPatch patch = JsonSerializer.Deserialize("""{"phone":null,"floor":3}""", JsonSerializerOptions.Web)!; +PatchState email = patch.Email.State; // Absent, not in the document +PatchState phone = patch.Phone.State; // Null, a clear +PatchState floor = patch.Floor.State; // Value +``` + +| In the document | `State` | `Match` calls | `TryGetValue` | `Tap` runs | `IfPresent` runs | `ToOption()` | +|---|---|---|---|---|---|---| +| not mentioned | `Absent` | `onAbsent` | false | no | no | `None` | +| `null` | `Null` | `onNull` | false | no | with `null` | `None` | +| a value | `Value` | `onValue` | true | with the value | with the value | `Some` | + +`default(Patch)` is `Absent`: the serializer never touches a property the document leaves out, so +it keeps the struct's default, and the analyzers, which refuse a `default` of every other struct in +this package, leave a patch alone. Reading never refuses a `null`, whatever `T` is: whether a clear +is accepted is the application's rule. `ToString()` prints the state and never the value. + +## Applying one + +Each field's own rule says what a clear means. A required field refuses one, an optional one is +cleared by it, and a field of a value type receives the clear as `null`, never as `0`: + +```csharp +public sealed class Contact +{ + public required Email Email { get; set; } + public string? Phone { get; set; } + public int? Floor { get; set; } +} + +public enum ContactFault { EmailRequired } + +public static class ContactPatching +{ + public static Result Apply(this Contact contact, ContactPatch patch) + { + if (patch.Email.State == PatchState.Null) return ContactFault.EmailRequired; // a required field refuses a clear + + patch.Email.Tap(email => contact.Email = email); + patch.Phone.IfPresent(phone => contact.Phone = phone); // an optional one: null clears it + patch.Floor.IfPresent(floor => contact.Floor = floor); // a struct's clear arrives as int?, null, never as 0 + return Result.Success(); + } +} +``` + +```csharp +var contact = new Contact { Email = Email.FromKnownGood("ann@example.com"), Phone = "555-0100", Floor = 2 }; +ContactPatch clearPhone = JsonSerializer.Deserialize("""{"phone":null,"floor":3}""", JsonSerializerOptions.Web)!; +ContactPatch clearEmail = JsonSerializer.Deserialize("""{"email":null}""", JsonSerializerOptions.Web)!; + +Result applied = contact.Apply(clearPhone); // Success +string? phone = contact.Phone; // null, cleared +int? floor = contact.Floor; // 3 +Result refused = contact.Apply(clearEmail); // Error: EmailRequired +``` + +`Tap` runs only for a value, for a target a clear cannot reach, as `Option`'s `Tap` does; `IfPresent` +runs for a value and for a clear. Both return the patch unchanged. `Match` takes all three, for a rule +that has to say what each means, and `ToOption()` is for a caller to whom a clear and an absence are +the same. Refusing a clear of a required field, as `Apply` does, says so to the client; ignoring it +with `Tap` alone would accept the request and do less than it asked. + +## A value object inside a patch + +A patch reads its value with the value object's own converter, so a value object inside one keeps its +rule: a value `Create` refuses is refused as anywhere else, and the refusal does not quote it. + +```csharp +JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web); // JsonException naming Email and NoAt, never the text +``` + +The exception's `Path` is the field's, `$.email`, as it is for a property that is no patch, so the +answer to the client can name the field. In an endpoint, that is the 400 any body a value object +refuses gets, with problem details where the host adds `UseStatusCodePages()` ([ASP.NET Core](aspnet-core.md#what-a-refused-value-answers)). A `null` is +not a refusal but the `Null` state, which `Apply` turns into a fault of its own, so a `Patch` +is never needed. A patch's `T` is `notnull`, as an option's is: `Patch` or `Patch` is +a warning (CS8714) at the declaration, and over the value's own type every member hands the value as +it is, never null. + +## Validating one + +Where the refusal should be the endpoint's own fault rather than a 400, the request carries a patch +over what was sent, and `Traverse` validates it into a patch over the domain's type: the value is +validated when the field carried one, with `Create`'s own fault, and a clear or an absent field +passes through, since there is nothing to validate. `Map` changes the value the same way and keeps +a clear and an absence as they are. + +```csharp +public sealed record ContactRequest(Patch Email, Patch Floor); +``` + +```csharp +ContactRequest nobody = JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web)!; +ContactRequest cleared = JsonSerializer.Deserialize("""{"email":null}""", JsonSerializerOptions.Web)!; +ContactRequest moved = JsonSerializer.Deserialize("""{"floor":3}""", JsonSerializerOptions.Web)!; + +Result, EmailFault> refused = nobody.Email.Traverse(Email.Create); // Error: NoAt, the endpoint's own fault +Result, EmailFault> clear = cleared.Email.Traverse(Email.Create); // Success: Null, nothing to validate +Result, EmailFault> untouched = moved.Email.Traverse(Email.Create); // Success: Absent +Patch tenfold = moved.Floor.Map(floor => floor * 10); // Value: 30, a clear and an absence kept as they are +``` + +`CodoMetis.TypeKit.Testing` asserts the state: `ShouldBeValue()` hands the value back, `ShouldBeNull()` +is a clear and `ShouldBeAbsent()` a field the document left out ([Testing](testing.md)). + +## Writing one + +A client, or a test, writes a patch body with the same type. A converter cannot leave its property +out of the document, so an absent patch is refused when it is written, unless the property says to +skip a default: + +```csharp +public sealed record ContactChange( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Phone, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Floor); +``` + +```csharp +string clear = JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web); // {"phone":null} +string set = JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web); // {"floor":4} +JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))); // NotSupportedException naming Email and WhenWritingDefault +``` + +`Patch.Value(null)` is the `Null` state, so a client that holds a nullable value can pass it as it is. + +## In an endpoint and in the document + +```csharp +builder.Services.AddOpenApi(options => options.AddTypeKit()); +app.MapPatch("/contacts/{id}", (int id, ContactPatch patch) => TypedResults.NoContent()); +``` + +The body binds as any other: each property of `ContactPatch` is read by its patch, and one the +request leaves out is `Absent`. With `AddTypeKit()`, the document describes a patch property as its +argument's schema, inlined rather than as a component of its own, admitting `null`, and never +required, in OpenAPI 3.1 and 3.0. Null joins whatever restricts the values: the type, an enum's list +(a closed set's names, an enum written as strings), or a branch beside the argument's own branches. +A validator therefore accepts null and judges every other value as it judges the argument's. A value +object inside a patch is described through the type it wraps, so `email` is a `string`. This is not ASP.NET Core's JSON Patch (RFC 6902, +`application/json-patch+json`), which is a list of operations. + +## Source-generated contexts and Native AOT + +The patch's converter reads `T` itself, so a source-generated context never sees through a patch to +what it holds. A primitive, a `string`, a `Guid`, a date or a `Uri` is read with the serializer's +built-in converter and a value object with its own; any other type, a class or an enum, is listed in +the context, or the serializer refuses with its own message naming it. + +```csharp +public sealed record Address(string Street, string City); + +public sealed record MovePatch(Patch
Address, Patch Floor); + +[JsonSerializable(typeof(MovePatch))] +[JsonSerializable(typeof(Address))] // a class inside a patch is listed: the generator never sees through the patch +internal partial class MoveJson : JsonSerializerContext; +``` + +```csharp +MovePatch move = JsonSerializer.Deserialize("""{"Address":{"Street":"Main 1","City":"Bern"}}""", MoveJson.Default.MovePatch)!; +string city = move.Address.Match(address => address.City, () => "cleared", () => "unchanged"); // "Bern" +string floor = move.Floor.Match(f => f.ToString(), () => "cleared", () => "unchanged"); // "unchanged" +``` + +The same holds under Native AOT, which the consumer smoke test publishes: the converter for each +closed `Patch` is registered by that type's own static constructor, since a converter built by +reflection has no code there. + +## What it is not + +- **Not a deep merge.** Each field is its own patch, so `Patch
` replaces the whole address. + For RFC 7396's merge of a nested object, its properties are patches too: `Patch`. +- **Not an `Option`.** An option says whether a value exists, and refuses JSON; a patch says whether + a request mentioned a field, and is a wire type. `ToOption()` crosses from one to the other. +- **Not a model.** A patch is the shape of a request; the entity it is applied to keeps its own + types, `int?` for a floor, and its own rules. diff --git a/docs/migrating-to-2.md b/docs/migrating-to-2.md new file mode 100644 index 0000000..c8b6786 --- /dev/null +++ b/docs/migrating-to-2.md @@ -0,0 +1,201 @@ +# Migrating from 1.5 to 2.0 + +2.0 targets `net10.0` and Metalama 2026.1, as 1.5 does: no runtime, SDK or Metalama version moves +with it. This page lists what builds or runs differently for code that built against 1.5, and what +to write instead; the [changelog](../CHANGELOG.md) lists everything 2.0 adds. The examples import +`CodoMetis.TypeKit`. + +## Update the packages + +The packages share one version and move together: raise every one you reference, satellites +included. + +```xml + + + +``` + +## A dropped Result is a build error + +CMTK0003, a `Result` or `Option` a call returns and nobody reads, was a warning in 1.x and is an +error in 2.0. It also reports what 1.x missed: + +- `Wait()` on a task of a result, with a timeout too, even where the `bool` that form answers is read; +- tasks of results gathered into an array or a sequence and passed to `Task.WaitAll`; +- a `foreach` or `await foreach` that never reads the results it walks; +- a lazy sequence of results turned into a list and dropped; +- a task picked by `?:`, a `switch` or `??` and awaited, its result never read; +- a tuple or a dictionary holding results, dropped. + +Tasks passed to `Task.WaitAll` or `Task.WaitAny` as separate arguments were reported by 1.x already. +A build that treated warnings as errors already failed on every 1.x form; only the new forms are new +errors. + +```csharp +await orders.CancelAsync(id); // CMTK0003: an error since 2.0 +orders.CancelAsync(id).Wait(); // CMTK0003: new in 2.0, waited for and forgotten + +_ = await orders.CancelAsync(id); // the drop, said in the code +await foreach (var _ in orders.CancelEachAsync(ids)) { } // a loop variable named _ says it too +``` + +A chain of taps on a result or a task you hold, `result.Tap(log).TapError(warn);`, was reported by +1.x and no longer is, since each tap returns its receiver. + +Every report is answered in the code and never needs a suppression: `_ =` for a drop nothing +depends on, a cast to `Task` for a task converted on purpose (`((Task)orders.CancelAsync(id)).Wait(timeout)` +waits without the result), a loop variable named `_`. To migrate +a large codebase in steps, lower the rule for a while: + +```ini +[*.cs] +dotnet_diagnostic.CMTK0003.severity = warning +``` + +CMTK0010 to CMTK0013 are new errors too, but each reports only on what 2.0 adds: the +type-parameter attributes, `[RequireParameterlessConstructor]` and closed sets. No code +that built against 1.5 meets them. + +## Value objects that are build errors now + +In 1.5, an abstract type that declared a value-object marker built: it generated nothing of its own, +its sealed derived types were generated, and a value held as the abstract type was written to JSON as +`{}`. In 2.0 the declaration is CMTK1022. Declare the marker on the sealed type, and keep the abstract +base for what the types share: + +```csharp +// public abstract partial record Money : IValue; 1.5 built these; 2.0: CMTK1022 +// public sealed partial record Euro : Money; + +public abstract record Money; +public sealed partial record Euro : Money, IValue; // the marker on the sealed type, which is generated +``` + +CMTK1005 refuses two more declarations 1.5 built. A value object over `object` was written to JSON as +`{}`: wrap the type the value has. A value object named as a member the generators add to it, which +1.5 mostly failed on inside the generated code, built where the member was not generated: one named +`Revalidate` that is not validated, or `CompareTo` over a type with no order. Rename it. + +## TryParse lets an exception from Create through + +Over a wrapped type parsed by a type converter, a static `Parse` or a string constructor, a +generated `TryParse` turned an exception thrown by your `Create` into `false`. In 2.0 it propagates, +as it does from `Parse` and from `TryParse` over a wrapped type with a `TryParse` of its own. Return a +fault from `Create` for input you mean to refuse. + +## Queries and documents that ran wrongly now throw + +Three things that ran in 1.5 and answered wrongly throw in 2.0: + +- **`.Value` on a column with a converter of the application's own.** `.Value`, `GetValue()` and + `ValueOrNull()` queried the representation TypeKit's converter would have stored, not the one the + application's `HasConversion` stores, and answered wrong rows; EF now refuses the query. Map the + property with TypeKit's converter, or compare the value object itself. +- **Ordering by a hand-written `CompareTo`.** A query that compares, orders, or takes `Min` or `Max` of + a value object that declares its own `CompareTo` was ordered by the column, the opposite of the + in-memory order for a reversed one; it is refused now, and a query filter that does so is refused + when the model is built. Order by `.Value` + where the column's order is meant ([EF Core](ef-core.md#queries)). +- **A component name two types claim.** A document in which a nested value object's or closed set's + component name, `Order.Status` as `OrderStatus`, is also another type's described both as one; it + throws now, naming both. Rename one, or give one its own name in `CreateSchemaReferenceId` + ([the ASP.NET Core README](../src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says)). + +## Rebuild libraries and compiled models + +A library whose value objects 1.5's generators wove lacks what 2.0 reads at run time, such as the +mark the hand-written-ordering refusal looks for, so the refusal does not reach its value objects +until the library is rebuilt against 2.0. A compiled model generated with 1.5 keeps 1.5's decisions, +the key generation below among them. Rebuild every project that references +`CodoMetis.TypeKit.Generators`, and regenerate a compiled model (`dotnet ef dbcontext optimize`). + +## Messages that changed + +A test that asserts an exception or a message meets these: + +- An asynchronous combinator given a delegate that returns no task throws an `ArgumentNullException` + naming the delegate, where the `await` threw a `NullReferenceException`. +- A `CodoMetis.TypeKit.Testing` failure, and a fault mapping's refusal, name a nested type after its + containing types (`Result`, `Api.OrderFault`), where 1.5 gave the type's name + alone. +- CMTK0009's message names `SingleOrNone` and `ElementAtOrNone` beside the other replacements. + +## Keys over small integer types + +A value-object key over `sbyte`, `ushort`, `uint` or `ulong` is now declared generated on add, as EF +declares a plain key of that type, so a migration created against your 1.5 snapshot alters the key +column. On PostgreSQL it adds an identity to a key over `sbyte`, `ushort` or `uint`, which applies +cleanly; on SQL Server the migrations generator refuses the change for all four ("To change the +IDENTITY property of a column, the column needs to be dropped and recreated"). A key over `ulong` is +not generated on PostgreSQL, since `numeric(20,0)` has no identity, and adding an entity with a +default `ulong` key throws `OverflowException` at `Add` on PostgreSQL and SQL Server, where 1.5 +stored 0. To keep 1.5's behaviour for any of these keys, configure `ValueGeneratedNever()` on it and +assign the key. A key over `char` is unchanged: the application's to assign, a default `'\0'` stored +as it is ([EF Core](ef-core.md#keys)). + +## A query's FirstOrNone runs in the database + +In 1.5, `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` on an EF Core query bound to +the `IEnumerable` overloads: every row was read and the condition ran in memory. In 2.0 they bind +to `IQueryable` overloads that hand the condition to the provider, so the answer is the same and +one row is read. A condition the provider cannot translate now throws where it used to run in +memory; say so with `AsEnumerable()`. `LastOrNone` and `LastOrError` on a query are compile errors +whose message says what to write instead. A lambda written at the call is an expression and runs in +the query; a `Func` variable or a method group still binds to the sequence's overload and +reads every row, as in 1.5, so hold an `Expression>` instead. + +```csharp +Option large = orders.FirstOrNone(o => o.Total > 100); // 2.0: WHERE and LIMIT 1 in the query; 1.5 read every row +Option local = orders.AsEnumerable().FirstOrNone(o => o.IsLarge()); // a condition the database cannot run, in memory as before +Option latest = orders.OrderByDescending(o => o.Placed).FirstOrNone(); // where 1.5 took LastOrNone(), now a compile error on a query +``` + +Under Native AOT, EF runs only the queries it precompiled at the application's own call sites, and +these helpers compose the query inside the package: they throw there, as +[Native AOT](native-aot.md#ef-core) describes, with the operators to write at the call site instead. + +## Names 2.0 adds + +2.0 adds members to types 1.5 already had. An extension of your own with the same name and +parameters, imported beside the package's, makes a call ambiguous (CS0121); delete yours. Where the +new member is an instance member, nothing is ambiguous and yours is silently no longer called: a +`MapAsync`, `BindAsync` or `TapAsync` of yours on `Option`. + +| On | Added | +|---|---| +| `Option` | `MapAsync`, `BindAsync`, `TapAsync` (instance members), `Traverse` | +| `Task>` | `MapAsync`, `BindAsync`, `FilterAsync`, `TapAsync`, `ToResultAsync` | +| `Option>`, `Result, TError>` | `Transpose` | +| a sequence | `SingleOrNone`, `ElementAtOrNone`, `Choose`, `TraverseAsync` | +| a sequence of options | `Choose` | +| `Patch` | `Map`, `Traverse` | +| a query | `FirstOrNone`, `FirstOrError`, `SingleOrNone`, `ElementAtOrNone` | +| an EF Core query | `FirstOrNoneAsync`, `SingleOrNoneAsync`, in EF Core's namespace | +| `string` | `AsText`, `AsIdentifier` | +| `Result` | `Catch`, `CatchAsync` | + +2.0 also adds types to the `CodoMetis.TypeKit` namespace: `Patch` and `Patch`, `PatchState`, +`PatchJsonConverterFactory`, `SequenceOptionExtensions`, `Fault` and `Fault`, `TextFault`, `StringTextExtensions`, +`AbstractAttribute`, `SealedAttribute`, `RecordAttribute`, `ConcreteAttribute`, +`RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; and `IClosedSet`, +`IFaultSet`, `IFault` and `FaultKind` to `CodoMetis.TypeKit.ClosedSets`. A type of yours with one of these names, imported beside the +package's namespace, makes a use ambiguous (CS0104): qualify one of them, or alias yours. + +## Fault responses + +Nothing to change in the code. `AddFaultResponses(fault => …)` maps an enum as it did, and +answers with the same response. The OpenAPI document of a host with fault responses describes +`fault`, an optional string, on the shared `ProblemDetails` component, so a client generated from it +gains an optional `fault` member on its problem type; ASP.NET's own problem responses never set it. +Regenerate clients, and update a checked-in snapshot of the document. `FaultProblem` takes any non-null fault type where it took `struct, Enum`, +so that a closed set can be one, and every type argument it accepted still is. To answer each fault +by its kind instead of by a switch, make the fault type a closed set: +[Closed sets](closed-sets.md#as-a-fault-type). + +## What is new + +[Closed sets](closed-sets.md), [merge patches](merge-patches.md), +[an exception as an outcome](result.md#an-exception-as-an-outcome), [written text](text.md), +[type constraints](type-constraints.md), and [fault responses](aspnet-core.md#a-result-as-a-response) +a client can act on, each with its page. diff --git a/docs/native-aot.md b/docs/native-aot.md new file mode 100644 index 0000000..b50ed50 --- /dev/null +++ b/docs/native-aot.md @@ -0,0 +1,183 @@ +# Native AOT + +The packages work in a trimmed or Native AOT application, with one exception, the query helpers on +an EF Core query ([EF Core](#ef-core) below), and every package builds with the trim and AOT analyzers +on, warnings as errors. The analyzers cannot see the code the generators weave into a consumer, nor +tell that a run-time path still works, so the consumer smoke test publishes two consumers with +`PublishAot` on every CI build, a web host with JSON and OpenAPI and a console application with EF +Core's compiled model, and runs the native binaries. This page is what Native AOT asks of an +application that uses value objects; each package README has its own section. + +## The project + +```xml + + true + false + + $(InterceptorsNamespaces);Microsoft.EntityFrameworkCore.GeneratedInterceptors + +``` + +```bash +dotnet publish -c Release +``` + +A warning that names a package (`CodoMetis.…`) or a woven member (your value object's namespace) is +a bug in this repository; please report it. A warning that names EF's `DbContext` constructor is +EF's, and its documentation covers it. + +## JSON contexts + +Native AOT serializes through a source-generated `JsonSerializerContext`. List the value objects, or +the types that hold them, not what they wrap: the generated converter builds the wrapped type's +contract itself, from a converter on the options, the type's own `[JsonConverter]` or the +serializer's built-in one. The one exception is an enum: a context's `UseStringEnumConverter` does not +reach an enum the context does not list, so an enum-backed value object is written as a number +unless the enum is listed too. [JSON](json.md#source-generated-contexts) shows both contexts and +their output. + +```csharp +public readonly partial record struct OrderId : IValue; + +public readonly partial record struct Quantity : IValue; + +public sealed record OrderLine(OrderId Order, Quantity Quantity); + +[JsonSerializable(typeof(OrderLine))] // the shape; neither Guid nor int is needed for the JSON +internal partial class OrdersJson : JsonSerializerContext; +``` + +```csharp +string json = JsonSerializer.Serialize(new OrderLine(OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), Quantity.From(3)), OrdersJson.Default.OrderLine); // {"Order":"0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee","Quantity":3} +OrderLine back = JsonSerializer.Deserialize(json, OrdersJson.Default.OrderLine)!; +bool roundTrip = back == new OrderLine(OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), Quantity.From(3)); // true +``` + +`Option` and `Result` refuse JSON there exactly as on the JIT, and `StoredJsonConverterFactory` +reads without the rules. A converter of your own over an open `T` has to be a converter per closed +type, not a factory: a factory builds the converter for each `T` by `MakeGenericType` +([JSON](json.md#option-and-result-at-the-wire)). + +Two nested types of one simple name in one context, a value object or a closed set `Billing.Status` +beside `Shipping.Status`, give the context two properties of one name: the source generator reports +SYSLIB1031, and the second type throws `NotSupportedException` when it is serialized. Name one with +`[JsonSerializable(typeof(Shipping.Status), TypeInfoPropertyName = "ShippingStatus")]`. + +## OpenAPI + +The document asks for something the JSON does not. ASP.NET builds a value object's schema from its +JSON contract for the wrapped type, and a context has no contract for a type the host never +serializes itself, so the host's context lists every type its value objects wrap as well. Without +one, generating the document fails with an `InvalidOperationException` that names the value object +and the `[JsonSerializable]` to add, rather than describing it as `{}`. The +[README](../src/CodoMetis.TypeKit.AspNetCore/README.md#native-aot) shows the context. A problem +response from `ToHttpResult()` needs `AddProblemDetails()`, which `AddFaultResponses` calls. + +## EF Core + +EF Core runs under Native AOT only through a compiled model and precompiled queries: + +```bash +dotnet ef dbcontext optimize --precompile-queries --nativeaot +``` + +`UseTypeKit()` works there, and the smoke test's console consumer inserts through an identity key, +filters on `.Value`, binds a value-object parameter and reads a stored value back without the rules, +in the native binary. EF's own limitations: a struct value object that wraps a reference type cannot +be a property of a compiled model, so such a value object on an entity is a `sealed partial record`; +a sealed entity class does not compile in precompiled queries; and a converter named in +`HasConversion` is not what the precompiled queries cast to, so the mapping stays `UseTypeKit()`'s +([EF Core](ef-core.md#compiled-models)). + +EF precompiles a query where the application calls one of EF's operators, and runs no other. The +query helpers compose their query inside the package, where it never looks: `FirstOrNone`, +`FirstOrError`, `SingleOrNone` and `ElementAtOrNone` on a query, and the EF satellite's +`FirstOrNoneAsync` and `SingleOrNoneAsync`, throw `InvalidOperationException: Query wasn't +precompiled and dynamic code isn't supported with NativeAOT.` Write the operators at the call site +and take the option of the list: `(await db.Orders.Where(o => o.Total > 100).Take(1).ToListAsync()).FirstOrNone()`. +The smoke test's consumer measures both the refusal and that call. + +## Configuration binding + +`PublishAot` and `PublishTrimmed` turn on the configuration binding source generator, which binds +only what it can see: it does not bind a value object, and leaves the property `default`. Bind the +wrapped type and apply `Create`: + +```csharp +public enum HostFault { Blank } + +public readonly partial record struct SmtpHost : IValidatedValue +{ + public static Result Create(string value) => + string.IsNullOrWhiteSpace(value) ? Result.Error(HostFault.Blank) : new SmtpHost(value.Trim()); +} +``` + +```csharp +var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Smtp:Host"] = " mail.example.com " }).Build(); + +Result host = SmtpHost.Create(configuration["Smtp:Host"] ?? ""); // the wrapped type bound, then Create +string bound = host.Match(h => h.Value, fault => fault.ToString()); // "mail.example.com" +``` + +The reflection-based binder, outside Native AOT, binds a value object through its generated +`TypeConverter`; the line above works in both. A closed set is the +same: the reflection-based binder reads it through its type converter, the source generator does not, +so bind its name as a `string` and `Parse` it. + +## Written text + +`AsText()` composes to Unicode normalization form C through the runtime's globalization data, and a +Native AOT project often turns that data off with `true`, +as the `webapiaot` template does. In that mode `string.Normalize` returns its input unchanged, so +`AsText()` throws `PlatformNotSupportedException` on its first call, whatever the string, rather than +keep one name in two spellings. An application that calls `AsText()` sets `InvariantGlobalization` to +`false` and makes ICU available to the process (`icu-libs` on Alpine, `libicu` on Debian and Ubuntu); +ICU installed while the mode stays on does nothing. `AsIdentifier()` composes nothing and works in +either mode, but it is the guard for a value another system issued, not for written text +([Written text](text.md#globalization-invariant-mode)). + +## Serilog + +`Destructure.WithTypeKit()` works under Native AOT: a value object or a closed set is logged as what +it prints. Serilog's own destructuring of other objects reads their properties by reflection, which +it reports (IL2072), and a record whose properties the trimmer removed is logged empty, `Held { }`. +Log such an object's fields by name, or keep its properties with `[DynamicallyAccessedMembers]`. + +## Discovery without reflection + +The satellites recognise a value object at run time by the attribute the generators put on it, +`GeneratedValueObjectAttribute`, whose type arguments are constrained to the +value-object interface. Never by `GetInterfaces()`: trimming removes an interface nothing in the +application uses, and nothing uses `IValueObject`, so under Native AOT every value +object once looked like no value object at all. Code of your own that is handed a `Type` does the +same: + +```csharp +static string Describe(Type type) => + type.GetCustomAttribute() is { } valueObject + ? $"{valueObject.ValueObjectType.Name} wraps {valueObject.WrappedType.Name}" + : $"{type.Name} is no value object"; +``` + +```csharp +string described = Describe(typeof(OrderId)); // "OrderId wraps Guid" +string plain = Describe(typeof(Guid)); // "Guid is no value object" +``` + +Code that needs the two types as type parameters calls `Accept` with an `IValueObjectVisitor` +and is handed them, without `MakeGenericType`. The attribute lives in +`CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense because nothing but this kind of code +needs it. + +## What the smoke test measures + +`test/consumer-smoke-test.sh` packs the packages, installs them into throwaway consumers outside the +repository, publishes the web and the data consumer with Native AOT, keeps every trim or AOT warning +that names a package or a woven member, and asserts on the native binaries' output: JSON through a +context that lists no wrapped type, the generated converter applying `Create`, the OpenAPI +component, parameter and list schemas, a route parameter bound, EF Core's insert, filter, parameter +and identity key. It needs the platform's linker, clang, and zlib on Linux. An +`[UnconditionalSuppressMessage]` in a package needs a measured reason, and that consumer is the +measurement. diff --git a/docs/option.md b/docs/option.md new file mode 100644 index 0000000..47105b8 --- /dev/null +++ b/docs/option.md @@ -0,0 +1,173 @@ +# Option + +`Option` is a value that may be absent. It has no `.Value`: the content is reached where +absence is handled, through `Match`, `TryGetValue` and the combinators, so a caller cannot read a +value that is not there. `T` is `notnull`, `Some(null)` throws, and a `default(Option)` is `None`, +so an option that reports a value always has one. `ToString()` never prints the content. It lives in +`CodoMetis.TypeKit`; the [base README](../src/CodoMetis.TypeKit/README.md#optiont) is its reference. + +The examples import `CodoMetis.TypeKit` and share a customer with an optional nickname and an optional address: + +```csharp +public readonly partial record struct CustomerId : IValue; + +public sealed record Address(string Town, string Country); + +public sealed record Customer(CustomerId Id, string Name, string Tier, Option Nickname, Option
Address); +``` + +## Creating one + +```csharp +Option found = customers.GetValueOrNone(ann.Id); // Some +Option unknown = customers.GetValueOrNone(CustomerId.New()); // None +Option gold = customers.Values.FirstOrNone(c => c.Tier == "gold"); // Some, the first match (or None) +Option nickname = ann.Nickname; // Some +Option parsed = int.TryParse(text, out var number) ? Option.Some(number) : Option.None(); // None, taking its type from the conditional +Option none = Option.None(); // where nothing supplies the type +``` + +`Option.Some(value)` and `Option.None()` build one by hand. `None()` takes its type from where it goes: +a `return`, a conditional beside a `Some`, a parameter. Where nothing supplies one, as with `var`, +write `Option.None()`. The lookups do the rest: `GetValueOrNone(key)` on a dictionary, +`FirstOrNone()` and `LastOrNone()` on a sequence, with or without a predicate, `SingleOrNone()`, which +throws for more than one, `ElementAtOrNone(index)`, and `ToOption()` on a nullable, `int?` or +`string?`, which turns a null into `None`. On a query, `FirstOrNone`, `SingleOrNone` and +`ElementAtOrNone` let the provider run the predicate and ask it for the rows they need, and +`LastOrNone` is a compile error, since a query has no last row until it is ordered. The predicate +runs in the query when it is an expression, as a lambda written at the call is; a `Func` held +in a variable, or a method group, binds to the sequence's overload instead, which reads every row and +runs the condition in memory, without a warning. Pass an `Expression>` there. CMTK0009 +points at `FirstOrDefault()` and its kind, whose `default` for a value object or a `Result` is an +instance that passed no factory. + +## Reaching the value + +```csharp +string greeting = found.Match(customer => $"Hello again {customer.Name}", () => "Hello"); // "Hello again Ann" +string tier = unknown.Map(customer => customer.Tier).Or("standard"); // "standard" +int? limit = parsed.OrNull(); // null, an int? for an Option +int sum = found.Map(customer => customer.Name.Length).OrDefault(); // 3, a struct's default for None +bool known = found.IsSome(); // true + +if (found.TryGetValue(out var customer)) visits.Add(customer.Id); // customer is non-null in the branch + +foreach (var each in unknown.AsEnumerable()) visits.Add(each.Id); // runs once for Some, never for None +``` + +`Match` takes both branches and returns what they return, so it is the one call that cannot forget +absence. `Or(fallback)` and `OrDefault()` unwrap with a fallback, `OrNull()` into a nullable, `int?` +for an `Option` and `string?` for an `Option`. `IsSome()` and `IsNone()` answer the +question alone, for a guard or an assertion, never for a branch that then reads the value. + +## Combining + +```csharp +Option discount = found.Filter(customer => customer.Tier == "gold").Map(_ => 0.1m); // Some, Ann is gold +Option country = found.Bind(customer => customer.Address).Map(address => address.Country); // Some, the Option
flattened and then mapped +Option<(string Name, string Town)> label = found.Zip(found.Bind(c => c.Address), (c, a) => (c.Name, a.Town)); // Some, only when both are +Option seen = found.Tap(customer => visits.Add(customer.Id)); // runs for Some, hands the option on +IEnumerable nicknames = customers.Values.Choose(customer => customer.Nickname); // "Annie", the nicknames of those who have one: a filter and a map in one +IEnumerable known = ids.Choose(id => customers.GetValueOrNone(id)); // the customers the ids find, the unknown ones dropped + +Option greeting = + from customer in found + from address in customer.Address // a later from sees the earlier ones + where address.Country == "DE" + select $"{customer.Name} in {address.Town}"; // Some +``` + +`Map` changes the value and keeps absence; `Bind` takes a selector that is itself optional, so an +`Option>` never appears; `Filter` turns a value that fails the predicate into `None`; +`Zip` combines two to six options into one value when every one of them is `Some`; `Tap` runs an +action on a value and returns the option unchanged. `Choose` keeps, in order, the values a selector +answers `Some` for and drops the `None`s, a filter and a map in one, lazily as a LINQ operator is; over +a sequence of options, `Choose()` alone keeps the values they hold. `Select`, `SelectMany` and `Where` make query +syntax work, and each `from` after the first sees the earlier ranges, as above. + +## A pending option + +A lookup that is still running is a `Task>`, and `MapAsync`, `BindAsync`, `FilterAsync`, +`TapAsync` and `ToResultAsync` continue it as [Result's async chains](result.md#async-chains) continue +a pending result, awaited once at the end. A callback there runs where the task it waits for completed: +at once on the caller's synchronization context when the lookup had already finished, and without it, +usually on a thread-pool thread, when it had not ([Where a callback runs](result.md#where-a-callback-runs)). +In a Blazor component or a WPF window, await the lookup and continue with the synchronous members: + +```csharp +Option loaded = await shop.FindAsync(ann.Id); // the await returns to the caller's context +string shown = loaded.Map(customer => customer.Name).Or("unknown"); // "Ann", the selector on that context too +``` + +## At the boundary + +An option is not a wire type, not a column and not a route parameter: System.Text.Json refuses it in +both directions ([JSON](json.md#option-and-result-at-the-wire)), and EF Core cannot map it +([EF Core](ef-core.md#option-and-result)). A shape that crosses a boundary says absent with `T?`, and +the option lives between the shapes. + +```csharp +public sealed record CustomerDto(string Name, string? Nickname); // the wire says absent with null + +static Customer FromDto(CustomerDto dto) => new(CustomerId.New(), dto.Name, "standard", dto.Nickname.ToOption(), Option.None()); + +static CustomerDto ToDto(Customer customer) => new(customer.Name, customer.Nickname.OrNull()); +``` + +```csharp +Result required = customers.GetValueOrNone(CustomerId.New()).ToResult(LookupFault.Unknown); // Error: Unknown +``` + +`ToOption()` on the way in and `OrNull()` on the way out; `ToResult(error)` where absence is an error +the caller has to act on, which hands the rest to [Result](result.md). + +## When it is the wrong type + +- The caller has to know why: a [Result](result.md) carries the fault. `Email.TryFrom(text)` answers + valid or not; `Email.Create(text)` says which rule refused it. +- Zero to many: a collection, possibly empty, says it without a type. +- A value that is always there once set: a `required` property. An `Option` property on an entity + or a DTO is refused by EF Core and by the serializer; a `T?` there is the shape, and `ToOption()` + lifts it where the domain wants an option. + +## The surface + +Every public member, on `Option`, on the static class `Option`, and as extensions in the +`CodoMetis.TypeKit` namespace. `T` is `notnull` throughout, but for the elements `Choose(selector)` +reads, which may be null: only what the selector finds is a value. + +| Member | On | Returns | +|---|---|---| +| `Some(value)` | `Option` | `Option`; throws `ArgumentNullException` for null | +| `None()` | `Option` | a `None` marker that converts to any `Option` | +| `None()` | `Option` | `Option` | +| `ToOption()` | `T?` (a nullable struct or a reference) | `Option`: `None` for null | +| `FirstOrNone()`, `FirstOrNone(predicate)` | `IEnumerable`, `IQueryable` | the first element, or the first matching one; `None` for none. On a query the provider runs the predicate and returns one row, when the predicate is an expression; a `Func` variable or a method group reads every row | +| `LastOrNone()`, `LastOrNone(predicate)` | `IEnumerable` | the last, likewise; a compile error on a query, which has no last row until it is ordered | +| `SingleOrNone()`, `SingleOrNone(predicate)` | `IEnumerable`, `IQueryable` | the only element, or `None` for none; throws for more than one. A query is asked for two rows | +| `ElementAtOrNone(index)` | `IEnumerable` (an `int` or an `Index`), `IQueryable` (an `int`) | the element at the index, or `None` past either end; an `Index` on a query is a compile error | +| `GetValueOrNone(key)` | `IReadOnlyDictionary` | `Option` | +| `Choose(selector)` | `IEnumerable`, its elements nullable or not | the values the selector answers `Some` for, in order and lazily: a filter and a map in one | +| `Choose()` | `IEnumerable>` | the values of the options that hold one, in order and lazily | +| `IsSome()`, `IsNone()` | `Option` | `bool` | +| `Match(onSome, onNone)` | `Option` | what the branch returns | +| `TryGetValue(out value)` | `Option` | `bool`; `value` is non-null when true | +| `Or(fallback)` | `Option` | `T` | +| `OrDefault()` | `Option` where `T : struct` | `T`, `default` for `None`; CMTK0009 for a value object, whose `default` is no value | +| `OrNull()` | `Option` | `T?`: `int?` for an `Option`, `string?` for an `Option` | +| `AsEnumerable()` | `Option` | zero or one element | +| `Map(selector)` | `Option` | `Option`; throws if the selector returns null | +| `Bind(selector)` | `Option` | the `Option` the selector returns, or `None` | +| `Filter(predicate)` | `Option` | the same option, or `None` when the predicate fails | +| `Tap(action)` | `Option` | the same option, after running the action on a value | +| `Zip(b, …, selector)` | `Option` with two to six options | `Option`, `Some` only when every one is | +| `ToResult(error)` | `Option` | `Result`: the error for `None` | +| `MapAsync(selector)`, `BindAsync(selector)`, `TapAsync(action)` | `Option`, `Task>` | the same over a callback that returns a `Task`, or continuing a pending option; the chain is awaited once | +| `FilterAsync(predicate)` | `Task>` | `Filter`, once the option is known | +| `ToResultAsync(error)` | `Task>` | `Task>`: a pending lookup into a `Result` pipeline | +| `Select`, `SelectMany`, `Where` | `Option` | query syntax over `Map`, `Bind` and `Filter` | +| implicit from `None` | `Option` | `None` | + +Equality is a record struct's: two options are equal when both are `None` or hold equal values. +A `default(Option)` is `None`, and the analyzer still refuses writing it (CMTK0001): say +`Option.None()`. diff --git a/docs/plan.md b/docs/plan.md deleted file mode 100644 index 33d6560..0000000 --- a/docs/plan.md +++ /dev/null @@ -1,1495 +0,0 @@ -# Plan: CodoMetis.TypeKit - -Status: **1.0.0 released 2026-09-28** (nuget.org, GitHub release `v1.0.0`). Phases 0 to 11 are done; -what is left for 1.x is listed at the end of phase 11, and package validation holds every pack to the -1.5.0 surface. Phase 12 is 1.5.0, released 2026-10-11; 1.5.1 followed the same day with a fix to parameter schemas. Phase 13 is the next release, 2.0.0 (decision 42). The decisions are in §8, Native AOT in §11. The fabric spike -([spikes/FabricSpike](../spikes/FabricSpike/README.md)), the translation comparison -([spikes/ValueTranslation](../spikes/ValueTranslation/README.md)), the EF mapping spike -([spikes/EfMapping](../spikes/EfMapping/README.md)) and the OpenAPI spike -([spikes/OpenApiSchemas](../spikes/OpenApiSchemas/README.md)) are done, and so is phase 12's -`Result`-to-HTTP spike ([spikes/ResultHttp](../spikes/ResultHttp/README.md)). - -## 1. Packages - -| Package | Depends on | Metalama | Contents | -|---|---|---|---| -| `CodoMetis.TypeKit` | Analyzers (flows, §10) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` (§9); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` (§4); `RequireCustomInitializationAttribute`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use (§3, §5, §11): `GeneratedValueObjectAttribute<,>`, `IValueObjectVisitor<>`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `IStoredJsonConverterSource` | -| `CodoMetis.TypeKit.Analyzers` | — | no | Roslyn analyzers and code fixes (§10). Nobody references it directly: it reaches every consumer through the base package | -| `CodoMetis.TypeKit.Generators` | TypeKit, Metalama.Framework 2026.1.x (flows) | yes | Internal aspects, internal `TransitiveProjectFabric`, `AspectOrder`. Future generated type families join this package too: one fabric, one aspect order | -| `CodoMetis.TypeKit.EntityFrameworkCore` | TypeKit, EF Core Relational | no | Generic converter, convention, `.Value` / `GetValue` / `ValueOrNull` translators, `UseTypeKit()` | -| `CodoMetis.TypeKit.AspNetCore` | TypeKit, Microsoft.AspNetCore.OpenApi | no | `AddTypeKit()`: one OpenAPI schema transformer (§7); `ToHttpResult()`, `AddFaultResponses` (decision 38) | -| `CodoMetis.TypeKit.Serilog` | TypeKit, Serilog 4.0.0 | no | `Destructure.WithTypeKit()`: one destructuring policy that logs every value object as its `ToString()` (decision 41) | -| `CodoMetis.TypeKit.Testing` | TypeKit | no | `ShouldBeSome()`/`ShouldBeNone()`, `ShouldBeSuccess()`/`ShouldBeError()`: test assertions that hand back the content, with no test framework dependency (decision 40) | - -**The bare name is the cheapest package.** Someone who only wants `Option`/`Result` references -`CodoMetis.TypeKit` and never receives Metalama by accident. Taking Metalama on is always an -explicit, named choice (`.Generators`). The EF and ASP.NET packages depend on the base package, -not on `.Generators`. They work at run time against the interfaces, so a host that maps value -objects from a referenced domain assembly never needs Metalama itself. - -**Namespaces:** - -| Namespace | Holds | -|---|---| -| `CodoMetis.TypeKit` | `Option`, `Result`, `ResultState`, the markers, `NotWireTypeJsonConverterFactory`, `RequireCustomInitializationAttribute`, `GuidValueExtensions` (`New()` is call-site vocabulary, like `Option`) | -| `CodoMetis.TypeKit.ValueObjects` | Every contract a user declares or constrains on: `IValue`, `IValidatedValue`, `IValueObject`, `IPlainValueObject`, `IValueObjectMaterializer`, and `StoredJsonConverterFactory` | -| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `IValueObjectVisitor`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | -| `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`; the plugins, internal | -| `CodoMetis.TypeKit.AspNetCore` | the schema transformer, internal; `FaultProblem` | -| `CodoMetis.TypeKit.Serilog` | the destructuring policy, internal | -| `Serilog` | `WithTypeKit()`, beside Serilog's own configuration methods (decision 22) | -| `CodoMetis.TypeKit.Testing` | the assertions and `TypeKitAssertionException` | -| `Microsoft.EntityFrameworkCore` | `UseTypeKit()`, beside the providers' `UseNpgsql` (decision 22) | -| `Microsoft.Extensions.DependencyInjection` | `AddEntityFrameworkTypeKit()` and `OpenApiOptions.AddTypeKit()`, beside `AddEntityFrameworkNpgsql` and `AddOpenApi` (decision 22) | - -The internal aspects live in `CodoMetis.TypeKit.Generators`. Users never type that namespace. - -## 2. Phases - -Each phase ends green, and its guards have been proven by seeding the defect -(`.claude/skills/verify-the-guard`). - -0. **Repo skeleton. ✅ Done 2026-09-27.** - - `Directory.Build.props`: warnings as errors, CS1591, Source Link, and package validation - without a baseline until the first release. - - `Directory.Packages.props` (central management; `spikes/` opts out), and `global.json` - pinned to 10.0.401 / `latestPatch`. - - The five shipping projects, still empty, in `CodoMetis.TypeKit.slnx`. - - The analyzer targets netstandard2.0 on Roslyn 5.0.0 (the floor), with RS2008 release tracking. - - `.github/workflows/dotnet.yml` (build and test, SHA-pinned actions, SDK from global.json) and - `dependabot.yml`. - - First guard: `MetalamaBoundaryTests`. Only `.Generators` may resolve Metalama, directly or - transitively. Proven by seeding a direct reference in the base package, which failed the base - and both satellites, and a transitive one via the EF satellite, which failed only that - satellite. -1. **Option and Result. ✅ Done 2026-09-27.** - - `Option`, `Result`, `Result`, the `Result.Success`/`Result.Error` markers, - their extensions and `RequireCustomInitializationAttribute` in `CodoMetis.TypeKit`. Tests in - `test/CodoMetis.TypeKit.Tests`. - - The throw on `Uninitialized`, a `ToString` that never prints the content, and a - `[DebuggerDisplay]` that does (§9). - - Guards, each proven by seeding its defect: - - one throw case per branching member (21), plus a completeness test that fails when a - public `Result` member is added without being classified as branching or not; - - `ToString` never contains the content, including for the markers; - - the debugger display names an existing private property and shows the content; - - the collapsing `Match` runs its success callback. - - Found in the fourth review on 2026-09-27, and fixed: `Option`/`Result` serialized as `{}` and - `{"State":1}` and read back as `None` and uninitialized, silently. Every exported struct now - carries `NotWireTypeJsonConverterFactory` (§9). Guard: with the attributes removed, 58 of the 62 - `NotWireTypeTests` fail, the other 4 pin the escape hatch and the absent-property gap. - - Found in the fifth review on 2026-09-27, and fixed: `Result` never checked its delegates, so a - null for the branch not taken passed until the other outcome first arrived (`Match(null, …)` on - every error), and `Option.ToResult` and the zips lost `Option`'s own check inside a lambda. Every - delegate is now checked before a branch is picked (§9). Guard: `NullDelegateTests` runs each case - on both branches, and its completeness test holds every delegate parameter of the assembly to a - case; with the checks reverted, 29 of its 42 tests fail. -2. **Value-object contracts and analyzer. ✅ Done 2026-09-27.** - - The contracts: `IValueObject<,>`, `IPlainValueObject<,>`, `IValue<>`, `IValidatedValue<,,>`, - `IValueObjectMaterializer<,>`, `GeneratedFactories`, `TranslatedAsWrappedValueAttribute`, and - `GuidValueExtensions` with `New()` and `New(DateTimeOffset)`. No NodaTime. - - CMTK0001 and CMTK0002, resolved by symbol once per compilation (§3). CMTK0001 also follows a - type parameter's constraints (`default(T)`/`new T()` where `T : struct, IValue<…>`, or - `T : IValue<…>, new()`, which only a struct value object satisfies). - - The verifier tests compile against the real `CodoMetis.TypeKit` assembly, with no copy of the - contracts, and `default(Option)` is one of the positive controls. - - Packaging (§10): `CodoMetis.TypeKit` depends on `CodoMetis.TypeKit.Analyzers`, which ships only - `analyzers/dotnet/cs`. `AnalyzerPackagingTests` packs both and reads the result. - - Guards, each proven by seeding its defect: - - a drifted metadata name silences the positive controls (10 tests for `IValue`1`, 9 for the - attribute); - - losing indirect markers (2) or type-parameter constraints (2) fails their tests; - - matching the generators by prefix or by suffix fails the look-alike cases (1 each); - - dropping the dependency, excluding its analyzers, packing the analyzer into `lib/` or - marking it a development dependency each fail a packaging test. -3. **Generators. ✅ Done 2026-09-27.** - - The ten aspects, the transitive fabric and `AspectOrder` in `CodoMetis.TypeKit.Generators`, - built on Metalama 2026.1.28 (§5). - - Every generated way into a validated value object applies `Create` (§5, entry points). - - Probes in `test/CodoMetis.TypeKit.Generators.Probes`, one per JSON and parsing strategy plus - the edge cases, and behaviour tests for every generated member against them. The tests reach - the generators only through the probes, which is the transitive-fabric check. - - The generated-surface snapshot (§6), `GeneratedSurface.verified.txt`. - - Build-outcome tests: a throwaway consumer with declarations that cannot be generated gets - exactly CMTK1000–1009 and CMTK0001, nothing else, and no CMTK0002 beside the real generators. - - Guards, each proven by seeding its defect: - - a JSON read, `Parse` or `TryParse` that bypasses `Create` fails 7, 9 and 5 entry-point - cases; a new public factory without a case fails the completeness tests; - - `MinValue` on a validated number, a missing JSON-null or `Parse(null)` guard, and a fabric - that misses indirect markers or nested types each fail their tests, and the surface ones - the snapshot too; - - without the more-than-one-marker or the generic-type check, the build-outcome tests see an - aspect failure instead of CMTK1003/1005; - - an analyzer that stops seeing value objects fails the CMTK0001 build test, and one that - looks for a misspelled generators assembly breaks the probes build with CMTK0002. - - Found while building them, and fixed: JSON reads and parsing constructed validated value objects - without `Create`; `MinValue`/`MaxValue` wrapped an unvalidated bound; a JSON `null` or - `Parse(null)` wrapped null; `bool`/`char` (explicit `IParsable`) did not compile; a NodaTime - `LocalDate` could not be a dictionary key; an internal value object got a public extension - class; a marker reached through a derived interface would not have been generated. - - Found in review on 2026-09-27, and fixed with guards proven by reverting: an enum-backed value - object's parsing threw for every input; string ordering was culture-sensitive while equality - is ordinal; `Parse(x.ToString(), null)` did not round-trip outside the invariant culture; - `IValue` failed as an aspect bug instead of CMTK1005; a fallback JSON key did not read - back; a record class's comparison threw on null (§5); `Option.Some(null)` reported a value; - and `Result` did not accept the `Result.Error(e)` marker (§9). - - Found in the second review on 2026-09-27, and fixed the same way (39 guards fail with every fix - reverted): two nested value objects of one name crashed Metalama, and a declared - `{Name}Extensions` failed the aspect (CMTK1007 now); malformed JSON threw `FormatException`, - which a minimal API answers with 500; interpolation formatted in the current culture, so - `Parse($"{x}", null)` read "1,5" as 15; a `DateTime` without an offset was converted through - the server's time zone; `From(null)` wrapped null; `Result` held a null value or error; - `Result.Match` could not see the error (§9); a derived record-class value object failed - inside the generated code (CMTK1006 now); CMTK0001 missed `new T()` under `new()`. - - Found in the third review on 2026-09-27: a hand-written comparison operator failed the aspect - (LAMA0500) and a hand-written object `CompareTo` was kept silently beside the generated generic - one; both are CMTK1008 now, and the one seam, `CompareTo(TSelf)`, is documented and pinned (§5). - - Found by the OpenAPI spike on 2026-09-27 (finding 16): a record class that wraps itself, or - two that wrap each other, compiled without a word, and a struct cycle failed as LAMA0611. Every - value object over a value object is CMTK1005 now (§5). Guards, each proven by seeding its - defect: without the refusal 12 build-outcome tests fail (the struct cycle's LAMA0611 among - them); reporting a cycle as plain nesting fails 4 (a self-wrap then compiles again); allowing - nesting without a cycle fails 2. - - Found in the fifth review on 2026-09-27: a hand-written constructor. One with the generated - constructor's signature failed as LAMA0611, a positional record (`OrderId(Guid Value)`) as - LAMA0500 or an exception in the aspect (LAMA0041), and any other compiled without a word: it - skipped `Create`, left `Value` null, or, as a record's copy constructor, did that to `with`. - Every hand-written instance constructor is CMTK1009 now (§5). Guards, each proven by seeding its - defect: without the refusal 13 build-outcome tests fail, the LAMA errors among them; refusing a - static constructor too fails 2. -4. **EF Core. ✅ Done 2026-09-27.** - - The mapping spike first (spikes/EfMapping): an additive type-mapping-source plugin maps every - value object, with keys, foreign keys, nullable properties and primitive collections, on - SQLite and PostgreSQL, whatever the registration order, and beside a library that replaces - EF's converter selector (decision 9). - - `CodoMetis.TypeKit.EntityFrameworkCore`: `ValueObjectConverter`, the plugin, the - `.Value`/`GetValue()`/`ValueOrNull()` translators, `UseTypeKit()`, and - `AddEntityFrameworkTypeKit()` for an application that builds EF's internal service provider. - - Stored JSON (§4): `StoredJsonConverterFactory` in the base package (decision 10). - - Tests: the model on SQLite, SQL snapshots on PostgreSQL via `ToQueryString`, the default - comparer against `.Value`, and one Testcontainers PostgreSQL round trip covering every - wrapped-type family, stored values the rules refuse, and the translated queries. - - Guards, each proven by seeding its defect: a plugin that stops recognising value objects - fails 33 of 34 EF tests (all but the control), one that drops the column facets fails the - facet test; losing the member or the method translator - fails 4 and 3; a validating `Materialize` fails the stored-row test; a stored-JSON converter - that validates fails the stored value and key tests. The SQL snapshots first failed on the - cast `Convert` produced, which led to re-typing the column (§4). - - Found in the fifth review on 2026-09-27, and fixed: the method translator took any static - one-argument method carrying the public `TranslatedAsWrappedValueAttribute` for the unwrap, so - a hand-marked `Length(this ProbeCode)` became `WHERE o."Code" = '3'`. It now requires the - unwrap's signature (§4). Guard: the hand-marked test fails without the check, and the - `GetValue`/`ValueOrNull` snapshots, now with a record class's `ValueOrNull`, still translate. -5. **ASP.NET Core. ✅ Done 2026-09-27.** - - The measurement spike first (spikes/OpenApiSchemas): a schema transformer reaches a value - object that only ever appears as a property, which the earlier measurement had denied; container - elements and `TryParse`-bound parameters need handling of their own (§7, decision 11). - - `CodoMetis.TypeKit.AspNetCore`: `OpenApiOptions.AddTypeKit()`, which adds - `ValueObjectSchemaTransformer`. A value object's schema is filled in, keyword by keyword, from - the schema ASP.NET itself publishes for the wrapped type. - - Tests in `test/CodoMetis.TypeKit.AspNetCore.Tests`: a probe host with minimal APIs and MVC whose - document is read per shape (15 value-object properties across the wrapped-type families, nullable, list, array, set, dictionary, - nested containers, bodies, route/query/header/`[AsParameters]`/MVC `[FromQuery]` parameters, a - query array), each against a control that uses the wrapped type, in OpenAPI 3.1 and 3.0; the - host's enum converter and number handling, on the wire too; composition with another - transformer in either order; a host that inlines value objects; a keyword-completeness test over - every `OpenApiSchema` property. - - Guards, each proven by seeding its defect: not registering it fails 66 tests; dropping the - container branch 9, the parameter branch 18, the MVC model-metadata fallback 8; using the - parameter descriptor instead fails the MVC `[FromQuery]` object (4); keeping ASP.NET's `string` - placeholder fails the integer parameters (8); dropping the nullable unwrap 9; copying - `Metadata` 44; overwriting what a container or a keyword already had 1 and 28; sharing - collections 5; assigning a missing keyword (null) 174; without the recursion stop every - document is refused (73); without the cycle refusal the test host dies of a stack overflow. - - Found while building it: assigning `null` to `OpenApiSchema.Const` is not a no-op, it writes - `"const": null`, so every keyword is assigned only when the wrapped schema has one, and the - keyword tests compare the written schema, not the getters. - - Found in the spike and documented, not fixed: ASP0020 (an error) for a minimal-API route - parameter whose value object is declared in the same project (decision 12). -6. **Delivery. ✅ Done 2026-09-27**, up to the first tag, which needs the GitHub repository and the - nuget.org policy (release.yml, "Setup, once"). - - Per-package READMEs: `src//README.md`, packed and named by `PackageReadmeFile` from - `src/Directory.Build.props`, so a new package cannot pack without one. `PackageReadmeTests` - packs every shipping project and reads the nuspec and the package. - - `test/consumer-smoke-test.sh [feed]`: packs the five packages (or takes a packed feed) and - builds three consumers outside the repository, with a private `NUGET_PACKAGES`, package source - mapping and the repository's `global.json`. `core` references only `CodoMetis.TypeKit`: Option - and Result work, no Metalama arrives, CMTK0001 and CMTK0002 fire. `layered`: a domain library - on the generators and an app that reaches them only through it; the app uses the generated - members, the transitive fabric generates a value object the app declares, and CMTK0001 fires in - the app with no CMTK0002. `host`: both satellites in a running web host; the OpenAPI document, - route binding, EF mapping, a round trip and the `.Value` SQL. It asserts on output, diagnostics - and the resolved package graph, never on an exit code alone. - - CI (`dotnet.yml`): test, then a pack job (smoke test against the packed feed, SBOMs) and a - smoke-test job that packs its own feed, the two modes a sibling repository found not to be - interchangeable. - - `release.yml`: on a `v*` tag, verify (tag against `Version`, the changelog dates the version, - full suite, pack, smoke test against the packed feed, one CycloneDX SBOM per package), then - publish through Trusted Publishing behind the `nuget` environment's approval, then a GitHub - release with the changelog section as notes and the SBOMs attached. Only the publish job can - mint a token, and it cannot write to the repository. `CHANGELOG.md` and a release checklist in - CONTRIBUTING.md. - - SBOMs exclude the analyzer's `PrivateAssets=all` Roslyn references: unfiltered, the analyzer's - SBOM listed 13 components for a nuspec that declares none (measured with CycloneDX 6.2.0). - - The unlicensed build: fabric spike finding 9 confirmed in a clean container, no license file - and no variable, the whole solution in Release and all 254 generator tests green. - - Guards, each proven by seeding its defect: the smoke test fails when the base package loses its - analyzer dependency (and the `default(Option)` build then succeeds) and when `AddTypeKit()` - does nothing (three OpenAPI assertions); a generators package without its assembly fails at - pack (NU5128), and a non-flowing Metalama in the repository's own build. - `ReleaseWiringTests`, `ChangelogTests` and `PackageDependencyTests` (17 seeds, each failing its - test): a renamed workflow or environment, a token in the verify job, repository write in the - publish job, a dropped tag or changelog check, either smoke-test mode dropped, a non-executable - script, a narrowed SBOM filter, an unpinned tool, a second publishing path, a malformed or - missing changelog section, a dated version with unshipped rules, a satellite without the base - package, a satellite depending on the generators. - - Found by the clean container: the build-outcome tests built their consumer in Debug whatever the - run's configuration, so CI's Release run would have failed 17 of them; the consumer now builds - in the test assembly's own configuration. - - Found by the smoke test: every README returned a bare fault from a method typed - `Result`, which does not compile (§9); and a host that has - `Microsoft.AspNetCore.OpenApi` only through the satellite fails with CS9137, because that - package enables its generator's interceptors in `build/`, which NuGet imports for a direct - reference only. The satellite README says to reference it, as the `webapi` template does; a copy - of Microsoft's switch in our own `buildTransitive` would drift from theirs. - -7. **Native AOT. ✅ Done 2026-09-27** (§11). - - `IsAotCompatible` on the three run-time packages, so the trim and AOT analyzers run in every - build and a warning fails it. `AotCompatibilityTests` reads the packed assemblies' metadata; the - compile-time packages are exempt by name, with the reason. - - The consumer smoke test's `aot` consumer: a domain library and a web host with JSON, OpenAPI - and EF Core (compiled model, precompiled queries), published with `PublishAot`. No trim or AOT - warning may name a package or a woven value object, and the native binary is run and asserted - on. The workflows install clang and zlib before it, held by a wiring test. - - Found by publishing with Native AOT, each fixed and guarded: - - `StoredJsonConverterFactory` found its private constructor by reflection, which the trimmer - removed: stored JSON was **validated after all**, silently. It now asks the generated - converter through `IStoredJsonConverterSource`. - - the OpenAPI satellite described **every value object as `{}`**, silently: the trimmer removes - `IValueObject<,>` from a type's interfaces when nothing uses it, and `GetInterfaces()` found - nothing. The satellites now read `GeneratedValueObjectAttribute<,>` (decision 17). - - `Option`/`Result` refused JSON with the runtime's "missing native code" message instead of - their own: the refusing converter was made with `MakeGenericType`. One object-typed converter - now serves every type. - - the generated JSON converter serialized the wrapped value through the options (20 warnings in - the consumer's assembly), and a source-generated context without the wrapped type refused a - `decimal` value object outright, on the JIT too. It now goes through `GeneratedJson.TypeInfo`. - - the generated parsing of a `[TypeConverter]` type (NodaTime) called - `TypeDescriptor.GetConverter`, which requires unreferenced code. - - EF Core: the converter's read path called an internal helper, which a compiled model - (`dotnet ef dbcontext optimize`) writes out as C# in the application's assembly, where it does - not compile; a precompiled query cast the converter to its design-time type, which the - compiled model does not recreate; a value-object parameter, mapped at run time, hit EF's own - `MakeGenericType` guard composing its JSON reader/writer; and the converter was made with - `MakeGenericType`. - - Guards, each proven by seeding its defect: reverting the generated JSON to the options fails 6 - `SourceGeneratedJsonTests`, and in the smoke test's `aot` consumer the warnings assertion (8 woven - warnings) and the JSON assertions; each reflection or `MakeGenericType` call put back fails the - build under the analyzers, and suppressed with `#pragma` only, the smoke test's warnings - assertion (IL2070 from ILCompiler); without the generated attribute, the OpenAPI host under - Native AOT is `{}` again; dropping `IsAotCompatible` from a package fails - `AotCompatibilityTests` for that package; a missing or late toolchain step fails the wiring test. - - Not caught at run time: a `GetInterfaces()` lookup suppressed with - `[UnconditionalSuppressMessage]`, since the attribute keeps the interface alive (§11). -8. **Pre-release naming and syntax. ✅ Done 2026-09-27.** - - Names (decision 18): the wrapped value, the plain value object, the value object's own type - parameter and the delegate parameters each have one name, and the namespaces follow who uses - a type (§1). Every rename is a compile error for code that used the old name; the analyzer's - metadata-name tests and the generated-surface snapshot moved with them, the snapshot by exactly - the interface rename and nothing else. - - Syntax (decision 19): `Result.Success` (was `Result.Ok`), an `Option.None()` marker, `MapError`, - `Bind` from a valued result onto `Result`, `Or(fallback)` (was `Coalesce`), and a - `ToResult(error)` that keeps the value; the marker-lambda overloads and the collapsing `Match` - are gone. Measured first against a replica of the conversions: the marker works in a return, a - conditional beside `Some`, a `Bind` lambda and an `async` method, and the new `Bind` resolves - beside the generic one. The completeness tests forced a case for every new member; seeding - `None` out of the refusal, `MapError` past the uninitialized check, or the command into the - error branch of `Bind` fails 7, 1 and 1 tests. `ToResult` checks its error on either branch, - like the delegates, and a `Some` that accepted a null error fails its test. -9. **Before the first release: rule set, Result pipelines, performance. ✅ Done 2026-09-28.** - - The analyzer rule set (decision 13): CMTK0003–CMTK0008 beside CMTK0001/0002 (§10). CMTK0004 - ships at Error, which only the first release can do without a major version. - - `Revalidate()` on a validated value object (§5): `Create` applied to the value it holds, for - finding the stored values a rule added later refuses. - - `Result` (§9): `MapAsync`/`BindAsync` on both shapes, and on a `Task>` a continuation - of every combinator (`MapAsync`, `BindAsync`, `MapErrorAsync`, `TapAsync`); `Zip` for two to six - results; `Sequence` and `Traverse`. All stop at the first error. - - Benchmarks (`benchmarks/`), against the wrapped types, and a spike against Vogen - ([spikes/ValueObjectPerformance](../spikes/ValueObjectPerformance/README.md)). - - Found by the benchmarks, and fixed: `TryFormat` boxed the wrapped value (32 bytes, 3.5 times a - `Guid`'s time); comparing an enum-backed value object boxed both operands (sorting 1,000 took 8 - times as long and allocated 367 KB); the JSON converter wrote and read a number through a nested - serializer call (1.35–1.40 times). All at parity after the fix (§5). - - Found while measuring the rules on woven code: Metalama runs analyzers on the source before - weaving, where a call to a generated member of a value object in the same project does not bind, - so CMTK0003 missed an ignored `TryFrom` and CMTK0007 every `FromKnownGood` (§10). - - Guards, each proven by seeding its defect: the new `Result` members' completeness tests force a - case each (28 null-delegate cases, one uninitialized case per extension, one continuation per - combinator), and six seeds (zips short-circuiting, a delegate checked after the await, `Sequence` - enumerating past the error, `MapAsync` calling its selector on an error, a combinator without a - continuation, the second error first) fail 5, 16, 3, 2, 3 and 1 tests. Each rule's verifier tests - have a positive control per form and a negative control per exemption; 29 seeds across the six - rules each fail their test. A `Revalidate()` that skips `Create` fails both of its behaviour - tests, one of them against PostgreSQL. CMTK0004's exemption of EF's compiled model is measured by the smoke - test: with generated code analysed, the AOT consumer fails with six CMTK0004 errors in - `CompiledModels/OrderEntityType.cs`. The build-outcome consumer holds every rule to a value object - of its own project; without the unbound-call path CMTK0003 and CMTK0007 fail there. The - performance fixes are held by allocation tests on a Debug build, where the JIT never removes a - box, and a number-handling matrix; reverting any fix fails them. -10. **Release readiness for 1.0.0. ✅ Done 2026-09-28.** Four independent audits (the generators, the - satellites, the analyzers, the documentation), each confirming findings empirically in throwaway - consumers of packed packages, and the fixes below, each with a test that fails with the fix reverted. - - Release: the first release is 1.0.0 (decision 21); package tags, copyright and release notes, - held by `PackageReadmeTests`; the analyzer's Roslyn floor measured and held (decision 25), and the - build-time `Microsoft.CodeAnalysis.Analyzers` on 5.9.0; registration extensions in the framework - namespaces (decision 22), which the smoke test's consumers use without a `using`. - - `Option`/`Result`: `Ensure`, `TapError` and their continuations; query syntax with more than - one `from` (the one-selector `SelectMany` made the second `from` a compile error); - `GetValueOrNone`; `FirstOrNone()`/`LastOrNone()` without a predicate; `FirstOrError`/`LastOrError` - check their error on either branch, as `ToResult` does. The completeness tests forced a null and - an uninitialized case for each; six seeds fail seven tests. - - Generators (§5): a hand-written `ToString()` was replaced silently (a secret printed); a value - the wrapped type could not parse was quoted in the exception; the JSON differed from the wrapped - type's for `TimeOnly`, date keys, host converters and key policies; an enum-backed value object - allocated 4.7 KB per JSON value; `Uri` parsing disagreed with JSON; `ToType` failed the identity - conversion; nine declarations failed as Metalama errors (CMTK1005, CMTK1010, CMTK1011 now). Guards: - `ToStringSeamTests`, `UnreadableInputTests`, `JsonParityTests`, the allocation and parsing tests, - 18 build-outcome rows. - - Analyzers (§10): nothing fired in Razor; `OrDefault()` and `FirstOrDefault` handed out defaults - unreported (CMTK0009); false positives on guard checks and zero-length arrays; false negatives - in the declaring project, for `x!`/`(x)`/named arguments, static comparisons, and a `Task` of a - result converted to `Task`; CMTK0001 35% faster. 42 verifier tests and six build-outcome cases - added, every seed recorded failing. - - Satellites (§4, §7): `.Value` on a collection element failed to translate; a key over an integer - lost its identity column (decision 23); nested value objects shared an OpenAPI component - (decision 24). The smoke test's AOT consumer inserts through an identity key. - - Documentation: every C# block of the READMEs is compiled and its stated outputs asserted (§6, - item 6); four samples did not compile, one SQL comment was wrong, and a dozen claims were wrong - or untested (a JSON `null` refused by a record class, configuration binding under the source - generator, a JSON 400 with a message from a minimal API, "symbols for every package", …). Each - is now corrected, or backed by a test that did not exist. - - Found and documented, not fixed: a property absent from a JSON body stays `default` (System.Text.Json - calls no converter; `required`/`RespectRequiredConstructorParameters`, now in the ASP.NET README - with a test); `ConfigureConventions` for the wrapped type does not reach a value object; a - validation attribute on a value-object property lands in the shared OpenAPI component. - - Upstream: the Metalama issue behind CMTK1010, reproduced with Metalama.Framework 2026.1.28 and - 2027.0.3-preview by a five-line aspect that introduces `ToString` with `OverrideStrategy.Override` - into a `partial record struct` declared in two parts (LAMA0611 CS0111, then LAMA0613). Lift - CMTK1010 once Metalama emits the override once. - -11. **Second review, against the packed 1.0.0. ✅ Done 2026-09-28.** Five reviews (the generators, - `Option`/`Result`, the analyzers, the satellites, packaging and release), each building throwaway - consumers of the packed packages with a private package cache, and the fixes below, each with a - guard seeded to fail. - - Analyzers (§10): where Metalama compiles a project, source generators run after its - transformation (Metalama 2025.0), and an analyzer sees only the source. Every rule was silent in - the `.razor` files of the usual Blazor layout (a web project referencing a value-object project) - and missed forms in the declaring project. The analyzer package's `buildTransitive` props lists - the analyzers' namespace as a `MetalamaTransformedCodeAnalyzer` item (decision 33). - `AnalyzerPackagingTests` holds every analyzer to it; the smoke test's layered consumer gains a - Razor library and a guard in Domain, and both build clean with the props left out. Every rule - has a `helpLinkUri` (`AnalyzerHelpLinkTests`). The README adds `[RequireCustomInitialization]` for - a struct made of value objects, and `.globalconfig` for severities in Razor, which no - `.editorconfig` section reaches (measured). - - `Option`/`Result` (§9): `Result` lost its `bool` conversion (decision 29); - `TapErrorAsync(Func)` on both shapes and both continuations, since an `async` error - callback ran as `async void` and its exception ended the process (decision 30); `TryCast` on - `object?` removed (decision 31). CMTK0003 treats the instance `TapErrorAsync` like `TapAsync`. - Five seeds fail twelve tests. - - Generators (§5): state besides the wrapped value is CMTK1012 (a `Currency` lost in JSON, a lazy - cache that made equal instances unequal, `required` as LAMA0611 CS9035); a hand-written equality, - `PrintMembers` without `ToString()`, and explicit implementations of the interfaces the - generators implement are CMTK1011, or CMTK1008 for comparison (an explicit `IParsable.Parse` - let a generic `T.Parse` past `Create`); a declared `IConvertible` is CMTK1011 (it was LAMA0041); - a `file`-local value object below a directive or nested in a `file` type is CMTK1005 (it was - LAMA0001, failing the whole project); an explicit `Create` beside a public one is CMTK1011, since - a generic `T.Create` reached a second rule set; and what a base record declares counts as the - value object's own (an unsealed `ToString()` on the base was replaced and printed the value). - The first CMTK1012 refused every record base from another assembly (its compiler-generated - `EqualityContract`); the check now skips `[CompilerGenerated]` members. Decision 32. - - Satellites (§4, §7): a container of nullable value objects, and a nullable value object inlined - by `CreateSchemaReferenceId`, admit null as the wrapped type does, in 3.1 and 3.0 (decision 36); - `InlinedValueObjectTests` compares 34 positions with the wrapped type. On SQLite an integer - value-object key is not `AUTOINCREMENT`, so the model never matched its snapshot and `Migrate()` - threw: the README documents `UseAutoincrement()`, and `MigrationSnapshotTests` checks it - (decision 35). The READMEs state the 10.0.12 floors (NU1605 below them), `AddTypeKit()` per - document or `ConfigureAll`, that `UseTypeKit()` needs a relational provider, and - that value objects with one name in different namespaces share a component, as any type does. - - Packaging (§6): the SBOMs also exclude what the SDK adds (`Microsoft.NET.ILLink.Tasks`, - `NETStandard.Library`), read from restore output (`suppressParent: All`); the ASP.NET Core - satellite drops `Microsoft.AspNetCore.OpenApi.SourceGenerators`, which had baked ten unused types - into it (assembly 229 → 37 KB), held by `GeneratedCodeTests`; symbol packages are pushed in a step - of their own, since a duplicate .nupkg made NuGet skip its .snupkg on a re-run; package - descriptions are plain text, and the release notes omit the changelog heading. - - Analyzer gaps, closed before the tag: CMTK0003 reports a collection of results the statement - made (`Task.WhenAll`, `Select(…).ToList()`) and a method group of a `Task` method - converted to a delegate returning `Task` (14 seeds); CMTK0008 reports a LINQ join on two - different value objects' values, query and method syntax (8 seeds); CMTK0009 reports `Find`, - the immutable collections' own `…OrDefault`, and the async forms of `System.Linq.AsyncEnumerable` - and EF Core (7 seeds). CMTK0001 no longer reports a `default` passed to a comparison guard or an - assertion, or assigned to the `out` parameter of a `bool` Try method (11 seeds). - - Refusals, after a reviewer's note that the refusal-heavy design will draw "why does this throw?" - reports: every deliberate run-time refusal ends with a link to its subsection of the base README's - "Why does this throw?" (`Refusals`, `RefusalLinkTests`, 38 refusals), the two raised by generated - code (`From(null)`, a JSON null) through `CompilerServices` helpers; every CMTK1000–CMTK1012 - message ends with a link to the Generators README's Build errors table, since a Metalama 2026.1 - `DiagnosticDefinition` takes no help link (`BuildOutcomeTests`). `OrNull()` for an `Option` of a - value type (`ValueTypeOptionExtensions`), so the JSON refusal's advice compiles for `Option`. - A nullable `Option`/`Result` is refused with its own message, null or not: writing null threw - `NullReferenceException`, and reading an option gave the `Result` advice. A source-generated - context cannot refuse `Option?` cleanly (it would need `MakeGenericType`) and rejects the - contract when it builds it; the README says so. Decision 37. - - Documentation: the root README gains "How it compares" (when TypeKit fits, a decision table of - needs another library meets better, and detail tables against Vogen, Thinktecture, - StronglyTypedId, LanguageExt, CSharpFunctionalExtensions and ErrorOr, each claim read from source - at the named release on 2026-09-28, the next major in preview included) and "Built with Metalama". - The Generators README links to the comparison. Both are GitHub-only and can be corrected without - a release. - - Found and left for 1.x: CMTK0003 over `.Wait()` and `await foreach`; CMTK0008 over tuple `==`, - `Contains` and `is var`, and composite join keys (all Warnings, decision 34). Raw Metalama errors - for a value object named after a generated member (`From`, `TryFrom`, `MinValue`), a wrapped type - whose `Parse` does not return it or that is abstract, and a public base member whose signature - matches a generated one (LAMA0500). `ToString()` of a default `Uri`/`Version` value object throws; `IConvertible` quotes - the value; `class Email : IValue` reports one of its three errors at a time; `IValue` - writes `{}`. JSON on `Option?` throws the wrong exception; a default marker converts. - `UseTypeKit()` on a non-relational provider could refuse in `Validate`. -12. **1.5.0: the boundaries. ✅ Done 2026-10-11.** Every item is additive, so it is a minor version. The - scope follows a survey of the competing libraries' trackers on 2026-10-11 (demand is small - everywhere, at most 22 reactions on one request), keeping what no library ships or what only - preview packages serve, and what follows from what TypeKit already holds to. - - **A `Result` as an HTTP response**, in `CodoMetis.TypeKit.AspNetCore`: a success as its value, a - fault as RFC 9457 problem details, with its status mapped from the fault in the host, so the - domain never references ASP.NET; and the endpoint's responses in the OpenAPI document. Mapping a - result to a response is not serializing it, so §9's refusal stands. The most-requested open item - in the field (FluentResults #149); the packages that answer it are previews or unmaintained. - Measured 2026-10-11 in [spikes/ResultHttp](../spikes/ResultHttp/README.md), under JIT and Native AOT, - minimal APIs and MVC: an explicit `ToHttpResult()` returning ASP.NET's own - `Results, FaultProblem>`, a mapping per fault type registered in the host - (`AddFaultResponses`, which calls `AddProblemDetails()`, without which Native AOT writes no - problem), endpoint metadata recording only the fault type (MVC hands `PopulateMetadata` an empty - service provider), an operation transformer adding a problem response per mapped status, and a - startup filter that refuses a host with an endpoint whose fault type has no mapping. Returning - the `Result` itself from a handler is not offered: it documents a `Result`, and a Native AOT host - would need it in its JSON context. **✅ Implemented 2026-10-11** (decision 38): `FaultResponseTests` - (27, against ASP.NET's own `ProducesProblem` as the control, in OpenAPI 3.1 and 3.0, minimal APIs - and MVC), the README's sample and its problem JSON read from the README, and the smoke test's - Native AOT consumer. Fifteen seeds each fail their guard. The weaving review found one guard - vacuous: every host also mapped ProducesProblem controls, so a problem component the package - never added went unnoticed; a host without them now holds it. Without `AddProblemDetails()` only - the `traceId` assertion notices under JIT, so the AOT consumer is its real guard. A handler typed - `IResult` loses the endpoint's metadata, as ASP.NET's typed results do; the README says so. - - **A value object's fault as a validation error.** A body or parameter that `Create` refuses is a - `400` whose problem details name the member and the fault, as ASP.NET Core 10's validation - reports a data annotation, instead of a bare `400` from a `JsonException`. No library does this. - Measured 2026-10-11 in [spikes/ValidationFaults](../spikes/ValidationFaults/README.md): ASP.NET - Core 10's validation never sees a value that failed to bind, a refused route or query value has - lost its fault (`TryParse` says only `false`), and MVC already puts the converter's message in its - validation problem. What remains is a minimal-API body, reachable only with `ThrowOnBadRequest` - turned on for the whole host and an exception handler, and only once the converter's refusal - carries its fault as data. The README's "Binding" said `AddProblemDetails()` gives a refused body - a response body; it needs `UseStatusCodePages()` too, which it now says. **Dropped 2026-10-11** - (decision 39). - - **`CodoMetis.TypeKit.Testing`**: assertions for `Option`/`Result` (`ShouldBeSome()`, - `ShouldBeError(fault)`), which a type without `.Value` needs more than any other, and generators - of valid and refused values that go through `Create`. **✅ Assertions implemented 2026-10-11** - (decision 40): `AssertionTests` (23), the README's block run as a test and its quoted failure read - from the README, the smoke test's core consumer installing the package. Generators are not built - (decision 40). - - **Sensitive value objects**: a marker that keeps a value out of `ToString()`, the debugger and - logs (`Microsoft.Extensions.Compliance` classification, structured-logging destructuring). It - extends decision 27 from refusals to every place a value is printed. No library does this. - Measured 2026-10-11 in [spikes/SensitiveValues](../spikes/SensitiveValues/README.md): the - `ToString()` seam the generators keep already masks a value in every formatting path and every - Microsoft.Extensions.Logging path, `[LogProperties]` included, so a marker would generate the one - line a user writes. A classification on the type makes the logging generator refuse the method - (LOGGEN035). The one leak is Serilog's `{@x}`, which destructures the public `Value` of every - value object, masked or not; a destructuring policy closes it. **✅ Implemented 2026-10-11** - (decision 41): no marker; the Generators README's "A value that must not be printed", and - `CodoMetis.TypeKit.Serilog`. - - **`Recover` on `Result`**: the error branch back to a success, which today needs `Match`. - **✅ Implemented 2026-10-11** on both shapes, with `RecoverAsync` (an asynchronous selector, and - continuations on a pending result). It returns a `Result`, so a selector recovers from some - errors and returns the others; the completeness tests (continuations, null delegates, - uninitialized results) found every new member until each had its case, and four seeds each fail - their guard. - - Candidates once those ship, each with its own measurement: a Dapper satellite (nothing registered - per type, through `GeneratedValueObjectAttribute.Accept`), Newtonsoft.Json under decision 26's - parity, and configuration binding, which the configuration binder's source generator does not do - for `TypeConverter`/`IParsable` types (dotnet/runtime#83599). The CMTK0003/CMTK0008 gaps above - stay Warnings (decision 34). - - **Ranges are not a TypeKit family**: CodoMetis.ValueRanges models them. A value object over one - (`IValidatedValue`) works with both packages as they - ship, measured 2026-10-11 in [spikes/RangeValueObjects](../spikes/RangeValueObjects/README.md): - - it weaves, so phase 11's "abstract wrapped type" gap does not cover a type that parses to itself; - - `UseTypeKit()` composes onto the `daterange` mapping in either order; - - `.Value.Contains(day)` becomes `@>`; - - the generated JSON and OpenAPI mirror `DateRange` exactly. - - No bridge package is needed. ValueRanges 9.0.0 (re-measured the same day) closes the three wire - gaps the value object had inherited: `[JsonConverter]` on every range type, ranges as dictionary - keys, and an OpenAPI transformer to copy, which reaches the value object through `AddTypeKit()`. - The re-run found a TypeKit defect, **fixed**: a value object's `TryParse`-bound parameter took the - wrapped type's JSON schema (`{}` over `DateRange`), where ASP.NET describes a parameter of the - wrapped type as `string` for anything off its own list (`EndpointModelMetadata.GetDisplayType`). - `ParameterTests` has a control for each entry of that list and one off it (`ProbeSpan`). Open: a - README section for the pattern, with no host registration needed for JSON; and CMTK1005 refusing - `RangeSet<,>`, whose equality is a value's, though `IEquatable` alone is no criterion - (`ImmutableArray`). - -13. **2.0.0: closed sets, fault responses and merge patches.** Next; not started. Scoped 2026-10-12 - (decision 42): the features proposed for 1.x ship in the major that already waits for the three - breaking reasons below, not as a 1.6.0 before it, because the smart-enum family changes what a - fault is, and fault responses built on enums first would be built twice. - - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the - release's headline. A member is a named instance with constant data. The generators emit `All`, - parsing and formatting by name, JSON, an OpenAPI string enum, and a `Match` with one required - delegate per member. C# never proves an enum `switch` complete; it only warns about a missing - discard (CS8524). As a fault type, the family carries what `FaultKind` (proposed 2026-10-11, - folded in here) would have read from attributes: - - **A kind per member**: NotFound, Conflict, Invalid, Forbidden or Unavailable, nothing - HTTP-specific, from which `AddFaultResponses` derives a status without a hand-written switch. - It is a property set where the member is declared, so Native AOT reads it without - reflection. The explicit mapping still overrides it, so decision 38's switch remains the - escape hatch. - - **An optional sentence per member**: the fault's remedy as a person reads it. It is optional - because a refusal that must not explain itself, a credential's or a secret's, has none on - purpose. - - **A generated qualified name** (`OrderFault.NotFound`) beside the bare one, so two fault types - that share a member name stay apart in one client. - - A smart enum names a fault. It does not replace an error that carries data: a closed set of - shapes (`NotAmendable(Status)`, `OutsidePeriod(Date)`) is what C# 15 unions are for. Translating - one names a smart-enum member, and what varies per occurrence travels as - `Fault.Detail` (below). A spike must answer: - - **Which status for each kind**, and whether a host may change a kind's default. - - **The startup check holds**: a member with neither a kind nor an explicit mapping still stops - the host. - - **C# 15 unions**: whether a smart enum is best expressed as one, or stays a generated type with - static members. - - **Persistence**: an EF column by name, mapped by `.EntityFrameworkCore` as value objects are. - - **Fault responses a client can act on**, proposed 2026-10-12. Decision 38's response carries a status and the fault's bare name, and its success is - `200` or `204`. An application whose client shows a refusal to a person, creates resources, or - reads with an ETag has to leave `ToHttpResult()` for each of those endpoints. The libraries - surveyed on 2026-10-12 map centrally too, but none models `Location` or `ETag`: Ardalis.Result - 10.1.0, ErrorOr 2.1.1 and FluentResults. Ardalis and FluentResults allow a per-call status. RFC - 9457 §3.1.1 makes `type` the primary identifier, and §3.1.4 has `detail` help the client correct - the problem. Five additive parts, each opt-in, so decision 38's default response is unchanged: - - **A response per member**: `AddFaultResponses(f => FaultResponse.Status(409).WithType(uri))`, - an overload beside the `int` one, checked for every member at registration as that one is. It - adds `type` and `title`, which name the problem type and nothing of the input. - - **A sentence per occurrence**: `Fault(TFault Kind, string? Detail)`, a record struct in - the base package. `Result>` infers `TFault` from a type argument, which an - `IFault` constraint could not, since C# infers nothing from a constraint. The mapping and - the document stay keyed on the enum. The sentence is the application's, so "the problem names - the fault, never the input" becomes the default rather than a guarantee, and the README says so. - - **A status per endpoint**: `.WithFaultStatus(OrderFault.NameTaken, 422)` as endpoint metadata, - read by `FaultProblem`, the operation transformer and the startup check. The same fault can - mean 404 on one route and 409 on another (RFC 9110 §15.5.10 defines 409 by the target resource). - - **The success the endpoint chooses**: `ToHttpResult(order => TypedResults.Created(uri, order))`, - returning `Results>`, so ASP.NET still describes the success. An - ETag needs a result of the application's own, and this is the hook for it. - - **The body the host shapes**: the fault reaches `CustomizeProblemDetails` as a request feature - (`IFaultFeature`: the fault type, the member, the detail), so a host can add extensions such as - an error code without the package naming any, plus an option to qualify `fault` as - `OrderFault.NotFound`, since two enums can share a member name in one client's switch. - - A spike must answer, in [spikes/FaultResponses](../spikes/FaultResponses/README.md): - - **Native AOT** for each part. - - **One reading of a fault.** The mapping, the startup check and the document read a fault type - through one internal abstraction, never `Enum` directly, so an enum fault and a smart-enum - fault (above) take the same path. - - **Inference**: `ToHttpResult()` over `Result>` and over `Result` - resolves without type arguments and without an ambiguity between the two. - - **The document**: a per-endpoint status is listed for that operation only, a `type` URI appears - in it, and `fault` stays a string on the shared `ProblemDetails` component. A per-operation enum - is optional, never a union over every fault type, because a fault type added later would change - every operation's schema. - - **The startup check** refuses a `WithFaultStatus` for a fault type the endpoint does not answer - with, and a member that the mapping, its kind and the endpoint all leave unmapped. - - **MVC**: the endpoint metadata reaches the transformer, where finding 4 found - `PopulateMetadata` handed an empty service provider. - - **`CustomizeProblemDetails` sees the feature** under JIT and Native AOT, and the feature is - absent for every problem `ToHttpResult()` did not write. - - **`Patch`, a field of a JSON Merge Patch (RFC 7396)**, proposed 2026-10-11 for 1.x. It has - three states: absent, null, or a value. System.Text.Json cannot tell an absent property from a - null, so every PATCH endpoint rebuilds this type by hand. It is a wire type on purpose, beside - `Option`, which still refuses JSON (decision 15): `Option` says whether a value exists, `Patch` - whether a request mentioned it. A spike must answer: - - **`default` is a state.** The serializer calls no converter for an absent property, so - `default(Patch)` has to mean "absent". That makes this the one type of the package whose - `default` is meaningful. Is it exempt from CMTK0001 by its contract, and does that stay - clear of `[RequireCustomInitialization]`? - - **Native AOT.** A converter factory over an open `T` is `MakeGenericType`, which the refusing - converter avoided only because it never reads `T`. Find what works under a source-generated - context: an attribute per closed type, or a generated converter. - - **Value objects.** `Patch`: a JSON null and a value `Create` refuses each give the - response decision 39 measured. - - **The OpenAPI document.** The property is not required, and admits null exactly where `T` - does, in 3.1 and 3.0. - - **Applying it.** Without reflection over the target (§11): `patch.Name.IfPresent(v => order.Name = v)`, - not an `ApplyTo(entity)` that walks properties. Optionally an analyzer for a `Patch` that is - bound and never read. - - It belongs in the base package, its OpenAPI part in the ASP.NET satellite. It is not ASP.NET - Core 10's JSON Patch (RFC 6902, a list of operations). - - **The breaking reasons it waits for**, unchanged: Metalama 2027.0 stable (decision 4), Warnings - promoted to Errors (decision 34), and C# 15 unions (.NET 11, GA 2026-11-10). The compiler-made - union types have a public `object? Value` and are serialized by System.Text.Json, so - `Option`/`Result` take part in their pattern matching only after a spike shows neither returns. - - **Open: does 2.0.0 move to .NET 11?** Decision 20 is `net10.0` only, and C# 15 unions need - .NET 11. A 2.0.0 that uses them can target `net11.0` alone, which leaves a consumer on the .NET - 10 LTS line on 1.x. It can target both, with unions only behind `net11.0`, at the cost of a - second target in every satellite (EF Core 11, Microsoft.AspNetCore.OpenApi 11) and a Native - AOT smoke run per target. Or it can stay on `net10.0` and leave unions to a later major, which - also settles the smart-enum spike's union question. The hinge is whether consumers on the LTS - line get the fault responses, smart enums and `Patch` at all, and the preview below - answers it with measurements rather than in advance. - - **A preview to evaluate first** (proposed 2026-10-12): `2.0.0-preview.N`, built on the .NET 11 - SDK and the Metalama 2027.0 preview, on a branch of its own, before either is stable. It - measures what C# 15 unions and 2027.0 buy before decisions 4 and 20 move for the release. - `main` keeps both decisions meanwhile, and a 1.5.x fix still ships from it. The release - workflow takes a preview tag as it is, since `v2.0.0-preview.1` passes the tag check against - `Version` and the changelog check against a dated `## 2.0.0-preview.1` section. The SDK comes - from `global.json`, though, so the branch pins the .NET 11 SDK there. The preview must answer: - - **The .NET 11 question above**, from what the unions actually change in `Option`, `Result` - and the smart-enum family. - - **Metalama 2027.0 on the .NET 11 SDK**: the aspects, the fabric and the analyzers build and - weave, and `[Durable]` returns to the `_value` placeholder (decision 4). - - **Package validation in a major**: against the 1.5.0 baseline it reports the intended breaks. - Whether they are suppressed per break or the baseline lapses for 2.0.0 is decided there, so - an unintended break still fails the pack. - - **Where it is published**: nuget.org, where the suffix marks it a pre-release, or a local - feed for the evaluation alone. - -## 3. Discovery is by interface - -No namespace strings, assembly-name prefixes or type-name lists anywhere. A name-based check -passes in tests and breaks for the first consumer who renames something. - -| Where | How | -|---|---| -| Analyzer, value-object rules | `CompilationStartAction` resolves `CodoMetis.TypeKit.ValueObjects.IValue`1` / `IValidatedValue`3` with `GetTypeByMetadataName` and compares `OriginalDefinition` with `SymbolEqualityComparer`. If `CodoMetis.TypeKit` is absent, nothing is registered | -| Analyzer, `[RequireCustomInitialization]` | The attribute symbol is resolved the same way | -| Analyzer, CMTK0002 | The compilation references an assembly whose name is exactly `CodoMetis.TypeKit.Generators`, ignoring case as assembly names do. That is the package's own id, which a consumer cannot rename, and a prefix or suffix match never counts. The assembly is not strong-named, so the name is the part of its identity to compare; the version must not be | -| EF Core | EF asks the type-mapping plugin per CLR type, and it answers for any type carrying `GeneratedValueObjectAttribute` (§4). There is no type scan and no assembly filter | -| OpenAPI | The transformer reads `GeneratedValueObjectAttribute<,>` on the schema's `JsonTypeInfo.Type`, its element type, or a parameter's type or model metadata (§7). There is no referenced-assembly walk | - -Run-time code that holds only a `Type` reads the attribute rather than calling `GetInterfaces()` -(decision 17). Its type arguments are constrained to `IValueObject` and -`IValueObjectMaterializer`, so the interface still decides what a value object is; the -attribute is how a type says so after trimming, which removes an interface nothing uses and keeps -custom attributes. - -## 4. EF Core - -- **Materializer.** An interface in `CodoMetis.TypeKit.ValueObjects`: - `IValueObjectMaterializer { static abstract TSelf Materialize(T value); }`. The aspect - implements it **explicitly**, so it is invisible on the type's public surface and reachable only - through a constrained generic. Documented contract: *skips validation; for values this - application wrote itself; never call it on input.* CMTK0004 enforces who may call it. -- **One pair of conversions.** `ValueObjectConverter : ValueConverter`, public so a - property can also name it explicitly. Its expressions call its own public static - `ProviderValue` and `Materialize`: an expression tree cannot call a static abstract member - directly (CS8927), and EF's compiled model writes the expressions out as C# in the application's - assembly, where an internal helper did not compile (§11). `UseTypeKit()` composes a plain - `ValueConverter` over the same expressions, the type the compiled model recreates, because a - precompiled query casts a property's converter to its design-time type. -- **No per-type comparer.** A `readonly record struct` already has value equality, and EF's default - comparer uses it. A test pins that it agrees with comparing `.Value`. -- **Application (decided 2026-09-27, spikes/EfMapping).** `optionsBuilder.UseTypeKit()` registers - an options extension. The name is deliberately not value-object specific, so a later `Option` - column mapping can join it. It adds an `IRelationalTypeMappingSourcePlugin` that answers any - `IValueObject` with the provider's mapping for `T` and the converter composed onto it. - EF consults it per CLR type wherever it maps one: property discovery, keys, foreign keys, - primitive-collection elements, query parameters. The lookup's facets are passed on. The plugin - composes the JSON reader/writer itself, with both types known through the attribute's visitor: - EF's own composition uses `MakeGenericType`, which it refuses under Native AOT, where a precompiled - query still maps a value-object parameter at run time. - - Not a replaced `IValueConverterSelector`: it maps the same, but a second library replacing the - selector (as strongly-typed-id guides recommend) would take it away, and the resulting model - error does not name the cause. Plugins are additive. This was first decided the other way on - an unmeasured assumption, and reversed by the spike the same day. - - Not a pre-convention: `Properties()` needs the list of types up front, which means a scan. - - An application that builds EF's internal service provider calls - `AddEntityFrameworkTypeKit()` on that service collection instead. -- **Translators.** `ValueObjectMemberTranslatorPlugin` / `ValueObjectMethodCallTranslatorPlugin` - are registered by the same options extension, so a consumer wires nothing else. A column - operand is **re-typed** as the wrapped type, keeping the column's store type, rather than cast: - `Convert` produced `o."Code"::text = 'ABC'`, which PostgreSQL discards but which is not a no-op - everywhere (on SQL Server, a cast to `nvarchar(max)` can stop an index seek). Any other operand - is still converted. The method translator acts only on a method whose signature is the unwrap, - from a value object (or its `Nullable`) to what it wraps: the attribute is public, and a - hand-marked `Length(this ProbeCode)` was translated as the column (`o."Code" = '3'`). Any other - method carrying it is left to EF, which refuses the call. - - **The element of a primitive collection is converted, not re-typed (decided 2026-09-28).** EF - infers a collection's element mapping from the columns that read its table expression (`unnest`, - `json_each`, `OPENJSON`, `VALUES`), and a re-typed element column handed it the wrapped type's - mapping for a collection of value objects: `o.Tags.Any(t => t.Value == "x")` and - `ids.Any(i => i.Value == o.Id.Value)` threw during translation on PostgreSQL and SQLite. EF 10's - `ColumnExpression` no longer knows its table, so the element column is recognised by the name - every relational provider gives it, `value`; the name only chooses between two correct - translations, and a table column named `Value` gets the cast (`t.value::text = 'x'`). -- **Keys over an integer are generated on add (decided 2026-09-28, decision 23).** EF's value - generation convention reads the property's CLR type, so a value-object key was never generated: an - entity added without one was stored as 0, and switching an `int` key to a value object dropped its - identity column. A convention plugin (first among the model-finalizing conventions, so a provider's - identity strategy sees it) marks a single-column, non-foreign key over `int`, `long` or `short` as - generated on add, at convention precedence. A key over a `Guid` is left alone: the application - assigns `OrderId.New()`, and a generated key that is already set makes EF take a new entity reached - through a navigation for an existing one. -- **Conventions for the wrapped type do not reach a value object.** `ConfigureConventions` - (`Properties()`) matches the property's CLR type; the README says to configure the value - object (`Properties()`). Measured 2026-09-28 for precision, maximum length, column type and - an enum conversion. -- **Stored JSON (decided 2026-09-27).** The generated JSON converter applies `Create` (§5), which - is right for input and wrong for JSON the application stored itself (an event store, a document - column): a rule added later would make old documents unreadable. `StoredJsonConverterFactory`, in - the base package because it is a System.Text.Json concern, is the opt-in for such a store's - `JsonSerializerOptions`. It hands out the generated converter in a mode only it can create - (a private constructor), so stored documents are read in exactly the format they were written - in, dictionary keys included. SECURITY.md lists it beside the EF satellite as the two - validation-free paths. -- **How far the translation is unique, measured 2026-09-27** - ([spikes/ValueTranslation](../spikes/ValueTranslation/README.md)): - - `.Value` in a query predicate fails in Vogen 8.0.7 and in plain EF Core 10. Thinktecture - 10.5.0 has no public `.Value`. - - Querying the underlying value through a **conversion-operator cast** works in plain EF Core - 10, and that is how Thinktecture users get it. - - README claim: "`.Value` works in LINQ as it does in memory". Never "the only library that can - query by the underlying value". - -## 5. Aspects - -- **Aspect-class count.** Ten classes. The Metalama Open Source edition (MIT) has no aspect-class - limit (pricing page, 2026-09-26). The unlicensed CI build confirms it (phase 6). -- **Entry points.** `ValueObjectAspect` introduces three private helpers on every value - object, and they are the only way the JSON, parsing and type-converter aspects create an - instance: `__FromJson` (a refusal throws `JsonException`), `__FromText` (`FormatException`) and - `__TryFromText` (`false`). A plain value object constructs directly; a validated one calls - `Create` through `GeneratedFactories` in the base package. The exception is the explicit - `IValueObjectMaterializer.Materialize`, by contract. `MinValue`/`MaxValue` are only generated for - a plain value object. -- **Declarations that cannot be generated** are errors, so no type is left half-generated: - CMTK1000/1001/1002 (not `partial`, not a record, a struct not `readonly`), CMTK1003 (more than one - marker), CMTK1004 (a validated marker whose first type argument is another type), CMTK1005 (a - generic value object, one that derives from another value object, a wrapped type that is not - a class, struct or enum, or one that is a value object: itself, one that reaches it again, or - any other, see below), CMTK1006 (a record class that is not `sealed`: a derived record compares - equal only to its own type, which is not value equality), CMTK1007 (the name of the - `GetValue`/`ValueOrNull` class is taken, see below), CMTK1008 (a comparison member beside the - seam, see below), CMTK1009 (a hand-written constructor, see below). -- **No hand-written constructor (decided 2026-09-27).** A value object's one constructor is the - generated private one, and a hand-written instance constructor is CMTK1009. The implementation - aspect answers it before introducing its own, since it reads only its target: `Constructors` minus - the implicitly declared ones, measured to catch every form (the generated one's signature, any - other, one chained to the generated one, a struct's `X()`, a record's copy constructor, a - positional record's parameter list) and to leave the compiler's own and a static constructor - alone. The type's own members can still call the private constructor, as `Create` must: that is - the one trusted seam into a validated value object, stated on `IValidatedValue` and in SECURITY.md. -- **`Revalidate()` (decided 2026-09-28)** on a validated value object returns `Create(Value)`: today's - rules applied to an instance that was rebuilt without them, by the EF satellite or - `StoredJsonConverterFactory`. It is how an application finds the stored values a rule added later - refuses, which the validation-free read path otherwise hides by design. It returns what `Create` - returns, normalisation included, and a type that declares its own keeps it. -- **The companion class** holding `GetValue()`/`ValueOrNull()` sits at namespace level, as - extension methods must, and is named after the whole nesting chain: `Order.Id` gets - `OrderIdExtensions`. Named after the value object alone, `Order.Id` and `Customer.Id` both asked - for `IdExtensions` and Metalama crashed (LAMA0001). A name that a declared type or another value - object's companion already has is CMTK1007, naming it. -- **NodaTime.** Only `ValueObjectJsonAspect` handles NodaTime types, and it must not refer to - NodaTime at compile time, which would force NodaTime on every consumer. It resolves the types - with `TypeFactory.TryGetType` from the consumer's compilation, compares them by symbol, and uses - NodaTime's converters (values and dictionary keys) only when - NodaTime.Serialization.SystemTextJson is referenced too, and only where the options have no - converter of their own for the type, which wins as it does for the wrapped type. Parsing reaches - NodaTime types through their `[TypeConverter]` (`TypeDescriptor.RegisterType` and - `GetConverterFromRegisteredType`, the trim-safe lookup). -- **Explicit `IParsable`.** `bool` and `char` implement their parsing interfaces explicitly, so the - generated code calls the wrapped type's `TryParse` through `GeneratedParsing`, whose constrained - type parameters reach an explicit implementation. `Parse` is `TryParse` and a throw (below). -- **Parsing culture (decided 2026-09-27).** In the generated `Parse`/`TryParse` a null - `IFormatProvider` means the invariant culture, unlike the BCL, because the generated `ToString()` - is invariant: `Parse(x.ToString(), null)` must round-trip, and with the current culture it parsed - "1.5" as 15 in de-DE. A provider that is given is used as given. The type converter already did - this. -- **Formatting culture (decided 2026-09-27),** the counterpart: a null provider means the invariant - culture in the generated `ToString(format, provider)`, both `TryFormat`s and `IConvertible`. - Interpolation, `string.Format` and `Convert.ToString` pass null, and with the current culture - `$"{amount}"` was "1,5" in de-DE while `amount.ToString()` was "1.5". Display text in a culture - passes it: `string.Create(culture, $"{amount}")`. -- **The wrapped value's JSON contract** comes from `GeneratedJson.TypeInfo(options, builtIn)` - (decided 2026-09-27, §11): the options' own contract when their resolver has one, so a host's - number handling and converters apply, and otherwise made in the serializer's order (a converter on - the options, the type's `[JsonConverter]`, the built-in converter the aspect names by its property - type). A source-generated context never sees what a value object wraps, and serializing through - the options needs reflection. A dictionary key goes through the same contract's converter. -- **The bytes are the wrapped type's (decided 2026-09-28, decision 26).** Every wrapped-type family - writes and reads its values and keys through `GeneratedJsonPlan`, the converter the options have - for `T` or else the built-in (or NodaTime's) one: exactly what the serializer writes for a `T`, so a - host's converter, number handling, enum converter and `DictionaryKeyPolicy` apply, and swapping a - primitive for a value object changes no JSON. The earlier per-family readers and writers wrote - `TimeOnly` as `"13:45:30.0000000"` and `DateTime` keys with seven fractional digits, ignored a - host's converter for `Guid`, `DateTime` and integer keys, and skipped the key policy. - `JsonParityTests` compares a value object with its wrapped type for 14 families under five option - sets, as a value, a property and a key. -- **The wrapped value is written and read by its converter directly** (decided 2026-09-28), wherever - that is what the serializer would do: a value converter, and, for a number type only, no number - handling that changes the output (`WriteAsString`, named literals) or, for a string token, the - input. Otherwise through the serializer, which alone applies number handling. A nested - `JsonSerializer.Serialize`/`Deserialize` per value cost 35–40% (measured). The plan is kept per - converter instance for the options it last saw; a static plan per wrapped type cost 8% on writes. - The enum's built-in converter is made once per plan: made per call it allocated 4.7 KB per value. -- **Formatting and comparison never box** (decided 2026-09-28). The wrapped type's `ToString`, - `TryFormat` and UTF-8 `TryFormat` are called through `GeneratedFormatting`, a constrained generic - call that also reaches an explicit implementation, and comparison through `Comparer.Default`, - which the JIT specialises per type and, for an enum, compares the underlying values. Casting the - value to the interface boxed it; an optimising JIT hid that for some types and not for others - (enums), so the allocation tests run on a Debug build, where it never does. -- **JSON reads never parse by rules of their own.** A value and a key are read by the plan's - converter, which accepts exactly what the serializer accepts for the wrapped type. A `Parse` in - the generated code once let `FormatException` escape, which a minimal API answers with 500 rather - than 400, and read number keys with `NumberStyles.Any` ("1,000" as 1000). -- **What the wrapped type cannot read is refused without quoting it (decided 2026-09-28, decision - 27).** The BCL's and NodaTime's parse messages quote the input ("The input string 'SECRET' was not - in a correct format."), and a malformed NodaTime JSON value carried it in an inner exception. A - generated `Parse` is the wrapped type's `TryParse` and a `FormatException` naming the value object - and the wrapped type; where there is no `TryParse` (a static `Parse`, a string constructor, a type - converter) the exception is replaced, not wrapped; a JSON read throws `JsonException` the same way. - An overflow is a `FormatException` too. `UnreadableInputTests` feeds a secret through every strategy - and entry point and searches the whole exception chain. Out of reach: `JsonException.Path`, which - the serializer fills with an unreadable dictionary key, and ASP.NET's own binding messages. -- **`Uri` parses relative or absolute (decided 2026-09-28)**, as JSON and `UriTypeConverter` read it: - `new Uri(s)` made "/orders/7" a `file://` URI on Unix and threw for "orders/7". -- **A hand-written `ToString()` is a seam (decided 2026-09-28, decision 28).** It was replaced - silently, so a value object that hid a secret printed it. It is kept, and then no formatting - interface (`IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`) is generated, since - interpolation would reach those first; the type converter and JSON still write the wrapped value. -- **More declarations refused (decided 2026-09-28).** Each failed as a Metalama error naming nothing - the user wrote, or not at all: - - CMTK1010: a value object declared in more than one `partial` part. Metalama 2026.1 (and 2027.0 - preview) writes the override of the record's synthesized `ToString` into every part (CS0111 as - LAMA0611); no other advice overrides it (LAMA0041, LAMA0509, LAMA0500 measured). A part a source - generator adds is not in `Sources` and does not count. The repro for an upstream report is in the - phase 10 notes below. - - CMTK1011: a hand-written member or attribute the generators introduce (`From`, `Value`, - `Parse`/`TryParse`, `MinValue`/`MaxValue`, `[JsonConverter]`, `[TypeConverter]`, the interfaces), - checked before anything is introduced, as CMTK1009 is; the seams (`TryFrom`, `FromKnownGood`, - `Revalidate`, `CompareTo(TSelf)`, `ToString()`) stay allowed. Some of these were introduced with - `OverrideStrategy.Ignore` and kept silently: a hand-written `Parse` became the binding entry - point. `Create` implemented explicitly is CMTK1011 too, since the generated code calls - `{Type}.Create`. Since phase 11 also a hand-written `Equals(TSelf)`/`GetHashCode()`/ - `IEquatable.Equals`, `PrintMembers` without `ToString()`, an explicit implementation of an - interface the generators implement (CMTK1008 for the comparison interfaces), and a declared - `IConvertible`. - - `Create` is declared once, public: an explicit one beside it is CMTK1011, since a generic - `T.Create` would reach a second rule set. A base record's members count as the value object's - own: its `sealed` `ToString()` is the seam; an unsealed one, its `PrintMembers`, its explicit - implementations of the generated interfaces and an `IConvertible` on it are CMTK1011, or CMTK1008 - for comparison. Members the compiler generated on a record from another assembly do not count. - - CMTK1012: state besides the wrapped value, an instance field, auto-property, `required` member or - field-like event, declared or inherited. JSON, parsing and the materializer carried the wrapped - value alone while equality compared the rest. Computed and static members stay allowed. In a - referenced assembly a base's private fields are invisible, so a writable property counts there. - - CMTK1005 adds a generic wrapped type, tuples included (its equality is not value equality: a - `List` compares by reference), a value object named `Value` (CS0542), and a `file`-local one - (LAMA0001, which failed the whole project). -- **A `DateTime` is UTC on the wire (decided 2026-09-27).** Written and read, value and key, through - `GeneratedJson.AsUtc`: a `Local` value converts, and an `Unspecified` one (an offset-less string, a - column without a time zone) is taken as UTC with its digits kept. `ToUniversalTime()` read it as - server-local, so 12:00 became 10:00Z on a server at +02:00 and 12:00Z on CI. The guards switch the - process to a +05:30 zone, since CI runs in UTC. -- **`From(null)` throws** `ArgumentNullException` for a reference-type wrapped value, as - `Option.Some(null)` does. It wrapped a null that `Value` promises it never holds. -- **Enums** parse by name through `Enum.TryParse`, with a `FormatException` for an unknown name. - `Convert.ChangeType` cannot make an enum from a string, so the former `IConvertible` strategy - threw for every input while the type advertised `IParsable`. Nothing else that is convertible - lacks `ISpanParsable`, so that strategy is gone. -- **String comparison is ordinal**, so ordering agrees with the record's equality. Culture - comparison sorts "a" before "B" and ignores a zero-width space, and a `SortedSet` then dropped a - value a `HashSet` kept. -- **A nullable wrapped type is CMTK1005.** The markers' `notnull` constraint is only a warning - (CS8714), and `IValue` then failed inside the generated code as LAMA0611/0612. -- **A value object never wraps a value object (decided 2026-09-27).** CMTK1005, answered in the - fabric because it reads the wrapped types (see "An aspect never scans its namespace" below). - - **A cycle** (`sealed partial record SelfWrap : IValue`, or A wraps B wraps A) has no - finite form: the fallback JSON converter serializes the wrapped value through the options, - which is its own converter again, and a schema walk never ends. As a record class it compiled - without a word (spikes/OpenApiSchemas finding 16), as a struct it failed inside the generated - code (CS0523 as LAMA0611). The message names the chain: `it wraps itself (A -> B -> A)`. - - **Any other value object** (`Outer : IValue`) terminates, but its surface depended on - where the inner one was declared. Over one from a referenced project it got every interface; - over one from its own project it silently lacked `IParsable`, `IComparable`, `ISpanFormattable`, - `IConvertible`, `IMinMaxValue` and the type converter, and sorting it threw: the aspects of one - layer do not see what their sibling instances introduce (measured 2026-09-27). The message - names what to wrap instead (`wrap 'Guid' instead`); a validated value object applies the inner - one's rules in its `Create`. Refused rather than made to work: a surface that follows the - project layout is the silent failure this repo exists to prevent, and lifting a refusal later - breaks nobody, while adding one after 1.0.0 would. - - The satellites keep their own refusal (§7): a value object declared by hand, with the - interfaces and `GeneratedValueObjectAttribute<,>` the satellites read (§3), can still wrap itself. -- **Every JSON key goes through the wrapped type's own converter**, which knows the type's key - format where it has one (an enum by name, a `Uri` as its text) and throws `NotSupportedException` - where it has none. Writing the serialized value as the property name gave a `Uri` key quotes inside - its quotes and an enum key its number, and neither read back. -- **A record class sorts null first.** Its generated `CompareTo` and comparison operators take a - nullable operand, as `IComparable.CompareTo(T?)` and the record's own `==` do, and treat null - as smallest, where they threw `NullReferenceException`. -- **Ordering follows the wrapped type.** `CompareTo` delegates to the wrapped type's, ordinal for a - string, so it agrees with equality exactly where the wrapped type's own does. A custom wrapped - type whose `CompareTo` returns 0 for values its `Equals` tells apart makes a `SortedSet` drop one, - of the value object and of the wrapped type alike (measured 2026-09-27). That is the wrapped - type's contract to keep, and the marker interfaces' documentation says so. -- **One comparison seam (decided 2026-09-27).** A hand-written `CompareTo(TSelf)` is kept, and the - object overload, the operators and the interfaces are derived from it, as `TryFrom` is for the - factories. Any other hand-written comparison member is CMTK1008: an operator failed the aspect - (LAMA0500, naming no fix), and an object overload was kept silently beside a generated generic - one that need not agree with it. -- **An aspect never scans its namespace (decided 2026-09-27, - [spikes/ConcurrentNamespaceTypes](../spikes/ConcurrentNamespaceTypes/README.md)).** What owns - a companion name is answered in the fabric, before any introduction, and reaches the aspect - through its constructor and the aspect state. Anything that has to look beyond the aspect's own - target goes the same way. -- **Never look up the code model by name (decided 2026-09-28, same spike).** Compile-time code - finds a type or member by enumerating a collection and comparing names: no `OfName`, no - `OfExactSignature`/`OfCompatibleSignature`, no string indexer on `Fields`/`Properties`/`Events`. - Metalama builds a collection's by-name index lazily and without a lock, and runs the fabric's - factory, like the instances of one aspect layer, in parallel on one code model. An `OfName` that - overlapped another caller completing the collection returned nothing for a declared - `TakenNameExtensions`, and the build compiled a second one (CS0260) or Metalama failed the aspect - (LAMA0531). In the fabric that missed CMTK1007 in 13 of 300 builds; the move there on 2026-09-27 - had measured 0 in 48 by chance and blamed sibling introductions, which was wrong. With concurrent - build off: 0 in 200. Enumerating, which takes the collection's lock: 0 in 600, and 4 in 150 - again with it reverted. The race has no deterministic test, so `CodeModelLookupTests` - (conventions) fails on any such lookup in `.Generators`. -- **Metalama 2026.1.** Aspect state uses `IDurableRef`, which exists in 2026.1. `[Durable]` on the - `_value` template placeholder is 2027.0-only and stays out until the upgrade (decision 4). Build - each aspect on 2026.1 as it lands, and use no 2027.0-only API. -- **`AspectOrder`** lives in `CodoMetis.TypeKit.Generators`. Open point: ordering against a - consumer's own aspects (fabric spike D). -- **Not in scope:** a `Try`/`Unit` type (§9 non-goals), clock-type analyzers, text-guarding types, - and decimal JSON converters (no aspect needs them, checked in phase 3). - -## 6. Test strategy - -1. **Behaviour.** Every public member of `Option`/`Result` and every generated member has a - behaviour test. Generated members are tested against woven probe types, never against code - written by hand in the test assembly. -2. **Generated surface.** A small tool reflects over the woven probe types. For each value object - it renders the public members, implemented interfaces and attributes as sorted text, which is - committed and asserted. An aspect change that adds or loses a member then shows up in review - instead of shipping silently. Once 1.0.0 ships, package validation against the last release - guards the hand-written surface too. -3. **Generated SQL.** `ToQueryString()` snapshots for `.Value`, `GetValue`, `ValueOrNull`, - `StartsWith`, equality, and `Contains` over a list of ids. -4. **OpenAPI.** A probe host's emitted document, asserted per shape against a control that uses - the wrapped type directly, so the tests follow what ASP.NET publishes for that type (§7). -5. **Performance.** Allocation tests hold the generated members to what the wrapped type allocates, - and `benchmarks/` measures time against the wrapped types. The benchmarks are not a gate: they are - run before a release and when a template changes. -6. **README samples** (2026-09-28). Every C# block of the READMEs is a region in - `test/CodoMetis.TypeKit.Samples`, which references the shipping projects as a consumer does, with - the analyzer on. `ReadmeSampleTests` requires each block to equal exactly one region and each region - to be shown, so a sample that stops compiling fails the build and an edit on one side fails the test. - An output stated in a comment is asserted against the README's own comment, found by the expression - the test evaluates. A pre-release audit had found samples that did not compile (a bare fault as a - `Result`, `quantity.Value;` as a statement, an ellipsis as a lambda body), CMTK0006 on the - EF entity sample, and SQL the query does not produce; the consumer smoke test had found the first - once before (§9). Seeded: a README or sample edited alone, the bare fault back in both, the committed - READMEs, a changed SQL, JSON or string output, an opt-out on a compiled block or without a reason, an - unlabelled fence, and either parser finding nothing each fail their test. - -## 7. OpenAPI - -**Decided 2026-09-27, measured in [spikes/OpenApiSchemas](../spikes/OpenApiSchemas/README.md)** -with Microsoft.AspNetCore.OpenApi 10.0.12. An earlier measurement had found that a schema -transformer never reached a type that only appears as a property, and repaired such types in a -document transformer. It does not hold in 10.0.12: one schema transformer reaches every JSON -position, and the change lands in the hoisted component. - -- **The schema is the wrapped type's, as ASP.NET publishes it for this host**, asked of ASP.NET - through `context.GetOrCreateSchemaAsync(typeof(T))`. It therefore follows the host's JSON options - (number handling, enum converter) exactly as the generated converter does, and the document's - other transformers (a NodaTime transformer describes a value object wrapping `Instant` too). No - format table of our own. -- **Keywords are filled in, never the object replaced**: the transformer receives the instance ASP.NET - hoists into the value object's own component, so `OrderId` stays a named component. What the - value object's schema already says is kept. ASP.NET's `Metadata` (the reference id) and the - schema-identity keywords are never copied; copying `Metadata` made every use a reference to the - wrapped type's component. A keyword is assigned only when the wrapped schema has one. -- **Containers**: ASP.NET drops `items`/`additionalProperties` for a converter-backed element before - any transformer runs; the value object's own schema is put back, only where missing. -- **Parameters** bound through the generated `TryParse` reach the transformer as `string`, ASP.NET's - placeholder for any parsable type, which the wrapped type's schema replaces. Minimal APIs name the - value object in the parameter's `Type`, MVC in `ModelMetadata.ModelType`; the parameter descriptor - names the container for an MVC `[FromQuery]` object and is not used. Each parameter then publishes - exactly what a parameter of the wrapped type publishes. -- **A value object that reaches itself** through what it wraps is an `InvalidOperationException` - naming the chain, not a stack overflow. -- **Source-generated JSON** (§11): ASP.NET builds the wrapped type's schema from the host's JSON - contract for it, and a source-generated context has none for a type the host never serializes. - The transformer then refuses with an `InvalidOperationException` naming the value object and the - `[JsonSerializable(typeof(Guid))]` to add, rather than the serializer's message about a Guid. A - resolver of our own cannot be added: the host's options are read-only by then. -- **Component names (decided 2026-09-28, decision 24).** ASP.NET names a component after the type's - simple name, so `Shop.Id` and `Stock.Id` shared one, and a stock id wrapping an `int` was documented - as a uuid. `AddTypeKit()` wraps `CreateSchemaReferenceId`: a nested value object that got ASP.NET's - default name is named after its nesting chain (`ShopId`, as its companion class is `ShopIdExtensions`); - a name the host's own delegate chose, or `null` for an inlined value object, is kept. -- **Known gaps, documented rather than fixed:** a validation attribute on a value-object property - lands in the shared component, as ASP.NET does for any referenced schema (rules belong in `Create`); - a property absent from a request body stays `default`, since System.Text.Json calls no converter - for it (`required`, `RespectRequiredConstructorParameters`); a minimal API answers a refused body - with an empty 400; MVC's own binding message quotes the input, as it does for a `Guid`. -- **ASP0020** (decision 12): a minimal-API route parameter whose value object is declared in the - same project fails the build, because the route analyzer reads the source before Metalama weaves - `IParsable` in. Binding is correct at run time and in the request delegate generator. A - `DiagnosticSuppressor` has no effect under Metalama's compiler, and Metalama's own suppression is - scoped to the aspect's targets, so the package README documents the pragma instead. - -Vogen 8.0.7 generates a schema transformer for Microsoft.AspNetCore.OpenApi too, from a table of -wrapped types (checked in its source at the tag, 2026-09-28; the 2026-09-27 note here said -Swashbuckle only). The README describes what the package does and claims nothing about other -libraries. - -## 8. Decisions - -1. **License: MIT** (2026-09-26). -2. **Name: `CodoMetis.TypeKit`** (2026-09-27), replacing `CodoMetis.ValueObjects`, which was too - narrow. Package map in §1. -3. **Analyzer ids: `CMTK`** (2026-09-27). Ids are public contract: a rule keeps its id even if it - later moves to another package. -4. **Metalama 2026.1.x (latest stable)** (2026-09-26), not the 2027.0 preview. - - Aspect state keeps `IDurableRef`, which exists in 2026.1. - - `[Durable]` on the `_value` template placeholder is omitted, because it is 2027.0-only, and - is added back on upgrade. - - A 2026.1-built package works in a 2027.0 consumer (fabric spike finding 11). - - Once 2027.0 is stable, upgrading is a planned task: MIT maintenance covers only the latest - `YYYY.N`. - - **Metalama is the generator technology for this project**, not a candidate for replacement. -5. **Option/Result** (2026-09-27): pure `Option`/`Result`, not a LanguageExt-style framework. - They ship in the base package. No `Try`/`Unit` (§9). -6. **`Result` shape** (2026-09-27): `Result` (value first), `readonly record struct`, - private fields, non-positional (§9). -7. **Analyzer as its own package** (2026-09-27), reaching consumers through the base package (§10). -8. **An uninitialized `Result` throws** (2026-09-27) instead of reporting `default(TError)` (§9). - -9. **EF mapping through an additive `IRelationalTypeMappingSourcePlugin`** (2026-09-27), not a - replaced `IValueConverterSelector` or a pre-convention scan (§4, spikes/EfMapping). -10. **Stored JSON through `StoredJsonConverterFactory`** in the base package (2026-09-27, §4). -11. **OpenAPI through one schema transformer** that fills a value object's schema in from the - wrapped type's, as ASP.NET publishes it (2026-09-27, §7, spikes/OpenApiSchemas), not a document - transformer and not a format table. -12. **ASP0020 is documented, not suppressed** (2026-09-27, §7): the package cannot suppress it, and - a blanket suppression would hide the check for every other type. -13. **The first release's rule set is CMTK0001–CMTK0009** (2026-09-28, §10; CMTK0009 added the same - day, at Warning, for the calls that hand out a default). CMTK0004 ships at Error - in 1.0.0, the one release where a new Error rule needs no major version; CMTK0003, CMTK0005 and - CMTK0006 at Warning, CMTK0006 once its fix was measured; CMTK0007 at Info, reporting only a - value that comes straight from a parameter. -15. **`Option`/`Result` are not wire types** (2026-09-27, §9): a `[JsonConverter]` on every exported - struct of the base package throws `NotSupportedException` in both directions. Not a lossless - converter, and not an analyzer. -16. **Native AOT wherever the platform allows it** (2026-09-27, §11): the run-time packages build with - `IsAotCompatible`, the woven code is published with Native AOT in the smoke test, and a path that - needs reflection or dynamic code is rewritten rather than annotated or suppressed. EF Core itself - stays experimental under Native AOT; the satellite adds nothing to what EF requires. -17. **Run-time discovery reads `GeneratedValueObjectAttribute`** (2026-09-27, §3, §11), - which the generators put on every value object, not `GetInterfaces()`. Measured under Native AOT: - the interface was gone from every value object reached through a property, and the OpenAPI - satellite described each as `{}`. The attribute's constraints are the interfaces, so what a value - object is has not changed; its `Accept` hands the two types to a visitor as type arguments, which - is how the EF satellite makes a converter without `MakeGenericType`. -18. **One name per concept** (2026-09-27, before the first release, when renaming still costs nothing). - - What a value object holds is *wrapped*: `GeneratedValueObjectAttribute.WrappedType` and - `TranslatedAsWrappedValueAttribute`, not "underlying", and not `ValueType`, which in .NET means - a struct and here returned `typeof(string)` for a string-backed value object. - - A value object without rules is *plain*: `IPlainValueObject`, which has `From`. It was - `IValueWrapper`, which named what every value object does. - - The value object's own type parameter is `TSelf` on the self-referencing contracts, as in - `IParsable`, and `TValueObject` where a helper or attribute names one. Never `TValue`, - which `KnownGood` used for the value object while "value" means the wrapped value everywhere - else. - - Delegate parameters: `onSome`/`onNone`, `onSuccess`/`onError`, `selector`, `predicate`, - `action`, as LINQ names them. They were `fnSome`, `map`, `fn`, `check` and `defaultProvider`. - - Namespaces follow who uses a type (§1). The helpers only generated code calls moved to - `CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense, and `Accepted` and `KnownGood` - became one `GeneratedFactories` whose methods name the exception each entry point throws. - - Internally: `ValueObjectAspect` (was the implementation aspect), `ValueObjectContractAspect`, - `ValueObjectCompanionAspect`, `ValueObjectKind.Plain`. -19. **Concise, not implicit** (2026-09-27, §9). Markers where a target type exists, explicit type - arguments where none does. - - One word for success: `Result.Success(...)` beside `Result<…>.Success(...)`, the `Success` - markers, `ResultState.Success` and `onSuccess`, as `Option.Some` sits beside `Option`. - `Result.Ok` was the one place that said it differently. - - `Option.None()` returns a marker that converts to any `Option`; `Option.None()` stays for - `var`. The analyzer forbids `default(Option)`, so every None needed its type argument. - - `MapError` on both shapes, for crossing layers, which needed - `Match>(x => x, e => Result.Error(…))`; and `Bind(Func>)`, a - command after a query, which replaces the collapsing `Match(_ => Result.Ok(), e => e)`, a - conversion wearing `Match`'s name. - - `Option.ToResult(error)` keeps the value (`Result`); it returned `Result`, - dropping it. `Coalesce` is `Or`, beside `OrDefault()` and `OrNull()`. - - Not added: an implicit `T` → `Option`, since a null would throw exactly where a reader - expects `None`; a bare error → `Result` (the ambiguity above); a shipped - `using static`. A lambda that returns a bare value and `Result.Error(...)` still needs its type - argument, because C# infers a lambda's return type from its body alone; the README says so. - -20. **net10.0 only** (2026-09-28). The satellites cannot go lower (EF Core 10 and - Microsoft.AspNetCore.OpenApi 10 require it), the contracts use static abstract interface members, - which rules out netstandard2.0, and .NET 8's support ends on 2026-11-10. -21. **The first release is 1.0.0** (2026-09-28), not 0.1.0. What the reasoning above says of "the - first release" (CMTK0004 at Error, refusals that can be lifted later) holds for 1.0.0 unchanged. - From then on the public API, the generated surface and the rule ids and severities follow - Semantic Versioning, and package validation compares every pack with 1.0.0. -22. **Registration extensions live in the framework's namespaces** (2026-09-28): `UseTypeKit()` in - `Microsoft.EntityFrameworkCore`, `AddEntityFrameworkTypeKit()` and `OpenApiOptions.AddTypeKit()` in - `Microsoft.Extensions.DependencyInjection`, as EF's providers and `AddOpenApi` do, so the one line a - host adds needs no `using` (the docs audit found every setup sample missing one). Types stay in - `CodoMetis.TypeKit.*`. Moving them after 1.0.0 would break every host. -23. **A value-object key over an integer is generated on add; one over a `Guid` is assigned** - (2026-09-28, §4). Parity with the wrapped key where only the database can make one, and the - application's `OrderId.New()` where it can. -24. **Nested value objects are named after their nesting chain in the OpenAPI document** - (2026-09-28, §7), as their companion classes are. -25. **The analyzer's Roslyn floor stays at 5.0.0** (2026-09-28), the compiler of the 10.0.1xx SDK band, - which is serviced for all of .NET 10 and is what Linux distributions build. Measured: built on - 5.9.0 (SDK 10.0.4xx) and run by the 5.0.0 compiler, the analyzer is not loaded (CS9057, a warning) - and `default(Option)` compiles. `AnalyzerPackagingTests` reads the packed analyzer's - references. The build-time `Microsoft.CodeAnalysis.Analyzers` follows the SDK (5.9.0). -26. **A value object's JSON is its wrapped type's, byte for byte** (2026-09-28, §5), under whatever - options the application uses: swapping a primitive for a value object changes no wire format. -27. **No refusal quotes the input** (2026-09-28, §5), not even one the wrapped type raises: every - generated `Parse`, type converter and JSON read replaces the wrapped type's exception with one that - names the value object and the wrapped type. -28. **Seams are declared, everything else refused** (2026-09-28, §5): `TryFrom`, `FromKnownGood`, - `Revalidate`, `CompareTo(TSelf)` and `ToString()` may be written by hand and are kept; any other - member or attribute the generators introduce is CMTK1011, never kept silently. Since phase 11 so - is an explicit implementation of an interface they implement, a hand-written equality, and - `PrintMembers` without `ToString()`, declared or inherited from a base record, whose `sealed` - `ToString()` is the seam; state besides the wrapped value is CMTK1012. -29. **Only `Result` converts to `bool`** (2026-09-28, §9). On a valued result the conversion - said whether the operation succeeded where a reader expects the value: `if (await - IsEmailTakenAsync(email))` over `Result` took the branch for `Success(false)`. The - same reason keeps a bare error from converting into one. Match it, or compare `State`. -30. **Asynchronous callbacks return `Task`** (2026-09-28, §9). `TapErrorAsync(Func)` sits - beside `Action`, as `TapAsync` already did. It ships in 1.0 because adding it later - rebinds existing calls. No `ValueTask` overloads: beside the `Task` ones every `async` lambda - becomes ambiguous (CS0121, measured). -31. **No extension on `object` or on an unconstrained `T`** (2026-09-28, §9): it is offered on every - expression of every file that imports the namespace, and collides with anyone else's. - `No_extension_member_extends_every_type` holds it. -32. **A value object holds its wrapped value alone, and its equality is that value's** (2026-09-28, - §5). Everything generated (JSON, parsing, the type converter, the EF column, comparison) carries - the wrapped value, so other state (CMTK1012), a hand-written equality or an explicit - implementation beside a generated member (CMTK1011, CMTK1008) would make two views of one value - disagree. Normalise in `Create` instead. -33. **The analyzers run on the code Metalama transformed** (2026-09-28, §10), through a - `MetalamaTransformedCodeAnalyzer` item in the analyzer package's `buildTransitive` props. The - namespace covers every rule, a future one included. -34. **Errors are fixed within a major version; Warnings may learn** (2026-09-28, §10). A new Error, or - an Error that reports more, waits for a major version. A Warning or Info may report more forms in - a minor version. The CMTK0003, CMTK0008 and CMTK0009 gaps the second review found were closed - before 1.0.0 was tagged all the same, so the rules ship as complete as the review could make them. -35. **SQLite's autoincrement is the application's line, not the convention's** (2026-09-28, §4). EF's - SQLite provider decides by the CLR type, and the only other way in is an annotation typed by the - SQLite assembly: setting it means naming the provider or writing a foreign annotation into every - other provider's model. The README documents `UseAutoincrement()`; `MigrationSnapshotTests` fails - once EF fixes the check, which is when the note can go. -36. **A nullable value object admits null in the OpenAPI document wherever it appears** (2026-09-28, - §7): as a property, as a container's element (`oneOf: [null, component]`, ASP.NET's own form), - and inlined (null added to `type`, or `oneOf` around an inlined enum). -37. **A refusal's message is its landing page** (2026-09-28): an exception has no help link, so every - deliberate run-time refusal says what to do instead and ends with a link to its subsection of the - base README's "Why does this throw?", and every generator build error with one to the Build errors - table. The links are held to the README's headings by tests. Nothing of the input is added. - -38. **A `Result` becomes a response through an explicit `ToHttpResult()`, its faults mapped in the host** - (2026-10-11, phase 12, spikes/ResultHttp). The return type is ASP.NET's - `Results, FaultProblem>`, so ASP.NET describes the success and the Request Delegate - Generator needs nothing of ours; a handler returning the `Result` itself would document a `Result`. - The fault type is an **enum**: its members are what a mapping can be checked against when it is - registered, and what the document lists. A fault of another shape is `MapError`ed to one at the - endpoint. The extensions live beside the framework's (`ToHttpResult` in `Microsoft.AspNetCore.Http`, - `AddFaultResponses` in `Microsoft.Extensions.DependencyInjection`, decision 22). A missing mapping - stops the host from starting (a startup filter, which comes with the first `AddFaultResponses`), a - value no member names is refused rather than answered, and the README's mapping ends in a throwing - discard arm: the registration calls it for every member, so a missed member still fails at startup, - and without the arm C# reports CS8524. - -39. **A value object's fault does not become a validation error** (2026-10-11, phase 12, - spikes/ValidationFaults). ASP.NET Core 10's validation never sees a value that failed to bind, a - refused route or query value has lost its fault, and MVC already reports a refused body with the - converter's message. What remained, a minimal-API body, needs `ThrowOnBadRequest` for the whole - host, an exception handler in its pipeline, and the fault carried as data by a new public type in - the base package: too much host-wide change for one of four positions. Reopen it if ASP.NET hands - a binding failure to a hook without `ThrowOnBadRequest`. -40. **`CodoMetis.TypeKit.Testing` holds assertions that hand back the content** (2026-10-11, phase 12). - `ShouldBeSome()`, `ShouldBeSuccess()` and `ShouldBeError()` return the value or the error, so the - test compares it with the assertions it already uses, and the package depends on no test framework: - a failure is a `TypeKitAssertionException`, which every runner reports. A failure names the error - it found and never prints a value, as `Option` and `Result` do not; an uninitialized result fails - every assertion as uninitialized, since `default(TError)` would otherwise read as an expected - first-member fault. **No generators**: one that goes through `Create` is a single filter over the - test's own property-based library (`Gen.String.Where(s => Email.TryFrom(s).IsSome())` in CsCheck), - and shipping it would tie the package to one such library. The package is new in 1.5.0, so its - project clears the package-validation baseline while that is 1.0.0, a condition that lapses on its - own when the baseline moves. - -41. **A sensitive value object is one that declares `ToString()`; Serilog gets a satellite** (2026-10-11, - phase 12, spikes/SensitiveValues). The seam already masks every formatting and - Microsoft.Extensions.Logging path, so a marker would generate the one line a user writes, and is - not added. Serilog's `{@x}` read the public `Value` of every value object, masked or not: - `CodoMetis.TypeKit.Serilog` registers one destructuring policy, `Destructure.WithTypeKit()`, that - logs a value object as a scalar of its `ToString()`, recognised by `GeneratedValueObjectAttribute`. - Every value object is then a string in the event, as Microsoft.Extensions.Logging writes it, rather - than its wrapped value's JSON type: what it prints is the one rule, and the policy cannot tell a - masked `ToString()` from the generated one. Serilog's floor is 4.0.0, the first of the current - major, and the tests run on it. A classification on the type is advised against: the logging - generator refuses the method (LOGGEN035). - -Still open: - -14. **context7.json.** The sibling repos register one. Add it once the repo is public. - -42. **2.0.0 is the next release, and it bundles the 1.x candidates** (2026-10-12, phase 13). The - fault responses, `Patch` and the smart-enum family ship together in the major that already - waits for Metalama 2027.0 stable, decision 34's promotions and C# 15 unions. A 1.6.0 first was the - alternative. Every part is additive, so it would have been a minor version, but the fault mapping - would have been designed on enums with `FaultKind` attributes read by reflection, then rebuilt - once smart enums exist. The cost is time: nothing ships before the later of .NET 11's GA and - Metalama 2027.0 stable. - -## 9. Option and Result - -`Result`, `Result` and `Option`, with the `Option.None()`, `Result.Success()` and -`Result.Error()` markers (decision 19), ship in `CodoMetis.TypeKit`. The value-object contracts -depend on them: `IValidatedValue.Create` returns a `Result`, and the generated `TryFrom` returns an -`Option`. - -**Non-goals.** No Either, no Validation applicative, no effect or IO types, no higher-kinded type -emulation, no immutable collections, no `Try` or `Unit`. Anything that turns this into a second -LanguageExt is out. - -**Shape, decided 2026-09-27.** -- **`Result`, value first.** Error-first (`Either e a`) exists in Haskell and Scala - only because a generic type there can be filled in from the left alone, which C# cannot do. - Rust, F#, Swift, DotNext, CSharpFunctionalExtensions and the csharplang unions design note all - put the value first. `IValidatedValue` does too. -- **`readonly record struct`, non-positional, private fields.** The synthesized equality is - correct (`State` included). The synthesized `ToString` prints only public members: - `Option { }` and `Result { State = Error }`. So it never prints a value, in line with - `FromKnownGood`'s message. **Never make these types positional:** positional parameters become public - properties, which reintroduces `.Value` and puts the value into `ToString`. -- **The markers** (decision 19): `Option.None()`, `Result.Success()`, `Result.Success(x)` and - `Result.Error(e)` return a small struct that converts implicitly to whatever `Option`/`Result` - the target is, so a method returns one without spelling out type arguments. They keep their - content internal too, so they print `Success { }` and `Error { }`. Only the implicit conversions - read it. Both shapes accept both result markers: `Result` converts from `Result.Error(e)` - as well as from a bare error, so a method can `return Result.Error(fault);` whichever shape it - returns. No combinator takes a marker-returning lambda: `Bind(x => Result.Success(x))` was `Map` - under another name, and is a compile error now. -- **`Result` converts from a bare value, never from a bare error** (measured - 2026-09-27). With both conversions, `Result r = 5;` compiles and is an **error**: - `int` is the more specific source type, so C# picks the error conversion. The READMEs had shown - `return Fault.X;` in valued methods, which does not compile; the consumer smoke test found it. -- **`Option.Some(null)` throws.** `notnull` is an annotation the runtime does not enforce, and a - `Some` over null reported a value it could not hand out. `Map` and the zips go through `Some`, - so a selector that returns null throws too. -- **So do `Result`'s factories.** Both shapes and both markers constrain their content to - `notnull`, and `Success(null)`/`Error(null)`/`Result.Success(null)`/`Result.Error(null)` throw - `ArgumentNullException`: a result over null handed it out of `TryGetValue`/`TryGetError` despite - `[NotNullWhen]`. `Map`, `MapError`, `Bind` and the conversions go through them. -- **Every delegate is checked before a branch is picked**, in `Option`, both `Result` shapes and - their extensions: a null for the branch not taken passed until the other outcome first arrived. - `NullDelegateTests` holds every delegate parameter of the assembly to a case run on both branches. -- **`Result.Match` hands the error to its error branch.** The only overload took a - parameterless `onError`, so `TryGetError` was the only way to the error. The parameterless one - stays, for a branch that does not need it. -- **`[DebuggerDisplay]`** on `Option` and both `Result` shapes shows the content in the debugger, - where it is what someone stepping through wants to see. The debugger is not a log. -- **`Result` has no `AsEnumerable`.** A sequence of zero or one units says no more than - the `bool` conversion. -- **Pipelines (decided 2026-09-28).** `MapAsync`/`BindAsync` take an asynchronous selector on a - result, and every combinator of both shapes continues a `Task>` (`MapAsync`, `BindAsync`, - `MapErrorAsync`, `TapAsync`), so a chain is awaited once, at its end; a completeness test holds - every combinator to a continuation. `Match` ends a chain after that `await`. Each continuation - checks its delegate before awaiting, so a null one is reported even when the pending result fails. - `Zip` combines two to six results, `Sequence` and `Traverse` a sequence of them. All stop at the - first error, in argument or sequence order: collecting errors is the validation applicative the - non-goals exclude. A zip still inspects every argument, so an uninitialized one after an error - throws. Callbacks return `Task` (decision 30): `TapAsync` and `TapErrorAsync` take a - `Func<…, Task>` beside the `Action`, so an `async` lambda is awaited; a `ValueTask` one needs - `.AsTask()`. - -**Decided 2026-09-27: an uninitialized `Result` throws.** -- A `default` result is `State == Uninitialized`. If the branching members tested only - `State == Success`, it would take the **error** branch with `default(TError)`. -- For an enum fault that is the first member (e.g. `Blank`): a plausible, wrong reason. For a - reference-type error it is `null`, despite `[NotNullWhen(false)]`. -- The analyzer blocks `default` in source, but array elements, class fields (CMTK0005/0006) and - reflection still produce such instances. -- **Decision:** every member that picks a branch (`Match`, `Map`, `Bind`, `Tap`, `TapAsync`, - `TapErrorAsync`, `TryGetValue`/`TryGetError`, `AsEnumerable`, the `bool` conversion of - `Result`) throws - `InvalidOperationException` on `Uninitialized`. That is a loud failure instead of a fabricated - fault. `State`, equality and `ToString` stay safe to call. - -**Decided 2026-09-27: `Option`/`Result` are not wire types.** -- Measured: with private state and no converter, System.Text.Json wrote `{}` for a `Some` and read - it back as `None`, and wrote `{"State":1}` for a success and read it back as `Uninitialized`. - Nothing raised. -- **Decision:** `NotWireTypeJsonConverterFactory`, on `Option`, both `Result` shapes and the - `Success`/`Success`/`Error` markers, throws `NotSupportedException` from `Read` and `Write`, - naming the type and the alternative and never the content. `HandleNull` is on, so a JSON `null` - gets the same message, and a dictionary key does too. That is what the serializer itself does for - `System.Type`. It hands every type one converter typed `object`, which the serializer wraps: a - converter per type needed `MakeGenericType`, and under Native AOT that failed for these structs - with the runtime's message instead of this one (§11). -- A serialized shape says absent with `T?`; `ToOption()`/`OrNull()` convert at the boundary; a - result is matched to a response or a document. An application that wants a wire format registers - its own converter on its options, which takes precedence over the type's attribute, in a - source-generated context too (measured). -- **Not a lossless converter** (`Some(x)` as `x`, `None` as `null`): its bytes are identical to - `T?`, so it would only move the CLR type onto the shape the design keeps it off, and a `Result` - has no sensible wire shape at all. -- **Not an analyzer:** whether a type is serialized is a property of the application's roots and - options, which the package cannot see. An application can add a shape test of its own. -- **Known gap:** the serializer calls no converter for a property that is absent from the document, - so it stays `default`. `required` or `RespectRequiredConstructorParameters` closes it, and the - README says so. -- Completeness: `NotWireTypeTests` walks every exported struct of the assembly, so a struct added - later refuses too, or is exempted on purpose. - -**Why not an existing package** (checked 2026-09-26). The criteria: no public `.Value`/`.Error`, -a generic `TError`, `default` is not success, maintained. -- **Funcky 3.6.0** is closest (`[NonDefaultable]` enforced by an analyzer, no `.Value`), but its - `Result` fixes the error type to `Exception`. -- **nlkl/Optional** has the right Option shape but no Result, and no release since 2018. -- **CSharpFunctionalExtensions, DotNext and FluentResults** expose a throwing `.Value`. -- **ErrorOr, Remora.Results and Ardalis.Result** expose an unguarded one. With ErrorOr, - CSharpFunctionalExtensions and Remora, `default(Result<…>)` reads as success. -- **JSON** (checked 2026-09-27): CSharpFunctionalExtensions 3.7.0 ships opt-in converters that write - a `Result` as a DTO with `IsSuccess`/`Error`/`Value`, for passing outcomes between services; - LanguageExt (v5 still beta) ships none; ErrorOr, OneOf and FluentResults ship none and serialize - their public properties as they are. None refuses. - -C# 15 `union` types (in .NET 11 preview) ship no standard `Option`/`Result`, and they need .NET 11 -anyway, while this repo targets net10.0. - -**Against the value-object libraries** (checked 2026-09-27): -- Vogen validation returns Ok/Invalid with a plain string, and its `TryFrom` is `bool` + `out`. -- Thinktecture returns a typed `ValidationError` but ships no Result type. -- TypeKit's value objects return `Result` from `Create` and `Option` from - `TryFrom`. That is the reason Option/Result and the value objects ship together. - -## 10. Analyzer package and rule set - -**Packaging (decided 2026-09-27).** `CodoMetis.TypeKit.Analyzers` (netstandard2.0; analyzers and -code fixes under `analyzers/dotnet/cs`). `CodoMetis.TypeKit` takes a package dependency on it. -- **Why separate:** rules and fixes can ship without a base version bump, and a consumer can take - a newer analyzer by referencing it directly. The code-fix assembly (which needs Workspaces) - stays out of a runtime library package. -- **Why still a dependency of the base package:** fabric spike consequence A. Whoever can see the - interfaces must get the guard. -- **Measured 2026-09-27 (SDK 10.0.401),** packed and consumed from a local feed: - - A project that references only `CodoMetis.TypeKit` gets CMTK0001 and CMTK0002, and so does a - project that reaches it only through another project. - - With the default `exclude="Build,Analyzers"` on the dependency, the analyzer **still** loads, - because the SDK takes analyzers from every package in the restore graph. `PrivateAssets="none"` - is kept so the dependency says what is intended instead of relying on that. - - What does drop it: a project reference with `ReferenceOutputAssembly="false"` emits **no - dependency at all**. The reference is therefore an ordinary one with `Private="false"`. -- **Guards:** `AnalyzerPackagingTests` packs both packages and reads the nuspec and the package - layout. The consumer smoke test (phase 6) asserts CMTK0001 fires, directly and transitively. - -Attributes stay in `CodoMetis.TypeKit`. The analyzer resolves them and the interfaces with -`GetTypeByMetadataName` and compares symbols (§3), so it does not reference the base package. -Inside the analyzer, rules that react to attributes are kept apart from value-object rules, so the -split stays mechanical if a generic analyzer package is ever wanted. - -**Scope.** Code that compiles but is wrong about value objects, `Result`/`Option` and types that -forbid `default`. General linting (clock types, style) stays out, since Meziantou and Roslynator -exist. - -| Id | Rule | Severity | Status | -|---|---|---|---| -| CMTK0001 | No `default`/`default(T)`/`new()`/`new T()` of a value object or a `[RequireCustomInitialization]` type. An operand of `==`/`!=`, or an argument of a call whose name has the word `Equal`, `Equals` or `Compare` or ends in `Be` (`ThrowIfEqual`, `Assert.NotEqual`, `ShouldNotBe`), is exempt: a guard check is the only defence against the defaults the rule cannot see. So is a `default` assigned to an `out` parameter of a method returning `bool`, the Try pattern. Where a sibling branch does not bind (same-project value objects), the target type is taken from the enclosing conditional, switch arm or collection | Error | shipped in 1.0.0 | -| CMTK0002 | Type implements `IValue<>`/`IValidatedValue<,,>`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so it is never woven | Error | shipped in 1.0.0 | -| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run. CA1806 can only enforce this per method name, not per return type | Warning | shipped in 1.0.0 | -| CMTK0004 | `IValueObjectMaterializer<,>.Materialize`, `ValueObjectConverter<,>.Materialize` or a hand-written value object's public `Materialize`, called or referenced (expression trees included). The satellite's own call is compiled in the satellite; EF's compiled model is generated code, whose diagnostics are not reported (measured: reported, it is six errors in the smoke test's AOT consumer). `StoredJsonConverterFactory` goes through `IStoredJsonConverterSource`, not `Materialize` | Error | shipped in 1.0.0 | -| CMTK0005 | `new T[n]`, `stackalloc T[n]`, `GC.AllocateUninitializedArray/AllocateArray` or `Array.Resize` of a no-default struct: every new slot starts as `default`. A constant length of zero (for `Array.Resize` and the `GC` methods too) or initial elements are silent | Warning | shipped in 1.0.0 | -| CMTK0006 | Field or auto-property of a no-default struct type in a class that no initializer, `required` or constructor sets; a non-public parameterless constructor (EF, serializers) and a record's copy constructor are exempt. Measured 2026-09-28 on the repository's EF entities and request types: 12 of 12 reports on settable properties of a type with only the implicit constructor, each a real way to a default. `required`, the fix the message names where it compiles, works with EF's compiled model, precompiled queries and Native AOT (the smoke test rerun with it). A `[SetsRequiredMembers]` constructor must assign the `required` members it promises | Warning | shipped in 1.0.0 | -| CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | -| CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | -| CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | -| — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | - -**Rules that read calls see the source before weaving (measured 2026-09-28).** Metalama runs -analyzers on the unwoven source, where a call to a member the generators introduce into a value -object of the same project does not bind. CMTK0003 recognises `X.TryFrom(…)` and `x.Revalidate()`, -CMTK0007 `X.FromKnownGood(…)` (a `using static` call included) and CMTK0009 `X.TryFrom(…).OrDefault()` -by the generated member's name on a validated value object's type, which binds; the names are pinned -by the generated-surface snapshot. CMTK0001 takes a `default`'s type from the enclosing conditional or -collection when a sibling does not bind, and CMTK0006 resolves an unbound `this(…)` to the one -constructor it can mean. A local declared `var` from an unbound call, and CMTK0008 over such locals, -stay out of reach. The build-outcome consumer holds every rule to a value object of its own project, -since the verifier tests never weave. - -**Generated code (decided 2026-09-28).** The rules analyse generated code and report from it only -where a location maps (`#line`) to a `.razor` or `.cshtml` file: skipping generated code, as Roslyn -does by default, silenced every rule inside Razor components, CMTK0001 included. Anything else -generated stays unreported, which keeps EF's compiled model out of CMTK0004 (the smoke test's AOT -consumer is the measurement). `GeneratedCode.Report` is the one reporting path. - -**Transformed code (decided 2026-09-28, decision 33).** Where Metalama compiles a project, source -generators run after its transformation, so the Razor compiler's output is not in the source an -analyzer sees, and neither are the members the aspects introduce. The analyzer package's -`buildTransitive` props lists the analyzers' namespace as a `MetalamaTransformedCodeAnalyzer`, and -Metalama's compiler runs them on the transformed code. The IDE ignores the item, so live analysis -still runs on the source, where the name-based forms above remain the reach. - -**Release discipline.** Keep `AnalyzerReleases.Shipped/Unshipped.md` tracking (RS2008). A new rule -ships at Warning or Info in a minor version and is raised to Error only in a major. Consumers -build with warnings as errors, so a new Error rule in a minor version would break their builds on -update. Within a major version an Error never reports more than it did; a Warning or Info may learn -more forms in a minor version (decision 34, stated in the analyzer README). - -## 11. Native AOT - -**Decided 2026-09-27 (decision 16), measured by publishing consumers with Native AOT** (SDK 10.0.401, -ILCompiler 10.0.12, osx-arm64), JSON through source-generated contexts, OpenAPI in a slim web -host, EF Core through `dotnet ef dbcontext optimize --precompile-queries --nativeaot`. - -- **What the analyzers can and cannot see.** `IsAotCompatible` runs the trim and AOT analyzers on - the packages' own code, and warnings are errors. The generators' product is compiled in the - consumer, where only the consumer's publish sees it, and the analyzers cannot tell that a run-time - path still works: the two worst findings (stored JSON validated, every schema `{}`) produced no - warning at build time on the JIT. So the smoke test publishes with Native AOT and runs the binary. -- **Trimming removes interfaces nothing uses.** ILCompiler keeps an interface on a type only if the - interface's definition is used somewhere; `typeof(IValueObject<,>)` does not count. Neither - `[DynamicallyAccessedMembers(Interfaces)]` on the interface nor on the value object kept it (both - measured). Custom attributes are kept, which is why discovery reads - `GeneratedValueObjectAttribute<,>` (decision 17). -- **And keeps them once anything uses the interface.** The attribute's own constraint does, and so - does the EF satellite's converter: with the attribute generated, a `GetInterfaces()` lookup found - `IValueObject<,>` again under Native AOT, and without it the same host described every value object - as `{}` (both measured). That is ILCompiler's behaviour, not a contract, so the smoke test cannot - catch a return to `GetInterfaces()` at run time; the analyzers' IL2070, an error in the packages' - build, does. -- **JSON.** The generated converter's wrapped value goes through `GeneratedJson.TypeInfo` (§5), so a - context lists the value objects and never what they wrap. A value object wrapping a type the - serializer has no converter for (a class of the application's) still needs that type in the - context, and the serializer's refusal names it. -- **OpenAPI.** The host's context lists what its value objects wrap (§7). -- **EF Core** is experimental under Native AOT and needs a compiled model and precompiled queries; - its `DbContext` constructors require dynamic code. With the satellite, a value object maps, writes, - queries by `.Value`, binds as a parameter and reads a stored value without validation, in the - native binary. EF limitations met on the way, not ours to fix: - - the compiled model cannot write the sentinel of a **struct value object wrapping a reference - type** (`default(Code)` converts to `null`, and EF then needs a literal for the struct itself). - Such a value object on an entity is a `sealed partial record` instead; - - EF 10's precompiled queries cast an entity to an internal interface, which does not compile for - a **sealed** entity class. -- **Third-party packages** report their own warnings (NodaTime.Serialization.SystemTextJson 1.4.0, - EF Core). The smoke test asserts only that none names a CodoMetis package or a woven value object. - diff --git a/docs/result.md b/docs/result.md new file mode 100644 index 0000000..7b6b840 --- /dev/null +++ b/docs/result.md @@ -0,0 +1,304 @@ +# Result + +`Result` is the outcome of an operation: a value or an error of your own type. +`Result` is the outcome of a command that produces no value: a success or an error. Neither +has a `.Value` or an `.Error`: the outcome is reached through `Match`, `TryGetValue` and the +combinators, so a failure is handled where the value is used. Both live in `CodoMetis.TypeKit`; the +[base README](../src/CodoMetis.TypeKit/README.md#resultt-terror-and-resultterror) is their reference, +and [Option](option.md) is the type for absence without a reason. + +The examples import `CodoMetis.TypeKit` and are a checkout: a cart is found, reserved against stock, and charged. + +```csharp +public readonly partial record struct CartId : IValue; + +public readonly partial record struct OrderId : IValue; + +public enum CheckoutFault { CartUnknown, CartEmpty, OutOfStock, PaymentRefused } + +public sealed record Line(string Sku, int Quantity, decimal Price); + +public sealed record Cart(CartId Id, IReadOnlyList Lines); + +public sealed record Reservation(OrderId Id, decimal Total); + +public sealed record Receipt(OrderId Id, decimal Total); +``` + +## Two shapes + +```csharp +public Result Reserve(Cart cart) // a value, or a fault +{ + if (cart.Lines.Count == 0) return Result.Error(CheckoutFault.CartEmpty); + if (!InStock(cart)) return Result.Error(CheckoutFault.OutOfStock); + + return new Reservation(OrderId.New(), cart.Lines.Sum(line => line.Quantity * line.Price)); // a bare value is a success +} + +public Result Charge(decimal total) => // a success or a fault, and no value + total <= limit ? Result.Success() : CheckoutFault.PaymentRefused; // a bare error converts here, where nothing can be confused with it +``` + +A bare value converts to a valued success; an error goes through `Result.Error(...)`, because in a +`Result` a bare `5` could be either. `Result` has no value, so there a bare error +converts, and so does the `Result.Success()` marker. The fault type is yours: an enum for most +operations, a closed set when each fault carries constant data, a `Fault` when one occurrence +carries a sentence, a record where a fault carries the rule's data +([Value objects](value-objects.md#faults)). Keep the input out of it: a fault reaches messages and +logs. `ToHttpResult()` takes the first three. + +## Reaching the outcome + +```csharp +var reserved = checkout.Reserve(cart); + +string outcome = reserved.Match(reservation => $"reserved {reservation.Total}", fault => $"refused: {fault}"); // "reserved 30" +ResultState state = reserved.State; // Success + +if (reserved.TryGetValue(out var reservation, out _) && checkout.Charge(reservation.Total)) audit.Add("charged"); // a Result converts to bool; a valued one never does + +Option maybe = reserved.ToOption(); // the fault dropped, where only presence matters +``` + +`Match` takes both branches and returns what they return. `TryGetValue(out value, out error)` gives +the branch and the content in one call: `value` is non-null when it returns true, `error` when it +returns false. Only `Result` converts to `bool`, so `if (checkout.Charge(total))` reads as it +should. A valued result does not: `if (await users.IsEmailTakenAsync(email))` over a +`Result` would take the branch for a success holding `false`. `State` tells the three states apart without throwing: +`Success`, `Error`, and `Uninitialized` for a `default`, which an array slot or an unassigned field +still produces and on which every branching member throws rather than inventing a `default(TError)`. +CMTK0005 reports an array of results created with a length, CMTK0001 a `default` in source. + +## A pipeline + +```csharp +Result receipt = checkout.FindCart(cartId) // Result + .Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty) // a rule on the value: break it, and this is the error + .Bind(checkout.Reserve) // a step that can fail itself + .Map(reservation => new Receipt(reservation.Id, reservation.Total)) // a step that cannot + .Tap(r => audit.Add($"receipt {r.Total}")) // on a success only + .TapError(fault => audit.Add($"refused: {fault}")); // on an error only + +Result charged = receipt.Bind(r => checkout.Charge(r.Total)); // a command after a query + +string paid = receipt.Match(r => $"paid {r.Total}", fault => $"refused: {fault}"); // "paid 30" +string ledger = charged.Match(() => "charged", fault => $"not charged: {fault}"); // "charged" +string empty = checkout.FindCart(emptyCartId).Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty).Bind(checkout.Reserve).Match(r => "reserved", fault => $"refused: {fault}"); // "refused: CartEmpty" +``` + +`Map`, `Bind`, `Tap` and `Ensure` run on a success and carry an error through unchanged; `MapError`, +`TapError` and `Recover` run on an error and carry a success through. The first error stops the chain, +and every step after it is skipped, which the `empty` line shows: `Reserve` never ran. `Recover` is +the error branch's `Bind`: its selector answers a success or another error, as a second chance: + +```csharp +Result settled = charged.Recover(fault => fault == CheckoutFault.PaymentRefused ? checkout.Invoice(cartId) : fault); // Success +``` + +A lambda that returns a bare value on one branch and `Result.Error(...)` on the other needs its type +argument, `.Bind(r => r.Total > 0 ? r : Result.Error(CheckoutFault.CartEmpty))`, because C# +infers a lambda's return type from its body alone; `.Ensure(r => r.Total > 0, CheckoutFault.CartEmpty)` +says the same without one. + +## Across layers + +A fault names what the operation found. What a client is told is the host's decision, and the two +meet in `MapError`: + +```csharp +public enum ApiFault { NotFound, Invalid, Unavailable } + +static class Faults +{ + public static ApiFault ToApi(CheckoutFault fault) => fault switch + { + CheckoutFault.CartUnknown => ApiFault.NotFound, + CheckoutFault.CartEmpty or CheckoutFault.OutOfStock => ApiFault.Invalid, + CheckoutFault.PaymentRefused => ApiFault.Unavailable, + _ => throw new UnreachableException(), + }; +} +``` + +```csharp +Result forApi = receipt.MapError(Faults.ToApi); // Success, untouched; an error would be the ApiFault +``` + +In ASP.NET Core, `ToHttpResult()` turns the result into a response, the fault into problem details +with the status the host maps it to ([ASP.NET Core](aspnet-core.md#a-result-as-a-response)). + +## Async chains + +The asynchronous members continue a `Task>` without an `await` each, so a chain is awaited +once, at its end: + +```csharp +Result chargedAsync = await checkout.FindCartAsync(cartId) // Task> + .EnsureAsync(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty) // a rule on a pending result + .BindAsync(cart => checkout.ReserveAsync(cart)) // an asynchronous step + .MapAsync(reservation => reservation.Total) // a synchronous step on a pending result + .BindAsync(total => checkout.ChargeAsync(total)) // a command: Result remains + .TapErrorAsync(fault => audit.Add($"not charged: {fault}")); + +string asyncLedger = chargedAsync.Match(() => "charged", fault => $"not charged: {fault}"); // "charged" +``` + +The asynchronous steps take callbacks that return a `Task` and wait for them, so what a callback +throws reaches the chain's `await`. A callback that returns a `ValueTask` needs `.AsTask()` or an +`async` lambda: `TapAsync` and `TapErrorAsync` would otherwise bind to their synchronous overload and +not wait, and `MapAsync` would hold the `ValueTask` as the value. + +## Where a callback runs + +Every `await` inside the package is `ConfigureAwait(false)`, so nothing resumes on the caller's +`SynchronizationContext`, and a caller that blocks on a chain with `.Result` or `.Wait()` is not +deadlocked by the package's own awaits. A callback therefore runs where the task it waits for completed. When that task has +already completed, the callback runs at once, on the caller's thread and context; when it is still +pending, the callback runs wherever the task completes, usually a thread-pool thread, without the +caller's context. That holds for every continuation of a pending result, for `TraverseAsync` after its +first operation, and for every step after one that waited. The caller's own `await` of the chain +returns to its context as always. + +In a Blazor component or a WPF window, a callback that touches state bound to the renderer or the +dispatcher would work or fail by timing. There, await the task and continue with the synchronous +combinators, or marshal inside the callback (`InvokeAsync`, `Dispatcher.Invoke`): + +```csharp +Result found = await checkout.FindCartAsync(cartId); // the await returns to the caller's context +string lineCount = found.Map(cart => $"{cart.Lines.Count} lines").Match(text => text, fault => $"refused: {fault}"); // "2 lines", the selector on that context too +``` + +## Collecting results + +```csharp +Result, CheckoutFault> lines = requested.Traverse(checkout.ToLine); // every request to a line, or the first fault +Result, CheckoutFault> all = reservations.Sequence(); // a list of results to a result of a list +Result amount = unitPrice.Zip(quantity, (price, count) => price * count); // both successes, or the first error +Result bulk = cart.Lines.FirstOrError(line => line.Quantity >= 10, CheckoutFault.OutOfStock); // the first match, or the error + +string summary = requested.Traverse(checkout.ToLine).Match(list => $"{list.Count} lines", fault => $"refused: {fault}"); // "2 lines" +string stopped = refused.Traverse(checkout.ToLine).Match(list => $"{list.Count} lines", fault => $"refused: {fault}"); // "refused: OutOfStock" +``` + +`Traverse`, `Sequence` and `Zip` (two to six results) stop at the first error, in sequence or +argument order, and `Traverse` calls its selector for nothing after it. None of them collects errors: +a fault type that is a list of your own is how several faults travel together. + +An asynchronous operation runs one element at a time with `TraverseAsync`, each awaited before the +next starts, so nothing starts after a fault. With a cancellation token, the token is checked before +each operation and handed to it, and a cancellation propagates rather than becoming an error. +Operations that may all run at once are `Task.WhenAll` over their tasks and `Sequence()` over what +it answers. + +```csharp +Result, CheckoutFault> reserved = await carts.TraverseAsync(checkout.ReserveAsync); // one at a time, in order; nothing starts after a fault +Result charged = await totals.TraverseAsync(checkout.ChargeAsync); // a command per element, likewise +Result, CheckoutFault> found = await ids.TraverseAsync(checkout.LoadCartAsync, cancellationToken); // the token checked before each, and handed to it +``` + +## An exception as an outcome + +A call that throws has no way into a `Result` except a `try`/`catch` written by hand. `Result.Catch` +runs the call and answers its value or the exception as the error: + +```csharp +Result url = Result.Catch(() => new Uri(text)); // Error: UriFormatException +Result narrowed = Result.Catch(() => new Uri(text)); // that type only; any other propagates +Result asFault = Result.Catch(() => new Uri(text)).MapError(_ => ApiFault.Invalid); // Error: Invalid +Result written = Result.Catch(() => File.WriteAllText(path, "receipt")); // Success, a command +Result read = await Result.CatchAsync(() => File.ReadAllTextAsync(path)); // Success: receipt +``` + +An exception is for the code that called, not for a client: `ToHttpResult()` does not accept a +`Result`, and `MapError` turns it into a fault that it does, as `asFault` shows. +`Catch` spells both type arguments, since C# infers all or none. `CatchAsync` awaits +the task, so an exception thrown after its first `await` is the error too; a delegate that returns a +`Task` given to `Catch` is a compile error naming `CatchAsync`, since the task's exception would be +thrown after `Catch` had returned. An `OperationCanceledException` passes through every overload, +since a cancellation is not an outcome, and so does an `AggregateException` that holds cancellations +alone, as `Wait()` and `.Result` throw for a cancelled task; one that holds anything beside them is the +error, as it was thrown. A cancellation over a `TimeoutException` is the error, wrapped or not: that is +how `HttpClient` reports its `Timeout` elapsing, the call failing, and `.Result` on such a call is the +error too. +`Map`, `Bind` and the rest are unchanged: a selector that throws propagates. + +`CatchAsync`'s error is what `await` throws, the exception `try { await task; } catch (Exception e)` +would catch. A task that faulted with several exceptions, as `Task.WhenAll`'s does when more than one +operation fails, throws the first of them, and a narrowed `CatchAsync` looks at that one alone. The +task's `Exception` holds them all, so where every failure matters, hold the task: + +```csharp +Task both = Task.WhenAll(File.WriteAllTextAsync(draft, "a"), File.WriteAllTextAsync(copy, "b")); +Result saved = await Result.CatchAsync(() => both); // Error: DirectoryNotFoundException, the first of the two +int failures = both.Exception?.InnerExceptions.Count ?? 0; // 2, every failure is on the task +``` + +## Query syntax + +```csharp +Result receipt = + from cart in checkout.FindCart(cartId) + from reservation in checkout.Reserve(cart) // each from sees the earlier ranges + select new Receipt(reservation.Id, reservation.Total); +``` + +`Select` and `SelectMany` make it work; there is no `where`, since a result has no error to give a +filtered-out value, which is what `Ensure(predicate, error)` is for. + +## The surface + +Every public member of `Result`, `Result`, the static class `Result`, and the +extensions in the `CodoMetis.TypeKit` namespace. `T` and `TError` are `notnull` throughout. + +**Creating** + +| Member | On | Returns | +|---|---|---| +| `Success()` | `Result` | a `Success` marker that converts to any `Result` | +| `Success(value)` | `Result` | a `Success` marker that converts to any `Result` | +| `Error(error)` | `Result` | an `Error` marker that converts to either shape | +| `Success(value)`, `Error(error)` | `Result` | the concrete result, where the type is spelled | +| `Success()`, `Error(error)` | `Result` | likewise | +| implicit from `T`, `Success`, `Error` | `Result` | a bare value is a success; a bare error is not, since it could be either | +| implicit from `TError`, `Success`, `Error` | `Result` | a bare error converts, and so does the marker | +| `Catch(action)`, `Catch(action)`, `Catch(action)` | `Result` | `Result` or `Result`; narrowed to one exception type | +| `CatchAsync(action)` and the same type-argument forms | `Result` | the same, awaiting the task | +| `ToResult(error)` | `Option` | `Result` | +| `FirstOrError(predicate, error)`, `LastOrError(predicate, error)` | `IEnumerable`; `FirstOrError` on `IQueryable` too | the first or last match, or the error; on a query the provider runs the predicate, and `LastOrError` is a compile error | +| `Traverse(selector)` | `IEnumerable` | `Result, TError>`, or `Result` for a command selector; stops at the first error | +| `Traverse(selector)` | `Option` | `Result, TError>`: an optional input validated when present | +| `Traverse(selector)` | `Patch` | `Result, TError>`: a patch field validated when it carries a value; a clear or an absence passes through | +| `TraverseAsync(selector)`, `TraverseAsync(selector, cancellationToken)` | `IEnumerable` | as `Traverse`, one operation awaited at a time; nothing starts after an error, or once the token is cancelled | +| `Transpose()` | `Option>`, `Result, TError>` | the one turned inside out: `None` is a success without a value, an error stays the error | +| `Sequence()` | `IEnumerable>`, `IEnumerable>` | the list of values, or the first error | + +**Reaching the outcome** + +| Member | On | Returns | +|---|---|---| +| `State` | both | `ResultState`: `Success`, `Error` or `Uninitialized` | +| `Match(onSuccess, onError)` | both | what the branch returns; `onError` may take the error or nothing | +| `TryGetValue(out value, out error)` | `Result` | `bool`; `value` non-null when true, `error` when false | +| `TryGetError(out error)` | `Result` | `bool` | +| implicit to `bool` | `Result` only | true for a success | +| `AsEnumerable()` | `Result` | zero or one element | +| `ToOption()` | `Result` | `Some(value)`, or `None` for an error | + +**Continuing** + +| Member | On | Does | +|---|---|---| +| `Map(selector)` | both | a new value on a success; `Result` from a `Result` | +| `Bind(selector)` | both | the result the selector returns on a success; a valued one can bind to a command | +| `Ensure(predicate, error)` | `Result` | a success that fails the predicate becomes that error | +| `MapError(selector)` | both | a new error type | +| `Recover(selector)` | both | the selector's result on an error, a success or another error | +| `Tap(action)`, `TapError(action)` | both | runs the action on that branch, hands the result on | +| `Zip(b, …, selector)` | `Result` with two to six results | one value from all, or the first error | +| `Select`, `SelectMany` | `Result` | query syntax over `Map` and `Bind` | +| `MapAsync`, `BindAsync`, `RecoverAsync`, `TapAsync`, `TapErrorAsync` | both, and `Task>` | the same, taking or continuing a task; the chain is awaited once | +| `MapErrorAsync`, `EnsureAsync` | `Task>`; `EnsureAsync` on `Task>` | `MapError` and `Ensure`, once the result is known | + +Equality is a record struct's. `ToString()` prints the state and never the value or the error, so a +result is safe to log; the debugger shows both. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..b136f6d --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,156 @@ +# Testing + +`Option` and `Result` have no `.Value`, so a test reaches the content through +`CodoMetis.TypeKit.Testing`, which hands it back and fails saying what the outcome was. The rest of +this page is the shape of the tests a codebase on value objects ends up writing: `Create` as a table, +the generated ways in against `Create`, EF Core on SQLite, and an endpoint on the test server. The +[Testing README](../src/CodoMetis.TypeKit.Testing/README.md) is the package's reference. + +```csharp +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.Testing; +using CodoMetis.TypeKit.ValueObjects; +``` + +```csharp +public enum CodeFault { Blank, NotUpperCase } + +public readonly partial record struct ProductCode : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(CodeFault.Blank); + if (value != value.ToUpperInvariant()) return Result.Error(CodeFault.NotUpperCase); + + return new ProductCode(value.Trim()); + } +} +``` + +## The assertions + +```csharp +[Fact] +public void A_code_is_created_or_refused() +{ + ProductCode code = ProductCode.Create("ABC").ShouldBeSuccess(); // the value, for the test's own assertions + code.Value.ShouldBe("ABC"); + + ProductCode.Create("abc").ShouldBeError(CodeFault.NotUpperCase); // this error, or the test fails + CodeFault fault = ProductCode.Create(" ").ShouldBeError(); // any error, handed back + fault.ShouldBe(CodeFault.Blank); + + ProductCode.TryFrom("ABC").ShouldBeSome().ShouldBe(code); + ProductCode.TryFrom("abc").ShouldBeNone(); +} +``` + +```csharp +string failure = Result.Catch(() => ProductCode.Create("abc").ShouldBeSuccess()).Match(_ => "", error => error.Message); // Expected a success, but the Result is the error NotUpperCase. +``` + +A failure is a `TypeKitAssertionException`, which every framework reports as the test's failure, so +the package depends on nothing but `CodoMetis.TypeKit`; the assertions sit beside xUnit's `Assert`, +Shouldly or whatever the test already uses. A failure prints the error through its `ToString()`, +which is what a test needs to see, and never prints a value, as `Option` and `Result` never do. An uninitialized result fails +every assertion, saying it is a default instance. + +A request's `Patch` fields say which of them the request mentioned, and the assertions say the +same: `ShouldBeValue()` hands the value back, `ShouldBeNull()` is a clear, and `ShouldBeAbsent()` a +field the document left out. + +```csharp +public sealed record CodePatch(Patch Code, Patch Note, Patch Quantity); +``` + +```csharp +[Fact] +public void A_patch_says_what_the_request_mentioned() +{ + CodePatch patch = JsonSerializer.Deserialize("""{"note":null,"quantity":2}""", JsonSerializerOptions.Web)!; + + patch.Quantity.ShouldBeValue().ShouldBe(2); // the value, for the test's own assertions + patch.Note.ShouldBeNull(); // a clear + patch.Code.ShouldBeAbsent(); // not in the document +} +``` + +## Create as a table + +The rules are the value object's contract, and a table says all of them in one place: + +```csharp +[Theory] +[InlineData("ABC", null)] +[InlineData(" ABC ", null)] +[InlineData("", CodeFault.Blank)] +[InlineData(" ", CodeFault.Blank)] +[InlineData("abc", CodeFault.NotUpperCase)] +public void Create_applies_each_rule(string input, CodeFault? refused) +{ + var created = ProductCode.Create(input); + + if (refused is { } fault) created.ShouldBeError(fault); + else created.ShouldBeSuccess().Value.ShouldBe(input.Trim()); +} +``` + +## The generated ways in agree with Create + +What the generators derive from `Create` is tested through behaviour, not presence: a build proves +`TryParse` exists, and only a test proves it refuses what `Create` refuses. + +```csharp +[Fact] +public void Every_way_in_applies_the_rules() +{ + ProductCode.TryParse("abc", null, out _).ShouldBeFalse(); + Should.Throw(() => ProductCode.Parse("abc", null)).Message.ShouldContain("NotUpperCase"); + Should.Throw(() => JsonSerializer.Deserialize("\"abc\"")).Message.ShouldContain("NotUpperCase"); + Should.Throw(() => ProductCode.FromKnownGood("abc")).Message.ShouldContain("NotUpperCase"); + ProductCode.FromKnownGood("ABC").Revalidate().ShouldBeSuccess(); +} +``` + +## EF Core on SQLite + +One open `SqliteConnection` to `:memory:`, `UseTypeKit()` on the options and `EnsureCreated` for +the schema; [EF Core](ef-core.md#keys) has the whole block, and [SQLite in tests](ef-core.md#sqlite-in-tests) +says why EF's in-memory provider cannot stand in. + +## An endpoint on the test server + +```csharp +public sealed record CodeRequest(ProductCode Code); +``` + +```csharp +[Fact] +public async Task A_refused_body_is_a_400() +{ + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + await using var app = builder.Build(); + app.MapPost("/codes", (CodeRequest request) => request.Code.Value); + await app.StartAsync(TestContext.Current.CancellationToken); + + var client = app.GetTestClient(); + var accepted = await client.PostAsJsonAsync("/codes", new { code = "ABC" }, TestContext.Current.CancellationToken); + var refused = await client.PostAsJsonAsync("/codes", new { code = "abc" }, TestContext.Current.CancellationToken); + + accepted.StatusCode.ShouldBe(HttpStatusCode.OK); + refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest); +} +``` + +The body went through the generated JSON converter, so the 400 is `Create`'s refusal; what the +response says, in a minimal API and in MVC, is in [ASP.NET Core](aspnet-core.md#what-a-refused-value-answers). + +## The analyzers in a test project + +The analyzers run in test projects as everywhere, so `default(ProductCode)` in a test is CMTK0001, +as it should be: a test that needs a default instance says so with a pragma around the line, which +is also what this repository's tests do. A comparison with `default` is a guard and is not reported +(`id.ShouldNotBe(default)`), nor is a `default` assigned to the `out` parameter of a `Try` method. A +dropped `Result` in a test is CMTK0003, so `_ = ProductCode.Create(input);` says the drop is meant. diff --git a/docs/text.md b/docs/text.md new file mode 100644 index 0000000..3a81d83 --- /dev/null +++ b/docs/text.md @@ -0,0 +1,135 @@ +# Written text + +Every application that stores what a person typed trims it and refuses a blank. Few refuse a +zero-width space, a Hangul filler or NUL, which `IsNullOrWhiteSpace` lets through and `Trim` leaves +in place. Fewer compose it, so a name written with `ü` and the same name written with `u` and a +combining diaeresis are two rows of a unique index that render alike. `AsText()` does all of this for +a line of text a person wrote, and `AsIdentifier()` trims and refuses for a value another system +issued; both are extensions on `string` in `CodoMetis.TypeKit` and answer with a +`Result`. The [base README](../src/CodoMetis.TypeKit/README.md#written-text) is +their reference and lists exactly what they refuse; this page is about using them: which one, where, +and inside a value object. The examples import `CodoMetis.TypeKit`. + +## Two guards + +| | `AsText()` | `AsIdentifier()` | +|---|---|---| +| For | one line of text a person wrote: a name, a title, a subject line | a value another system issued: a subject claim, a session id, a key | +| Trims | yes | yes | +| Folds every space separator to an ordinary space | yes | no | +| Composes to NFC, so one spelling per name | yes, and only that: a ligature, a superscript or a fullwidth letter stays as written, as NFKC would not keep it | no, since an issued value is compared against what its issuer sends next time | +| Refuses a character that renders as nothing | yes | yes | +| A line break, a tab or another control character | refused as `Hidden`: a value is one line, so a multi-line field such as a comment does not go through the guard | refused | +| A format character: a soft hyphen, a byte-order mark, a bidirectional mark or override, a tag character | refused, so an Arabic name ending in a right-to-left mark is refused too | refused | +| The Arabic number sign, the end of ayah and the other prepended concatenation marks | kept: format characters by category, but drawn, spanning the digits after them | kept | +| The two zero-width joiners | kept: they spell Persian, Arabic and Indic scripts and hold an emoji sequence together | refused: no issuer needs one | +| A variation selector | kept after the character it varies (an emoji's presentation, an ideograph's variant); elsewhere it selects nothing and is refused | the same | +| A value of joiners, variation selectors and combining marks alone | refused: it renders no character of its own | refused | +| More than 30 combining marks in a row | refused: no script stacks that many, and composing a run takes time that grows with the square of its length | refused | +| An unpaired surrogate, or `U+FFFE` | refused, where `Normalize` would throw | refused | +| A null receiver | `Blank` | `Blank` | +| In globalization-invariant mode | throws `PlatformNotSupportedException`, since it cannot compose there ([below](#globalization-invariant-mode)) | works, for issued values | + +```csharp +Result composed = "Zoe\u0308".AsText(); // Success: Zoë, composed to NFC: one code point for ë +Result folded = "Jean\u2003Paul".AsText(); // Success: Jean Paul, an em space folded to a space +Result joined = "\U0001F469\u200D\U0001F4BB".AsText(); // Success: 👩‍💻, the joiner holds the sequence together +Result hidden = "Zoe\u200B".AsText(); // Error: Hidden, a zero-width space +Result filler = "\u3164".AsText(); // Error: Hidden, a Hangul filler that IsNullOrWhiteSpace accepts +Result bare = "\u200D".AsText(); // Error: Hidden, a joiner with nothing to join +Result issued = " a1b2-c3 ".AsIdentifier(); // Success: a1b2-c3, trimmed and otherwise as issued +Result forged = "sub\u200Dject".AsIdentifier(); // Error: Hidden, a joiner in an identifier +``` + +Both scan by `Rune`, so a character above the Basic Multilingual Plane is seen for what it is, and an +unpaired surrogate is `Hidden` rather than an exception, which `Normalize` and a database driver would +both throw on. No string makes either guard throw, and none makes either take more than linear time. +Not refused on purpose: unassigned code points, since the runtime's Unicode tables lag the standard +and a name spelled with a newly assigned character would be refused until the SDK caught up; and +confusable letters across scripts, which is a comparison question and not a rejection. + +## Required and optional + +`TextFault` has two members because the caller decides what a blank means. A required field makes +`Blank` an error; an optional one makes it absence. `Hidden` is a refusal in both. + +```csharp +public sealed record ProfileRequest(string? Name, string? Note); // bound from a request: nothing enforces the non-nullability + +static Result Required(string? text) => text.AsText(); // Blank is the error for a required field + +static Result, TextFault> Optional(string? text) => + text.AsText() + .Map(clean => Option.Some(clean)) + .Recover(fault => fault == TextFault.Blank ? Option.None() : Result.Error(fault)); // Blank is absence, Hidden still a refusal +``` + +```csharp +string name = Required(request.Name).Match(text => text, fault => fault.ToString()); // "Blank" +string note = Optional(request.Note).Match(text => text.Match(t => t, () => "none"), fault => fault.ToString()); // "none" +string tagged = Optional("note\u200B").Match(text => text.Match(t => t, () => "none"), fault => fault.ToString()); // "Hidden" +``` + +A null is `Blank` because the extension is the door a bound request property goes through, and +binding enforces no non-nullability: a `string Name` in a record is null when the body lacked it. + +## A value object over the guard + +No value object ships with the guards. One over either is one line, named for the field rather than +for the guard, since `DisplayName` says more than `Text` and two fields of one type can be assigned to +each other. The guard composes with rules of your own: + +```csharp +public enum NameFault { Blank, Hidden, TooLong } + +public readonly partial record struct DisplayName : IValidatedValue +{ + public static Result Create(string value) => + value.AsText() + .MapError(fault => fault == TextFault.Blank ? NameFault.Blank : NameFault.Hidden) // the guard's faults become the field's + .Ensure(text => text.Length <= 50, NameFault.TooLong) // a rule of this field + .Map(text => new DisplayName(text)); +} + +public readonly partial record struct SubjectId : IValidatedValue +{ + public static Result Create(string value) => value.AsIdentifier().Map(id => new SubjectId(id)); +} +``` + +```csharp +string shown = DisplayName.Create(" Zoe\u0308\u2003Q ").Match(name => name.Value, fault => fault.ToString()); // "Zoë Q" +string refused = DisplayName.Create(new string('x', 51)).Match(name => name.Value, fault => fault.ToString()); // "TooLong" +bool equal = DisplayName.FromKnownGood("Mu\u0308ller") == DisplayName.FromKnownGood("M\u00FCller"); // true, one spelling after NFC +``` + +Every generated way into `DisplayName`, JSON, a route parameter, `TryFrom`, applies the guard and the +length rule, so a document or a request that carries a hidden character is refused with `Hidden` +before it reaches the domain. Where the guard is the whole rule, the fault type is `TextFault` itself, +as `SubjectId` shows. + +## Globalization-invariant mode + +`AsText()` composes through the runtime's globalization data. A process in globalization-invariant +mode has none: `true` in the project, which Native +AOT and container templates often set, or `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`. There +`string.Normalize` returns its input unchanged, and a guard that answered anyway would keep one name in +two spellings, without a word. So `AsText()` throws `PlatformNotSupportedException` there, on its first +call whatever the string, rather than on the first decomposed name a client happens to send. + +Written text has no guard in that mode. Where the application calls `AsText()`, turn the mode off and +make ICU available to the process (`libicu` on Debian and Ubuntu, `icu-libs` on Alpine); ICU installed +while the mode stays on does nothing. `AsIdentifier()` keeps working there, but only for what it is for, +a value another system issued: it refuses the joiners a Persian name or an emoji sequence needs and +folds no space, so it is no stand-in for `AsText()`. + +## Where it does not belong + +- **A password or a secret.** Composing or folding it changes what the person typed; compare it as + given, and never through a guard that would also put it in a fault. +- **A multi-line field**, such as a comment or a message body: a line break is a control character, + refused as `Hidden`. Guard each line, or keep the field as the `string` it is. +- **Text another system will parse**, such as a path or a URL: it has its own rules, and trimming it + is already a change. +- **A comparison key.** Confusable letters across scripts and case folding are a comparison's + concern; the guard decides what is stored, not what is equal. diff --git a/docs/type-constraints.md b/docs/type-constraints.md new file mode 100644 index 0000000..8eaf1c9 --- /dev/null +++ b/docs/type-constraints.md @@ -0,0 +1,145 @@ +# Type constraints + +C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : abstract`, `sealed` +or `record`, and no way to demand a parameterless constructor of a given accessibility. The base +package has attributes that say what C# cannot, and the analyzers report the type that breaks them, +at build time, in whichever project declares it. CMTK0010 and CMTK0011 are both errors; the +[Analyzers README](../src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0010) is their reference. The +examples import `CodoMetis.TypeKit`. + +## Constraints on a type parameter + +| Attribute | Accepts | Refuses | +|---|---|---| +| `[Abstract]` | an abstract class or record | a concrete type, and an interface | +| `[Sealed]` | a sealed class or record, a struct, an enum | a type that can be derived from | +| `[Record]` | a record class or record struct | a class or a struct | +| `[Concrete]` | a class, record or struct that can be instantiated, an enum, a delegate | an abstract or static type, an interface, and the abstract bases `Enum`, `Delegate` and `ValueType`. No constructor is demanded, unlike `new()` | + +An aggregate whose events are a closed family, and whose identifier is a value object: + +```csharp +public abstract record AccountEvent; + +public sealed record Deposited(decimal Amount) : AccountEvent; + +public readonly partial record struct AccountId : IValue; + +public abstract class Aggregate<[Abstract] TEvent, [Sealed] TId> +{ + public List Pending { get; } = []; +} + +public sealed class Account : Aggregate; // fine: an abstract record, a struct + +public sealed class Ledger : Aggregate; // CMTK0010: Deposited is not abstract +``` + +The rule reports at every closed use of the generic: a base type as above, an implemented interface, +a field, a property, a parameter, a local, a `typeof`, a call, and a method group converted to a +delegate, whether its type arguments are written or inferred. A written argument is reported where it +is written; an inferred one at the call, or at the method group, whose arguments come from the delegate. + +```csharp +public static class Snapshots +{ + public static string Describe<[Record] TState>(TState state) => state!.ToString()!; // a record prints its members +} + +public sealed class Balance { public decimal Amount { get; init; } } + +public sealed record Statement(decimal Amount); +``` + +```csharp +string shown = Snapshots.Describe(new Statement(10m)); // "Statement { Amount = 10 }" +string hidden = Snapshots.Describe(new Balance()); // CMTK0010: Balance is not a record, inferred at the call +``` + +## Forwarding + +A type parameter given as the argument must carry the attribute itself, or be constrained as the +table below says, or the requirement would end at the first forwarding parameter: + +```csharp +public abstract class Leaky : Aggregate; // CMTK0010: TEvent does not carry [Abstract] + +public abstract class Sound<[Abstract] TEvent> : Aggregate; // the requirement follows the chain + +public sealed class Sealed : Aggregate where T : struct; // struct proves [Sealed], and [Concrete] +``` + +A method that implements an interface's method, or overrides a base class's, carries the constraint +of the method it implements whether or not it repeats the attribute, so a call through the class is +held to what the interface declared. Repeat the attribute anyway, so the declaration says so. + +A parameter whose C# constraints already leave it no argument that breaks the attribute needs no +attribute: + +| Attribute | Proved by | +|---|---| +| `[Abstract]` | nothing but `[Abstract]`: no C# constraint excludes a concrete type | +| `[Sealed]` | `struct` or `unmanaged`, or a bound `where T : U` with `U` proving `[Sealed]`, since a sealed type has no other subtype | +| `[Record]` | a bound that is a record, `where T : AccountEvent`, or a `U` proving `[Record]`, since only records derive from records | +| `[Concrete]` | `new()`, or anything that proves `[Sealed]`, `[Sealed]` itself included, since a sealed type is never abstract | + +The open generic inside its own body and an unbound +`typeof(Aggregate<,>)` are never reported. Reflection, `MakeGenericType`, is out of reach, and the +attributes carry no run-time check: the guarantee is the build. + +## A parameterless constructor + +A type the host creates by reflection, an endpoint, a module, an entity configuration, a job, needs +a parameterless constructor of a given accessibility, and C# checks that only through `new()`, which +demands a public one and only where a type parameter says so. `[RequireParameterlessConstructor]` on +the interface or base class says it once; CMTK0011 appears on the concrete type that lacks the +constructor, directly or through any chain of base classes and interfaces, and the code fix writes +it. + +```csharp +[RequireParameterlessConstructor] +public interface IJob +{ + Task RunAsync(CancellationToken cancellationToken); +} + +[RequireParameterlessConstructor(ConstructorAccessibility.Internal)] +public abstract class Plugin; + +public sealed class Nightly : IJob // fine: no constructor declared, so the implicit one is public +{ + public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed class Report : IJob // CMTK0011: no parameterless constructor; the fix adds one +{ + public Report(TimeProvider clock) { } + + public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed class Importer : Plugin; // CMTK0011: the implicit constructor is public, Plugin requires internal +``` + +| The type | Reported | The fix | +|---|---|---| +| declares no constructor, and `Public` is required | no: the implicit constructor is public | | +| declares no constructor, and another accessibility is required | at the type | adds `internal Importer() { }` | +| declares constructors, none parameterless | at the type | adds one | +| has a parameterless constructor of the wrong accessibility | at the constructor | changes it | +| has a primary constructor, in any of its parts | at the type | none: every other constructor would chain to the primary one, with arguments only its author knows | +| is abstract, or an interface | no: only concrete descendants are checked | | +| is a struct or a record struct | no: its parameterless constructor is public and can be nothing else (CS8958), so it satisfies `Public` as it is and no other accessibility can be asked of it | | + +A record's copy constructor does not count. The attribute is read from metadata, so the marked +interface can live in another package, and a host's contract reaches every implementation in every +project. It is a check and not an aspect that adds the constructor, since an aspect runs only in a +project that compiles with Metalama, and the error has to appear in one that does not. + +## Severity + +Both rules are errors, since the code is wrong whatever surrounds it: a type argument that breaks +what its parameter declares is what the host's reflection-driven construction then fails on, at run +time, where the build could have said so. Both arrived in 2.0.0, a major version, which is the +only kind that adds a rule at Error. The ids are public contract; the severity follows +`.editorconfig` as every rule's does (`dotnet_diagnostic.CMTK0010.severity = warning`). diff --git a/docs/value-objects.md b/docs/value-objects.md new file mode 100644 index 0000000..bb6965d --- /dev/null +++ b/docs/value-objects.md @@ -0,0 +1,287 @@ +# Value objects + +A value object wraps exactly one value, is equal to another when the wrapped values are equal, and +is declared in one line. The contracts live in `CodoMetis.TypeKit`, the generation in +`CodoMetis.TypeKit.Generators`, which the project that declares value objects references. This page +is about designing them: which shape to pick, how to write `Create`, what a fault should carry, and +which factory to call where. What is generated, member by member, and every build error are in the +[Generators README](../src/CodoMetis.TypeKit.Generators/README.md); the contracts are in the +[base README](../src/CodoMetis.TypeKit/README.md#value-objects). The examples import `CodoMetis.TypeKit` +and `CodoMetis.TypeKit.ValueObjects`. + +## Struct or class + +```csharp +public readonly partial record struct Sku : IValue; // a struct: never null, and default is CMTK0001 + +public sealed partial record Note : IValue; // a class: for a string column in an EF Core compiled model +``` + +A struct is the default: it cannot be null, it allocates nothing, and the analyzer refuses a +`default` of it. Declare a `sealed partial record` class instead in two cases. A property of an EF +Core compiled model cannot be a struct that wraps a reference type, since EF cannot write its sentinel +([EF Core](ef-core.md#compiled-models)). And a class reads a JSON `null` as `null`, where a struct refuses +it, which matters only for a shape that cannot say `Note?` ([JSON](json.md#null-and-missing)). Equality, +JSON, parsing and the column are the same for both. + +## Identifiers + +```csharp +public readonly partial record struct OrderId : IValue; + +public readonly partial record struct InvoiceNo : IValue; +``` + +```csharp +var id = OrderId.New(); // a version 7 Guid, which sorts by creation time +var imported = OrderId.New(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); // its timestamp, for a migrated record +var no = InvoiceNo.From(42); + +int version = id.Value.Version; // 7 +``` + +`New()` is an extension on every plain value object over a `Guid`, in the `CodoMetis.TypeKit` +namespace, so the file that calls it imports that. Identifiers created in different milliseconds sort +by creation time; two of one millisecond do not order against each other, and SQL Server's +`uniqueidentifier` compares other bytes first. `New(timestamp)` keeps only the milliseconds and throws +`ArgumentOutOfRangeException` for a time before the Unix epoch. An identifier over an integer is what the database +assigns: `From` builds one from a known number, and EF Core fills a key in on insert +([EF Core](ef-core.md#keys)). Two identifiers of different types never compare: `orderId == customerId` +does not compile, and comparing their `.Value`s is CMTK0008. + +## Validated text + +`Create` is the one method you write. Every generated way in applies it: JSON, parsing, the type +converter, `TryFrom` and `FromKnownGood`. + +```csharp +public enum EmailFault { Blank, NoAt, TooLong } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(EmailFault.Blank); + + var normalised = value.Trim().ToLowerInvariant(); // equal addresses hold the same wrapped value + if (!normalised.Contains('@')) return Result.Error(EmailFault.NoAt); + if (normalised.Length > 254) return Result.Error(EmailFault.TooLong); + + return new Email(normalised); // the private constructor is generated + } + + public string Domain => Value[(Value.IndexOf('@') + 1)..]; // computed from Value: fine. A second field is CMTK1012 +} +``` + +```csharp +string lowered = Email.Create(" Ops@Example.com ").Match(email => email.Value, fault => fault.ToString()); // "ops@example.com" +bool same = Email.FromKnownGood("Ops@Example.com") == Email.FromKnownGood("ops@example.com"); // true +string domain = Email.FromKnownGood("ops@example.com").Domain; // "example.com" +``` + +Normalise in `Create`, never in a hand-written `Equals`: equality, ordering, the JSON and the column +are all the wrapped value's, so two values that should be equal must hold the same string. A plain +value object that needs normalising becomes an `IValidatedValue` for that alone. The private +constructor applies no rules, so call it from `Create` only. + +The order of the checks is the order the fault reports: `Blank` before `NoAt`, so a caller that +shows one message shows the first thing to fix. + +## Faults + +A fault names the rule, never the input. The text a value object refused can be a password or an +address, and the fault is what reaches an exception message and a log. An enum is enough for most +value objects, and `ToHttpResult()` turns an enum, a closed set or a `Fault` into problem +details ([ASP.NET Core](aspnet-core.md#a-result-as-a-response)). A fault that must carry something +carries the rule's own data, and an endpoint maps it to a response itself: + +```csharp +public abstract record QuantityFault +{ + public sealed record NotPositive : QuantityFault; + + public sealed record AboveLimit(int Limit) : QuantityFault; // the limit, never the value given +} + +public readonly partial record struct Quantity : IValidatedValue +{ + public const int Limit = 1_000; + + public static Result Create(int value) + { + if (value <= 0) return Result.Error(new QuantityFault.NotPositive()); + if (value > Limit) return Result.Error(new QuantityFault.AboveLimit(Limit)); + + return new Quantity(value); + } +} +``` + +```csharp +static string Describe(QuantityFault fault) => fault switch +{ + QuantityFault.NotPositive => "must be positive", + QuantityFault.AboveLimit { Limit: var limit } => $"at most {limit}", + _ => throw new UnreachableException(), +}; + +string explained = Quantity.Create(5_000).Match(quantity => quantity.ToString(), Describe); // "at most 1000" +``` + +`Result.Error(new QuantityFault.AboveLimit(Limit))` alone would infer the derived type, so the base +is named as the type argument. Where the fault is an enum, `Result.Error(EmailFault.NoAt)` infers it. + +## Numbers + +```csharp +public readonly partial record struct Weight : IValue; // any decimal, with MinValue and MaxValue + +public enum PercentageFault { BelowZero, AboveHundred } + +public readonly partial record struct Percentage : IValidatedValue +{ + public static Result Create(int value) + { + if (value < 0) return Result.Error(PercentageFault.BelowZero); + if (value > 100) return Result.Error(PercentageFault.AboveHundred); + + return new Percentage(value); + } + + public override string ToString() => $"{Value} %"; // kept; then no formatting interface is generated, so interpolation reaches it too +} +``` + +```csharp +bool widest = Weight.MaxValue.Value == decimal.MaxValue; // true +string shown = $"{Percentage.FromKnownGood(42)}"; // "42 %" +bool ordered = Percentage.FromKnownGood(5) < Percentage.FromKnownGood(50); // true, the wrapped type's ordering +``` + +A plain value object over a type with its own `MinValue` and `MaxValue` gets both, and implements +`IMinMaxValue`; a validated one never does, since its range is `Create`'s. Every value object +over a comparable type gets `CompareTo` and the operators, ordinal for a string. The generated +`ToString()` is invariant: `Weight.From(1.5m)` prints `1.5` whatever the current culture. + +## Enums, dates and other wrapped types + +```csharp +public readonly partial record struct Weekday : IValue; + +public readonly partial record struct DueOn : IValue; + +public readonly partial record struct Homepage : IValue; +``` + +```csharp +var day = Weekday.Parse("Friday", null); // Friday +var fifth = Weekday.Parse("5", null); // Friday, as Enum.TryParse reads a number +var due = DueOn.Parse("2026-10-13", null); // 2026-10-13 +bool sooner = DueOn.From(new DateOnly(2026, 1, 1)) < due; // true +bool sortable = typeof(IComparable).IsAssignableFrom(typeof(Homepage)); // false, a Uri has no ordering +``` + +The generated members follow what the wrapped type offers: parsing where it parses, formatting where +it formats, comparison where it is comparable. A `Uri` parses and formats but has no ordering; NodaTime's +types parse through their type converters. A null `IFormatProvider` means the invariant culture +throughout, so a value object's text is the same wherever the process runs, and `Parse(x.ToString(), null)` +gives `x` back wherever the wrapped type's own text is lossless: numbers, `Guid`, `DateOnly`, +`TimeSpan`, `Version`, `Uri`, enums, strings. `TimeOnly`, `DateTime` and `DateTimeOffset` print the +invariant culture's short patterns, as the BCL types do, and lose seconds (`TimeOnly`) or the fraction +of a second (and a `DateTime` its `Kind`) on the way: use `x.ToString("O", null)` or the JSON form for +those ([Parsing and formatting](../src/CodoMetis.TypeKit.Generators/README.md#parsing-and-formatting)). + +What cannot be wrapped is refused at build time (CMTK1005): a generic type, a tuple included, because +a `List` compares by reference; a nullable; an array; `object`, which the serializer writes by the +type of what it holds and a value object's converter cannot; `System.Enum`, which is no enum; and +another value object, since wrapping what it wraps says the same with one type. An interface or an +abstract class is wrapped, and its JSON is the serializer's for that type, polymorphic where the type +declares it. + +## Which factory + +| Call | When | On refusal | +|---|---|---| +| `Create(value)` | Input: a request, a form, a file. The caller has to say what to fix | Returns the fault | +| `TryFrom(value)` | Valid or not is the whole question | `None` | +| `FromKnownGood(value)` | A literal in source, or a value the code has just produced | Throws, naming the call site's expression and the fault, never the value: `request.Code`, not what it held. A literal is its own expression, and is named as written (CMTK0007 points at a parameter given to it) | +| `From(value)` | A plain value object: there are no rules | Throws only for a null | +| `Revalidate()` | An instance read back without the rules, a column or a stored document, against today's `Create` | Returns the fault | + +```csharp +Result fromRequest = Email.Create(request.Email); // the fault says which rule refused it +Option maybe = Email.TryFrom(request.Email); // is it valid? +Email support = Email.FromKnownGood("support@example.com"); // a refusal here is a bug at this line +InvoiceNo first = InvoiceNo.From(1); // no rules +Result stillValid = support.Revalidate(); // today's rules, applied again +``` + +A validated value object has no `From`, so nothing that looks harmless throws on input. The explicit +materializer that EF Core reads a column through is the one way in that skips `Create`, and calling +it anywhere else is CMTK0004 ([EF Core](ef-core.md#reading-back-without-validation)). + +## Your own members + +A value object may declare `TryFrom`, `FromKnownGood`, `Revalidate`, `CompareTo(TSelf)` and +`ToString()`; each is kept, and what depends on it is derived from it. Any other member the generators +introduce, written by hand, is CMTK1011, and any instance state beside the wrapped value is CMTK1012. + +```csharp +public readonly partial record struct Priority : IValue +{ + public int CompareTo(Priority other) => other.Value.CompareTo(Value); // 1 ranks above 2; the operators follow +} +``` + +```csharp +var first = new[] { Priority.From(3), Priority.From(1) }.Min(); // 3 +bool urgent = Priority.From(1) > Priority.From(2); // true +``` + +A database cannot run a hand-written `CompareTo`, so an EF Core query that compares or orders such a +value object is refused ([EF Core](ef-core.md#queries)); order by `.Value` where the wrapped value's +order is meant. + +A computed property, a static member and a constant are fine, as `Email.Domain` and `Quantity.Limit` +above. A `ToString()` that masks a secret is in [Logging and secrets](logging-and-secrets.md). + +## Declared inside another type + +```csharp +public sealed partial class Shipment +{ + public readonly partial record struct Id : IValue; // Shipment.Id; its companion class is ShipmentIdExtensions + + public required Id Key { get; init; } +} +``` + +The companion class, which holds `GetValue()` and `ValueOrNull()` for EF Core queries, is named +after the whole nesting chain, so `Shipment.Id` and `Order.Id` in one namespace do not collide. The +OpenAPI document names the component the same way, `ShipmentId`. + +## Generic code over value objects + +The contracts are static abstract, so one method serves every value object of a kind. The generated +types also implement `IEqualityOperators`, `IComparisonOperators` and `IMinMaxValue` where the wrapped +type allows, so they satisfy generic-math constraints. + +```csharp +static Result, TFault> CreateAll(IEnumerable values) + where TSelf : IValidatedValue + where T : notnull + where TFault : notnull => + values.Traverse(TSelf.Create); // the first fault stops it; nothing after it is created +``` + +```csharp +string outcome = CreateAll(["ops@example.com", "nobody"]).Match(list => $"{list.Count} valid", fault => $"refused: {fault}"); // "refused: NoAt" +``` + +## Reading what was generated + +Set `true` in the +declaring project and read `obj/Debug/net10.0/metalama/`: every generated member is there as ordinary +C#. A declaration the generators cannot complete fails the build with CMTK1000 to CMTK1012 or CMTK1022, listed in +the [Generators README](../src/CodoMetis.TypeKit.Generators/README.md#build-errors). diff --git a/global.json b/global.json index 6a1fb6e..7bae113 100644 --- a/global.json +++ b/global.json @@ -8,7 +8,7 @@ "The test key selects Microsoft.Testing.Platform, which xunit.v3 4.x requires on SDK 10." ], "sdk": { - "version": "10.0.401", + "version": "10.0.402", "rollForward": "latestPatch", "allowPrerelease": false }, diff --git a/spikes/ClosedSets/.gitignore b/spikes/ClosedSets/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/spikes/ClosedSets/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/spikes/ClosedSets/ClosedSets.slnx b/spikes/ClosedSets/ClosedSets.slnx new file mode 100644 index 0000000..11cdd3b --- /dev/null +++ b/spikes/ClosedSets/ClosedSets.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/spikes/ClosedSets/Directory.Build.props b/spikes/ClosedSets/Directory.Build.props new file mode 100644 index 0000000..e525bb6 --- /dev/null +++ b/spikes/ClosedSets/Directory.Build.props @@ -0,0 +1,11 @@ + + + + net10.0 + 14 + enable + enable + 2026.1.29 + 0.0.1-spike + + diff --git a/spikes/ClosedSets/README.md b/spikes/ClosedSets/README.md new file mode 100644 index 0000000..7e876d9 --- /dev/null +++ b/spikes/ClosedSets/README.md @@ -0,0 +1,49 @@ +# Spike: can a closed set be a generated type with static members, on Metalama 2026.1 and .NET 10? + +**Status: run 2026-10-13, answered.** From the "closed-set (smart enum) family" part of phase 13 +(2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13). Decision 44 pins 2.0.0 to `net10.0` and Metalama +2026.1, so the family is a generated type with static members, not a C# 15 union; this spike is the +measurement that nothing in it needs anything newer. + +**Question.** A member is a `public static readonly` field of the set's own type, with constant data +through a private constructor the author writes, or none. Can a Metalama 2026.1 aspect, applied by +a transitive fabric as the value-object aspects are, derive the rest: `Name` from the field, `All` +in declaration order, `Match` with one required delegate per member named after it, `TryParse` and +`Parse` by name, `ToString`, `QualifiedName`, a JSON converter writing the name and reading it back +as the member itself, a private constructor where none is declared, and the interface's static +abstract members satisfied so generic code can use any set? + +**Answer: yes, all of it, on .NET SDK 10.0.401 and Metalama 2026.1.29**, with one workaround. The +names are assigned by an initializer injected before the static constructor +(`InitializerKind.BeforeTypeConstructor`), which runs after the static field initializers, so a +member's `Name` needs no `nameof` at the declaration. `Match`'s parameters are added per member in +`buildMethod`; `Func` cannot be built from the method builder's own type parameter +(Metalama asserts "the reference to ITypeSymbol being null is not supported yet"), so the template +declares one `Func` parameter, whose bound type the other members' parameters copy, and the +first is renamed. Everything else is the plain advising API the value-object aspects already use. + +## The candidate + +`Spike.Generators` holds `IClosedSet` (the contract), `ClosedSetAspect` and a transitive +fabric; `Spike.App` declares `OrderStatus` (no data, no constructor) and `OrderFault` (a kind and a +remedy per member through a private constructor) and runs the claims. `dotnet build ClosedSets.slnx` +then `dotnet run --no-build --project Spike.App`. + +## Findings + +| # | Claim | Result | +|---|---|---| +| 1 | `All` holds the members themselves, in declaration order | measured: `Open,Shipped,Cancelled`, `All[1]` is `Shipped` | +| 2 | `Name` comes from the field, assigned after the static field initializers; `QualifiedName` and `ToString` follow | measured: `OrderFault.NotFound`, `Cancelled` | +| 3 | Constant data per member through the author's private constructor, optional | measured: a kind and a remedy, one remedy null | +| 4 | `Match` has one required delegate per member, named after it in camel case | measured: `Match(open:, shipped:, cancelled:)` picks the branch; built through the template-parameter workaround above | +| 5 | `TryParse` and `Parse` by name; an unknown name is `false` or a `FormatException` naming the type, never the input | measured | +| 6 | JSON writes the name and reads the member itself back; an unknown name is a `JsonException` naming the type, never the input | measured, through a nested converter class and `[JsonConverter]` introduced by the aspect | +| 7 | Members are singletons: `==` is reference equality and `Parse` returns the member | measured | +| 8 | The interface's static abstract members are generated, so `T.All` works in generic code | measured: `Names()` | +| 9 | A set without a constructor gets a private one, so `= new()` in a member compiles and nothing outside can construct one | measured: `OrderStatus` builds and runs | +| 10 | Diagnostics: not partial, not sealed, no member, a public constructor, a public static property of the set's type | written (SPIKE001–005); held by the generators' tests once ported | + +**What the port adds beyond this spike:** the fault bits (a kind per member, an optional sentence, +read by the fault responses of step 8), the discovery attribute the satellites read, the OpenAPI +string enum and the EF column by name, and the CMTK ids for the diagnostics. diff --git a/spikes/ClosedSets/Spike.App/Program.cs b/spikes/ClosedSets/Spike.App/Program.cs new file mode 100644 index 0000000..39f8ca2 --- /dev/null +++ b/spikes/ClosedSets/Spike.App/Program.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Spike.App; + +var failures = 0; +void Claim(int number, string text, bool holds) +{ + Console.WriteLine($"{(holds ? "PASS" : "FAIL")} {number,2} {text}"); + if (!holds) failures++; +} + +Claim(1, $"All in declaration order: {string.Join(",", OrderStatus.All.Select(s => s.Name))}", string.Join(",", OrderStatus.All.Select(s => s.Name)) == "Open,Shipped,Cancelled"); +Claim(1, $"All holds the members themselves: {ReferenceEquals(OrderStatus.All[1], OrderStatus.Shipped)}", ReferenceEquals(OrderStatus.All[1], OrderStatus.Shipped)); +Claim(2, $"Name from the field, set after the static field initializers: {OrderStatus.Open.Name}, {OrderFault.Shipped.Name}", OrderStatus.Open.Name == "Open" && OrderFault.Shipped.Name == "Shipped"); +Claim(2, $"QualifiedName and ToString: {OrderFault.NotFound.QualifiedName}, {OrderStatus.Cancelled}", OrderFault.NotFound.QualifiedName == "OrderFault.NotFound" && OrderStatus.Cancelled.ToString() == "Cancelled"); +Claim(3, $"constant data through the author's constructor: {OrderFault.Shipped.Kind} '{OrderFault.Shipped.Remedy}' / {OrderFault.Empty.Remedy ?? ""}", OrderFault.Shipped.Kind == FaultKind.Conflict && OrderFault.Empty.Remedy is null); +Claim(4, $"Match with one delegate per member: {OrderStatus.Shipped.Match(open: () => "o", shipped: () => "s", cancelled: () => "c")}", OrderStatus.Shipped.Match(() => "o", () => "s", () => "c") == "s"); +Claim(5, $"TryParse/Parse by name: {OrderStatus.TryParse("Shipped", out var parsed)} {parsed?.Name}; unknown: {OrderStatus.TryParse("Nope", out _)}", OrderStatus.TryParse("Shipped", out parsed) && ReferenceEquals(parsed, OrderStatus.Shipped) && !OrderStatus.TryParse("Nope", out _)); +try { OrderStatus.Parse("Nope"); Claim(5, "Parse of an unknown name throws", false); } +catch (FormatException e) { Claim(5, $"Parse of an unknown name throws FormatException naming the type, not the input: {e.Message}", e.Message.Contains("OrderStatus") && !e.Message.Contains("Nope")); } +var json = JsonSerializer.Serialize(new Holder(OrderStatus.Shipped, OrderFault.Empty)); +Claim(6, $"JSON writes the name: {json}", json == """{"Status":"Shipped","Fault":"Empty"}"""); +var back = JsonSerializer.Deserialize(json)!; +Claim(6, $"and reads back the member itself: {ReferenceEquals(back.Status, OrderStatus.Shipped)} {ReferenceEquals(back.Fault, OrderFault.Empty)}", ReferenceEquals(back.Status, OrderStatus.Shipped) && ReferenceEquals(back.Fault, OrderFault.Empty)); +try { JsonSerializer.Deserialize("""{"Status":"Nope"}"""); Claim(6, "unknown name refused", false); } +catch (JsonException e) { Claim(6, $"an unknown name is refused, the input not quoted: {e.Message[..Math.Min(80, e.Message.Length)]}", e.Message.Contains("OrderStatus") && !e.Message.Contains("Nope")); } +Claim(7, $"members are singletons: {OrderStatus.Open == OrderStatus.Parse("Open")}", OrderStatus.Open == OrderStatus.Parse("Open")); +Claim(8, $"the interface is satisfied generically: {Names()}", Names() == "NotFound,Shipped,Empty"); + +static string Names() where T : class, Spike.Generators.IClosedSet => string.Join(",", T.All.Select(m => m.Name)); + +Console.WriteLine(failures == 0 ? "ALL PASS" : $"{failures} FAILED"); +return failures; + +public sealed record Holder(OrderStatus Status, OrderFault Fault); diff --git a/spikes/ClosedSets/Spike.App/Sets.cs b/spikes/ClosedSets/Spike.App/Sets.cs new file mode 100644 index 0000000..eabed88 --- /dev/null +++ b/spikes/ClosedSets/Spike.App/Sets.cs @@ -0,0 +1,31 @@ +using Spike.Generators; + +namespace Spike.App; + +/// Plain: no data, no constructor; the generators add a private one. +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} + +public enum FaultKind { NotFound, Conflict, Invalid } + +/// With constant data per member, through a private constructor the author writes. +public sealed partial class OrderFault : IClosedSet +{ + public static readonly OrderFault NotFound = new(FaultKind.NotFound, "The order does not exist."); + public static readonly OrderFault Shipped = new(FaultKind.Conflict, "The order has shipped and cannot change."); + public static readonly OrderFault Empty = new(FaultKind.Invalid, null); + + private OrderFault(FaultKind kind, string? remedy) + { + Kind = kind; + Remedy = remedy; + } + + public FaultKind Kind { get; } + + public string? Remedy { get; } +} diff --git a/spikes/ClosedSets/Spike.App/Spike.App.csproj b/spikes/ClosedSets/Spike.App/Spike.App.csproj new file mode 100644 index 0000000..f5bda72 --- /dev/null +++ b/spikes/ClosedSets/Spike.App/Spike.App.csproj @@ -0,0 +1,8 @@ + + + Exe + + + + + diff --git a/spikes/ClosedSets/Spike.Generators/ClosedSetAspect.cs b/spikes/ClosedSets/Spike.Generators/ClosedSetAspect.cs new file mode 100644 index 0000000..963debc --- /dev/null +++ b/spikes/ClosedSets/Spike.Generators/ClosedSetAspect.cs @@ -0,0 +1,212 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Metalama.Framework.Advising; +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; +using Metalama.Framework.Code.DeclarationBuilders; +using Metalama.Framework.Code.SyntaxBuilders; +using Metalama.Framework.Diagnostics; + +namespace Spike.Generators; + +[CompileTime] +internal sealed class ClosedSetArguments +{ + public required INamedType Set { get; init; } + public required IReadOnlyList Members { get; init; } +} + +internal sealed class ClosedSetAspect : TypeAspect +{ + private static readonly DiagnosticDefinition NotPartial = new("SPIKE001", Severity.Error, "'{0}' must be partial: the generators add its members."); + private static readonly DiagnosticDefinition NotSealed = new("SPIKE002", Severity.Error, "'{0}' must be sealed: a closed set has no subtypes."); + private static readonly DiagnosticDefinition NoMembers = new("SPIKE003", Severity.Error, "'{0}' declares no member: a public static readonly field of its own type."); + private static readonly DiagnosticDefinition PublicConstructor = new("SPIKE004", Severity.Error, "'{0}' has a public constructor, so instances beside its members can be made."); + private static readonly DiagnosticDefinition<(INamedType, string)> MemberIsNotAField = new("SPIKE005", Severity.Error, "'{0}.{1}' is a public static member of the set's type but not a readonly field; a member is a public static readonly field."); + +#pragma warning disable CS0649, CS8618 + [Template] public string _name; + [Template] public static IReadOnlyList _all; +#pragma warning restore CS0649, CS8618 + + public override void BuildAspect(IAspectBuilder builder) + { + var target = builder.Target; + var errors = false; + + void Report(DiagnosticDefinition definition, T args) { builder.Diagnostics.Report(definition.WithArguments(args)); errors = true; } + + if (!target.IsPartial) Report(NotPartial, target); + if (!target.IsSealed) Report(NotSealed, target); + + foreach (var constructor in target.Constructors.Where(c => !c.IsImplicitlyDeclared && c.Accessibility == Accessibility.Public)) + Report(PublicConstructor, target); + + var members = target.Fields + .Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(target)) + .ToList(); + + foreach (var field in members.Where(f => f.Writeability != Writeability.ConstructorOnly)) + Report(MemberIsNotAField, (target, field.Name)); + foreach (var property in target.Properties.Where(p => p.IsStatic && p.Accessibility == Accessibility.Public && p.Type.Equals(target))) + Report(MemberIsNotAField, (target, property.Name)); + + if (members.Count == 0) Report(NoMembers, target); + if (errors) { builder.SkipAspect(); return; } + + var args = new ClosedSetArguments { Set = target, Members = members }; + builder.Tags = args; + + // A set that declares no constructor has the implicit public one; a private parameterless one + // replaces it, so that `= new()` in a member keeps compiling and nothing outside can. + if (!target.Constructors.Any(c => !c.IsImplicitlyDeclared)) + builder.IntroduceConstructor(nameof(PrivateConstructorTemplate), buildConstructor: c => c.Accessibility = Accessibility.Private); + + builder.IntroduceField(nameof(_name), IntroductionScope.Instance, OverrideStrategy.Fail, f => f.Accessibility = Accessibility.Private); + builder.IntroduceProperty(nameof(NameTemplate), buildProperty: p => { p.Name = "Name"; p.Accessibility = Accessibility.Public; }); + builder.IntroduceProperty(nameof(QualifiedNameTemplate), buildProperty: p => { p.Name = "QualifiedName"; p.Accessibility = Accessibility.Public; }); + builder.IntroduceMethod(nameof(ToStringTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => m.Name = "ToString"); + + // The names: assigned once, after the static field initializers, before any static constructor body. + builder.AddInitializer(nameof(NamesInitializerTemplate), InitializerKind.BeforeTypeConstructor); + + // All, in declaration order, as a static field initialized from the members. + var listType = ((INamedType)TypeFactory.GetType(typeof(IReadOnlyList<>))).WithTypeArguments(target); + builder.IntroduceField(nameof(_all), IntroductionScope.Static, OverrideStrategy.Fail, f => + { + f.Accessibility = Accessibility.Private; + f.Type = listType; + f.Writeability = Writeability.ConstructorOnly; + f.InitializerExpression = ExpressionFactory.Parse($"new global::{target.FullName}[] {{ {string.Join(", ", members.Select(m => $"global::{target.FullName}.{m.Name}"))} }}"); + }); + builder.IntroduceProperty(nameof(AllTemplate), buildProperty: p => { p.Name = "All"; p.Type = listType; p.Accessibility = Accessibility.Public; p.IsStatic = true; }); + + // TryParse and Parse by name. + builder.IntroduceMethod(nameof(TryParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "TryParse"; + m.Accessibility = Accessibility.Public; + m.Parameters[1].Type = target.ToNullable(); + }); + builder.IntroduceMethod(nameof(ParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "Parse"; + m.Accessibility = Accessibility.Public; + m.ReturnType = target; + }); + + // Match: one required delegate per member, named after it. + // The template is generic itself, so TResult is a real type parameter; the parameters, one per + // member, are Func built from it. + // Func cannot be built from the builder's own type parameter (Metalama 2026.1 asserts: + // "the reference to ITypeSymbol being null is not supported yet"), so the template declares one + // such parameter, whose bound type the other members' parameters copy; the first is renamed. + builder.IntroduceMethod(nameof(MatchTemplate), IntroductionScope.Instance, OverrideStrategy.Fail, m => + { + m.Name = "Match"; + m.Accessibility = Accessibility.Public; + var func = m.Parameters[0].Type; + m.Parameters[0].Name = Camel(members[0].Name); + foreach (var member in members.Skip(1)) m.AddParameter(Camel(member.Name), func); + }); + + // JSON: the name as a string, read back by name; an unknown name refused without quoting it. + var converter = builder.IntroduceClass($"{target.Name}JsonConverter", buildType: t => + { + t.Accessibility = Accessibility.Public; + t.BaseType = ((INamedType)TypeFactory.GetType(typeof(JsonConverter<>))).WithTypeArguments(target); + t.IsStatic = false; + }); + converter.IntroduceMethod(nameof(JsonReadTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Read"; m.ReturnType = target; }); + converter.IntroduceMethod(nameof(JsonWriteTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Write"; m.Parameters[1].Type = target; }); + builder.IntroduceAttribute(AttributeConstruction.Create(typeof(JsonConverterAttribute), constructorArguments: [converter.Target])); + } + + [CompileTime] + private static string Camel(string name) => char.ToLowerInvariant(name[0]) + name[1..]; + + [Template] public void PrivateConstructorTemplate() { } + + [Template] public string NameTemplate { get => meta.This._name; } + + [Template] public string QualifiedNameTemplate { get => meta.Target.Type.Name + "." + meta.This._name; } + + [Template] public string ToStringTemplate() => meta.This._name; + + [Template] + public static void NamesInitializerTemplate() + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var member in args.Members) + { + member.Value!._name = member.Name; + } + } + + [Template] public static dynamic? AllTemplate { get => meta.ThisType._all; } + + [Template] + public static bool TryParseTemplate(string name, out dynamic? member) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (name == candidate.Name) + { + member = candidate.Value; + return true; + } + } + + member = null; + return false; + } + + [Template] + public static dynamic? ParseTemplate(string name) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value; + } + + throw new FormatException($"{args.Set.Name} has no member of that name."); + } + + [Template] + public TResult MatchTemplate(Func first) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var index = meta.CompileTime(0); + foreach (var member in args.Members) + { + if (ReferenceEquals(meta.This, member.Value)) return meta.Target.Parameters[index].Value!.Invoke(); + index++; + } + + throw new InvalidOperationException($"{args.Set.Name}: not a member."); + } + + [Template] + public dynamic? JsonReadTemplate(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var name = reader.GetString(); + if (name is not null) + { + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value; + } + } + + throw new JsonException($"{args.Set.Name} has no member of that name."); + } + + [Template] + public void JsonWriteTemplate(Utf8JsonWriter writer, dynamic? value, JsonSerializerOptions options) + { + writer.WriteStringValue((string)value!.Name); + } +} diff --git a/spikes/ClosedSets/Spike.Generators/ClosedSetFabric.cs b/spikes/ClosedSets/Spike.Generators/ClosedSetFabric.cs new file mode 100644 index 0000000..e4b8436 --- /dev/null +++ b/spikes/ClosedSets/Spike.Generators/ClosedSetFabric.cs @@ -0,0 +1,14 @@ +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; +using Metalama.Framework.Fabrics; + +namespace Spike.Generators; + +internal sealed class ClosedSetFabric : TransitiveProjectFabric +{ + public override void AmendProject(IProjectAmender amender) => + amender.SelectTypes(includeNestedTypes: true) + .Where(type => type.TypeKind == TypeKind.Class + && type.AllImplementedInterfaces.Any(i => i.IsGeneric && i.Definition.Equals(TypeFactory.GetType(typeof(IClosedSet<>))))) + .AddAspect(_ => new ClosedSetAspect()); +} diff --git a/spikes/ClosedSets/Spike.Generators/IClosedSet.cs b/spikes/ClosedSets/Spike.Generators/IClosedSet.cs new file mode 100644 index 0000000..a028094 --- /dev/null +++ b/spikes/ClosedSets/Spike.Generators/IClosedSet.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Spike.Generators; + +/// A closed set: every instance is a named public static readonly field of the type, and the generators derive the rest. +public interface IClosedSet where TSelf : class, IClosedSet +{ + string Name { get; } + + string QualifiedName { get; } + + static abstract IReadOnlyList All { get; } + + static abstract bool TryParse(string name, [MaybeNullWhen(false)] out TSelf member); +} diff --git a/spikes/ClosedSets/Spike.Generators/Spike.Generators.csproj b/spikes/ClosedSets/Spike.Generators/Spike.Generators.csproj new file mode 100644 index 0000000..03683b7 --- /dev/null +++ b/spikes/ClosedSets/Spike.Generators/Spike.Generators.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/spikes/FabricSpike/README.md b/spikes/FabricSpike/README.md index cd31370..358d935 100644 --- a/spikes/FabricSpike/README.md +++ b/spikes/FabricSpike/README.md @@ -2,7 +2,7 @@ **Question.** Can the value-object aspects be applied by a `TransitiveProjectFabric` shipped in the aspect package, so the package holding `IValue` / `IValidatedValue<,,>` needs no Metalama at all? -The answer decides the package boundaries in [docs/plan.md](../../docs/plan.md). +The answer decides the package boundaries in [the design notes](../../design/plan.md). **Naming (2026-09-27).** The spike predates the package names. `Spike.Abstractions` stands for `CodoMetis.TypeKit` and `Spike.ValueObjects` for `CodoMetis.TypeKit.Generators`. The spike keeps @@ -47,7 +47,7 @@ dotnet run --project spikes/FabricSpike/Spike.App --no-build **A. The analyzer travels with the base package, not with the aspects.** Finding 8 is the fabric's price. With `[Inheritable]` on the interface, anyone who could name `IValue` also got the aspect. Now they get it only by referencing `.Generators`. So `CodoMetis.TypeKit` brings the -analyzer package along (plan §10). When a type implements a marker and the compilation does not +analyzer package along ([Analyzers](../../design/analyzers.md)). When a type implements a marker and the compilation does not reference `CodoMetis.TypeKit.Generators`, it reports an error (CMTK0002). For the same reason `ForbiddenStructDefaultInitializationAnalyzer` (CMTK0001) travels the same way, since it guards the interfaces wherever they are visible. diff --git a/spikes/FaultResponses/README.md b/spikes/FaultResponses/README.md index 6e972fa..cca1ed6 100644 --- a/spikes/FaultResponses/README.md +++ b/spikes/FaultResponses/README.md @@ -1,8 +1,8 @@ -# Spike: can a fault response carry what a client acts on, without giving up decision 38's checks? +# Spike: can a fault response carry what a client acts on, without giving up [decision 38](../../design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s checks? -**Status: outline, not yet run.** Proposed 2026-10-12, from the "Fault responses a client can act -on" part of phase 13 (2.0.0) in [docs/plan.md](../../docs/plan.md) §2. The fault's kind is no longer -a `FaultKind` attribute: it is a property of a smart-enum member, in the same phase. +**Status: run 2026-10-13, answered.** From the "Fault responses a client can act on" part of phase 13 +(2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13). The fault's kind is no longer a `FaultKind` +attribute: it is a property of a closed-set member, in the same phase. **Question.** Decision 38's `ToHttpResult()` answers a fault with its status and its bare name, and a success with `200` or `204`. An application that shows a refusal to a person, creates resources, @@ -12,42 +12,64 @@ OpenAPI description as they are, under JIT and Native AOT, in minimal APIs and M 1. **`FaultResponse` per member**: status, `type` URI and `title`, from an `AddFaultResponses(Func)` overload. -2. **`Fault(TFault Kind, string? Detail)`**, a record struct in the base package, so a - sentence travels per occurrence while the mapping stays keyed on the enum. +2. **`Fault`**, a record struct in the base package, so a sentence travels per occurrence + while the mapping stays keyed on the fault type. 3. **`.WithFaultStatus(fault, status)`**, endpoint metadata overriding the host's status for one endpoint. 4. **`ToHttpResult(success)`**, the endpoint's own success result (`Created`, or an application result that sets an ETag) in `Results>`. 5. **`IFaultFeature`** on the request while the problem is written, so `CustomizeProblemDetails` - can add the host's own extensions, plus an option to qualify `fault` with the enum's name. + can add the host's own extensions, plus an option to qualify `fault` with its type's name. -**Answer: pending.** +**Answer: yes, all five, beside a closed set as a fault type answered by its members' kinds.** +Measured 2026-10-13 on .NET SDK 10.0.401, ASP.NET Core 10.0.12 and Microsoft.AspNetCore.OpenApi +10.0.12, under JIT in minimal APIs and MVC, and published with Native AOT by the consumer smoke test. +The candidate was built in `CodoMetis.TypeKit.AspNetCore` itself, since it is the satellite's shipped +shape, and every claim below is a test of it, so this directory holds no project of its own: the +evidence is `FaultResponsePartsTests` and `FaultResponseTests` (test/CodoMetis.TypeKit.AspNetCore.Tests), +`FaultResponseSampleTests` (the README's blocks, run) and the `aot` consumer of +`test/consumer-smoke-test.sh`. Each guard was seeded (the endpoint override ignored, the feature left +on the request, a kind's status changed, the qualified names ignored, the detail dropped, the stray +status check removed, the problem types dropped from the document), and each seed failed its test. -## The candidate (planned) +## What the measurement decided -The layout follows [spikes/ResultHttp](../ResultHttp/README.md): `Domain.cs` with no ASP.NET in it -(`OrderFault`, methods returning `Result` and `Result>`), -`Candidate.cs` as the satellite would ship it, and `Program.cs` running each part, writing the -documents to `out/` and printing every response body. A `--mvc` switch adds a controller, and an -`-p:Aot=true` publish repeats the minimal-API half under Native AOT. +- **One reading of a fault type.** `FaultType`, internal, holds the members and each one's + name, qualified name, kind and description; the mapping, the startup check, the document and the + response read nothing else. It is made by the registration overload whose constraint says what the + type is (`Enum.GetValues()` for an enum, `TSet.All` for a closed set), and a member's kind + is read through the base package's non-generic `IFault`, so nothing reflects. +- **Overloads by constraint, in two classes.** An enum's `AddFaultResponses` and `ToHttpResult` and a + closed set's take the same parameters and differ only in their constraints, which C# allows only + across classes; with the type arguments inferred or written, the candidate whose constraint fails + drops out (C# 7.3), so each call resolves without ambiguity. `Fault`'s overloads take a + receiver of another shape and live beside the enum's. +- **A kind's default status**: `NotFound` 404, `Conflict` 409, `Invalid` 422, `Forbidden` 403, + `Unavailable` 503, through `FaultResponse.For(kind)`. A host does not change the table; a mapping + of its own, which can call `FaultResponse.For` for the members it keeps, is the escape hatch, as + [decision 38](../../design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s switch is. `FaultKind` starts at 1, so a member whose kind is `default` is refused + where the set is mapped. +- **Where a sentence goes.** RFC 9457 §3.1.4: `detail` helps the client correct this occurrence. A + member's description is the `detail` by default, and a `Fault`'s detail replaces it; the + `title` is the problem type's summary, a `FaultResponse`'s or the status's own. +- **The constraint relaxed.** `FaultProblem` takes `notnull` where it took `struct, Enum`. + Package validation reports it (CP0021) though no closed type it accepted is refused now; it is the + first intended difference from the 1.5.1 baseline, suppressed alone ([decision 48](../../design/adr/0048-intended-break-suppressed-alone-baseline-stays.md)). ## Findings -Each row is a claim the spike must settle. A seed that breaks the guard proves each one, as in -`.claude/skills/verify-the-guard`. - | # | Claim | Result | |---|---|---| -| 1 | The `FaultResponse` overload checks every member at registration, as the `int` one does, and refuses a status outside 400 to 599 | | -| 2 | A `type` URI and `title` reach the body and the operation's problem response, and nothing of the input does | | -| 3 | `ToHttpResult()` over `Result>` and over `Result` both resolve without type arguments, with no ambiguity | | -| 4 | `Detail` reaches the body only when set, and the default response is byte-identical to 1.5.1's | | -| 5 | `WithFaultStatus` changes the status for that endpoint only, in the response and in that operation's document entry | | -| 6 | The startup check refuses a `WithFaultStatus` naming a fault type the endpoint does not answer with | | -| 7 | MVC: the per-endpoint metadata reaches the operation transformer (ResultHttp finding 4 found `PopulateMetadata` handed an empty service provider) | | -| 8 | `ToHttpResult(success)` describes `201` with its body, and a `Location` header is written | | -| 9 | `CustomizeProblemDetails` sees `IFaultFeature` for a problem `ToHttpResult()` wrote, and not for any other problem | | -| 10 | Qualified `fault` names are opt-in; the default stays the bare member name | | -| 11 | `fault` stays a string on the shared `ProblemDetails` component; a per-operation enum, if offered, is opt-in and never a union across fault types | | -| 12 | The mapping, the startup check and the document read a fault type through one internal abstraction, so an enum fault and a smart-enum fault take the same path | | -| 13 | Every part runs under Native AOT with no new trim or AOT warning | | +| 1 | The `FaultResponse` overload checks every member at registration, as the `int` one does, and refuses a status outside 400 to 599 | measured: `A_response_per_member_is_checked_where_it_is_mapped` (200, 302, 600 refused, naming `OrderFault.NameTaken` and `responseOf`); a kind that is no `FaultKind` refused naming the member | +| 2 | A `type` URI and `title` reach the body and the operation's problem response, and nothing of the input does | measured: the body carries both; the response's description lists the problem types answered with that status (`Conflict. Problem types: …`) | +| 3 | `ToHttpResult()` over `Result>`, over `Result` and over a closed set's resolves without type arguments, with no ambiguity | measured: the tests call each without type arguments and compile | +| 4 | `Detail` reaches the body only when set, and the default response is unchanged from 1.5.1 | measured: the default body's members are 1.5.1's (`fault`, `status`, `title`, `traceId`, `type`), and [decision 38](../../design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s tests pass unchanged; `detail` appears for a `Fault` with one and a set member with a description, never for one without | +| 5 | `WithFaultStatus` changes the status for that endpoint only, in the response and in that operation's document entry | measured: 422 on the endpoint, 409 elsewhere; the endpoint's operation lists 422 in place of 409 | +| 6 | The startup check refuses a `WithFaultStatus` naming a fault type the endpoint does not answer with | measured; and an MVC attribute naming no member | +| 7 | MVC: the per-endpoint metadata reaches the operation transformer (ResultHttp finding 4 found `PopulateMetadata` handed an empty service provider) | measured: `[FaultStatus(nameof(OrderFault.NameTaken), 422)]` on an action changes its response and its operation; the transformer reads it from the endpoint's metadata, with the services from the document's context | +| 8 | `ToHttpResult(success)` describes `201` with its body, and a `Location` header is written | measured, minimal API and the smoke test's AOT binary; `Results` needs `TSuccess` annotated as ASP.NET annotates `TResult1`, which the trim analyzer said (IL2091) and the annotation answers, without a suppression | +| 9 | `CustomizeProblemDetails` sees `IFaultFeature` for a problem `ToHttpResult()` wrote, and not for any other problem | measured: present for an enum fault, a `Fault` and a set's member, absent for `TypedResults.Problem` elsewhere and on the request once the problem is written | +| 10 | Qualified `fault` names are opt-in; the default stays the bare member name | measured: `FaultResponseOptions.QualifiedFaultNames` gives `OrderFault.NotFound`, the default `NotFound` | +| 11 | `fault` stays a string on the shared `ProblemDetails` component; a per-operation enum, if offered, is opt-in and never a union across fault types | measured: the component names no fault and no `enum`; no per-operation enum is offered in 2.0.0 | +| 12 | The mapping, the startup check and the document read a fault type through one internal abstraction, so an enum fault and a smart-enum fault take the same path | built so (`FaultType`), and measured by a closed set answered by kind end to end, its document listing 403, 404 and 409 | +| 13 | Every part runs under Native AOT with no new trim or AOT warning | measured: the smoke test's `aot` web consumer publishes a fault set answered by kind, a `WithFaultStatus`, a `Fault` with a detail, a `Created` success and a `CustomizeProblemDetails` reading the feature, with no trim or AOT warning naming a package or woven code | diff --git a/spikes/IntroducedMemberDocs/Directory.Build.props b/spikes/IntroducedMemberDocs/Directory.Build.props new file mode 100644 index 0000000..e525bb6 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Directory.Build.props @@ -0,0 +1,11 @@ + + + + net10.0 + 14 + enable + enable + 2026.1.29 + 0.0.1-spike + + diff --git a/spikes/IntroducedMemberDocs/IntroducedMemberDocs.slnx b/spikes/IntroducedMemberDocs/IntroducedMemberDocs.slnx new file mode 100644 index 0000000..c918812 --- /dev/null +++ b/spikes/IntroducedMemberDocs/IntroducedMemberDocs.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/spikes/IntroducedMemberDocs/README.md b/spikes/IntroducedMemberDocs/README.md new file mode 100644 index 0000000..2cb7830 --- /dev/null +++ b/spikes/IntroducedMemberDocs/README.md @@ -0,0 +1,50 @@ +# Spike: can a generated member carry XML documentation, on Metalama 2026.1? + +**Status: run 2026-10-13, answered: no.** Asked for 2.0.0: the generated members (`From`, `TryFrom`, +`FromKnownGood`, `Parse`, `TryParse`, `Value`, `CompareTo`, the operators, a closed set's `Match`, +`All`, `Name`) carry no XML documentation, so IntelliSense shows nothing for them. + +**Question.** Does a member a Metalama aspect introduces carry the XML documentation comment of its +template, or can an aspect give it one, and does that documentation reach (a) the consumer's compiled +XML documentation file, which a referencing project's IntelliSense reads, and (b) the IDE's +design-time view of the consumer's own project? + +**Answer: neither, on Metalama 2026.1.29 nor on 2027.0.3-preview (SDK 10.0.401).** A template's +documentation comment is not copied to the member introduced from it, whether introduced +declaratively (`[Introduce]`) or programmatically (`IntroduceMethod`, `IntroduceProperty`), and the +framework has no API that sets one: the public API of both versions, searched in its XML +documentation, has no member about documentation, and the only comment API, `meta.InsertComment`, +writes a `//` comment into a body. The consumer's documentation file lists the members written by hand +and none of the introduced ones. Supporting it would need either Metalama to copy or accept +documentation, or a step of our own that rewrites the consumer's documentation file after the build; +the second is a hand-rolled workaround that would also miss the IDE's view of the consumer's own +project, so it is not done. + +## The candidate + +`Spike.Aspects` holds `DocumentedAspect`, which introduces a method and a property declaratively and +a method, a property and a nested class programmatically, each template with a `///` comment. +`Spike.App` applies it to a documented `Target`, with `GenerateDocumentationFile` and +`MetalamaEmitCompilerTransformedFiles`. `dotnet build IntroducedMemberDocs.slnx`, then read +`Spike.App/bin/Debug/net10.0/Spike.App.xml` and `Spike.App/obj/Debug/net10.0/metalama/Target.cs`. +`-p:MetalamaVersion=2027.0.3-preview` measures the preview. + +## Findings + +| # | Claim | Result | +|---|---|---| +| 1 | The woven `Target` holds the four introduced members and the nested class | measured: all five, in `obj/.../metalama/Target.cs` | +| 2 | An introduced member carries its template's `///` comment | measured: no; the only `///` in the woven file is the hand-written one on `Target` | +| 3 | The consumer's XML documentation file documents an introduced member | measured: no; one ``, `T:Spike.App.Target` | +| 4 | An aspect can set a member's documentation | no such API in Metalama.Framework 2026.1.29 or 2027.0.3-preview (their XML documentation has no member about documentation; `meta.InsertComment` writes a `//` comment in a body) | +| 5 | Metalama 2027.0.3-preview copies the template's comment | measured: no, as 2 and 3 | +| 6 | An undocumented introduced public member is a CS1591 in a consumer with `GenerateDocumentationFile` | measured: no warning at all, so a consumer that treats warnings as errors builds | +| 7 | The IDE's design-time view shows documentation for an introduced member | not measured: the design-time pipeline runs in the IDE's process, not in a command-line build. With no documentation at compile time and no API to give one, nothing suggests it would | +| 8 | It changes when the aspect library writes its own documentation file (`-p:GenerateDocumentationFile=true`) | measured: no; the library's file documents its five templates, the consumer's still one member | +| 9 | The same holds for this repository's generators, whose templates are documented and whose project writes a documentation file | measured in the woven probes (`test/CodoMetis.TypeKit.Generators.Probes/obj/.../metalama/Probes.cs`): `From` has none of its template's "Wraps any value but null", a closed set's `Parse` none of "A FormatException naming the set" | + +## What it decided + +Generated members stay undocumented in 2.0.0. The roadmap's 2.1 line: ask Metalama for documentation +on introduced declarations (copied from the template, or set through the builder), and document every +generated member once it exists, with a test that reads a consumer's documentation file. diff --git a/spikes/IntroducedMemberDocs/Spike.App/Spike.App.csproj b/spikes/IntroducedMemberDocs/Spike.App/Spike.App.csproj new file mode 100644 index 0000000..9adec98 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Spike.App/Spike.App.csproj @@ -0,0 +1,15 @@ + + + + Library + true + true + + + + + diff --git a/spikes/IntroducedMemberDocs/Spike.App/Target.cs b/spikes/IntroducedMemberDocs/Spike.App/Target.cs new file mode 100644 index 0000000..91cd027 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Spike.App/Target.cs @@ -0,0 +1,7 @@ +using Spike.Aspects; + +namespace Spike.App; + +/// The type the aspect introduces into. Documented by hand, so it is not a CS1591 itself. +[DocumentedAspect] +public partial class Target; diff --git a/spikes/IntroducedMemberDocs/Spike.Aspects/DocumentedAspect.cs b/spikes/IntroducedMemberDocs/Spike.Aspects/DocumentedAspect.cs new file mode 100644 index 0000000..cdd3ec4 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Spike.Aspects/DocumentedAspect.cs @@ -0,0 +1,48 @@ +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; + +namespace Spike.Aspects; + +/// +/// Introduces one member of each kind, each from a template that carries an XML documentation +/// comment, declaratively and programmatically, so the build shows which of them reach the +/// consumer's woven code and its documentation file. +/// +public sealed class DocumentedAspect : TypeAspect +{ + /// Declarative method: introduced by [Introduce]. + /// One. + [Introduce] + public static int DeclaredMethod() => 1; + + /// Declarative property: introduced by [Introduce]. + [Introduce] + public static int DeclaredProperty => 2; + + /// Programmatic method: introduced by IntroduceMethod. + /// The value. + /// The value. + [Template] + public static int ProgrammaticMethodTemplate(int value) => value; + + /// Programmatic property: introduced by IntroduceProperty. + [Template] + public int ProgrammaticPropertyTemplate => 3; + + public override void BuildAspect(IAspectBuilder builder) + { + builder.IntroduceMethod(nameof(ProgrammaticMethodTemplate), IntroductionScope.Static, buildMethod: m => + { + m.Name = "ProgrammaticMethod"; + m.Accessibility = Accessibility.Public; + }); + + builder.IntroduceProperty(nameof(ProgrammaticPropertyTemplate), buildProperty: p => + { + p.Name = "ProgrammaticProperty"; + p.Accessibility = Accessibility.Public; + }); + + builder.IntroduceClass("NestedConverter", buildType: t => t.Accessibility = Accessibility.Public); + } +} diff --git a/spikes/IntroducedMemberDocs/Spike.Aspects/Spike.Aspects.csproj b/spikes/IntroducedMemberDocs/Spike.Aspects/Spike.Aspects.csproj new file mode 100644 index 0000000..03683b7 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Spike.Aspects/Spike.Aspects.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/spikes/MergePatch/.gitignore b/spikes/MergePatch/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/spikes/MergePatch/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/spikes/MergePatch/Candidate.cs b/spikes/MergePatch/Candidate.cs new file mode 100644 index 0000000..ea2cece --- /dev/null +++ b/spikes/MergePatch/Candidate.cs @@ -0,0 +1,216 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using CodoMetis.TypeKit.CompilerServices; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace MergePatch; + +public enum PatchState { Absent, Null, Value } + +/// Shape A: a converter factory that closes a generic converter with MakeGenericType, and a static reference from the struct to that converter so the instantiation exists under Native AOT. +#if FACTORY_STATIC +[JsonConverter(typeof(PatchAStaticFieldFactory))] +#elif FACTORY_REGISTRY +[JsonConverter(typeof(PatchARegistryFactory))] +#else +[JsonConverter(typeof(PatchAJsonConverterFactory))] +#endif +public readonly record struct PatchA +{ + // The instantiation PatchAJsonConverter is compiled whenever PatchA is (claim 3). Under + // Native AOT that is not enough for MakeGenericType + Activator: the constructor has no + // reflection invoker ("missing native code or metadata", measured). The static field, read by + // reflection, and the registry the static constructor fills are the two reflection-light ways + // from a Type to the instance. + internal static readonly PatchAJsonConverter Converter = new(); + + static PatchA() => PatchARegistryFactory.Register(typeof(PatchA), Converter); + + private readonly T? _value; + private readonly PatchState _state; + + private PatchA(T? value, PatchState state) { _value = value; _state = state; } + + public static PatchA Absent() => default; + public static PatchA Null() => new(default, PatchState.Null); + public static PatchA Of(T value) => new(value, PatchState.Value); + + public PatchState State => _state; + + public TResult Match(Func onValue, Func onNull, Func onAbsent) => + _state switch { PatchState.Value => onValue(_value!), PatchState.Null => onNull(), _ => onAbsent() }; + + public bool TryGetValue([NotNullWhen(true)] out T? value) { value = _value; return _state == PatchState.Value; } + + public override string ToString() => $"PatchA {{ State = {_state} }}"; +} + +public sealed class PatchAJsonConverter : JsonConverter> +{ + public override bool HandleNull => true; + + public override PatchA Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return PatchA.Null(); + + var value = JsonSerializer.Deserialize(ref reader, ContractOf(options)); + return value is null ? PatchA.Null() : PatchA.Of(value); + } + + public override void Write(Utf8JsonWriter writer, PatchA value, JsonSerializerOptions options) + { + switch (value.State) + { + case PatchState.Absent: + throw new NotSupportedException("An absent patch cannot be written. Mark the property [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)], so an absent patch is left out of the document."); + case PatchState.Null: + writer.WriteNullValue(); + break; + default: + value.TryGetValue(out var inner); + JsonSerializer.Serialize(writer, inner!, ContractOf(options)); + break; + } + } + + /// + /// T's contract: the options' own where they have one; else made as the generated converters make a + /// wrapped type's (GeneratedJson.TypeInfo): a registered converter, the type's own attribute (a value + /// object), or the serializer's built-in converter for a primitive. A class T that a source-generated + /// context never saw, because the patch's converter hides it, is refused with the serializer's own + /// message naming the context (claim 4). + /// + private static JsonTypeInfo ContractOf(JsonSerializerOptions options) => + options.TryGetTypeInfo(typeof(T), out var info) ? (JsonTypeInfo)info : GeneratedJson.TypeInfo(options, BuiltIn()); + + private static JsonConverter? BuiltIn() => (JsonConverter?)(object?)( + typeof(T) == typeof(string) ? JsonMetadataServices.StringConverter + : typeof(T) == typeof(int) ? JsonMetadataServices.Int32Converter + : typeof(T) == typeof(long) ? JsonMetadataServices.Int64Converter + : typeof(T) == typeof(bool) ? JsonMetadataServices.BooleanConverter + : typeof(T) == typeof(decimal) ? JsonMetadataServices.DecimalConverter + : typeof(T) == typeof(double) ? JsonMetadataServices.DoubleConverter + : typeof(T) == typeof(Guid) ? JsonMetadataServices.GuidConverter + : typeof(T) == typeof(DateTime) ? JsonMetadataServices.DateTimeConverter + : typeof(T) == typeof(DateTimeOffset) ? JsonMetadataServices.DateTimeOffsetConverter + : typeof(T) == typeof(DateOnly) ? JsonMetadataServices.DateOnlyConverter + : typeof(T) == typeof(TimeOnly) ? JsonMetadataServices.TimeOnlyConverter + : (JsonConverter?)null); +} + +public sealed class PatchAJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) => + typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(PatchA<>); + + // The instantiation exists: PatchA holds a static PatchAJsonConverter, compiled with it. Measured in this spike under Native AOT. + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "PatchA.Converter keeps PatchAJsonConverter compiled for every T a consumer uses; measured in spikes/MergePatch under Native AOT.")] + [UnconditionalSuppressMessage("Trimming", "IL2055", Justification = "Same: the closed converter type is reached from PatchA, never only here.")] + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var argument = typeToConvert.GetGenericArguments()[0]; + var closed = typeof(PatchAJsonConverter<>).MakeGenericType(argument); + return (JsonConverter)Activator.CreateInstance(closed)!; + } +} + +/// Shape A, variant: no MakeGenericType at all. The factory reads the static converter PatchA<T> holds, through the field the struct keeps for every T it is used with. +public sealed class PatchAStaticFieldFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) => + typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(PatchA<>); + + [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Measured: the field is kept with PatchA under Native AOT, or it is not, which this spike shows.")] + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => + (JsonConverter)typeToConvert.GetField("Converter", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; +} + +/// Shape A, variant: a registry the struct's static constructor fills; the factory runs that constructor for the closed type and looks the converter up. No reflection over members at all. +public sealed class PatchARegistryFactory : JsonConverterFactory +{ + private static readonly System.Collections.Concurrent.ConcurrentDictionary Registry = new(); + + public static void Register(Type patch, JsonConverter converter) => Registry[patch] = converter; + + public override bool CanConvert(Type typeToConvert) => + typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(PatchA<>); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(typeToConvert.TypeHandle); + return Registry.TryGetValue(typeToConvert, out var converter) + ? converter + : throw new NotSupportedException($"{typeToConvert} registered no converter: its static constructor did not run."); + } +} + +#if OPEN_GENERIC +/// Shape B: an open generic converter type named by the attribute, closed by the serializer if it supports that. +[JsonConverter(typeof(PatchBJsonConverter<>))] +public readonly record struct PatchB +{ + private readonly T? _value; + private readonly PatchState _state; + private PatchB(T? value, PatchState state) { _value = value; _state = state; } + public static PatchB Null() => new(default, PatchState.Null); + public static PatchB Of(T value) => new(value, PatchState.Value); + public PatchState State => _state; +} + +public sealed class PatchBJsonConverter : JsonConverter> +{ + public override bool HandleNull => true; + public override PatchB Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null ? PatchB.Null() : PatchB.Of(JsonSerializer.Deserialize(ref reader, (JsonTypeInfo)options.GetTypeInfo(typeof(T)))!); + public override void Write(Utf8JsonWriter writer, PatchB value, JsonSerializerOptions options) => throw new NotSupportedException(); +} +#endif + +/// +/// The candidate schema transformer. A patch property is described as its argument's schema, inlined +/// (the reference-id hook below returns null for a patch), never required, and admitting null, since +/// the Null state is part of the wire format whatever the argument: whether the application accepts +/// a clear is its own rule, not the document's. +/// +public sealed class PatchSchemaTransformer : IOpenApiSchemaTransformer +{ + public static bool IsPatch(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(PatchA<>); + + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var info = context.JsonTypeInfo; + + // The object holding patch properties: none of them is required. + if (info.Kind == JsonTypeInfoKind.Object && schema.Required is { Count: > 0 } required) + { + foreach (var property in info.Properties) + if (IsPatch(property.PropertyType)) required.Remove(property.Name); + } + + if (!IsPatch(info.Type)) return; + + var argument = info.Type.GetGenericArguments()[0]; + + // A value object that appears only inside a patch has no metadata in a source-generated + // context (the patch's converter hides it), so ASP.NET cannot make its schema there (measured + // under Native AOT). Its wrapped type's schema is what the value-object transformer would have + // put in anyway: GeneratedValueObjectAttribute names it, as the satellites read it. + var wrapped = argument.GetCustomAttributes(inherit: false) + .Select(attribute => attribute.GetType()) + .FirstOrDefault(type => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CodoMetis.TypeKit.CompilerServices.GeneratedValueObjectAttribute<,>)) + ?.GetGenericArguments()[1]; + var inner = await context.GetOrCreateSchemaAsync(wrapped ?? argument, parameterDescription: null, cancellationToken); + + schema.Properties?.Clear(); + schema.Type = (inner.Type ?? JsonSchemaType.Null) | JsonSchemaType.Null; + schema.Format = inner.Format; + schema.Pattern = inner.Pattern; + schema.Items = inner.Items; + schema.AdditionalProperties = inner.AdditionalProperties; + schema.Description = $"A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it.{(inner.Description is null ? "" : " " + inner.Description)}"; + } +} diff --git a/spikes/MergePatch/Directory.Build.props b/spikes/MergePatch/Directory.Build.props new file mode 100644 index 0000000..578e79b --- /dev/null +++ b/spikes/MergePatch/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/spikes/MergePatch/MergePatch.csproj b/spikes/MergePatch/MergePatch.csproj new file mode 100644 index 0000000..83d4cfa --- /dev/null +++ b/spikes/MergePatch/MergePatch.csproj @@ -0,0 +1,37 @@ + + + Exe + net10.0 + 14 + enable + enable + + $(NoWarn);ASP0020 + + true + + + true + $(DefineConstants);AOT + + + + $(DefineConstants);FACTORY_STATIC + + + $(DefineConstants);FACTORY_REGISTRY + + + + $(DefineConstants);OPEN_GENERIC + + + + + + + + + + + diff --git a/spikes/MergePatch/Model.cs b/spikes/MergePatch/Model.cs new file mode 100644 index 0000000..58563af --- /dev/null +++ b/spikes/MergePatch/Model.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace MergePatch; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value.Trim()) : Result.Error(EmailFault.NoAt); +} + +public sealed record Address(string Street, string City); + +/// Shape A's request: a reference type, a value object, a primitive, a class, a nullable value object. +public sealed record UpdateA( + PatchA Name, + PatchA Email, + PatchA Quantity, + PatchA Address); + +/// What the write claims serialize: the ignore condition that leaves an absent patch out. +public sealed record OutgoingA( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] PatchA Name, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] PatchA Quantity); + +public sealed record BareOutgoingA(PatchA Name); + +public sealed record PatchStates(string Name, string Email, string Quantity, string Address); + +#if OPEN_GENERIC +public sealed record UpdateB(PatchB Name, PatchB Quantity); +#endif + +// The analyzers run here as in a consumer: a default patch is a state and must stay silent (claim 9). +public sealed class Defaults +{ + public PatchA Unassigned { get; set; } + public static PatchA Written() => default; + public static PatchA Made() => new(); +} + +[JsonSerializable(typeof(UpdateA))] +[JsonSerializable(typeof(Address))] // a class a patch wraps must be in the context: the patch's converter hides it from the generator (claim 4) +[JsonSerializable(typeof(OutgoingA))] +[JsonSerializable(typeof(BareOutgoingA))] +[JsonSerializable(typeof(PatchStates))] +#if OPEN_GENERIC +[JsonSerializable(typeof(UpdateB))] +#endif +internal partial class SpikeJson : JsonSerializerContext; diff --git a/spikes/MergePatch/Program.cs b/spikes/MergePatch/Program.cs new file mode 100644 index 0000000..02ac6ef --- /dev/null +++ b/spikes/MergePatch/Program.cs @@ -0,0 +1,175 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit.AspNetCore; +using MergePatch; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.OpenApi; +using System.Text.Json.Serialization.Metadata; + +Directory.CreateDirectory("out"); +var flavour = System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported ? "jit" : "aot"; +var failures = 0; + +void Claim(int number, string text, bool holds) +{ + Console.WriteLine($"{(holds ? "PASS" : "FAIL")} {number,2} {text}"); + if (!holds) failures++; +} + +static string State(PatchA patch) => patch.Match(v => $"Value({v})", () => "Null", () => "Absent"); + +// ---- claims 1, 2, 4, 5: reading, under the reflection resolver and the source-generated context +var resolvers = new List<(string Name, JsonSerializerOptions Options)> { ("source-generated", new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = SpikeJson.Default }) }; +#if !AOT +resolvers.Insert(0, ("reflection", new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() })); +#endif +foreach (var (name, options) in resolvers) +{ + + UpdateA Read(string json) => JsonSerializer.Deserialize(json, (JsonTypeInfo)options.GetTypeInfo(typeof(UpdateA)))!; + string Write(T value) => JsonSerializer.Serialize(value, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); + + var absent = Read("""{}"""); + Claim(1, $"[{name}] every property absent reads as Absent (default): {State(absent.Name)}, {State(absent.Email)}, {State(absent.Quantity)}, {State(absent.Address)}", + absent.Name.State == PatchState.Absent && absent.Email.State == PatchState.Absent && absent.Quantity.State == PatchState.Absent && absent.Address.State == PatchState.Absent); + + var nulls = Read("""{"name":null,"email":null,"quantity":null,"address":null}"""); + Claim(2, $"[{name}] null reads as Null for every T, the value object included: {State(nulls.Name)}, {State(nulls.Email)}, {State(nulls.Quantity)}, {State(nulls.Address)}", + nulls.Name.State == PatchState.Null && nulls.Email.State == PatchState.Null && nulls.Quantity.State == PatchState.Null && nulls.Address.State == PatchState.Null); + + try + { + var values = Read("""{"name":"Ada","email":"ada@example.com","quantity":3,"address":{"street":"1 Main","city":"Oslo"}}"""); + Claim(2, $"[{name}] a value reads as Value, a primitive and a class under the context's own metadata, the value object under its converter: {State(values.Name)}, {State(values.Email)}, {State(values.Quantity)}, {State(values.Address)}", + values.Name.TryGetValue(out var n) && n == "Ada" && values.Email.TryGetValue(out var e) && e.Value == "ada@example.com" + && values.Quantity.TryGetValue(out var q) && q == 3 && values.Address.TryGetValue(out var a) && a.City == "Oslo"); + } + catch (Exception e) + { + Claim(4, $"[{name}] T's contract: {e.GetType().Name}: {e.Message[..Math.Min(160, e.Message.Length)]}", false); + } + + try + { + Read("""{"email":"not-an-address"}"""); + Claim(5, $"[{name}] a refused value object inside a patch is refused", false); + } + catch (JsonException e) + { + Claim(5, $"[{name}] a refused value object inside a patch throws the value object's own JsonException, naming no input: {e.Message[..Math.Min(120, e.Message.Length)]}", e.Message.Contains("Email") && !e.Message.Contains("not-an-address")); + } + + // ---- claim 7: writing + var outgoing = Write(new OutgoingA(PatchA.Null(), PatchA.Absent())); + Claim(7, $"[{name}] Absent is left out with WhenWritingDefault, Null writes null: {outgoing}", outgoing == """{"name":null}"""); + var written = Write(new OutgoingA(PatchA.Of("Ada"), PatchA.Of(2))); + Claim(7, $"[{name}] Value writes T: {written}", written == """{"name":"Ada","quantity":2}"""); + try + { + Write(new BareOutgoingA(PatchA.Absent())); + Claim(7, $"[{name}] writing an Absent patch without the ignore condition is refused", false); + } + catch (NotSupportedException e) + { + Claim(7, $"[{name}] writing an Absent patch without the ignore condition is refused, naming the condition: {e.Message[..60]}…", e.Message.Contains("WhenWritingDefault")); + } +} + +// ---- claim 3 variant: the factory without MakeGenericType, reading the struct's static field +try +{ + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = SpikeJson.Default }; + options.Converters.Insert(0, new PatchAStaticFieldFactory()); + var read = JsonSerializer.Deserialize("""{"quantity":5,"email":"a@b.c"}""", (JsonTypeInfo)options.GetTypeInfo(typeof(UpdateA)))!; + Claim(3, $"static-field factory (no MakeGenericType): {State(read.Quantity)}, {State(read.Email)}", read.Quantity.TryGetValue(out var q) && q == 5 && read.Email.State == PatchState.Value); +} +catch (Exception e) +{ + Claim(3, $"static-field factory (no MakeGenericType): {e.GetType().Name}: {e.Message[..Math.Min(140, e.Message.Length)]}", false); +} + +#if OPEN_GENERIC +try +{ + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = SpikeJson.Default }; + var b = JsonSerializer.Deserialize("""{"name":"Ada","quantity":null}""", (JsonTypeInfo)options.GetTypeInfo(typeof(UpdateB)))!; + Claim(4, $"shape B, an open generic converter in [JsonConverter]: {b.Name.State}, {b.Quantity.State}", b.Name.State == PatchState.Value && b.Quantity.State == PatchState.Null); +} +catch (Exception e) +{ + Claim(4, $"shape B, an open generic converter in [JsonConverter]: {e.GetType().Name}: {e.Message[..Math.Min(160, e.Message.Length)]}", false); +} +#endif + +// ---- claims 5 (HTTP), 8: a host +foreach (var version in new[] { OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0 }) +foreach (var transform in new[] { false, true }) +{ + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { EnvironmentName = Environments.Development }); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.ConfigureHttpJsonOptions(json => json.SerializerOptions.TypeInfoResolverChain.Insert(0, SpikeJson.Default)); + builder.Services.AddProblemDetails(); + builder.Services.AddOpenApi(openApi => + { + openApi.OpenApiVersion = version; + openApi.AddTypeKit(); + if (transform) + { + var inner = openApi.CreateSchemaReferenceId; + openApi.CreateSchemaReferenceId = type => PatchSchemaTransformer.IsPatch(type.Type) ? null : inner(type); + openApi.AddSchemaTransformer(); + } + }); + + await using var app = builder.Build(); + app.UseStatusCodePages(); + app.MapOpenApi(); + app.MapPatch("/orders/{id}", (int id, UpdateA update) => Results.Ok(new PatchStates(State(update.Name), State(update.Email), State(update.Quantity), State(update.Address)))); + await app.StartAsync(); + + var address = app.Services.GetRequiredService().Features.Get()!.Addresses.First(); + using var client = new HttpClient { BaseAddress = new Uri(address) }; + + if (version == OpenApiSpecVersion.OpenApi3_1 && !transform) + { + var ok = await client.PatchAsync("/orders/1", new StringContent("""{"name":"Ada","quantity":null}""", Encoding.UTF8, "application/json")); + var body = await ok.Content.ReadAsStringAsync(); + Claim(5, $"HTTP: a patch body binds through the context: {(int)ok.StatusCode} {body}", ok.StatusCode == HttpStatusCode.OK && body.Contains("Value(Ada)") && body.Contains("\"quantity\":\"Null\"") && body.Contains("\"email\":\"Absent\"")); + + var refused = await client.PatchAsync("/orders/1", new StringContent("""{"email":"nope"}""", Encoding.UTF8, "application/json")); + var problem = await refused.Content.ReadAsStringAsync(); + Claim(5, $"HTTP: a refused value object inside a patch is 400, the input not quoted: {(int)refused.StatusCode} {problem[..Math.Min(100, problem.Length)]}", refused.StatusCode == HttpStatusCode.BadRequest && !problem.Contains("nope")); + } + + var response = await client.GetAsync("/openapi/v1.json"); + var document = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) { Claim(8, $"document {version} transform={transform}: {(int)response.StatusCode} {document[..Math.Min(400, document.Length)]}", false); await app.StopAsync(); continue; } + var file = $"out/{flavour}-{(version == OpenApiSpecVersion.OpenApi3_1 ? "3.1" : "3.0")}-{(transform ? "transformed" : "plain")}.json"; + await File.WriteAllTextAsync(file, document); + + var schema = JsonNode.Parse(document)!["components"]?["schemas"]?["UpdateA"]; + var props = schema?["properties"]; + var required = schema?["required"]?.AsArray().Select(n => n!.GetValue()).ToArray() ?? []; + var nameSchema = props?["name"]?.ToJsonString(); + var emailSchema = props?["email"]?.ToJsonString(); + var quantitySchema = props?["quantity"]?.ToJsonString(); + Console.WriteLine($" [{Path.GetFileName(file)}] required=[{string.Join(",", required)}] name={nameSchema} email={emailSchema} quantity={quantitySchema}"); + if (transform) + { + var nullable = version == OpenApiSpecVersion.OpenApi3_1 ? "\"null\"" : "\"nullable\":true"; + Claim(8, $"[{Path.GetFileName(file)}] a patch property is its argument's schema inlined, not required, admitting null", + required.Length == 0 + && nameSchema is not null && !nameSchema.Contains("$ref") && nameSchema.Contains("string") && nameSchema.Contains(nullable) + && quantitySchema is not null && !quantitySchema.Contains("$ref") && quantitySchema.Contains("integer") && quantitySchema.Contains(nullable) + && emailSchema is not null && !emailSchema.Contains("$ref") && emailSchema.Contains("string") && emailSchema.Contains(nullable)); + } + await app.StopAsync(); +} + +Console.WriteLine(failures == 0 ? "ALL PASS" : $"{failures} FAILED"); +return failures; diff --git a/spikes/MergePatch/README.md b/spikes/MergePatch/README.md new file mode 100644 index 0000000..bd2b19a --- /dev/null +++ b/spikes/MergePatch/README.md @@ -0,0 +1,47 @@ +# Spike: can a merge-patch field tell absent from null, read a value object, and survive Native AOT? + +**Status: run 2026-10-13, answered.** From the "`Patch`" part of phase 13 (2.0.0) in +the former plan's phase log (git, `design/plan.md` before 2026-10-13). + +**Question.** A JSON Merge Patch (RFC 7396) field has three states, absent, null and a value, and +System.Text.Json calls no converter for a property it does not find. Can one struct in the base +package carry the three, with `default` as absent, read a value object with its own rule, write +through `[JsonIgnore(WhenWritingDefault)]`, be described in the OpenAPI document as its argument, +and do all of it under a source-generated context and Native AOT, where a converter over an open +`T` is the hard part? + +**Answer: yes, with the converter registered by the struct's own static constructor.** Measured +2026-10-13 on .NET SDK 10.0.401, Microsoft.AspNetCore.OpenApi 10.0.12, against a value object woven +by the real generators and the real OpenAPI satellite, under the reflection resolver, a +source-generated context, and published with Native AOT. Of the converter shapes, `MakeGenericType` +plus `Activator` fails under AOT ("missing native code or metadata": the closed converter type +exists, its constructor has no reflection invoker), a static field on the struct read by reflection +is trimmed, and an open generic converter type in `[JsonConverter]` is refused by the source +generator (SYSLIB1220, no metadata). A registry the closed `Patch`'s static constructor fills, +which the factory runs with `RuntimeHelpers.RunClassConstructor` for the type it is asked for, passes +every claim under AOT, with one IL2059 trim warning whose reason is this measurement. Shipped in 2.0.0 +as `Patch` with `PatchTests`, `PatchSchemaTests` and the consumer smoke test holding each row. + +## The candidate + +A web project: `Candidate.cs` holds `PatchA` with the three factories selectable by +`-p:Factory=Static|Registry` (the default is `MakeGenericType`), shape B behind `-p:OpenGeneric=true`, +and the schema transformer; `Model.cs` a woven `Email`, the request records and the context; +`Program.cs` runs each claim under both resolvers, then a host for the HTTP and document claims, +writing the documents to `out/`. `dotnet run -p:Factory=Registry` is the JIT run and +`dotnet publish -c Release -p:Aot=true -p:Factory=Registry -o out/aot-Registry` the AOT one. + +## Findings + +| # | Claim | Result | +|---|---|---| +| 1 | `default` is absent: a property not in the document reads as Absent, under both resolvers | measured: all four properties Absent, reflection and source-generated, JIT and AOT | +| 2 | null reads as Null and a value as Value, for a string, a value object, an int and a class | measured, both resolvers; the value object through its generated converter | +| 3 | A converter over an open `T` under Native AOT | measured: `MakeGenericType` + `Activator` throws "missing native code or metadata"; the static field read by reflection is null (trimmed); the registry filled by the static constructor works for every `T`, including the record-struct value object | +| 4 | T's contract under a source-generated context | measured: the generator never sees `T` behind the converter, so `string` and `int` had no metadata until the converter supplied `JsonMetadataServices`'s built-in ones; a class must be listed in the context, and the serializer's own message says so; the open generic `[JsonConverter(typeof(C<>))]` is SYSLIB1220 | +| 5 | A value `Create` refuses inside a patch is refused as anywhere else, and a bound body answers 400 | measured: `Email refused the JSON value (NoAt)`, the input not quoted; HTTP 400 problem details | +| 6 | A null for a value object is the Null state, not a refusal | measured; so `Patch` is never needed, and a `Nullable<>` argument has no contract in a source-generated context anyway | +| 7 | Writing: Absent is left out under `WhenWritingDefault`, refused without it; Null writes null; Value writes T | measured, both resolvers, JIT and AOT | +| 8 | The document: a patch property is its argument's schema, inlined, not required, admitting null, in 3.1 and 3.0 | measured, JIT and AOT, once the reference-id hook inlines the patch, the parent's `required` drops patch properties, and a value object is described through its wrapped type (its own contract is absent under source generation) | +| 9 | CMTK0001, CMTK0005 and CMTK0006 are silent on a default, a `new()`, an array and an unassigned member of `Patch` | measured: the spike's build reports no CMTK diagnostic | +| 10 | Native AOT publish of the registry shape: no trim or AOT warning in the candidate beyond IL2059 at `RunClassConstructor` | measured; the binary passes every claim but the static-field variant, which is recorded above as unviable | diff --git a/spikes/RangeValueObjects/README.md b/spikes/RangeValueObjects/README.md index 7236bc3..7d4d485 100644 --- a/spikes/RangeValueObjects/README.md +++ b/spikes/RangeValueObjects/README.md @@ -17,7 +17,7 @@ TypeKit's EF converter has to compose onto a mapping that another plugin provide translation has to hand the range algebra's translator an expression it recognises. **Answer: yes, with no bridge package. On 8.0.0 every gap was on the wire, in ValueRanges' JSON, and -TypeKit reproduced it exactly, as decision 26 requires. ValueRanges 9.0.0 closes all three, and the +TypeKit reproduced it exactly, as [decision 26](../../design/adr/0026-value-object-s-json-wrapped-type.md) requires. ValueRanges 9.0.0 closes all three, and the value object inherits each fix with no change in TypeKit.** Measured 2026-10-11 against value objects woven by the real generators (main at 1.5.0), CodoMetis.ValueRanges and CodoMetis.ValueRanges.EFCore.PostgreSQL 8.0.0, then 9.0.0, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3, @@ -67,13 +67,13 @@ OpenAPI transformer (`ValueRangesSchemaTransformer.cs`, copied verbatim) added b | 7 | JSON **without** `AddRangeConverters()`. On 8.0.0: `DateRange` writes `{"Length":181}`, and reading throws `NotSupportedException` (an abstract type). The value object writes the same `{"Length":181}`, reports "could not read the JSON value as DateRange", and an ASP.NET body holding it is a 400. Parity held, but the wire was wrong for both types, and it failed silently on writing. On 9.0.0: both write `"[2026-01-01,2026-06-30]"` and read it back, under default and web options. A refused range is "refused the JSON value (NotFinite)". With nothing registered, a valid body now binds (200), and a refused one is a 400 | ✅ 9.0.0 | | 8 | A dictionary key. On 8.0.0: neither `DateRange` nor the value object can be one, with or without the converters. On 9.0.0: both write `{"[2026-01-01,2026-06-30]":1}` under all four option sets. A `Dictionary` body binds (200), and a key `Create` refuses is a 400 | ✅ 9.0.0 | | 9 | EF model: in both registration orders (`UseNpgsql(n => n.UseValueRanges()).UseTypeKit()` and `UseTypeKit().UseNpgsql(…)`), `Period` and the nullable `Availability` map to `daterange`. Their mapping is ValueRanges' `ValueRangeTypeMapping` with TypeKit's converter composed onto it (`CompositeValueConverter`). `HasIndex(c => c.Period).HasMethod("gist")` creates a GiST index | ✅ | -| 10 | Round trip on PostgreSQL: the values read back equal to those written. A row the rule refuses, inserted by SQL (`'[2026-01-01,)'`), materializes without validation, as §4 intends, and `Revalidate()` reports `NotFinite` | ✅ | +| 10 | Round trip on PostgreSQL: the values read back equal to those written. A row the rule refuses, inserted by SQL (`'[2026-01-01,)'`), materializes without validation, as [EF Core](../../design/ef-core.md) intends, and `Revalidate()` reports `NotFinite` | ✅ | | 11 | Translation composes. See the table below; every query ran and returned the in-memory answer | ✅ | | 12 | ASP.NET Core minimal APIs, in a host with `AddRangeConverters()`: a valid body binds and echoes, and a body `Create` refuses is a 400. A query parameter binds through `TryParse` (`?period=[2026-01-01,2026-06-30]` → 200), and a refused one is a 400. No ASP0020 for a query parameter of a value object declared in the same project | ✅ | -| 13 | OpenAPI with `AddTypeKit()`: the value object's schema is `DateRange`'s, as §7 requires. On 8.0.0 that is `{}` with the converters and an object with `length` without them. On 9.0.0 it is `{}` in every host, since ASP.NET cannot see through a converter. With ValueRanges' documented transformer, in either order with `AddTypeKit()`, both component schemas are `type: string` with the literal's `pattern` and description. TypeKit asks ASP.NET for the wrapped schema (`GetOrCreateSchemaAsync`), so the other transformer reaches the value object with no wiring | ✅ 9.0.0 + the copied transformer | +| 13 | OpenAPI with `AddTypeKit()`: the value object's schema is `DateRange`'s, as [OpenAPI](../../design/openapi.md) requires. On 8.0.0 that is `{}` with the converters and an object with `length` without them. On 9.0.0 it is `{}` in every host, since ASP.NET cannot see through a converter. With ValueRanges' documented transformer, in either order with `AddTypeKit()`, both component schemas are `type: string` with the literal's `pattern` and description. TypeKit asks ASP.NET for the wrapped schema (`GetOrCreateSchemaAsync`), so the other transformer reaches the value object with no wiring | ✅ 9.0.0 + the copied transformer | | 14 | A query parameter. ASP.NET describes a `DateRange` query parameter as `{"type":"string"}`: a `TryParse`-bound parameter is its own schema only for a primitive, an enum and eight listed types (`EndpointModelMetadata.GetDisplayType`, internal), and `string` for anything else. TypeKit replaced its `string` with the wrapped type's JSON schema regardless, which gave `{}` for the value object (and, with the transformer, a pattern a `DateRange` parameter does not get). Fixed in `ValueObjectSchemaTransformer`: the placeholder stays where ASP.NET keeps it for the wrapped type. After the fix the value object's query parameter is `{"type":"string"}` in all four hosts, as `DateRange`'s is | ✅ fixed in TypeKit | | 15 | Configuration binding. 9.0.0 gives `DateRange` a `TypeConverter` (`RangeTypeConverter`). `"[2026-01-01,2026-06-30]"` binds to both a `ContractPeriod?` and a `DateRange?` property. A range `Create` refuses and text that is no range both throw `InvalidOperationException` ("Failed to convert configuration value … to type 'ContractPeriod'"): the binder's message quotes the value, the value object's own does not | ✅ | -| 16 | A query array (`[FromQuery] ContractPeriod[]`): ASP.NET gives a `DateRange[]` query parameter no `items`, as it gives any converter-backed element none (§7). TypeKit's container branch puts the value object's component back, as for every value object (`{}` on 9.0.0, the literal's pattern with the copied transformer). Two values bind (200) in every host | ✅ | +| 16 | A query array (`[FromQuery] ContractPeriod[]`): ASP.NET gives a `DateRange[]` query parameter no `items`, as it gives any converter-backed element none ([OpenAPI](../../design/openapi.md)). TypeKit's container branch puts the value object's component back, as for every value object (`{}` on 9.0.0, the literal's pattern with the copied transformer). Two values bind (200) in every host | ✅ | Queries (finding 11), as `ToQueryString()` prints them: diff --git a/spikes/ResultCatch/.gitignore b/spikes/ResultCatch/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/spikes/ResultCatch/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/spikes/ResultCatch/Candidate.cs b/spikes/ResultCatch/Candidate.cs new file mode 100644 index 0000000..ada94ad --- /dev/null +++ b/spikes/ResultCatch/Candidate.cs @@ -0,0 +1,107 @@ +using CodoMetis.TypeKit; + +namespace ResultCatch; + +/// The factories as the base package would ship them on its Result static class. +public static class Candidate +{ + public static Result Catch(Func action) where T : notnull + { + try + { + return Result.Success(action()); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static Result Catch(Action action) + { + try + { + action(); + return Result.Success(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static Result Catch(Func action) + where T : notnull + where TException : Exception + { + try + { + return Result.Success(action()); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static Result Catch(Action action) + where TException : Exception + { + try + { + action(); + return Result.Success(); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + // The trap: a Task-returning delegate given to Catch binds T to the task and catches nothing + // the task throws. The overload is more specific than Func, so it wins, and it is an error. + [Obsolete("The delegate returns a Task, which Catch cannot observe: use CatchAsync.", error: true)] + public static Result, Exception> Catch(Func> action) => throw new NotSupportedException(); + + [Obsolete("The delegate returns a Task, which Catch cannot observe: use CatchAsync.", error: true)] + public static Result Catch(Func action) => throw new NotSupportedException(); + + public static async Task> CatchAsync(Func> action) where T : notnull + { + try + { + return Result.Success(await action().ConfigureAwait(false)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static async Task> CatchAsync(Func action) + { + try + { + await action().ConfigureAwait(false); + return Result.Success(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static async Task> CatchAsync(Func> action) + where T : notnull + where TException : Exception + { + try + { + return Result.Success(await action().ConfigureAwait(false)); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } +} diff --git a/spikes/ResultCatch/Directory.Build.props b/spikes/ResultCatch/Directory.Build.props new file mode 100644 index 0000000..578e79b --- /dev/null +++ b/spikes/ResultCatch/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/spikes/ResultCatch/Program.cs b/spikes/ResultCatch/Program.cs new file mode 100644 index 0000000..ed20670 --- /dev/null +++ b/spikes/ResultCatch/Program.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using CodoMetis.TypeKit; +using ResultCatch; + +var failures = 0; + +void Claim(int number, string text, bool holds) +{ + Console.WriteLine($"{(holds ? "PASS" : "FAIL")} {number,2} {text}"); + if (!holds) failures++; +} + +static string Name(T _) => typeof(T).Name; +static string Show(Result r) where T : notnull where TError : notnull => + r.Match(v => $"Success({v})", e => $"Error({e.GetType().Name})"); +static string ShowUnit(Result r) where TError : notnull => + r.Match(() => "Success", e => $"Error({e.GetType().Name})"); +static int Throws() => throw new InvalidOperationException("boom"); +static async Task ThrowsLaterAsync() { await Task.Yield(); throw new InvalidOperationException("later"); } + +// 1 inference +var parsed = Candidate.Catch(() => int.Parse("42")); +Claim(1, $"Catch(() => int.Parse(s)) infers T: {Name(parsed)}", Name(parsed) == "Result`2" && Show(parsed) == "Success(42)"); +var refused = Candidate.Catch(() => int.Parse("x")); +Claim(1, $"and a throw is Error: {Show(refused)}", Show(refused) == "Error(FormatException)"); + +// 2 value-less +var acted = Candidate.Catch(() => Console.Write("")); +Claim(2, $"Catch(Action) is Result: {Name(acted)} {ShowUnit(acted)}", Name(acted) == "Result`1" && ShowUnit(acted) == "Success"); +Claim(2, $"and a throwing action is Error: {ShowUnit(Candidate.Catch(() => { Throws(); }))}", ShowUnit(Candidate.Catch(() => { Throws(); })) == "Error(InvalidOperationException)"); + +// 3 narrowed +var narrowed = Candidate.Catch(() => int.Parse("x")); +Claim(3, $"Catch catches that type: {Show(narrowed)}", Show(narrowed) == "Error(FormatException)"); +try +{ + Candidate.Catch(Throws); + Claim(3, "another exception propagates", false); +} +catch (InvalidOperationException e) +{ + Claim(3, $"another exception propagates with its stack intact: {e.StackTrace?.Contains("Throws")}", e.StackTrace?.Contains("Throws") == true); +} + +// 4 both type arguments spelled: Catch(...) compiles above; Catch(() => int.Parse("x")) would bind the Action overload or fail (Trap would show). Recorded in the README. +Claim(4, "Catch needs both type arguments; a one-argument Catch(Action) exists for the value-less shape", true); + +// 6 async +var later = await Candidate.CatchAsync(ThrowsLaterAsync); +Claim(6, $"CatchAsync(Task) catches after the first await: {Show(later)}", Show(later) == "Error(InvalidOperationException)"); +var laterUnit = await Candidate.CatchAsync(async () => { await Task.Yield(); Throws(); }); +Claim(6, $"CatchAsync(Task) too: {ShowUnit(laterUnit)}", ShowUnit(laterUnit) == "Error(InvalidOperationException)"); +var ok = await Candidate.CatchAsync(async () => { await Task.Yield(); return 7; }); +Claim(6, $"and a completing task is Success: {Show(ok)}", Show(ok) == "Success(7)"); + +// 7 cancellation passes through +bool CancellationPassed(Action act) { try { act(); return false; } catch (OperationCanceledException) { return true; } } +Claim(7, "sync Catch lets OperationCanceledException through", CancellationPassed(() => Candidate.Catch(int () => throw new OperationCanceledException()))); +Claim(7, "narrowed Catch lets it through too", CancellationPassed(() => Candidate.Catch(int () => throw new OperationCanceledException()))); +using var cts = new CancellationTokenSource(); +cts.Cancel(); +var asyncCancelled = false; +try { await Candidate.CatchAsync(async () => { await Task.Delay(10, cts.Token); return 1; }); } +catch (OperationCanceledException) { asyncCancelled = true; } +Claim(7, "CatchAsync lets a cancelled token through as cancellation", asyncCancelled); + +// 8 combinators unchanged +var selectorThrew = false; +try { Candidate.Catch(() => 1).Map(_ => Throws()); } catch (InvalidOperationException) { selectorThrew = true; } +Claim(8, "Map's throwing selector propagates", selectorThrew); + +// 10 the instance thrown +var thrown = new InvalidOperationException("same"); +var caught = Candidate.Catch(int () => throw thrown); +Claim(10, "the error is the instance thrown, nothing wraps it", caught.TryGetValue(out _, out var error) == false && ReferenceEquals(error, thrown)); + +// 11: a dropped Catch is reported by CMTK0003 at build time (read the build output). +Candidate.Catch(() => 1); +// 5 (the cost): a lambda that only throws has no return type, so Catch(() => throw x) is ambiguous between Func and the Func> trap; int () => throw x resolves it. + +Console.WriteLine(failures == 0 ? "ALL PASS" : $"{failures} FAILED"); +return failures; diff --git a/spikes/ResultCatch/README.md b/spikes/ResultCatch/README.md new file mode 100644 index 0000000..3f60403 --- /dev/null +++ b/spikes/ResultCatch/README.md @@ -0,0 +1,59 @@ +# Spike: can an exception enter a `Result` through one factory, without a `Try` type? + +**Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "`Result.Catch`" part of phase 13 +(2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md). [Option and Result](../../design/option-result.md) keeps `Try` and `Unit` out: +a `Try` is `Result` under a second name, and `Result` is the value-less +shape a `Unit` would stand in for. + +**Question.** A call that throws has no way into a `Result` today, so every caller writes the +`try`/`catch` by hand. Can four factories on the `Result` static class take that job, each +returning a concrete result rather than a marker, with the combinators unchanged? + +1. **`Catch(Func)`** to `Result`, and **`Catch(Action)`** to + `Result`. +2. **`Catch(Func)`** to `Result`, catching that type and letting + every other exception through. +3. **`CatchAsync`** for each, over `Task`, `Task` and `ValueTask`. +4. **A cancellation is not an outcome**: `OperationCanceledException` passes through every overload. + +**Answer: yes, with two shapes settled by the compiler rather than by the plan.** Measured 2026-10-13 +on .NET SDK 10.0.401 against the real base package, under JIT and published with Native AOT (no +trim or AOT warning; the AOT binary passes every claim). The async trap is an `[Obsolete(error: true)]` +overload over `Func>` and `Func`: more specific than `Func`, so the compiler picks it +for a task-returning delegate and reports CS0619 naming `CatchAsync`. Its cost is a lambda that only +throws, which has no return type: with a type argument it is ambiguous (CS0121), and without one the +compiler prefers the non-void delegate for an expression body and lands on the trap; `int () => throw …` +or `void () => throw …` resolves both, and real call sites return something. No `ValueTask` overloads: +beside `Func>` they are ambiguous (CS0121) for every `async` lambda, so a `ValueTask` takes +`.AsTask()` or an `async` lambda, as the README already says of `MapAsync`. `Catch` +spells both type arguments; a one-argument `Catch(Action)` is the command form, and a +value-returning expression lambda given to it is discarded, which the README says. Shipped in 2.0.0 +as `Result.Catch`/`Result.CatchAsync`, with `ResultCatchTests` holding each row. + +## The candidate + +A console project on the base package: `Candidate.cs` holds the factories as the base package +ships them, `Program.cs` runs each claim and prints PASS or FAIL, and `Trap.cs`, behind `-p:Trap=true`, +holds the calls that must not compile, so the build's errors are the measurement. `dotnet run` is the +JIT run, `dotnet build --no-incremental` shows CMTK0003 on the dropped call, and +`dotnet publish -c Release -p:Aot=true -o out/aot` repeats the run under Native AOT. + +## Findings + +Each row is a claim the spike must settle. A seed that breaks the guard proves each one, as in +`.claude/skills/verify-the-guard`. + +| # | Claim | Result | +|---|---|---| +| 1 | `Result.Catch(() => int.Parse(s))` infers `T`, and the result is `Result`, not a marker | measured: `Result`, `Success(42)`; `int.Parse("x")` is `Error(FormatException)` | +| 2 | `Catch(Action)` returns `Result`, so no `Unit` is needed for a call without a value | measured: `Catch(Action)` is `Result`, a throwing action is its error | +| 3 | `Catch` catches that type only; another exception propagates with its stack intact | measured: `FormatException` caught; an `InvalidOperationException` propagates with `Throws` in its stack | +| 4 | `Catch` needs both type arguments spelled (C# infers all or none), or a shape exists that infers `T` from the delegate and takes `TException` alone | measured: both are spelled; no shape infers `T` alone, since C# infers all or none. The command form `Catch(Action)` takes one | +| 5 | The async trap: `Result.Catch(() => client.GetAsync(url))` is refused, by an `[Obsolete]` error overload over `Func`/`Func>`, by a CMTK rule, or both; `CatchAsync` is the way | measured: the `[Obsolete(error: true)]` overloads over `Func>` and `Func` are CS0619 for a lambda and for a method group, naming `CatchAsync`; no analyzer rule needed. Cost: a lambda that only throws needs `int () =>`/`void () =>` | +| 6 | `CatchAsync` over `Task`, `Task` and `ValueTask` catches an exception the awaited task throws, not only one thrown before the first await | measured for `Task` and `Task`: an exception after `Task.Yield()` is the error. `ValueTask` dropped: its overload is CS0121 for every `async` lambda | +| 7 | `OperationCanceledException` passes through every overload, sync and async, and a cancelled token surfaces as cancellation, never as an error | measured: a thrown `OperationCanceledException` and a cancelled `Task.Delay` both surface as cancellation, sync, narrowed and async | +| 8 | `Map`, `Bind` and the query-syntax `Select` are unchanged: a selector that throws propagates | measured: `Catch(() => 1).Map(_ => Throws())` throws | +| 9 | `ToHttpResult()` over `Result` is a compile error | measured: `Catch(() => 1).ToHttpResult()` is CS1061, the extension does not bind on `Result` | +| 10 | A caught exception is handed out by `TryGetError` and `Match` as the instance thrown; nothing wraps it | measured: `ReferenceEquals(error, thrown)` | +| 11 | CMTK0003 reports a dropped `Result.Catch(...)` as it reports any dropped result | measured: CMTK0003 on the dropped `Candidate.Catch(() => 1)` in a clean build | +| 12 | Native AOT: no new trim or AOT warning, in the packages or in the consumer | measured: `dotnet publish -p:Aot=true` with no IL warning; the binary prints ALL PASS | diff --git a/spikes/ResultCatch/ResultCatch.csproj b/spikes/ResultCatch/ResultCatch.csproj new file mode 100644 index 0000000..93e69e6 --- /dev/null +++ b/spikes/ResultCatch/ResultCatch.csproj @@ -0,0 +1,26 @@ + + + Exe + net10.0 + 14 + enable + enable + + + + true + + + + $(DefineConstants);TRAP + + + + + + + + + + + diff --git a/spikes/ResultCatch/Trap.cs b/spikes/ResultCatch/Trap.cs new file mode 100644 index 0000000..603af03 --- /dev/null +++ b/spikes/ResultCatch/Trap.cs @@ -0,0 +1,23 @@ +#if TRAP +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; + +namespace ResultCatch; + +public static class Trap +{ + static Task FetchAsync() => Task.FromResult(1); + static Task DoAsync() => Task.CompletedTask; + + // Claim 5: each of these must be an error, not a Result over a Task. + public static void AsyncTrap() + { + var a = Candidate.Catch(() => FetchAsync()); + var b = Candidate.Catch(() => DoAsync()); + var d = Candidate.Catch(FetchAsync); + } + + // Claim 9: an exception is not a fault a client acts on. + public static object HttpTrap() => Candidate.Catch(() => 1).ToHttpResult(); +} +#endif diff --git a/spikes/ResultHttp/README.md b/spikes/ResultHttp/README.md index c17962e..d81995e 100644 --- a/spikes/ResultHttp/README.md +++ b/spikes/ResultHttp/README.md @@ -2,7 +2,7 @@ **Question.** Phase 12 puts "a `Result` as an HTTP response" into `CodoMetis.TypeKit.AspNetCore`: a success as its value, a fault as RFC 9457 problem details with a status the host maps, and the -endpoint's responses in the OpenAPI document. The domain must not reference ASP.NET, and §9's refusal +endpoint's responses in the OpenAPI document. The domain must not reference ASP.NET, and [Option and Result](../../design/option-result.md)'s refusal to serialize a `Result` must stand. Which shape does ASP.NET Core 10 support for that, in minimal APIs and MVC, under Native AOT, and what happens when a host forgets to map a fault type? @@ -67,18 +67,18 @@ arguments from the result through a visitor, as `GeneratedValueObjectAttribute.A | 6 | Candidate 2 (metadata records only the fault type; the transformer reads the mapping): MVC and minimal APIs both documented (findings 2, 3). An operation transformer runs after ASP.NET hoisted schemas into components, so a schema from `GetOrCreateSchemaAsync` stays inline; `Document.AddComponent` plus an `OpenApiSchemaReference` gives the `$ref` ASP.NET's own metadata gives | ✅ | | 7 | Candidate 2 without the startup check, unmapped: only a fault response fails (`500`, the exception naming the fault type and `AddFaultResponses`), and the document fails; successes are served. Loud only once a fault occurs | ✅ | | 8 | A hosted service cannot do the startup check: hosted services start before the pipeline is built, and `EndpointDataSource` then lists 0 endpoints. An `IStartupFilter` sees all of them after `next(app)` (7 here, MVC included) and its exception fails `StartAsync` before the server listens, naming each endpoint and fault type | ✅ | -| 9 | Shape B under JIT: the filter turns the result into the same response, but the document describes the `200` as `ResultOfOrderAndOrderFault`, whose schema is the type's XML doc description, and lists no problem response; the startup check cannot see it, since nothing records its fault type. Without the filter, §9's refusal answers `500` with its message, and the document still promises a `Result` | ✅ | -| 10 | Shape B under Native AOT: the host does not start. The Request Delegate Generator asks for `JsonTypeInfo` of `Result` when the endpoint is built. With the result added to the host's context it starts and the filter answers correctly; the unfiltered endpoint is §9's refusal again | ✅ | +| 9 | Shape B under JIT: the filter turns the result into the same response, but the document describes the `200` as `ResultOfOrderAndOrderFault`, whose schema is the type's XML doc description, and lists no problem response; the startup check cannot see it, since nothing records its fault type. Without the filter, [Option and Result](../../design/option-result.md)'s refusal answers `500` with its message, and the document still promises a `Result` | ✅ | +| 10 | Shape B under Native AOT: the host does not start. The Request Delegate Generator asks for `JsonTypeInfo` of `Result` when the endpoint is built. With the result added to the host's context it starts and the filter answers correctly; the unfiltered endpoint is [Option and Result](../../design/option-result.md)'s refusal again | ✅ | | 11 | Shape A under Native AOT: no trim or AOT warning in the publish. Every fault answers `500` (`JsonTypeInfo` for `ProblemDetails` missing, the document too) until `AddProblemDetails()` is called, which puts ASP.NET's problem-details context into the host's resolver chain. The candidate calls it in `AddFaultResponses` | ✅ | | 12 | With it, Native AOT answers and documents exactly as JIT does (findings 1, 2), and the startup check refuses an unmapped fault type. `AddProblemDetails()` also adds a `traceId` to every problem, as ASP.NET does for its own | ✅ | ## Consequences for the design -- **Shape A, explicit.** `ToHttpResult()` at the endpoint, as decision 19 prefers concise to implicit: +- **Shape A, explicit.** `ToHttpResult()` at the endpoint, as [decision 19](../../design/adr/0019-concise-implicit.md) prefers concise to implicit: the handler's return type is ASP.NET's own `Results<…>`, so the success's schema, MVC and the Request Delegate Generator all work without anything of ours (findings 1–3, 11). Shape B is not offered: it documents a `Result` (finding 9) and makes a Native AOT host put one in its context - (finding 10), the opposite of §9. + (finding 10), the opposite of [decision 15](../../design/adr/0015-option-result-wire-types.md). - **The mapping lives in the host**, registered per fault type, so the domain declares faults and never statuses. Metadata records only the fault type; the transformer and the startup filter read the mapping from the application's services (findings 4, 6, 8). diff --git a/spikes/SensitiveValues/README.md b/spikes/SensitiveValues/README.md index 97fae54..c957885 100644 --- a/spikes/SensitiveValues/README.md +++ b/spikes/SensitiveValues/README.md @@ -34,7 +34,7 @@ prototype Serilog destructuring policy. | 4 | Serilog: `{Masked}` prints the mask, but `{@Masked}` destructures the public properties, `MaskedEmail { Value: "ann@example.com" }`, and so does `{@Signup}` for every value object inside. Every value object, masked or not, is logged as `{ Value: … }` there | ✅ | | 5 | A destructuring policy that turns any value object (recognised by `GeneratedValueObjectAttribute<,>`) into a scalar of its `ToString()`: `{@Masked}` and `{@Signup}` print the mask, and an unmasked value object prints its value as a plain string instead of `{ Value: … }` | ✅ | | 6 | A `DataClassificationAttribute` on the value object's *type*: the logging generator reports LOGGEN035 ("has a sensitive field/property in its type") and generates nothing for the method, which then fails with CS8795 | ✅ | -| 7 | The debugger: no value object declares `[DebuggerDisplay]` or hides `Value`, so the debugger shows `ToString()`, the mask, and expands `Value`. §9 already holds that the debugger is not a log | ✅ | +| 7 | The debugger: no value object declares `[DebuggerDisplay]` or hides `Value`, so the debugger shows `ToString()`, the mask, and expands `Value`. [Option and Result](../../design/option-result.md) already holds that the debugger is not a log | ✅ | ## Consequences diff --git a/spikes/TypeArgumentConstraints/.gitignore b/spikes/TypeArgumentConstraints/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/spikes/TypeArgumentConstraints/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/spikes/TypeArgumentConstraints/Component/Component.csproj b/spikes/TypeArgumentConstraints/Component/Component.csproj new file mode 100644 index 0000000..9eb251f --- /dev/null +++ b/spikes/TypeArgumentConstraints/Component/Component.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + + + + + + + diff --git a/spikes/TypeArgumentConstraints/Component/Probe.razor b/spikes/TypeArgumentConstraints/Component/Probe.razor new file mode 100644 index 0000000..90d9bde --- /dev/null +++ b/spikes/TypeArgumentConstraints/Component/Probe.razor @@ -0,0 +1,5 @@ +@using Library + +@code { + Box? _box; // CMTK0010 (claim 11: a Razor component, reported at the markup line) +} diff --git a/spikes/TypeArgumentConstraints/Consumer/Consumer.csproj b/spikes/TypeArgumentConstraints/Consumer/Consumer.csproj new file mode 100644 index 0000000..80893bc --- /dev/null +++ b/spikes/TypeArgumentConstraints/Consumer/Consumer.csproj @@ -0,0 +1,14 @@ + + + net10.0 + 14 + enable + enable + false + + + + + + + diff --git a/spikes/TypeArgumentConstraints/Consumer/Seeds.cs b/spikes/TypeArgumentConstraints/Consumer/Seeds.cs new file mode 100644 index 0000000..ebfcd00 --- /dev/null +++ b/spikes/TypeArgumentConstraints/Consumer/Seeds.cs @@ -0,0 +1,50 @@ +// Every line marked "// CMTK0010" must be reported, once, and no other line may be. The build output +// is the measurement: compare the reported line numbers with the marked ones (README). +using Library; +#pragma warning disable CS0169, CS0649, CS8618, CS0219 + +namespace Consumer; + +public sealed class OrderDecider : Decider { } // CMTK0010 (claim 1: base type, two arguments, two reports) + +public interface IHasBox : IComparer> { } // CMTK0010 (claim 2: implemented interface) + +public sealed class Members +{ + private Box _field; // CMTK0010 (claim 2: field) + public Box Property { get; set; } // CMTK0010 (claim 2: property) + public Box Method(Box parameter) // CMTK0010 (claim 2: return and parameter, two reports) + { + Box local = null!; // CMTK0010 (claim 2: local) + var open = typeof(Box); // CMTK0010 (claim 3: typeof) + Runner.Run(new PlaceOrder()); // CMTK0010 (claim 3: written type argument) + Runner.Run(new PlaceOrder()); // CMTK0010 (claim 4: inferred, at the call) + Runner.Run(new PlaceOrder()); // fine: Command is abstract + return local; + } +} + +public abstract class Leaky : Decider { } // CMTK0010 (claim 5: forwarding without the attribute; OrderPlaced is sealed, so two) +public abstract class Sound<[CodoMetis.TypeKit.Abstract, CodoMetis.TypeKit.Record] TCommand> : Decider { } +public sealed class Closed : Sound { } // CMTK0010 (claim 5: the forwarded requirement, at its own use) + +public static class Methods +{ + public static void Call(T value) where T : struct => Runner.Boxed(); // claim 6: a generic method's own parameter, satisfied by struct + public static void Proof() where T : struct => Runner.Boxed(); + public static Box Unproven() where T : class => new(); // CMTK0010 (claim 6: a method's parameter without proof) +} + +public static class Shapes +{ + public static Box SealedRecord; + public static Box Struct; + public static Box Enum; + public static Factory Concrete; + public static Factory ConcreteStruct; + public static Box Interface; // CMTK0010 (claim 8: an interface is not sealed) + public static Decider InterfaceNotAbstract; // CMTK0010 (claim 8: an interface is not abstract, and not a record: two) + public static Factory AbstractNotConcrete; // CMTK0010 (claim 8) + public static Decider AbstractClassNotRecord; // CMTK0010 (claim 9: [Abstract, Record], abstract but not a record) + public static Decider Fine; +} diff --git a/spikes/TypeArgumentConstraints/Directory.Build.props b/spikes/TypeArgumentConstraints/Directory.Build.props new file mode 100644 index 0000000..578e79b --- /dev/null +++ b/spikes/TypeArgumentConstraints/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/spikes/TypeArgumentConstraints/Library/Library.cs b/spikes/TypeArgumentConstraints/Library/Library.cs new file mode 100644 index 0000000..74ee48b --- /dev/null +++ b/spikes/TypeArgumentConstraints/Library/Library.cs @@ -0,0 +1,29 @@ +using CodoMetis.TypeKit; + +namespace Library; + +public abstract record Command; +public abstract record Event; +public sealed record PlaceOrder : Command; +public sealed record OrderPlaced : Event; +public abstract class Handler { } +public interface IHandler { } +public sealed class OrderHandler : Handler { } +public struct Point { } + +/// The library author's generic: the command and event families must be abstract records. +public abstract class Decider<[Abstract, Record] TCommand, [Abstract, Record] TEvent> +{ + public Decider? Next; // claim 10: the open generic in its own body, silent + public static Type Open => typeof(Decider<,>); // claim 10: unbound, silent +} + +public sealed class Box<[Sealed] T> { } + +public sealed class Factory<[Concrete] T> { } + +public static class Runner +{ + public static void Run<[Abstract] T>(T command) { } + public static Box Boxed() where T : struct => new(); // claim 5: struct proves [Sealed] +} diff --git a/spikes/TypeArgumentConstraints/Library/Library.csproj b/spikes/TypeArgumentConstraints/Library/Library.csproj new file mode 100644 index 0000000..973f2b9 --- /dev/null +++ b/spikes/TypeArgumentConstraints/Library/Library.csproj @@ -0,0 +1,13 @@ + + + net10.0 + 14 + enable + enable + + + + + + + diff --git a/spikes/TypeArgumentConstraints/README.md b/spikes/TypeArgumentConstraints/README.md new file mode 100644 index 0000000..55f09a0 --- /dev/null +++ b/spikes/TypeArgumentConstraints/README.md @@ -0,0 +1,59 @@ +# Spike: can an attribute on a type parameter constrain its arguments where C# cannot? + +**Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "Constraints a type parameter +cannot declare" part of phase 13 (2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md), and +the CMTK0010 row of [the analyzers note](../../design/analyzers.md). + +**Question.** C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : +abstract`, `sealed` or `record`. A generic base whose type argument must be an abstract record gets +no help from the compiler, and the closed type that violates it compiles. Can attributes on the +parameter, `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` in the base package, and one +analyzer rule, CMTK0010, make the violation an error at every closed use, in every consumer, +without Metalama? + +An analyzer rather than an aspect, because an aspect on the parameter sees the declaration and +never the use, and the use site need not compile with Metalama ([Discovery](../../design/discovery.md): the base package is +Metalama-free, and discovery is by the attribute symbol). + +**Answer: yes.** Measured 2026-10-13 on .NET SDK 10.0.401 with the rule as `.Analyzers` ships it +(`TypeArgumentConstraintAnalyzer`), over a library declaring the generics, a consumer of it and a +Razor class library. Every seeded line is reported, with the expected count where a line breaks +two constraints, and nothing else is: one report per written argument, one per inferred call, none +for the open generic in its own body or an unbound `typeof`. A forwarding type parameter is a breach +unless it carries the attribute, or `struct`/`new()` proves it. Shipped in 2.0.0 as CMTK0010 with +`TypeArgumentConstraintAnalyzerTests` holding each row. + +## The candidate + +`Library/` declares `Decider<[Abstract, Record] TCommand, [Abstract, Record] TEvent>`, +`Box<[Sealed] T>`, `Factory<[Concrete] T>` and `Runner.Run<[Abstract] T>` against the real base +package; `Consumer/Seeds.cs` has one breach per claim, each on a line ending in `// CMTK0010`, so +the build output is the measurement; `Component/Probe.razor` is claim 11. The rule is the real one, +referenced from `src/`, not a stand-in. To repeat: + +```bash +dotnet build spikes/TypeArgumentConstraints/Consumer --no-incremental 2>&1 | grep -o -E "Seeds.cs\([0-9]+,[0-9]+\): error CMTK0010" | sort -u +grep -n "// CMTK0010" spikes/TypeArgumentConstraints/Consumer/Seeds.cs | cut -d: -f1 +dotnet build spikes/TypeArgumentConstraints/Component --no-incremental 2>&1 | grep -o "Probe.razor([0-9]*,[0-9]*): error CMTK0010" +``` + +## Findings + +Each row is a claim the spike must settle. A seed that breaks the guard proves each one, as in +`.claude/skills/verify-the-guard`. + +| # | Claim | Result | +|---|---|---| +| 1 | A base type `: Base` is reported at the type argument, naming the parameter, the argument and the constraint | measured: `Decider` reports at each argument, naming `TCommand`/`TEvent` of `Decider` and the breach | +| 2 | An implemented generic interface, a field, a property, a parameter, a return type and a local are each reported | measured: an implemented `IComparer>`, a field, a property, a return type, a parameter and a local each report once | +| 3 | A `typeof(Base)` and a generic method call with explicit type arguments are reported | measured: `typeof(Box)` and `Runner.Run(…)` report at the argument | +| 4 | A generic method call with inferred type arguments is reported, at the call, since no argument is written | measured: `Runner.Run(new PlaceOrder())` reports at the call, once; the written form is not reported twice | +| 5 | Forwarding: `class Sub : Base` is reported unless `U` carries `[Abstract]` itself; with it, `Sub` is reported at its own closed use | measured: `Leaky : Decider` reports `TCommand` for each attribute it lacks; `Sound<[Abstract, Record] TCommand>` is silent and `Closed : Sound` reports at its own use | +| 6 | A constraint on a type parameter of a generic method (`void M<[Sealed] T>()`) is checked at its calls | measured: `Boxed() where T : struct` is silent (struct proves sealed); `Unproven() where T : class` reports `T` | +| 7 | One site is reported once, where a type appears in both syntax and a symbol action | measured: the reported line counts equal the seeded ones, 2 where two arguments break, 3 for the forwarding line with two attributes and a sealed event | +| 8 | `[Record]` accepts a record class and a record struct; `[Abstract]` accepts an abstract class or record and refuses an interface; `[Sealed]` accepts a sealed class, a struct and a record struct; `[Concrete]` refuses an abstract type and an interface | measured: a sealed record, a struct and an enum pass `[Sealed]`, an interface fails it and `[Abstract]`; an abstract class fails `[Record]` and `[Concrete]`; a struct passes `[Concrete]` | +| 9 | The four attributes compose: `[Abstract, Record]` requires both | measured: `[Abstract, Record]` reports each attribute broken, so an abstract class reports `is not a record` alone | +| 10 | The open generic itself (`Base` inside its own body) is never reported | measured: `Decider` inside its own body and `typeof(Decider<,>)` report nothing; the library builds clean | +| 11 | A closed use in a Razor component is reported ([Analyzers](../../design/analyzers.md), generated code with a mapped location) | measured: `Probe.razor(4,9): error CMTK0010` in a Razor class library | +| 12 | `MakeGenericType` at run time is out of reach, stated in the README, and the attribute carries no run-time check | by design: no run-time check, the README says `MakeGenericType` is out of reach | +| 13 | The attributes have `AttributeTargets.GenericParameter` only, so a misplaced one is CS0592 | measured in `TypeArgumentConstraintAnalyzerTests`: `[Abstract]` on a class is CS0592 | diff --git a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md index 91bcbdc..370cdef 100644 --- a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md @@ -36,3 +36,20 @@ Rule ID | Category | Severity | Notes Rule ID | Category | Severity | Notes --------|----------|----------|------------------------------------------------------------- + +## Release 2.0.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------------------------------------------------------------- +CMTK0010 | Usage | Error | TypeArgumentConstraintAnalyzer, a type argument that breaks [Abstract], [Sealed], [Record] or [Concrete] on its parameter +CMTK0011 | Usage | Error | RequiredParameterlessConstructorAnalyzer, a concrete type under [RequireParameterlessConstructor] without the constructor it requires +CMTK0012 | Usage | Error | PositionalMatchArgumentAnalyzer, a closed set's Match given an argument by position +CMTK0013 | Usage | Error | ClosedSetMemberAssignedAnalyzer, a closed set's member written outside its declaration + +### Changed Rules + +Rule ID | New Category | New Severity | Old Category | Old Severity | Notes +--------|--------------|--------------|--------------|--------------|------------------------------------------------------------- +CMTK0003 | Usage | Error | Usage | Warning | IgnoredOutcomeAnalyzer, raised in a major version; also a Wait, a WaitAll and a loop that drop results diff --git a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md index 30e93e8..f2b7fad 100644 --- a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,3 +1,2 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md - diff --git a/src/CodoMetis.TypeKit.Analyzers/ClosedSetMemberAssignedAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/ClosedSetMemberAssignedAnalyzer.cs new file mode 100644 index 0000000..f4c77e2 --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/ClosedSetMemberAssignedAnalyzer.cs @@ -0,0 +1,103 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace CodoMetis.TypeKit.Analyzers; + +/// +/// CMTK0013: a closed set's member written anywhere but its own declaration. +/// +/// +/// +/// The generators give each member its name and fill All after the static field initializers +/// and before the static constructor's body. A member assigned in that body, Open = Closed;, +/// keeps the field pointing at another member: Closed.Match takes the open branch, +/// Parse("Open") answers an instance written as "Closed", and All still lists the +/// instance the field no longer holds. One assigned in another static field's initializer, before the +/// names, gives one instance two. All of it compiles, and C# allows such a write nowhere else: a +/// static readonly field is written only by its type's static initializers and constructor. +/// +/// +/// A write is an assignment, compound or ??= included, a deconstruction into the field, an +/// increment, a ref or out argument, and a writable ref taken to it, through which it +/// can be written later (a ref readonly one, declared or reassigned, only reads); each also where the field is a branch of a ref conditional, +/// (flag ? ref Open : ref Closed) = Closed. The field's own initializer is no reference to it, and CMTK1019 holds it to new. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ClosedSetMemberAssignedAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticIds.ClosedSetMemberAssigned, + title: "A closed set's member written outside its declaration", + messageFormat: "'{0}.{1}' is written outside its declaration, after the generators named the instance it held and listed it in All: Match, Parse and the JSON would answer for another member, or none. Create each member with new where it is declared, and never write it again.", + category: "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A closed set's members are named and listed once, after the static field initializers and before the static constructor's body. A member written in either then holds an instance whose name and place in All are another's.", + helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.ClosedSetMemberAssigned) + ); + + /// + public override ImmutableArray SupportedDiagnostics => [Rule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCode.AnalysisFlags); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(start => + { + if (TypeKitSymbols.Resolve(start.Compilation) is not { } symbols) return; + + start.RegisterOperationAction(operation => Analyze(operation, symbols), OperationKind.FieldReference); + }); + } + + private static void Analyze(OperationAnalysisContext context, TypeKitSymbols symbols) + { + var reference = (IFieldReferenceOperation)context.Operation; + var field = reference.Field; + + if (field is not { IsStatic: true, IsReadOnly: true } || !SymbolEqualityComparer.Default.Equals(field.Type, field.ContainingType)) return; + if (!IsWrite(reference)) return; + if (field.ContainingType.TypeKind != TypeKind.Class || symbols.FindClosedSetMarker(field.ContainingType) is null) return; + + context.Report(Diagnostic.Create(Rule, reference.Syntax.GetLocation(), field.ContainingType.Name, field.Name)); + } + + /// Whether the reference writes the field, or hands out a ref through which it can be written. + private static bool IsWrite(IOperation reference) + { + // (Open, Closed) = (Closed, Open), and flag ? ref Open : ref Closed: the field is an element of a + // tuple, or a branch of a ref conditional, possibly nested, and that is what is written. + var target = reference; + while (target.Parent is ITupleOperation || target.Parent is IConditionalOperation { IsRef: true } conditional && conditional.Condition != target) + target = target.Parent; + + return target.Parent switch + { + IDeconstructionAssignmentOperation deconstruction when deconstruction.Target == target => true, + IAssignmentOperation assignment when assignment.Target == target => true, + ISimpleAssignmentOperation { IsRef: true } assignment when assignment.Value == target => IsWritableRef(assignment.Target), + IIncrementOrDecrementOperation => true, + IArgumentOperation { Parameter.RefKind: RefKind.Ref or RefKind.Out } => true, + IVariableInitializerOperation { Parent: IVariableDeclaratorOperation { Symbol.RefKind: RefKind.Ref } } => true, + _ => false, + }; + } + + /// + /// Whether a ref reassigned to the field can write it: a ref readonly local, parameter or + /// field only reads, alias = ref Open as much as its declaration. + /// + private static bool IsWritableRef(IOperation target) => target switch + { + ILocalReferenceOperation local => local.Local.RefKind == RefKind.Ref, + IParameterReferenceOperation parameter => parameter.Parameter.RefKind is RefKind.Ref or RefKind.Out, + IFieldReferenceOperation field => field.Field.RefKind == RefKind.Ref, + _ => true, + }; +} diff --git a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/CodoMetis.TypeKit.Analyzers.CodeFixes.csproj b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/CodoMetis.TypeKit.Analyzers.CodeFixes.csproj new file mode 100644 index 0000000..c462512 --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/CodoMetis.TypeKit.Analyzers.CodeFixes.csproj @@ -0,0 +1,26 @@ + + + + + netstandard2.0 + disable + true + true + false + CodoMetis.TypeKit.Analyzers + + + + + + + + diff --git a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/PositionalMatchArgumentCodeFixProvider.cs b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/PositionalMatchArgumentCodeFixProvider.cs new file mode 100644 index 0000000..f996f26 --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/PositionalMatchArgumentCodeFixProvider.cs @@ -0,0 +1,78 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CodoMetis.TypeKit.Analyzers; + +/// +/// The fix for CMTK0012: names every argument of a closed set's Match after the parameter it +/// is bound to now, so the call means what it meant and no longer depends on the members' order. +/// +/// +/// An argument by position after a named one is in its own position, or the call would not compile, +/// so an argument's index is its parameter's. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PositionalMatchArgumentCodeFixProvider))] +[Shared] +public sealed class PositionalMatchArgumentCodeFixProvider : CodeFixProvider +{ + /// The rule this fixes; held to the analyzer's id by its tests. + public const string DiagnosticId = "CMTK0012"; + + /// The diagnostic property carrying the parameter names; held to the analyzer's by its tests. + public const string NamesProperty = "Names"; + + /// + public override ImmutableArray FixableDiagnosticIds => [DiagnosticId]; + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return; + + foreach (var diagnostic in context.Diagnostics) + { + if (!diagnostic.Properties.TryGetValue(NamesProperty, out var joined) || joined is null) continue; + if (root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf() is not { } invocation) continue; + + var names = joined.Split(','); + if (names.Length != invocation.ArgumentList.Arguments.Count) continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Name every argument of Match", + cancellationToken => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(invocation.ArgumentList, Named(invocation.ArgumentList, names)))), + equivalenceKey: DiagnosticId), + diagnostic); + } + } + + private static ArgumentListSyntax Named(ArgumentListSyntax list, string[] names) + { + var arguments = list.Arguments.Select((argument, index) => argument.NameColon is not null ? argument : Named(argument, names[index])); + return list.WithArguments(SyntaxFactory.SeparatedList(arguments, list.Arguments.GetSeparators())); + } + + /// The argument with name: before it, in the argument's place: what led the argument now leads the name. + private static ArgumentSyntax Named(ArgumentSyntax argument, string name) + { + var identifier = SyntaxFacts.GetKeywordKind(name) == SyntaxKind.None + ? SyntaxFactory.Identifier(name) + : SyntaxFactory.Identifier(SyntaxTriviaList.Empty, SyntaxKind.IdentifierToken, "@" + name, name, SyntaxTriviaList.Empty); + + var nameColon = SyntaxFactory.NameColon(SyntaxFactory.IdentifierName(identifier), SyntaxFactory.Token(SyntaxKind.ColonToken).WithTrailingTrivia(SyntaxFactory.Space)) + .WithLeadingTrivia(argument.GetLeadingTrivia()); + + return argument.WithoutLeadingTrivia().WithNameColon(nameColon); + } +} diff --git a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs new file mode 100644 index 0000000..a39d402 --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs @@ -0,0 +1,277 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace CodoMetis.TypeKit.Analyzers; + +/// +/// The fix for CMTK0011: writes the parameterless constructor the type lacks, or changes the +/// accessibility of the one it declares. +/// +/// +/// +/// A type with a primary constructor, in any of its parts, gets no fix: every other constructor of +/// such a type chains to the primary one (CS8862), and the arguments to chain with are the author's to +/// choose. Nor does a type whose base base() does not reach, which the added constructor calls +/// implicitly: a base with only constructors that take arguments (CS7036), one the type cannot access +/// (CS0122), or two that base() could each be (CS0121). Nor does a struct, which the rule never +/// reports: its parameterless constructor can only be public. +/// +/// +/// The fix writes its own trivia and asks no formatter, which would lay out the whole type again: the +/// type's and the constructor's doc comments, attributes and comments stay where they are, the members +/// keep their layout, and the lines it adds are indented as the members are and end as the file's do. +/// +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(RequiredParameterlessConstructorCodeFixProvider))] +[Shared] +public sealed class RequiredParameterlessConstructorCodeFixProvider : CodeFixProvider +{ + /// The rule this fixes; held to the analyzer's id by its tests. + public const string DiagnosticId = "CMTK0011"; + + /// The diagnostic property carrying the required accessibility, as spells it; held to the analyzer's by its tests. + public const string RequiredProperty = "Required"; + + /// + public override ImmutableArray FixableDiagnosticIds => [DiagnosticId]; + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return; + + foreach (var diagnostic in context.Diagnostics) + { + if (!diagnostic.Properties.TryGetValue(RequiredProperty, out var requiredName) + || !System.Enum.TryParse(requiredName, out var required)) + continue; + + var node = root.FindNode(diagnostic.Location.SourceSpan); + var spelling = Spell(required); + + // A struct's parameterless constructor is public and can be nothing else (CS8958): the rule + // reports none, and a fix that wrote another accessibility there would not compile. + if (node.FirstAncestorOrSelf() is { } declaring && declaring.Kind() is SyntaxKind.StructDeclaration or SyntaxKind.RecordStructDeclaration) + continue; + + if (node.FirstAncestorOrSelf() is { } constructor) + { + context.RegisterCodeFix( + CodeAction.Create( + $"Make the constructor {spelling}", + cancellationToken => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(constructor, WithAccessibility(constructor, required)))), + equivalenceKey: $"{DiagnosticId}:change"), + diagnostic); + } + else if (node.FirstAncestorOrSelf() is { } type + && await CanAddConstructorAsync(context.Document, type, context.CancellationToken).ConfigureAwait(false)) + { + context.RegisterCodeFix( + CodeAction.Create( + $"Add {(required == Accessibility.Internal ? "an" : "a")} {spelling} parameterless constructor", + cancellationToken => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(type, WithConstructor(type, required, root.SyntaxTree.GetText(cancellationToken))))), + equivalenceKey: $"{DiagnosticId}:add"), + diagnostic); + } + } + } + + /// + /// Whether a parameterless constructor added to the type compiles, or whether that cannot be told: a + /// fix withheld is better than one that does not compile. It does not where any part of the type + /// declares a primary constructor, nor where its implicit base() binds to nothing. + /// + private static async Task CanAddConstructorAsync(Document document, TypeDeclarationSyntax type, CancellationToken cancellationToken) + { + if (type.ParameterList is not null) return false; + + var model = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (model?.GetDeclaredSymbol(type, cancellationToken) is not INamedTypeSymbol symbol) return false; + + return !symbol.DeclaringSyntaxReferences.Any(reference => reference.GetSyntax(cancellationToken) is TypeDeclarationSyntax { ParameterList: not null }) + && BaseCallBinds(symbol, model.Compilation); + } + + /// + /// Whether base() in a constructor of binds: to the base's one + /// accessible parameterless constructor, or, without one, to its one accessible constructor whose + /// parameters are all optional or a params array. Two of the second kind are ambiguous. + /// + private static bool BaseCallBinds(INamedTypeSymbol type, Compilation compilation) + { + if (type.BaseType is not { SpecialType: not SpecialType.System_Object } baseType) return true; + + var reachable = baseType.InstanceConstructors.Where(constructor => compilation.IsSymbolAccessibleWithin(constructor, type)).ToList(); + if (reachable.Any(constructor => constructor.Parameters.IsEmpty)) return true; + + return reachable.Count(constructor => constructor.Parameters.All(parameter => parameter.IsOptional || parameter.IsParams)) == 1; + } + + /// + /// Only the accessibility changes. It takes the place of the first accessibility keyword written, + /// with what led it and what followed the last, comments included; where none is written, it goes + /// before the other modifiers, or before the name, after the attributes, taking what led there. + /// + private static ConstructorDeclarationSyntax WithAccessibility(ConstructorDeclarationSyntax constructor, Accessibility required) + { + var modifiers = constructor.Modifiers; + var written = modifiers.Where(IsAccessibility).ToList(); + + if (written.Count == 0) + { + if (modifiers.Count == 0) + { + return constructor.WithModifiers(SyntaxFactory.TokenList(Keywords(required, constructor.Identifier.LeadingTrivia, SyntaxFactory.TriviaList(SyntaxFactory.Space)))) + .WithIdentifier(constructor.Identifier.WithLeadingTrivia()); + } + + var leading = Keywords(required, modifiers[0].LeadingTrivia, SyntaxFactory.TriviaList(SyntaxFactory.Space)); + return constructor.WithModifiers(SyntaxFactory.TokenList(leading.Concat(modifiers.Select((modifier, index) => index == 0 ? modifier.WithLeadingTrivia() : modifier)))); + } + + // protected /* note */ internal: a comment after a keyword that goes is kept after the new ones. + var last = written[written.Count - 1]; + var trailing = SyntaxFactory.TriviaList(written.Take(written.Count - 1) + .SelectMany(keyword => keyword.TrailingTrivia.Any(IsComment) ? keyword.TrailingTrivia : SyntaxFactory.TriviaList()) + .Concat(last.TrailingTrivia)); + var tokens = new List(); + + foreach (var modifier in modifiers) + { + if (modifier == written[0]) tokens.AddRange(Keywords(required, modifier.LeadingTrivia, trailing)); + else if (!IsAccessibility(modifier)) tokens.Add(modifier); + } + + return constructor.WithModifiers(SyntaxFactory.TokenList(tokens)); + } + + /// + /// The type with the constructor as its first member, laid out as the body it joins: on a body of + /// several lines, on lines of its own, indented as the members are; on a body of one line, on that + /// line; on a declaration ending in ;, in a body on that line, after which the ;'s + /// trivia, a comment included, is kept. + /// + private static TypeDeclarationSyntax WithConstructor(TypeDeclarationSyntax type, Accessibility required, SourceText text) + { + var signature = $"{Spell(required)} {type.Identifier.Text}()"; + + if (type.OpenBraceToken.IsKind(SyntaxKind.None) || type.OpenBraceToken.IsMissing) + { + var semicolon = type.SemicolonToken; + var spaced = semicolon.GetPreviousToken().TrailingTrivia.LastOrDefault().IsKind(SyntaxKind.WhitespaceTrivia); + var open = SyntaxFactory.Token(spaced ? semicolon.LeadingTrivia : semicolon.LeadingTrivia.Add(SyntaxFactory.Space), SyntaxKind.OpenBraceToken, SyntaxFactory.TriviaList(SyntaxFactory.Space)); + var close = SyntaxFactory.Token(SyntaxFactory.TriviaList(), SyntaxKind.CloseBraceToken, semicolon.TrailingTrivia); + + return type.WithSemicolonToken(default) + .WithOpenBraceToken(open) + .WithMembers(SyntaxFactory.SingletonList(Constructor($"{signature} {{ }}", SyntaxFactory.TriviaList(), SyntaxFactory.TriviaList(SyntaxFactory.Space)))) + .WithCloseBraceToken(close); + } + + var end = type.OpenBraceToken.TrailingTrivia.LastOrDefault(trivia => trivia.IsKind(SyntaxKind.EndOfLineTrivia)); + if (!end.IsKind(SyntaxKind.EndOfLineTrivia)) + { + var lead = type.OpenBraceToken.TrailingTrivia.Count == 0 ? SyntaxFactory.TriviaList(SyntaxFactory.Space) : SyntaxFactory.TriviaList(); + return type.WithMembers(type.Members.Insert(0, Constructor($"{signature} {{ }}", lead, SyntaxFactory.TriviaList(SyntaxFactory.Space)))); + } + + // The line ending of the brace the constructor follows, so a CRLF file stays CRLF. + var eol = end.ToString(); + var indent = MemberIndentation(type, text); + var leading = indent.Length == 0 ? SyntaxFactory.TriviaList() : SyntaxFactory.TriviaList(SyntaxFactory.Whitespace(indent)); + var trailing = type.Members.Count > 0 + ? SyntaxFactory.TriviaList(SyntaxFactory.EndOfLine(eol), SyntaxFactory.EndOfLine(eol)) + : SyntaxFactory.TriviaList(SyntaxFactory.EndOfLine(eol)); + + return type.WithMembers(type.Members.Insert(0, Constructor($"{signature}{eol}{indent}{{{eol}{indent}}}", leading, trailing))); + } + + /// + /// A constructor parsed from its text, so every token carries the trivia written there, and none + /// is elastic trivia a code action's cleanup would lay out again; the trivia around it is given. + /// + private static MemberDeclarationSyntax Constructor(string source, SyntaxTriviaList leading, SyntaxTriviaList trailing) => + (SyntaxFactory.ParseMemberDeclaration(source) ?? throw new System.InvalidOperationException($"'{source}' does not parse as a constructor.")) + .WithLeadingTrivia(leading) + .WithTrailingTrivia(trailing); + + /// + /// The indentation of the first member's line; without a member, the closing brace's, or the + /// type's, one level deeper, a tab where the file indents with tabs. + /// + private static string MemberIndentation(TypeDeclarationSyntax type, SourceText text) + { + if (type.Members.Count > 0 && Indentation(text, type.Members[0].SpanStart) is { } member) return member; + + var outer = Indentation(text, type.CloseBraceToken.SpanStart) ?? Indentation(text, type.SpanStart) ?? ""; + var tabs = outer.IndexOf('\t') >= 0 || text.Lines.Any(line => line.Span.Length > 0 && text[line.Start] == '\t'); + + return outer + (tabs ? "\t" : " "); + } + + /// The whitespace that starts the line of , or when anything else stands before it. + private static string? Indentation(SourceText text, int position) + { + var line = text.Lines.GetLineFromPosition(position); + var end = line.Start; + + while (end < position && text[end] is ' ' or '\t') end++; + + return end == position ? text.ToString(TextSpan.FromBounds(line.Start, end)) : null; + } + + /// The accessibility's keywords, the first led by , the last followed by . + private static IEnumerable Keywords(Accessibility accessibility, SyntaxTriviaList leading, SyntaxTriviaList trailing) + { + var kinds = Modifiers(accessibility); + + for (var i = 0; i < kinds.Length; i++) + { + yield return SyntaxFactory.Token( + i == 0 ? leading : SyntaxFactory.TriviaList(), + kinds[i], + i == kinds.Length - 1 ? trailing : SyntaxFactory.TriviaList(SyntaxFactory.Space)); + } + } + + private static bool IsComment(SyntaxTrivia trivia) => + trivia.IsKind(SyntaxKind.SingleLineCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineCommentTrivia); + + private static string Spell(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "private", + }; + + private static bool IsAccessibility(SyntaxToken token) => + token.IsKind(SyntaxKind.PublicKeyword) || token.IsKind(SyntaxKind.InternalKeyword) + || token.IsKind(SyntaxKind.ProtectedKeyword) || token.IsKind(SyntaxKind.PrivateKeyword); + + private static SyntaxKind[] Modifiers(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => [SyntaxKind.PublicKeyword], + Accessibility.Internal => [SyntaxKind.InternalKeyword], + Accessibility.Protected => [SyntaxKind.ProtectedKeyword], + Accessibility.ProtectedOrInternal => [SyntaxKind.ProtectedKeyword, SyntaxKind.InternalKeyword], + Accessibility.ProtectedAndInternal => [SyntaxKind.PrivateKeyword, SyntaxKind.ProtectedKeyword], + _ => [SyntaxKind.PrivateKeyword], + }; +} diff --git a/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj b/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj index 3b17cf4..d77fbfa 100644 --- a/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj +++ b/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj @@ -2,7 +2,7 @@ netstandard2.0 @@ -30,6 +30,18 @@ + + + + + + + + - ASP.NET Core integration for CodoMetis.TypeKit: OpenAPI schemas for value objects, and a Result as an HTTP response with problem details. + ASP.NET Core integration for CodoMetis.TypeKit: OpenAPI schemas for value objects, closed sets and merge patches, and a Result as an HTTP response with problem details and fault responses a client can act on. aspnetcore;openapi;json-schema;minimal-api;value-object;strongly-typed-id;ddd;native-aot;result;problem-details diff --git a/src/CodoMetis.TypeKit.AspNetCore/CompatibilitySuppressions.xml b/src/CodoMetis.TypeKit.AspNetCore/CompatibilitySuppressions.xml new file mode 100644 index 0000000..c6ac70a --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/CompatibilitySuppressions.xml @@ -0,0 +1,11 @@ + + + + + CP0021 + T:CodoMetis.TypeKit.AspNetCore.FaultProblem`1``0:notnull + lib/net10.0/CodoMetis.TypeKit.AspNetCore.dll + lib/net10.0/CodoMetis.TypeKit.AspNetCore.dll + true + + \ No newline at end of file diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs index 6a8dce4..5661ac6 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace CodoMetis.TypeKit.AspNetCore; @@ -14,35 +15,63 @@ namespace CodoMetis.TypeKit.AspNetCore; /// /// Made by ToHttpResult(), as the second case of the endpoint's /// . The status comes -/// from services.AddFaultResponses<TFault>(…), so the domain that declares the fault -/// never references ASP.NET. +/// from services.AddFaultResponses<TFault>(…), or from the endpoint's +/// WithFaultStatus, so the domain that declares the fault never references ASP.NET. /// /// /// The endpoint's OpenAPI operation lists a problem response for every status the fault type is -/// mapped to (AddTypeKit()), and a host with an endpoint whose fault type is not mapped does -/// not start. The problem names the fault, never anything of the input. +/// answered with (AddTypeKit()), whose schema describes fault as an optional string, +/// and a host with an endpoint whose fault type is not mapped does not start. The problem names the fault, and a type and title where the mapping gives +/// them; its detail is the occurrence's (), or else a fault set +/// member's description, and nothing else of the request reaches it. /// /// -/// The fault type, an enum. +/// The fault type, an enum or a closed set. public sealed class FaultProblem : IResult, IEndpointMetadataProvider - where TFault : struct, Enum + where TFault : notnull { - internal FaultProblem(TFault fault) => Fault = fault; + internal FaultProblem(TFault fault, string? detail = null) + { + Fault = fault; + Detail = detail; + } /// The fault this response reports. public TFault Fault { get; } + /// The sentence about this occurrence, where the result carried a with one. + public string? Detail { get; } + /// - public Task ExecuteAsync(HttpContext httpContext) + public async Task ExecuteAsync(HttpContext httpContext) { ArgumentNullException.ThrowIfNull(httpContext); var responses = httpContext.RequestServices.GetService>() ?? throw FaultResponses.Unmapped(); + var answer = responses.Of(Fault); + var status = httpContext.GetEndpoint()?.Metadata is { } metadata ? FaultStatuses.Override(metadata, Fault, responses.Type) ?? answer.Status : answer.Status; + var name = httpContext.RequestServices.GetService>()?.Value.QualifiedFaultNames == true ? answer.QualifiedName : answer.Name; + var detail = Detail ?? answer.Description; - return TypedResults.Problem( - statusCode: responses.StatusOf(Fault), - extensions: new Dictionary { ["fault"] = Fault.ToString() }) - .ExecuteAsync(httpContext); + // Present only while this problem is written, so a CustomizeProblemDetails that reads it sees it + // for this problem and for no other the request writes. + var features = httpContext.Features; + var previous = features.Get(); + features.Set(new FaultFeature(typeof(TFault), Fault, name, detail)); + try + { + await TypedResults.Problem( + statusCode: status, + type: answer.Type, + title: answer.Title, + detail: detail, + extensions: new Dictionary { [FaultResponsesOperationTransformer.FaultMember] = name }) + .ExecuteAsync(httpContext); + } + finally + { + features.Set(previous); + } } /// Records which fault type the endpoint answers with; the statuses are read where the services are. @@ -58,4 +87,39 @@ static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, Endpoi builder.Metadata.Add(FaultResponsesMetadata.Instance); } + + private sealed record FaultFeature(Type FaultType, object Fault, string Name, string? Detail) : IFaultFeature; +} + +/// +/// The fault a problem reports, on the request while ToHttpResult() writes it, so a host's +/// CustomizeProblemDetails can add what it needs, an error code, a link, without this package +/// naming any: context.HttpContext.Features.Get<IFaultFeature>(). +/// +/// Absent for every other problem the request writes, such as an exception handler's. +public interface IFaultFeature +{ + /// The fault type, an enum or a closed set. + Type FaultType { get; } + + /// The fault: the enum member or the closed set's member. + object Fault { get; } + + /// The name the problem's fault member carries, qualified where the host asks for it. + string Name { get; } + + /// The problem's detail, or . + string? Detail { get; } +} + +/// How ToHttpResult() writes a problem: services.Configure<FaultResponseOptions>(options => …). +public sealed class FaultResponseOptions +{ + /// + /// Whether the problem's fault member is qualified with its type, OrderFault.NotFound, + /// so two fault types that share a member name stay apart in one client. A nested type is named + /// after its nesting chain, Billing.Fault.NotFound, an enum as a closed set's + /// QualifiedName is. Off by default: the bare member name, NotFound. + /// + public bool QualifiedFaultNames { get; set; } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs new file mode 100644 index 0000000..4057692 --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs @@ -0,0 +1,100 @@ +using CodoMetis.TypeKit.ClosedSets; + +namespace CodoMetis.TypeKit.AspNetCore; + +/// +/// How a host answers one fault: its status, and optionally the problem's type and title. +/// +/// +/// +/// Returned for each member by the mapping given to AddFaultResponses: +/// fault => FaultResponse.Status(409).WithType(new Uri("https://example.com/problems/name-taken")). +/// A bare status converts, so fault => 404 is a response too. RFC 9457 makes type the +/// problem's primary identifier, which a client matches on, and title its summary; both name +/// the problem type and nothing of the request. Without them, ASP.NET writes the status's own. +/// +/// +/// The status is checked where the faults are mapped, with the member it belongs to named: it must +/// be an error status, 400 to 599. +/// +/// +public sealed class FaultResponse +{ + private FaultResponse(int statusCode, Uri? type, string? title) + { + StatusCode = statusCode; + Type = type; + Title = title; + } + + /// The HTTP status. + public int StatusCode { get; } + + /// The problem's type, or for the status's own. + public Uri? Type { get; } + + /// The problem's title, or for the status's own. + public string? Title { get; } + + /// A response with , and the status's own type and title. + /// The HTTP status, 400 to 599, checked where the faults are mapped. + /// The response. + public static FaultResponse Status(int statusCode) => new(statusCode, null, null); + + /// + /// The response a is answered with when no mapping says otherwise: + /// NotFound 404, Conflict 409, Invalid 422, Forbidden 403 and + /// Unavailable 503. + /// + /// + /// For a mapping that changes some members and keeps the rest: + /// fault => fault == OrderFault.Gone ? 410 : FaultResponse.For(fault.Kind). + /// + /// The kind. + /// The response. + /// is no member of , as a default one is not. + public static FaultResponse For(FaultKind kind) => Status(kind switch + { + FaultKind.NotFound => 404, + FaultKind.Conflict => 409, + FaultKind.Invalid => 422, + FaultKind.Forbidden => 403, + FaultKind.Unavailable => 503, + _ => throw new ArgumentOutOfRangeException(nameof(kind), $"{(int)kind} is no FaultKind: a fault set's member declares one of its members, and a default kind is none." + FaultRefusals.Mapping), + }); + + /// The same response with the problem's type, a URI a client matches on. + /// + /// The problem and the document write an absolute URI as , escaped as + /// RFC 3986 has it (a%20b stays a%20b, and an unescaped space or non-ASCII letter is escaped), and + /// a relative one as given. would unescape it into no URI at all. + /// + /// The problem type's URI. + /// A new response. + public FaultResponse WithType(Uri type) + { + ArgumentNullException.ThrowIfNull(type); + return new(StatusCode, type, Title); + } + + /// + /// A problem type as the response and the document write it: RFC 9457 makes type a URI + /// reference, so an absolute URI is , which keeps an escape escaped, + /// and a relative one is as given. Never , which unescapes %20 and + /// an escaped non-ASCII character. + /// + internal static string Written(Uri type) => type.IsAbsoluteUri ? type.AbsoluteUri : type.OriginalString; + + /// The same response with the problem's title, a summary of the problem type a person reads. + /// The title. Never blank. + /// A new response. + public FaultResponse WithTitle(string title) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + return new(StatusCode, Type, title); + } + + /// A bare status as a response, so a mapping can return 404 where it needs nothing else. + /// The HTTP status. + public static implicit operator FaultResponse(int statusCode) => Status(statusCode); +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs index a05f9ba..06b4c7c 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs @@ -1,55 +1,103 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + namespace CodoMetis.TypeKit.AspNetCore; -/// The status a host answers each fault of one type with, and every status the type can give. -internal sealed class FaultResponses where TFault : struct, Enum +/// What every AddFaultResponses overload does, once it has the fault type's members and a response for each. +internal static class FaultRegistration +{ + public static IServiceCollection Add(IServiceCollection services, FaultType type, Func responseOf, string parameter) + where TFault : notnull + { + // Two mappings for one fault type: whichever was registered last would answer, silently. + if (services.Any(descriptor => descriptor.ServiceType == typeof(FaultResponses))) + throw new InvalidOperationException( + $"The fault type {type.TypeName} is mapped already. Map it once, with a status for each member."); + + services.AddSingleton(FaultResponses.Map(type, responseOf, parameter)); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.AddProblemDetails(); + + return services; + } +} + +/// How a host answers each fault of one type, and every status the type can give. +internal sealed class FaultResponses where TFault : notnull { - private readonly Dictionary _statuses; + private readonly Dictionary _answers; - private FaultResponses(Dictionary statuses) + private FaultResponses(FaultType type, Dictionary answers) { - _statuses = statuses; - Statuses = [.. statuses.Values.Distinct().Order()]; + Type = type; + _answers = answers; + Statuses = [.. answers.Values.Select(answer => answer.Status).Distinct().Order()]; } - /// Every status a member is mapped to, ascending: what the document lists. + public FaultType Type { get; } + + /// Every status a member is mapped to, ascending: what the document lists where no endpoint changes one. public IReadOnlyList Statuses { get; } /// /// Maps every member up front, so a mapping that throws for one, or gives one a status that is no /// error, fails where it is registered and not on the request that first reports that fault. /// - public static FaultResponses Map(Func statusOf) + /// The fault type's members. + /// The response of each member. + /// The caller's parameter, which a refusal names: the mapping as the host wrote it. + public static FaultResponses Map(FaultType type, Func responseOf, string parameter) { - var statuses = new Dictionary(); - foreach (var fault in Enum.GetValues()) + var answers = new Dictionary(); + foreach (var fault in type.Members) { - var status = statusOf(fault); - if (status is < 400 or > 599) + var response = responseOf(fault) + ?? throw new ArgumentException($"{type.QualifiedNameOf(fault)} is mapped to no response." + FaultRefusals.Mapping, parameter); + + if (response.StatusCode is < 400 or > 599) throw new ArgumentException( - $"{typeof(TFault).Name}.{fault} is mapped to {status}, which is not an error status (400 to 599). " + + $"{type.QualifiedNameOf(fault)} is mapped to {response.StatusCode}, which is not an error status (400 to 599). " + "A fault is answered as problem details; a success is the value of the Result." + FaultRefusals.Mapping, - nameof(statusOf)); + parameter); - statuses[fault] = status; + answers[fault] = new Answer(response.StatusCode, response.Type is { } problemType ? FaultResponse.Written(problemType) : null, response.Title, type.NameOf(fault), type.QualifiedNameOf(fault), FaultType.DescriptionOf(fault)); } - return new FaultResponses(statuses); + return new FaultResponses(type, answers); } /// - /// The status of . A value no member names, which only a cast makes, has - /// none, and is refused rather than answered with a status the document does not list. + /// How is answered. A value no member names, which only a cast makes, has + /// no answer, and is refused rather than answered with a status the document does not list. /// - public int StatusOf(TFault fault) => - _statuses.TryGetValue(fault, out var status) - ? status + public Answer Of(TFault fault) => + _answers.TryGetValue(fault, out var answer) + ? answer : throw new InvalidOperationException( - $"{typeof(TFault).Name} has no member with the value {fault:D}, " + + $"{Type.TypeName} has no member {(fault is Enum ? $"with the value {fault:D}" : "that is this instance")}, " + "so no status is mapped for it." + FaultRefusals.Mapping); + /// Every status an endpoint gives, with the statuses its metadata sets for some members in place of the mapped ones. + public IReadOnlyList StatusesOf(IReadOnlyList endpointMetadata) => + [.. Type.Members.Select(fault => FaultStatuses.Override(endpointMetadata, fault, Type) ?? _answers[fault].Status).Distinct().Order()]; + + /// The problem types of the members an endpoint answers with , for that response's description. + public IReadOnlyList TypesOf(int status, IReadOnlyList endpointMetadata) => + [.. Type.Members.Where(fault => (FaultStatuses.Override(endpointMetadata, fault, Type) ?? _answers[fault].Status) == status) + .Select(fault => _answers[fault].Type) + .OfType() + .Distinct()]; + public static InvalidOperationException Unmapped() => - new($"No HTTP status is mapped for the fault type {typeof(TFault).Name}. " + - $"Map each of its members with services.AddFaultResponses<{typeof(TFault).Name}>(fault => …)." + FaultRefusals.Mapping); + new($"No HTTP status is mapped for the fault type {FaultTypes.NameOf(typeof(TFault))}. " + + $"Map each of its members with services.AddFaultResponses<{FaultTypes.NameOf(typeof(TFault))}>(fault => …)." + FaultRefusals.Mapping); + + /// + /// The mapped answer for one member: what the problem says, before an endpoint changes its status. Its + /// Type is the problem's type as the response and the document write it (). + /// + internal sealed record Answer(int Status, string? Type, string? Title, string Name, string QualifiedName, string? Description); } /// @@ -58,20 +106,46 @@ public static InvalidOperationException Unmapped() => /// internal abstract class FaultResponsesMetadata { + public abstract Type FaultType { get; } + public abstract string FaultName { get; } - /// The statuses the host mapped the fault type to, or where it mapped none. - public abstract IReadOnlyList? Statuses(IServiceProvider services); + /// The statuses the endpoint answers the fault type with, or where the host mapped none. + public abstract IReadOnlyList? Statuses(IServiceProvider services, IReadOnlyList endpointMetadata); + + /// The problem types listed for . + public abstract IReadOnlyList Types(IServiceProvider services, int status, IReadOnlyList endpointMetadata); + + /// What is wrong with the endpoint's status overrides for this fault type: a name no member has. + public abstract IEnumerable Problems(IServiceProvider services, IReadOnlyList endpointMetadata); } -internal sealed class FaultResponsesMetadata : FaultResponsesMetadata where TFault : struct, Enum +internal sealed class FaultResponsesMetadata : FaultResponsesMetadata where TFault : notnull { public static readonly FaultResponsesMetadata Instance = new(); private FaultResponsesMetadata() { } - public override string FaultName => typeof(TFault).Name; + public override Type FaultType => typeof(TFault); + + public override string FaultName { get; } = FaultTypes.NameOf(typeof(TFault)); + + public override IReadOnlyList? Statuses(IServiceProvider services, IReadOnlyList endpointMetadata) => + Responses(services)?.StatusesOf(endpointMetadata); + + public override IReadOnlyList Types(IServiceProvider services, int status, IReadOnlyList endpointMetadata) => + Responses(services)?.TypesOf(status, endpointMetadata) ?? []; + + public override IEnumerable Problems(IServiceProvider services, IReadOnlyList endpointMetadata) + { + if (Responses(services) is not { } responses) yield break; + + foreach (var status in endpointMetadata.OfType>()) + { + if (status.Problem(responses.Type) is { } problem) yield return problem; + } + } - public override IReadOnlyList? Statuses(IServiceProvider services) => - (services.GetService(typeof(FaultResponses)) as FaultResponses)?.Statuses; + private static FaultResponses? Responses(IServiceProvider services) => + services.GetService(typeof(FaultResponses)) as FaultResponses; } diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs index da999e8..0a6a2cc 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs @@ -10,8 +10,9 @@ namespace CodoMetis.TypeKit.AspNetCore; /// -/// Adds a problem-details response for every status an endpoint's fault type is mapped to, as -/// ProducesProblem(status) would. +/// Adds a problem-details response for every status an endpoint answers its fault type with, as +/// ProducesProblem(status) would: the mapped ones, with the endpoint's WithFaultStatus in +/// place of the mapped status for the members it names. /// /// /// The mapping is read here, from the application's services, and not in the endpoint's metadata, @@ -19,16 +20,33 @@ namespace CodoMetis.TypeKit.AspNetCore; /// components when operation transformers run, so the problem schema is added as the component the /// host's names, and referred to (finding 6). A /// response the operation already has is kept, which also makes a second AddTypeKit() a no-op. +/// A response's description is the status's reason phrase, followed by the problem types of the +/// members answered with it where the mapping gives any. The problem schema those responses refer to +/// gains an optional fault string, the member a client of a fault response matches on: on the +/// shared component, which ASP.NET's own problem responses refer to as well, so its description says +/// which problems carry it. A string, since an enum of every fault type would change every operation +/// when one is added. A document without a fault response is left as ASP.NET writes it. /// internal sealed class FaultResponsesOperationTransformer : IOpenApiOperationTransformer { + /// The problem's extension member writes. + internal const string FaultMember = "fault"; + + /// What fault holds, as the host writes it: the member's name, or its qualified name. + internal static string FaultDescription(bool qualified) => + (qualified + ? "The fault this problem reports: the name of its member, qualified with its type's (Type.Member, a nested type after its nesting chain)." + : "The fault this problem reports: the name of its member.") + + " Present only on a problem that answers a fault of the endpoint's Result."; + public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { IOpenApiSchema? problem = null; + IReadOnlyList endpointMetadata = [.. context.Description.ActionDescriptor.EndpointMetadata]; - foreach (var metadata in context.Description.ActionDescriptor.EndpointMetadata.OfType().Distinct()) + foreach (var metadata in endpointMetadata.OfType().Distinct()) { - var statuses = metadata.Statuses(context.ApplicationServices) + var statuses = metadata.Statuses(context.ApplicationServices, endpointMetadata) ?? throw new InvalidOperationException( $"No HTTP status is mapped for the fault type {metadata.FaultName}. " + $"Map each of its members with services.AddFaultResponses<{metadata.FaultName}>(fault => …)." + FaultRefusals.Mapping); @@ -40,9 +58,12 @@ public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTra if (operation.Responses.ContainsKey(key)) continue; problem ??= await ProblemSchemaAsync(context, cancellationToken); + var types = metadata.Types(context.ApplicationServices, status, endpointMetadata); operation.Responses[key] = new OpenApiResponse { - Description = ReasonPhrases.GetReasonPhrase(status), + Description = types.Count == 0 + ? ReasonPhrases.GetReasonPhrase(status) + : $"{ReasonPhrases.GetReasonPhrase(status)}. Problem types: {string.Join(", ", types)}", Content = new Dictionary { ["application/problem+json"] = new() { Schema = problem } }, }; } @@ -53,15 +74,38 @@ private static async Task ProblemSchemaAsync(OpenApiOperationTra { var schema = await context.GetOrCreateSchemaAsync(typeof(ProblemDetails), parameterDescription: null, cancellationToken); - var json = context.ApplicationServices.GetRequiredService>().Value.SerializerOptions; - var id = context.ApplicationServices.GetRequiredService>().Get(context.DocumentName) - .CreateSchemaReferenceId(json.GetTypeInfo(typeof(ProblemDetails))); + var services = context.ApplicationServices; + var json = services.GetRequiredService>().Value.SerializerOptions; + var id = services.GetRequiredService>().Get(context.DocumentName) + .CreateSchemaReferenceId(json.GetTypeInfo(typeof(ProblemDetails))); + var qualified = services.GetService>()?.Value.QualifiedFaultNames == true; // A host that inlines it gets it inline, as ASP.NET writes its own problem responses there. - if (id is null || context.Document is not { } document) return schema; + if (id is null || context.Document is not { } document) return WithFault(schema, qualified); - if (document.Components?.Schemas?.ContainsKey(id) != true) document.AddComponent(id, schema); + if (document.Components?.Schemas?.TryGetValue(id, out var component) != true || component is null) + { + document.AddComponent(id, schema); + component = schema; + } + WithFault(component, qualified); return new OpenApiSchemaReference(id, document); } + + /// + /// Describes fault on the problem schema, beside what ASP.NET describes of + /// , where nothing describes it yet: a fault another transformer + /// already gave the schema is kept, and a second fault response finds it there. + /// + private static IOpenApiSchema WithFault(IOpenApiSchema schema, bool qualified) + { + if (schema is OpenApiSchema problem && problem.Properties?.ContainsKey(FaultMember) != true) + { + problem.Properties ??= new Dictionary(StringComparer.Ordinal); + problem.Properties[FaultMember] = new OpenApiSchema { Type = JsonSchemaType.String, Description = FaultDescription(qualified) }; + } + + return schema; + } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs index 17785f9..79e41e4 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs @@ -1,21 +1,38 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; namespace CodoMetis.TypeKit.AspNetCore; /// -/// Refuses to start a host with an endpoint whose fault type has no mapping, naming each endpoint. +/// Refuses to start a host with an endpoint whose fault type has no mapping, a fault status whose +/// scope has no endpoint that answers with its fault type, or a fault status naming a member its type +/// does not have, naming each endpoint. /// /// +/// /// A startup filter, because the endpoints are known only once the pipeline is built: a hosted service /// starts before that and sees none (spikes/ResultHttp, finding 8). Its exception fails the host's /// start before the server listens. Without it a missing mapping shows only when that fault first /// occurs, in production (finding 7). +/// +/// +/// A status's scope is where it was declared (decision 61): one endpoint or action, which must answer +/// with the fault type; or a route group, a convention builder or a controller, of which one endpoint +/// must. Each declaration is one instance that every endpoint of its scope carries in its metadata, so +/// the scope is read from the endpoints that hold that instance. +/// /// internal sealed class FaultResponsesStartupCheck : IStartupFilter { + /// + /// One declaration is one instance: an attribute's own Equals compares its values, so two + /// controllers' equal attributes would read as one scope, and it reads them by reflection. + /// + private static readonly IEqualityComparer DeclarationComparer = ReferenceEqualityComparer.Instance; + public Action Configure(Action next) => app => { next(app); @@ -25,16 +42,42 @@ public Action Configure(Action next) = var unmapped = endpoints.Endpoints .SelectMany(endpoint => endpoint.Metadata.OfType().Select(metadata => (Endpoint: endpoint, Metadata: metadata))) - .Where(pair => pair.Metadata.Statuses(services) is null) - .GroupBy(pair => pair.Metadata.FaultName, pair => pair.Endpoint.DisplayName ?? pair.Endpoint.ToString()) - .ToList(); + .Where(pair => pair.Metadata.Statuses(services, pair.Endpoint.Metadata) is null) + // By type, not by name: two nested types of one simple name are two refusals. + .GroupBy(pair => pair.Metadata.FaultType, pair => (pair.Metadata.FaultName, Endpoint: Name(pair.Endpoint))) + .Select(fault => + $"No HTTP status is mapped for the fault type {fault.First().FaultName}, which these endpoints answer with: " + + $"{string.Join(", ", fault.Select(pair => pair.Endpoint).Distinct())}. Map each of its members with services.AddFaultResponses<{fault.First().FaultName}>(fault => …)."); + + // A status is one instance, which every endpoint of the scope it was declared on carries: a group's + // or a convention builder's endpoints, a controller's actions, or the one endpoint or action. It + // concerns the endpoints of its scope that answer with its fault type, and is stale, or set on the + // wrong scope, where none does. An endpoint of another scope does not count: it answers to its own. + var stray = endpoints.Endpoints + .SelectMany(endpoint => endpoint.Metadata.OfType().Select(status => (Status: status, Endpoint: endpoint))) + .GroupBy(pair => pair.Status, pair => pair.Endpoint, DeclarationComparer) + .Where(scope => !scope.Any(endpoint => Answers(endpoint, scope.Key.FaultType))) + .Select(scope => Stray(scope.Key, [.. scope.Distinct()])); + + var nameless = endpoints.Endpoints + .SelectMany(endpoint => endpoint.Metadata.OfType().Distinct() + .SelectMany(metadata => metadata.Problems(services, endpoint.Metadata)) + .Select(problem => $"{problem}, on {Name(endpoint)}.")); - if (unmapped.Count == 0) return; + var refusals = unmapped.Concat(stray).Concat(nameless).ToList(); + if (refusals.Count == 0) return; - throw new InvalidOperationException( - string.Join(" ", unmapped.Select(fault => - $"No HTTP status is mapped for the fault type {fault.Key}, which these endpoints answer with: " + - $"{string.Join(", ", fault.Distinct())}. Map each of its members with services.AddFaultResponses<{fault.Key}>(fault => …).")) + - FaultRefusals.Mapping); + throw new InvalidOperationException(string.Join(" ", refusals) + FaultRefusals.Mapping); }; + + private static bool Answers(Endpoint endpoint, Type faultType) => + endpoint.Metadata.OfType().Any(answered => answered.FaultType == faultType); + + private static string Stray(IFaultStatus status, IReadOnlyList scope) => + scope.Count == 1 + ? $"{status.Describe()} is set on {Name(scope[0])}, which answers with no {FaultTypes.NameOf(status.FaultType)}." + : $"{status.Describe()} is set on {scope.Count} endpoints, none of which answers with {FaultTypes.NameOf(status.FaultType)}: " + + $"{string.Join(", ", scope.Select(Name))}."; + + private static string Name(Endpoint endpoint) => endpoint.DisplayName ?? endpoint.ToString() ?? "an endpoint"; } diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs new file mode 100644 index 0000000..2db8824 --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs @@ -0,0 +1,159 @@ +using CodoMetis.TypeKit.AspNetCore; +using Microsoft.AspNetCore.Http; + +namespace CodoMetis.TypeKit.AspNetCore +{ + /// A status an endpoint answers some fault with in place of the host's: what the startup check reads without knowing the type. + internal interface IFaultStatus + { + Type FaultType { get; } + + /// How the metadata reads in a message: WithFaultStatus(OrderFault.NameTaken, 422). + string Describe(); + } + + /// The typed form, which the response, the document and the startup check read. + internal interface IFaultStatus : IFaultStatus where TFault : notnull + { + int Status { get; } + + bool Matches(TFault fault, FaultType type); + + /// Why the metadata names no member, or where it names one. + string? Problem(FaultType type); + } + + internal sealed class FaultStatusMetadata(TFault fault, int status) : IFaultStatus where TFault : notnull + { + public Type FaultType => typeof(TFault); + + public int Status => status; + + public bool Matches(TFault candidate, FaultType type) => EqualityComparer.Default.Equals(candidate, fault); + + public string? Problem(FaultType type) => + type.Members.Contains(fault) ? null : $"{Describe()} names a value no member of {type.TypeName} has"; + + public string Describe() => $"WithFaultStatus({FaultTypes.NameOf(typeof(TFault))}.{fault}, {status})"; + } + + /// The ordered override: the last status the endpoint's metadata sets for a member, as ASP.NET reads metadata. + internal static class FaultStatuses + { + public static int? Override(IReadOnlyList metadata, TFault fault, FaultType type) where TFault : notnull + { + int? status = null; + foreach (var item in metadata) + { + if (item is IFaultStatus candidate && candidate.Matches(fault, type)) status = candidate.Status; + } + + return status; + } + + public static void ThrowIfNoErrorStatus(int status, string parameter) + { + if (status is < 400 or > 599) + throw new ArgumentOutOfRangeException(parameter, status, $"{status} is not an error status (400 to 599): a fault is answered as problem details." + FaultRefusals.Mapping); + } + } + + /// + /// An MVC action's or controller's status for one fault, in place of the host's: + /// [FaultStatus<OrderFault>(nameof(OrderFault.NameTaken), 422)]. A minimal API endpoint or + /// route group says it with .WithFaultStatus(OrderFault.NameTaken, 422). + /// + /// + /// + /// The member is named, since an attribute takes only constants, and nameof keeps the name + /// in step with the member. A name no member has stops the host from starting, as an endpoint + /// whose fault type is not mapped does. The response, the operation's document entry and the + /// startup check all read it. + /// + /// + /// On an action, the action must answer with , or the host does not + /// start. On a controller class, it applies to the actions that answer with + /// and leaves the others alone; the host does not start only when none + /// of that controller's actions does. A controller that derives from a base class with the + /// attribute is a controller of its own. An action's own status replaces its controller's. + /// + /// + /// The fault type, an enum or a closed set. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] + public sealed class FaultStatusAttribute : Attribute, IFaultStatus where TFault : notnull + { + /// Answers the member named with on this action. + /// The member's name, as nameof(OrderFault.NameTaken) gives it. + /// The HTTP status, 400 to 599. + /// is not an error status. + public FaultStatusAttribute(string fault, int status) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fault); + FaultStatuses.ThrowIfNoErrorStatus(status, nameof(status)); + + Fault = fault; + Status = status; + } + + /// The member's name. + public string Fault { get; } + + /// The HTTP status. + public int Status { get; } + + Type IFaultStatus.FaultType => typeof(TFault); + + bool IFaultStatus.Matches(TFault fault, FaultType type) => string.Equals(type.NameOf(fault), Fault, StringComparison.Ordinal); + + string? IFaultStatus.Problem(FaultType type) => + type.TryFind(Fault, out _) ? null : $"{((IFaultStatus)this).Describe()} names no member of {type.TypeName}"; + + string IFaultStatus.Describe() => $"[FaultStatus<{FaultTypes.NameOf(typeof(TFault))}>(\"{Fault}\", {Status})]"; + } +} + +// Beside WithMetadata and the other endpoint conventions. +namespace Microsoft.AspNetCore.Builder +{ + /// A status for one fault on one endpoint, or on a group of them, in place of the host's. + public static class TypeKitFaultStatusEndpointConventionBuilderExtensions + { + /// + /// Answers with on this endpoint, or on the + /// endpoints of this group that answer with , in place of the status + /// the host maps it to: the same fault can be a 404 on one route and a 409 on another. + /// + /// + /// + /// The response, the operation's document entry and the startup check all read it. On one + /// endpoint, a status for a fault type the endpoint does not answer with stops the host from + /// starting, so a stale one is found before it misleads. + /// + /// + /// On a route group, or any builder that covers several endpoints such as the one + /// MapControllers() returns, it applies to the endpoints that answer with + /// and leaves the others, a health check, alone. The host does not + /// start only when none of the endpoints of that group answers with ; + /// an endpoint of another group does not count. An endpoint's own status replaces its group's, + /// and an inner group's an outer group's. + /// + /// + /// The endpoint, or a group or convention of endpoints. + /// The fault, an enum member or a closed set's member. + /// The HTTP status, 400 to 599. + /// The builder. + /// The fault type. + /// The same builder. + /// is not an error status. + public static TBuilder WithFaultStatus(this TBuilder builder, TFault fault, int status) + where TBuilder : IEndpointConventionBuilder + where TFault : notnull + { + ArgumentNullException.ThrowIfNull(builder); + if (fault is null) throw new ArgumentNullException(nameof(fault)); + FaultStatuses.ThrowIfNoErrorStatus(status, nameof(status)); + + return builder.WithMetadata(new FaultStatusMetadata(fault, status)); + } + } +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultType.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultType.cs new file mode 100644 index 0000000..3f0d47f --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultType.cs @@ -0,0 +1,80 @@ +using CodoMetis.TypeKit.ClosedSets; + +namespace CodoMetis.TypeKit.AspNetCore; + +/// +/// What the satellite knows of a fault type, an enum or a closed set: its members, and each member's +/// name, qualified name, kind and description. The mapping, the startup check, the document and the +/// response read a fault type through this alone, so an enum and a closed set take the same path. +/// +/// +/// Made once per type, by the registration overload whose constraint says which it is, so nothing +/// reflects: an enum's members come from , a set's from its +/// All. A member's kind and description are read through , which a fault +/// set's members implement and an enum's never do. +/// +internal sealed class FaultType where TFault : notnull +{ + private readonly Func _name; + private readonly Func _qualifiedName; + + internal FaultType(string typeName, IReadOnlyList members, Func name, Func qualifiedName) + { + TypeName = typeName; + Members = members; + _name = name; + _qualifiedName = qualifiedName; + } + + /// The fault type's name, as every message names it. + public string TypeName { get; } + + /// Every member, in declaration order. + public IReadOnlyList Members { get; } + + public string NameOf(TFault fault) => _name(fault); + + public string QualifiedNameOf(TFault fault) => _qualifiedName(fault); + + /// The member's kind, where it is a fault set's member; an enum's has none. + public static FaultKind? KindOf(TFault fault) => fault is IFault member ? member.Kind : null; + + /// The member's description, where it is a fault set's member that declares one. + public static string? DescriptionOf(TFault fault) => fault is IFault member ? member.Description : null; + + /// The member of that name, compared ordinally. + public bool TryFind(string name, out TFault fault) + { + foreach (var member in Members) + { + if (string.Equals(NameOf(member), name, StringComparison.Ordinal)) + { + fault = member; + return true; + } + } + + fault = default!; + return false; + } +} + +/// Makes the of an enum or a closed set, each under the constraint that says which. +internal static class FaultTypes +{ + /// + /// An enum's members, each qualified after the enum's nesting chain, Billing.Fault.NotFound, + /// as a closed set's generated QualifiedName is, so two nested enums of one simple name stay apart. + /// + public static FaultType Enum() where TEnum : struct, Enum + { + var typeName = NameOf(typeof(TEnum)); + return new(typeName, System.Enum.GetValues(), static fault => fault.ToString(), fault => $"{typeName}.{fault}"); + } + + public static FaultType Set() where TSet : class, IClosedSet => + new(NameOf(typeof(TSet)), TSet.All, static fault => fault.Name, static fault => fault.QualifiedName); + + /// A fault type's name as every message names it, and as C# writes it: Billing.Fault for a nested one. + public static string NameOf(Type faultType) => TypeNames.NestingChain(faultType, "."); +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs new file mode 100644 index 0000000..5b4d6b8 --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore; + +/// +/// A property in the document: its argument's schema, inlined, never +/// required, and admitting null, since the Null state is part of the wire format whatever the +/// argument is. +/// +/// +/// +/// Inlined rather than a PatchOfstring component: makes ASP.NET's +/// reference-id hook answer null for a patch, so the schema is written where the property is, and +/// this transformer then fills it from the argument's. +/// +/// +/// A value object that appears only inside a patch has no metadata in a source-generated context, +/// since the patch's converter hides it, and ASP.NET cannot make its schema there (measured under +/// Native AOT in spikes/MergePatch). Its wrapped type's schema is asked for instead, which is what +/// the value-object transformer would have put in. +/// +/// +internal sealed class PatchSchemaTransformer : IOpenApiSchemaTransformer +{ + /// A patch is inlined where it is used, never hoisted into a component of its own. + public static void Apply(OpenApiOptions options) + { + var inner = options.CreateSchemaReferenceId; + options.CreateSchemaReferenceId = type => IsPatch(type.Type) ? null : inner(type); + } + + private static bool IsPatch(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Patch<>); + + /// + /// The argument's schema, as it is, with null admitted beside every value it admits. + /// + /// + /// + /// How depends on what restricts the values. A type gains null, which 3.0 writes as + /// nullable. An enum lists every value there is, so it gains null as a value too: + /// without it a closed set's "type": ["null", "string"] still refused null, and a string + /// enum, which has no type, became "type": "null" beside its names and admitted nothing. + /// A subschema refuses null whatever the type says, so a schema that combines subschemas, such as + /// a host's oneOf of a string and a number, is kept whole in a branch beside a null one. + /// (ASP.NET's own polymorphic schema does not need it: its branches state no type.) A schema that + /// restricts nothing, an 's, admits null as it is; it became + /// "type": "null" and admitted only null. Every other keyword (required, + /// pattern, items) applies to its own type only and lets null through. + /// + /// + private static void AdmitNull(OpenApiSchema schema, OpenApiSchema inner) + { + if (inner.AnyOf is { Count: > 0 } || inner.OneOf is { Count: > 0 } || inner.AllOf is { Count: > 0 } || inner.Not is not null || inner.Const is not null) + { + var value = new OpenApiSchema(); + value.FillFrom(inner); + schema.AnyOf = [value, new OpenApiSchema { Type = JsonSchemaType.Null }]; + return; + } + + schema.FillFrom(inner); + + if (schema.Enum is { Count: > 0 } values && !values.Any(value => value is null || value.GetValueKind() == System.Text.Json.JsonValueKind.Null)) + values.Add(null!); + + if (schema.Type is { } type) schema.Type = type | JsonSchemaType.Null; + } + + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var info = context.JsonTypeInfo; + + // The object holding patch properties: none of them is required, since absent is a state. + if (info.Kind == JsonTypeInfoKind.Object && schema.Required is { Count: > 0 } required) + { + foreach (var property in info.Properties) + { + if (IsPatch(property.PropertyType)) required.Remove(property.Name); + } + } + + if (!IsPatch(info.Type)) return; + + var argument = info.Type.GetGenericArguments()[0]; + var described = ValueObjectTypes.WrappedType(argument) ?? argument; + var inner = await context.GetOrCreateSchemaAsync(described, parameterDescription: null, cancellationToken); + + schema.Properties?.Clear(); + AdmitNull(schema, inner); + schema.Description = "A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it." + + (inner.Description is null ? "" : " " + inner.Description); + } +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 7fcefdb..fa8367d 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -20,8 +20,9 @@ value objects references `CodoMetis.TypeKit.Generators`. builder.Services.AddOpenApi(options => options.AddTypeKit()); ``` -`AddTypeKit()` adds one schema transformer, and names the component of a nested value object (see -below). Nothing is registered per type and no assembly is scanned, and its position among the +`AddTypeKit()` adds a schema transformer each for value objects, closed sets and merge patches, an +operation transformer for the fault responses, and names the component of a nested value object or +closed set (see below). Nothing is registered per type and no assembly is scanned, and its position among the document's other transformers does not matter. It lives in `Microsoft.Extensions.DependencyInjection`, beside `AddOpenApi`, which a web project imports implicitly. @@ -73,11 +74,14 @@ no component at all), and both parameters are a bare `string`. `JsonStringEnumConverter` an enum-backed value object is its names. - **Each value object keeps its own component**, named after it, so the document still says `OrderId` where the API means an order id. A value object nested in another type is named after - the whole chain, `Shop.Id` as `ShopId`, so `Shop.Id` and `Stock.Id` do not share one. Two value + the whole chain, `Shop.Id` as `ShopId`, so `Shop.Id` and `Stock.Id` do not share one; a nested + closed set too, `Order.Status` as `OrderStatus`. Where that name is another type's as well, a + top-level `OrderStatus` beside `Order.Status`, from any assembly, generating the document throws an + `InvalidOperationException` naming both, rather than describing both as one. Two value objects of one name in different namespaces (`Billing.InvoiceNo`, `Shipping.InvoiceNo`) do share one, as ASP.NET does for any two types of one name, and it describes both as the first it meets. A name your own `CreateSchemaReferenceId` gives, set before `AddTypeKit()`, is kept, so give one of - them its own name there. A host that prefers value objects inlined returns `null` for them, the + them its own name there, or rename one. A host that prefers value objects inlined returns `null` for them, the nullable ones included, after `AddTypeKit()`: ```csharp @@ -91,6 +95,9 @@ no component at all), and both parameters are a bare `string`. : OpenApiOptions.CreateDefaultSchemaReferenceId(type); ``` + Inlined, a nullable value object or closed set admits null in its own schema wherever a referenced + one admits it around its reference: a closed set's property is then the enum of its names and null. + - **Rules belong in `Create`, not on the property.** A validation attribute on a value-object property (`[MaxLength]`, `[Range]`) lands in the value object's shared component, as ASP.NET does for any referenced schema, so it then applies to every other use too. `Create` is where every way @@ -108,6 +115,17 @@ no component at all), and both parameters are a bare `string`. - **What is already said is kept.** Whatever a value object's schema already says, from ASP.NET or another transformer, is not overwritten; only what it leaves open is filled in. +**A merge patch.** A property typed `Patch` (CodoMetis.TypeKit, a JSON Merge Patch field) is +described as `T`'s schema: + +- inlined where the property is, rather than as a `PatchOfstring` component; +- never required, since absent is a state; +- admitting null, since null is the clear: beside the type, in an enum's list of values, or as a + branch beside the argument's own. + +A value object inside a patch is described through the type it wraps, which is also what a +source-generated host can do: the patch's converter hides the value object from its context. + ## A Result as a response `ToHttpResult()` turns a `Result` into what an endpoint returns: a success as `200 OK` with its value @@ -132,12 +150,13 @@ app.MapDelete("/orders/{id}", (OrderId id) => orders.Cancel(id).ToHttpResult()); { "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10", "title": "Conflict", "status": 409, "fault": "AlreadyShipped", "traceId": "…" } ``` -- **The domain declares faults, the host their statuses.** The fault type is an enum the domain owns; - only the host references ASP.NET. A problem names the fault, never anything of the request. +- **The domain declares faults, the host their statuses.** The fault type is an enum or a closed set + the domain owns; only the host references ASP.NET. The package adds nothing of the request to a + problem: it names the fault, and a `Fault`'s detail is your sentence ([below](#a-sentence-per-occurrence)). - **The document says it too.** The endpoint's return type is ASP.NET's own `Results, FaultProblem>`, so the operation describes the value, and `AddTypeKit()` adds a problem response for every status the fault type is mapped to, as - `ProducesProblem` would. In minimal APIs and in MVC, an action returning the same type. + `ProducesProblem` would. An MVC action returning the same type is described the same way. - **Not a wire format.** The `Result` is matched to a response, not serialized: returning the `Result` itself from a handler is still refused, and the document would promise a `Result`. - **The host's problem details apply.** `AddFaultResponses` calls `AddProblemDetails()`, so a @@ -151,18 +170,194 @@ app.MapDelete("/orders/{id}", (OrderId id) => orders.Cancel(id).ToHttpResult()); ### Mapping faults to statuses -`AddFaultResponses` maps every member of the enum when it is called, so a mapping that throws for a +`AddFaultResponses` maps every member of the fault type when it is called, so a mapping that throws for a member, or gives one a status that is no error (outside 400 to 599), fails there, at startup. That is what the discard arm above relies on: a member the `switch` misses throws while the host starts, not when the fault first occurs. Without the arm, C# warns (CS8524) that a cast could produce a value no member names. A fault type is mapped once. -A host with an endpoint whose fault type is not mapped does not start: the exception names the fault -type and each endpoint that answers with it. The check comes with the first `AddFaultResponses` call; -a host that never calls it refuses the first fault an endpoint reports, and its OpenAPI document, -with the same message. A value no member names, which only a cast makes, is refused rather than +A host with an endpoint whose fault type is not mapped does not start: an +`InvalidOperationException` names the fault type and each endpoint that answers with it. The check +comes with the first `AddFaultResponses` call. A host that never calls it throws the same exception +when an endpoint first reports a fault, and when its OpenAPI document is generated. A value no member names, which only a cast makes, is refused rather than answered with a status the document does not list. +### A response per fault + +A mapping can say more than a status: RFC 9457 makes the problem's `type` its primary identifier, +which a client matches on, and `title` its summary. Both name the problem type and nothing of the +request; without them, ASP.NET writes the status's own. + +```csharp +builder.Services.AddFaultResponses(fault => fault switch +{ + PaymentFault.Declined => FaultResponse.Status(StatusCodes.Status402PaymentRequired).WithType(new Uri("https://example.com/problems/declined")), + PaymentFault.Duplicate => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), +}); +``` + +A bare status converts to a `FaultResponse`, so the arms that need nothing else stay a number; +`WithTitle("…")` sets the `title` the same way. Every member is still mapped and checked when this +is called. The document's problem response for a +status lists the problem types answered with it in its description: +`Payment Required. Problem types: https://example.com/problems/declined`. +Both write the type as the URI it is: an escape stays escaped (`/problems/a%20b`, `%C3%BC`), an +absolute URI given unescaped is escaped, and a relative one is written as given. + +### A closed set as the fault type + +A closed set whose members are faults declares `IFaultSet`: each member carries a `FaultKind`, +nothing HTTP-specific, and optionally a sentence a person reads. + +```csharp +public sealed partial class AccountFault : IFaultSet +{ + public static readonly AccountFault NotFound = new(FaultKind.NotFound, "The account does not exist."); + public static readonly AccountFault EmailTaken = new(FaultKind.Conflict, "Another account uses that email address."); + public static readonly AccountFault Locked = new(FaultKind.Forbidden, null); // a refusal that must not explain itself + + private AccountFault(FaultKind kind, string? description) { Kind = kind; Description = description; } + + public FaultKind Kind { get; } + public string? Description { get; } +} +``` + +```csharp +builder.Services.AddFaultResponses(); // every member by its kind, its description as the detail +``` + +A kind gives the status: `NotFound` 404, `Conflict` 409, `Invalid` 422, `Forbidden` 403 and +`Unavailable` 503. A member whose kind is `default`, which is no kind, fails this call. The +description becomes the problem's `detail`, so a client can show it; a member without one has no +`detail`. To change some members, map them all, with the rest as their kind says: +`fault => fault == AccountFault.Locked ? 423 : FaultResponse.For(fault.Kind)`. Any closed set can be a +fault type with a mapping of its own, `AddFaultResponses(fault => …)`, and `ToHttpResult()` +takes a result over either. + +### A sentence per occurrence + +What varies per occurrence travels in a `Fault` (CodoMetis.TypeKit): the fault, and a sentence +about this one. The response, the mapping and the document stay keyed on the fault type. + +```csharp +Result> taken = Result.Error(Fault.Of(AccountFault.EmailTaken, "The address has been in use since 3 May.")); +``` + +```csharp +app.MapPut("/accounts/{id:int}/email", (int id, NewAccount body) => accounts.Rename(id, body.Email).ToHttpResult()); +``` + +The detail is the problem's `detail`, in place of the member's description. It is the application's +sentence, written as it is: a fault's name never quotes the request, and whether a detail does is the +application's to decide. + +### A status per endpoint + +The same fault can mean 404 on one route and 409 on another. An endpoint changes the status of one +fault for itself, in its response and in its document entry: + +```csharp +app.MapPost("/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult()) + .WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); +``` + +An MVC action says it with an attribute, naming the member, since an attribute takes only constants: + +```csharp +[HttpPost("/mvc/accounts")] +[FaultStatus(nameof(AccountFault.EmailTaken), StatusCodes.Status422UnprocessableEntity)] +public Results, FaultProblem> Open(NewAccount body) => accounts.Open(body).ToHttpResult(); +``` + +A route group says it for its endpoints, and a controller class, with the same attribute, for its +actions. Such a status applies to the endpoints of that group or controller that answer with the +fault type and leaves the others alone, so a group can hold a health check beside them: + +```csharp +var v3 = app.MapGroup("/v3").WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); +v3.MapPost("/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult()); // answers with AccountFault: the 422 applies +v3.MapGet("/health", () => "ok"); // answers with none: left alone +``` + +What stops the host from starting, naming the status and its endpoints: + +- a status on one endpoint or action that answers with no fault of that type; +- a status on a group or a controller none of whose own endpoints answers with that type. An endpoint + of another group or controller does not count, and a controller that derives from a base class + with the attribute is a controller of its own; +- an attribute naming no member. + +The same holds for any convention builder that covers several endpoints, such as the one +`MapControllers()` returns. A status outside 400 to 599 is refused where it is set. Where several +statuses name one member, the last in ASP.NET's metadata order wins: an endpoint's own over its +group's, an inner group's over an outer group's, an action's over its controller's. ASP.NET puts the +metadata of `MapControllers()`'s builder after every attribute, so a status set there wins over an +action's own. + +### The success the endpoint chooses + +A success is `200 OK` with its value unless the endpoint says otherwise, with any of ASP.NET's typed +results, so the document still describes it: + +```csharp +app.MapPost("/v2/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult(account => TypedResults.Created($"/accounts/{account.Id}", account))); +``` + +The operation lists `201` with the account as its body, beside the problem responses, and the +response carries the `Location` header. A result of the application's own, one that sets an ETag, +takes the same place. A `Result` takes a factory without a value, `ToHttpResult(() => +TypedResults.Accepted("/jobs/7"))`. + +### What the host adds to a problem + +While `ToHttpResult()` writes a problem, the request carries an `IFaultFeature`: the fault type, the +fault, the name the problem gives it and its detail. A `CustomizeProblemDetails` of the host's reads +it to add what the package does not name, and finds it on no other problem the request writes. + +```csharp +builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => +{ + if (context.HttpContext.Features.Get() is { } fault) + context.ProblemDetails.Extensions["code"] = $"ACC-{fault.Name}"; // the host's own extension, for this fault only +}); +builder.Services.Configure(options => options.QualifiedFaultNames = true); // "fault": "AccountFault.EmailTaken" +``` + +`QualifiedFaultNames` writes `fault` with its type, so two fault types that share a member name stay +apart in one client; the default is the bare name. A nested type is qualified after its nesting chain, +an enum as a closed set's `QualifiedName` is, so `Billing.Fault` and `Shipping.Fault` give +`Billing.Fault.NotFound` and `Shipping.Fault.NotFound`. + +The document describes `fault` as an optional string on the problem schema the fault responses refer +to, so a generated client has the member to match on; its description says which of the two forms +this host writes. That schema is the shared `ProblemDetails` component, which ASP.NET's own problem +responses refer to as well, so the description also says that only a problem answering a fault +carries it. It stays a string, since an enum of every fault type would change every operation when +one is added. A document without a fault response keeps the schema as ASP.NET writes it. + +### Faults a client must not tell apart + +Every problem `ToHttpResult()` writes names its fault in `fault`, for every member, whether it has a +description or not: the name is part of the response's contract, as its status is, and the document +describes it. A status alone hides nothing. A sign-in that answers 401 for an unknown user and for a +wrong password still says `UnknownUser` or `WrongPassword`, and so which accounts exist. Where two +causes must not be told apart, make them one fault before `ToHttpResult()`, mapping the domain's +faults to a member of the boundary's own: + +```csharp +public enum SignInFault { UnknownUser, WrongPassword } // the domain's: which check refused +public enum LoginFault { InvalidCredentials } // what the client is told + +builder.Services.AddFaultResponses(_ => StatusCodes.Status401Unauthorized); + +app.MapPost("/login", (Credentials credentials) => accounts.SignIn(credentials).MapError(_ => LoginFault.InvalidCredentials).ToHttpResult()); +``` + +Both causes then answer alike, status, `type`, `title`, `fault` and `detail`. The domain keeps its two +faults for its own use, a log or a lockout count; a `Fault`'s detail stays behind with them. + ## NodaTime ASP.NET cannot see through NodaTime's JSON converters, so once they are on the host's JSON options @@ -234,3 +429,17 @@ The generated members cover the request itself: Both minimal APIs and MVC then answer a body without it with 400. An optional constructor parameter (`Discount? Discount`) then needs a default value (`= null`). +- **A query, header or form value missing from an MVC request is not refused either.** MVC binds + nothing for a non-nullable struct and calls the action with `default`, as it does for a `Guid` or + an `int`, and the model state stays valid; for a struct value object that is again an instance + that passed no factory. A minimal API answers 400, and MVC already requires a class value object + or a closed set, as it does any non-nullable reference type. Mark the parameter `[BindRequired]`, + and MVC answers 400 ("A value for the 'id' parameter or property was not provided."), or make it + nullable where it is optional, and the action receives `null`: + + ```csharp + using Microsoft.AspNetCore.Mvc.ModelBinding; // BindRequired + + [HttpGet("/mvc/orders")] + public OrderDto Find([FromQuery, BindRequired] OrderId id, [FromQuery] Quantity? limit) => orders.Find(id, limit); + ``` diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitFaultResponsesServiceCollectionExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitFaultResponsesServiceCollectionExtensions.cs index efe527f..6d9600f 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitFaultResponsesServiceCollectionExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitFaultResponsesServiceCollectionExtensions.cs @@ -1,11 +1,11 @@ using CodoMetis.TypeKit.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection.Extensions; +using CodoMetis.TypeKit.ClosedSets; // Beside AddProblemDetails, in a namespace every web project imports implicitly. namespace Microsoft.Extensions.DependencyInjection; -/// Maps the faults of a Result to the HTTP statuses a host answers them with. +/// Maps the faults of a Result, an enum's members, to the HTTP responses a host answers them with. +/// A closed set's members take the same calls from . public static class TypeKitFaultResponsesServiceCollectionExtensions { /// @@ -36,15 +36,82 @@ public static IServiceCollection AddFaultResponses(this IServiceCollecti ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(statusOf); - // Two mappings for one fault type: whichever was registered last would answer, silently. - if (services.Any(descriptor => descriptor.ServiceType == typeof(FaultResponses))) - throw new InvalidOperationException( - $"The fault type {typeof(TFault).Name} is mapped already. Map it once, with a status for each member."); + return FaultRegistration.Add(services, FaultTypes.Enum(), fault => FaultResponse.Status(statusOf(fault)), nameof(statusOf)); + } + + /// + /// Answers every fault of with the response + /// gives it: a status, and optionally the problem's type and title. + /// + /// + /// As the overload that takes a status: every member is mapped here, the document lists each + /// status, and the host does not start with an endpoint whose fault type is not mapped. A bare + /// status converts to a , so a mapping returns 404 for one member + /// and FaultResponse.Status(409).WithType(uri) for another. + /// + /// The fault type, an enum. + /// The host's services. + /// The response of each fault, typically a switch expression. + /// The same services. + /// is mapped already. + /// A member is mapped to a status that is not an error. + public static IServiceCollection AddFaultResponses(this IServiceCollection services, Func responseOf) + where TFault : struct, Enum + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(responseOf); + + return FaultRegistration.Add(services, FaultTypes.Enum(), responseOf, nameof(responseOf)); + } +} + +/// Maps the faults of a Result, a closed set's members, to the HTTP responses a host answers them with. +/// Separate from the enum's overloads, which take the same parameters, since C# tells the two apart by their constraints alone. +public static class TypeKitClosedSetFaultResponsesServiceCollectionExtensions +{ + /// + /// Answers every member of the fault set by its , + /// with no mapping written: NotFound 404, Conflict 409, Invalid 422, + /// Forbidden 403 and Unavailable 503, and its description as the problem's detail. + /// + /// + /// A member whose kind is no , a default one, fails this call. To change + /// some members, map them all: fault => fault == OrderFault.Gone ? 410 : FaultResponse.For(fault.Kind). + /// + /// The fault set. + /// The host's services. + /// The same services. + /// is mapped already. + /// A member's kind is no . + public static IServiceCollection AddFaultResponses(this IServiceCollection services) + where TFault : class, IFaultSet + { + ArgumentNullException.ThrowIfNull(services); - services.AddSingleton(FaultResponses.Map(statusOf)); - services.TryAddEnumerable(ServiceDescriptor.Transient()); - services.AddProblemDetails(); + return FaultRegistration.Add(services, FaultTypes.Set(), static fault => + { + try { return FaultResponse.For(fault.Kind); } + catch (ArgumentOutOfRangeException refused) { throw new ArgumentException($"{fault.QualifiedName}: {refused.Message}", nameof(TFault), refused); } + }, nameof(TFault)); + } + + /// + /// Answers every member of the closed set with the response + /// gives it, as the enum's overload does. + /// + /// Every member is mapped here; a bare status converts to a . + /// The fault type, a closed set. + /// The host's services. + /// The response of each member. + /// The same services. + /// is mapped already. + /// A member is mapped to a status that is not an error. + public static IServiceCollection AddFaultResponses(this IServiceCollection services, Func responseOf) + where TFault : class, IClosedSet + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(responseOf); - return services; + return FaultRegistration.Add(services, FaultTypes.Set(), responseOf, nameof(responseOf)); } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs index f76d621..bd963ca 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs @@ -1,19 +1,32 @@ +using System.Diagnostics.CodeAnalysis; using CodoMetis.TypeKit; using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ClosedSets; using Microsoft.AspNetCore.Http.HttpResults; // Beside TypedResults, in a namespace every web project imports implicitly. namespace Microsoft.AspNetCore.Http; -/// Turns a Result into the HTTP response an endpoint returns. +/// Turns a Result whose fault is an enum, or a , into the HTTP response an endpoint returns. /// +/// /// Matching a result to a response is not serializing it: the success's value is written as the /// endpoint's body, and the Result itself still refuses System.Text.Json. A handler that /// returns the Result instead would document a Result, and under Native AOT need one in /// the host's JSON context (spikes/ResultHttp, findings 9 and 10). +/// +/// +/// A result whose fault is a closed set takes the same calls from +/// : C# tells the two apart by their constraints, so +/// they live in two classes and resolve without type arguments. +/// /// public static class TypeKitHttpResultExtensions { + // What ASP.NET's Results asks of its first type argument, carried to the + // success type the endpoint chooses, so the trimmer keeps the members it describes the response by. + internal const DynamicallyAccessedMemberTypes SuccessMembers = DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods; + /// /// A success as 200 OK with its value, a fault as problem details with the status /// AddFaultResponses mapped it to. @@ -43,4 +56,130 @@ public static Results> ToHttpResult(this result.Match>>( static () => TypedResults.NoContent(), static fault => new FaultProblem(fault)); + + /// + /// A success as the response makes of its value, such as + /// TypedResults.Created(uri, order); a fault as problem details. + /// + /// The success is ASP.NET's own typed result, so the operation describes its status and body, a 201 with its value. + /// The outcome of the operation the endpoint ran. + /// Makes the response for the value. + /// The response. + public static Results> ToHttpResult(this Result result, Func success) + where T : notnull + where TError : struct, Enum + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(value => success(value), static fault => new FaultProblem(fault)); + } + + /// A success as the response makes, such as TypedResults.Accepted(uri); a fault as problem details. + /// The outcome of the operation the endpoint ran. + /// Makes the response for a success. + /// The response. + public static Results> ToHttpResult(this Result result, Func success) + where TError : struct, Enum + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(() => success(), static fault => new FaultProblem(fault)); + } + + /// + /// A success as 200 OK with its value; a fault as problem details for its reason, with its + /// detail as the problem's detail. The mapping and the document are the reason's type's. + /// + /// The outcome of the operation the endpoint ran. + /// The response. + public static Results, FaultProblem> ToHttpResult(this Result> result) + where T : notnull + where TFault : notnull => + result.Match, FaultProblem>>( + static value => TypedResults.Ok(value), + static fault => new FaultProblem(fault.Reason, fault.Detail)); + + /// A success as 204 No Content; a fault as problem details for its reason, with its detail. + /// The outcome of the operation the endpoint ran. + /// The response. + public static Results> ToHttpResult(this Result> result) + where TFault : notnull => + result.Match>>( + static () => TypedResults.NoContent(), + static fault => new FaultProblem(fault.Reason, fault.Detail)); + + /// A success as the response makes of its value; a fault as problem details for its reason, with its detail. + /// The outcome of the operation the endpoint ran. + /// Makes the response for the value. + /// The response. + public static Results> ToHttpResult(this Result> result, Func success) + where T : notnull + where TFault : notnull + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(value => success(value), static fault => new FaultProblem(fault.Reason, fault.Detail)); + } + + /// A success as the response makes; a fault as problem details for its reason, with its detail. + /// The outcome of the operation the endpoint ran. + /// Makes the response for a success. + /// The response. + public static Results> ToHttpResult(this Result> result, Func success) + where TFault : notnull + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(() => success(), static fault => new FaultProblem(fault.Reason, fault.Detail)); + } +} + +/// Turns a Result whose fault is a closed set into the HTTP response an endpoint returns, as does for an enum. +public static class TypeKitClosedSetHttpResultExtensions +{ + private const DynamicallyAccessedMemberTypes SuccessMembers = TypeKitHttpResultExtensions.SuccessMembers; + + /// A success as 200 OK with its value, a fault as problem details with the status the host answers it with. + /// The outcome of the operation the endpoint ran. + /// The response. + public static Results, FaultProblem> ToHttpResult(this Result result) + where T : notnull + where TError : class, IClosedSet => + result.Match, FaultProblem>>( + static value => TypedResults.Ok(value), + static fault => new FaultProblem(fault)); + + /// A success as 204 No Content, a fault as problem details. + /// The outcome of the operation the endpoint ran. + /// The response. + public static Results> ToHttpResult(this Result result) + where TError : class, IClosedSet => + result.Match>>( + static () => TypedResults.NoContent(), + static fault => new FaultProblem(fault)); + + /// A success as the response makes of its value; a fault as problem details. + /// The outcome of the operation the endpoint ran. + /// Makes the response for the value. + /// The response. + public static Results> ToHttpResult(this Result result, Func success) + where T : notnull + where TError : class, IClosedSet + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(value => success(value), static fault => new FaultProblem(fault)); + } + + /// A success as the response makes; a fault as problem details. + /// The outcome of the operation the endpoint ran. + /// Makes the response for a success. + /// The response. + public static Results> ToHttpResult(this Result result, Func success) + where TError : class, IClosedSet + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(() => success(), static fault => new FaultProblem(fault)); + } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs index 9e0ac16..88c3c79 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs @@ -27,7 +27,8 @@ public static class TypeKitOpenApiOptionsExtensions /// /// /// An endpoint that returns ToHttpResult() also lists a problem-details response for every - /// status its fault type is mapped to with AddFaultResponses. + /// status its fault type is mapped to with AddFaultResponses, and the problem schema those + /// responses refer to describes the fault member. /// /// /// A nested value object's component is named after its nesting chain (Shop.Id is @@ -43,8 +44,11 @@ public static OpenApiOptions AddTypeKit(this OpenApiOptions options) ArgumentNullException.ThrowIfNull(options); ValueObjectSchemaReferenceIds.Apply(options); + PatchSchemaTransformer.Apply(options); return options.AddSchemaTransformer() + .AddSchemaTransformer() + .AddSchemaTransformer() .AddOperationTransformer(); } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeNames.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeNames.cs new file mode 100644 index 0000000..12832aa --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeNames.cs @@ -0,0 +1,24 @@ +namespace CodoMetis.TypeKit.AspNetCore; + +/// +/// How the satellite names a type: after its nesting chain, as the generators name a nested value +/// object's companion class (ShopIdExtensions) and a nested closed set's +/// QualifiedName (Order.Status.Draft). +/// +internal static class TypeNames +{ + /// + /// The type's name after its containing types, joined by , without a + /// generic arity: Order.Status is OrderStatus with "", and Order.Status + /// with ".". + /// + public static string NestingChain(Type type, string separator) => + type.DeclaringType is { } declaring ? NestingChain(declaring, separator) + separator + Simple(type) : Simple(type); + + /// The type's namespace and nesting chain, as a message names it. + public static string Display(Type type) => + type.Namespace is { Length: > 0 } space ? $"{space}.{NestingChain(type, ".")}" : NestingChain(type, "."); + + private static string Simple(Type type) => + type.Name.IndexOf('`') is var tick and >= 0 ? type.Name[..tick] : type.Name; +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaReferenceIds.cs b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaReferenceIds.cs index b96d536..c26af7c 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaReferenceIds.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaReferenceIds.cs @@ -1,38 +1,73 @@ +using System.Collections.Concurrent; using Microsoft.AspNetCore.OpenApi; namespace CodoMetis.TypeKit.AspNetCore; /// -/// Names a nested value object's component after its whole nesting chain: Shop.Id is -/// ShopId, as its generated companion class is ShopIdExtensions. +/// Names a nested value object's or closed set's component after its whole nesting chain: +/// Shop.Id is ShopId, as its generated companion class is ShopIdExtensions, and +/// Order.Status is OrderStatus. Refuses a document in which such a name is also another +/// type's. /// /// +/// /// ASP.NET names a component after the type's simple name, so Shop.Id and Stock.Id /// shared one component Id, and whichever was described first described both: a stock id /// wrapping an was documented as a uuid (measured with Microsoft.AspNetCore.OpenApi -/// 10.0.12). Only ASP.NET's default name is replaced: a name the host's own +/// 10.0.12), and Order.Status and Shipment.Status shared one enum of the first's +/// names. Only ASP.NET's default name is replaced: a name the host's own /// chose, or for a value /// object it inlines, is kept. +/// +/// +/// The chain's name can be another type's own: Order.Status and a top-level OrderStatus +/// both got OrderStatus, the one component described whichever ASP.NET met first, and the +/// property typed Order.Status referenced the other's names. Nothing at compile time sees it: a +/// closed set has no companion class to collide, and two assemblies never meet there. So a name this +/// package gave that another type claims too, or a name another type got that this package gives, is +/// an naming both. Two types this package does not rename +/// sharing ASP.NET's name stay ASP.NET's behaviour, as for any two types of one name. +/// +/// +/// The names claimed are kept per instance, which is per document: two +/// documents are two sets of components. A type's name is the same on every call, so a document +/// generated again, or two generated at once, claim the same names. +/// /// internal static class ValueObjectSchemaReferenceIds { public static void Apply(OpenApiOptions options) { var inner = options.CreateSchemaReferenceId; + var claims = new ConcurrentDictionary(StringComparer.Ordinal); options.CreateSchemaReferenceId = type => { - var id = inner(type); - - return id is not null - && (Nullable.GetUnderlyingType(type.Type) ?? type.Type) is { IsNested: true } valueObject - && ValueObjectTypes.IsValueObject(valueObject) - && id == OpenApiOptions.CreateDefaultSchemaReferenceId(type) - ? NestingChain(valueObject) - : id; + if (inner(type) is not { } id) return null; + + var claimant = Nullable.GetUnderlyingType(type.Type) ?? type.Type; + var renamed = claimant.IsNested + && (ValueObjectTypes.IsValueObject(claimant) || ClosedSetSchemaTransformer.Describe(claimant) is not null) + && id == OpenApiOptions.CreateDefaultSchemaReferenceId(type); + if (renamed) id = TypeNames.NestingChain(claimant, ""); + + var claim = new Claim(claimant, renamed); + var first = claims.GetOrAdd(id, claim); + if (first.Type != claimant && (first.Renamed || renamed)) + throw Collision(id, first, claim); + + return id; }; } - private static string NestingChain(Type type) => - type.DeclaringType is { } declaring ? NestingChain(declaring) + type.Name : type.Name; + private static InvalidOperationException Collision(string id, Claim first, Claim second) => + new($"Two types are named '{id}' in the OpenAPI document: {first.Describe()} and {second.Describe()}. " + + "One component would describe both as one of them. Rename one of the types, or give one of them a " + + $"component name of its own with OpenApiOptions.CreateSchemaReferenceId, set before AddTypeKit()."); + + private sealed record Claim(Type Type, bool Renamed) + { + public string Describe() => + Renamed ? $"{TypeNames.Display(Type)} (named after its nesting chain)" : TypeNames.Display(Type); + } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs index 5fb88b2..d22064f 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs @@ -127,7 +127,7 @@ private static async Task ElementSchemaAsync(Type element, OpenA /// The component a type gets in this document, or where the host inlines it: the /// document's own , which ASP.NET asks too. /// - private static string? SchemaReferenceId(JsonTypeInfo type, OpenApiSchemaTransformerContext context) => + internal static string? SchemaReferenceId(JsonTypeInfo type, OpenApiSchemaTransformerContext context) => context.ApplicationServices.GetRequiredService>().Get(context.DocumentName).CreateSchemaReferenceId(type); /// @@ -202,9 +202,12 @@ private static async Task DescribeAsync( else { // An inlined wrapped type (a number, a string, a Guid) admits null in its type, as ASP.NET's - // exporter writes a nullable property of it. + // exporter writes a nullable property of it; an inlined enum or closed set among its values + // too, since an enum lists every value a validator accepts. A new list: the wrapped one is + // the wrapped type's. schema.FillFrom(wrappedSchema); if (schema.Type is { } type) schema.Type = type | JsonSchemaType.Null; + if (schema.Enum is { Count: > 0 } values && !values.Contains(null!)) schema.Enum = [.. values, null!]; } } catch (NotSupportedException unsupported) diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetConverter.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetConverter.cs new file mode 100644 index 0000000..f0049b3 --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetConverter.cs @@ -0,0 +1,60 @@ +using System.ComponentModel; +using System.Linq.Expressions; +using CodoMetis.TypeKit.ClosedSets; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// +/// Stores a closed set's member as its name, and reads the member itself back with the set's own +/// TryParse. +/// +/// +/// UseTypeKit() applies it to every closed-set property by itself, so it only needs to be +/// named to configure a property explicitly. A name the set no longer has is an +/// , never a null. +/// +/// The closed set. +public sealed class ClosedSetConverter : ValueConverter + where TSet : class, IClosedSet +{ + // Both directions call a public static method of this class. EF's compiled model + // (dotnet ef dbcontext optimize, which Native AOT requires) writes these expressions out as C# in + // the application's own assembly, where an internal helper does not compile (CS0122, measured with + // EF Core 10.0.12); and an expression tree can call neither the static abstract TryParse nor + // anything with an out variable. + internal static readonly Expression> ToProvider = member => ProviderValue(member); + internal static readonly Expression> FromProvider = name => MemberNamed(name); + + /// Creates the converter. + public ClosedSetConverter() + : this(null) + { + } + + /// Creates the converter with hints for the text column. + /// Size or Unicode hints for the column. + public ClosedSetConverter(ConverterMappingHints? mappingHints) + : base(ToProvider, FromProvider, mappingHints) + { + } + + /// The value to store: the member's name. + /// Public for the code EF's compiled model generates; nothing else needs to call it. + /// The member. + /// Its name. + /// The instance is not one of the set's members. + [EditorBrowsable(EditorBrowsableState.Never)] + public static string ProviderValue(TSet member) => member.Name; + + /// The member of a stored name. + /// Public for the code EF's compiled model generates; nothing else needs to call it. + /// A name the application stored. + /// The member itself. + /// The set has no member of that name. + [EditorBrowsable(EditorBrowsableState.Never)] + public static TSet MemberNamed(string name) => + TSet.TryParse(name, out var member) + ? member + : throw new InvalidOperationException($"{typeof(TSet).Name} has no member of that name: the column holds a name the set no longer has."); +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs new file mode 100644 index 0000000..bbaa392 --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs @@ -0,0 +1,59 @@ +using System.Collections.Concurrent; +using System.Reflection; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.CompilerServices; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.Json; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// +/// A closed set as a column: its member's name, read back with the set's own TryParse, and a +/// JSON reader/writer (primitive collections, JSON columns) over the string's. Made per set through +/// , never by MakeGenericType, so it +/// works under Native AOT as the value-object mappings do. +/// +/// +/// As for a value object (), the converter is a plain +/// over the expressions of +/// , whose public methods the compiled model's code can call, and +/// the JSON reader/writer is composed here, with both types known, where EF's own composition uses +/// MakeGenericType. +/// +internal abstract class ClosedSetMapping +{ + private static readonly ConcurrentDictionary Descriptions = new(); + private static readonly ConcurrentDictionary Mappings = new(); + + /// The set's attribute, or null for any other type. + public static GeneratedClosedSetAttribute? Describe(Type type) => + Descriptions.GetOrAdd(type, static candidate => + candidate.GetCustomAttribute(inherit: false) is { } set && set.SetType == candidate ? set : null); + + public static ClosedSetMapping For(GeneratedClosedSetAttribute set) => + Mappings.GetOrAdd(set.SetType, static (_, description) => description.Accept(Factory.Instance), set); + + public abstract ValueConverter Converter { get; } + + /// The set's reader/writer over the string's, or where the text mapping has none. + protected abstract JsonValueReaderWriter? ReaderWriterOver(JsonValueReaderWriter? text); + + public RelationalTypeMapping ComposeOnto(RelationalTypeMapping text) => + (RelationalTypeMapping)text.WithComposedConverter(Converter, jsonValueReaderWriter: ReaderWriterOver(text.JsonValueReaderWriter)); + + private sealed class Of : ClosedSetMapping where TSet : class, IClosedSet + { + public override ValueConverter Converter { get; } = new ValueConverter(ClosedSetConverter.ToProvider, ClosedSetConverter.FromProvider); + + protected override JsonValueReaderWriter? ReaderWriterOver(JsonValueReaderWriter? text) => + text is JsonValueReaderWriter typed ? new JsonConvertedValueReaderWriter(typed, Converter) : null; + } + + private sealed class Factory : IClosedSetVisitor + { + public static readonly Factory Instance = new(); + + public ClosedSetMapping Visit() where TSet : class, IClosedSet => new Of(); + } +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/CodoMetis.TypeKit.EntityFrameworkCore.csproj b/src/CodoMetis.TypeKit.EntityFrameworkCore/CodoMetis.TypeKit.EntityFrameworkCore.csproj index fad1b96..73dacbb 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/CodoMetis.TypeKit.EntityFrameworkCore.csproj +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/CodoMetis.TypeKit.EntityFrameworkCore.csproj @@ -2,7 +2,7 @@ - EF Core integration for CodoMetis.TypeKit: value-object converters by convention, and .Value in LINQ queries. + EF Core integration for CodoMetis.TypeKit: value-object and closed-set converters by convention, and .Value in LINQ queries. efcore;entity-framework-core;value-object;strongly-typed-id;value-converter;linq;postgresql;ddd;native-aot diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs new file mode 100644 index 0000000..5f1499f --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -0,0 +1,208 @@ +using System.Linq.Expressions; +using System.Reflection; +using CodoMetis.TypeKit.CompilerServices; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// +/// Refuses a query that compares, orders, or takes the minimum or maximum of a value object whose +/// ordering is hand-written (): the database orders its column +/// by the wrapped value, and CompareTo cannot run there. +/// +/// +/// +/// EF translates o.Priority > p as "o"."Priority" > @p, dropping the operator the +/// value object declares, rewrites o.Priority.CompareTo(p) > 0 the same way, and orders by the +/// column: with a descending CompareTo, every answer was the opposite of the in-memory one, +/// without a word (measured with EF Core 10.0.12 on SQLite). Comparing and ordering by .Value +/// stays allowed: that says the wrapped value's order is meant. +/// +/// +/// An interceptor because no translator sees these: EF translates a comparison without asking for the +/// operator's method, and an ordering key is a column, not a call. QueryCompilationStarting sees +/// the LINQ tree once per query shape, before any translation, with the value objects' types intact; +/// a precompiled query (Native AOT) passes through it at dbcontext optimize, which then fails. +/// It does not see the query filters EF adds later, which refuses. +/// +/// +internal sealed class HandWrittenOrderingInterceptor : IQueryExpressionInterceptor +{ + public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) + { + HandWrittenOrdering.RefuseIn(queryExpression); + + return queryExpression; + } +} + +/// +/// Refuses a query filter that compares, orders, or takes the minimum or maximum of a value object whose +/// ordering is hand-written, when the model is built, named filters included. +/// +/// +/// EF adds an entity type's query filters to a query after QueryCompilationStarting, so +/// never saw them: HasQueryFilter(r => r.Rank > From(2)) +/// kept the rows the column's order keeps, the opposite of the in-memory one for a descending +/// CompareTo (measured with EF Core 10.0.12 on SQLite). A filter is part of the model, and applies +/// to every query of its entity type and of the navigations that reach it, so it is refused where the +/// model is built, as an invalid configuration is; a compiled model is refused at dbcontext optimize. +/// +internal sealed class HandWrittenOrderingQueryFilterConvention : IModelFinalizedConvention +{ + public IModel ProcessModelFinalized(IModel model) + { + foreach (var entityType in model.GetEntityTypes()) + foreach (var filter in entityType.GetDeclaredQueryFilters()) + if (filter.Expression is { } expression) + HandWrittenOrdering.RefuseInFilter(expression, filter.IsAnonymous + ? $"in the query filter of {entityType.DisplayName()}" + : $"in the query filter \"{filter.Key}\" of {entityType.DisplayName()}"); + + return model; + } +} + +/// Finds a comparison, an ordering, or a minimum or maximum of a value object with a hand-written ordering, and throws. +internal static class HandWrittenOrdering +{ + /// In a query's LINQ tree, which EF has already stripped of what it evaluates in memory. + public static void RefuseIn(Expression query) => + new Finder("in a query", ", or do it in memory after the query", skipsWhatRunsInMemory: false).Visit(query); + + /// + /// In a query filter, where EF has not yet evaluated what reads nothing of the database: a comparison of + /// the context's own values runs in memory, by CompareTo, and is not refused. + /// + public static void RefuseInFilter(LambdaExpression filter, string where) => + new Finder(where, "", skipsWhatRunsInMemory: true).Visit(filter); + + private sealed class Finder(string where, string orElse, bool skipsWhatRunsInMemory) : ExpressionVisitor + { + protected override Expression VisitBinary(BinaryExpression node) + { + if (node.NodeType is ExpressionType.LessThan or ExpressionType.LessThanOrEqual or ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual + && !RunsInMemory(node)) + { + Refuse(node.Left.Type, $"compared with {Operator(node.NodeType)}"); + Refuse(node.Right.Type, $"compared with {Operator(node.NodeType)}"); + } + + return base.VisitBinary(node); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + var method = node.Method; + + if (method.Name is nameof(IComparable.CompareTo) or nameof(IComparer<>.Compare)) + { + if (!RunsInMemory(node)) + { + if (node.Object is not null) Refuse(node.Object.Type, $"compared with {method.Name}"); + foreach (var argument in node.Arguments) Refuse(argument.Type, $"compared with {method.Name}"); + } + } + else if ((method.DeclaringType == typeof(Queryable) || method.DeclaringType == typeof(Enumerable)) && OrderedType(method) is { } ordered) + { + Refuse(ordered, $"ordered by {method.Name}"); + + // A key the selector converts (to object, IComparable) is still ordered in memory by the + // value object's CompareTo, while EF drops the conversion and orders by the column. + if (KeySelector(node) is { } key) Refuse(Unconverted(key.Body).Type, $"ordered by {method.Name}"); + } + + return base.VisitMethodCall(node); + } + + /// The operator's key or value selector, quoted for Queryable, a lambda for Enumerable. + private static LambdaExpression? KeySelector(MethodCallExpression node) => + node.Arguments.Count > 1 ? StripQuotes(node.Arguments[1]) as LambdaExpression : null; + + private static Expression StripQuotes(Expression expression) => + expression is UnaryExpression { NodeType: ExpressionType.Quote } quote ? StripQuotes(quote.Operand) : expression; + + /// The expression under its boxing and reference conversions; a conversion operator, which changes the value, is kept. + private static Expression Unconverted(Expression expression) => + expression is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked or ExpressionType.TypeAs, Method: null } conversion + ? Unconverted(conversion.Operand) + : expression; + + /// + /// Whether EF evaluates the comparison before translating, in a filter: it reads no parameter + /// bound outside it (the entity) and no query (a DbSet of the context). + /// + private bool RunsInMemory(Expression comparison) => skipsWhatRunsInMemory && !DatabaseReads.In(comparison); + + /// The type a LINQ operator orders by: the key, the element for Order, the result for Min and Max. + private static Type? OrderedType(MethodInfo method) => method.Name switch + { + nameof(Queryable.OrderBy) or nameof(Queryable.OrderByDescending) or nameof(Queryable.ThenBy) or nameof(Queryable.ThenByDescending) + or nameof(Queryable.MinBy) or nameof(Queryable.MaxBy) when method.IsGenericMethod => method.GetGenericArguments()[1], + nameof(Queryable.Order) or nameof(Queryable.OrderDescending) when method.IsGenericMethod => method.GetGenericArguments()[0], + nameof(Queryable.Min) or nameof(Queryable.Max) => method.ReturnType, + _ => null, + }; + + private void Refuse(Type type, string how) + { + var candidate = Nullable.GetUnderlyingType(type) ?? type; + + if (!ValueObjectTypes.HasHandWrittenOrdering(candidate)) return; + + var wrapped = ValueObjectTypes.Describe(candidate)!.WrappedType; + + throw new InvalidOperationException( + $"{candidate.Name} is {how} {where}, but its ordering is its own CompareTo, which the database cannot run: " + + $"there the column orders by the {wrapped.Name} it holds, which need not be {candidate.Name}'s order. " + + $"Compare or order by .Value explicitly where the {wrapped.Name}'s order is meant{orElse}."); + } + + private static string Operator(ExpressionType type) => type switch + { + ExpressionType.LessThan => "<", + ExpressionType.LessThanOrEqual => "<=", + ExpressionType.GreaterThan => ">", + _ => ">=", + }; + } + + /// Whether an expression reads a parameter it does not declare itself, or a query. + private sealed class DatabaseReads : ExpressionVisitor + { + private readonly HashSet _declared = []; + private bool _found; + + public static bool In(Expression expression) + { + var reads = new DatabaseReads(); + reads.Visit(expression); + + return reads._found; + } + + public override Expression? Visit(Expression? node) + { + if (_found || node is null) return node; + if (typeof(IQueryable).IsAssignableFrom(node.Type)) _found = true; + + return _found ? node : base.Visit(node); + } + + protected override Expression VisitLambda(Expression node) + { + _declared.UnionWith(node.Parameters); + + return base.VisitLambda(node); + } + + protected override Expression VisitParameter(ParameterExpression node) + { + if (!_declared.Contains(node)) _found = true; + + return node; + } + } +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 0d402bb..24db417 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -67,13 +67,35 @@ would be a `default` instance.) A property can still name the converter explicit modelBuilder.Entity().Property(o => o.Code).HasConversion>(); ``` -**Keys.** A single-column key over an integer (`int`, `long`, `short`) is generated on add, as a key -of that integer type is. On PostgreSQL and SQL Server it is an identity column, so switching an `int` -key to a value object there changes no schema. A key over a `Guid` is the application's to assign, +**Keys.** A single-column key over an integer is declared generated on add, as a key of that integer +type is: `int`, `long`, `short`, `sbyte`, `uint`, `ulong` and `ushort`, EF's own list, and not `byte`. +Whether a value is then generated is the provider's to decide, as for a plain key. On PostgreSQL and +SQL Server it is an identity column, so switching an `int` key to a value object there changes no +schema; where the provider has none for the type, the key is no more generated than a plain one (on +PostgreSQL a `ulong` is a `numeric(20,0)`, which cannot be an identity). Two exceptions, because EF +picks the generator that fills a value object's key at `Add` by the column's type, not by the type it +wraps: + +- **A key over `char` is the application's to assign.** It is never declared generated, on any + provider: none generates a `char` key (PostgreSQL refuses an identity on `character(1)`, SQLite an + `AUTOINCREMENT` on `TEXT`, and SQL Server has no identity for `nvarchar(1)`), and on SQL Server + `Add` gave the value object a character of a GUID string, which was stored. This stops the invented + value; it does not check that the application set a meaningful one. A default key, `'\0'`, is + stored as it is, and a second such row fails on the primary key. `ValueGeneratedOnAdd()` or a value + generator of your own on the property still applies. +- **A default key over `ulong` throws at `Add` where the column is a `decimal`** (SQL Server, + PostgreSQL): EF's temporary value for the column is a negative `decimal`, and converting it back + throws `OverflowException`, where a plain `ulong` key gets a temporary `ulong`. Configure + `ValueGeneratedNever()` on such a key and assign it. + +A key over a `Guid` is the application's to assign, `Id = OrderId.New()` (a version 7 Guid, which sorts by creation time), and EF inserts it as given. For EF to generate it instead, configure `ValueGeneratedOnAdd()` on the property; EF then also takes an -entity whose key is already set, reached through a navigation, for an existing one. An explicit -configuration always wins over these defaults. +entity whose key is already set, reached through a navigation, for an existing one. The root of an +inheritance hierarchy follows the same rule under TPH and TPT. Under TPC, as for an `int` key, the key +is generated only where the provider numbers the whole hierarchy from a sequence (PostgreSQL, SQL +Server), an abstract root's included; SQLite has none, so there the application assigns it. An explicit configuration always wins +over these defaults. **SQLite: configure integer keys with `UseAutoincrement()`.** SQLite fills such a key in, but EF's SQLite provider makes a key `AUTOINCREMENT` only when the property's CLR type is an integer, and a @@ -93,6 +115,12 @@ type, so `configurationBuilder.Properties().HavePrecision(18, 2)` reach properties and not an `Amount` that wraps one. Configure the value object instead: `configurationBuilder.Properties().HavePrecision(18, 2)`. +**Closed sets.** A closed set (`IClosedSet`) is a text column of the member's name, read back +as the member itself, in a table, a primitive collection of names (`text[]` on PostgreSQL, a JSON +array elsewhere) and a JSON column alike, with nothing registered per set. A name the set no longer has throws when it is read, rather +than reading as null, and an instance that is no member throws when it is written. +`ClosedSetConverter` names the converter for an explicit configuration. + **`Option` and `Result` are not columns.** EF cannot map them, and the model says so. An optional value is `T?` on the entity, and `ToOption()` converts it where the domain wants an `Option`. @@ -116,11 +144,54 @@ re-typed, not cast, so an index on it still serves the query. The element of a p converted, which works everywhere and may add a cast. `ValueOrNull()` is for an optional value object, where `.Value` would first unwrap the `Nullable`. +They translate only where the column holds the wrapped value, which is where TypeKit's converter +stores it: the one `UseTypeKit()` applies, or `ValueObjectConverter<,>` named in `HasConversion`. A +property the application converts itself (`HasConversion(c => "C:" + c.Value, …)`, a converter type, +a conversion in `ConfigureConventions`) does not hold `.Value`, so a query that reads it there is +refused with EF's "could not be translated", rather than compared with what the column holds. +Comparing the value object itself (`o.Code == code`) goes through that converter and still works. + Ordering in SQL follows the database's collation for the column, while the generated comparison in memory is ordinal for a string, as it is for any string column. +A value object that declares its own `CompareTo(TSelf)` orders in memory by that method, which the +database cannot run: the column orders by the wrapped value, the opposite of a descending +`CompareTo`. A query that compares it (`<`, `>`, `<=`, `>=`, `CompareTo`), orders by it (`OrderBy`, +`ThenBy`, their descending forms, `Order`) or takes its `Min` or `Max` is therefore refused with an +`InvalidOperationException` naming the value object. Compare or order by `.Value` where the wrapped +value's order is meant (`OrderBy(o => o.Priority.Value)`), or order in memory after the query. +Equality is not ordering, and stays allowed. A query filter (`HasQueryFilter`, named or not) that +does the same is refused when the model is built, naming the entity type, since it applies to every +query of that type. + The mapping is tested on SQLite and PostgreSQL, the SQL of each translation is pinned on PostgreSQL, -and every wrapped-type family makes a round trip through a real PostgreSQL. +every wrapped-type family makes a round trip through a real PostgreSQL, and key generation is also +checked in SQL Server's model. + +## An Option for nothing found + +```csharp +Option order = await db.Orders.FirstOrNoneAsync(o => o.Id == id); // the database runs the condition and returns one row +Option earliest = db.Orders.OrderBy(o => o.PlacedAt).FirstOrNone(); // the same, synchronous +Result found = db.Orders.FirstOrError(o => o.Id == id, OrderFault.NotFound); +``` + +`FirstOrNoneAsync`, and the base package's `FirstOrNone` and `FirstOrError` on a query, ask the +database for one row and let it run the condition, `WHERE … LIMIT @p`, where `FirstOrDefaultAsync` +hands back a `default` value object for nothing found (CMTK0009). `SingleOrNoneAsync` and +`SingleOrNone` ask for two rows and throw for more than one, and `ElementAtOrNone` skips to its row, +`OFFSET @p`. That holds for a condition EF can read, an expression, which a lambda written at the +call is. A `Func` held in a variable, or a method group, binds `FirstOrNone`, +`SingleOrNone` and `FirstOrError` to their overloads on a sequence: every row is read and the +condition runs in memory, without a warning. Hold an `Expression>` instead; +`FirstOrNoneAsync` takes only an expression, so a `Func` there does not compile. On a query, `LastOrNone` and +`LastOrError` are compile errors: a query has no last row until it is ordered, so order it the +other way and take the first, or call `AsEnumerable()` to read every row on purpose. + +Under Native AOT, EF runs only the queries it precompiled, and it precompiles a query where the +application calls one of EF's own operators, which these are not: they throw "Query wasn't +precompiled" there (measured with EF Core 10.0.12). Write `Where(…).Take(1).ToListAsync()` at the +call site instead, and take the first element of the list. ## Reading back skips validation @@ -146,14 +217,14 @@ that experimentally: dotnet ef dbcontext optimize --precompile-queries --nativeaot ``` -`UseTypeKit()` works there: value objects map through the compiled model, `.Value` translates in -precompiled queries, a value-object parameter binds, a stored value reads back without the rules, and -a key over an integer is generated by the database, in the native binary (the consumer smoke test -runs exactly that). A compiled model without Native AOT +`UseTypeKit()` works there: value objects and closed sets map through the compiled model, a JSON +column included, `.Value` translates in precompiled queries, a value-object or closed-set parameter +binds, a stored value reads back without the rules, and a key over an integer is generated by the +database, in the native binary (the consumer smoke test runs exactly that). A compiled model without Native AOT works the same way. This package adds no trim or AOT warning of its own; EF's `DbContext` constructors report theirs, which EF's documentation covers. -Three EF limitations to know: +Five EF limitations to know, measured with EF Core 10.0.12: - **A struct value object that wraps a reference type** (`string`, `Uri`) cannot be a property in a compiled model: EF cannot write its sentinel, since `default(ProductCode)` converts to `null`, and @@ -163,4 +234,14 @@ Three EF limitations to know: interceptors). - **Precompiled queries need the converter `UseTypeKit()` composes.** They cast a property's converter to the type it had at design time, which the compiled model does not recreate for a - `ValueObjectConverter<,>` named in `HasConversion`. + `ValueObjectConverter<,>` or a `ClosedSetConverter<>` named in `HasConversion`. +- **Precompiled queries compile the project they are in without the generators' bodies.** + `--precompile-queries` reads the generated members as declarations only, so a value object or a + closed set declared in that project fails it (CS0534, CS0177 in the generated code). Declare them + in another project, as a domain library usually is. +- **A collection of converted elements cannot be read by a precompiled query**, a `List` or + a `List` as much as a `List` stored as text by EF's own converter: + precompilation fails with "Encountered a constant of unsupported type 'ValueConverter`2'". An + entity with any primitive collection, a `List` too, fails to materialize in a precompiled + query (a `BadImageFormatException` from an accessor EF generates). A query compiled at run time, + without Native AOT, handles both. diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs new file mode 100644 index 0000000..220c82e --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// +/// Adds TypeKit's conventions to every model UseTypeKit() builds: the key generation of +/// and , +/// and the refusal of . +/// +internal sealed class TypeKitConventionSetPlugin : IConventionSetPlugin +{ + public ConventionSet ModifyConventions(ConventionSet conventionSet) + { + var finalizing = conventionSet.ModelFinalizingConventions; + + // After the last of EF's finalizing conventions that change what its value-generation convention + // re-reads, and so before a provider's value-generation strategy (an identity column on PostgreSQL + // and SQL Server), which is chosen for keys already generated on add and comes after them. + finalizing.Insert(IndexAfterTheLastReset(finalizing), new ValueObjectKeyValueGenerationConvention()); + + // Last of all: a TPC key is generated only where the provider gave it a sequence. + finalizing.Add(new ValueObjectTpcKeyValueGenerationConvention()); + + // On the finished model, whose query filters are final. + conventionSet.ModelFinalizedConventions.Add(new HandWrittenOrderingQueryFilterConvention()); + + return conventionSet; + } + + /// + /// EF's value-generation convention decides a key again, from its CLR type, whenever an entity type's + /// mapping strategy or table changes (RelationalValueGenerationConvention.ProcessEntityTypeAnnotationChanged), + /// and two finalizing conventions change them: sets + /// the mapping strategy on every hierarchy's root, TPH where none is configured, and + /// makes table names unique. Decided before them, the key of a TPH + /// root went back to never generated on SQLite, whose provider reads the CLR type, and the second + /// entity added threw for a duplicate key (measured with EF Core 10.0.12). + /// + private static int IndexAfterTheLastReset(List finalizing) + { + var last = finalizing.FindLastIndex(convention => convention is EntityTypeHierarchyMappingConvention or SharedTableConvention); + + return last + 1; + } +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitDbContextOptionsBuilderExtensions.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitDbContextOptionsBuilderExtensions.cs index f421410..a8bf79c 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitDbContextOptionsBuilderExtensions.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitDbContextOptionsBuilderExtensions.cs @@ -14,14 +14,18 @@ public static class TypeKitDbContextOptionsBuilderExtensions /// /// /// - /// Value objects are recognised by wherever EF meets - /// them: properties, keys, foreign keys, elements of primitive collections and query parameters. - /// Nothing is registered per type. Reading a column back does not apply the value object's rules + /// Value objects and closed sets are recognised wherever EF meets them, by the attribute the + /// generators put on each (, + /// whose type arguments are constrained to , + /// and ): properties, + /// keys, foreign keys, elements of primitive collections and query parameters. Never by name, and + /// never by listing a type's interfaces, which trimming can empty. Nothing is registered per type. + /// Reading a column back does not apply the value object's rules /// (). /// /// - /// It only adds plugins, so it replaces nothing of EF's or of another library's, and the order - /// against the database provider does not matter. + /// It only adds plugins, conventions and an interceptor, so it replaces nothing of EF's or of + /// another library's, and the order against the database provider does not matter. /// /// /// The options being configured. diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs new file mode 100644 index 0000000..9636046 --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs @@ -0,0 +1,98 @@ +using System.Linq.Expressions; +using CodoMetis.TypeKit; + +// The namespace of EF Core's own FirstOrDefaultAsync, so the same using reaches both. +namespace Microsoft.EntityFrameworkCore; + +/// +/// FirstOrNoneAsync and SingleOrNoneAsync: an element of a query as an +/// , asked of the database, where FirstOrDefaultAsync and +/// SingleOrDefaultAsync hand back a default for nothing found. +/// +/// +/// The condition and the limit run in the query, WHERE … LIMIT @p, as they do for the base +/// package's FirstOrNone on a query. Under Native AOT, EF runs only the queries it precompiled, +/// and it precompiles a query where the application calls one of its own operators, which these are +/// not: they throw "Query wasn't precompiled" there (measured with EF Core 10.0.12), so write +/// Where(…).Take(1).ToListAsync() at the call site instead. +/// +public static class TypeKitQueryableExtensions +{ + /// The query. + /// The type of the elements. + extension(IQueryable source) where T : notnull + { + /// The first element of the query, or None: the database is asked for one. + /// Cancels the query. + /// The first element, or None if the query has none. + /// The first element is null. + public Task> FirstOrNoneAsync(CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + + return FirstOfAsync(source.Take(1), cancellationToken); + } + + /// The first element that matches, or None: the database runs the condition, and is asked for one. + /// The condition to match, translated to SQL. + /// Cancels the query. + /// The first matching element, or None if nothing matches. + /// The first matching element is null. + public Task> FirstOrNoneAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(predicate); + + return FirstOfAsync(source.Where(predicate).Take(1), cancellationToken); + } + + /// The only element of the query, or None: the database is asked for two, and more than one throws. + /// Cancels the query. + /// The only element, or None if the query has none. + /// The query has more than one element. + /// The only element is null. + public Task> SingleOrNoneAsync(CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + + return SingleOfAsync(source.Take(2), cancellationToken); + } + + /// The only element that matches, or None: the database runs the condition and is asked for two. + /// The condition to match, translated to SQL. + /// Cancels the query. + /// The only matching element, or None if nothing matches. + /// More than one element matches. + /// The only matching element is null. + public Task> SingleOrNoneAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(predicate); + + return SingleOfAsync(source.Where(predicate).Take(2), cancellationToken); + } + } + + private static async Task> FirstOfAsync(IQueryable limited, CancellationToken cancellationToken) where T : notnull + { + await foreach (var element in limited.AsAsyncEnumerable().WithCancellation(cancellationToken).ConfigureAwait(false)) + return Option.Some(element); + + return Option.None(); + } + + private static async Task> SingleOfAsync(IQueryable limited, CancellationToken cancellationToken) where T : notnull + { + var found = Option.None(); + + await foreach (var element in limited.AsAsyncEnumerable().WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (found.IsSome()) + throw new InvalidOperationException("The query holds more than one element, so SingleOrNoneAsync has no single one to answer. Use FirstOrNoneAsync where any one will do."); + + found = Option.Some(element); + } + + return found; + } +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs index aee6895..dd659b6 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodoMetis.TypeKit.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; using Microsoft.EntityFrameworkCore.Query; @@ -21,13 +22,14 @@ public static IServiceCollection AddEntityFrameworkTypeKit(this IServiceCollecti { ArgumentNullException.ThrowIfNull(serviceCollection); - // All four are plugins, which EF collects from every registration: nothing of EF's or of - // another library's is replaced. + // Plugins and an interceptor, which EF collects from every registration: nothing of EF's or of + // another library's is replaced, and an application's own interceptors still run. new EntityFrameworkRelationalServicesBuilder(serviceCollection) .TryAdd() .TryAdd() .TryAdd() - .TryAdd(); + .TryAdd() + .TryAdd(); return serviceCollection; } diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs index baf2447..934f884 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs @@ -6,51 +6,112 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore; -/// Adds to every model UseTypeKit() builds. -internal sealed class ValueObjectKeyConventionSetPlugin : IConventionSetPlugin +/// +/// A single-column primary key that is a value object over an integer is declared generated on add, as +/// a key of that integer type is: EF's own convention looks at the property's CLR type and leaves every +/// value object alone, so OrderNo : IValue<int> lost the identity column int had, +/// and an entity added without a key was stored as 0. +/// +/// +/// +/// The integer types are those EF declares a key of generated (), +/// with one exception, , which is the application's to assign. This declares the key +/// generated; whether a value is then generated is the provider's to decide, as for a plain key (an +/// identity, a sequence), and EF picks the generator that fills a value object's key at Add by +/// the column's type, not by the wrapped type. So the rule is narrower than parity with the wrapped type +/// (decisions 58 and 63): +/// +/// +/// +/// A key over is never generated. No provider generates one (PostgreSQL refuses an +/// identity on character(1), SQLite an AUTOINCREMENT on TEXT, and SQL Server has no +/// identity for nvarchar(1)), and on SQL Server a value object's key declared generated was given +/// a character of a GUID string at Add, which was then stored. A default '\0' is stored as +/// it is: nothing checks that the application set a meaningful character. +/// +/// +/// A key over stays declared generated, as a plain one is, and fails loudly where the +/// column is a decimal (SQL Server, PostgreSQL): a default key throws +/// at Add, from EF's negative temporary decimal. ValueGeneratedNever() and an +/// assigned key are the workaround. +/// +/// +/// +/// A value object over a is left as EF leaves it, never generated: an identifier the +/// application creates itself (OrderId.New()) is then always inserted, whereas a generated key +/// that is set makes EF take a new entity reached through a navigation for an existing one. +/// +/// +/// It sets the convention's value only, so ValueGeneratedNever(), ValueGeneratedOnAdd(), a +/// value generator or any other explicit configuration still wins, and a key that is also a foreign key +/// is left alone. +/// +/// +internal sealed class ValueObjectKeyValueGenerationConvention : IModelFinalizingConvention { - public ConventionSet ModifyConventions(ConventionSet conventionSet) + public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) + { + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + if (GeneratedKey(entityType) is { } property) + property.Builder.ValueGenerated(ValueGenerated.OnAdd); + } + + /// The entity type's key, where it is a value object over an integer EF generates a key of. + internal static IConventionProperty? GeneratedKey(IConventionEntityType entityType) { - // First among the finalizing conventions: a provider's value-generation strategy (an identity - // column on PostgreSQL and SQL Server) is chosen there, for keys already generated on add. - conventionSet.ModelFinalizingConventions.Insert(0, new ValueObjectKeyValueGenerationConvention()); + if (entityType.BaseType is not null || entityType.FindPrimaryKey() is not { Properties: [var property] }) return null; + // As EF's own rule: the foreign key that links a TPT table to its base type's is no reason not to + // generate the key, and EntityTypeHierarchyMappingConvention has added it by now. + if (property.GetContainingForeignKeys().Any(foreignKey => !foreignKey.IsBaseLinking())) return null; - return conventionSet; + return ValueObjectTypes.Describe(Nullable.GetUnderlyingType(property.ClrType) ?? property.ClrType) is { WrappedType: var wrapped } + && IsGeneratedByTheDatabase(wrapped) + ? property + : null; } + + /// + /// EF's own rule for a key of the wrapped type (ValueGenerationConvention.CanBeGenerated in + /// EF Core 10), every integer type but , the unsigned ones and + /// included, without , which EF counts as one and no provider generates. + /// + private static bool IsGeneratedByTheDatabase(Type wrapped) => + wrapped == typeof(int) || wrapped == typeof(long) || wrapped == typeof(short) || wrapped == typeof(sbyte) + || wrapped == typeof(uint) || wrapped == typeof(ulong) || wrapped == typeof(ushort); } /// -/// A single-column primary key that is a value object over an integer is generated on add, as a key -/// of that integer type is: EF's own convention looks at the property's CLR type and leaves every -/// value object alone, so OrderNo : IValue<int> lost the identity column int had, -/// and an entity added without a key was stored as 0. +/// A TPC hierarchy's key is generated only where the provider generates it from a sequence, as EF +/// decides for an integer key: each table of the hierarchy would otherwise number its own rows, and +/// two entities of different types would share a key. /// /// /// -/// Only integers, where only the database can produce the key. A value object over a -/// is left as EF leaves it, never generated: an identifier the application -/// creates itself (OrderId.New()) is then always inserted, whereas a generated key that is set -/// makes EF take a new entity reached through a navigation for an existing one. +/// EF refuses to generate an integer key under TPC (RelationalValueGenerationConvention.MappingStrategyAllowsValueGeneration) +/// unless the provider gives it a sequence, which PostgreSQL's and SQL Server's do, as the column's +/// default (nextval, NEXT VALUE FOR), and SQLite's does not. That default is what this +/// reads, after every provider convention, so no provider is named. /// /// -/// It sets the convention's value only, so ValueGeneratedNever() or any other explicit -/// configuration still wins, and a key that is also a foreign key is left alone. +/// It reads it in the first table the key is mapped to, as the providers' own value-generation +/// conventions do (GetMappedStoreObjects): an abstract root has no table of its own, only its +/// derived types', and reading the root's own table found none and took a sequence's generation away +/// (measured with Npgsql 10.0.3: every entity was stored with 0). /// /// -internal sealed class ValueObjectKeyValueGenerationConvention : IModelFinalizingConvention +internal sealed class ValueObjectTpcKeyValueGenerationConvention : IModelFinalizingConvention { public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) { foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) { - if (entityType.BaseType is not null || entityType.FindPrimaryKey() is not { Properties: [var property] }) continue; - if (property.IsForeignKey()) continue; + if (entityType.GetMappingStrategy() != RelationalAnnotationNames.TpcMappingStrategy) continue; + if (ValueObjectKeyValueGenerationConvention.GeneratedKey(entityType) is not { } property) continue; + if (property.GetValueGeneratedConfigurationSource() != ConfigurationSource.Convention) continue; - if (ValueObjectTypes.Describe(Nullable.GetUnderlyingType(property.ClrType) ?? property.ClrType) is { WrappedType: var wrapped } - && IsGeneratedByTheDatabase(wrapped)) - property.Builder.ValueGenerated(ValueGenerated.OnAdd); + var table = property.GetMappedStoreObjects(StoreObjectType.Table).FirstOrDefault(); + if (table.Name is null || RelationalValueGenerationConvention.GetValueGenerated(property, table) is null) + property.Builder.ValueGenerated(null); } } - - private static bool IsGeneratedByTheDatabase(Type wrapped) => wrapped == typeof(int) || wrapped == typeof(long) || wrapped == typeof(short); } diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectMapping.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectMapping.cs index 5918ce7..6bf1531 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectMapping.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectMapping.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; using CodoMetis.TypeKit.CompilerServices; using CodoMetis.TypeKit.ValueObjects; +using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage.Json; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -43,6 +46,21 @@ public static ValueObjectMapping For(GeneratedValueObjectAttribute valueObject) /// The value object's reader/writer over the wrapped type's, or where the wrapped type has none. protected abstract JsonValueReaderWriter? ReaderWriterOver(JsonValueReaderWriter? wrapped); + /// + /// Whether a column converted by holds the wrapped value, as the + /// wrapped type's own mapping (whose converter is ) stores it: + /// whether the converter is this value object's , + /// alone or composed onto the wrapped type's. + /// + /// + /// Read from the conversion expression, not the converter's type: UseTypeKit() composes a plain + /// , a compiled model creates its converters again from + /// the expressions it wrote out, and an application can name + /// itself. Every one of them calls + /// on the value object and nothing else, before the wrapped type's own conversion, if it has one. + /// + public abstract bool StoresTheWrappedValue(ValueConverter converter, ValueConverter? wrappedConverter); + public RelationalTypeMapping ComposeOnto(RelationalTypeMapping wrapped) => (RelationalTypeMapping)wrapped.WithComposedConverter(Converter, jsonValueReaderWriter: ReaderWriterOver(wrapped.JsonValueReaderWriter)); @@ -53,6 +71,52 @@ private sealed class Of : ValueObjectMapping public override ValueConverter Converter { get; } = new ValueConverter(ValueObjectConverter.ToProvider, ValueObjectConverter.FromProvider); + private static readonly MethodInfo ProviderValue = ((MethodCallExpression)ValueObjectConverter.ToProvider.Body).Method; + + public override bool StoresTheWrappedValue(ValueConverter converter, ValueConverter? wrappedConverter) + { + if (converter.ConvertToProviderExpression is not { Parameters: [var instance] } toProvider || instance.Type != typeof(TValueObject)) return false; + + // The conversion with ProviderValue(instance) taken out is what remains to be applied to the + // wrapped value: nothing, or exactly the wrapped type's own conversion. + var wrapped = Expression.Parameter(typeof(T), "value"); + var unwrapped = new ProviderValueReplacer(instance, wrapped); + var remaining = unwrapped.Visit(toProvider.Body); + + if (unwrapped.Replaced != 1 || unwrapped.InstanceLeft) return false; + + var expected = wrappedConverter?.ConvertToProviderExpression is { Parameters: [var value] } wrappedToProvider + ? ReplacingExpressionVisitor.Replace(value, wrapped, wrappedToProvider.Body) + : wrapped; + + return ExpressionEqualityComparer.Instance.Equals(remaining, expected); + } + + /// Replaces ProviderValue(instance) with the wrapped value, and notes any other use of the instance. + private sealed class ProviderValueReplacer(ParameterExpression instance, ParameterExpression wrapped) : ExpressionVisitor + { + public int Replaced { get; private set; } + + public bool InstanceLeft { get; private set; } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Object is null && node.Method == ProviderValue && node.Arguments is [var argument] && argument == instance) + { + Replaced++; + return wrapped; + } + + return base.VisitMethodCall(node); + } + + protected override Expression VisitParameter(ParameterExpression node) + { + if (node == instance) InstanceLeft = true; + return node; + } + } + // The wrapped mapping's reader/writer handles values of the wrapped type, converted or not, so // it is a JsonValueReaderWriter; anything else is left to EF's own composition. protected override JsonValueReaderWriter? ReaderWriterOver(JsonValueReaderWriter? wrapped) => diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs index 83e54d4..4cbf797 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs @@ -35,9 +35,9 @@ private sealed class Translator(ISqlExpressionFactory sqlExpressionFactory, IRel ) { if (instance is null || member.Name != nameof(IValueObject<,>.Value)) return null; - if (member.DeclaringType is not { } declaringType || !ValueObjectTypes.IsValueObject(declaringType)) return null; + if (member.DeclaringType is not { } declaringType || ValueObjectTypes.Describe(declaringType) is not { } valueObject) return null; - return WrappedValue.Of(instance, returnType, sqlExpressionFactory, typeMappingSource); + return WrappedValue.Of(instance, valueObject, returnType, sqlExpressionFactory, typeMappingSource); } } } @@ -82,7 +82,7 @@ private sealed class Translator(ISqlExpressionFactory sqlExpressionFactory, IRel || valueObject.WrappedType != valueType) return null; - return WrappedValue.Of(arguments[0], valueType, sqlExpressionFactory, typeMappingSource); + return WrappedValue.Of(arguments[0], valueObject, valueType, sqlExpressionFactory, typeMappingSource); } } } @@ -97,11 +97,29 @@ internal static class WrappedValue /// parameter or a function result, is converted, and so is the element column of a primitive /// collection (). /// - public static SqlExpression Of(SqlExpression operand, Type valueType, ISqlExpressionFactory sqlExpressionFactory, IRelationalTypeMappingSource typeMappingSource) + /// + /// , so EF refuses the query, where the operand's converter is not TypeKit's: + /// a column the application converts itself (HasConversion(c => "C:" + c.Value, …), a converter + /// type, a ConfigureConventions conversion) does not hold .Value, and re-typed as if it + /// did, t.Code.Value == "abc" compared the column with what it never holds and answered no row. + /// An operand that is not mapped yet, such as a parameter whose mapping EF infers later, is a value + /// object of the query's own and is converted as before. + /// + public static SqlExpression? Of( + SqlExpression operand, + GeneratedValueObjectAttribute valueObject, + Type valueType, + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource + ) { var mapping = (operand.TypeMapping?.StoreType is { } storeType ? typeMappingSource.FindMapping(valueType, storeType) : null) ?? typeMappingSource.FindMapping(valueType); + if (operand.TypeMapping is { } operandMapping + && (operandMapping.Converter is not { } converter || !ValueObjectMapping.For(valueObject).StoresTheWrappedValue(converter, mapping?.Converter))) + return null; + return operand is ColumnExpression column && !IsCollectionElement(column) ? new ColumnExpression(column.Name, column.TableAlias, valueType, mapping, column.IsNullable) : sqlExpressionFactory.Convert(operand, valueType, mapping); diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypeMappingSourcePlugin.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypeMappingSourcePlugin.cs index 3afdb0b..d04ed86 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypeMappingSourcePlugin.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypeMappingSourcePlugin.cs @@ -34,6 +34,16 @@ internal sealed class ValueObjectTypeMappingSourcePlugin(IServiceProvider servic { if (mappingInfo.ClrType is not { } clrType) return null; + // A closed set is a text column holding the member's name. + if (ClosedSetMapping.Describe(clrType) is { } set) + { + _typeMappingSource ??= services.GetRequiredService(); + var text = _typeMappingSource.FindMapping( + typeof(string), mappingInfo.StoreTypeName, mappingInfo.IsKeyOrIndex, mappingInfo.IsUnicode, mappingInfo.Size, + mappingInfo.IsRowVersion, mappingInfo.IsFixedLength, mappingInfo.Precision, mappingInfo.Scale); + return text is null ? null : ClosedSetMapping.For(set).ComposeOnto(text); + } + if (ValueObjectTypes.Describe(Nullable.GetUnderlyingType(clrType) ?? clrType) is not { } valueObject) return null; _typeMappingSource ??= services.GetRequiredService(); diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypes.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypes.cs index beab43e..d2d90f4 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypes.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypes.cs @@ -16,6 +16,7 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore; internal static class ValueObjectTypes { private static readonly ConcurrentDictionary Descriptions = new(); + private static readonly ConcurrentDictionary HandWrittenOrderings = new(); /// The value object's attribute, or for any other type. public static GeneratedValueObjectAttribute? Describe(Type type) => @@ -25,4 +26,9 @@ internal static class ValueObjectTypes : null); public static bool IsValueObject(Type type) => Describe(type) is not null; + + /// Whether is a value object that declares its own CompareTo(TSelf) (). + public static bool HasHandWrittenOrdering(Type type) => + HandWrittenOrderings.GetOrAdd(type, static candidate => + IsValueObject(candidate) && candidate.GetCustomAttribute(inherit: false) is not null); } diff --git a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs index c62d1ad..b12c288 100644 --- a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs +++ b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs @@ -56,4 +56,41 @@ internal static class AspectDiagnostics public static readonly DiagnosticDefinition<(INamedType Type, string Members)> StateBesideTheWrappedValue = new("CMTK1012", Severity.Error, "'{0}' holds {1} besides its wrapped value, so it is not generated: its JSON, parsing, type converter and EF Core column carry the wrapped value alone, so the rest is lost on every round trip, and the record's equality compares it too. Compute it from Value in a property without a backing field, make it static, or wrap a type of your own that holds all of it." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetNotPartial = + new("CMTK1013", Severity.Error, "'{0}' is a closed set and must be declared partial." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetNotSealed = + new("CMTK1014", Severity.Error, "'{0}' is a closed set and must be declared sealed, so it is not generated: a set with subtypes is not closed." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetNoMembers = + new("CMTK1015", Severity.Error, "'{0}' declares no member, so it is not generated: a member is a public static readonly field of the set's own type." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetPublicConstructor = + new("CMTK1016", Severity.Error, "'{0}' has a constructor that is not private, so it is not generated: an instance beside its members could be made. Make the constructor private." + SeeBuildErrors); + + public static readonly DiagnosticDefinition<(INamedType, string, string)> ClosedSetMemberNotAField = + new("CMTK1017", Severity.Error, "'{0}.{1}' is {2} of the set's own type, so '{0}' is not generated: a member is a public static readonly field, and nothing else of that type is public and static." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetGeneric = + new("CMTK1018", Severity.Error, "'{0}' is generic or nested in a generic type, so it is not generated: a closed set's members are named instances, which a type parameter cannot have." + SeeBuildErrors); + + public static readonly DiagnosticDefinition<(INamedType, string)> ClosedSetMemberNotCreated = + new("CMTK1019", Severity.Error, "'{0}.{1}' is not initialized with new, so '{0}' is not generated: each member is its own instance, created where it is declared. A member initialized with another would give that instance two names, and one left null would have none." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetRecord = + new("CMTK1020", Severity.Error, "'{0}' is a record, so it is not generated: 'with' copies a member into an instance that is none, and a record compares by value where a closed set's members are singletons. Declare it a sealed partial class." + SeeBuildErrors); + + public static readonly DiagnosticDefinition<(INamedType Type, string Member, string Reason)> ClosedSetMemberNameTaken = + new("CMTK1021", Severity.Error, "'{0}.{1}' {2}, so '{0}' is not generated: rename the member." + SeeBuildErrors); + + /// + /// CMTK1021's collision the other way round: the set itself, not one of its members, has the name of + /// a generated member, which C# refuses inside a type of that name (CS0542). + /// + public static readonly DiagnosticDefinition ClosedSetNamedAsGeneratedMember = + new("CMTK1021", Severity.Error, "'{0}' has the name of a member the generators introduce into it, and a member cannot have the name of its type, so '{0}' is not generated: rename the set." + SeeBuildErrors); + + public static readonly DiagnosticDefinition AbstractValueObject = + new("CMTK1022", Severity.Error, "'{0}' is abstract, so it is not generated: an abstract type cannot be a value object. A value held as '{0}' would have no Value, and its JSON would be '{{}}'. Declare the marker on a sealed type." + SeeBuildErrors); } diff --git a/src/CodoMetis.TypeKit.Generators/AspectOrder.cs b/src/CodoMetis.TypeKit.Generators/AspectOrder.cs index 5ee61c3..dc79531 100644 --- a/src/CodoMetis.TypeKit.Generators/AspectOrder.cs +++ b/src/CodoMetis.TypeKit.Generators/AspectOrder.cs @@ -1,10 +1,18 @@ using CodoMetis.TypeKit.Generators; using Metalama.Framework.Aspects; -// ValueObjectAspect runs first and leaves ValueObjectAspectState for the others. An aspect -// missing from this list would run in an unspecified order, find no state, and generate nothing. +// ClosedSetAspect runs first, so the value-object aspects see what it introduces into a closed set of +// the same project: a value object over one gets the set's IParsable, Parse and type converter to build +// on, as over a set from a referenced project. Last, it ran after them, and such a value object was +// generated without parsing and with the base TypeConverter (decision 60). One aspect class never sees +// what its own sibling instances introduce, which is why a value object over a value object is refused +// instead; the closed-set aspect reads nothing the value-object aspects introduce. +// Of the value-object aspects, ValueObjectAspect runs first and leaves ValueObjectAspectState for the +// others. An aspect missing from this list would run in an unspecified order, find no state, and +// generate nothing. [assembly: AspectOrder( AspectOrderDirection.CompileTime, + typeof(ClosedSetAspect), typeof(ValueObjectAspect), typeof(ValueObjectContractAspect), typeof(ValueObjectJsonAspect), diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs new file mode 100644 index 0000000..e1ea079 --- /dev/null +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs @@ -0,0 +1,279 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json; +using Metalama.Framework.Aspects; +using Metalama.Framework.Code.SyntaxBuilders; + +namespace CodoMetis.TypeKit.Generators; + +internal sealed partial class ClosedSetAspect +{ +#pragma warning disable CS0649, CS8618 // template fields: the generated ones are assigned by generated code + [Template] public string _name; + [Template] public static IReadOnlyList _all; +#pragma warning restore CS0649, CS8618 + + [Template] + public void PrivateConstructorTemplate() + { + } + + /// + /// An instance the set's own code made beside its members has no name, and neither has a member + /// read before the names initializer ran: refused, since a null name is written as JSON null and + /// stored as a null column, and read back as nothing. + /// + [Template] + public string NameTemplate + { + get + { + var args = (ClosedSetArguments)meta.Tags.Source!; + return meta.This._name ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NotAMember(args.Set.Name); + } + } + + /// The set's name, with its containing types for a nested set, and the member's: two Status sets stay apart. + [Template] + public string QualifiedNameTemplate + { + get => ClosedSetTypes.NestingChain(meta.Target.Type) + "." + meta.This.Name; + } + + [Template] + public string ToStringTemplate() => meta.This.Name; + + /// + /// After the static field initializers of every part, before any static constructor body: each + /// member learns its name, and All is filled, since a field initializer ran with its own + /// part only and listed a member declared in another file as null. + /// + [Template] + public static void NamesInitializerTemplate() + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var member in args.Members) + { + member.Value!._name = member.Name; + } + + meta.ThisType._all = ExpressionFactory.Parse(ClosedSetTypes.AllExpression(args.Set, args.Members)).Value; + } + + /// + /// Refused while the set's static fields are still being initialized: All is filled after + /// every member exists, so a static field of the set that read it held null for the process's life. + /// + [Template] + public static dynamic? AllTemplate + { + get + { + var args = (ClosedSetArguments)meta.Tags.Source!; + return meta.ThisType._all ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); + } + } + + [Template] + public static bool TryParseTemplate([NotNullWhen(true)] string? name, [NotNullWhen(true)] out dynamic? member) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (name == candidate.Name) + { + member = candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); + return true; + } + } + + member = null; + return false; + } + + /// + /// The member of that name, compared ordinally, or ignoring case when asked: a header or a query + /// string typed by a client. Two members whose names differ only in case are refused where the + /// set is declared (CMTK1021), so there is one member to find. + /// + [Template] + public static bool TryParseIgnoreCaseTemplate([NotNullWhen(true)] string? name, bool ignoreCase, [NotNullWhen(true)] out dynamic? member) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + foreach (var candidate in args.Members) + { + if (string.Equals(name, candidate.Name, comparison)) + { + member = candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); + return true; + } + } + + member = null; + return false; + } + + /// A naming the set, never the input. + [Template] + public static dynamic? ParseTemplate(string? name) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); + } + + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember(args.Set.Name); + } + + /// The member of that name, compared ordinally or ignoring case when asked; a naming the set, never the input. + [Template] + public static dynamic? ParseIgnoreCaseTemplate(string? name, bool ignoreCase) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + foreach (var candidate in args.Members) + { + if (string.Equals(name, candidate.Name, comparison)) return candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); + } + + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember(args.Set.Name); + } + + /// IParsable<TSelf>.Parse: the set's own Parse; the provider means nothing to a name. + [Template] + public static dynamic? ParsableParseTemplate(string s, IFormatProvider? provider) + { + ArgumentNullException.ThrowIfNull(s); + return meta.ThisType.Parse(s); + } + + /// IParsable<TSelf>.TryParse: the member of that name, compared ordinally, as the set's own TryParse. + [Template] + public static bool ParsableTryParseTemplate([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out dynamic result) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (s == candidate.Name) + { + result = candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); + return true; + } + } + + result = null!; + return false; + } + + [Template] + public bool CanConvertFromTemplate(ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || meta.Base.CanConvertFrom(context, sourceType); + + [Template] + public bool CanConvertToTemplate(ITypeDescriptorContext? context, Type? destinationType) => + destinationType == typeof(string) || meta.Base.CanConvertTo(context, destinationType); + + /// A name is read with the set's own Parse: a naming the set, never the input. + [Template] + public object? ConvertFromTemplate(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + if (value is string s) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + return ExpressionFactory.Parse($"{ValueObjectTypes.SourceName(args.Set)}.Parse(s)").Value; + } + + return meta.Base.ConvertFrom(context, culture, value); + } + + /// A member is written as its name, which throws for an instance that is no member. + [Template] + public object? ConvertToTemplate(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) + { + if (destinationType == typeof(string) && value is not null) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + return ExpressionFactory.Parse($"(({ValueObjectTypes.SourceName(args.Set)})value).Name").Value; + } + + return meta.Base.ConvertTo(context, culture, value, destinationType); + } + + /// + /// Every delegate is checked before a branch is picked, as Option and Result check + /// theirs: a null for a member not matched passed until that member first arrived. + /// + [Template] + public TResult MatchTemplate(Func first) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var parameter in meta.Target.Parameters) + { + ArgumentNullException.ThrowIfNull(parameter.Value, parameter.Name); + } + + var index = meta.CompileTime(0); + foreach (var member in args.Members) + { + if (ReferenceEquals(meta.This, member.Value)) return meta.Target.Parameters[index].Value!.Invoke(); + index++; + } + + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NotAMember(args.Set.Name); + } + + /// + /// A naming the set, never the input. A JSON null never reaches this: a + /// set is a class, so the serializer reads null as null, and its RespectNullableAnnotations + /// refuses it for a property that is not nullable. Handling null here would refuse it for an + /// OrderStatus? property too, which the converter cannot tell apart. + /// + [Template] + public dynamic? JsonReadTemplate(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var name = reader.TokenType == JsonTokenType.String ? reader.GetString() : null; + if (name is not null) + { + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); + } + } + + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMemberInJson(args.Set.Name); + } + + [Template] + public void JsonWriteTemplate(Utf8JsonWriter writer, dynamic? value, JsonSerializerOptions options) + { + writer.WriteStringValue((string)value!.Name); + } + + /// + /// A dictionary key is the member's name too, which the serializer refused for a converter without + /// these two (NotSupportedException, both ways). Written as it is, as the value is: a + /// DictionaryKeyPolicy renames only on writing, so a renamed key would not read back. + /// + [Template] + public dynamic? JsonReadAsPropertyNameTemplate(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var name = reader.GetString(); + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); + } + + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMemberInJson(args.Set.Name); + } + + [Template] + public void JsonWriteAsPropertyNameTemplate(Utf8JsonWriter writer, dynamic? value, JsonSerializerOptions options) + { + writer.WritePropertyName((string)value!.Name); + } +} diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs new file mode 100644 index 0000000..bd675fb --- /dev/null +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -0,0 +1,236 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit.CompilerServices; +using Metalama.Framework.Advising; +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; +using Metalama.Framework.Code.DeclarationBuilders; +using Metalama.Framework.Code.SyntaxBuilders; +using Metalama.Framework.Diagnostics; +using static CodoMetis.TypeKit.Generators.AspectDiagnostics; + +namespace CodoMetis.TypeKit.Generators; + +/// What the templates read: the set and its members. +[CompileTime] +internal sealed class ClosedSetArguments +{ + public required INamedType Set { get; init; } + public required IReadOnlyList Members { get; init; } +} + +/// +/// Generates a closed set: Name, QualifiedName, All, Match, TryParse +/// and Parse, each ordinal and each with an ignoreCase overload, IParsable<TSelf> explicitly, ToString() where none is written by +/// hand, the JSON converter, the type converter, the discovery attribute, and a private constructor +/// where the type declares none. Applied by . +/// +/// +/// +/// A member's name is assigned by an initializer that runs after the static field initializers and +/// before any static constructor body, so the declaration needs no nameof. Each member is its +/// own instance (CMTK1019), so no name overwrites another; an instance without one is refused where its +/// name is read. Match's +/// parameters are added per member; the template declares one Func<TResult> parameter, +/// whose bound type the others copy, since Metalama 2026.1 cannot build a generic instance over the +/// method builder's own type parameter (spikes/ClosedSets). +/// +/// +internal sealed partial class ClosedSetAspect : TypeAspect +{ + public override void BuildAspect(IAspectBuilder builder) + { + var target = builder.Target; + var members = ClosedSetTypes.Members(target); + var problems = new List(); + + if (!target.IsPartial) problems.Add(ClosedSetNotPartial.WithArguments(target)); + if (!target.IsSealed) problems.Add(ClosedSetNotSealed.WithArguments(target)); + if (target.IsRecord) problems.Add(ClosedSetRecord.WithArguments(target)); + if (target.IsGeneric || target.DeclaringType is { IsGeneric: true }) problems.Add(ClosedSetGeneric.WithArguments(target)); + if (target.Constructors.Any(c => !c.IsImplicitlyDeclared && c.Accessibility != Accessibility.Private)) problems.Add(ClosedSetPublicConstructor.WithArguments(target)); + if (members.Count == 0) problems.Add(ClosedSetNoMembers.WithArguments(target)); + + foreach (var member in members.Where(member => !ClosedSetTypes.IsCreatedWhereDeclared(member, target))) + problems.Add(ClosedSetMemberNotCreated.WithArguments((target, member.Name))); + + // Any static field or property of the set's own type that is not a public readonly field: a + // writable one may be replaced, and a non-public one is an instance that is no member, whose + // name throws and which TryParse never finds, while the declaration reads as a member. + foreach (var field in target.Fields.Where(f => f.IsStatic && f.Type.Equals(target) && (f.Accessibility != Accessibility.Public || f.Writeability != Writeability.ConstructorOnly))) + problems.Add(ClosedSetMemberNotAField.WithArguments((target, field.Name, field.Accessibility != Accessibility.Public ? "a static field that is not public" : "a static field that is not readonly"))); + foreach (var property in target.Properties.Where(p => p.IsStatic && p.Type.Equals(target))) + problems.Add(ClosedSetMemberNotAField.WithArguments((target, property.Name, "a static property"))); + + // A member named as a generated member failed the aspect (LAMA0506 beside Match, Parse and + // TryParse, LAMA0611 beside ToString). Two members whose names differ only in case are one + // name to TryParse(name, ignoreCase: true), and two whose Match parameters are one name gave + // Match one parameter twice (LAMA0611, CS0100). Each is found by the transform itself, not by + // one standing in for both: ignoring case ordinally left 'Kelvin' and a Kelvin sign's + // 'Kelvin' (U+212A) apart, while the parameters' lower-casing made both 'kelvin'. + var generatedNames = ClosedSetTypes.GeneratedNames(target); + + // A set named as a generated member failed inside the generated code (LAMA0611, CS0542: a + // member cannot have the name of its type), naming nothing the user wrote. + if (generatedNames.Contains(target.Name)) problems.Add(ClosedSetNamedAsGeneratedMember.WithArguments(target)); + + foreach (var member in members.Where(member => generatedNames.Contains(member.Name))) + problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, "has the name of a member the generators introduce"))); + foreach (var (member, earlier, reason) in ClosedSetTypes.CaseCollisions(members)) + problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, reason.Replace("{0}", $"'{target.Name}.{earlier.Name}'")))); + + // A hand-written member the generators introduce failed the aspect with a Metalama error that + // names no fix; a hand-written ToString() is the seam and is kept instead. + var declaredByHand = ClosedSetTypes.GeneratedMembersDeclaredByHand(target, members); + if (declaredByHand.Count > 0) + problems.Add(HandWrittenGeneratedMember.WithArguments((target, string.Join(", ", declaredByHand), + (declaredByHand.Count == 1 ? "the generators introduce it themselves; remove it" : "the generators introduce these themselves; remove them") + + ". The generated member a closed set may declare instead is ToString()"))); + + if (problems.Count > 0) + { + foreach (var problem in problems) builder.Diagnostics.Report(problem); + builder.SkipAspect(); + return; + } + + builder.Tags = new ClosedSetArguments { Set = target, Members = members }; + + if (!target.Constructors.Any(c => !c.IsImplicitlyDeclared)) + { + builder.IntroduceConstructor(nameof(PrivateConstructorTemplate), buildConstructor: c => + { + c.Accessibility = Accessibility.Private; + c.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + } + + // Assigned by the initializer below, after every member exists; null! keeps the consumer's + // nullable analysis quiet about the constructor. + builder.IntroduceField(nameof(_name), IntroductionScope.Instance, OverrideStrategy.Fail, f => + { + f.Accessibility = Accessibility.Private; + f.InitializerExpression = ExpressionFactory.Parse("null!"); + f.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + builder.IntroduceProperty(nameof(NameTemplate), buildProperty: p => { p.Name = "Name"; p.Accessibility = Accessibility.Public; p.AddAttribute(CodeAnnotations.CompilerGenerated); }); + builder.IntroduceProperty(nameof(QualifiedNameTemplate), buildProperty: p => { p.Name = "QualifiedName"; p.Accessibility = Accessibility.Public; p.AddAttribute(CodeAnnotations.CompilerGenerated); }); + if (!ClosedSetTypes.DeclaresToString(target)) + builder.IntroduceMethod(nameof(ToStringTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "ToString"; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + + builder.AddInitializer(nameof(NamesInitializerTemplate), InitializerKind.BeforeTypeConstructor); + + // Assigned by the initializer below, with the names: as a field initializer it ran with the + // part it was introduced into, before the fields of a part in another file, which were null in + // it. The array wrapped, never the array itself: cast to IList or T[], it was writable, and + // a member replaced there was listed while Parse, which reads the fields, answered the original. + var listType = TypeFactory.GetNamedType(typeof(IReadOnlyList<>)).WithTypeArguments(target); + builder.IntroduceField(nameof(_all), IntroductionScope.Static, OverrideStrategy.Fail, f => + { + f.Accessibility = Accessibility.Private; + f.Type = listType; + f.Writeability = Writeability.ConstructorOnly; + f.InitializerExpression = ExpressionFactory.Parse("null!"); + f.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + builder.IntroduceProperty(nameof(AllTemplate), buildProperty: p => + { + p.Name = "All"; + p.Type = listType; + p.Accessibility = Accessibility.Public; + p.IsStatic = true; + p.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + + builder.IntroduceMethod(nameof(TryParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "TryParse"; + m.Accessibility = Accessibility.Public; + m.Parameters[1].Type = target.ToNullable(); + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + builder.IntroduceMethod(nameof(ParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "Parse"; + m.Accessibility = Accessibility.Public; + m.ReturnType = target; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + + // The same two with ignoreCase, opted into at the call: JSON, the column, binding and + // IParsable stay ordinal, since what is stored or sent is the name as it is. + builder.IntroduceMethod(nameof(TryParseIgnoreCaseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "TryParse"; + m.Accessibility = Accessibility.Public; + m.Parameters[2].Type = target.ToNullable(); + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + builder.IntroduceMethod(nameof(ParseIgnoreCaseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "Parse"; + m.Accessibility = Accessibility.Public; + m.ReturnType = target; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + + // IParsable, explicitly: Parse(string) and TryParse(string, out) are the set's own + // surface, and the provider means nothing to a name. Generic code calling T.Parse reaches it. + var parsable = builder.ImplementInterface(TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(target)); + parsable.ExplicitMembers.IntroduceMethod(nameof(ParsableParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "Parse"; + m.ReturnType = target; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + parsable.ExplicitMembers.IntroduceMethod(nameof(ParsableTryParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "TryParse"; + m.Parameters[2].Type = target; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + + builder.IntroduceMethod(nameof(MatchTemplate), IntroductionScope.Instance, OverrideStrategy.Fail, m => + { + m.Name = "Match"; + m.Accessibility = Accessibility.Public; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + var func = m.Parameters[0].Type; + m.Parameters[0].Name = ClosedSetTypes.ParameterName(members[0]); + foreach (var member in members.Skip(1)) m.AddParameter(ClosedSetTypes.ParameterName(member), func); + }); + + var converter = builder.IntroduceClass($"{target.Name}JsonConverter", buildType: t => + { + t.Accessibility = Accessibility.Public; + t.BaseType = TypeFactory.GetNamedType(typeof(JsonConverter<>)).WithTypeArguments(target); + t.IsStatic = false; + t.IsSealed = true; + t.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + converter.IntroduceMethod(nameof(JsonReadTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Read"; m.ReturnType = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + converter.IntroduceMethod(nameof(JsonWriteTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Write"; m.Parameters[1].Type = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + converter.IntroduceMethod(nameof(JsonReadAsPropertyNameTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "ReadAsPropertyName"; m.ReturnType = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + converter.IntroduceMethod(nameof(JsonWriteAsPropertyNameTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "WriteAsPropertyName"; m.Parameters[1].Type = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + + // The reflection-based configuration binder, MVC and anything else that reads a string through + // TypeDescriptor find a set by its type converter alone; without one a set bound to null. + var typeConverter = builder.IntroduceClass($"{target.Name}TypeConverter", buildType: t => + { + t.Accessibility = Accessibility.Public; + t.BaseType = TypeFactory.GetNamedType(typeof(TypeConverter)); + t.IsStatic = false; + t.IsSealed = true; + t.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + typeConverter.IntroduceMethod(nameof(CanConvertFromTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = nameof(TypeConverter.CanConvertFrom); m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + typeConverter.IntroduceMethod(nameof(CanConvertToTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = nameof(TypeConverter.CanConvertTo); m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + typeConverter.IntroduceMethod(nameof(ConvertFromTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = nameof(TypeConverter.ConvertFrom); m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + typeConverter.IntroduceMethod(nameof(ConvertToTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = nameof(TypeConverter.ConvertTo); m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + + builder.IntroduceAttribute(AttributeConstruction.Create(typeof(JsonConverterAttribute), constructorArguments: [converter.Target])); + builder.IntroduceAttribute(AttributeConstruction.Create(typeof(TypeConverterAttribute), constructorArguments: [typeConverter.Target])); + builder.IntroduceAttribute(AttributeConstruction.Create(TypeFactory.GetNamedType(typeof(GeneratedClosedSetAttribute<>)).WithTypeArguments(target))); + builder.IntroduceAttribute(CodeAnnotations.CompilerGenerated); + } +} diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs new file mode 100644 index 0000000..3b2ac82 --- /dev/null +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -0,0 +1,235 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.CompilerServices; +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; + +namespace CodoMetis.TypeKit.Generators; + +/// What the fabric and the aspect know about closed sets: a class implementing IClosedSet<TSelf>, directly or through another interface. +[CompileTime] +internal static class ClosedSetTypes +{ + public static bool IsClosedSet(INamedType type) => + type.TypeKind == TypeKind.Class && Marker(type) is not null; + + /// The IClosedSet<TSelf> the type implements, or null. + public static INamedType? Marker(INamedType type) => + type.AllImplementedInterfaces.FirstOrDefault(i => i.IsGeneric && i.Definition.Equals(TypeFactory.GetNamedType(typeof(IClosedSet<>)))); + + /// The members: every public static readonly field of the set's own type, in declaration order. + public static IReadOnlyList Members(INamedType type) => + [.. type.Fields.Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(type) && f.Writeability == Writeability.ConstructorOnly)]; + + /// The set's name with its containing types, dotted: Order.Status for a set nested in Order. + public static string NestingChain(INamedType set) => + set.DeclaringType is { } declaring ? NestingChain(declaring) + "." + set.Name : set.Name; + + /// The expression that makes All: the members in declaration order, wrapped read-only. + public static string AllExpression(INamedType set, IReadOnlyList members) => + $"global::System.Array.AsReadOnly(new {ValueObjectTypes.SourceName(set)}[] {{ {string.Join(", ", members.Select(member => $"{ValueObjectTypes.SourceName(set)}.{member.Name}"))} }})"; + + /// The names of what the generators introduce into the set itself; nothing else in it may have one (CMTK1011, CMTK1021). + public static IReadOnlyList GeneratedNames(INamedType set) => + ["Name", "QualifiedName", "All", "Match", "Parse", "TryParse", "ToString", "_name", "_all", $"{set.Name}JsonConverter", $"{set.Name}TypeConverter"]; + + /// + /// The name of 's parameter of Match: its own, with the first letter + /// lower-cased. A keyword is escaped where the code is written, so New is @new: at a + /// call. The analyzer's CMTK0012 names the arguments by the same rule. + /// + public static string ParameterName(IField member) => char.ToLowerInvariant(member.Name[0]) + member.Name[1..]; + + /// + /// Each member that collides with one declared before it, with that one and why: by + /// TryParse(name, ignoreCase: true)'s comparison (), + /// by , the transform that names Match's parameters, or by both. + /// The reason holds {0} where the earlier member is named. The two transforms differ: the + /// Kelvin sign (U+212A) lower-cases to k, so it names the same parameter as K, while + /// ignoring case ordinally keeps the two apart (it upper-cases, and the sign has no upper case of + /// its own). + /// + public static IEnumerable<(IField Member, IField Earlier, string Reason)> CaseCollisions(IReadOnlyList members) + { + for (var index = 1; index < members.Count; index++) + { + var member = members[index]; + + foreach (var earlier in members.Take(index)) + { + var parse = string.Equals(member.Name, earlier.Name, StringComparison.OrdinalIgnoreCase); + var match = string.Equals(ParameterName(member), ParameterName(earlier), StringComparison.Ordinal); + + if (!parse && !match) continue; + + yield return (member, earlier, + parse && match ? "differs from {0} only in case, which Match's parameters and TryParse(name, ignoreCase: true) cannot tell apart" + : parse ? "differs from {0} only in case, which TryParse(name, ignoreCase: true) cannot tell apart" + : $"gives Match the parameter name '{ParameterName(member)}' that {{0}} gives it too"); + break; + } + } + } + + /// + /// Whether is initialized with new of the set where it is declared, + /// and with nothing else: new(), new(1, null), new OrderStatus(), each with an + /// optional object initializer. Not = Open, which makes one instance two members whose names + /// overwrite each other, nor new OrderStatus().Canonical() or any other expression that only + /// starts with a creation, nor a call, nor no initializer at all, which leaves the member null until + /// a static constructor runs. + /// + /// + /// Read from the initializer's source text, since the code model exposes no syntax kind. The type + /// after new is compared by its last name; a using alias of the set is refused, which + /// is loud and names the member. + /// + public static bool IsCreatedWhereDeclared(IField member, INamedType set) + { + if (member.InitializerExpression is not ISourceExpression source) return false; + + var text = WithoutComments(source.AsString).Trim(); + if (text.Length <= 3 || !text.StartsWith("new", StringComparison.Ordinal) || IsIdentifierPart(text[3])) return false; + + var at = Skip(text, 3); + if (at < text.Length && text[at] != '(' && text[at] != '{') + { + var nameStart = at; + while (at < text.Length && (IsIdentifierPart(text[at]) || text[at] is '.' or ':' or '@')) at++; + + var typeName = text.Substring(nameStart, at - nameStart); + if (typeName.Substring(typeName.LastIndexOfAny(['.', ':']) + 1).TrimStart('@') != set.Name) return false; + + at = Skip(text, at); + } + + // An argument list, an object initializer, or both in that order, and then the end of the text. + var groups = 0; + if (at < text.Length && text[at] == '(') { at = Skip(text, AfterGroup(text, at)); groups++; } + if (at < text.Length && text[at] == '{') { at = Skip(text, AfterGroup(text, at)); groups++; } + + return groups > 0 && at == text.Length; + } + + private static bool IsIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_'; + + private static int Skip(string text, int at) + { + while (at < text.Length && char.IsWhiteSpace(text[at])) at++; + return at; + } + + /// The index after the bracket that closes the one at , past nested brackets and literals; the length when it never closes. + private static int AfterGroup(string text, int open) + { + var depth = 0; + for (var at = open; at < text.Length; at++) + { + switch (text[at]) + { + case '(' or '{' or '[': + depth++; + break; + case ')' or '}' or ']': + if (--depth == 0) return at + 1; + break; + case '"' or '\'': + at = AfterLiteral(text, at); + break; + } + } + + return text.Length; + } + + /// The index of the quote that ends the string or character literal starting at ; a verbatim string doubles its quotes. + private static int AfterLiteral(string text, int quote) + { + var verbatim = text[quote] == '"' && quote > 0 && (text[quote - 1] == '@' || (quote > 1 && text[quote - 2] == '@')); + for (var at = quote + 1; at < text.Length; at++) + { + if (!verbatim && text[at] == '\\') { at++; continue; } + if (text[at] != text[quote]) continue; + if (verbatim && at + 1 < text.Length && text[at + 1] == '"') { at++; continue; } + return at; + } + + return text.Length; + } + + /// The text with its // and /* */ comments blanked, literals left as they are. + private static string WithoutComments(string text) + { + var chars = text.ToCharArray(); + for (var at = 0; at < chars.Length; at++) + { + if (chars[at] is '"' or '\'') + { + at = AfterLiteral(text, at); + } + else if (chars[at] == '/' && at + 1 < chars.Length && chars[at + 1] is '/' or '*') + { + var block = chars[at + 1] == '*'; + var end = block ? text.IndexOf("*/", at + 2, StringComparison.Ordinal) : text.IndexOf('\n', at); + end = end < 0 ? chars.Length : block ? end + 2 : end; + for (var blank = at; blank < end; blank++) chars[blank] = ' '; + at = end - 1; + } + } + + return new string(chars); + } + + /// + /// The members the generators introduce, written by hand: a property, field, event or nested type + /// of a generated name, a method with a generated signature, an explicit implementation of + /// IClosedSet<TSelf>, IParsable<TSelf> declared, or an attribute the + /// generators put on the set. Each failed the aspect with a Metalama error naming no fix. An + /// overload of another signature is the set's own and is kept, as is ToString(), the seam. A + /// member of the set with a generated name is CMTK1021's, which says to rename it. + /// + public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList members) + { + var text = TypeFactory.GetType(SpecialType.String); + var boolean = TypeFactory.GetType(SpecialType.Boolean); + var memberNames = new HashSet(members.Select(member => member.ToDisplayString())); + + return + [ + .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. GeneratedNames(set)]).Where(declared => !memberNames.Contains(declared)), + .. set.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean))).Select(ValueObjectDeclaration.Describe), + .. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, set) || SameParameters(method, text, boolean, set))).Select(ValueObjectDeclaration.Describe), + .. set.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count)).Select(ValueObjectDeclaration.Describe), + .. set.Methods.Where(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && method.ExplicitInterfaceImplementations.Any(OfClosedSet)).Select(ValueObjectDeclaration.Describe), + .. set.Properties.Where(property => property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && property.ExplicitInterfaceImplementations.Any(OfClosedSet)).Select(ValueObjectDeclaration.Describe), + .. set.ImplementedInterfaces.Where(implemented => implemented.Equals(TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(set))).Select(implemented => $"the interface {implemented.ToDisplayString()}"), + .. set.Attributes.Where(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(JsonConverterAttribute))) + || attribute.Type.Equals(TypeFactory.GetNamedType(typeof(TypeConverterAttribute))) + || attribute.Type.Definition.Equals(TypeFactory.GetNamedType(typeof(GeneratedClosedSetAttribute<>)))) + .Select(attribute => $"the attribute {attribute.Type.Name.Substring(0, attribute.Type.Name.Length - nameof(Attribute).Length)}"), + ]; + } + + private static bool IsOwn(IMethod method) => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false }; + + /// Parameter types equal, nullability aside: Metalama refuses to introduce a method beside one of the same signature. + private static bool SameParameters(IMethod method, params IType[] types) => + method.Parameters.Count == types.Length + && method.Parameters.Select(parameter => parameter.Type.ToNonNullable()).Zip(types, (declared, expected) => declared.Equals(expected.ToNonNullable())).All(same => same); + + /// The generated Match<TResult>: one Func<TResult> per member. + private static bool IsGeneratedMatch(IMethod method, int memberCount) => + method is { Name: "Match", TypeParameters.Count: 1 } + && method.Parameters.Count == memberCount + && method.Parameters.All(parameter => parameter.Type is INamedType { IsGeneric: true } func + && func.Definition.Equals(TypeFactory.GetNamedType(typeof(Func<>))) + && func.TypeArguments[0].Equals(method.TypeParameters[0])); + + /// Whether a ToString() is written by hand: the seam, kept in place of the generated one. + public static bool DeclaresToString(INamedType set) => + set.Methods.Any(method => method is { Name: "ToString", IsStatic: false, IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, Parameters.Count: 0 }); + + private static bool OfClosedSet(IMember implemented) => + implemented.DeclaringType.Definition.Equals(TypeFactory.GetNamedType(typeof(IClosedSet<>))); +} diff --git a/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj b/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj index 8b091cc..7ea4d01 100644 --- a/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj +++ b/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj @@ -6,7 +6,7 @@ Metalama.Framework reference flows on purpose (spikes/FabricSpike). --> - Compile-time generation of strongly-typed value objects for CodoMetis.TypeKit, built on Metalama. + Compile-time generation of value objects and closed sets (smart enums) for CodoMetis.TypeKit, built on Metalama: one declared Create is what JSON, parsing, route binding and TryFrom apply. value-object;strongly-typed-id;primitive-obsession;code-generation;metalama;ddd;domain-driven-design;system-text-json;iparsable;validation diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 0cdc708..4921952 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -1,9 +1,10 @@ # CodoMetis.TypeKit.Generators -Compile-time generation of strongly-typed value objects for +Compile-time generation of value objects and closed sets (smart enums) for [CodoMetis.TypeKit](https://www.nuget.org/packages/CodoMetis.TypeKit), built on [Metalama](https://www.postsharp.net/metalama). One line becomes a complete value object, with -its JSON converter, parsing, formatting, comparison, type converter and query companions. +its JSON converter, parsing, formatting, comparison, type converter and query companions, each +applying the one `Create` you wrote. ```bash dotnet add package CodoMetis.TypeKit.Generators @@ -16,7 +17,7 @@ those projects. No Metalama license key is needed, there or here: the generators Open Source edition. How this compares with Vogen, Thinktecture.Runtime.Extensions and StronglyTypedId, and when one of -them is the better choice: [How it compares](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit#how-it-compares). +them is the better choice: [How it compares](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/comparison.md). ## Built with Metalama @@ -88,8 +89,8 @@ Any non-generic class, struct or enum, with the exceptions CMTK1005 lists. The g follow what the wrapped type offers: parsing where it can be parsed, formatting where it formats, and comparison where it is comparable. Numbers, `bool`, `char`, `string`, `Guid`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly` and enums have all of it; a `Uri` has no ordering, and neither -do NodaTime's `OffsetDateTime`, `ZonedDateTime`, `Period` and `Interval`, whose `Interval` has no type -converter to parse through either. An enum parses as `Enum.TryParse` does, case-sensitively: a name, a +do NodaTime's `OffsetDateTime`, `ZonedDateTime`, `Period` and `Interval`, and `Interval` also has no +type converter to parse through. An enum parses as `Enum.TryParse` does, case-sensitively: a name, a comma-separated list of names, or a number, which need not name a member. A generic wrapped type, a tuple included, is refused (CMTK1005): a `List` or an `ImmutableArray` @@ -115,10 +116,12 @@ does. | `{TSelf}Extensions.GetValue()` and `ValueOrNull()` | every value object an extension class can reach (not a `private` or `protected` nested one) | For EF Core queries, where they translate to the column. | | `IValueObjectMaterializer` | every value object | Explicit, invisible on the type. Rebuilds an instance without validation, for the EF Core satellite. | -Every generated way into a validated value object goes through `Create`. The only exception is the -explicit materializer, for values the application wrote itself. Inside the type, your own members can -reach the private constructor, which applies no rules, because `Create` needs it: call it from -`Create` only. Another way in is a static method that calls `From` or `Create`. +Every generated way into a validated value object goes through `Create`. Two paths skip it, both for +values the application wrote itself: the explicit materializer, which the EF Core satellite reads a +column through, and `StoredJsonConverterFactory`, registered only on the options that read the +application's own JSON. Inside the type, your own members can reach the private constructor, which +applies no rules, because `Create` needs it: call it from `Create` only. For another way in, write a +static method that calls `From` or `Create`. `default(Email)` is not one of those ways, and the analyzer refuses it (CMTK0001), but an array slot or a JSON document without the property can still produce it (CMTK0005, and "A missing property" below). @@ -135,9 +138,9 @@ interfaces the generators implement, and `IConvertible` where they generate it. implementation of a member of an interface they implement, such as `IParsable.Parse` or `IFormattable.ToString`: every caller through the interface would reach it rather than the generated member, and a generic `T.Parse` would skip `Create`. `Create` is declared once, `public static`, and -never as an explicit interface implementation: alone, the generated code cannot call it, and beside a -public one, generic code calling `T.Create` would reach its rules instead of the ones every generated -way in applies. +never as an explicit interface implementation. The generated code cannot call an explicit one; and +beside a public one, generic code calling `T.Create` would reach the explicit one's rules, not those +every generated way in applies. To change what a value object prints, declare `ToString()`: a record's `PrintMembers(StringBuilder)` is CMTK1011 without it, since the generated `ToString()` prints the wrapped value and never calls it. @@ -149,11 +152,14 @@ since C# keeps it in every derived record; one that is not sealed is CMTK1011, b `IConvertible` on it are refused as they are on the value object. Equality is the wrapped value's, as the ordering, the JSON and the column are, so a hand-written -`Equals(TSelf)`, `GetHashCode()` or `IEquatable.Equals` is CMTK1011: it would make values equal -that sort apart. To make values that differ only in form equal, such as "abc" and "ABC", normalise them +`Equals(TSelf)`, `GetHashCode()`, `IEquatable.Equals`, or `operator ==` or `!=` between two of +the type, is CMTK1011: it would make values equal that sort apart. To make values that differ only in form equal, such as "abc" and "ABC", normalise them in `Create` (a plain value object becomes an `IValidatedValue` for that), so that equal values hold the same wrapped value. +An `[Obsolete]` on a value object, or on a type it is nested in, is copied to its `{TSelf}Extensions` +class, which names the value object outside it: with `error: true` that use failed the build. + A value object holds its wrapped value and nothing else. Its JSON, parsing, type converter and column carry that value alone, while a record's equality compares every field, so any other instance state would be lost on a round trip or make equal values unequal. An instance field, an auto-property, a @@ -173,8 +179,8 @@ public sealed partial record ApiKey : IValue } ``` -- **What still carries the value is what must**: its JSON, the type converter and the database column, - which are conversions, not display; and the debugger, which shows `ToString()` but expands `Value`. +- **Still carrying the value**, since they are conversions and not display: its JSON, the type + converter and the database column; and the debugger, which shows `ToString()` but expands `Value`. - **Serilog** reads the public `Value` where it destructures (`{@Key}`, or a record holding one), past `ToString()`. [CodoMetis.TypeKit.Serilog](https://www.nuget.org/packages/CodoMetis.TypeKit.Serilog) logs every value object as what it prints instead. @@ -182,6 +188,78 @@ public sealed partial record ApiKey : IValue `DataClassificationAttribute` on the value object's type makes the logging generator report LOGGEN035 and generate nothing for the method. +## Closed sets + +An `enum` names its members and nothing else: a `switch` over one is never proven complete (C# +only warns about a missing discard), a member carries no data, and the names are integers on the +wire unless a converter says otherwise. A closed set is a sealed class whose every instance is a +named member, declared as a `public static readonly` field, with constant data through a private +constructor where the members carry some: + +```csharp +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} + +public sealed partial class Tier : IClosedSet +{ + public static readonly Tier Basic = new(1, null); + public static readonly Tier Premium = new(2, "Priority support."); + + private Tier(int rank, string? perk) { Rank = rank; Perk = perk; } + + public int Rank { get; } + public string? Perk { get; } +} +``` + +The generators add `Name` from the field's name, `QualifiedName` (`OrderStatus.Open`), `All` in +declaration order and read-only, `Match` with one required delegate per member named after it, `TryParse` and +`Parse` by name, each ordinal or ignoring case when asked, `ToString()`, a JSON converter that +writes the name and reads the member itself back, and a private parameterless constructor where the +type declares none: + +```csharp +var label = status.Match(open: () => "Open", shipped: () => "On its way", cancelled: () => "Cancelled"); // every member, or it does not compile +var names = OrderStatus.All.Select(s => s.Name); // Open Shipped Cancelled +var same = OrderStatus.Parse("Shipped") == OrderStatus.Shipped; // True, members are singletons +var json = JsonSerializer.Serialize(Tier.Premium); // "Premium" +``` + +Members are singletons, so equality is reference equality, and a member added later fails every +`Match` until it is handled. Every parameter of `Match` has the same type, so an argument by +position would follow the declaration order and swap branches when two members are reordered: the +analyzer requires every argument named (CMTK0012), and its code fix names them. `Match` checks every +delegate for null before it picks one. `Parse` and the JSON converter refuse an unknown name with an +exception naming the set, never the input, and a set is a dictionary key in JSON by its name too. +`Parse(name, ignoreCase: true)` and `TryParse(name, ignoreCase: true, out member)` accept any casing. +JSON, the type converter, `IParsable` and a bound route or query parameter stay ordinal, since what +is stored or sent is the name as it is: a header or a query string a client typed in any casing is +bound as a `string` and parsed with `ignoreCase: true`. +`IParsable` is implemented explicitly, for generic code that parses through `T.Parse`, and a +type converter reads and writes the name, so the configuration binder, MVC and anything else that +reads a string through `TypeDescriptor` bind a set. The binder's source generator reads no type +converter ([dotnet/runtime#83599](https://github.com/dotnet/runtime/issues/83599)): under it, as under +Native AOT, bind the name as a `string` and `Parse` it. +The EF Core satellite maps a closed set to a text column of the member's name, and the ASP.NET Core +satellite describes it as a string enum of the names, with nothing registered per set. A set whose +members are faults declares `IFaultSet`, a `FaultKind` and an optional description per member, +and the ASP.NET Core satellite answers it by kind. + +Each member is initialized with `new` where it is declared (CMTK1019), and never written again +(CMTK0013, the analyzer's: a member assigned in the static constructor would hold an instance named +and listed as another), and the constructors are private (CMTK1016); a record is refused (CMTK1020). A member may not take the name of a generated +member, nor differ from another's only in case (`Open` and `open`), since +`TryParse(name, ignoreCase: true)` could not tell them apart (CMTK1021). A `ToString()` written by +hand is kept, and the name is still what JSON, parsing and the column use. An overload of a generated +method with another signature, such as `Parse(int rank)`, is kept too. Any other generated member +written by hand is CMTK1011. An instance the set's own code creates beside its members has no name, +so reading its name throws rather than writing a null. A JSON `null` reads as `null`, as for any class, unless the serializer's +`RespectNullableAnnotations` refuses it. + ## JSON The wire format is the wrapped value's, so an `OrderId` is a string and a `Quantity` a number: @@ -201,6 +279,15 @@ property by an `OrderId` therefore changes no JSON, and a test compares the two family under several sets of options. One addition: a `DateTime` is written as UTC, a `Local` one converted and an `Unspecified` one taken as UTC with its digits kept. +One exception: number handling given by an attribute rather than by the options. A +`[JsonNumberHandling]` on the containing type reaches the number properties the serializer writes +itself, and a value object's converter never sees it: under +`[JsonNumberHandling(WriteAsString | AllowReadingFromString)]`, `record Dto(int Count)` writes +`{"Count":"5"}` and reads `"5"`, while `record Dto(Quantity Count)` writes `{"Count":5}` and refuses +`"5"` with a `JsonException`. On the property itself the serializer refuses the attribute for any type +but a number (`InvalidOperationException`). Set the number handling on the options, or on a +source-generated context's `[JsonSourceGenerationOptions]`, which do reach the value object. + Numbers and dates are read as strictly as the serializer reads the wrapped type. What it cannot read is a `JsonException` naming the value object and the wrapped type ("Quantity could not read the JSON value as Int32."), with no inner exception that could carry the text; ASP.NET Core answers it with 400. @@ -240,13 +327,29 @@ var shown = $"{Amount.From(1.5m)}"; // "1.5" ``` A null `IFormatProvider` means the invariant culture in every generated `Parse`, `TryParse`, -`ToString` and `TryFormat`, so `Parse(x.ToString(), null)` round-trips wherever the process runs. A -provider that is given is used as given where the wrapped type's parsing and formatting take one; a -`Uri`, a NodaTime type and a type parsed through a constructor or a provider-less `Parse` ignore it. +`ToString` and `TryFormat`, so the text does not depend on the culture the process runs in: +`ToString()` is the wrapped type's own default text under the invariant culture, and the type +converter and `$"{x}"` write the same. It reads back as the same value wherever the wrapped type's +own does: numbers (`float`, `double` and `Half` included), `BigInteger`, `Guid`, `DateOnly`, +`TimeSpan`, `Version`, `Uri`, enums and strings. It does not for `TimeOnly`, `DateTime` and +`DateTimeOffset`, whose default text drops what the invariant culture's short patterns drop: +`TimeOnly(3, 4, 5, 123)` prints `03:04`, a `DateTime` loses its fraction of a second and its `Kind`, +a `DateTimeOffset` its fraction. Format those with `"O"` (`x.ToString("O", null)`), which a `TimeOnly` +and a `DateTimeOffset` read back exactly, or keep them in their JSON form, which a `DateTime` reads +back as the UTC instant it wrote. A provider that is given is used as given where the wrapped type's +parsing and formatting take one; a `Uri`, a NodaTime type and a type parsed through a constructor or a +provider-less `Parse` ignore it. Text the wrapped type cannot read is a `FormatException` naming the value object and the wrapped type ("Quantity could not read the input as Int32."), an overflow included, with no inner exception: -the wrapped type's own message quotes the input. A refusal by `Create` names the fault instead. +the wrapped type's own message quotes the input. A refusal by `Create` names the fault instead. The +one place the input still appears is System.Text.Json's own: a refused dictionary key ends the +`JsonException`'s `Path`. +`TryParse` answers `false` for both, and lets anything else through as `Parse` does: an exception +`Create` throws is a bug to see, not text to refuse. + +A default instance, which CMTK0001 keeps out of your own code but EF Core, MVC and serializers can +make, formats as `""` over a reference type, as it always did over a string. ## Comparison @@ -254,7 +357,13 @@ the wrapped type's own message quotes the input. A refusal by `Create` names the differently, declare `CompareTo(TSelf)` yourself: it is kept, and the object overload, the operators and the interfaces are derived from it. Any other hand-written comparison member, an explicit implementation of `IComparable`, `IComparable` or `IComparisonOperators` included, is an error -(CMTK1008), so the ordering can never disagree with itself. +(CMTK1008), so in memory every comparison agrees with your `CompareTo`. + +A database cannot run it: it orders the column by the wrapped value. The type then carries +`[HandWrittenOrdering]`, and an EF Core query that compares, orders, or takes the `Min` or `Max` of +it is refused, naming the value object, rather than answered in the column's order +([CodoMetis.TypeKit.EntityFrameworkCore](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +Compare or order by `.Value` where the wrapped value's order is meant. ## Build errors @@ -267,16 +376,26 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1002 | A struct not declared `readonly`. | | CMTK1003 | More than one marker, such as `IValue, IValue`. | | CMTK1004 | An `IValidatedValue` whose `TSelf` is another type. | -| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value`, derived from another value object, or wrapping an array, a pointer, a nullable type, a generic type (a tuple included) or a value object (itself included). Wrap what the other value object wraps instead. | +| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value` or as another member the generators introduce (`From`, `TryFrom`, `FromKnownGood`, `Revalidate`, `Parse`, `TryParse`, `TryFormat`, `MinValue`, `MaxValue`, `CompareTo`), derived from another value object, or wrapping `object`, `System.Enum`, an array, a pointer, a nullable type, a generic type (a tuple included) or a value object (itself included). Wrap what the other value object wraps instead. | | CMTK1006 | A record class not declared `sealed`. | | CMTK1007 | The `{TSelf}Extensions` companion's name is taken by a declared type or by another value object's companion. | | CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones. | | CMTK1009 | A hand-written instance constructor, including a positional record's parameter list such as `OrderId(Guid Value)`. The constructor and `Value` are generated; a static constructor is fine. | | CMTK1010 | Declared in more than one `partial` part: Metalama 2026.1 writes the generated `ToString()` into every part, which does not compile. A part a source generator adds, such as a `[GeneratedRegex]` method's, does not count. | -| CMTK1011 | A hand-written member or attribute the generators introduce (see "Your own members"), an explicit implementation of an interface they implement, a hand-written equality, a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | +| CMTK1011 | A hand-written member or attribute the generators introduce (see "Your own members", and for a closed set every generated member but `ToString()`, by signature, `IParsable`, `[JsonConverter]` and `[TypeConverter]`), an explicit implementation of an interface they implement, a hand-written equality (an `operator ==` or `!=` between two of the type included, which C# also reports as CS0111), a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | | CMTK1012 | Instance state besides the wrapped value: a field, an auto-property, a `required` member or a field-like event, declared or inherited. Compute it from `Value`, make it static, or wrap a type that holds all of it. | - -The analyzers that come with the base package add CMTK0001 to CMTK0009; see +| CMTK1013 | A closed set not declared `partial`. | +| CMTK1014 | A closed set not declared `sealed`: a set with subtypes is not closed. | +| CMTK1015 | A closed set with no member: a member is a `public static readonly` field of the set's own type. | +| CMTK1016 | A closed set with a constructor that is not private, so an instance beside its members could be made. | +| CMTK1017 | A closed set with a static member of its own type that is not a public readonly field: a property, a field that is not `readonly`, or a field that is not public, which is an instance that is no member. | +| CMTK1018 | A closed set that is generic or nested in a generic type. | +| CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | +| CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | +| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), a set itself named as one of them (a member cannot have the name of its type), or a member whose name differs from another member's only in case: they collide in `TryParse(name, ignoreCase: true)`, and in `Match`'s parameters where the first letter is the difference; or two members whose `Match` parameters would have one name, which lower-casing the first letter gives a member starting with the Kelvin sign (U+212A) and one starting with `K`. | +| CMTK1022 | An abstract type with a value-object marker: it would get no member, and a value held as it, of a type derived from it, would have no `Value` and be written as `{}`. Declare the marker on the sealed type. An abstract closed set is CMTK1014. | + +The analyzers that come with the base package add CMTK0001 to CMTK0013; see [CodoMetis.TypeKit.Analyzers](https://www.nuget.org/packages/CodoMetis.TypeKit.Analyzers). ## Reading the generated code diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index bedfe52..2107ba9 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs @@ -138,14 +138,20 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp return false; } - // Its generated property is named Value, and a member cannot have its type's name (CS0542, - // inside the generated code). + // A member cannot have its type's name (CS0542, inside the generated code, as LAMA0611): Value, + // and every other member the generators introduce into a value object of some wrapped type. if (target.Name == nameof(IValueObject<,>.Value)) { builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, $"a value object cannot be named '{nameof(IValueObject<,>.Value)}', the name of its generated property; rename it"))); return false; } + if (ValueObjectDeclaration.GeneratedMemberNames.Contains(target.Name)) + { + builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, $"a value object cannot be named '{target.Name}', the name of a member the generators introduce, since a member cannot have the name of its type; rename it"))); + return false; + } + // The companion class and the attribute's type arguments sit outside the file, where a file-local // type, or one nested in it, cannot be named, and Metalama crashed on it (LAMA0001, naming nothing). if (ValueObjectDeclaration.IsFileLocal(target)) @@ -214,6 +220,28 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp return false; } + // object holds a value of any type, and the serializer writes an object by the type of what it + // holds, which a converter called directly does not: OverObject.From(5) was written as {} where an + // object property holding 5 is written as 5 (measured 2026-10-13). An interface or an abstract + // class is written and read as the serializer writes it (its declared members, or polymorphic + // where the type says so), so it stays allowed. + if (namedValueType.SpecialType == SpecialType.Object) + { + builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, + "the wrapped type 'object' holds a value of any type, which the serializer writes by its run-time type and a value object's converter cannot (5 was written as {}); wrap the type the value has"))); + return false; + } + + // System.Enum is the base of every enum and no enum itself: its generic Enum.Parse(string) + // was taken for a Parse of its own, and the generated parsing failed inside the generated code + // (LAMA0611, CS0411), naming nothing the user wrote. + if (namedValueType.Equals(TypeFactory.GetNamedType(typeof(Enum)))) + { + builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, + "the wrapped type 'Enum' is the base of every enum and no enum itself; wrap the enum type"))); + return false; + } + // A generic wrapped type, tuples included, failed inside the generated code (LAMA0611): its // name lost its type arguments there, and a named tuple broke the attribute. Refused rather than // made to work, because a value object's equality is its wrapped value's, and a generic type's diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectCompanionAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectCompanionAspect.cs index 85f9ba2..f5efadf 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectCompanionAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectCompanionAspect.cs @@ -59,6 +59,13 @@ public override void BuildAspect(IAspectBuilder builder) type.IsStatic = true; type.IsPartial = true; type.AddAttribute(CodeAnnotations.CompilerGenerated); + + // The class names the value object outside it, where an + // [Obsolete(error: true)] on it or on a type it is nested in + // failed inside the generated code (LAMA0611, CS0619). Inside + // an obsolete declaration C# reports no use of one. + if (ObsoleteAttribute(builder.Target) is { } obsolete) + type.AddAttribute(obsolete.ToAttributeConstruction()); }); companionClass.IntroduceMethod( @@ -93,4 +100,15 @@ public override void BuildAspect(IAspectBuilder builder) ); } + /// The [Obsolete] of , or of the nearest type it is nested in that has one. + private static IAttribute? ObsoleteAttribute(INamedType type) + { + for (INamedType? current = type; current is not null; current = current.DeclaringType) + { + if (current.Attributes.FirstOrDefault(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(ObsoleteAttribute)))) is { } obsolete) + return obsolete; + } + + return null; + } } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index a65873f..6bcef68 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs @@ -1,6 +1,8 @@ using System.Numerics; +using CodoMetis.TypeKit.CompilerServices; using Metalama.Framework.Aspects; using Metalama.Framework.Code; +using Metalama.Framework.Code.DeclarationBuilders; namespace CodoMetis.TypeKit.Generators; @@ -51,6 +53,12 @@ public override void BuildAspect(IAspectBuilder builder) return; } + // The seam taken: run-time code that orders values where CompareTo cannot run, a database + // ordering a column by the wrapped value, reads this and refuses rather than answer the + // opposite order (the EF satellite). + if (DeclaresCompareTo(builder.Target)) + builder.IntroduceAttribute(AttributeConstruction.Create(typeof(HandWrittenOrderingAttribute))); + builder.Tags = new ComparableImplementationArguments { ValueType = valueType, @@ -149,6 +157,12 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe yield return member; } + /// The seam: a CompareTo(TSelf) the type declares itself, which the generated one then leaves in place. + private static bool DeclaresCompareTo(INamedType target) => + target.Methods.Any(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, Name: nameof(IComparable.CompareTo) } + && method.Parameters is [var other] + && other.Type.Equals(target)); + /// /// The operand type: nullable for a record class, as IComparable<T>.CompareTo(T?) and /// the record's own equality operators declare it, so a caller passing null gets flow analysis diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.Templates.cs index 78c7d08..073a294 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.Templates.cs @@ -6,6 +6,12 @@ namespace CodoMetis.TypeKit.Generators; // A null provider means the invariant culture, as in the generated formatting and parsing: // Convert.ToString(amount) passes null. +// +// Over a reference type (a string) every member goes through System.Convert with the wrapped value as +// an object, which for a value is the value's own IConvertible and for the null a default instance holds +// is Convert's answer for null: "" for text, the target type's default for a number. Cast to +// IConvertible, that null threw NullReferenceException, where ToString() answered "" (decided +// 2026-10-13). A struct is cast, as before: it is never null, and Convert would box it the same way. internal sealed partial class ValueObjectConvertibleAspect { // C# text: in a template, CultureInfo.InvariantCulture beside a run-time operand of ?? counts as @@ -13,8 +19,13 @@ internal sealed partial class ValueObjectConvertibleAspect private const string Provider = "(provider ?? global::System.Globalization.CultureInfo.InvariantCulture)"; [Template] - public TypeCode GetTypeCodeTemplate() => - ((IConvertible)meta.This.Value).GetTypeCode(); + public TypeCode GetTypeCodeTemplate([CompileTime] bool referenceType) + { + if (referenceType) + return (TypeCode)ExpressionFactory.Parse("global::System.Convert.GetTypeCode((object?)this.Value)", TypeFactory.GetType(typeof(TypeCode)), false).Value!; + + return ((IConvertible)meta.This.Value).GetTypeCode(); + } /// /// The wrapped value's conversion, except the identity: Convert.ChangeType(q, typeof(Quantity)) @@ -23,7 +34,7 @@ public TypeCode GetTypeCodeTemplate() => /// 's, so it honours a hand-written ToString(). /// [Template] - public object ToTypeTemplate(Type conversionType, IFormatProvider? provider, [CompileTime] INamedType valueObjectType, [CompileTime] bool declaresToString) + public object ToTypeTemplate(Type conversionType, IFormatProvider? provider, [CompileTime] INamedType valueObjectType, [CompileTime] bool declaresToString, [CompileTime] bool referenceType) { string valueObject = meta.CompileTime(ValueObjectTypes.SourceName(valueObjectType)); @@ -35,16 +46,29 @@ public object ToTypeTemplate(Type conversionType, IFormatProvider? provider, [Co if (conversionType == typeof(string)) return meta.This.ToString(); } + else if (referenceType) + { + // Text is the same as IConvertible.ToString(provider)'s, "" for a default instance, where + // Convert.ChangeType answers null for it. + if (conversionType == typeof(string)) + return ExpressionFactory.Parse($"global::System.Convert.ToString((object?)this.Value, {Provider})", TypeFactory.GetType(SpecialType.Object), false).Value!; + } + + if (referenceType) + return ExpressionFactory.Parse($"global::System.Convert.ChangeType((object?)this.Value, conversionType, {Provider})!", TypeFactory.GetType(SpecialType.Object), false).Value!; return ExpressionFactory.Parse($"((global::System.IConvertible)this.Value).ToType(conversionType, {Provider})", TypeFactory.GetType(SpecialType.Object), false).Value!; } [Template] - public string ToStringTemplate(IFormatProvider? provider, [CompileTime] bool declaresToString) + public string ToStringTemplate(IFormatProvider? provider, [CompileTime] bool declaresToString, [CompileTime] bool referenceType) { if (declaresToString) return meta.This.ToString(); + if (referenceType) + return (string)ExpressionFactory.Parse($"global::System.Convert.ToString((object?)this.Value, {Provider})", TypeFactory.GetType(SpecialType.String), false).Value!; + return (string)ExpressionFactory.Parse($"((global::System.IConvertible)this.Value).ToString({Provider})", TypeFactory.GetType(SpecialType.String), false).Value!; } @@ -52,11 +76,14 @@ public string ToStringTemplate(IFormatProvider? provider, [CompileTime] bool dec public dynamic TypedConversionTemplate( IFormatProvider? provider, [CompileTime] string methodName, - [CompileTime] IType returnType + [CompileTime] IType returnType, + [CompileTime] bool referenceType ) { return ExpressionFactory.Parse( - $"((global::System.IConvertible)this.Value).{methodName}({Provider})", + referenceType + ? $"global::System.Convert.{methodName}((object?)this.Value, {Provider})" + : $"((global::System.IConvertible)this.Value).{methodName}({Provider})", returnType, false ).Value!; diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.cs index 9f68e81..7874edd 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.cs @@ -42,6 +42,9 @@ public override void BuildAspect(IAspectBuilder builder) var interfaceType = builder.ImplementInterface(typeof(IConvertible), OverrideStrategy.Ignore); + // A default instance of a struct value object over a reference type holds null. + var referenceType = valueType.IsReferenceType == true; + // GetTypeCode — unique shape, dedicated template. interfaceType.ExplicitMembers.IntroduceMethod( nameof(GetTypeCodeTemplate), @@ -51,7 +54,8 @@ public override void BuildAspect(IAspectBuilder builder) { m.Name = nameof(IConvertible.GetTypeCode); m.AddAttribute(CodeAnnotations.CompilerGenerated); - } + }, + args: new { referenceType } ); // ToType — universal escape hatch, dedicated template. @@ -64,7 +68,7 @@ public override void BuildAspect(IAspectBuilder builder) m.Name = nameof(IConvertible.ToType); m.AddAttribute(CodeAnnotations.CompilerGenerated); }, - args: new { valueObjectType = builder.Target, declaresToString = state.DeclaresToString } + args: new { valueObjectType = builder.Target, declaresToString = state.DeclaresToString, referenceType } ); // ToString(IFormatProvider): the wrapped value's text, or the value object's own ToString() @@ -79,7 +83,7 @@ public override void BuildAspect(IAspectBuilder builder) m.Name = nameof(IConvertible.ToString); m.AddAttribute(CodeAnnotations.CompilerGenerated); }, - args: new { declaresToString = state.DeclaresToString } + args: new { declaresToString = state.DeclaresToString, referenceType } ); // One template, introduced once per typed conversion with its name and return type. @@ -101,7 +105,8 @@ public override void BuildAspect(IAspectBuilder builder) args: new { methodName = capturedMethodName, - returnType = capturedReturnType.ToNamedType() + returnType = capturedReturnType.ToNamedType(), + referenceType } ); } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 9dd772c..aa613cb 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -14,7 +14,7 @@ namespace CodoMetis.TypeKit.Generators; /// introduced: the seams it keeps, and what it declares that would collide with a generated member. /// /// -/// Every lookup enumerates a collection and compares names, never OfName (plan §5). +/// Every lookup enumerates a collection and compares names, never OfName (design/aspects.md). /// [CompileTime] internal static class ValueObjectDeclaration @@ -168,6 +168,19 @@ private static int SkipAttributeList(string text, int start) return text.Length; } + /// + /// The names of the members the generators introduce into a value object, besides Value, for + /// some marker or wrapped type: a value object of one of these names failed inside the generated + /// code (LAMA0611, CS0542: a member cannot have the name of its type), naming nothing the user wrote + /// (measured 2026-10-13 for From, TryFrom, Parse, TryParse, + /// TryFormat, MinValue and CompareTo). Refused whatever it wraps, so the rule is + /// one list rather than one per wrapped type. What a record synthesizes (ToString, + /// Equals) is C#'s own error, and an explicit implementation (IConvertible's) does not + /// collide. + /// + public static IReadOnlyList GeneratedMemberNames { get; } = + ["From", "TryFrom", "FromKnownGood", "Revalidate", "Parse", "TryParse", "TryFormat", "MinValue", "MaxValue", "CompareTo", "_value", "__FromJson", "__FromText", "__TryFromText"]; + /// /// What the declaration writes by hand that a generator introduces itself, which would fail inside /// the generated code (LAMA0500, LAMA0503, LAMA0512, LAMA0521, LAMA0531, LAMA0611) naming nothing @@ -288,6 +301,7 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta if (type.Equals(TypeFactory.GetNamedType(typeof(JsonConverterAttribute))) || type.Equals(TypeFactory.GetNamedType(typeof(CompilerGeneratedAttribute))) || type.Definition.Equals(TypeFactory.GetNamedType(typeof(GeneratedValueObjectAttribute<,>))) + || type.Equals(TypeFactory.GetNamedType(typeof(HandWrittenOrderingAttribute))) || (parse != ValueParseStrategy.Unsupported && type.Equals(TypeFactory.GetNamedType(typeof(TypeConverterAttribute))))) found.Add($"the attribute {(type.Name.EndsWith(nameof(Attribute), StringComparison.Ordinal) ? type.Name.Substring(0, type.Name.Length - nameof(Attribute).Length) : type.Name)}"); } @@ -382,7 +396,8 @@ .. SelfAndBases(target).SelectMany(type => type.Methods /// IEquatable<TSelf>.Equals. The record kept it, while the generated ordering, the JSON /// dictionary keys and the EF Core column went on comparing the wrapped value: a case-insensitive /// Equals made "abc" and "ABC" equal with CompareTo 32, so a HashSet held one and - /// a SortedSet two. + /// a SortedSet two. An operator == or != between two of the type collides with + /// the record's own, which C# reports as CS0111 naming only op_Equality; the error says why. /// public static IReadOnlyList EqualityDeclaredByHand(INamedType target) { @@ -396,6 +411,10 @@ .. target.Methods ? method.ExplicitInterfaceImplementations.Any(implemented => implemented.DeclaringType.Definition.Equals(equatable)) : (method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.Equals(target)) || method is { Name: nameof(GetHashCode), Parameters.Count: 0 })) + .Select(Describe), + .. target.Methods + .Where(method => method is { IsStatic: true, IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, OperatorKind: OperatorKind.Equality or OperatorKind.Inequality, Parameters: [{ } left, { } right] } + && left.Type.ToNonNullable().Equals(target) && right.Type.ToNonNullable().Equals(target)) .Select(Describe) ]; } @@ -475,7 +494,7 @@ private static bool IsHandWritten(IMember member) => /// Non-implicit members of with one of , of any kind. private static IEnumerable MembersNamed(INamedType target, params string[] names) => MembersNamed(target, methods: true, names); - private static IEnumerable MembersNamed(INamedType target, bool methods, params string[] names) + internal static IEnumerable MembersNamed(INamedType target, bool methods, params string[] names) { foreach (var field in target.Fields) if (!field.IsImplicitlyDeclared && Enumerable.Contains(names, field.Name)) yield return field.ToDisplayString(); diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs index 504d55e..6ceba7f 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs @@ -1,5 +1,6 @@ using Metalama.Framework.Aspects; using Metalama.Framework.Code; +using Metalama.Framework.Diagnostics; using Metalama.Framework.Fabrics; namespace CodoMetis.TypeKit.Generators; @@ -31,10 +32,21 @@ namespace CodoMetis.TypeKit.Generators; /// internal sealed class ValueObjectFabric : TransitiveProjectFabric { - public override void AmendProject(IProjectAmender amender) => + public override void AmendProject(IProjectAmender amender) + { amender.SelectTypes(includeNestedTypes: true) .Where(ValueObjectTypes.IsValueObject) .AddAspect(type => new ValueObjectAspect( CompanionClass.NameOwner(type), ValueObjectTypes.WrappedValueObjectRefusal(type))); + + // An abstract type gets no aspect, so it is refused here, where it is selected. + amender.SelectTypes(includeNestedTypes: true) + .Where(ValueObjectTypes.IsAbstractValueObject) + .ReportDiagnostic(type => AspectDiagnostics.AbstractValueObject.WithArguments(type)); + + amender.SelectTypes(includeNestedTypes: true) + .Where(ClosedSetTypes.IsClosedSet) + .AddAspect(_ => new ClosedSetAspect()); + } } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs index a87bde6..339c2b4 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs @@ -40,8 +40,10 @@ public string FormattableToStringTemplate(string? format, IFormatProvider? forma // Overrides the record-generated ToString() with the invariant culture. The BCL's own ToString() // uses the current culture; this one does not, because a value object's text is an identifier // or a wire value more often than a display string, and the generated Parse treats a null - // provider as invariant too, so Parse(x.ToString(), null) round-trips. Callers who need a - // culture pass it: x.ToString(format, culture), or string.Create(culture, $"{x}"). + // provider as invariant too, so Parse(x.ToString(), null) reads it back as far as the wrapped + // type's default text is lossless (TimeOnly, DateTime and DateTimeOffset drop seconds or a + // fraction in theirs). Callers who need a culture pass it: x.ToString(format, culture), or + // string.Create(culture, $"{x}"). [Template] public string ToStringOverrideTemplate() => meta.This.ToString(null, CultureInfo.InvariantCulture); @@ -54,9 +56,12 @@ public bool TryFormatTemplate( IFormatProvider? provider ) { + var tag = (FormattableImplementationArguments)meta.Tags.Source!; + charsWritten = 0; - return (bool)Formatted($"{GeneratedFormatting}.TryFormat(this.Value, destination, out charsWritten, format, provider {InvariantIfNull})", SpecialType.Boolean).Value!; + // A default instance over a reference type holds null, whose text is "" (WrappedText). + return (bool)Formatted((tag.ValueType.IsReferenceType == true ? "this.Value is null || " : "") + $"{GeneratedFormatting}.TryFormat(this.Value, destination, out charsWritten, format, provider {InvariantIfNull})", SpecialType.Boolean).Value!; } // Satisfies IUtf8SpanFormattable.TryFormat — only emitted when T : IUtf8SpanFormattable. @@ -68,8 +73,10 @@ public bool TryFormatUtf8Template( IFormatProvider? provider ) { + var tag = (FormattableImplementationArguments)meta.Tags.Source!; + bytesWritten = 0; - return (bool)Formatted($"{GeneratedFormatting}.TryFormatUtf8(this.Value, utf8Destination, out bytesWritten, format, provider {InvariantIfNull})", SpecialType.Boolean).Value!; + return (bool)Formatted((tag.ValueType.IsReferenceType == true ? "this.Value is null || " : "") + $"{GeneratedFormatting}.TryFormatUtf8(this.Value, utf8Destination, out bytesWritten, format, provider {InvariantIfNull})", SpecialType.Boolean).Value!; } } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.cs index 8593c0a..eccc0c1 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.cs @@ -122,12 +122,21 @@ public override void BuildAspect(IAspectBuilder builder) /// converter writes the wrapped value through it too, so it keeps doing so for a value object that /// declares its own ToString() and gets no formatting interfaces. /// + /// + /// A default instance of a struct value object over a reference type holds null, and its text is + /// "", as interpolating that null gives and as a string's always was: formatting it through + /// the wrapped type threw NullReferenceException over a Uri, a Version or an + /// IPAddress (decided 2026-10-13). The generated span formats write nothing for it. + /// internal static string WrappedText(INamedType valueType, string value, string format, string provider) => ResolveStrategy(valueType) switch { ValueFormatStrategy.String => $"({value} ?? string.Empty)", + ValueFormatStrategy.Formattable or ValueFormatStrategy.SpanFormattable when valueType.IsReferenceType == true => + $"({value} is null ? string.Empty : global::CodoMetis.TypeKit.CompilerServices.GeneratedFormatting.ToString({value}, {format}, {provider}))", ValueFormatStrategy.Formattable or ValueFormatStrategy.SpanFormattable => $"global::CodoMetis.TypeKit.CompilerServices.GeneratedFormatting.ToString({value}, {format}, {provider})", // No formatting knowledge: the format and the provider have nothing to apply to. + _ when valueType.IsReferenceType == true => $"({value}?.ToString() ?? string.Empty)", _ => $"({value}.ToString() ?? string.Empty)" }; diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.Templates.cs index a48d84f..84acc05 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.Templates.cs @@ -42,18 +42,17 @@ public static bool TryParseTemplate( return (bool)ExpressionFactory.Parse($"(s is not null && {tag.TryFromText}(s, out result))", TypeFactory.GetType(SpecialType.Boolean), false).Value!; } - // The remaining strategies have no TryParse of their own: their inner parse throws. + // The remaining strategies have no TryParse of their own: their inner parse throws, and what it + // throws for text the wrapped type cannot read is GeneratedParsing.Unreadable, a FormatException. + // Only that is caught, around the inner parse alone: a bare catch around the wrapping as well + // made an exception from Create a false, where Parse, and TryParse over a type with a TryParse + // of its own, let it through. if (s is null) return false; - try - { - return (bool)ExpressionFactory.Parse($"{tag.TryFromText}({tag.InnerParse()}, out result)", TypeFactory.GetType(SpecialType.Boolean), false).Value!; - } - catch - { - result = meta.Default(tag.ValueObjectType); - return false; - } + meta.InsertStatement($"{ValueObjectTypes.SourceName(tag.ValueType)} __inner;"); + meta.InsertStatement($"try {{ __inner = {tag.InnerParse()}; }} catch (global::System.FormatException) {{ return false; }}"); + + return (bool)ExpressionFactory.Parse($"{tag.TryFromText}(__inner, out result)", TypeFactory.GetType(SpecialType.Boolean), false).Value!; } [Template] diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs index 838122b..f150e3c 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs @@ -35,8 +35,10 @@ internal sealed class ParsableImplementationArguments /// /// The provider the generated parsing hands to the wrapped type. A null provider means the /// invariant culture, not the current one as in the BCL: the generated ToString() is - /// invariant, so Parse(x.ToString(), null) has to round-trip, and with the current - /// culture it parsed "1.5" as 15 in de-DE. A provider that is given is used as given. + /// invariant, so what it writes has to read back the same wherever the process runs, and with the + /// current culture "1.5" was parsed as 15 in de-DE. It is as lossless as the wrapped type's own + /// default text: TimeOnly, DateTime and DateTimeOffset lose seconds or a + /// fraction in it. A provider that is given is used as given. /// public const string Provider = "(provider ?? global::System.Globalization.CultureInfo.InvariantCulture)"; @@ -214,7 +216,9 @@ internal static ValueParseStrategy ResolveStrategy(INamedType valueType) if (valueType.Constructors.Any(constructor => constructor is { Accessibility: Accessibility.Public, Parameters: [{ Type.SpecialType: SpecialType.String }] })) return ValueParseStrategy.StringConstructor; - if (valueType.Methods.Any(method => method is { Name: "Parse", IsStatic: true, Accessibility: Accessibility.Public, Parameters: [{ Type.SpecialType: SpecialType.String }] })) + // Not a generic one, such as Enum.Parse(string), whose type argument the generated call + // cannot infer (LAMA0611, CS0411). + if (valueType.Methods.Any(method => method is { Name: "Parse", IsStatic: true, Accessibility: Accessibility.Public, TypeParameters.Count: 0, Parameters: [{ Type.SpecialType: SpecialType.String }] })) return ValueParseStrategy.StaticParse; return ValueParseStrategy.Unsupported; diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs index 59ec21b..bbe5364 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs @@ -38,6 +38,17 @@ type.TypeKind is TypeKind.Struct or TypeKind.Class && !type.IsAbstract && Markers(type).Count > 0; + /// + /// An abstract class with a marker, which the fabric refuses (CMTK1022) rather than skip: it was + /// generated nothing and reported nothing, and a value held as it, of a sealed type derived from + /// it, had no Value and was written as {}. + /// + public static bool IsAbstractValueObject(INamedType type) => + type.TypeKind == TypeKind.Class + && type.IsAbstract + && !type.IsStatic + && Markers(type).Count > 0; + /// /// Why cannot be generated over what it wraps, or /// when that is not a value object. Called from the fabric only, since it diff --git a/src/CodoMetis.TypeKit.Serilog/CodoMetis.TypeKit.Serilog.csproj b/src/CodoMetis.TypeKit.Serilog/CodoMetis.TypeKit.Serilog.csproj index 1285d6b..2a36425 100644 --- a/src/CodoMetis.TypeKit.Serilog/CodoMetis.TypeKit.Serilog.csproj +++ b/src/CodoMetis.TypeKit.Serilog/CodoMetis.TypeKit.Serilog.csproj @@ -2,7 +2,7 @@ - Serilog integration for CodoMetis.TypeKit: value objects are logged as what they print, so a destructured value object or a record holding one never exposes a value its ToString() masks. + Serilog integration for CodoMetis.TypeKit: value objects and closed sets are logged as what they print, so a destructured value object or a record holding one never exposes a value its ToString() masks. serilog;logging;structured-logging;value-object;strongly-typed-id;ddd;redaction diff --git a/src/CodoMetis.TypeKit.Serilog/README.md b/src/CodoMetis.TypeKit.Serilog/README.md index f1ab3c2..94afba7 100644 --- a/src/CodoMetis.TypeKit.Serilog/README.md +++ b/src/CodoMetis.TypeKit.Serilog/README.md @@ -1,12 +1,14 @@ # CodoMetis.TypeKit.Serilog Serilog integration for [CodoMetis.TypeKit](https://www.nuget.org/packages/CodoMetis.TypeKit) value -objects: each is logged as what it prints, also where Serilog destructures it. +objects and closed sets: each is logged as what it prints, also where Serilog destructures it. ```bash dotnet add package CodoMetis.TypeKit.Serilog ``` +It needs Serilog 4.0.0 or later. + ```csharp using Serilog; @@ -25,9 +27,12 @@ logger.Information("Created {@Client}", new Client(ApiKey.From("k-123"), "Ann")) - **Every value object**, masked or not, is then a string in the event: an `OrderId` is `"0199aaaa-…"`, not `{ Value: "0199aaaa-…" }`, and a `Quantity` is `"3"`. A list or dictionary of them is a list or dictionary of strings. -- **Nothing per type.** A value object is recognised by the attribute the generators put on it, so - the host needs this package and the base package, not the generators. Other types are left to - Serilog. +- **A closed set** is logged as its name, or as what its own `ToString()` prints, never by its + properties: `{@Order}` holds `Status: "Shipped"`, not the set's constant data. An instance the + set's own code made beside its members has no name, and Serilog logs that it could not capture it. +- **Nothing per type.** A value object or a closed set is recognised by the attribute the generators + put on it, so the host needs this package and the base package, not the generators. Other types + are left to Serilog. - To mask a value object, declare `ToString()`; the [Generators README](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#a-value-that-must-not-be-printed) says what else then still carries the value. diff --git a/src/CodoMetis.TypeKit.Serilog/ValueObjectDestructuringPolicy.cs b/src/CodoMetis.TypeKit.Serilog/ValueObjectDestructuringPolicy.cs index 16c82d2..cf8dc01 100644 --- a/src/CodoMetis.TypeKit.Serilog/ValueObjectDestructuringPolicy.cs +++ b/src/CodoMetis.TypeKit.Serilog/ValueObjectDestructuringPolicy.cs @@ -8,8 +8,8 @@ namespace CodoMetis.TypeKit.Serilog; /// -/// Destructures a value object as what it prints: a scalar of its ToString(), never its -/// properties. +/// Destructures a value object or a closed set as what it prints: a scalar of its ToString(), +/// never its properties. /// /// /// @@ -17,25 +17,29 @@ namespace CodoMetis.TypeKit.Serilog; /// { Value: … }, read from the public Value: a value object that masks itself with a /// hand-written ToString() is logged unmasked there, and nowhere else (spikes/SensitiveValues, /// finding 4). With it, Serilog logs a value object as Microsoft.Extensions.Logging does, as its text. +/// A closed set was logged by its properties too, its constant data and its hand-written +/// ToString()'s text bypassed alike; it is its name, or what its own ToString() prints. /// /// -/// Recognised by the every value object carries, never by -/// a name or by , and remembered per type. +/// Recognised by the every value object carries and the +/// every closed set carries, never by a name or by +/// , and remembered per type. /// /// internal sealed class ValueObjectDestructuringPolicy : IDestructuringPolicy { - private static readonly ConcurrentDictionary ValueObjects = new(); + private static readonly ConcurrentDictionary PrintedTypes = new(); public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, [NotNullWhen(true)] out LogEventPropertyValue? result) { - result = IsValueObject(value.GetType()) ? new ScalarValue(value.ToString()) : null; + result = PrintsItself(value.GetType()) ? new ScalarValue(value.ToString()) : null; return result is not null; } /// A boxed nullable arrives as its underlying type, so the type itself is the value object. - private static bool IsValueObject(Type type) => - ValueObjects.GetOrAdd(type, static candidate => - candidate.GetCustomAttribute(inherit: false) is { } valueObject && valueObject.ValueObjectType == candidate); + private static bool PrintsItself(Type type) => + PrintedTypes.GetOrAdd(type, static candidate => + (candidate.GetCustomAttribute(inherit: false) is { } valueObject && valueObject.ValueObjectType == candidate) + || (candidate.GetCustomAttribute(inherit: false) is { } set && set.SetType == candidate)); } diff --git a/src/CodoMetis.TypeKit.Testing/CodoMetis.TypeKit.Testing.csproj b/src/CodoMetis.TypeKit.Testing/CodoMetis.TypeKit.Testing.csproj index 3b745fd..dd89959 100644 --- a/src/CodoMetis.TypeKit.Testing/CodoMetis.TypeKit.Testing.csproj +++ b/src/CodoMetis.TypeKit.Testing/CodoMetis.TypeKit.Testing.csproj @@ -2,7 +2,7 @@ - Test assertions for CodoMetis.TypeKit's Option and Result: hand a test the value or the error it expects, or fail with what the outcome was instead. No test framework dependency. + Test assertions for CodoMetis.TypeKit's Option, Result and Patch: hand a test the value or the error it expects, or fail with what the outcome was instead. No test framework dependency. testing;assertions;option;result;xunit;nunit;mstest diff --git a/src/CodoMetis.TypeKit.Testing/Outcomes.cs b/src/CodoMetis.TypeKit.Testing/Outcomes.cs index 3b630dc..05949e8 100644 --- a/src/CodoMetis.TypeKit.Testing/Outcomes.cs +++ b/src/CodoMetis.TypeKit.Testing/Outcomes.cs @@ -16,11 +16,40 @@ public static void ThrowIfOtherError(TError actual, TError exp throw new TypeKitAssertionException($"Expected the error {expected}, but the {Name()} is the error {actual}."); } - /// Result<Order, OrderFault>, as the type is written in C#. + /// + /// Result<Order, OrderFault>, as the type is written in C# from outside its containing + /// types: Billing.Fault, Outer<Int32>.Inner. + /// public static string Name() => Name(typeof(T)); - private static string Name(Type type) => - type.IsGenericType - ? $"{type.Name[..type.Name.IndexOf('`', StringComparison.Ordinal)]}<{string.Join(", ", type.GetGenericArguments().Select(Name))}>" - : type.Name; + /// + /// A nested type's generic arguments are its containing types' and then its own, in one list, and a + /// type nested in a generic one is generic without an arity of its own (Inner, not + /// Inner`1): each name in the chain takes as many arguments as its own arity says. + /// + private static string Name(Type type) + { + if (type.IsArray) + return $"{Name(type.GetElementType()!)}[{new string(',', type.GetArrayRank() - 1)}]"; + + if (type.IsGenericParameter) + return type.Name; + + var arguments = type.IsGenericType ? type.GetGenericArguments() : []; + var chain = new List(); + for (var link = type; link is not null; link = link.DeclaringType) + chain.Insert(0, link); + + var taken = 0; + return string.Join(".", chain.Select(link => + { + var tick = link.Name.IndexOf('`', StringComparison.Ordinal); + if (tick < 0) return link.Name; + + var arity = int.Parse(link.Name.AsSpan(tick + 1), System.Globalization.CultureInfo.InvariantCulture); + var own = arguments.Skip(taken).Take(arity).Select(Name); + taken += arity; + return $"{link.Name[..tick]}<{string.Join(", ", own)}>"; + })); + } } diff --git a/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs b/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs new file mode 100644 index 0000000..5a131c6 --- /dev/null +++ b/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs @@ -0,0 +1,45 @@ +namespace CodoMetis.TypeKit.Testing; + +/// Assertions on a : which of its three states a field is in. +/// +/// hands the value back, so the test compares it with the +/// assertions it already uses. A failure never prints the value, as never does. +/// +public static class PatchAssertions +{ + /// The value of a patch whose field must have carried one. + /// The patch under test. + /// The value. + /// The field was null or not mentioned. + public static T ShouldBeValue(this Patch patch) where T : notnull => + patch.TryGetValue(out var value) + ? value + : throw new TypeKitAssertionException($"Expected a value, but the {Outcomes.Name>()} is {Describe(patch.State)}."); + + /// Fails unless the field was null: a clear. + /// The patch under test. + /// The field carried a value, which is not printed, or was not mentioned. + public static void ShouldBeNull(this Patch patch) where T : notnull + { + if (patch.State != PatchState.Null) + throw new TypeKitAssertionException($"Expected Null, a clear, but the {Outcomes.Name>()} {Was(patch.State)}."); + } + + /// Fails unless the field was not mentioned. + /// The patch under test. + /// The field carried a value, which is not printed, or was null. + public static void ShouldBeAbsent(this Patch patch) where T : notnull + { + if (patch.State != PatchState.Absent) + throw new TypeKitAssertionException($"Expected Absent, but the {Outcomes.Name>()} {Was(patch.State)}."); + } + + private static string Was(PatchState state) => state == PatchState.Value ? "holds a value (not printed)" : $"is {Describe(state)}"; + + private static string Describe(PatchState state) => state switch + { + PatchState.Null => "Null: the field was cleared", + PatchState.Absent => "Absent: the field was not mentioned", + _ => "a value", + }; +} diff --git a/src/CodoMetis.TypeKit.Testing/README.md b/src/CodoMetis.TypeKit.Testing/README.md index 2b181f0..28f0cb5 100644 --- a/src/CodoMetis.TypeKit.Testing/README.md +++ b/src/CodoMetis.TypeKit.Testing/README.md @@ -1,7 +1,7 @@ # CodoMetis.TypeKit.Testing -Test assertions for the `Option` and `Result` of -[CodoMetis.TypeKit](https://www.nuget.org/packages/CodoMetis.TypeKit). Neither type has a `.Value`, so a +Test assertions for the `Option`, `Result` and `Patch` of +[CodoMetis.TypeKit](https://www.nuget.org/packages/CodoMetis.TypeKit). None of them has a `.Value`, so a test reaches the content through these, and fails with what the outcome was instead. ```bash @@ -19,13 +19,25 @@ Email.TryFrom("ops@example.com").ShouldBeSome(); Email.TryFrom("nobody").ShouldBeNone(); ``` -- **The content comes back.** `ShouldBeSome()`, `ShouldBeSuccess()` and `ShouldBeError()` return the - value or the error, so it is compared with whatever assertions the test already uses: xUnit's - `Assert`, Shouldly, or another. +A `Patch` field of a request says whether the request mentioned it, and the assertions say the +same; `OrderPatch` here has `Patch Name`, `Patch Nickname` and `Patch Quantity`: + +```csharp +OrderPatch patch = JsonSerializer.Deserialize("""{"nickname":null,"quantity":2}""", JsonSerializerOptions.Web)!; + +int quantity = patch.Quantity.ShouldBeValue(); // the value, handed back +patch.Nickname.ShouldBeNull(); // a clear +patch.Name.ShouldBeAbsent(); // not in the document +``` + +- **The content comes back.** `ShouldBeSome()`, `ShouldBeSuccess()`, `ShouldBeError()` and + `ShouldBeValue()` return the value or the error, so it is compared with whatever assertions the test + already uses: xUnit's `Assert`, Shouldly, or another. - **No test framework.** A failure is a `TypeKitAssertionException`, and every framework reports an exception from a test as its failure. The package depends on `CodoMetis.TypeKit` alone. - **A failure says what the outcome was**: `Expected a success, but the Result is - the error NoAt.` It names the error, which is what a test needs to see, and never prints a value, as - `Option` and `Result` themselves never do. + the error NoAt.` It prints the error through its `ToString()`, which is what a test needs to see, so + an exception's message or a `Fault`'s detail appears; it never prints a value, as `Option`, `Result` + and `Patch` themselves never do. - **An uninitialized result fails every assertion**, saying it is a default instance, neither a success nor an error. diff --git a/src/CodoMetis.TypeKit.Testing/TypeKitAssertionException.cs b/src/CodoMetis.TypeKit.Testing/TypeKitAssertionException.cs index def3682..8106cf2 100644 --- a/src/CodoMetis.TypeKit.Testing/TypeKitAssertionException.cs +++ b/src/CodoMetis.TypeKit.Testing/TypeKitAssertionException.cs @@ -1,7 +1,7 @@ namespace CodoMetis.TypeKit.Testing; /// -/// An or a Result is not the outcome the test expected. Every test +/// An , a Result or a is not the outcome the test expected. Every test /// framework reports an exception from a test as its failure, so this package needs none of them. /// public sealed class TypeKitAssertionException : Exception diff --git a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs new file mode 100644 index 0000000..5f32333 --- /dev/null +++ b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs @@ -0,0 +1,70 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CodoMetis.TypeKit.ClosedSets; + +/// +/// Declares a closed set, a type whose every instance is a named member declared as a +/// public static readonly field of the type: +/// public sealed partial class OrderStatus : IClosedSet<OrderStatus> { public static readonly OrderStatus Open = new(); } +/// +/// +/// +/// CodoMetis.TypeKit.Generators generates everything else: from the field's name, +/// (OrderStatus.Open), in declaration order, +/// Match with one required delegate per member named after it, Parse and +/// by name, explicitly, ToString(), a JSON +/// converter that writes the name and reads the member back, a type converter for what reads a string +/// through TypeDescriptor (the configuration binder, MVC), and a private parameterless +/// constructor where the type declares none. A member +/// may carry constant data through a private constructor the type declares, as +/// new(FaultKind.NotFound, "The order does not exist."). +/// +/// +/// Unlike an enum, a closed set's switch is complete by construction: Match takes +/// a delegate per member, so a member added later fails every call site until it is handled. Its +/// arguments are named (CMTK0012), since by position they would follow the declaration order, and +/// it refuses a null delegate before picking one. The type is a sealed class, so members are +/// singletons and equality is reference equality. +/// +/// +/// The type must be a sealed partial class (CMTK1013, CMTK1014) with at least one member (CMTK1015), +/// only private constructors (CMTK1016), no other public static member of its own type (CMTK1017), +/// and not a record (CMTK1020), since every one of those would make an instance the generators cannot +/// see as a member. Each member is initialized with new where it is declared, and with nothing +/// else (CMTK1019), and never written again (CMTK0013): a member initialized or assigned with another +/// member would give that instance two names, or answer to another's. A +/// member takes no name the generators use, nor another's Match parameter (CMTK1021). A +/// ToString() written by hand is kept, as is an overload of a generated method with another +/// signature; any other member the generators introduce, written by hand, is CMTK1011. +/// +/// +/// An instance the set's own code creates beside its members has no name: , the +/// generated ToString(), Match, JSON and the EF Core column throw for it rather than carry +/// a null. +/// A JSON null reads as , as for any class; the serializer's +/// RespectNullableAnnotations refuses it for a property that is not nullable. +/// +/// +/// Without a reference to CodoMetis.TypeKit.Generators nothing is generated, and the analyzer +/// reports CMTK0002 on the declaration. +/// +/// +/// The set itself. +public interface IClosedSet where TSelf : class, IClosedSet +{ + /// The member's name: the name of the field that declares it. + /// The instance is not one of the set's members. + string Name { get; } + + /// The set's name and the member's, OrderStatus.Open, so two sets that share a member name stay apart. + string QualifiedName { get; } + + /// Every member, in declaration order. + static abstract IReadOnlyList All { get; } + + /// The member of that name, if any. Names compare ordinally. + /// The name. + /// The member, or null. + /// Whether a member has that name. + static abstract bool TryParse([NotNullWhen(true)] string? name, [MaybeNullWhen(false)] out TSelf member); +} diff --git a/src/CodoMetis.TypeKit/ClosedSets/IFaultSet.cs b/src/CodoMetis.TypeKit/ClosedSets/IFaultSet.cs new file mode 100644 index 0000000..8d79f5b --- /dev/null +++ b/src/CodoMetis.TypeKit/ClosedSets/IFaultSet.cs @@ -0,0 +1,73 @@ +namespace CodoMetis.TypeKit.ClosedSets; + +/// +/// What kind of refusal a fault is, as a client acts on it: nothing HTTP-specific, so a domain +/// declares it without referencing ASP.NET, and a host derives its status from it. +/// +/// +/// The values start at 1: a default kind is no kind, and a host refuses it where the faults are +/// mapped rather than answer it as a . +/// +public enum FaultKind +{ + /// What the request names does not exist. A host answers 404 Not Found. + NotFound = 1, + + /// + /// The request conflicts with the current state: a name taken, a stale version, a step the state + /// forbids. A host answers 409 Conflict. + /// + Conflict, + + /// The request is well formed and a rule refuses it. A host answers 422 Unprocessable Content. + Invalid, + + /// The caller may not do this. A host answers 403 Forbidden. + Forbidden, + + /// Something the operation needs is unavailable, and the same request may succeed later. A host answers 503 Service Unavailable. + Unavailable, +} + +/// What a member of a fault set says of itself: the kind of refusal, and a sentence a person reads. +/// +/// Implemented through . Non-generic, so code that holds a fault of a +/// type it does not know, such as the ASP.NET Core satellite, reads it with an interface check and no +/// reflection. +/// +public interface IFault +{ + /// What kind of refusal this is, from which a host derives its status. + FaultKind Kind { get; } + + /// + /// The fault as a person reads it, a sentence a client can show, or for a + /// refusal that must not explain itself, a credential's or a secret's. + /// + /// + /// The same for every occurrence: what varies per occurrence travels as + /// . Unless a set declares it, it is . + /// + string? Description => null; +} + +/// +/// Declares a closed set whose members are faults, each with the a host derives +/// its status from and an optional : +/// public static readonly OrderFault NotFound = new(FaultKind.NotFound, "The order does not exist."); +/// +/// +/// +/// A closed set in every other respect (): the generators add its name, +/// All, Match, parsing and JSON. The set declares , and +/// where its members carry one, through its private constructor, so a +/// member's kind is read where it is declared, without reflection. +/// +/// +/// In CodoMetis.TypeKit.AspNetCore, AddFaultResponses<TFault>() maps every member by its +/// kind, and the description is the problem's detail unless the occurrence carries one of its own. +/// A smart enum names a fault; data that varies per occurrence travels as . +/// +/// +/// The set itself. +public interface IFaultSet : IClosedSet, IFault where TSelf : class, IFaultSet; diff --git a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj index 0de00a7..f306981 100644 --- a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj +++ b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj @@ -5,7 +5,7 @@ Metalama, directly or transitively; MetalamaBoundaryTests fails the build if it does. --> - Option and Result types that cannot be misused, and the contracts for strongly-typed value objects. + Option and Result types that cannot be misused, and the contracts for value objects and closed sets (smart enums): one validation rule and one typed fault, carried through JSON, EF Core, OpenAPI and HTTP problem details by the TypeKit packages. Also a merge patch's field and the text guards. option;result;maybe;railway-oriented-programming;functional;error-handling;value-object;strongly-typed-id;primitive-obsession;ddd;domain-driven-design;native-aot @@ -21,7 +21,7 @@ + + + + diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs index dcebc3a..8189a2b 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs @@ -65,6 +65,14 @@ private static AnalyzerTest TestWithEntityFramewor private const string NullableFirst = "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"; + private const string SingleOrNone = "Use SingleOrNone, which returns an Option, or pass a default value"; + + private const string ElementAtOrNone = "Use ElementAtOrNone, which returns an Option"; + + private const string SingleOrNoneAsync = "Use SingleOrNoneAsync, which returns an Option and asks the query for two elements, or select a nullable first, as in Select(x => (Plain?)x)"; + + private const string FirstOrNoneAsync = "Use FirstOrNoneAsync, which returns an Option and asks the query for one element, or select a nullable first, as in Select(x => (Plain?)x)"; + private const string MissingKey = " for a missing key"; private const string GetValueOrNone = "Use GetValueOrNone, which returns an Option, or pass a default value"; @@ -117,15 +125,19 @@ public static void Run(List ids) Cmtk0009(1, "FirstOrDefault", "Plain", NothingFound, FirstOrNone), Cmtk0009(2, "LastOrDefault", "Plain", NothingFound, LastOrNone), Cmtk0009(3, "LastOrDefault", "Plain", NothingFound, LastOrNone), - Cmtk0009(4, "SingleOrDefault", "Plain", NothingFound, DefaultValue), - Cmtk0009(5, "SingleOrDefault", "Plain", NothingFound, DefaultValue), - Cmtk0009(6, "ElementAtOrDefault", "Plain", " for an index past the end", "Use Skip(index).FirstOrNone(), which returns an Option"), - Cmtk0009(7, "ElementAtOrDefault", "Plain", " for an index past the end", "Use Skip(index).FirstOrNone(), which returns an Option"), + Cmtk0009(4, "SingleOrDefault", "Plain", NothingFound, SingleOrNone), + Cmtk0009(5, "SingleOrDefault", "Plain", NothingFound, SingleOrNone), + Cmtk0009(6, "ElementAtOrDefault", "Plain", " for an index past the end", ElementAtOrNone), + Cmtk0009(7, "ElementAtOrDefault", "Plain", " for an index past the end", ElementAtOrNone), Cmtk0009(8, "DefaultIfEmpty", "Plain", " for an empty sequence", DefaultValue), Cmtk0009(9, "FirstOrDefault", "Plain", NothingFound, FirstOrNone)) .RunAsync(TestContext.Current.CancellationToken); - /// In a query, FirstOrNone would run it on the client; a nullable projection is translated. + /// + /// In a query, FirstOrNone and ElementAtOrNone ask the provider for one element and + /// SingleOrNone for two; the rest have no query form beside the nullable projection, which + /// every provider translates. + /// [Fact] public Task Every_query_form_without_a_default_value_reports() => Test( @@ -142,10 +154,10 @@ public static void Run(IQueryable ids) } } """, - Cmtk0009(0, "FirstOrDefault", "Plain", NothingFound, "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), + Cmtk0009(0, "FirstOrDefault", "Plain", NothingFound, "Use FirstOrNone, which returns an Option and asks the query for one element, or select a nullable first, as in Select(x => (Plain?)x)"), Cmtk0009(1, "LastOrDefault", "Plain", NothingFound, "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), - Cmtk0009(2, "SingleOrDefault", "Plain", NothingFound, "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), - Cmtk0009(3, "ElementAtOrDefault", "Plain", " for an index past the end", "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), + Cmtk0009(2, "SingleOrDefault", "Plain", NothingFound, "Use SingleOrNone, which returns an Option and asks the query for two elements, or select a nullable first, as in Select(x => (Plain?)x)"), + Cmtk0009(3, "ElementAtOrDefault", "Plain", " for an index past the end", "Use ElementAtOrNone, which returns an Option and asks the query for one element"), Cmtk0009(4, "DefaultIfEmpty", "Plain", " for an empty sequence", "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null")) .RunAsync(TestContext.Current.CancellationToken); @@ -250,9 +262,9 @@ public static void Run(ImmutableArray ids, ImmutableArray.Builder Cmtk0009(1, "FirstOrDefault", "Plain", NothingFound, FirstOrNone), Cmtk0009(2, "LastOrDefault", "Plain", NothingFound, LastOrNone), Cmtk0009(3, "LastOrDefault", "Plain", NothingFound, LastOrNone), - Cmtk0009(4, "SingleOrDefault", "Plain", NothingFound, DefaultValue), - Cmtk0009(5, "SingleOrDefault", "Plain", NothingFound, DefaultValue), - Cmtk0009(6, "ElementAtOrDefault", "Plain", PastTheEnd, "Use Skip(index).FirstOrNone(), which returns an Option"), + Cmtk0009(4, "SingleOrDefault", "Plain", NothingFound, SingleOrNone), + Cmtk0009(5, "SingleOrDefault", "Plain", NothingFound, SingleOrNone), + Cmtk0009(6, "ElementAtOrDefault", "Plain", PastTheEnd, ElementAtOrNone), Cmtk0009(7, "FirstOrDefault", "Plain", NothingFound, FirstOrNone), Cmtk0009(8, "LastOrDefault", "Plain", NothingFound, LastOrNone), Cmtk0009(9, "GetValueOrDefault", "Plain", MissingKey, GetValueOrNone), @@ -328,16 +340,32 @@ public static async Task Run(IQueryable ids, System.Threading.Cancellatio } } """, - Cmtk0009(0, "FirstOrDefaultAsync", "Plain", NothingFound, NullableFirst), - Cmtk0009(1, "FirstOrDefaultAsync", "Plain", NothingFound, NullableFirst), + Cmtk0009(0, "FirstOrDefaultAsync", "Plain", NothingFound, FirstOrNoneAsync), + Cmtk0009(1, "FirstOrDefaultAsync", "Plain", NothingFound, FirstOrNoneAsync), Cmtk0009(2, "LastOrDefaultAsync", "Plain", NothingFound, NullableFirst), Cmtk0009(3, "LastOrDefaultAsync", "Plain", NothingFound, NullableFirst), - Cmtk0009(4, "SingleOrDefaultAsync", "Plain", NothingFound, NullableFirst), - Cmtk0009(5, "SingleOrDefaultAsync", "Plain", NothingFound, NullableFirst), + Cmtk0009(4, "SingleOrDefaultAsync", "Plain", NothingFound, SingleOrNoneAsync), + Cmtk0009(5, "SingleOrDefaultAsync", "Plain", NothingFound, SingleOrNoneAsync), Cmtk0009(6, "ElementAtOrDefaultAsync", "Plain", PastTheEnd, NullableFirst), - Cmtk0009(7, "FirstOrDefaultAsync", "Plain", NothingFound, NullableFirst)) + Cmtk0009(7, "FirstOrDefaultAsync", "Plain", NothingFound, FirstOrNoneAsync)) .RunAsync(TestContext.Current.CancellationToken); + /// FirstOrNoneAsync is the EF Core satellite's: a project that references EF Core without it is told the nullable projection. + [Fact] + public Task Without_the_satellite_FirstOrDefaultAsync_is_told_the_nullable_projection() + { + var test = new AnalyzerTest("using Microsoft.EntityFrameworkCore;" + Environment.NewLine + Subjects + + """ + public static class C + { + public static async Task Run(IQueryable ids) { var first = await {|#0:ids.FirstOrDefaultAsync()|}; } + } + """); + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions).Assembly.Location)); + test.ExpectedDiagnostics.Add(Cmtk0009(0, "FirstOrDefaultAsync", "Plain", NothingFound, NullableFirst)); + return test.RunAsync(TestContext.Current.CancellationToken); + } + /// /// The new forms keep the rule's line: an overload given a default value, and an element type whose /// default is null or an ordinary value, stay silent. @@ -395,7 +423,7 @@ public static void Run(List> options, List> resul """, Cmtk0009(0, "FirstOrDefault", "Option", NothingFound, FirstOrNone), Cmtk0009(1, "LastOrDefault", "Result", NothingFound, LastOrNone), - Cmtk0009(2, "SingleOrDefault", "Guarded", NothingFound, DefaultValue), + Cmtk0009(2, "SingleOrDefault", "Guarded", NothingFound, SingleOrNone), Cmtk0009(3, "FirstOrDefault", "Validated", NothingFound, FirstOrNone), Cmtk0009(4, "FirstOrDefault", "T", NothingFound, FirstOrNone), Cmtk0009(5, "FirstOrDefault", "T", NothingFound, FirstOrNone)) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs index 153c446..4a3e473 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs @@ -62,16 +62,20 @@ public partial class Component { } """; + private const string Razor = "Components/Component.razor"; + + private const string Cshtml = "Views/Home/Index.cshtml"; + /// /// The generated file. $ in a trigger becomes a distinct suffix per copy, and only the copy /// under the mapping to the markup keeps its {|#0:…|} location. /// - private static string Generated(string trigger) => + private static string Generated(string trigger, string markup) => "// \n" + Usings + $$""" public partial class Component { - #line 7 "Components/Component.razor" + #line 7 "{{markup}}" {{trigger.Replace("$", "Mapped")}} #line default #line hidden @@ -83,11 +87,11 @@ public partial class Component private static string Unmarked(string trigger) => trigger.Replace("{|#0:", "").Replace("|}", ""); - private static Task Verify(string trigger, string id, DiagnosticSeverity severity, string subjects = Subjects) + private static Task Verify(string trigger, string id, DiagnosticSeverity severity, string subjects = Subjects, string markup = Razor) where TAnalyzer : DiagnosticAnalyzer, new() { var test = new AnalyzerTest(subjects); - test.TestState.Sources.Add(("Component.razor.g.cs", Generated(trigger))); + test.TestState.Sources.Add(("Component.razor.g.cs", Generated(trigger, markup))); test.TestState.AdditionalReferences.AddRange(RealEntityFrameworkCore.References); test.ExpectedDiagnostics.Add(new DiagnosticResult(id, severity).WithLocation(0)); return test.RunAsync(TestContext.Current.CancellationToken); @@ -103,7 +107,7 @@ public Task CMTK0002_reports_in_markup_only() => [Fact] public Task CMTK0003_reports_in_markup_only() => - Verify("private void Run$(Service service) { {|#0:service.Load()|}; }", "CMTK0003", DiagnosticSeverity.Warning); + Verify("private void Run$(Service service) { {|#0:service.Load()|}; }", "CMTK0003", DiagnosticSeverity.Error); [Fact] public Task CMTK0004_reports_in_markup_only() => @@ -130,6 +134,97 @@ public Task CMTK0008_reports_in_markup_only() => public Task CMTK0009_reports_in_markup_only() => Verify("private Plain First$(List ids) => {|#0:ids.FirstOrDefault()|};", "CMTK0009", DiagnosticSeverity.Warning); + /// + /// The rules of 2.0.0, each in a .razor and a .cshtml mapping: the mapped copy is + /// reported and the hidden and unmapped ones are not, so a rule that reported past + /// GeneratedCode fails here as surely as one that skipped generated code. + /// + public static TheoryData Markup() => [Razor, Cshtml]; + + private const string Constraints = + Subjects + + """ + public sealed class Box<[Abstract] T> { } + + public sealed class Concrete { } + + public static class Runner + { + public static void Run<[Abstract] T>(T command) { } + } + + """; + + /// A type argument written in source is checked by the syntax path, at the argument. + [Theory] + [MemberData(nameof(Markup))] + public Task CMTK0010_written_reports_in_markup_only(string markup) => + Verify("private object Make$() => new Box<{|#0:Concrete|}>();", "CMTK0010", DiagnosticSeverity.Error, Constraints, markup); + + /// A type argument inferred at a call is checked by the operation path, at the call. + [Theory] + [MemberData(nameof(Markup))] + public Task CMTK0010_inferred_reports_in_markup_only(string markup) => + Verify("private void Run$(Concrete command) => {|#0:Runner.Run(command)|};", "CMTK0010", DiagnosticSeverity.Error, Constraints, markup); + + [Theory] + [MemberData(nameof(Markup))] + public Task CMTK0011_reports_in_markup_only(string markup) => + Verify( + "public sealed class {|#0:Job$|} : IJob { public Job$(int port) { } }", + "CMTK0011", + DiagnosticSeverity.Error, + Subjects + "[RequireParameterlessConstructor] public interface IJob { }\n", + markup); + + private const string Sets = + Subjects + + """ + public sealed class Status : CodoMetis.TypeKit.ClosedSets.IClosedSet + { + public static readonly Status Open = new(); + public static readonly Status Shipped = new(); + + private Status() { } + + public string Name => ""; + public string QualifiedName => ""; + public static IReadOnlyList All => [Open, Shipped]; + public static bool TryParse([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? name, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out Status member) { member = null; return false; } + + public TResult Match(Func open, Func shipped) => open(); + } + + """; + + [Theory] + [MemberData(nameof(Markup))] + public Task CMTK0012_reports_in_markup_only(string markup) => + Verify("private int Pick$(Status status) => status.Match({|#0:() => 1|}, () => 2);", "CMTK0012", DiagnosticSeverity.Error, Sets, markup); + + /// + /// The tests above hold each rule of today to the one reporting path; this holds a rule added + /// later. A rule that calls ReportDiagnostic itself reports from every generated file, EF + /// Core's compiled model and #line hidden included, as CMTK0010 did until 2.0.0's review. + /// + [Fact] + public void No_rule_reports_past_GeneratedCode() + { + var files = Directory.GetFiles(Path.Combine(AnalyzerHelpLinkTests.RepositoryRoot(), "src", "CodoMetis.TypeKit.Analyzers"), "*.cs") + .Where(file => Path.GetFileName(file) != "GeneratedCode.cs") + .ToList(); + + files.Count.ShouldBeGreaterThanOrEqualTo(14, "The analyzer sources were not found, so this test checks nothing."); + + var calls = from file in files + let root = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(File.ReadAllText(file)).GetRoot(TestContext.Current.CancellationToken) + from name in root.DescendantNodes().OfType() + where name.Identifier.ValueText == "ReportDiagnostic" + select $"{Path.GetFileName(file)}:{name.GetLocation().GetLineSpan().StartLinePosition.Line + 1}"; + + calls.ShouldBeEmpty("These rules call ReportDiagnostic directly, so they report from generated code that maps to no markup. Report through GeneratedCode.Report."); + } + /// A Razor view compiles to generated code too, mapped to its .cshtml. [Fact] public Task A_cshtml_mapping_counts_as_markup() => diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index 7a5e40d..a696136 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -5,7 +5,8 @@ namespace CodoMetis.TypeKit.Analyzers.Tests; /// /// CMTK0003: a Result or Option, or a collection of them, dropped by an expression -/// statement or by a conversion of its task to a plain Task. +/// statement, by a conversion of its task to a plain Task, by Wait, or by a loop that +/// never reads them. /// public sealed class IgnoredOutcomeAnalyzerTests { @@ -46,6 +47,21 @@ public sealed class Service public Task>> SaveAllAsync(int[] values) => Task.FromResult>>([]); + public IEnumerable> SaveEach(int[] values) => values.Select(Save); + + public async IAsyncEnumerable> SaveEachAsync(int[] values) + { + foreach (var value in values) yield return await CancelAsync(value); + } + + public (Result, int) Pair() => (Load(), 1); + + public Task<(Result Loaded, int Count)> PairAsync() => Task.FromResult<(Result, int)>(Pair()); + + public Dictionary> Map(int[] values) => []; + + public Task>> MapAsync() => Task.FromResult>>(new Dictionary>()); + public Task RunAsync() => Task.CompletedTask; public int Count() => 1; @@ -68,7 +84,7 @@ private static AnalyzerTest Test(string code, params Dia } private static DiagnosticResult Cmtk0003(int location, string outcome, string method) => - new DiagnosticResult("CMTK0003", DiagnosticSeverity.Warning).WithLocation(location).WithArguments(outcome, method); + new DiagnosticResult("CMTK0003", DiagnosticSeverity.Error).WithLocation(location).WithArguments(outcome, method); /// A second file, whose own using directives come first. private static AnalyzerTest TestWithFile(string file, params DiagnosticResult[] expected) @@ -84,7 +100,7 @@ public void The_descriptor_is_the_published_contract() var rule = new IgnoredOutcomeAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); rule.Id.ShouldBe("CMTK0003"); - rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Warning); + rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Error); rule.IsEnabledByDefault.ShouldBeTrue(); } @@ -97,7 +113,7 @@ public static class Caller public static void Run(Service service) { {|#0:service.Save(1)|}; } } """, - new DiagnosticResult("CMTK0003", DiagnosticSeverity.Warning) + new DiagnosticResult("CMTK0003", DiagnosticSeverity.Error) .WithLocation(0) .WithMessage("The Result that 'Save' returns is ignored, so its outcome is never seen. Handle it, or discard it with '_ =' if nothing depends on it.")) .RunAsync(TestContext.Current.CancellationToken); @@ -133,6 +149,102 @@ public static async Task Run(Service service, Service? maybe) Cmtk0003(8, "Result", "LoadAsync")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A patch is no outcome: its Tap and IfPresent return it, as Option's Tap + /// returns the option, and a statement that drops it drops nothing anyone has to decide on. + /// + [Fact] + public Task A_patch_s_Tap_and_IfPresent_as_statements_are_silent() => + Test( + """ + public static class Caller + { + public static void Run(Patch quantity, Patch nickname, List seen) + { + quantity.Tap(seen.Add); + quantity.IfPresent(value => seen.Add(value ?? 0)); + nickname.IfPresent(value => seen.Add(value?.Length ?? 0)); + Patch.Value(3).Tap(seen.Add); + } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// ConfigureAwait and WaitAsync hand on the task they are called on: a task the caller + /// still holds keeps its result through them, as it does through await held;. They were + /// reported as the producers of the result, at Error. + /// + [Fact] + public Task A_held_task_passed_through_ConfigureAwait_or_WaitAsync_stays_silent() => + Test( + """ + public static class Caller + { + private static readonly Task> Field = Task.FromResult>(1); + + public static async Task Run(Task> held, Task> other, ValueTask> valueHeld, bool dry, System.Threading.CancellationToken t) + { + await held; + await held.ConfigureAwait(false); + await held.ConfigureAwait(ConfigureAwaitOptions.None); + await Field.ConfigureAwait(false); + await (dry ? held : other).ConfigureAwait(false); + await valueHeld.ConfigureAwait(false); + await held.WaitAsync(t); + await held.WaitAsync(TimeSpan.FromSeconds(1)); + await held.WaitAsync(TimeSpan.FromSeconds(1), t); + await held.WaitAsync(TimeSpan.FromSeconds(1), TimeProvider.System); + await held.WaitAsync(TimeSpan.FromSeconds(1), TimeProvider.System, t); + await held.WaitAsync(t).ConfigureAwait(false); + held.WaitAsync(t); + held.ConfigureAwait(false); + held.WaitAsync(t).Wait(); + await Task.WhenAll(held.WaitAsync(t), other); + } + + public static Task Handed(Task> held, System.Threading.CancellationToken t) => held.WaitAsync(t); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A task a call made, passed through ConfigureAwait or WaitAsync, is still that + /// call's result, dropped and named after the call: awaited, not awaited, waited for, converted to + /// a plain task, or picked by a branch. + /// + [Fact] + public Task A_call_s_task_passed_through_ConfigureAwait_or_WaitAsync_reports_naming_the_call() => + Test( + """ + public static class Caller + { + public static async Task Run(Service service, Task> held, bool dry, System.Threading.CancellationToken t) + { + {|#0:await service.LoadAsync().ConfigureAwait(ConfigureAwaitOptions.None)|}; + {|#1:await service.SaveAsync().ConfigureAwait(false)|}; + {|#2:await service.LoadAsync().WaitAsync(t)|}; + {|#3:await service.LoadAsync().WaitAsync(TimeSpan.FromSeconds(1), TimeProvider.System, t).ConfigureAwait(false)|}; + {|#4:service.LoadAsync().WaitAsync(t)|}; + {|#5:service.LoadAsync().ConfigureAwait(false)|}; + await (dry ? held : {|#6:service.LoadAsync()|}).ConfigureAwait(false); + {|#7:service.LoadAsync().WaitAsync(t).Wait()|}; + } + + public static Task Handed(Service service, System.Threading.CancellationToken t) => {|#8:service.LoadAsync().WaitAsync(t)|}; + } + """, + Cmtk0003(0, "Result", "LoadAsync"), + Cmtk0003(1, "Result", "SaveAsync"), + Cmtk0003(2, "Result", "LoadAsync"), + Cmtk0003(3, "Result", "LoadAsync"), + Cmtk0003(4, "Result", "LoadAsync"), + Cmtk0003(5, "Result", "LoadAsync"), + Cmtk0003(6, "Result", "LoadAsync"), + Cmtk0003(7, "Result", "LoadAsync"), + Cmtk0003(8, "Result", "LoadAsync")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// Tap, TapAsync, TapError and TapErrorAsync return their receiver: /// dropping it loses nothing only when the receiver is still held. @@ -174,6 +286,97 @@ public static async Task Run(Service service, Result parameter) Cmtk0003(5, "Result", "TapErrorAsync")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A chain of taps on a stored result or task hands back what the caller still holds, link by + /// link: each Tap returns its receiver, and that receiver is the previous Tap, which + /// returned a stored one. So is a tap on what a held task completes with, awaited in the chain. A + /// chain that starts at a call, or passes through a combinator that makes a new result, drops it. + /// + [Fact] + public Task A_chain_of_taps_on_a_stored_result_or_task_is_silent_and_on_a_call_reports() => + Test( + """ + public static class Caller + { + private static Result _field = 1; + + public static async Task Run(Service service, Result parameter, Result command, Option option, Task> pending) + { + var local = service.Load(); + parameter.Tap(_ => { }).TapError(_ => { }); + local.Tap(_ => { }).Tap(_ => { }).TapError(_ => { }); + _field.TapError(_ => { }).Tap(_ => { }); + command.Tap(() => { }).TapError(_ => { }); + option.Tap(_ => { }).Tap(_ => { }); + await local.TapAsync(_ => Task.CompletedTask).TapErrorAsync(_ => { }); + await pending.TapAsync(_ => { }).TapErrorAsync(_ => { }); + await pending.TapAsync(_ => { }).TapErrorAsync(_ => Task.CompletedTask).ConfigureAwait(false); + await parameter.Tap(_ => { }).TapErrorAsync(_ => Task.CompletedTask); + + var held = await pending; + held.Tap(_ => { }).TapError(_ => { }); + (await pending).Tap(_ => { }).TapError(_ => { }); + (await pending.TapAsync(_ => { })).TapError(_ => { }); + (await pending.TapAsync(_ => { }).ConfigureAwait(false)).TapError(_ => { }); + await pending.WaitAsync(TimeSpan.FromSeconds(1)).TapAsync(_ => { }).TapErrorAsync(_ => { }); + + {|#0:service.Load().Tap(_ => { }).TapError(_ => { })|}; + {|#6:await service.LoadAsync().WaitAsync(TimeSpan.FromSeconds(1)).TapAsync(_ => { })|}; + {|#1:await service.LoadAsync().TapAsync(_ => { }).TapErrorAsync(_ => { })|}; + {|#2:(await service.LoadAsync().TapAsync(_ => { })).TapError(_ => { })|}; + {|#3:(await service.LoadAsync()).Tap(_ => { })|}; + {|#4:local.Map(x => x + 1).Tap(_ => { })|}; + {|#5:await pending.MapAsync(x => x + 1).TapAsync(_ => { })|}; + } + } + """, + Cmtk0003(0, "Result", "TapError"), + Cmtk0003(1, "Result", "TapErrorAsync"), + Cmtk0003(2, "Result", "TapError"), + Cmtk0003(3, "Result", "Tap"), + Cmtk0003(4, "Result", "Tap"), + Cmtk0003(5, "Result", "TapAsync"), + Cmtk0003(6, "Result", "TapAsync")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A tap on a stored result or task, converted to a plain Task or waited for, leaves the + /// outcome with the caller, as the same tap as a statement does: both were reported, on a held + /// Result and on a held Task of one. A tap on what a call made still drops it. + /// + [Fact] + public Task A_tap_on_a_stored_result_or_task_converted_to_a_plain_Task_or_waited_for_is_silent() => + Test( + """ + public static class Caller + { + private static Result _field = 1; + + public static Task FromResult(Result held) => held.TapAsync(_ => Task.CompletedTask); + public static Task FromTask(Task> pending) => pending.TapAsync(_ => { }); + public static Task Chained(Task> pending) => pending.TapAsync(_ => { }).TapErrorAsync(_ => { }); + public static Task Field() => _field.TapErrorAsync(_ => Task.CompletedTask); + public static Task Bounded(Task> pending) => pending.WaitAsync(TimeSpan.FromSeconds(1)).TapAsync(_ => { }); + public static Func Later(Task> pending) => () => pending.TapAsync(_ => { }); + public static Task Either(bool flag, Task> pending, Service service) => + flag ? pending.TapAsync(_ => { }) : {|#2:service.LoadAsync().TapAsync(_ => { })|}; + + public static Task Made(Service service) => {|#0:service.LoadAsync().TapAsync(_ => { })|}; + public static Task MadeFromResult(Service service) => {|#1:service.Load().TapAsync(_ => Task.CompletedTask)|}; + + public static void Blocked(Task> pending, Service service) + { + pending.TapAsync(_ => { }).Wait(); + {|#3:service.LoadAsync().TapAsync(_ => { }).Wait()|}; + } + } + """, + Cmtk0003(0, "Result", "TapAsync"), + Cmtk0003(1, "Result", "TapAsync"), + Cmtk0003(2, "Result", "TapAsync"), + Cmtk0003(3, "Result", "TapAsync")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// The Task continuations TapAsync and TapErrorAsync return the task's result /// unchanged, so awaiting them on a task the caller still holds drops nothing, as the instance @@ -207,6 +410,122 @@ public static async Task Run(Service service, Task> pending, Cmtk0003(1, "Result", "MapAsync")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A result task reached through a branch was silent: the ternary or the switch expression + /// that picked it, awaited or converted whole to a plain task; and a method group that answers a + /// collection of results, converted to a delegate that returns a plain task. + /// + [Fact] + public Task A_result_dropped_through_a_branch_or_a_collection_s_method_group_reports() => + Test( + """ + public static class Branches + { + public static async Task Run(Service service, bool dry, int id) + { + await (dry ? {|#0:service.CancelAsync(id)|} : {|#1:service.CancelAsync(0)|}); + await (id switch { 1 => {|#2:service.CancelAsync(1)|}, _ => {|#3:service.CancelAsync(2)|} }); + await (dry ? service.RunAsync() : Task.CompletedTask); + } + + public static Task Submit(Service service, bool draft, int id) => draft ? {|#4:service.CancelAsync(id)|} : {|#5:service.CancelAsync(0)|}; + + public static void Hold(Service service) + { + Func all = {|#6:service.SaveAllAsync|}; + Func run = service.RunAsync; + } + } + """, + Cmtk0003(0, "Result", "CancelAsync"), + Cmtk0003(1, "Result", "CancelAsync"), + Cmtk0003(2, "Result", "CancelAsync"), + Cmtk0003(3, "Result", "CancelAsync"), + Cmtk0003(4, "Result", "CancelAsync"), + Cmtk0003(5, "Result", "CancelAsync"), + Cmtk0003(6, "collection of Results", "SaveAllAsync")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// Neighbours of the branch, collection and conversion forms were silent: a call picked by + /// ??, awaited or converted to a plain task; a tuple that holds a result; a dictionary of + /// results, or a sequence of key-value pairs of them materialised. + /// + [Fact] + public Task A_result_dropped_through_a_coalesce_a_tuple_or_a_dictionary_reports() => + Test( + """ + public static class Neighbours + { + public static async Task Run(Service service, Task> held, int[] ids) + { + await (held ?? {|#0:service.LoadAsync()|}); + {|#1:service.Pair()|}; + {|#2:await service.PairAsync()|}; + {|#3:service.PairAsync()|}; + {|#4:service.Map(ids)|}; + {|#5:ids.ToDictionary(i => i, i => service.Save(i))|}; + {|#6:await service.MapAsync()|}; + {|#7:ids.Select(i => new KeyValuePair>(i, service.Save(i))).ToList()|}; + } + + public static Func Deferred(Service service, Task> held) => () => held ?? {|#8:service.LoadAsync()|}; + + public static Task Converted(Service service) => {|#9:service.PairAsync()|}; + + public static Task Mapped(Service service) => {|#10:service.MapAsync()|}; + + public static void Groups(Service service) + { + Func pairs = {|#11:service.PairAsync|}; + Func maps = {|#12:service.MapAsync|}; + } + } + """, + Cmtk0003(0, "Result", "LoadAsync"), + Cmtk0003(1, "tuple holding a Result", "Pair"), + Cmtk0003(2, "tuple holding a Result", "PairAsync"), + Cmtk0003(3, "tuple holding a Result", "PairAsync"), + Cmtk0003(4, "collection of Results", "Map"), + Cmtk0003(5, "collection of Results", "ToDictionary"), + Cmtk0003(6, "collection of Options", "MapAsync"), + Cmtk0003(7, "collection of Results", "ToList"), + Cmtk0003(8, "Result", "LoadAsync"), + Cmtk0003(9, "tuple holding a Result", "PairAsync"), + Cmtk0003(10, "collection of Options", "MapAsync"), + Cmtk0003(11, "tuple holding a Result", "PairAsync"), + Cmtk0003(12, "collection of Options", "MapAsync")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A coalesce of held tasks, a tuple or a dictionary that is used or discarded, and a copy of a + /// held dictionary or of a held tuple's result drop nothing. + /// + [Fact] + public Task A_coalesce_a_tuple_or_a_dictionary_that_is_held_or_used_stays_silent() => + Test( + """ + public static class Neighbours + { + public static async Task Run(Service service, Task> held, Task> other, Dictionary> saved, (Result, int) pair) + { + await (held ?? other); + _ = service.Pair(); + var (loaded, count) = service.Pair(); + _ = await service.PairAsync(); + _ = service.Map([1]); + var kept = await service.MapAsync(); + saved.ToDictionary(entry => entry.Key, entry => entry.Value); + saved.Where(entry => entry.Key > 0).ToList(); + ValueTuple.Create(pair.Item1, 1); + service.Counts().ToDictionary(c => c, c => c); + } + + public static Task Held(Task> held, Task> other) => held ?? other; + } + """) + .RunAsync(TestContext.Current.CancellationToken); + /// /// A Task of a result converts implicitly to Task, which drops the result as silently /// as a statement does: an expression-bodied member, a return, a lambda, an argument. @@ -417,6 +736,135 @@ public static async Task Run(Service service, int[] ids, List """) .RunAsync(TestContext.Current.CancellationToken); + /// + /// Wait blocks until the result exists and hands it to nobody, whatever reads what it returns; + /// Task.WaitAll over tasks the statement made converts them to plain ones; a lazy sequence a + /// call made without being given a result makes its results as it is materialised. + /// + [Fact] + public Task Waiting_for_or_materialising_results_the_statement_made_reports() => + Test( + """ + public static class Caller + { + public static async Task Run(Service service, int[] ids) + { + {|#0:service.CancelAsync(1).Wait()|}; + if ({|#1:service.FindAsync().Wait(100)|}) { } + {|#2:Task.WhenAll(service.CancelAsync(1), service.CancelAsync(2)).Wait()|}; + Task.WaitAll({|#3:ids.Select(service.CancelAsync)|}); + Task.WaitAll({|#4:ids.Select(i => service.CancelAsync(i)).ToArray()|}); + {|#5:service.SaveEach(ids).ToList()|}; + {|#6:await service.SaveEachAsync(ids).ToListAsync()|}; + Task.WaitAny({|#7:ids.Select(service.CancelAsync).ToArray()|}); + Drain({|#8:ids.Select(service.CancelAsync).ToArray()|}); + } + + private static void Drain(ReadOnlySpan tasks) { } + } + """, + Cmtk0003(0, "Result", "CancelAsync"), + Cmtk0003(1, "Option", "FindAsync"), + Cmtk0003(2, "collection of Results", "WhenAll"), + Cmtk0003(3, "collection of Results", "Select"), + Cmtk0003(4, "collection of Results", "ToArray"), + Cmtk0003(5, "collection of Results", "ToList"), + Cmtk0003(6, "collection of Results", "ToListAsync"), + Cmtk0003(7, "collection of Results", "ToArray"), + Cmtk0003(8, "collection of Results", "ToArray")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A task still held keeps its result after Wait, and so do held tasks after + /// Task.WaitAll; a plain task has none; a held sequence, or one filtered from what the + /// statement was given, made nothing; an explicit cast to plain tasks says the drop is intended. + /// + [Fact] + public Task Waiting_for_or_materialising_results_that_are_held_stays_silent() => + Test( + """ + public static class Caller + { + public static void Run(Service service, int[] ids, Task> held, List>> pending, IEnumerable> each) + { + var task = service.CancelAsync(1); + task.Wait(); + held.Wait(); + service.RunAsync().Wait(); + Task.WaitAll(pending); + Task.WaitAll(pending.ToArray()); + Task.WaitAll(task, held); + Task.WaitAll(ids.Select(_ => service.RunAsync())); + each.ToList(); + each.Where(result => result.TryGetError(out _)).ToList(); + _ = service.SaveEach(ids).ToList(); + var all = service.SaveEach(ids).ToList(); + Task.WaitAll((ReadOnlySpan)ids.Select(service.CancelAsync).ToArray()); + Task.WaitAll((IEnumerable)ids.Select(service.CancelAsync)); + } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A loop over results its header made, whose body never reads the iteration variable, drains + /// them: a call, a method group passed to Select, an asynchronous stream with its + /// cancellation, a sequence of tasks. + /// + [Fact] + public Task A_loop_that_never_reads_the_results_its_header_made_reports() => + Test( + """ + public static class Caller + { + public static async Task Run(Service service, int[] ids, System.Threading.CancellationToken token) + { + var count = 0; + foreach (var outcome in {|#0:service.SaveAll(ids)|}) { } + foreach (var outcome in {|#1:ids.Select(service.Save)|}) count++; + await foreach (var outcome in {|#2:service.SaveEachAsync(ids)|}) { } + await foreach (var outcome in {|#3:service.SaveEachAsync(ids).WithCancellation(token)|}) count++; + foreach (var pending in {|#4:ids.Select(service.CancelAsync)|}) { } + } + } + """, + Cmtk0003(0, "sequence of Results", "SaveAll"), + Cmtk0003(1, "sequence of Results", "Select"), + Cmtk0003(2, "sequence of Results", "SaveEachAsync"), + Cmtk0003(3, "sequence of Results", "WithCancellation"), + Cmtk0003(4, "sequence of Results", "Select")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A loop that reads each result, even only inside a lambda, handles them; a variable named + /// _ says the drop is intended; a held collection keeps its results; a loop over anything + /// else is not about results. + /// + [Fact] + public Task A_loop_that_reads_its_results_discards_them_or_walks_a_held_collection_stays_silent() => + Test( + """ + public static class Caller + { + public static async Task Run(Service service, int[] ids, List> loaded, IAsyncEnumerable> stream) + { + var errors = 0; + foreach (var outcome in service.SaveAll(ids)) if (outcome.TryGetError(out _)) errors++; + await foreach (var outcome in service.SaveEachAsync(ids)) _ = outcome; + foreach (var outcome in service.SaveEach(ids)) Run(() => outcome.TryGetError(out _)); + foreach (var _ in service.SaveAll(ids)) { } + await foreach (var _ in service.SaveEachAsync(ids)) { } + foreach (var outcome in loaded) { } + await foreach (var outcome in stream) { } + foreach (var outcome in loaded.Where(result => result.TryGetValue(out _, out _))) { } + foreach (var count in service.Counts()) { } + } + + private static void Run(Func check) { } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + /// /// A generated TryFrom on a value object of the same project does not bind where Metalama /// runs analyzers; CS0117 here reproduces that compilation. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/PatchIsDefaultableTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/PatchIsDefaultableTests.cs new file mode 100644 index 0000000..c26e9ed --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/PatchIsDefaultableTests.cs @@ -0,0 +1,34 @@ +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// Patch<T> is the one struct of the base package whose default means something: +/// absent. It carries no [RequireCustomInitialization], so the no-default rules leave it alone. +/// +public sealed class PatchIsDefaultableTests +{ + private const string Source = + """ + using CodoMetis.TypeKit; + + public sealed class Update + { + public Patch Name { get; set; } + + public Patch Written() => default; + public Patch Made() => new(); + public Patch[] Slots() => new Patch[3]; + } + """; + + [Fact] + public Task CMTK0001_is_silent_on_a_default_patch() => + new AnalyzerTest(Source).RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task CMTK0005_is_silent_on_an_array_of_patches() => + new AnalyzerTest(Source).RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task CMTK0006_is_silent_on_an_unassigned_patch_member() => + new AnalyzerTest(Source).RunAsync(TestContext.Current.CancellationToken); +} diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs new file mode 100644 index 0000000..6e9445c --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs @@ -0,0 +1,294 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0012: a closed set's Match given an argument by position, which reordering the members +/// swaps silently. +/// +/// +/// The verifier never weaves, so the sets below declare by hand what the generators would: the +/// woven one as it binds from another project, and one without Match as the project declaring +/// the set sees it, where Metalama runs analyzers before weaving and the call does not bind. +/// BuildOutcomeTests holds the second form to a real woven build. +/// +public sealed class PositionalMatchArgumentAnalyzerTests +{ + private const string Sets = + """ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using CodoMetis.TypeKit.ClosedSets; + + namespace Subjects + { + public sealed class Status : IClosedSet + { + public static readonly Status Open = new(); + public static readonly Status Shipped = new(); + public static readonly Status New = new(); + + private Status() { } + + public string Name => ""; + public string QualifiedName => ""; + public static IReadOnlyList All => [Open, Shipped, New]; + public static bool TryParse([NotNullWhen(true)] string? name, [MaybeNullWhen(false)] out Status member) { member = null; return false; } + + public TResult Match(Func open, Func shipped, Func @new) => open(); + + // An overload of another signature, the set's own. + public string Match(Func describe) => describe(this); + } + + // As the declaring project sees a set before weaving: no Match yet. + public sealed class Unwoven : IClosedSet + { + public static readonly Unwoven Low = new(); + public static readonly Unwoven High = new(); + + private Unwoven() { } + + public string Name => ""; + public string QualifiedName => ""; + public static IReadOnlyList All => [Low, High]; + public static bool TryParse([NotNullWhen(true)] string? name, [MaybeNullWhen(false)] out Unwoven member) { member = null; return false; } + } + + // Not a closed set: a Match of the same shape is nobody's concern here. + public sealed class Other + { + public TResult Match(Func a, Func b) => a(); + } + } + + """; + + private static AnalyzerTest Test(string code, params DiagnosticResult[] expected) + { + var test = new AnalyzerTest(Sets + code); + test.ExpectedDiagnostics.AddRange(expected); + return test; + } + + private static DiagnosticResult Cmtk0012(string set, string names) => Cmtk0012(0, set, names); + + private static DiagnosticResult Cmtk0012(int location, string set, string names) => + new DiagnosticResult("CMTK0012", DiagnosticSeverity.Error).WithLocation(location).WithArguments(set, names); + + [Fact] + public void The_descriptor_is_the_published_contract() + { + var rule = new PositionalMatchArgumentAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); + + rule.Id.ShouldBe("CMTK0012"); + rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Error); + rule.IsEnabledByDefault.ShouldBeTrue(); + } + + /// The fix lives in another assembly, so the id and the property name it reads are held to the analyzer's here. + [Fact] + public void The_code_fix_names_the_rule_and_the_property_the_analyzer_writes() + { + new PositionalMatchArgumentCodeFixProvider().FixableDiagnosticIds.ShouldBe(["CMTK0012"]); + PositionalMatchArgumentCodeFixProvider.NamesProperty.ShouldBe(PositionalMatchArgumentAnalyzer.NamesProperty); + } + + [Fact] + public Task Every_argument_named_is_silent() => + Test( + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match(shipped: () => 2, open: () => 1, @new: () => 3); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task An_argument_by_position_is_reported_with_every_name_a_keyword_escaped() => + Test( + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match({|#0:() => 1|}, () => 2, () => 3); + } + """, + new DiagnosticResult("CMTK0012", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage("'Status.Match' is given an argument by position, which binds to whichever member is declared at that place: reorder the members and the branches swap without a word. Name every argument (open:, shipped:, @new:).")) + .RunAsync(TestContext.Current.CancellationToken); + + /// C# 7.2 lets a named argument in its own position precede one by position; the one by position is still reported. + [Fact] + public Task One_argument_by_position_among_named_ones_is_reported_at_it() => + Test( + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match(open: () => 1, {|#0:() => 2|}, @new: () => 3); + } + """, + Cmtk0012("Status", "open:, shipped:, @new:")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_conditional_call_is_reported() => + Test( + """ + static class Use + { + static int? Pick(Subjects.Status? s) => s?.Match({|#0:() => 1|}, () => 2, () => 3); + } + """, + Cmtk0012("Status", "open:, shipped:, @new:")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A conditional call followed by another conditional access, s?.Match(…)?.ToUpper(): the + /// nearest ?. around the call is the one after it, whose receiver is the call's own result. + /// The receiver is the one whose ?. binds the call, bound or not. + /// + [Fact] + public Task A_conditional_call_followed_by_a_conditional_access_is_reported() => + Test( + """ + sealed class Holder + { + public Subjects.Status Status { get; set; } = Subjects.Status.Open; + } + + static class Use + { + static string Upper(Subjects.Status? s) => s?.Match({|#0:() => "a"|}, () => "b", () => "c")?.ToUpper(); + + static string Trimmed(Holder h) => h.Status?.Match({|#1:() => " a"|}, () => "b", () => "c")?.Trim(); + + static string Awaited(Subjects.Status? s) => s?.Match({|#2:() => System.Threading.Tasks.Task.FromResult("a")|}, () => System.Threading.Tasks.Task.FromResult("b"), () => System.Threading.Tasks.Task.FromResult("c"))?.Result; + + static string Unbound(Subjects.Unwoven? u) => u?{|CS1061:.Match|}({|#3:() => 1|}, () => 2)?.ToString(); + + static string Nested(Holder? h) => h?.Status?.Match({|#4:() => "a"|}, () => "b", () => "c")?.Trim()?.ToUpper(); + + static int? Length(Subjects.Status? s) => s?.Match({|#5:() => "a"|}, () => "b", () => "c").Length; + } + """, + Cmtk0012(0, "Status", "open:, shipped:, @new:"), + Cmtk0012(1, "Status", "open:, shipped:, @new:"), + Cmtk0012(2, "Status", "open:, shipped:, @new:"), + Cmtk0012(3, "Unwoven", "low:, high:"), + Cmtk0012(4, "Status", "open:, shipped:, @new:"), + Cmtk0012(5, "Status", "open:, shipped:, @new:")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// Before weaving, in the project that declares the set, the call does not bind; one argument per + /// member on a closed set is the generated Match, and the names are the generators' rule. + /// + [Fact] + public Task An_unbound_call_with_one_argument_per_member_is_reported_with_the_generators_names() => + Test( + """ + static class Use + { + static int Pick(Subjects.Unwoven u) => u.{|CS1061:Match|}({|#0:() => 1|}, () => 2); + } + """, + Cmtk0012("Unwoven", "low:, high:")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task An_unbound_call_with_another_count_is_not_taken_for_the_generated_Match() => + Test( + """ + static class Use + { + static int Pick(Subjects.Unwoven u) => u.{|CS1061:Match|}(() => 1); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task An_overload_the_set_declares_and_a_Match_of_another_type_are_silent() => + Test( + """ + static class Use + { + static string Describe(Subjects.Status s) => s.Match(status => status.Name); + + static int Pick(Subjects.Other o) => o.Match(() => 1, () => 2); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_names_each_argument_by_position_after_the_parameter_it_binds_to_now() => + new CodeFixTest( + Sets + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match( + {|#0:() => 1|}, + shipped: () => 2, + () => 3); + } + """, + Sets + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match( + open: () => 1, + shipped: () => 2, + @new: () => 3); + } + """) + { + ExpectedDiagnostics = { Cmtk0012("Status", "open:, shipped:, @new:") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_names_the_arguments_of_a_conditional_call_followed_by_a_conditional_access() => + new CodeFixTest( + Sets + """ + sealed class Holder + { + public Subjects.Status Status { get; set; } = Subjects.Status.Open; + } + + static class Use + { + static string Upper(Subjects.Status? s) => s?.Match({|#0:() => "a"|}, () => "b", () => "c")?.ToUpper(); + + static string Trimmed(Holder h) => h.Status?.Match({|#1:() => " a"|}, () => "b", () => "c")?.Trim(); + + static int? Unbound(Subjects.Unwoven? u) => u?{|CS1061:.Match|}({|#2:() => 1|}, () => 2)?.GetHashCode(); + } + """, + Sets + """ + sealed class Holder + { + public Subjects.Status Status { get; set; } = Subjects.Status.Open; + } + + static class Use + { + static string Upper(Subjects.Status? s) => s?.Match(open: () => "a", shipped: () => "b", @new: () => "c")?.ToUpper(); + + static string Trimmed(Holder h) => h.Status?.Match(open: () => " a", shipped: () => "b", @new: () => "c")?.Trim(); + + static int? Unbound(Subjects.Unwoven? u) => u?{|CS1061:.Match|}(low: () => 1, high: () => 2)?.GetHashCode(); + } + """) + { + ExpectedDiagnostics = + { + Cmtk0012(0, "Status", "open:, shipped:, @new:"), + Cmtk0012(1, "Status", "open:, shipped:, @new:"), + Cmtk0012(2, "Unwoven", "low:, high:"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); +} diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs new file mode 100644 index 0000000..c6ee004 --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs @@ -0,0 +1,829 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0011: a concrete type under [RequireParameterlessConstructor] without the constructor +/// it requires. The host would find out by reflection, at run time; this rule says so at the type. +/// +public sealed class RequiredParameterlessConstructorAnalyzerTests +{ + /// + /// The marked types, present in every test: an interface and an abstract class carry the + /// requirement and are never reported themselves, and a derived interface forwards it. + /// + private const string Subjects = + """ + using CodoMetis.TypeKit; + + namespace Subjects + { + [RequireParameterlessConstructor] + public interface IEndpoint { } + + public interface IQueryEndpoint : IEndpoint { } + + [RequireParameterlessConstructor(ConstructorAccessibility.Internal)] + public abstract class Module { } + + [RequireParameterlessConstructor(ConstructorAccessibility.Private)] + public interface IActivated { } + + [RequireParameterlessConstructor(ConstructorAccessibility.ProtectedInternal)] + public abstract class Widget { } + + [RequireParameterlessConstructor(ConstructorAccessibility.PrivateProtected)] + public abstract class Gadget { } + } + + """; + + private static AnalyzerTest Test(string declarations, params DiagnosticResult[] expected) + { + var test = new AnalyzerTest(Subjects + declarations); + test.ExpectedDiagnostics.AddRange(expected); + return test; + } + + private static DiagnosticResult Cmtk0011(string type, string state, string source, string required) => + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(0).WithArguments(type, state, source, required); + + [Fact] + public void The_descriptor_is_the_published_contract() + { + var rule = new RequiredParameterlessConstructorAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); + + rule.Id.ShouldBe("CMTK0011"); + rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Error); + rule.IsEnabledByDefault.ShouldBeTrue(); + } + + /// The fix lives in another assembly, so the id and the property name it reads are held to the analyzer's here. + [Fact] + public void The_code_fix_names_the_rule_and_the_property_the_analyzer_writes() + { + new RequiredParameterlessConstructorCodeFixProvider().FixableDiagnosticIds.ShouldBe(["CMTK0011"]); + RequiredParameterlessConstructorCodeFixProvider.RequiredProperty.ShouldBe(RequiredParameterlessConstructorAnalyzer.RequiredProperty); + } + + [Fact] + public Task A_class_without_any_constructor_has_the_public_one_and_is_silent() => + Test("public sealed class Orders : Subjects.IEndpoint { }").RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_class_with_only_a_parameterised_constructor_reports_on_its_name() => + Test( + """ + public sealed class {|#0:Orders|} : Subjects.IEndpoint + { + public Orders(int port) { } + } + """, + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage("'Orders' declares no parameterless constructor, but IEndpoint requires one that is public")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_wrong_accessibility_reports_on_the_constructor() => + Test( + """ + public sealed class Orders : Subjects.IEndpoint + { + private {|#0:Orders|}() { } + } + """, + Cmtk0011("Orders", "declares a private parameterless constructor", "IEndpoint", "public")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The state names the declared accessibility with its article, as English writes it: "an + /// internal", "a protected internal". Every accessibility C# lets a class's constructor have. + /// + [Theory] + [InlineData("internal", "declares an internal parameterless constructor")] + [InlineData("protected", "declares a protected parameterless constructor")] + [InlineData("protected internal", "declares a protected internal parameterless constructor")] + [InlineData("private protected", "declares a private protected parameterless constructor")] + [InlineData("private", "declares a private parameterless constructor")] + public Task The_message_names_the_declared_accessibility_with_its_article(string declared, string state) => + Test( + $$""" + public class Orders : Subjects.IEndpoint + { + {{declared}} {|#0:Orders|}() { } + } + """, + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage($"'Orders' {state}, but IEndpoint requires one that is public")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_public_constructor_against_a_non_public_requirement_says_a_public() => + Test( + """ + public class Identity : Subjects.Module + { + public {|#0:Identity|}() { } + } + """, + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage("'Identity' declares a public parameterless constructor, but Module requires one that is internal")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The two accessibilities of two keywords, read from the attribute's enum by position (3 and 4): + /// each is satisfied by its own constructor, in either keyword order, and not by the other's. + /// + [Fact] + public Task Protected_internal_and_private_protected_are_each_required_as_written() => + Test( + """ + public class WidgetOne : Subjects.Widget { protected internal WidgetOne() { } } + + public class WidgetTwo : Subjects.Widget { internal protected WidgetTwo() { } } + + public class GadgetOne : Subjects.Gadget { private protected GadgetOne() { } } + + public class GadgetTwo : Subjects.Gadget { protected private GadgetTwo() { } } + + public class WidgetWrong : Subjects.Widget { private protected {|#0:WidgetWrong|}() { } } + + public class GadgetWrong : Subjects.Gadget { protected internal {|#1:GadgetWrong|}() { } } + + public class {|#2:WidgetImplicit|} : Subjects.Widget { } + + public class {|#3:GadgetImplicit|} : Subjects.Gadget { } + """, + Cmtk0011("WidgetWrong", "declares a private protected parameterless constructor", "Widget", "protected internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("GadgetWrong", "declares a protected internal parameterless constructor", "Gadget", "private protected"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(2).WithArguments("WidgetImplicit", "has the implicit public parameterless constructor", "Widget", "protected internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(3).WithArguments("GadgetImplicit", "has the implicit public parameterless constructor", "Gadget", "private protected")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A struct always has a public parameterless constructor, the implicit one or one it declares, and + /// can have no other (CS8958). It satisfies Public as it is, and no other accessibility can + /// be asked of it, so no requirement is reported on it: an Error its author could answer only by a + /// suppression would be no rule. + /// + [Theory] + [InlineData("Public")] + [InlineData("Internal")] + [InlineData("Protected")] + [InlineData("ProtectedInternal")] + [InlineData("PrivateProtected")] + [InlineData("Private")] + public Task A_struct_or_record_struct_is_never_reported(string accessibility) => + Test( + $$""" + [RequireParameterlessConstructor(ConstructorAccessibility.{{accessibility}})] + public interface IJob { } + + public struct Implicit : IJob { } + + public struct Declared : IJob { public Declared() { } } + + public struct Parameterised : IJob { public Parameterised(int port) { } } + + public record struct Record : IJob; + + public readonly record struct DeclaredRecord : IJob { public DeclaredRecord() { } } + + public record struct Positional(int Port) : IJob; + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_implicit_public_constructor_does_not_satisfy_a_non_public_requirement() => + Test( + "public sealed class {|#0:Identity|} : Subjects.Module { }", + Cmtk0011("Identity", "has the implicit public parameterless constructor", "Module", "internal")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_required_accessibility_is_the_attribute_s() => + Test( + """ + public sealed class Identity : Subjects.Module + { + internal Identity() { } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_requirement_reaches_through_a_derived_interface_and_a_base_class() => + Test( + """ + public abstract class EndpointBase : Subjects.IQueryEndpoint { } + + public sealed class {|#0:Orders|} : EndpointBase + { + public Orders(int port) { } + } + """, + Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_record_with_a_primary_constructor_has_no_parameterless_one() => + Test( + "public sealed record {|#0:Orders|}(int Port) : Subjects.IEndpoint;", + Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_struct_has_the_public_constructor_and_is_silent_for_public() => + Test("public struct Ping : Subjects.IEndpoint { }").RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task Two_requirements_of_different_accessibility_each_report_once() => + Test( + """ + public sealed class {|#0:Both|} : Subjects.Module, Subjects.IActivated + { + public Both(int x) { } + } + """, + Cmtk0011("Both", "declares no parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(0).WithArguments("Both", "declares no parameterless constructor", "IActivated", "private")) + .RunAsync(TestContext.Current.CancellationToken); + + /// The subjects alone: the marked interface and abstract class are never reported. + [Fact] + public Task The_marked_types_themselves_are_silent() => + Test("").RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_type_under_no_marked_type_is_left_alone() => + Test( + """ + public sealed class Free + { + private Free() { } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// Robustness, not a guard: without CodoMetis.TypeKit there is nothing to look at, and nothing may fail. + [Fact] + public Task A_compilation_without_TypeKit_is_left_alone() => + new AnalyzerTest("public struct S { }", referenceTypeKit: false) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_adds_the_constructor_a_class_lacks() => + new CodeFixTest( + Subjects + """ + public sealed class {|#0:Orders|} : Subjects.IEndpoint + { + public Orders(int port) { } + } + """, + Subjects + """ + public sealed class Orders : Subjects.IEndpoint + { + public Orders() + { + } + + public Orders(int port) { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_changes_a_wrong_accessibility_in_place() => + new CodeFixTest( + Subjects + """ + public sealed class Identity : Subjects.Module + { + public {|#0:Identity|}() { } + } + """, + Subjects + """ + public sealed class Identity : Subjects.Module + { + internal Identity() { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Identity", "declares a public parameterless constructor", "Module", "internal") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_writes_the_non_public_constructor_a_class_only_has_implicitly() => + new CodeFixTest( + Subjects + "public sealed class {|#0:Identity|} : Subjects.Module { }", + Subjects + "public sealed class Identity : Subjects.Module { internal Identity() { } }") + { + ExpectedDiagnostics = { Cmtk0011("Identity", "has the implicit public parameterless constructor", "Module", "internal") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// The two accessibilities of two keywords, added as C# writes them. + [Theory] + [InlineData("Widget", "protected internal")] + [InlineData("Gadget", "private protected")] + public Task The_fix_adds_a_constructor_of_two_keywords(string marked, string modifiers) => + new CodeFixTest( + Subjects + $$""" + public class {|#0:Orders|} : Subjects.{{marked}} + { + public Orders(int port) { } + } + """, + Subjects + $$""" + public class Orders : Subjects.{{marked}} + { + {{modifiers}} Orders() + { + } + + public Orders(int port) { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", marked, modifiers) }, + CodeActionVerifier = (action, verifier) => verifier.Equal($"Add a {modifiers} parameterless constructor", action.Title), + } + .RunAsync(TestContext.Current.CancellationToken); + + /// Each keyword of the wrong accessibility goes, whichever accessibility of one or two keywords replaces it. + [Theory] + [InlineData("Widget", "public", "a public", "protected internal")] + [InlineData("Widget", "private protected", "a private protected", "protected internal")] + [InlineData("Gadget", "protected internal", "a protected internal", "private protected")] + [InlineData("Gadget", "internal", "an internal", "private protected")] + public Task The_fix_changes_a_constructor_to_one_of_two_keywords(string marked, string declared, string named, string required) => + new CodeFixTest( + Subjects + $$""" + public class Orders : Subjects.{{marked}} + { + {{declared}} {|#0:Orders|}() { } + } + """, + Subjects + $$""" + public class Orders : Subjects.{{marked}} + { + {{required}} Orders() { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", $"declares {named} parameterless constructor", marked, required) }, + CodeActionVerifier = (action, verifier) => verifier.Equal($"Make the constructor {required}", action.Title), + } + .RunAsync(TestContext.Current.CancellationToken); + + /// The action's title agrees its article with the accessibility, as the message does. + [Fact] + public Task The_fix_s_title_says_an_internal() => + new CodeFixTest( + Subjects + "public sealed class {|#0:Identity|} : Subjects.Module { }", + Subjects + "public sealed class Identity : Subjects.Module { internal Identity() { } }") + { + ExpectedDiagnostics = { Cmtk0011("Identity", "has the implicit public parameterless constructor", "Module", "internal") }, + CodeActionVerifier = (action, verifier) => verifier.Equal("Add an internal parameterless constructor", action.Title), + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The rule reports no struct, so the fix never meets one. Handed a diagnostic there all the same, + /// as the rule gave one before 2.0.0 was tagged, it offers nothing: internal S() { } in a + /// struct is CS8958. The diagnostics are made here, at each place the rule reported, with the + /// rule's own descriptor; a class beside them gets its fix, so the property they carry is one the + /// fix reads. + /// + [Fact] + public async Task The_fix_offers_nothing_on_a_struct() + { + const string source = + """ + public interface IJob { } + + public struct Implicit : IJob { } + + public struct Declared : IJob { public Declared() { } } + + public record struct Record : IJob; + + public readonly record struct DeclaredRecord : IJob { public DeclaredRecord() { } } + + public sealed class Control : IJob { public Control(int port) { } } + """; + + var token = TestContext.Current.CancellationToken; + var rule = new RequiredParameterlessConstructorAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); + + using var workspace = new AdhocWorkspace(); + var document = workspace.AddProject("Structs", LanguageNames.CSharp).AddDocument("Structs.cs", source); + var root = await document.GetSyntaxRootAsync(token) ?? throw new InvalidOperationException("No syntax root."); + + // Where the rule reported: at a declared parameterless constructor, or at the type. + var places = root.DescendantNodes().OfType() + .Where(type => type.BaseList is not null) + .Select(type => (type.Identifier.ValueText, + Location: type.Members.OfType() + .FirstOrDefault(constructor => constructor.ParameterList.Parameters.Count == 0)?.Identifier.GetLocation() + ?? type.Identifier.GetLocation())) + .ToList(); + + places.Count.ShouldBe(5, "The source's types were not found, so this test checks nothing."); + + foreach (var (name, location) in places) + { + var diagnostic = Diagnostic.Create( + rule, + location, + ImmutableDictionary.Empty.Add(RequiredParameterlessConstructorAnalyzer.RequiredProperty, nameof(Accessibility.Internal)), + name, + "declares no parameterless constructor", + "IJob", + "internal"); + + var actions = new List(); + await new RequiredParameterlessConstructorCodeFixProvider().RegisterCodeFixesAsync( + new Microsoft.CodeAnalysis.CodeFixes.CodeFixContext(document, diagnostic, (action, _) => actions.Add(action.Title), token)); + + if (name == "Control") + actions.ShouldBe(["Add an internal parameterless constructor"], "The class beside the structs got no fix, so the diagnostics here carry nothing the fix reads."); + else + actions.ShouldBeEmpty($"The fix offers '{string.Join("', '", actions)}' on the struct {name}, which can have no parameterless constructor but a public one (CS8958)."); + } + } + + /// Every other constructor of a type with a primary constructor chains to it, with arguments only the author knows. + [Fact] + public Task A_type_with_a_primary_constructor_gets_no_fix() => + new CodeFixTest( + Subjects + "public sealed record {|#0:Orders|}(int Port) : Subjects.IEndpoint;", + Subjects + "public sealed record {|#0:Orders|}(int Port) : Subjects.IEndpoint;") + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + FixedState = { ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") } }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A primary constructor in another part of a partial type binds every constructor of the type, the + /// one the fix would add included (CS8862), so the fix is withheld there as it is in the same part. + /// The rule reports at the first part, which has no parameter list. + /// + [Fact] + public Task A_partial_type_whose_other_part_has_a_primary_constructor_gets_no_fix() + { + const string first = "public sealed partial class {|#0:Orders|} : Subjects.IEndpoint { }"; + const string second = "public sealed partial class Orders(int port) { public int Port => port; }"; + + return new CodeFixTest(Subjects + first, Subjects + first) + { + TestState = { Sources = { ("Second.cs", second) } }, + FixedState = { Sources = { ("Second.cs", second) }, ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") } }, + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + } + + /// + /// The constructor the fix adds calls base() implicitly, so it is withheld where that call + /// does not bind: a base with only a constructor that takes arguments (CS7036), one whose + /// parameterless constructor the type cannot reach (CS0122), and one where base() could be + /// either of two constructors (CS0121). + /// + [Theory] + [InlineData("protected Base(int id) { }")] + [InlineData("private Base() { } protected Base(int id) { }")] + [InlineData("protected Base(int id = 0) { } protected Base(string name = \"\") { }")] + public Task A_type_whose_base_has_no_constructor_base_reaches_gets_no_fix(string constructors) + { + var source = Subjects + $$""" + public abstract class Base { {{constructors}} } + + public sealed class {|#0:Orders|} : Base, Subjects.IEndpoint + { + public Orders(int port) : base(port) { } + } + """; + + return new CodeFixTest(source, source) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + FixedState = { ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") } }, + } + .RunAsync(TestContext.Current.CancellationToken); + } + + /// + /// Where base() binds, the fix is offered: to a parameterless constructor beside others, to one + /// whose parameters are all optional or a params array. + /// The fixed code compiles, or the test fails. + /// + [Theory] + [InlineData("protected Base() { } protected Base(int id) { }")] + [InlineData("protected Base(int id = 0) { }")] + [InlineData("protected Base(params int[] ids) { }")] + public Task A_type_whose_base_has_a_constructor_base_reaches_gets_the_fix(string constructors) => + new CodeFixTest( + Subjects + $$""" + public abstract class Base { {{constructors}} } + + public sealed class {|#0:Orders|} : Base, Subjects.IEndpoint + { + public Orders(int port) { } + } + """, + Subjects + $$""" + public abstract class Base { {{constructors}} } + + public sealed class Orders : Base, Subjects.IEndpoint + { + public Orders() + { + } + + public Orders(int port) { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// A private parameterless constructor is reached by a type nested in its own. + [Fact] + public Task A_nested_type_reaching_its_container_s_private_constructor_gets_the_fix() => + new CodeFixTest( + Subjects + """ + public class Base + { + private Base() { } + + public Base(int id) { } + + public sealed class {|#0:Orders|} : Base, Subjects.IEndpoint + { + public Orders(int port) : base(port) { } + } + } + """, + Subjects + """ + public class Base + { + private Base() { } + + public Base(int id) { } + + public sealed class Orders : Base, Subjects.IEndpoint + { + public Orders() + { + } + + public Orders(int port) : base(port) { } + } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The fix adds a constructor and touches nothing else: the type's doc comment, its attributes and a + /// comment beside one stay where they are, and the members keep their layout. + /// + [Fact] + public Task The_fix_keeps_the_type_s_doc_comment_attributes_and_comments() => + new CodeFixTest( + Subjects + """ + /// The orders endpoint. + [System.Serializable] // the host serializes it + [System.Diagnostics.DebuggerDisplay("Orders")] + public sealed class {|#0:Orders|} : Subjects.IEndpoint // mapped by the host + { + // the port the host gives + public Orders(int port) { } + + public int Port { get; } + } + """, + Subjects + """ + /// The orders endpoint. + [System.Serializable] // the host serializes it + [System.Diagnostics.DebuggerDisplay("Orders")] + public sealed class Orders : Subjects.IEndpoint // mapped by the host + { + public Orders() + { + } + + // the port the host gives + public Orders(int port) { } + + public int Port { get; } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A constructor without modifiers keeps its doc comment, its attribute and a comment beside it where + /// they are: the accessibility goes before the name, after the attributes, and so does a comment + /// after a modifier that is replaced. + /// + [Fact] + public Task The_fix_keeps_the_constructor_s_doc_comment_attributes_and_comments() => + new CodeFixTest( + Subjects + """ + public class Identity : Subjects.Module + { + /// For the host. + [System.Obsolete("For the host")] // not for code + {|#0:Identity|}() { } + } + + public class Inline : Subjects.Module + { + [System.Obsolete("For the host")] {|#1:Inline|}() { } + } + + public class Commented : Subjects.Module + { + /// For the host. + protected /* the host's */ {|#2:Commented|}() { } + } + """, + Subjects + """ + public class Identity : Subjects.Module + { + /// For the host. + [System.Obsolete("For the host")] // not for code + internal Identity() { } + } + + public class Inline : Subjects.Module + { + [System.Obsolete("For the host")] internal Inline() { } + } + + public class Commented : Subjects.Module + { + /// For the host. + internal /* the host's */ Commented() { } + } + """) + { + ExpectedDiagnostics = + { + Cmtk0011("Identity", "declares a private parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("Inline", "declares a private parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(2).WithArguments("Commented", "declares a protected parameterless constructor", "Module", "internal"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// The added constructor is indented as the members around it are, with tabs where they use tabs. + [Fact] + public Task The_fix_indents_as_the_members_do() => + new CodeFixTest( + Subjects + "public class {|#0:Orders|} : Subjects.IEndpoint\n{\n\tpublic int Port;\n\n\tpublic Orders(int port) { Port = port; }\n}\n", + Subjects + "public class Orders : Subjects.IEndpoint\n{\n\tpublic Orders()\n\t{\n\t}\n\n\tpublic int Port;\n\n\tpublic Orders(int port) { Port = port; }\n}\n") + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A body with no member is indented one level inside its braces, a nested and generic type's + /// included; the constructor is named as the type is written, a keyword escaped. + /// + [Fact] + public Task The_fix_indents_an_empty_body_one_level_inside_its_braces() => + new CodeFixTest( + Subjects + """ + public class Outer + { + public sealed class {|#0:Inner|} : Subjects.Module + { + } + } + + public sealed class {|#1:@event|} : Subjects.Module + { + } + """, + Subjects + """ + public class Outer + { + public sealed class Inner : Subjects.Module + { + internal Inner() + { + } + } + } + + public sealed class @event : Subjects.Module + { + internal @event() + { + } + } + """) + { + ExpectedDiagnostics = + { + Cmtk0011("Inner", "has the implicit public parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("event", "has the implicit public parameterless constructor", "Module", "internal"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A file with CRLF line endings stays CRLF throughout: the lines the fix adds end as the file's + /// do, whatever the machine running the fix uses. + /// + [Fact] + public Task The_fix_keeps_the_file_s_line_endings() => + new CodeFixTest( + (Subjects + """ + public sealed class {|#0:Orders|} : Subjects.IEndpoint + { + public Orders(int port) { } + } + + public class Identity : Subjects.Module + { + public {|#1:Identity|}() { } + } + + """).ReplaceLineEndings("\r\n"), + (Subjects + """ + public sealed class Orders : Subjects.IEndpoint + { + public Orders() + { + } + + public Orders(int port) { } + } + + public class Identity : Subjects.Module + { + internal Identity() { } + } + + """).ReplaceLineEndings("\r\n")) + { + // One add and one change, each a fix-all pass of its own. + NumberOfFixAllIterations = 2, + ExpectedDiagnostics = + { + Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("Identity", "declares a public parameterless constructor", "Module", "internal"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A type written on one line gets the constructor on that line, and one ending in ; gets a + /// body there to hold it, the comment after the ; kept after the body. + /// + [Fact] + public Task The_fix_keeps_a_type_on_one_line_on_its_line() => + new CodeFixTest( + Subjects + """ + public sealed class {|#0:Importer|} : Subjects.Module; // found by scanning + + public sealed record {|#1:Activated|} : Subjects.IActivated; + + public class {|#2:Gauge|} : Subjects.Widget { public Gauge(int size) { } } + """, + Subjects + """ + public sealed class Importer : Subjects.Module { internal Importer() { } } // found by scanning + + public sealed record Activated : Subjects.IActivated { private Activated() { } } + + public class Gauge : Subjects.Widget { protected internal Gauge() { } public Gauge(int size) { } } + """) + { + ExpectedDiagnostics = + { + Cmtk0011("Importer", "has the implicit public parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("Activated", "has the implicit public parameterless constructor", "IActivated", "private"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(2).WithArguments("Gauge", "declares no parameterless constructor", "Widget", "protected internal"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); +} diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs new file mode 100644 index 0000000..8aa2d8c --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs @@ -0,0 +1,596 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0010: a type argument that breaks what its parameter declares by attribute. Each claim of +/// spikes/TypeArgumentConstraints is a test here. +/// +public sealed class TypeArgumentConstraintAnalyzerTests +{ + /// The generic types and the candidates, present in every test. + private const string Subjects = + """ + using System; + using CodoMetis.TypeKit; + using Subjects; + + namespace Subjects + { + public abstract record Command; + public sealed record PlaceOrder : Command; + public abstract class Handler { } + public interface IHandler { } + public sealed class OrderHandler : Handler { } + public struct Point { } + public static class Helpers { } + + public abstract class Decider<[Abstract] TCommand, [Record] TEvent> { } + + public sealed class Box<[Sealed] T> { } + + public sealed class Factory<[Concrete] T> { } + + public static class Runner + { + public static void Run<[Abstract] T>(T command) { } + public static void Seal<[Sealed] T>(T value) { } + } + } + + """; + + private static AnalyzerTest Test(string declarations, params DiagnosticResult[] expected) + { + var test = new AnalyzerTest(Subjects + declarations); + test.ExpectedDiagnostics.AddRange(expected); + return test; + } + + private static DiagnosticResult Cmtk0010(int location, string argument, string parameter, string owner, string breach) => + new DiagnosticResult("CMTK0010", DiagnosticSeverity.Error).WithLocation(location).WithArguments(argument, parameter, owner, breach); + + [Fact] + public void The_descriptor_is_the_published_contract() + { + var rule = new TypeArgumentConstraintAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); + + rule.Id.ShouldBe("CMTK0010"); + rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Error); + rule.IsEnabledByDefault.ShouldBeTrue(); + } + + [Fact] + public Task A_base_type_is_reported_at_the_argument_that_breaks_it() => + Test( + "public sealed class OrderDecider : Decider<{|#0:PlaceOrder|}, PlaceOrder> { }", + new DiagnosticResult("CMTK0010", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage("'PlaceOrder', the type argument for 'TCommand' of 'Decider', is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task Members_parameters_returns_locals_and_typeof_are_each_reported() => + Test( + """ + public sealed class Uses + { + private Box<{|#0:Handler|}> _field; + public Box<{|#1:Handler|}> Property { get; set; } + public Box<{|#2:Handler|}> Method(Box<{|#3:Handler|}> parameter) + { + Box<{|#4:Handler|}> local = null!; + var type = typeof(Box<{|#5:Handler|}>); + return local; + } + } + """, + Cmtk0010(0, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(1, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(2, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(3, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(4, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(5, "Handler", "T", "Box", "is not sealed")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_call_with_written_type_arguments_is_reported_at_the_argument_once() => + Test( + """ + public static class Calls + { + public static void Go() => Runner.Run<{|#0:PlaceOrder|}>(new PlaceOrder()); + } + """, + Cmtk0010(0, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_call_with_inferred_type_arguments_is_reported_at_the_call() => + Test( + """ + public static class Calls + { + public static void Go() => {|#0:Runner.Run(new PlaceOrder())|}; + } + """, + Cmtk0010(0, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A method group whose type arguments are inferred from the delegate it converts to has no + /// generic name and is no call: reported at the group, wherever it converts, a local function's + /// included. One with its arguments written is the generic name's, reported there once. + /// + [Fact] + public Task A_method_group_with_inferred_type_arguments_is_reported_at_the_group() => + Test( + """ + public static class Groups + { + public static void Register(Action handler) { } + + public static void Go() + { + Action run = {|#0:Runner.Run|}; + Register({|#1:Runner.Run|}); + var cast = (Action){|#2:Runner.Run|}; + Action seal = Runner.Seal; + Action sealedHandler = Runner.Seal; + Action open = {|#3:Runner.Seal|}; + Action local = {|#4:Local|}; + Action written = Runner.Run<{|#5:PlaceOrder|}>; + + static void Local<[Abstract] T>(T command) { } + } + + public static void Fine() + { + Action run = Runner.Run; + Register(Runner.Run); + Func> later = () => Runner.Run; + } + } + """, + Cmtk0010(0, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record"), + Cmtk0010(1, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record"), + Cmtk0010(2, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record"), + Cmtk0010(3, "Handler", "T", "Runner.Seal", "is not sealed"), + Cmtk0010(4, "PlaceOrder", "T", "Groups.Local", "is not an abstract class or record"), + Cmtk0010(5, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + /// A forwarding parameter carries the attribute, or the requirement ends at it. + [Fact] + public Task A_forwarding_type_parameter_must_carry_the_attribute() => + Test( + """ + public abstract class Leaky : Decider<{|#0:TCommand|}, PlaceOrder> { } + + public abstract class Sound<[Abstract] TCommand> : Decider { } + + public sealed class Closed : Sound<{|#1:PlaceOrder|}> { } + """, + Cmtk0010(0, "TCommand", "TCommand", "Decider", "is a type parameter that does not carry [Abstract]"), + Cmtk0010(1, "PlaceOrder", "TCommand", "Sound", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_struct_or_new_constraint_proves_sealed_and_concrete() => + Test( + """ + public static class Proofs + { + public static Box Boxed() where T : struct => new(); + public static Factory Made() where T : new() => new(); + public static Factory<{|#0:T|}> Unproven() where T : class => new(); + } + """, + Cmtk0010(0, "T", "T", "Factory", "is a type parameter that does not carry [Concrete]")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// [Concrete] is a type that is not abstract, static or an interface, the type a host can + /// instantiate. An enum is a value type, which new() and struct both accept, and a + /// struct parameter that proves [Concrete] can be one; a delegate is a sealed class, + /// made from a target and a method, as a class with only a parameterised constructor is made from + /// its arguments; dynamic is object. Their abstract bases are not. + /// + [Fact] + public Task Concrete_accepts_an_enum_a_delegate_and_dynamic_and_refuses_their_abstract_bases() => + Test( + """ + public delegate void Handled(Handler handler); + + public static class Instantiable + { + public static Factory Enum; + public static Factory Delegate; + public static Factory> FrameworkDelegate; + public static Factory Dynamic; + public static Factory Object; + public static Factory NullableStruct; + public static Factory<(int, string)> Tuple; + public static Factory<{|#0:System.Enum|}> EnumBase; + public static Factory<{|#1:System.Delegate|}> DelegateBase; + public static Factory<{|#2:MulticastDelegate|}> MulticastBase; + public static Factory<{|#3:ValueType|}> ValueTypeBase; + } + """, + Cmtk0010(0, "Enum", "T", "Factory", "is not a concrete class, record or struct"), + Cmtk0010(1, "Delegate", "T", "Factory", "is not a concrete class, record or struct"), + Cmtk0010(2, "MulticastDelegate", "T", "Factory", "is not a concrete class, record or struct"), + Cmtk0010(3, "ValueType", "T", "Factory", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A forwarding parameter whose own constraints already prove what the attribute asks needs no + /// attribute. Only records derive from records (CS8865), so a parameter bound to a record, or to a + /// parameter that proves [Record], is a record. A sealed type has no other subtype, so a + /// parameter bound to one that proves [Sealed] is that type. A sealed type is never abstract, + /// so whatever proves [Sealed] proves [Concrete], unmanaged included. Nothing + /// but the attribute proves [Abstract]: C# has no constraint that excludes a concrete type. + /// + [Fact] + public Task A_constraint_that_already_proves_the_attribute_is_a_proof() => + Test( + """ + public sealed class Journal<[Record] T> { } + + public static class Proven + { + public static Journal BoundToARecord() where T : Command => new(); + public static Journal BoundThroughAParameter() where T : U where U : Command => new(); + public static Journal BoundToARecordParameter() where T : U => new(); + public static Box BoundToASealedParameter() where T : U => new(); + public static Factory SealedIsConcrete<[Sealed] T>() => new(); + public static Factory BoundToASealedParameterIsConcrete() where T : U => new(); + public static Box Unmanaged() where T : unmanaged => new(); + public static Factory UnmanagedIsConcrete() where T : unmanaged => new(); + } + + public static class Unproven + { + public static Journal<{|#0:T|}> BoundToAClass() where T : Handler => new(); + public static Decider<{|#1:T|}, PlaceOrder> BoundToAnAbstractRecord() where T : Command => null!; + public static Decider<{|#2:T|}, PlaceOrder> BoundToAnAbstractParameter() where T : U => null!; + public static Factory<{|#3:T|}> BoundToAConcreteParameter() where T : U => new(); + public static Factory<{|#4:T|}> BoundToEnum() where T : System.Enum => new(); + public static Factory<{|#5:T|}> RecordIsNotConcrete<[Record] T>() => new(); + public static Box<{|#6:T|}> ConcreteIsNotSealed<[Concrete] T>() => new(); + public static Journal<{|#7:T|}> AStructIsNoRecord() where T : struct => new(); + } + """, + Cmtk0010(0, "T", "T", "Journal", "is a type parameter that does not carry [Record]"), + Cmtk0010(1, "T", "TCommand", "Decider", "is a type parameter that does not carry [Abstract]"), + Cmtk0010(2, "T", "TCommand", "Decider", "is a type parameter that does not carry [Abstract]"), + Cmtk0010(3, "T", "T", "Factory", "is a type parameter that does not carry [Concrete]"), + Cmtk0010(4, "T", "T", "Factory", "is a type parameter that does not carry [Concrete]"), + Cmtk0010(5, "T", "T", "Factory", "is a type parameter that does not carry [Concrete]"), + Cmtk0010(6, "T", "T", "Box", "is a type parameter that does not carry [Sealed]"), + Cmtk0010(7, "T", "T", "Journal", "is a type parameter that does not carry [Record]")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task Each_constraint_accepts_and_refuses_what_it_says() => + Test( + """ + public sealed class Accepted : Decider { } + public static class Also + { + public static Box SealedRecord; + public static Box Struct; + public static Box Enum; + public static Factory Concrete; + public static Factory ConcreteStruct; + public static Box<{|#0:IHandler|}> Interface; + public static Decider<{|#1:IHandler|}, PlaceOrder> InterfaceIsNotAbstract; + public static Decider NotARecord; + public static Factory<{|#3:Handler|}> Abstract; + public static Factory<{|#4:IHandler|}> InterfaceNotConcrete; + } + """, + Cmtk0010(0, "IHandler", "T", "Box", "is not sealed"), + Cmtk0010(1, "IHandler", "TCommand", "Decider", "is not an abstract class or record"), + Cmtk0010(2, "OrderHandler", "TEvent", "Decider", "is not a record"), + Cmtk0010(3, "Handler", "T", "Factory", "is not a concrete class, record or struct"), + Cmtk0010(4, "IHandler", "T", "Factory", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task Two_attributes_on_one_parameter_each_report() => + Test( + """ + public sealed class Both<[Abstract, Record] T> { } + public static class Uses + { + public static Both<{|#0:OrderHandler|}> Neither; + public static Both<{|#1:PlaceOrder|}> RecordButSealed; + public static Both Fine; + } + """, + Cmtk0010(0, "OrderHandler", "T", "Both", "is not an abstract class or record"), + new DiagnosticResult("CMTK0010", DiagnosticSeverity.Error).WithLocation(0).WithArguments("OrderHandler", "T", "Both", "is not a record"), + Cmtk0010(1, "PlaceOrder", "T", "Both", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + /// The open generic inside its own body, and an unbound typeof, are never reported. + [Fact] + public Task The_open_generic_and_an_unbound_typeof_stay_silent() => + Test( + """ + public abstract class Self<[Abstract] T> + { + public Self? Next; + public static Type Open => typeof(Self<>); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_misplaced_attribute_is_the_compiler_s_error() => + new AnalyzerTest( + """ + using CodoMetis.TypeKit; + + [{|CS0592:Abstract|}] + public sealed class NotAParameter { } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// Robustness, not a guard: without CodoMetis.TypeKit there is nothing to look at, and nothing may fail. + /// + /// A cref in a documentation comment names the generic's own parameter, which carries + /// nothing, and was reported on every documented API; an array is a sealed, concrete class and + /// was "not sealed". + /// + [Fact] + public Task A_doc_comment_cref_and_an_array_argument_are_not_breaches() => + Test( + """ + /// See , , and . + public static class Documented + { + public static Box Sealed => new(); + public static Factory Concrete => new(); + public static Decider<{|#0:string[]|}, PlaceOrder>? Abstract => null; + } + """, + Cmtk0010(0, "string[]", "TCommand", "Decider", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A method implementing an interface's method, or overriding a base class's, carries the + /// constraint whether or not it repeats the attribute: a call through the class was silent. + /// + [Fact] + public Task An_implementation_or_an_override_carries_the_constraint_of_what_it_implements() => + Test( + """ + public interface ISender { void Send<[Concrete] T>(T handler); } + public sealed class Sender : ISender { public void Send(T handler) { } } + public abstract class Base { public abstract void Go<[Sealed] T>(); } + public sealed class Derived : Base { public override void Go() { } } + + public static class Calls + { + public static void Go(Sender sender, Derived derived, IHandler handler, OrderHandler concrete) + { + sender.Send<{|#0:IHandler|}>(handler); + {|#1:sender.Send(handler)|}; + sender.Send(concrete); + derived.Go<{|#2:Handler|}>(); + derived.Go(); + } + } + """, + Cmtk0010(0, "IHandler", "T", "Sender.Send", "is not a concrete class, record or struct"), + Cmtk0010(1, "IHandler", "T", "Sender.Send", "is not a concrete class, record or struct"), + Cmtk0010(2, "Handler", "T", "Derived.Go", "is not sealed")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A C# 14 extension block's type parameter is inferred from the receiver at a use: for a method, + /// a property, a static member, a method group and an operator alike, reported at the use. Written, + /// the block's arguments come first in the member's list (handler.Take<Handler, Handler>(handler), + /// measured on Roslyn 5.0.0), and each is reported where it is written. Only the lowered + /// Ex.Go(handler) was reported. + /// + [Fact] + public Task An_extension_block_s_type_parameter_is_checked_at_every_member_use() => + Test( + """ + public static class Ex + { + extension<[Concrete] T>(T value) + { + public void Go() { } + public void Take<[Sealed] TOther>(TOther other) { } + public int Prop => 1; + public static void Make() { } + } + + extension<[Concrete] T>(T value) where T : class + { + public static T operator +(T left, int right) => left; + } + } + + public static class ExtensionUses + { + public static void Go(Handler handler, OrderHandler concrete) + { + {|#0:handler.Go()|}; + _ = {|#1:handler.Prop|}; + {|#2:Handler.Make()|}; + Action group = {|#3:handler.Go|}; + {|#4:Ex.Go(handler)|}; + {|#5:handler.Take(concrete)|}; + handler.Take<{|#6:Handler|}, {|#7:Handler|}>(handler); + _ = {|#8:handler + 1|}; + + concrete.Go(); + _ = concrete.Prop; + OrderHandler.Make(); + Action fine = concrete.Go; + concrete.Take(concrete); + concrete.Take(concrete); + _ = concrete + 1; + } + } + """, + Cmtk0010(0, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(1, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(2, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(3, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(4, "Handler", "T", "Ex.Go", "is not a concrete class, record or struct"), + Cmtk0010(5, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(6, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(7, "Handler", "TOther", "Ex.Take", "is not sealed"), + Cmtk0010(8, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + /// A generic attribute's name binds to its constructor, and its type argument was not checked. + [Fact] + public Task A_generic_attribute_s_type_argument_is_checked() => + Test( + """ + [AttributeUsage(AttributeTargets.All)] + public sealed class MarkAttribute<[Concrete] T> : Attribute { } + + [Mark<{|#0:Handler|}>] public sealed class Marked { } + [MarkAttribute<{|#1:Handler|}>] public sealed class MarkedInFull { } + [Mark] public sealed class Fine { } + """, + Cmtk0010(0, "Handler", "T", "MarkAttribute", "is not a concrete class, record or struct"), + Cmtk0010(1, "Handler", "T", "MarkAttribute", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A default interface method called through a derived interface is its own implementation there: + /// the analyzer took it for a method it implements, and recursed until the compiler's stack + /// overflowed. It carries its own constraint, once. + /// + [Fact] + public Task A_default_interface_method_reached_through_a_derived_interface_carries_its_own_constraint() => + Test( + """ + public interface IRunner { void Run<[Concrete] T>() { } } + public interface IFastRunner : IRunner { } + public sealed class FastRunner : IFastRunner { } + public interface IMaker { static virtual void Make<[Concrete] T>() { } } + public interface IFastMaker : IMaker { } + + public static class Calls + { + public static void Go(IFastRunner runner, IRunner plain) + { + runner.Run<{|#0:Handler|}>(); + runner.Run(); + plain.Run<{|#1:Handler|}>(); + } + + public static void Generic() where TMaker : IFastMaker => TMaker.Make<{|#2:Handler|}>(); + } + """, + Cmtk0010(0, "Handler", "T", "IRunner.Run", "is not a concrete class, record or struct"), + Cmtk0010(1, "Handler", "T", "IRunner.Run", "is not a concrete class, record or struct"), + Cmtk0010(2, "Handler", "T", "IMaker.Make", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A static method implementing a static abstract interface method, and a base class's method that + /// implements an interface for a derived class, carry the interface's constraint; both were + /// silent. Through the base class itself, which implements nothing, there is no constraint. + /// + [Fact] + public Task A_static_abstract_or_a_base_class_implementation_carries_the_interface_s_constraint() => + Test( + """ + public interface IFactory { static abstract void Make<[Concrete] T>(); } + public sealed class Factory : IFactory { public static void Make() { } } + public class FactoryBase { public static void Make() { } } + public sealed class DerivedFactory : FactoryBase, IFactory { } + + public interface ISender { void Send<[Concrete] T>(T handler); } + public class BaseSender { public void Send(T handler) { } } + public sealed class DerivedSender : BaseSender, ISender + { + public void Inside(Handler handler) => Send<{|#0:Handler|}>(handler); + public void Based(Handler handler) => base.Send<{|#8:Handler|}>(handler); + } + + public class OpenSender : BaseSender, ISender { } + + public static class Calls + { + public static void Go(DerivedSender derived, BaseSender plain, Handler handler, OrderHandler concrete) + { + Factory.Make<{|#1:Handler|}>(); + DerivedFactory.Make<{|#2:Handler|}>(); + Factory.Make(); + derived.Send<{|#3:Handler|}>(handler); + {|#4:derived.Send(handler)|}; + Action group = {|#5:derived.Send|}; + derived.Send(concrete); + plain.Send(handler); + FactoryBase.Make(); + } + + public static void Generic() where TFactory : IFactory => TFactory.Make<{|#6:Handler|}>(); + + public static void Constrained(TSender sender, Handler handler) where TSender : OpenSender => sender.Send<{|#7:Handler|}>(handler); + } + """, + Cmtk0010(0, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(1, "Handler", "T", "Factory.Make", "is not a concrete class, record or struct"), + Cmtk0010(2, "Handler", "T", "FactoryBase.Make", "is not a concrete class, record or struct"), + Cmtk0010(3, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(4, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(5, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(6, "Handler", "T", "IFactory.Make", "is not a concrete class, record or struct"), + Cmtk0010(7, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(8, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A null-conditional call with written arguments was reported twice, at the argument and at the + /// call; a nameof names a type and uses no argument of it; a type that does not exist is + /// the compiler's error alone. + /// + [Fact] + public Task A_null_conditional_call_once_and_a_nameof_or_a_missing_type_not_at_all() => + Test( + """ + public sealed class Holder + { + public void Inst<[Sealed] T>() { } + public void Take<[Sealed] T>(T value) { } + } + + public static class Uses + { + public static Box<{|CS0246:Missing|}>? Unknown; + + public static void Go(Holder? holder, Handler handler) + { + holder?.Inst<{|#0:Handler|}>(); + holder?.Inst(); + holder?{|#1:.Take(handler)|}; + _ = nameof(Box); + _ = nameof(Box.ToString); + _ = typeof(Box<{|#2:Handler|}>); + } + } + """, + Cmtk0010(0, "Handler", "T", "Holder.Inst", "is not sealed"), + Cmtk0010(1, "Handler", "T", "Holder.Take", "is not sealed"), + Cmtk0010(2, "Handler", "T", "Box", "is not sealed")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_compilation_without_TypeKit_is_left_alone() => + new AnalyzerTest("public sealed class Box { } public static class U { public static Box B; }", referenceTypeKit: false) + .RunAsync(TestContext.Current.CancellationToken); +} diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/ValueObjectWithoutGeneratorsAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/ValueObjectWithoutGeneratorsAnalyzerTests.cs index f9e1b83..ba3dfd4 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/ValueObjectWithoutGeneratorsAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/ValueObjectWithoutGeneratorsAnalyzerTests.cs @@ -80,6 +80,27 @@ public Task A_marker_reached_through_another_interface_reports() => Test("public readonly partial record struct {|#0:CustomerId|} : Subjects.IIdentifier;", Cmtk0002("CustomerId", "IValue")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A closed set is generated by the same fabric. Its members are written out here only so the + /// compilation has no CS0535 beside the rule; without them the rule says why those errors appear. + /// + [Fact] + public Task A_closed_set_without_the_generators_reports() => + Test( + """ + public sealed partial class {|#0:OrderStatus|} : CodoMetis.TypeKit.ClosedSets.IClosedSet + { + public static readonly OrderStatus Open = new(); + + public string Name => "Open"; + public string QualifiedName => "OrderStatus.Open"; + public static System.Collections.Generic.IReadOnlyList All => [Open]; + public static bool TryParse(string? name, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out OrderStatus member) { member = Open; return name == "Open"; } + } + """, + Cmtk0002("OrderStatus", "IClosedSet")) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task A_partial_type_reports_once() => Test( diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetBindingTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetBindingTests.cs new file mode 100644 index 0000000..87fcc1b --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetBindingTests.cs @@ -0,0 +1,43 @@ +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.Extensions.Configuration; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// A closed set in configuration: the reflection-based binder reads it through its type converter. +/// Without one, a set property bound to null with no error, the setting silently ignored. +/// +public sealed class ClosedSetBindingTests +{ + public sealed class Settings + { + public ProbeStatus? Status { get; set; } + + public ProbeTier Tier { get; set; } = ProbeTier.Basic; + + public List Allowed { get; set; } = []; + } + + private static IConfiguration Configuration(params (string Key, string Value)[] values) => + new ConfigurationBuilder().AddInMemoryCollection(values.Select(value => KeyValuePair.Create(value.Key, (string?)value.Value))).Build(); + + [Fact] + public void A_set_binds_by_its_member_s_name() + { + var settings = Configuration(("Status", "Shipped"), ("Tier", "Premium"), ("Allowed:0", "Open"), ("Allowed:1", "Cancelled")).Get()!; + + ReferenceEquals(settings.Status, ProbeStatus.Shipped).ShouldBeTrue(); + ReferenceEquals(settings.Tier, ProbeTier.Premium).ShouldBeTrue(); + settings.Allowed.ShouldBe([ProbeStatus.Open, ProbeStatus.Cancelled]); + } + + /// An unknown name fails the binding, as it fails Parse, and the message does not quote the setting. + [Fact] + public void An_unknown_name_fails_the_binding_without_quoting_it() + { + var refusal = Should.Throw(() => Configuration(("Status", "Nope")).Get()); + + refusal.InnerException.ShouldBeOfType().Message.ShouldContain("ProbeStatus"); + refusal.InnerException!.Message.ShouldNotContain("Nope"); + } +} diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs new file mode 100644 index 0000000..3555ae2 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs @@ -0,0 +1,176 @@ +using System.Text.Json.Nodes; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// An MVC action with closed-set parameters, top-level since MVC discovers only top-level public +/// controllers: the transformer reaches its parameters through their model metadata. +/// +[ApiController] +public sealed class TicketsController : ControllerBase +{ + [HttpGet("/mvc/tickets/{status}")] + public int ByRoute(ProbeStatus status) => 0; + + [HttpGet("/mvc/tickets")] + public int ByQuery([FromQuery] ProbeStatus status) => 0; +} + +/// A closed set in the document: a string enum of its members' names, as an enum is described. +public sealed class ClosedSetSchemaTests +{ + public sealed record Ticket(ProbeStatus Status, ProbeTier Tier); + + public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; + + [Theory] + [MemberData(nameof(Versions))] + public async Task A_closed_set_is_a_string_enum_of_its_members(OpenApiSpecVersion version) + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version, + endpoints: app => app.MapPost("/tickets", int (Ticket ticket) => 0)); + + var status = document.Resolve(document.Property(nameof(Ticket), "status")); + + status["type"]!.GetValue().ShouldBe("string"); + status["enum"]!.AsArray().Select(node => node!.GetValue()).ShouldBe(["Open", "Shipped", "Cancelled"]); + status["properties"].ShouldBeNull(); + } + + public sealed record Board(List Statuses, ProbeStatus[] History, Dictionary ByName); + + public sealed record Shipments(ProbeOrder.Status Order, ProbeShipment.Status Shipment); + + private static IEnumerable Names(JsonNode schema) => + schema["enum"].ShouldNotBeNull($"no enum: {schema.ToJsonString()}").AsArray().Select(node => node!.GetValue()); + + /// + /// A set bound from the route or the query string arrives as ASP.NET's placeholder string, as a + /// value object does, and was published as a bare string: it is the enum an enum parameter + /// is, and a query array of sets has the enum as its items. + /// + [Theory] + [MemberData(nameof(Versions))] + public async Task A_closed_set_parameter_is_the_string_enum_an_enum_parameter_is(OpenApiSpecVersion version) + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version, endpoints: app => + { + app.MapGet("/tickets/{status}", int (ProbeStatus status) => 0); + app.MapGet("/tickets", int (ProbeStatus status, ProbeStatus[] any) => 0); + }); + + foreach (var (path, name) in new[] { ("/tickets/{status}", "status"), ("/tickets", "status") }) + { + var parameter = document.Resolve(document.Parameter(path, "get", name)); + + parameter["type"]!.GetValue().ShouldBe("string", $"{path} {name}: {parameter.ToJsonString()}"); + Names(parameter).ShouldBe(["Open", "Shipped", "Cancelled"], $"{path} {name}"); + } + + var items = document.Parameter("/tickets", "get", "any")["items"].ShouldNotBeNull("the query array lost its items"); + Names(document.Resolve(items)).ShouldBe(["Open", "Shipped", "Cancelled"]); + } + + /// ASP.NET drops the element of a container of converter-backed types; the set's own schema is put back, as a value object's is. + [Theory] + [MemberData(nameof(Versions))] + public async Task A_container_of_closed_sets_has_their_enum_as_its_elements(OpenApiSpecVersion version) + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version, + endpoints: app => app.MapPost("/boards", int (Board board) => 0)); + + foreach (var (property, keyword) in new[] { ("statuses", "items"), ("history", "items"), ("byName", "additionalProperties") }) + { + var element = document.Property(nameof(Board), property)[keyword].ShouldNotBeNull($"'{property}' lost its {keyword}"); + + Names(document.Resolve(element)).ShouldBe(["Open", "Shipped", "Cancelled"], property); + } + } + + /// MVC names a bound parameter's type in its model metadata, where a minimal API names it in the parameter's type: both are the enum. + [Theory] + [MemberData(nameof(Versions))] + public async Task An_MVC_closed_set_parameter_is_the_string_enum_too(OpenApiSpecVersion version) + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version); + + foreach (var path in new[] { "/mvc/tickets/{status}", "/mvc/tickets" }) + { + var parameter = document.Resolve(document.Parameter(path, "get", "status")); + + parameter["type"]!.GetValue().ShouldBe("string", $"{path}: {parameter.ToJsonString()}"); + Names(parameter).ShouldBe(["Open", "Shipped", "Cancelled"], path); + } + } + + public sealed record Assignment(ProbeStatus? Status, ProbeStatus Required, List History, Dictionary ByName); + + /// + /// A nullable set admits null wherever a referenced one does, also where the host inlines sets + /// (decision 36). Inlined, the property was the bare enum, and a validator or a generated client + /// refused the null the application reads. Elements and parameters admit what a referenced set's + /// do: ASP.NET describes no null there, as for a List<string?>. + /// + [Theory] + [MemberData(nameof(Versions))] + public async Task An_inlined_closed_set_admits_null_wherever_a_referenced_one_does(OpenApiSpecVersion version) + { + var referenced = await AssignmentsAsync(version, inlined: false); + var inlined = await AssignmentsAsync(version, inlined: true); + + inlined.Property(nameof(Assignment), "status").ToJsonString().ShouldNotContain("$ref", customMessage: "the set is not inlined"); + PatchSchemaTests.Validator(inlined, inlined.Property(nameof(Assignment), "status"), version)("null") + .ShouldBeTrue($"an inlined nullable set refuses null: {inlined.Property(nameof(Assignment), "status").ToJsonString()}"); + + foreach (var (property, values) in new (string, string[])[] + { + ("status", ["null", "\"Open\"", "\"Nope\""]), + ("required", ["null", "\"Open\"", "\"Nope\""]), + ("history", ["[\"Open\"]", "[null]", "[\"Nope\"]"]), + ("byName", ["{\"a\":\"Open\"}", "{\"a\":null}", "{\"a\":\"Nope\"}"]), + }) + { + var asReferenced = PatchSchemaTests.Validator(referenced, referenced.Property(nameof(Assignment), property), version); + var asInlined = PatchSchemaTests.Validator(inlined, inlined.Property(nameof(Assignment), property), version); + + foreach (var value in values) + asInlined(value).ShouldBe(asReferenced(value), $"'{property}' inlined judges {value} unlike referenced: {inlined.Property(nameof(Assignment), property).ToJsonString()}"); + } + + inlined.Parameter("/assignments", "get", "status").ShouldDescribeTheSameAs(referenced.Parameter("/assignments", "get", "status"), "the status parameter"); + } + + private static Task AssignmentsAsync(OpenApiSpecVersion version, bool inlined) => + ProbeHost.DocumentAsync( + openApi => + { + openApi.AddTypeKit(); + if (inlined) + openApi.CreateSchemaReferenceId = type => + ClosedSetSchemaTransformer.Describe(type.Type) is null ? Microsoft.AspNetCore.OpenApi.OpenApiOptions.CreateDefaultSchemaReferenceId(type) : null; + }, + version: version, + endpoints: app => + { + app.MapPost("/assignments", int (Assignment assignment) => 0); + app.MapGet("/assignments", int (ProbeStatus? status) => 0); + }); + + /// Two nested sets of one simple name shared one component, named after the first described, whose names then stood for both. + [Fact] + public async Task Two_nested_sets_of_one_simple_name_are_two_components() + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), + endpoints: app => app.MapPost("/shipments", int (Shipments shipments) => 0)); + + Names(document.Resolve(document.Property(nameof(Shipments), "order"))).ShouldBe(["Open"]); + Names(document.Resolve(document.Property(nameof(Shipments), "shipment"))).ShouldBe(["Sent"]); + document.Components.ContainsKey("ProbeOrderStatus").ShouldBeTrue(string.Join(", ", document.Components.Select(c => c.Key))); + document.Components.ContainsKey("ProbeShipmentStatus").ShouldBeTrue(); + } +} diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/CodoMetis.TypeKit.AspNetCore.Tests.csproj b/test/CodoMetis.TypeKit.AspNetCore.Tests/CodoMetis.TypeKit.AspNetCore.Tests.csproj index 535bd40..75a4479 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/CodoMetis.TypeKit.AspNetCore.Tests.csproj +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/CodoMetis.TypeKit.AspNetCore.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/ComponentCollisionTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/ComponentCollisionTests.cs new file mode 100644 index 0000000..69a0cd1 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/ComponentCollisionTests.cs @@ -0,0 +1,133 @@ +using CodoMetis.TypeKit.AspNetCore.Tests.Collisions; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CodoMetis.TypeKit.AspNetCore.Tests +{ + /// + /// A nested value object or closed set is named after its nesting chain (decision 24), so + /// Order.Status is OrderStatus, which a top-level OrderStatus is too. One + /// component then described both, as whichever ASP.NET met first, and nothing said so: the + /// document is refused, naming both types and the way out. + /// + public sealed class ComponentCollisionTests + { + [Fact] + public async Task A_nested_closed_set_and_a_top_level_one_of_its_chains_name_are_refused() + { + var refusal = await Should.ThrowAsync(() => ProbeHost.DocumentAsync( + openApi => openApi.AddTypeKit(), + endpoints: app => app.MapPost("/orders", int (OrderStatuses statuses) => 0))); + + refusal.Message.ShouldContain("'OrderStatus'"); + refusal.Message.ShouldContain("CodoMetis.TypeKit.AspNetCore.Tests.Collisions.Order.Status"); + refusal.Message.ShouldContain("CodoMetis.TypeKit.AspNetCore.Tests.Collisions.OrderStatus"); + refusal.Message.ShouldContain("CreateSchemaReferenceId"); + } + + /// The companion classes of these two do not collide, being in different namespaces, so nothing refused them at compile time. + [Fact] + public async Task A_nested_value_object_and_a_top_level_one_of_its_chains_name_are_refused() + { + var refusal = await Should.ThrowAsync(() => ProbeHost.DocumentAsync( + openApi => openApi.AddTypeKit(), + endpoints: app => app.MapPost("/carts", int (Carts carts) => 0))); + + refusal.Message.ShouldContain("'CartId'"); + refusal.Message.ShouldContain("CodoMetis.TypeKit.AspNetCore.Tests.Collisions.Cart.Id"); + refusal.Message.ShouldContain("CodoMetis.TypeKit.AspNetCore.Tests.Collisions.Elsewhere.CartId"); + } + + /// The way out the refusal names: a name of the host's own, set before AddTypeKit(). + [Fact] + public async Task A_name_the_host_gives_one_of_them_separates_them() + { + var document = await ProbeHost.DocumentAsync( + openApi => + { + openApi.CreateSchemaReferenceId = type => + type.Type == typeof(OrderStatus) ? "PlainOrderStatus" : OpenApiOptions.CreateDefaultSchemaReferenceId(type); + openApi.AddTypeKit(); + }, + endpoints: app => app.MapPost("/orders", int (OrderStatuses statuses) => 0)); + + Names(document.Resolve(document.Property(nameof(OrderStatuses), "nested"))).ShouldBe(["Draft", "Placed"]); + Names(document.Resolve(document.Property(nameof(OrderStatuses), "topLevel"))).ShouldBe(["Open", "Shipped"]); + } + + /// + /// Two documents of one host are two namespaces of components: a name one claims is free in the + /// other, and a document generated again is not refused for what it claimed the first time. + /// + [Fact] + public async Task Each_document_has_its_own_names() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi("nested", openApi => openApi.AddTypeKit()); + builder.Services.AddOpenApi("top", openApi => openApi.AddTypeKit()); + + await using var app = builder.Build(); + app.MapOpenApi(); + app.MapPost("/nested", int (NestedOnly body) => 0).WithGroupName("nested"); + app.MapPost("/top", int (TopLevelOnly body) => 0).WithGroupName("top"); + await app.StartAsync(TestContext.Current.CancellationToken); + var client = app.GetTestClient(); + + var documents = await Task.WhenAll(Enumerable.Range(0, 8).Select(i => + client.GetStringAsync(i % 2 == 0 ? "/openapi/nested.json" : "/openapi/top.json", TestContext.Current.CancellationToken))); + + var nested = new OpenApiJson(JsonNode.Parse(documents[0])!); + var top = new OpenApiJson(JsonNode.Parse(documents[1])!); + Names(nested.Component("OrderStatus")).ShouldBe(["Draft", "Placed"]); + Names(top.Component("OrderStatus")).ShouldBe(["Open", "Shipped"]); + } + + private static string[] Names(JsonNode schema) => + [.. schema["enum"]!.AsArray().Select(name => name!.GetValue())]; + } +} + +namespace CodoMetis.TypeKit.AspNetCore.Tests.Collisions +{ + public static class Order + { + public sealed partial class Status : IClosedSet + { + public static readonly Status Draft = new(); + public static readonly Status Placed = new(); + } + } + + public sealed partial class OrderStatus : IClosedSet + { + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + } + + public sealed record OrderStatuses(Order.Status Nested, OrderStatus TopLevel); + + public sealed record NestedOnly(Order.Status Status); + + public sealed record TopLevelOnly(OrderStatus Status); + + public static partial class Cart + { + public readonly partial record struct Id : IValue; + } + + public sealed record Carts(Cart.Id Nested, Elsewhere.CartId TopLevel); +} + +namespace CodoMetis.TypeKit.AspNetCore.Tests.Collisions.Elsewhere +{ + public readonly partial record struct CartId : IValue; +} diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs new file mode 100644 index 0000000..b779f12 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs @@ -0,0 +1,480 @@ +using System.Net; +using System.Reflection; +using System.Text.Json.Nodes; +using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// The fault responses a client can act on (spikes/FaultResponses): a response per member, a closed set +/// as a fault type answered by its kinds, a sentence per occurrence, a status per endpoint, the +/// endpoint's own success, and the fault on the request while its problem is written. Each claim of the +/// spike is a test here; decision 38's default response is held by . +/// +public sealed class FaultResponsePartsTests +{ + public enum OrderFault { NotFound, NameTaken } + + public sealed record Order(int Id, string Name); + + private static readonly Uri NameTakenType = new("https://example.com/problems/name-taken"); + + private static Result Rename(int id) => + id == 1 ? new Order(1, "renamed") : Result.Error(id == 2 ? OrderFault.NameTaken : OrderFault.NotFound); + + private static Result> RenameWithDetail(int id) => + id == 1 ? new Order(1, "renamed") : Result.Error(Fault.Of(OrderFault.NameTaken, "Order 7 is called that.")); + + private static Result> ArchiveWithDetail(int id) => + id == 1 ? Result.Success() : Result.Error(Fault.Of(OrderFault.NameTaken, "Order 7 is called that.")); + + private static Result Find(int id) => id switch + { + 1 => new Order(1, "book"), + 2 => Result.Error(ProbeOrderFault.NameTaken), + 3 => Result.Error(ProbeOrderFault.Locked), + _ => Result.Error(ProbeOrderFault.NotFound), + }; + + private static void Responses(IServiceCollection services) + { + services.AddFaultResponses(fault => fault switch + { + OrderFault.NotFound => StatusCodes.Status404NotFound, + OrderFault.NameTaken => FaultResponse.Status(StatusCodes.Status409Conflict).WithType(NameTakenType).WithTitle("The name is taken"), + _ => throw new ArgumentOutOfRangeException(nameof(fault)), + }); + services.AddFaultResponses(); + } + + private static void Endpoints(WebApplication app) + { + app.MapPut("/orders/{id:int}", (int id) => Rename(id).ToHttpResult()); + app.MapPut("/strict/orders/{id:int}", (int id) => Rename(id).ToHttpResult()).WithFaultStatus(OrderFault.NameTaken, StatusCodes.Status422UnprocessableEntity); + app.MapPut("/detailed/orders/{id:int}", (int id) => RenameWithDetail(id).ToHttpResult()); + app.MapGet("/catalog/{id:int}", (int id) => Find(id).ToHttpResult()); + app.MapPost("/orders", () => Rename(1).ToHttpResult(order => TypedResults.Created($"/orders/{order.Id}", order))); + app.MapGet("/elsewhere", () => TypedResults.Problem(statusCode: StatusCodes.Status418ImATeapot)); + } + + // 1: the FaultResponse overload checks every member where it is mapped, as the int one does. + [Theory] + [InlineData(200)] + [InlineData(302)] + [InlineData(600)] + public void A_response_per_member_is_checked_where_it_is_mapped(int status) + { + var refusal = Should.Throw(() => new ServiceCollection().AddFaultResponses( + fault => fault == OrderFault.NameTaken ? FaultResponse.Status(status).WithType(NameTakenType) : 404)); + + refusal.ParamName.ShouldBe("responseOf"); + refusal.Message.ShouldStartWith($"FaultResponsePartsTests.OrderFault.NameTaken is mapped to {status}, which is not an error status (400 to 599)."); + } + + /// A kind is required: a member whose kind is default has none, and the set is refused where it is mapped, naming the member. + [Fact] + public void A_fault_set_with_a_member_of_no_kind_is_refused_where_it_is_mapped() + { + var refusal = Should.Throw(() => new ServiceCollection().AddFaultResponses()); + + refusal.Message.ShouldStartWith("ProbeKindlessFault.Unset: 0 is no FaultKind"); + } + + // 2: a type URI and a title reach the body and the operation's problem response; nothing of the input does. + [Fact] + public async Task A_type_and_a_title_reach_the_problem_and_the_document() + { + await using var app = await StartAsync(); + + var body = await ProblemAsync(app, "/orders/2", HttpStatusCode.Conflict); + body["type"]!.GetValue().ShouldBe(NameTakenType.ToString()); + body["title"]!.GetValue().ShouldBe("The name is taken"); + body["fault"]!.GetValue().ShouldBe("NameTaken"); + + var conflict = (await DocumentAsync(app))["paths"]!["/orders/{id}"]!["put"]!["responses"]!["409"]!; + conflict["description"]!.GetValue().ShouldBe($"Conflict. Problem types: {NameTakenType}"); + } + + public enum TypedFault { Escaped } + + /// + /// A problem type is written as the URI it is: unescaped %20 and an + /// escaped non-ASCII character, and the problem's type was then no URI at all, in the response + /// and in the document's description alike. + /// + [Theory] + [InlineData("https://example.com/problems/a%20b", "https://example.com/problems/a%20b")] + [InlineData("https://example.com/problems/%C3%BCber", "https://example.com/problems/%C3%BCber")] + [InlineData("https://example.com/problems/a%2Fb", "https://example.com/problems/a%2Fb")] + [InlineData("urn:example:problems:a%20b", "urn:example:problems:a%20b")] + [InlineData("https://example.com/problems/a b", "https://example.com/problems/a%20b")] // given unescaped: written as a URI + [InlineData("/problems/a%20b", "/problems/a%20b")] // relative: as given + public async Task A_problem_type_is_written_with_its_escapes(string given, string written) + { + var type = new Uri(given, UriKind.RelativeOrAbsolute); + await using var app = await StartAsync( + services: services => + { + Responses(services); + services.AddFaultResponses(_ => FaultResponse.Status(StatusCodes.Status409Conflict).WithType(type)); + }, + endpoints: app => app.MapGet("/typed", () => Result.Error(TypedFault.Escaped).ToHttpResult())); + + (await ProblemAsync(app, "/typed", HttpStatusCode.Conflict))["type"]!.GetValue().ShouldBe(written); + (await DocumentAsync(app))["paths"]!["/typed"]!["get"]!["responses"]!["409"]!["description"]!.GetValue() + .ShouldBe($"Conflict. Problem types: {written}"); + } + + // 3: ToHttpResult over Result>, Result and a closed set's resolves without type arguments. + [Fact] + public void ToHttpResult_resolves_for_an_enum_a_fault_and_a_closed_set_without_type_arguments() + { + Rename(2).ToHttpResult().Result.ShouldBeOfType>().Fault.ShouldBe(OrderFault.NameTaken); + + var detailed = RenameWithDetail(2).ToHttpResult().Result.ShouldBeOfType>(); + detailed.Fault.ShouldBe(OrderFault.NameTaken); + detailed.Detail.ShouldBe("Order 7 is called that."); + + ReferenceEquals(Find(3).ToHttpResult().Result.ShouldBeOfType>().Fault, ProbeOrderFault.Locked).ShouldBeTrue(); + Rename(1).ToHttpResult(order => TypedResults.Created($"/orders/{order.Id}", order)).Result.ShouldBeOfType>(); + } + + // 4: a detail reaches the body only when set, and the default response is unchanged. + [Fact] + public async Task A_detail_reaches_the_problem_only_when_there_is_one() + { + await using var app = await StartAsync(); + + var plain = (await ProblemAsync(app, "/orders/2", HttpStatusCode.Conflict)).AsObject(); + plain.Select(member => member.Key).Order().ShouldBe(["fault", "status", "title", "traceId", "type"]); + + var detailed = (await ProblemAsync(app, "/detailed/orders/2", HttpStatusCode.Conflict)).AsObject(); + detailed["detail"]!.GetValue().ShouldBe("Order 7 is called that."); + detailed["fault"]!.GetValue().ShouldBe("NameTaken", "the mapping stays keyed on the enum"); + } + + /// + /// Every overload over a carries the occurrence's detail to the problem: + /// the valued one, the command, and each with the endpoint's own success. + /// + [Theory] + [InlineData("PUT", "/detailed/orders/2")] // Result> + [InlineData("DELETE", "/detailed/orders/2")] // Result> + [InlineData("POST", "/detailed/orders/2/copies")] // Result>, with the endpoint's success + [InlineData("POST", "/detailed/orders/2/archive")] // Result>, with the endpoint's success + public async Task An_occurrence_s_detail_reaches_the_problem_through_every_overload(string method, string path) + { + await using var app = await StartAsync(endpoints: app => + { + app.MapDelete("/detailed/orders/{id:int}", (int id) => ArchiveWithDetail(id).ToHttpResult()); + app.MapPost("/detailed/orders/{id:int}/copies", (int id) => RenameWithDetail(id).ToHttpResult(order => TypedResults.Created($"/orders/{order.Id}", order))); + app.MapPost("/detailed/orders/{id:int}/archive", (int id) => ArchiveWithDetail(id).ToHttpResult(() => TypedResults.Accepted("/jobs/7"))); + }); + + using var request = new HttpRequestMessage(new HttpMethod(method), path); + using var response = await app.GetTestClient().SendAsync(request, TestContext.Current.CancellationToken); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict, $"{method} {path}"); + problem["fault"]!.GetValue().ShouldBe("NameTaken"); + problem.AsObject().ContainsKey("detail").ShouldBeTrue($"{method} {path} lost the occurrence's detail: {problem.ToJsonString()}"); + problem["detail"]!.GetValue().ShouldBe("Order 7 is called that."); + } + + /// + /// A closed set answered by its kinds: 404, 409 and 403 with nothing mapped by hand, and a member's + /// description as the problem's detail, where the member has one. + /// + [Fact] + public async Task A_fault_set_is_answered_by_its_kinds_and_its_descriptions() + { + await using var app = await StartAsync(); + + var notFound = await ProblemAsync(app, "/catalog/9", HttpStatusCode.NotFound); + notFound["fault"]!.GetValue().ShouldBe("NotFound"); + notFound["detail"]!.GetValue().ShouldBe("The order does not exist."); + + (await ProblemAsync(app, "/catalog/2", HttpStatusCode.Conflict))["detail"]!.GetValue().ShouldBe("Another order has that name."); + (await ProblemAsync(app, "/catalog/3", HttpStatusCode.Forbidden)).AsObject().ContainsKey("detail").ShouldBeFalse("a member that must not explain itself has no detail"); + + var responses = (await DocumentAsync(app))["paths"]!["/catalog/{id}"]!["get"]!["responses"]!.AsObject(); + responses.Select(response => response.Key).Order().ShouldBe(["200", "403", "404", "409"]); + } + + // 5: WithFaultStatus changes the status for that endpoint only, in the response and in its document entry. + [Fact] + public async Task A_status_per_endpoint_changes_that_endpoint_alone() + { + await using var app = await StartAsync(); + + await ProblemAsync(app, "/strict/orders/2", HttpStatusCode.UnprocessableEntity); + await ProblemAsync(app, "/orders/2", HttpStatusCode.Conflict); + + var paths = (await DocumentAsync(app))["paths"]!; + paths["/strict/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "404", "422"]); + paths["/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "404", "409"]); + } + + /// + /// A route group's status reaches every endpoint of it, and an endpoint's own replaces it for that + /// endpoint: the last status the metadata sets, as ASP.NET reads metadata, in the response and the document. + /// + [Fact] + public async Task A_group_s_status_reaches_its_endpoints_and_an_endpoint_s_own_replaces_it() + { + await using var app = await StartAsync(endpoints: app => + { + var archive = app.MapGroup("/archive").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + archive.MapPut("/orders/{id:int}", (int id) => Rename(id).ToHttpResult()); + archive.MapPut("/restored/orders/{id:int}", (int id) => Rename(id).ToHttpResult()).WithFaultStatus(OrderFault.NotFound, StatusCodes.Status404NotFound); + }); + + (await ProblemAsync(app, "/archive/orders/9", HttpStatusCode.Gone))["fault"]!.GetValue().ShouldBe("NotFound"); + await ProblemAsync(app, "/archive/restored/orders/9", HttpStatusCode.NotFound); + await ProblemAsync(app, "/archive/restored/orders/2", HttpStatusCode.Conflict); + + var paths = (await DocumentAsync(app))["paths"]!; + paths["/archive/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "409", "410"]); + paths["/archive/restored/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "404", "409"]); + } + + /// The same for MVC: a controller's attribute reaches every action of it, and an action's own replaces it. + [Fact] + public async Task A_controller_s_status_attribute_reaches_its_actions_and_an_action_s_own_replaces_it() + { + await using var app = await StartAsync(mvc: typeof(ArchiveController)); + + await ProblemAsync(app, "/mvc/archive/orders/9", HttpStatusCode.Gone); + await ProblemAsync(app, "/mvc/archive/restored/orders/9", HttpStatusCode.NotFound); + + var paths = (await DocumentAsync(app))["paths"]!; + paths["/mvc/archive/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "409", "410"]); + paths["/mvc/archive/restored/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "404", "409"]); + } + + [Fact] + public void A_status_per_endpoint_is_an_error_status() => + Should.Throw(() => WebApplication.CreateBuilder().Build().MapGet("/x", () => Rename(1).ToHttpResult()).WithFaultStatus(OrderFault.NameTaken, 200)) + .ParamName.ShouldBe("status"); + + // 6: the startup check refuses a WithFaultStatus for a fault type the endpoint does not answer with. + [Fact] + public async Task A_status_for_a_fault_type_the_endpoint_does_not_answer_with_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + app.MapGet("/stray/{id:int}", (int id) => Find(id).ToHttpResult()).WithFaultStatus(OrderFault.NameTaken, 422))); + + refusal.Message.ShouldContain("WithFaultStatus(FaultResponsePartsTests.OrderFault.NameTaken, 422) is set on"); + refusal.Message.ShouldContain("which answers with no FaultResponsePartsTests.OrderFault"); + } + + // 7: MVC: the attribute reaches the response and the operation transformer. + [Fact] + public async Task An_MVC_action_s_status_attribute_reaches_the_response_and_the_document() + { + await using var app = await StartAsync(mvc: typeof(RenamesController)); + + await ProblemAsync(app, "/mvc/orders/2", HttpStatusCode.UnprocessableEntity); + (await DocumentAsync(app))["paths"]!["/mvc/orders/{id}"]!["put"]!["responses"]!.AsObject() + .Select(response => response.Key).Order().ShouldBe(["200", "404", "422"]); + } + + [Fact] + public async Task An_attribute_naming_no_member_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: typeof(MisnamedController))); + + refusal.Message.ShouldContain("[FaultStatus(\"Taken\", 422)] names no member of FaultResponsePartsTests.OrderFault"); + } + + // 8: the endpoint's own success: 201 with its body in the document, and a Location header. + [Fact] + public async Task The_endpoint_s_own_success_is_answered_and_described() + { + await using var app = await StartAsync(); + + using var response = await app.GetTestClient().PostAsync("/orders", content: null, TestContext.Current.CancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.Created); + response.Headers.Location.ShouldBe(new Uri("/orders/1", UriKind.Relative)); + + var responses = (await DocumentAsync(app))["paths"]!["/orders"]!["post"]!["responses"]!.AsObject(); + responses.Select(entry => entry.Key).Order().ShouldBe(["201", "404", "409"]); + responses["201"]!["content"]!["application/json"]!["schema"].ShouldNotBeNull("the success's body is described"); + } + + // 9: CustomizeProblemDetails sees the fault for a problem ToHttpResult wrote, and for no other. + [Fact] + public async Task CustomizeProblemDetails_sees_the_fault_of_a_problem_ToHttpResult_wrote_and_of_no_other() + { + await using var app = await StartAsync(services: services => + { + Responses(services); + services.AddProblemDetails(options => options.CustomizeProblemDetails = context => + context.ProblemDetails.Extensions["code"] = context.HttpContext.Features.Get() is { } fault + ? $"{fault.FaultType.Name}:{fault.Name}:{fault.Detail ?? "-"}" + : "none"); + }); + + (await ProblemAsync(app, "/detailed/orders/2", HttpStatusCode.Conflict))["code"]!.GetValue().ShouldBe("OrderFault:NameTaken:Order 7 is called that."); + (await ProblemAsync(app, "/catalog/9", HttpStatusCode.NotFound))["code"]!.GetValue().ShouldBe("ProbeOrderFault:NotFound:The order does not exist."); + (await ProblemAsync(app, "/elsewhere", (HttpStatusCode)418))["code"]!.GetValue().ShouldBe("none"); + } + + /// The feature is on the request only while its problem is written: whatever the request writes afterwards does not see it. + [Fact] + public async Task The_fault_is_off_the_request_once_its_problem_is_written() + { + var after = new List(); + await using var app = await StartAsync(endpoints: endpoints => + endpoints.MapPut("/observed/orders/{id:int}", (int id) => new Observed(Rename(id).ToHttpResult(), after))); + + await ProblemAsync(app, "/observed/orders/2", HttpStatusCode.Conflict); + + after.ShouldHaveSingleItem().ShouldBeNull(); + } + + /// Executes the endpoint's result, then records what the request's features hold. + private sealed class Observed(IResult inner, List after) : IResult + { + public async Task ExecuteAsync(HttpContext httpContext) + { + await inner.ExecuteAsync(httpContext); + after.Add(httpContext.Features.Get()); + } + } + + // 10: qualified fault names are opt-in; the default stays the bare member name. + [Fact] + public async Task Qualified_fault_names_are_opt_in() + { + await using var app = await StartAsync(services: services => + { + Responses(services); + services.Configure(options => options.QualifiedFaultNames = true); + }); + + (await ProblemAsync(app, "/orders/9", HttpStatusCode.NotFound))["fault"]!.GetValue().ShouldBe("FaultResponsePartsTests.OrderFault.NotFound"); + (await ProblemAsync(app, "/catalog/9", HttpStatusCode.NotFound))["fault"]!.GetValue().ShouldBe("ProbeOrderFault.NotFound"); + } + + // 11: fault stays a string on the shared problem component; no enum across fault types. + [Fact] + public async Task The_problem_component_is_shared_and_says_nothing_of_any_fault_type() + { + await using var app = await StartAsync(); + + var document = await DocumentAsync(app); + var problem = document["components"]!["schemas"]!["ProblemDetails"]!.ToJsonString(); + problem.ShouldNotContain("NameTaken"); + problem.ShouldNotContain("\"enum\""); + } + + /// A member a cast makes is refused for a closed set too, since an instance that is no member has no answer. + [Fact] + public void A_fault_set_member_is_one_of_its_members() + { + var services = new ServiceCollection(); + services.AddFaultResponses(); + services.Any(descriptor => descriptor.ServiceType == typeof(FaultResponses)).ShouldBeTrue(); + Should.Throw(() => services.AddFaultResponses()).Message.ShouldContain("mapped already"); + } + + private static async Task StartAsync(Action? services = null, Action? endpoints = null, Type? mvc = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi(options => options.AddTypeKit()); + (services ?? Responses)(builder.Services); + if (mvc is not null) + { + builder.Services.AddControllers().ConfigureApplicationPartManager(parts => + { + parts.ApplicationParts.Clear(); + parts.FeatureProviders.Add(new OnlyControllers(mvc)); + }); + } + + var app = builder.Build(); + app.MapOpenApi(); + Endpoints(app); + endpoints?.Invoke(app); + if (mvc is not null) app.MapControllers(); + + try + { + await app.StartAsync(TestContext.Current.CancellationToken); + } + catch + { + await app.DisposeAsync(); + throw; + } + + return app; + } + + private static async Task ProblemAsync(WebApplication app, string path, HttpStatusCode status) + { + using var response = path.Contains("orders/", StringComparison.Ordinal) && !path.StartsWith("/catalog", StringComparison.Ordinal) + ? await app.GetTestClient().PutAsync(path, content: null, TestContext.Current.CancellationToken) + : await app.GetTestClient().GetAsync(path, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(status, path); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json"); + + return JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + } + + private static async Task DocumentAsync(WebApplication app) => + JsonNode.Parse(await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken))!; + + private sealed class OnlyControllers(params Type[] controllers) : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) + { + foreach (var controller in controllers) feature.Controllers.Add(controller.GetTypeInfo()); + } + } + + [ApiController] + public sealed class RenamesController : ControllerBase + { + [HttpPut("/mvc/orders/{id:int}")] + [FaultStatus(nameof(OrderFault.NameTaken), StatusCodes.Status422UnprocessableEntity)] + public Results, FaultProblem> Rename(int id) => FaultResponsePartsTests.Rename(id).ToHttpResult(); + } + + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public sealed class ArchiveController : ControllerBase + { + [HttpPut("/mvc/archive/orders/{id:int}")] + public Results, FaultProblem> Rename(int id) => FaultResponsePartsTests.Rename(id).ToHttpResult(); + + [HttpPut("/mvc/archive/restored/orders/{id:int}")] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status404NotFound)] + public Results, FaultProblem> Restore(int id) => FaultResponsePartsTests.Rename(id).ToHttpResult(); + } + + [ApiController] + public sealed class MisnamedController : ControllerBase + { + [HttpPut("/mvc/misnamed/{id:int}")] + [FaultStatus("Taken", StatusCodes.Status422UnprocessableEntity)] + public Results, FaultProblem> Rename(int id) => FaultResponsePartsTests.Rename(id).ToHttpResult(); + } +} diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponseTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponseTests.cs index aacf37b..7b8b254 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponseTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponseTests.cs @@ -213,11 +213,11 @@ public async Task A_host_with_an_endpoint_whose_fault_type_is_not_mapped_does_no var refusal = await Should.ThrowAsync(() => StartAsync(services => services.AddFaultResponses(_ => StatusCodes.Status410Gone), mvc: mvc)); - refusal.Message.ShouldStartWith("No HTTP status is mapped for the fault type OrderFault, which these endpoints answer with: "); + refusal.Message.ShouldStartWith("No HTTP status is mapped for the fault type FaultResponseTests.OrderFault, which these endpoints answer with: "); refusal.Message.ShouldContain("HTTP: GET /orders/{id:int}"); refusal.Message.ShouldContain("HTTP: DELETE /orders/{id:int}"); if (mvc) refusal.Message.ShouldContain("OrdersController.Get"); - refusal.Message.ShouldContain("services.AddFaultResponses(fault => …)"); + refusal.Message.ShouldContain("services.AddFaultResponses(fault => …)"); } /// @@ -233,10 +233,10 @@ public async Task Without_any_mapping_a_fault_and_the_document_are_refused() (await client.GetStringAsync("/orders/1", TestContext.Current.CancellationToken)).ShouldBe("""{"id":1,"item":"Book"}"""); var refusal = await Should.ThrowAsync(() => client.GetAsync("/orders/2", TestContext.Current.CancellationToken)); - refusal.Message.ShouldStartWith("No HTTP status is mapped for the fault type OrderFault. Map each of its members with services.AddFaultResponses(fault => …)."); + refusal.Message.ShouldStartWith("No HTTP status is mapped for the fault type FaultResponseTests.OrderFault. Map each of its members with services.AddFaultResponses(fault => …)."); var document = await Should.ThrowAsync(() => client.GetAsync("/openapi/v1.json", TestContext.Current.CancellationToken)); - document.Message.ShouldStartWith("No HTTP status is mapped for the fault type OrderFault."); + document.Message.ShouldStartWith("No HTTP status is mapped for the fault type FaultResponseTests.OrderFault."); } [Fact] @@ -246,7 +246,7 @@ public async Task A_value_no_member_names_is_refused_rather_than_answered() var refusal = await Should.ThrowAsync(() => app.GetTestClient().GetAsync("/orders/42", TestContext.Current.CancellationToken)); - refusal.Message.ShouldStartWith("OrderFault has no member with the value 42, so no status is mapped for it."); + refusal.Message.ShouldStartWith("FaultResponseTests.OrderFault has no member with the value 42, so no status is mapped for it."); } [Fact] @@ -256,7 +256,7 @@ public void A_fault_type_is_mapped_once() MapOrders(services); Should.Throw(() => MapOrders(services)) - .Message.ShouldBe("The fault type OrderFault is mapped already. Map it once, with a status for each member."); + .Message.ShouldBe("The fault type FaultResponseTests.OrderFault is mapped already. Map it once, with a status for each member."); } [Theory] @@ -269,7 +269,7 @@ public void A_member_mapped_to_a_status_that_is_no_error_is_refused_where_it_is_ fault => fault == OrderFault.NotYours ? status : StatusCodes.Status404NotFound)); refusal.ParamName.ShouldBe("statusOf"); - refusal.Message.ShouldStartWith($"OrderFault.NotYours is mapped to {status}, which is not an error status (400 to 599)."); + refusal.Message.ShouldStartWith($"FaultResponseTests.OrderFault.NotYours is mapped to {status}, which is not an error status (400 to 599)."); } [Fact] @@ -297,6 +297,10 @@ public async Task The_hosts_problem_details_customization_applies() body["fault"]!.GetValue().ShouldBe("NotFound"); } + /// + /// Inline, as ProducesProblem writes it, with fault beside ASP.NET's members; ProducesProblem's + /// own inline schema is another instance and stays without it. + /// [Fact] public async Task A_host_that_inlines_problem_details_gets_them_inline_as_ProducesProblem_does() { @@ -305,13 +309,66 @@ public async Task A_host_that_inlines_problem_details_gets_them_inline_as_Produc var document = await DocumentAsync(app); var responses = Responses(document, "/orders/{id}", "get"); - var expected = Responses(document, "/control/orders/{id}", "get"); + var control = Responses(document, "/control/orders/{id}", "get")["404"]!; + ProblemSchema(control)["properties"]!.AsObject().ContainsKey("fault").ShouldBeFalse("ASP.NET's own problem response is left as it writes it"); - responses["404"]!["content"]!["application/problem+json"]!["schema"]!["$ref"].ShouldBeNull(); - JsonNode.DeepEquals(responses["404"], expected["404"]).ShouldBeTrue(responses["404"]!.ToJsonString()); + var expected = control.DeepClone(); + ProblemSchema(expected)["properties"]!["fault"] = new JsonObject + { + ["type"] = "string", + ["description"] = FaultResponsesOperationTransformer.FaultDescription(qualified: false), + }; + + ProblemSchema(responses["404"]!)["$ref"].ShouldBeNull(); + JsonNode.DeepEquals(responses["404"], expected).ShouldBeTrue(responses["404"]!.ToJsonString()); document["components"]!["schemas"]!["ProblemDetails"].ShouldBeNull(); } + /// + /// The member a client of a fault response matches on is in the schema the response refers to + /// (decision 49): an optional string whose description says what it holds, as this host writes it. + /// + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1, false)] + [InlineData(OpenApiSpecVersion.OpenApi3_0, false)] + [InlineData(OpenApiSpecVersion.OpenApi3_1, true)] + [InlineData(OpenApiSpecVersion.OpenApi3_0, true)] + public async Task The_problem_schema_describes_fault_as_an_optional_string(OpenApiSpecVersion version, bool qualified) + { + await using var app = await StartAsync(version: version, services: services => + { + MapOrders(services); + if (qualified) services.Configure(options => options.QualifiedFaultNames = true); + }); + var document = await DocumentAsync(app); + + ProblemSchema(Responses(document, "/orders/{id}", "get")["404"]!)["$ref"]!.GetValue().ShouldBe("#/components/schemas/ProblemDetails"); + var problem = document["components"]!["schemas"]!["ProblemDetails"]!; + var fault = problem["properties"]!["fault"].ShouldNotBeNull($"ProblemDetails has no fault: {problem.ToJsonString()}"); + + fault.AsObject().Select(keyword => keyword.Key).Order().ShouldBe(["description", "type"]); + fault["type"]!.ToJsonString().ShouldBe("\"string\"", "never null where it is present, in 3.1 too"); + fault["description"]!.GetValue().ShouldStartWith(qualified + ? "The fault this problem reports: the name of its member, qualified with its type's" + : "The fault this problem reports: the name of its member. "); + (problem["required"]?.AsArray().Select(name => name!.GetValue()) ?? []).ShouldNotContain("fault"); + problem["properties"]!["status"].ShouldNotBeNull("ASP.NET's members stay"); + } + + /// A host whose document has no fault response keeps ASP.NET's problem schema, and the whole document, byte for byte. + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + public async Task A_document_without_a_fault_response_is_as_ASP_NET_writes_it(OpenApiSpecVersion version) + { + await using var withTypeKit = await StartAsync(version: version, orders: false); + await using var without = await StartAsync(version: version, orders: false, typeKit: false, services: _ => { }); + + var json = await withTypeKit.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken); + json.ShouldBe(await without.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken)); + JsonNode.Parse(json)!["components"]!["schemas"]!["ProblemDetails"]!["properties"]!.AsObject().ContainsKey("fault").ShouldBeFalse(); + } + [Fact] public async Task A_response_the_operation_already_has_is_kept() { @@ -362,7 +419,9 @@ private static async Task StartAsync( bool mvc = false, Action? endpoints = null, bool map = true, - bool controls = true) + bool controls = true, + bool orders = true, + bool typeKit = true) { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); builder.WebHost.UseTestServer(); @@ -370,7 +429,7 @@ private static async Task StartAsync( builder.Services.AddOpenApi(options => { options.OpenApiVersion = version; - options.AddTypeKit(); + if (typeKit) options.AddTypeKit(); openApi?.Invoke(options); }); if (services is not null) services(builder.Services); @@ -387,9 +446,13 @@ private static async Task StartAsync( var app = builder.Build(); app.MapOpenApi(); - app.MapGet("/orders/{id:int}", (int id) => Find(id).ToHttpResult()); - app.MapGet("/orders-async/{id:int}", async (int id) => (await Task.FromResult(Find(id))).ToHttpResult()); - app.MapDelete("/orders/{id:int}", (int id) => Cancel(id).ToHttpResult()); + if (orders) + { + app.MapGet("/orders/{id:int}", (int id) => Find(id).ToHttpResult()); + app.MapGet("/orders-async/{id:int}", async (int id) => (await Task.FromResult(Find(id))).ToHttpResult()); + app.MapDelete("/orders/{id:int}", (int id) => Cancel(id).ToHttpResult()); + } + if (controls) MapControls(app); if (mvc) app.MapControllers(); endpoints?.Invoke(app); @@ -430,6 +493,8 @@ private static async Task DocumentAsync(WebApplication app) => private static JsonObject Responses(JsonNode document, string path, string method) => document["paths"]![path]![method]!["responses"]!.AsObject(); + private static JsonNode ProblemSchema(JsonNode response) => response["content"]!["application/problem+json"]!["schema"]!; + private static string ReadmePath() { for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultStatusScopeTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultStatusScopeTests.cs new file mode 100644 index 0000000..bff8945 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultStatusScopeTests.cs @@ -0,0 +1,417 @@ +using System.Reflection; +using CodoMetis.TypeKit.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// A fault status declared on a scope, a route group or a controller, applies to the endpoints of that +/// scope that answer with its fault type and leaves the others alone; the host refuses it only where its +/// own scope has no such endpoint, whatever other scopes have (decision 61). A status on one endpoint or +/// action is a scope of one, and stays strict. +/// +public sealed class FaultStatusScopeTests +{ + public enum OrderFault { NotFound, NameTaken } + + private static Result Find(int id) => id switch + { + 1 => 1, + 2 => Result.Error(OrderFault.NameTaken), + _ => Result.Error(OrderFault.NotFound), + }; + + public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; + + // Mixed scopes: the fault endpoint takes the scope's status, the plain one answers and is documented as before. + + [Theory] + [MemberData(nameof(Versions))] + public async Task A_group_s_status_applies_to_its_endpoints_that_answer_with_the_fault_type_and_leaves_the_others(OpenApiSpecVersion version) + { + await using var app = await StartAsync(version, endpoints: app => + { + var orders = app.MapGroup("/orders").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + orders.MapGet("/{id:int}", (int id) => Find(id).ToHttpResult()); + orders.MapGet("/health", () => "ok"); + }); + + await ProblemAsync(app, "/orders/9", HttpStatusCode.Gone); + (await app.GetTestClient().GetStringAsync("/orders/health", TestContext.Current.CancellationToken)).ShouldBe("ok"); + + var document = await DocumentAsync(app); + Statuses(document, "/orders/{id}").ShouldBe(["200", "409", "410"]); + Statuses(document, "/orders/health").ShouldBe(["200"]); + } + + [Theory] + [MemberData(nameof(Versions))] + public async Task A_controller_s_status_applies_to_its_actions_that_answer_with_the_fault_type_and_leaves_the_others(OpenApiSpecVersion version) + { + await using var app = await StartAsync(version, mvc: [typeof(MixedController)]); + + await ProblemAsync(app, "/mvc/orders/9", HttpStatusCode.Gone); + (await app.GetTestClient().GetStringAsync("/mvc/orders/health", TestContext.Current.CancellationToken)).ShouldBe("ok"); + + var document = await DocumentAsync(app); + Statuses(document, "/mvc/orders/{id}").ShouldBe(["200", "409", "410"]); + Statuses(document, "/mvc/orders/health").ShouldBe(["200"]); + } + + // Wholly stale scopes: no endpoint of the scope answers with the fault type. + + [Fact] + public async Task A_group_none_of_whose_endpoints_answers_with_the_fault_type_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + { + var probes = app.MapGroup("/probes").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + probes.MapGet("/health", () => "ok"); + probes.MapGet("/ready", () => "ok"); + })); + + refusal.Message.ShouldContain( + "WithFaultStatus(FaultStatusScopeTests.OrderFault.NotFound, 410) is set on 2 endpoints, none of which answers with FaultStatusScopeTests.OrderFault: " + + "HTTP: GET /probes/health, HTTP: GET /probes/ready."); + } + + [Fact] + public async Task A_controller_none_of_whose_actions_answers_with_the_fault_type_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(StaleController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"NotFound\", 410)] is set on 2 endpoints, none of which answers with FaultStatusScopeTests.OrderFault: "); + refusal.Message.ShouldContain("StaleController.Health"); + refusal.Message.ShouldContain("StaleController.Ready"); + } + + // Unrelated scopes: an endpoint of another scope that answers with the fault type does not save a stale declaration. + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_stale_group_stops_the_host_beside_another_group_that_answers_with_the_fault_type(bool otherDeclaresItsOwn) + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + { + var probes = app.MapGroup("/probes").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + probes.MapGet("/health", () => "ok"); + probes.MapGet("/ready", () => "ok"); + + var orders = app.MapGroup("/orders"); + if (otherDeclaresItsOwn) orders.WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + orders.MapGet("/{id:int}", (int id) => Find(id).ToHttpResult()); + })); + + refusal.Message.ShouldContain( + "WithFaultStatus(FaultStatusScopeTests.OrderFault.NotFound, 410) is set on 2 endpoints, none of which answers with FaultStatusScopeTests.OrderFault: " + + "HTTP: GET /probes/health, HTTP: GET /probes/ready."); + refusal.Message.ShouldNotContain("GET /orders"); + } + + [Fact] + public async Task A_stale_controller_stops_the_host_beside_another_controller_that_answers_with_the_fault_type() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(StaleController), typeof(MixedController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"NotFound\", 410)] is set on 2 endpoints, none of which answers with FaultStatusScopeTests.OrderFault: "); + refusal.Message.ShouldContain("StaleController.Health"); + refusal.Message.ShouldNotContain("MixedController"); + } + + /// An attribute on a base controller is declared again on each controller that derives from it: each is its own scope. + [Fact] + public async Task Each_controller_deriving_from_a_base_with_a_status_is_a_scope_of_its_own() + { + await using (var app = await StartAsync(mvc: [typeof(ArchivedOrdersController)])) + await ProblemAsync(app, "/mvc/archive/orders/9", HttpStatusCode.Gone); + + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(ArchivedOrdersController), typeof(ArchiveHealthController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"NotFound\", 410)] is set on "); + refusal.Message.ShouldContain("ArchiveHealthController.Health"); + refusal.Message.ShouldContain("which answers with no FaultStatusScopeTests.OrderFault."); + refusal.Message.ShouldNotContain("ArchivedOrdersController"); + } + + // Nested scopes: the innermost status wins, and an outer declaration is satisfied by an endpoint of an inner group. + + [Theory] + [MemberData(nameof(Versions))] + public async Task Nested_groups_and_an_endpoint_answer_with_the_innermost_status(OpenApiSpecVersion version) + { + await using var app = await StartAsync(version, endpoints: app => + { + // The outer group's only endpoints that answer with the fault type are the inner group's. + var outer = app.MapGroup("/outer") + .WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone) + .WithFaultStatus(OrderFault.NameTaken, StatusCodes.Status422UnprocessableEntity); + outer.MapGet("/health", () => "ok"); + + var inner = outer.MapGroup("/inner").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status451UnavailableForLegalReasons); + inner.MapGet("/a/{id:int}", (int id) => Find(id).ToHttpResult()); + inner.MapGet("/b/{id:int}", (int id) => Find(id).ToHttpResult()).WithFaultStatus(OrderFault.NotFound, StatusCodes.Status404NotFound); + }); + + await ProblemAsync(app, "/outer/inner/a/9", HttpStatusCode.UnavailableForLegalReasons); + await ProblemAsync(app, "/outer/inner/a/2", HttpStatusCode.UnprocessableEntity); + await ProblemAsync(app, "/outer/inner/b/9", HttpStatusCode.NotFound); + await ProblemAsync(app, "/outer/inner/b/2", HttpStatusCode.UnprocessableEntity); + (await app.GetTestClient().GetStringAsync("/outer/health", TestContext.Current.CancellationToken)).ShouldBe("ok"); + + var document = await DocumentAsync(app); + Statuses(document, "/outer/inner/a/{id}").ShouldBe(["200", "422", "451"]); + Statuses(document, "/outer/inner/b/{id}").ShouldBe(["200", "404", "422"]); + Statuses(document, "/outer/health").ShouldBe(["200"]); + } + + /// + /// The builder MapControllers() returns covers every action, and ASP.NET puts its metadata after + /// the controllers' attributes: its status is the last, and wins over an action's own. + /// + [Fact] + public async Task A_status_on_MapControllers_covers_every_action_and_comes_after_their_attributes() + { + await using var app = await StartAsync( + mvc: [typeof(StrictActionController), typeof(PlainController)], + controllers: builder => builder.WithFaultStatus(OrderFault.NotFound, StatusCodes.Status451UnavailableForLegalReasons)); + + await ProblemAsync(app, "/mvc/strict/9", HttpStatusCode.UnavailableForLegalReasons); + (await app.GetTestClient().GetStringAsync("/mvc/plain", TestContext.Current.CancellationToken)).ShouldBe("ok"); + } + + // A status on one endpoint or action stays strict, also inside a scope whose own status is satisfied. + + [Fact] + public async Task An_endpoint_s_own_status_on_a_plain_endpoint_stops_the_host_inside_a_satisfied_group() + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + { + var orders = app.MapGroup("/orders").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + orders.MapGet("/{id:int}", (int id) => Find(id).ToHttpResult()); + orders.MapGet("/health", () => "ok").WithFaultStatus(OrderFault.NameTaken, StatusCodes.Status422UnprocessableEntity); + })); + + refusal.Message.ShouldContain("WithFaultStatus(FaultStatusScopeTests.OrderFault.NameTaken, 422) is set on HTTP: GET /orders/health, which answers with no FaultStatusScopeTests.OrderFault."); + refusal.Message.ShouldNotContain("NotFound, 410"); + } + + [Fact] + public async Task An_action_s_own_status_on_a_plain_action_stops_the_host_inside_a_satisfied_controller() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(StrayActionController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"NameTaken\", 422)] is set on "); + refusal.Message.ShouldContain("StrayActionController.Health"); + refusal.Message.ShouldContain("which answers with no FaultStatusScopeTests.OrderFault."); + refusal.Message.ShouldNotContain("\"NotFound\", 410"); + } + + // A scope's declaration is checked as an endpoint's is: a member its type has, and an error status. + + [Fact] + public async Task A_group_s_status_for_a_value_no_member_has_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + { + var orders = app.MapGroup("/orders").WithFaultStatus((OrderFault)42, StatusCodes.Status410Gone); + orders.MapGet("/{id:int}", (int id) => Find(id).ToHttpResult()); + orders.MapGet("/health", () => "ok"); + })); + + refusal.Message.ShouldContain("WithFaultStatus(FaultStatusScopeTests.OrderFault.42, 410) names a value no member of FaultStatusScopeTests.OrderFault has, on HTTP: GET /orders/{id:int}."); + } + + [Fact] + public async Task A_controller_s_attribute_naming_no_member_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(MisnamedController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"Gone\", 410)] names no member of FaultStatusScopeTests.OrderFault, on "); + refusal.Message.ShouldContain("MisnamedController.Get"); + } + + [Fact] + public void A_group_s_status_is_an_error_status() => + Should.Throw(() => WebApplication.CreateBuilder().Build().MapGroup("/orders").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status200OK)) + .ParamName.ShouldBe("status"); + + [Fact] + public async Task A_controller_s_attribute_with_no_error_status_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(NoErrorController)])); + + var cause = refusal; + while (cause is not ArgumentOutOfRangeException && cause.InnerException is { } inner) cause = inner; + cause.ShouldBeOfType($"{refusal.GetType().Name}: {refusal.Message}").ParamName.ShouldBe("status"); + } + + private static async Task StartAsync( + OpenApiSpecVersion version = OpenApiSpecVersion.OpenApi3_1, + Action? endpoints = null, + Type[]? mvc = null, + Action? controllers = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi(options => + { + options.OpenApiVersion = version; + options.AddTypeKit(); + }); + builder.Services.AddFaultResponses(fault => fault switch + { + OrderFault.NotFound => StatusCodes.Status404NotFound, + OrderFault.NameTaken => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), + }); + if (mvc is not null) + { + builder.Services.AddControllers().ConfigureApplicationPartManager(parts => + { + parts.ApplicationParts.Clear(); + parts.FeatureProviders.Add(new OnlyControllers(mvc)); + }); + } + + var app = builder.Build(); + app.MapOpenApi(); + endpoints?.Invoke(app); + if (mvc is not null) + { + var actions = app.MapControllers(); + controllers?.Invoke(actions); + } + + try + { + await app.StartAsync(TestContext.Current.CancellationToken); + } + catch + { + await app.DisposeAsync(); + throw; + } + + return app; + } + + private static async Task ProblemAsync(WebApplication app, string path, HttpStatusCode status) + { + using var response = await app.GetTestClient().GetAsync(path, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(status, path); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json"); + } + + private static async Task DocumentAsync(WebApplication app) => + JsonNode.Parse(await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken))!; + + private static string[] Statuses(JsonNode document, string path) => + [.. document["paths"]![path]!["get"]!["responses"]!.AsObject().Select(response => response.Key).Order()]; + + private sealed class OnlyControllers(Type[] controllers) : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) + { + foreach (var controller in controllers) feature.Controllers.Add(controller.GetTypeInfo()); + } + } + + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public sealed class MixedController : ControllerBase + { + [HttpGet("/mvc/orders/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + + [HttpGet("/mvc/orders/health")] + public string Health() => "ok"; + } + + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public sealed class StaleController : ControllerBase + { + [HttpGet("/mvc/stale/health")] + public string Health() => "ok"; + + [HttpGet("/mvc/stale/ready")] + public string Ready() => "ok"; + } + + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public abstract class ArchiveControllerBase : ControllerBase; + + [ApiController] + public sealed class ArchivedOrdersController : ArchiveControllerBase + { + [HttpGet("/mvc/archive/orders/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + } + + [ApiController] + public sealed class ArchiveHealthController : ArchiveControllerBase + { + [HttpGet("/mvc/archive/health")] + public string Health() => "ok"; + } + + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public sealed class StrayActionController : ControllerBase + { + [HttpGet("/mvc/stray/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + + [HttpGet("/mvc/stray/health")] + [FaultStatus(nameof(OrderFault.NameTaken), StatusCodes.Status422UnprocessableEntity)] + public string Health() => "ok"; + } + + [ApiController] + public sealed class StrictActionController : ControllerBase + { + [HttpGet("/mvc/strict/{id:int}")] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + } + + [ApiController] + public sealed class PlainController : ControllerBase + { + [HttpGet("/mvc/plain")] + public string Get() => "ok"; + } + + [ApiController] + [FaultStatus("Gone", StatusCodes.Status410Gone)] + public sealed class MisnamedController : ControllerBase + { + [HttpGet("/mvc/misnamed/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + + [HttpGet("/mvc/misnamed/health")] + public string Health() => "ok"; + } + + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status200OK)] + public sealed class NoErrorController : ControllerBase + { + [HttpGet("/mvc/no-error/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + } +} diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/InlinedValueObjectTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/InlinedValueObjectTests.cs index eccf9d9..584be04 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/InlinedValueObjectTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/InlinedValueObjectTests.cs @@ -64,6 +64,40 @@ public async Task An_inlined_value_object_is_described_as_its_wrapped_type_in_th .ShouldDescribeTheSameAs(Inline(document, document.Property("ControlDocument", property))!, property); } + public static TheoryData EnumProperties() + { + var data = new TheoryData(); + foreach (var version in new[] { OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0 }) + foreach (var property in new[] { "maybeWeekday", "weekday" }) + foreach (var enumsByName in new[] { false, true }) + data.Add(property, version, enumsByName); + + return data; + } + + /// + /// Where the host inlines the wrapped enum too, a nullable value object over it admits null among the + /// enum's values, as ASP.NET writes a nullable enum: it got null in its type at most, and an enum by + /// name, which has no type, refused the null outright. + /// + [Theory] + [MemberData(nameof(EnumProperties))] + public async Task A_value_object_over_an_inlined_enum_is_described_as_the_enum_in_the_same_position(string property, OpenApiSpecVersion version, bool enumsByName) + { + var document = await ProbeHost.DocumentAsync( + openApi => + { + openApi.AddTypeKit(); + openApi.CreateSchemaReferenceId = type => type.Type == typeof(ProbeDocument) || type.Type == typeof(ControlDocument) ? type.Type.Name : null; + }, + enumsByName ? json => json.SerializerOptions.Converters.Add(new JsonStringEnumConverter()) : null, + version); + + var inlined = document.Property("ProbeDocument", property); + inlined.ToJsonString().ShouldNotContain("$ref", customMessage: $"'{property}' is not inlined"); + inlined.ShouldDescribeTheSameAs(document.Property("ControlDocument", property), property); + } + /// A second AddTypeKit() visits what the first made, the null-or-content schemas included. [Fact] public async Task Adding_it_twice_to_an_inlining_host_publishes_the_same_document() diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/MvcBindingTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/MvcBindingTests.cs new file mode 100644 index 0000000..acdf829 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/MvcBindingTests.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using System.Reflection; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// A query, header or form value missing from an MVC request reaches the action as default for a +/// struct value object, an instance no factory made, with the model state valid: MVC's rule for any +/// non-nullable struct, as for a or an . A minimal API answers 400, +/// and MVC already requires a class value object or a closed set, as any non-nullable reference type. +/// The README's Binding section and docs/aspnet-core.md say so, with the remedy pinned here: +/// [BindRequired] answers 400, and a nullable parameter receives . +/// +public sealed class MvcBindingTests +{ + private const string Default = "default, model state valid"; + + [Theory] + // The hazard: MVC binds nothing and calls the action. + [InlineData("GET", "/mvc/query", HttpStatusCode.OK, Default)] + [InlineData("GET", "/mvc/header", HttpStatusCode.OK, Default)] + [InlineData("POST", "/mvc/form", HttpStatusCode.OK, Default)] + [InlineData("GET", "/mvc/wrapped", HttpStatusCode.OK, Default)] // the wrapped types alike: MVC's rule, not the value object's + // Already refused: a class value object and a closed set in MVC, non-nullable reference types, and a minimal API. + [InlineData("GET", "/mvc/class", HttpStatusCode.BadRequest, null)] + [InlineData("GET", "/mvc/set", HttpStatusCode.BadRequest, null)] + [InlineData("GET", "/minimal/query", HttpStatusCode.BadRequest, null)] + // The remedy: [BindRequired], or a nullable parameter. + [InlineData("GET", "/mvc/required/query", HttpStatusCode.BadRequest, "A value for the 'count' parameter or property was not provided.")] + [InlineData("GET", "/mvc/required/header", HttpStatusCode.BadRequest, "A value for the 'X-Id' parameter or property was not provided.")] + [InlineData("POST", "/mvc/required/form", HttpStatusCode.BadRequest, "A value for the 'count' parameter or property was not provided.")] + [InlineData("GET", "/mvc/required/query?count=3", HttpStatusCode.OK, "3")] + [InlineData("GET", "/mvc/nullable", HttpStatusCode.OK, "null")] + public async Task A_missing_value_reaches_an_MVC_action_as_default_unless_it_is_required_or_nullable(string method, string path, HttpStatusCode status, string? body) + { + await using var app = await StartAsync(); + + using var request = new HttpRequestMessage(new HttpMethod(method), path); + if (method == "POST") request.Content = new FormUrlEncodedContent([]); + using var response = await app.GetTestClient().SendAsync(request, TestContext.Current.CancellationToken); + var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(status, $"{method} {path}: {text}"); + if (body is not null) text.ShouldContain(body); + } + + private static async Task StartAsync() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddControllers().ConfigureApplicationPartManager(parts => + { + parts.ApplicationParts.Clear(); + parts.FeatureProviders.Add(new OnlyControllers(typeof(BindingController))); + }); + + var app = builder.Build(); + app.MapControllers(); + app.MapGet("/minimal/query", ([FromQuery] ProbeCount count) => count.Value.ToString(CultureInfo.InvariantCulture)); + + try + { + await app.StartAsync(TestContext.Current.CancellationToken); + } + catch + { + await app.DisposeAsync(); + throw; + } + + return app; + } + + private sealed class OnlyControllers(params Type[] controllers) : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) + { + foreach (var controller in controllers) feature.Controllers.Add(controller.GetTypeInfo()); + } + } + + [ApiController] + public sealed class BindingController : ControllerBase + { + [HttpGet("/mvc/query")] + public string Query([FromQuery] ProbeCode code, [FromQuery] ProbeCount count) => Seen(code.Value is null && count.Value == 0); + + [HttpGet("/mvc/header")] + public string Header([FromHeader(Name = "X-Id")] ProbeId id) => Seen(id.Value == Guid.Empty); + + [HttpPost("/mvc/form")] + public string Form([FromForm] ProbeCount count) => Seen(count.Value == 0); + + [HttpGet("/mvc/wrapped")] + public string Wrapped([FromQuery] Guid id, [FromQuery] int count) => Seen(id == Guid.Empty && count == 0); + + [HttpGet("/mvc/class")] + public string Class([FromQuery] ProbeLabel label) => label.Value; + + [HttpGet("/mvc/set")] + public string Set([FromQuery] ProbeStatus status) => status.ToString(); + + [HttpGet("/mvc/required/query")] + public string RequiredQuery([FromQuery, BindRequired] ProbeCount count) => count.Value.ToString(CultureInfo.InvariantCulture); + + [HttpGet("/mvc/required/header")] + public string RequiredHeader([FromHeader(Name = "X-Id"), BindRequired] ProbeId id) => id.Value.ToString(); + + [HttpPost("/mvc/required/form")] + public string RequiredForm([FromForm, BindRequired] ProbeCount count) => count.Value.ToString(CultureInfo.InvariantCulture); + + [HttpGet("/mvc/nullable")] + public string Nullable([FromQuery] ProbeCount? count) => count is { } given ? given.Value.ToString(CultureInfo.InvariantCulture) : "null"; + + private string Seen(bool isDefault) => isDefault && ModelState.IsValid ? Default : "bound"; + } +} diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/NestedFaultTypeTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/NestedFaultTypeTests.cs new file mode 100644 index 0000000..139ece6 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/NestedFaultTypeTests.cs @@ -0,0 +1,133 @@ +using CodoMetis.TypeKit.AspNetCore.Tests.NestedFaults; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CodoMetis.TypeKit.AspNetCore.Tests +{ + /// + /// Two fault enums of one simple name, Payments.Fault and Parcels.Fault: a qualified + /// fault name followed the simple name, so both sent Fault.NotFound, and the startup check + /// merged both into one message about a type Fault. An enum's qualified name follows its + /// nesting chain, as a closed set's QualifiedName does. + /// + public sealed class NestedFaultTypeTests + { + [Fact] + public async Task A_nested_enums_qualified_name_follows_its_nesting_chain() + { + await using var app = await StartAsync(services => + { + Map(services); + services.Configure(options => options.QualifiedFaultNames = true); + }); + + (await FaultAsync(app, "/payments")).ShouldBe("Payments.Fault.NotFound"); + (await FaultAsync(app, "/parcels")).ShouldBe("Parcels.Fault.NotFound"); + } + + /// The one rule for both kinds of fault type: the satellite's chain for a nested closed set is its generated QualifiedName. + [Fact] + public void An_enum_is_qualified_as_a_closed_set_is() + { + FaultTypes.Set().QualifiedNameOf(ProbeOrder.Status.Open).ShouldBe(ProbeOrder.Status.Open.QualifiedName); + TypeNames.NestingChain(typeof(ProbeOrder.Status), ".").ShouldBe("ProbeOrder.Status"); + FaultTypes.Enum().QualifiedNameOf(Payments.Fault.NotFound).ShouldBe("Payments.Fault.NotFound"); + FaultTypes.Enum.Fault>().QualifiedNameOf(Outer.Fault.Gone).ShouldBe("Outer.Fault.Gone"); + } + + [Fact] + public async Task Two_unmapped_fault_types_of_one_simple_name_are_two_refusals() + { + // Another type's mapping brings the startup check; neither of these two is mapped. + var refusal = await Should.ThrowAsync(() => + StartAsync(services => services.AddFaultResponses(_ => StatusCodes.Status410Gone))); + + refusal.Message.ShouldContain("No HTTP status is mapped for the fault type Payments.Fault, which these endpoints answer with: HTTP: GET /payments."); + refusal.Message.ShouldContain("No HTTP status is mapped for the fault type Parcels.Fault, which these endpoints answer with: HTTP: GET /parcels."); + refusal.Message.ShouldContain("services.AddFaultResponses(fault => …)"); + + // One chain in two namespaces: two types, so two refusals, each naming its own endpoints. + refusal.Message.ShouldContain("No HTTP status is mapped for the fault type Payments.Fault, which these endpoints answer with: HTTP: GET /elsewhere/payments."); + } + + private static Result Bill() => Result.Error(Payments.Fault.NotFound); + + private static Result Ship() => Result.Error(Parcels.Fault.NotFound); + + private static Result PayElsewhere() => Result.Error(NestedFaults.Elsewhere.Payments.Fault.NotFound); + + private static void Map(IServiceCollection services) + { + services.AddFaultResponses(_ => StatusCodes.Status404NotFound); + services.AddFaultResponses(_ => StatusCodes.Status404NotFound); + services.AddFaultResponses(_ => StatusCodes.Status404NotFound); + } + + private static async Task FaultAsync(WebApplication app, string path) + { + using var response = await app.GetTestClient().GetAsync(path, TestContext.Current.CancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.NotFound, path); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + return problem["fault"]!.GetValue(); + } + + private static async Task StartAsync(Action services) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + services(builder.Services); + + var app = builder.Build(); + app.MapGet("/payments", () => Bill().ToHttpResult()); + app.MapGet("/parcels", () => Ship().ToHttpResult()); + app.MapGet("/elsewhere/payments", () => PayElsewhere().ToHttpResult()); + + try + { + await app.StartAsync(TestContext.Current.CancellationToken); + } + catch + { + await app.DisposeAsync(); + throw; + } + + return app; + } + } +} + +namespace CodoMetis.TypeKit.AspNetCore.Tests.NestedFaults +{ + public static class Payments + { + public enum Fault { NotFound } + } + + public static class Parcels + { + public enum Fault { NotFound } + } + + public enum Unrelated { Gone } + + public static class Outer + { + public enum Fault { Gone } + } +} + +namespace CodoMetis.TypeKit.AspNetCore.Tests.NestedFaults.Elsewhere +{ + public static class Payments + { + public enum Fault { NotFound } + } +} diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs new file mode 100644 index 0000000..54e5c34 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -0,0 +1,227 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit.Generators.Probes; +using Json.Schema; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// A property in the document: its argument's schema, inlined, never +/// required, admitting null. Measured first in spikes/MergePatch, in 3.1 and 3.0 and under +/// Native AOT, where a value object inside a patch has no contract and is described through what it wraps. +/// +public sealed class PatchSchemaTests +{ + public sealed record OrderPatch(Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines); + + public sealed record Lines(int Count); + + public sealed record Control(string? Name, int Quantity, ProbeCode Code, ProbeId Id, Lines? Lines); + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum Colour { Red, Green } + + public enum Size { Small, Large } + + [JsonPolymorphic] + [JsonDerivedType(typeof(Circle), "circle")] + [JsonDerivedType(typeof(Square), "square")] + public abstract record Shape; + + public sealed record Circle(int Radius) : Shape; + + public sealed record Square(int Side) : Shape; + + /// Described by the host as a string or a number, two branches that each refuse null. + public sealed record Either(string Value); + + /// One patch per kind of schema: a type, an enum without a type, an enum with one, branches, none at all. + public sealed record KindsPatch( + Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines, + Patch Colour, Patch Status, Patch Size, Patch Numbers, Patch Shape, Patch Anything, Patch Either); + + /// The same arguments, unpatched: what each value is judged against. + public sealed record Kinds( + string Name, int Quantity, ProbeCode Code, ProbeId Id, Lines Lines, + Colour Colour, ProbeStatus Status, Size Size, int[] Numbers, Shape Shape, object Anything, Either Either); + + public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; + + private static Task DocumentAsync(OpenApiSpecVersion version) => + ProbeHost.DocumentAsync(openApi => + { + openApi.AddTypeKit(); + openApi.AddSchemaTransformer((schema, context, _) => + { + if (context.JsonTypeInfo.Type != typeof(Either)) return Task.CompletedTask; + schema.Type = null; + schema.Properties?.Clear(); + schema.Required?.Clear(); + schema.OneOf = [new OpenApiSchema { Type = JsonSchemaType.String }, new OpenApiSchema { Type = JsonSchemaType.Integer }]; + return Task.CompletedTask; + }); + }, version: version, endpoints: app => + { + app.MapPatch("/patched/{id}", int (int id, OrderPatch patch) => 0); + app.MapPost("/control", int (Control control) => 0); + app.MapPatch("/kinds", int (KindsPatch patch) => 0); + app.MapPost("/kinds", int (Kinds kinds) => 0); + }); + + /// For each property, values its argument accepts and values it refuses, as JSON. + private static readonly (string Property, string[] Values)[] Values = + [ + ("name", ["\"Ada\"", "1"]), + ("quantity", ["1", "\"many\"", "true"]), + ("code", ["\"ABC\"", "1"]), + ("id", ["\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\"", "1"]), + ("lines", ["{\"count\":1}", "{}", "\"x\""]), + ("colour", ["\"Red\"", "\"Blue\"", "1"]), + ("status", ["\"Open\"", "\"Lost\"", "1"]), + ("size", ["0", "\"x\""]), + ("numbers", ["[1,2]", "[\"x\"]", "1"]), + ("shape", ["{\"$type\":\"circle\",\"radius\":1}", "\"x\"", "1"]), + ("anything", ["1", "\"x\"", "{}", "[]"]), + ("either", ["\"x\"", "1", "true"]), + ]; + + /// + /// A patch's schema is its argument's with null beside it, judged by a JSON Schema validator as a + /// client would: null is accepted, and every other value exactly as the argument's own schema + /// judges it. A string enum has no type, a closed set's enum lists its names, and a schema the + /// host describes as branches that each refuse null admits null only beside them, so null cannot be + /// admitted by a type alone; an restricts nothing, and admitting null must not + /// restrict it to null. + /// + [Theory] + [MemberData(nameof(Versions))] + public async Task A_patch_accepts_null_and_otherwise_exactly_what_its_argument_accepts(OpenApiSpecVersion version) + { + var document = await DocumentAsync(version); + Values.Length.ShouldBe(document.Component(nameof(KindsPatch))["properties"]!.AsObject().Count, "every property of the patch has values to judge"); + + foreach (var (property, values) in Values) + { + var patch = Validator(document, document.Property(nameof(KindsPatch), property), version); + var argument = Validator(document, document.Property(nameof(Kinds), property), version); + + patch("null").ShouldBeTrue($"'{property}' should accept null: {document.Property(nameof(KindsPatch), property).ToJsonString()}"); + argument(values[0]).ShouldBeTrue($"'{property}': the control should accept {values[0]}, or the comparison means nothing"); + + foreach (var value in values) + patch(value).ShouldBe(argument(value), $"'{property}' should judge {value} as its argument does: {document.Property(nameof(KindsPatch), property).ToJsonString()}"); + } + } + + /// + /// A validator for a property's schema, its references to components inlined, and in 3.0 its + /// nullable read as 3.0.3 defines it: null added to a type the same schema states, and + /// nothing where it states none. + /// + internal static Func Validator(OpenApiJson document, JsonNode schema, OpenApiSpecVersion version) + { + var inlined = Inline(document, schema.DeepClone()); + if (version == OpenApiSpecVersion.OpenApi3_0) ReadNullable(inlined); + inlined.AsObject()["$schema"] = "https://json-schema.org/draft/2020-12/schema"; + + var validator = JsonSchema.FromText(inlined.ToJsonString()); + return value => + { + using var instance = JsonDocument.Parse(value); + return validator.Evaluate(instance.RootElement).IsValid; + }; + } + + private static JsonNode Inline(OpenApiJson document, JsonNode schema) + { + switch (schema) + { + case JsonObject node when node["$ref"]?.GetValue() is { } reference: + return Inline(document, document.Component(reference.Split('/')[^1]).DeepClone()); + case JsonObject node: + foreach (var (key, child) in node.ToArray()) + if (child is not null && key != "discriminator") node[key] = Inline(document, child.DeepClone()); + return node; + case JsonArray array: + for (var index = 0; index < array.Count; index++) + if (array[index] is { } child) array[index] = Inline(document, child.DeepClone()); + return array; + default: + return schema; + } + } + + private static void ReadNullable(JsonNode schema) + { + switch (schema) + { + case JsonObject node: + if (node["nullable"]?.GetValue() == true && node["type"] is JsonValue type) + node["type"] = new JsonArray(type.GetValue(), "null"); + node.Remove("nullable"); + foreach (var (_, child) in node) if (child is not null) ReadNullable(child); + break; + case JsonArray array: + foreach (var child in array) if (child is not null) ReadNullable(child); + break; + } + } + + [Theory] + [MemberData(nameof(Versions))] + public async Task A_patch_property_is_its_argument_s_schema_inlined_and_admits_null(OpenApiSpecVersion version) + { + var document = await DocumentAsync(version); + + foreach (var property in new[] { "name", "quantity", "code", "id" }) + { + var patch = document.Property(nameof(OrderPatch), property); + var control = document.Resolve(document.Property(nameof(Control), property)); + + patch["$ref"].ShouldBeNull($"'{property}' should be inlined, but was {patch.ToJsonString()}"); + patch["description"]!.GetValue().ShouldStartWith("A JSON Merge Patch field"); + AdmitsNull(patch, version).ShouldBeTrue($"'{property}' should admit null: {patch.ToJsonString()}"); + WithoutNull(patch).ShouldDescribeTheSameAs(WithoutNull(control), property); + } + } + + [Theory] + [MemberData(nameof(Versions))] + public async Task No_patch_property_is_required(OpenApiSpecVersion version) + { + var document = await DocumentAsync(version); + + var required = document.Component(nameof(OrderPatch))["required"]?.AsArray().Select(node => node!.GetValue()) ?? []; + required.ShouldBeEmpty(); + + document.Component(nameof(Control))["required"].ShouldNotBeNull("the control has required properties, so the patch's emptiness means something"); + } + + private static bool AdmitsNull(JsonNode schema, OpenApiSpecVersion version) => + version == OpenApiSpecVersion.OpenApi3_1 + ? schema["type"] is JsonArray types && types.Any(type => type!.GetValue() == "null") + : schema["nullable"]?.GetValue() == true; + + /// The schema without its null admission and the patch's own description, for comparing with the control. + private static JsonNode WithoutNull(JsonNode schema) + { + var copy = JsonNode.Parse(schema.ToJsonString())!.AsObject(); + copy.Remove("nullable"); + copy.Remove("description"); + if (copy["type"] is JsonArray types) + { + var kept = types.Where(type => type!.GetValue() != "null").Select(type => type!.GetValue()).ToArray(); + copy["type"] = kept.Length == 1 ? kept[0] : new JsonArray([.. kept.Select(type => (JsonNode)type)]); + } + if (copy["anyOf"] is JsonArray anyOf) + { + var kept = anyOf.Where(option => option!["enum"] is null && option["nullable"] is null).Select(option => JsonNode.Parse(option!.ToJsonString())!).ToArray(); + if (kept.Length == 0) copy.Remove("anyOf"); else copy["anyOf"] = new JsonArray(kept); + } + return copy; + } +} diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/AnalyzerPackagingTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/AnalyzerPackagingTests.cs index 0a9c147..990b830 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/AnalyzerPackagingTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/AnalyzerPackagingTests.cs @@ -60,6 +60,22 @@ public void The_analyzer_package_ships_the_analyzer_and_nothing_to_compile_again "The analyzer package has a lib/ folder, so every consumer would compile against the analyzer assembly."); } + /// + /// The code fixes are a project of their own (RS1038: an analyzer assembly must not reference + /// Workspaces), packed beside the analyzer by an item in the analyzer's project file. Without that + /// item the pack succeeds, the analyzer still ships and loads, and CMTK0011 and CMTK0012 lose their + /// fixes without a word; found by removing it while every other packaging test stayed green. + /// + [Fact] + public void The_analyzer_package_ships_the_code_fixes_beside_the_analyzer() + { + var entries = packs.Entries(AnalyzerPackage); + + entries.ShouldContain($"analyzers/dotnet/cs/{AnalyzerPackage}.CodeFixes.dll", + $"{AnalyzerPackage} does not pack {AnalyzerPackage}.CodeFixes.dll under analyzers/dotnet/cs, so no IDE offers a CMTK code fix. " + + "Pack it with a None item beside the analyzer's own."); + } + [Fact] public void The_analyzer_package_is_not_a_development_dependency() { diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/AotCompatibilityTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/AotCompatibilityTests.cs index 92cadfd..1c6e642 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/AotCompatibilityTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/AotCompatibilityTests.cs @@ -3,7 +3,7 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// /// Every package that ships run-time code is built with the trim and AOT analyzers on /// (IsAotCompatible), so a call that trimming or Native AOT cannot honour fails the build, -/// where warnings are errors, instead of a consumer's publish (docs/plan.md §11). +/// where warnings are errors, instead of a consumer's publish (design/native-aot.md). /// /// /// Read from the packed assembly, which carries the SDK's IsAotCompatible metadata when the diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/DesignRecordTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/DesignRecordTests.cs new file mode 100644 index 0000000..cb54783 --- /dev/null +++ b/test/CodoMetis.TypeKit.Conventions.Tests/DesignRecordTests.cs @@ -0,0 +1,44 @@ +namespace CodoMetis.TypeKit.Conventions.Tests; + +/// +/// The decision records under design/adr/: one file per decision, numbered without a gap, each +/// with its title, its date and its status, and every one listed in the index. A record nobody can +/// find, or one without the date and status a reader scans for, is a decision that will be taken +/// again. +/// +public sealed class DesignRecordTests +{ + private static readonly string Records = Path.Combine(Repository.Root, "design", "adr"); + + private static IReadOnlyList Names => + [.. Directory.EnumerateFiles(Records, "*.md").Select(Path.GetFileName).Where(name => name != "README.md").Order(StringComparer.Ordinal)!]; + + public static TheoryData RecordNames => [.. Names]; + + [Fact] + public void The_records_are_found() => Names.Count.ShouldBeGreaterThanOrEqualTo(50, string.Join(", ", Names)); + + [Fact] + public void The_records_are_numbered_without_a_gap() => + Names.Select(name => int.Parse(name[..4])).ShouldBe(Enumerable.Range(1, Names.Count), "Each record is NNNN-slug.md, and the numbers run from 1 without a gap."); + + [Theory] + [MemberData(nameof(RecordNames))] + public void A_record_has_its_title_date_and_status(string name) + { + var text = File.ReadAllText(Path.Combine(Records, name)); + var lines = text.Split('\n'); + + lines[0].ShouldStartWith($"# {name[..4]}. ", customMessage: $"{name} opens with its number and title."); + text.ShouldMatch(@"(?m)^Date: \d{4}-\d{2}-\d{2}\s*$", $"{name} has a 'Date: YYYY-MM-DD' line."); + text.ShouldMatch(@"(?m)^Status: (accepted|open|superseded by \d{4})\s*$", $"{name} has a 'Status:' line of accepted, open or superseded by NNNN."); + } + + [Fact] + public void The_index_lists_every_record() + { + var index = File.ReadAllText(Path.Combine(Records, "README.md")); + + Names.Where(name => !index.Contains($"]({name})", StringComparison.Ordinal)).ShouldBeEmpty("Every record is linked from design/adr/README.md."); + } +} diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs new file mode 100644 index 0000000..ab4abe5 --- /dev/null +++ b/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs @@ -0,0 +1,115 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace CodoMetis.TypeKit.Conventions.Tests; + +/// +/// Every relative link in the root README, the package READMEs, the docs/ pages and the +/// design/ pages opens a file or directory that exists, and every anchor in one is a heading of the file it points at, derived as +/// GitHub derives it. A link to a renamed page is a 404; a link to a renamed heading opens the top +/// of the page, silently, which is the worse of the two. +/// +public sealed partial class DocumentationLinkTests +{ + private static readonly Lazy> Links = new(() => + [.. ReadmeSamples.Readmes().Concat(DesignPages()).SelectMany(page => RelativeLinks(page, File.ReadAllText(Path.Combine(Repository.Root, page))))]); + + /// The design notes, the decision records and the roadmap under design/: linked like the pages, compiled by nothing. + private static IEnumerable DesignPages() => + Directory.EnumerateFiles(Path.Combine(Repository.Root, "design"), "*.md", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(Repository.Root, path).Replace('\\', '/')) + .Order(StringComparer.Ordinal); + + public static TheoryData Locations => [.. Links.Value.Select(link => link.Location)]; + + [Fact] + public void The_pages_link_each_other() => Links.Value.Count.ShouldBeGreaterThanOrEqualTo(60, string.Join(", ", Links.Value.Select(link => link.Location))); + + [Theory] + [MemberData(nameof(Locations))] + public void A_relative_link_opens_a_file_and_its_anchor_is_a_heading(string location) + { + var link = Links.Value.Single(candidate => candidate.Location == location); + var target = Path.GetFullPath(Path.Combine(Repository.Root, Path.GetDirectoryName(link.Page) ?? "", link.Path)); + + (File.Exists(target) || Directory.Exists(target)).ShouldBeTrue($"{location} links {link.Path}, which does not exist."); + + if (link.Anchor is null) return; + + var headings = Headings(File.ReadAllText(target)); + headings.ShouldContain(link.Anchor, $"{location} links #{link.Anchor}, which is no heading of {link.Path}. The headings are: {string.Join(", ", headings)}."); + } + + public static TheoryData PackageReadmes => [.. Repository.ShippingProjects().Select(project => $"src/{project}/README.md")]; + + /// + /// A package README is packed into its package, and nuget.org renders it there, where a link + /// relative to the repository opens nothing. It links another file by its absolute GitHub address, + /// as every other package README does; an in-page anchor stays relative. + /// + [Theory] + [MemberData(nameof(PackageReadmes))] + public void A_package_README_links_other_files_by_their_absolute_address(string readme) + { + PackageReadmes.Count.ShouldBeGreaterThanOrEqualTo(5); + File.Exists(Path.Combine(Repository.Root, readme)).ShouldBeTrue($"{readme} does not exist."); + + Links.Value.Where(link => link.Page == readme).Select(link => link.Location).ShouldBeEmpty( + $"{readme} is packed into its package, where a relative link opens nothing on nuget.org. Link https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/... instead."); + } + + /// The positive control: links with and without anchors are found, external and in-page links are left alone, and the slug is GitHub's. + [Fact] + public void Links_and_headings_are_read_as_GitHub_reads_them() + { + const string markdown = + """ + # Title + + See [the plan](design/plan.md#2-phases), [EF Core](ef-core.md), [here](#the-surface) and [GitHub](https://github.com/x). + + ## Option<T> + + ## Result<T, TError> and Result<TError> + + ## The surface + + ### CMTK0010 + """; + + RelativeLinks("docs/page.md", markdown).Select(link => (link.Path, link.Anchor)).ShouldBe([("design/plan.md", "2-phases"), ("ef-core.md", null)]); + Headings(markdown).ShouldBe(["title", "optiont", "resultt-terror-and-resultterror", "the-surface", "cmtk0010"]); + } + + private static IReadOnlyList RelativeLinks(string page, string markdown) => + [ + .. MarkdownLink().Matches(markdown.ReplaceLineEndings("\n")) + .Select(match => (Target: match.Groups["target"].Value, Line: markdown.ReplaceLineEndings("\n")[..match.Index].Count(c => c == '\n') + 1)) + .Where(link => !link.Target.StartsWith("http", StringComparison.Ordinal) && !link.Target.StartsWith('#')) + .Select(link => link.Target.Split('#', 2) is var parts + ? new Link(page, link.Line, parts[0], parts.Length > 1 ? parts[1] : null) + : throw new InvalidOperationException()) + ]; + + /// The headings' anchors as GitHub derives them: entities decoded, lowercased, punctuation dropped, spaces to hyphens. + private static IReadOnlyList Headings(string markdown) => + [ + .. Heading().Matches(markdown.ReplaceLineEndings("\n")) + .Select(match => WebUtility.HtmlDecode(match.Groups["text"].Value).Trim().ToLowerInvariant()) + .Select(text => NotInASlug().Replace(text, "").Replace(' ', '-')) + ]; + + private sealed record Link(string Page, int Line, string Path, string? Anchor) + { + public string Location => $"{Page}:{Line} -> {Path}{(Anchor is null ? "" : $"#{Anchor}")}"; + } + + [GeneratedRegex(@"\]\((?[^)\s]+)\)")] + private static partial Regex MarkdownLink(); + + [GeneratedRegex(@"^#{1,6} (?.+)$", RegexOptions.Multiline)] + private static partial Regex Heading(); + + [GeneratedRegex(@"[^\p{L}\p{N} _-]")] + private static partial Regex NotInASlug(); +} diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs index 1f8f16e..82b5727 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs @@ -1,9 +1,9 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// -/// Every C# block in the READMEs is compiled code: it equals, after indentation, exactly one sample -/// region in test/CodoMetis.TypeKit.Samples, and every region there is shown in the README its -/// name starts with. A README edit that is not mirrored in the samples fails here, and a sample that +/// Every C# block in the READMEs and the topic pages under docs/ is compiled code: it equals, +/// after indentation, exactly one sample region in test/CodoMetis.TypeKit.Samples, and every +/// region there is shown in the page its name starts with. A README edit that is not mirrored in the samples fails here, and a sample that /// stops compiling fails the build. /// /// @@ -11,7 +11,7 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// A pre-release audit compiled the READMEs' blocks and found a bare fault returned from a method typed /// Result<T, TError>, statements that are not statements (quantity.Value;), and SQL in a /// comment that the query does not produce. The consumer smoke test had found the first once before -/// (docs/plan.md §9). Each was fixed by hand, and nothing kept the next one out. +/// (design/option-result.md). Each was fixed by hand, and nothing kept the next one out. /// /// /// A block that cannot compile on purpose, showing a compile error, says so above its fence: @@ -35,6 +35,56 @@ public sealed class ReadmeSampleTests public void The_READMEs_are_discovered() => ReadmeSamples.Readmes().Count.ShouldBeGreaterThanOrEqualTo(6, string.Join(", ", ReadmeSamples.Readmes())); + /// The topic pages are found: a docs/ that yielded nothing would compile no page and fail nothing. + [Fact] + public void The_docs_pages_are_discovered() => + ReadmeSamples.DocsPages().Count.ShouldBeGreaterThanOrEqualTo(8, string.Join(", ", ReadmeSamples.DocsPages())); + + [Theory] + [InlineData("README.md", "README")] + [InlineData("src/CodoMetis.TypeKit.EntityFrameworkCore/README.md", "CodoMetis.TypeKit.EntityFrameworkCore")] + [InlineData("docs/ef-core.md", "ef-core")] + public void A_page_s_samples_are_named_after_it(string page, string prefix) => ReadmeSamples.Prefix(page).ShouldBe(prefix); + + /// + /// The root README's "Documentation" section is the table of contents: every topic page is linked + /// from it, and every page it links exists. A page written and never listed cannot be reached from + /// where a reader starts, and a link to a page since renamed opens nothing, silently. + /// + [Fact] + public void The_root_README_lists_every_docs_page_and_links_only_pages_that_exist() + { + var listed = ReadmeSamples.DocumentationLinks(File.ReadAllText(Path.Combine(Repository.Root, ReadmeSamples.RootReadme))); + + listed.Count.ShouldBeGreaterThanOrEqualTo(8, string.Join(", ", listed)); + ReadmeSamples.DocsPages().Except(listed).ShouldBeEmpty("Every page in docs/ is linked from the Documentation section of README.md."); + listed.Where(page => !File.Exists(Path.Combine(Repository.Root, page))).ShouldBeEmpty("The Documentation section of README.md links a page that does not exist."); + } + + /// The positive control for the table of contents: links with and without anchors are found, in order, and other sections are not read. + [Fact] + public void Documentation_links_are_found_in_the_section_alone() + { + const string markdown = + """ + # Title + + ## Documentation + + | Page | Covers | + |---|---| + | [EF Core](docs/ef-core.md) | columns | + | [JSON](docs/json.md#stored-documents) | stored | + + ## Status + + See [the plan](design/plan.md) and [JSON](docs/json.md). + """; + + ReadmeSamples.DocumentationLinks(markdown).ShouldBe(["docs/ef-core.md", "docs/json.md"]); + Should.Throw(() => ReadmeSamples.DocumentationLinks("# Title\n\nno section")).Message.ShouldBe("README.md has no '## Documentation' section."); + } + [Fact] public void The_csharp_blocks_and_the_samples_are_discovered() { @@ -71,7 +121,7 @@ public void A_sample_is_shown_in_the_README_its_name_starts_with(string name) readme.ShouldNotBeNull( $"'{name}' ({sample.Location}) starts with '{sample.ReadmePrefix}', which is no README. " + - $"A sample's name starts with README or a package id: {string.Join(", ", ReadmeSamples.Readmes().Select(ReadmeSamples.Prefix))}."); + $"A sample's name starts with README, a package id or a topic page's name: {string.Join(", ", ReadmeSamples.Readmes().Select(ReadmeSamples.Prefix))}."); var shown = Blocks.Value.Where(block => block.Readme == readme && block.IsCSharp).ToArray(); diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs index 2ca3c08..ab771ab 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs @@ -40,12 +40,55 @@ internal static partial class ReadmeSamples public const string RootReadme = "README.md"; - /// The root README and every shipping package's, discovered from src/. + public const string DocsDirectory = "docs"; + + /// + /// Every page whose C# is compiled: the root README, every shipping package's, discovered from + /// src/, and every topic page in docs/ (). + /// public static IReadOnlyList Readmes() => - [RootReadme, .. Repository.ShippingProjects().Select(project => $"src/{project}/README.md")]; + [RootReadme, .. Repository.ShippingProjects().Select(project => $"src/{project}/README.md"), .. DocsPages()]; + + /// + /// The topic pages, discovered from docs/: every Markdown file there. A page is compiled by + /// being there, so one that is forgotten in the root README's table fails + /// rather than going unread. The plan and the design notes live in + /// design/, whose code is sketches and is not compiled. + /// + public static IReadOnlyList DocsPages() => + [ + .. Directory.EnumerateFiles(Path.Combine(Repository.Root, DocsDirectory), "*.md", SearchOption.TopDirectoryOnly) + .Select(path => $"{DocsDirectory}/{Path.GetFileName(path)}") + .Order(StringComparer.Ordinal) + ]; + + /// + /// The prefix of the samples a page shows: README for the root README, the package id for + /// a package's, and the file name without .md for a topic page (docs/ef-core.md shows + /// ef-core/…). + /// + public static string Prefix(string readme) => + readme == RootReadme ? "README" + : readme.StartsWith("src/", StringComparison.Ordinal) ? readme.Split('/')[1] + : Path.GetFileNameWithoutExtension(readme); + + /// + /// The pages the root README's "Documentation" section links under docs/, in order, each + /// without its anchor: the table of contents a reader starts from. + /// + public static IReadOnlyList DocumentationLinks(string markdown) + { + var section = DocumentationSection().Match(markdown.ReplaceLineEndings("\n")); + if (!section.Success) throw new InvalidOperationException($"{RootReadme} has no '## Documentation' section."); + + return [.. DocsLink().Matches(section.Groups["body"].Value).Select(link => link.Groups["page"].Value)]; + } + + [GeneratedRegex(@"^## Documentation\n(?.*?)(?=^## |\z)", RegexOptions.Multiline | RegexOptions.Singleline)] + private static partial Regex DocumentationSection(); - /// The prefix of the samples a README shows: README, or the package id. - public static string Prefix(string readme) => readme == RootReadme ? "README" : readme.Split('/')[1]; + [GeneratedRegex(@"\]\((?docs/[^)#\s]+)(?:#[^)\s]*)?\)")] + private static partial Regex DocsLink(); public static IReadOnlyList CodeBlocks() => [.. Readmes().SelectMany(readme => CodeBlocks(readme, File.ReadAllText(Path.Combine(Repository.Root, readme))))]; diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs new file mode 100644 index 0000000..ecd2212 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs @@ -0,0 +1,173 @@ +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// A closed set as a column: the member's name, read back as the member itself, with nothing registered per set. +public sealed class ClosedSetMappingTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + public ClosedSetMappingTests() => _connection.Open(); + + public void Dispose() => _connection.Dispose(); + + private DbContextOptions Options => new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options; + + [Fact] + public void A_closed_set_property_is_a_text_column_of_the_member_s_name() + { + using var db = new SetDb(Options); + + var status = db.Model.FindEntityType(typeof(Ticket))!.FindProperty(nameof(Ticket.Status)).ShouldNotBeNull(); + status.GetTypeMapping().Converter.ShouldNotBeNull().ProviderClrType.ShouldBe(typeof(string)); + status.GetColumnType().ShouldBe("TEXT"); + } + + [Fact] + public void A_member_round_trips_and_a_query_compares_by_name() + { + using (var db = new SetDb(Options)) + { + db.Database.EnsureCreated(); + db.Tickets.AddRange(new Ticket { Id = 1, Status = ProbeStatus.Open, Tier = ProbeTier.Premium }, new Ticket { Id = 2, Status = ProbeStatus.Shipped, Tier = ProbeTier.Basic }); + db.SaveChanges(); + } + + using (var db = new SetDb(Options)) + { + var shipped = db.Tickets.Single(ticket => ticket.Status == ProbeStatus.Shipped); + ReferenceEquals(shipped.Status, ProbeStatus.Shipped).ShouldBeTrue(); + ReferenceEquals(shipped.Tier, ProbeTier.Basic).ShouldBeTrue(); + + db.Tickets.Where(ticket => ticket.Tier == ProbeTier.Premium).ToQueryString().ShouldContain("'Premium'"); + } + } + + /// + /// An instance the set's own code made beside its members has no name. Before, a nullable column + /// stored its null name and read it back as no value at all. + /// + [Fact] + public void An_instance_that_is_not_a_member_is_never_stored() + { + using var db = new SetDb(Options); + db.Database.EnsureCreated(); + db.Tickets.Add(new Ticket { Id = 3, Level = ProbeLevel.Unlisted() }); + + var thrown = Should.Throw(() => db.SaveChanges()); + var cause = thrown as InvalidOperationException ?? thrown.InnerException.ShouldBeOfType(); + cause.Message.ShouldEndWith("#an-instance-that-is-not-a-member-of-its-set"); + } + + /// A collection of members is a JSON array of their names, as a collection of strings would be. + [Fact] + public void A_collection_of_members_is_a_JSON_column_of_names_and_reads_the_members_back() + { + using (var db = new SetDb(Options)) + { + db.Database.EnsureCreated(); + db.Tickets.Add(new Ticket { Id = 4, History = [ProbeStatus.Open, ProbeStatus.Shipped] }); + db.SaveChanges(); + } + + using (var db = new SetDb(Options)) + { + var ticket = db.Tickets.Single(t => t.Id == 4); + ticket.History.ShouldBe([ProbeStatus.Open, ProbeStatus.Shipped]); + ReferenceEquals(ticket.History[1], ProbeStatus.Shipped).ShouldBeTrue(); + + db.Tickets.Count(t => t.History.Contains(ProbeStatus.Shipped)).ShouldBe(1); + db.Database.SqlQueryRaw("SELECT History AS Value FROM Tickets WHERE Id = 4").Single().ShouldBe("""["Open","Shipped"]"""); + } + } + + /// A member inside a JSON column is its name there too, and a query reaches into it. + [Fact] + public void A_member_inside_a_JSON_column_is_its_name_and_a_query_compares_by_it() + { + using (var db = new SetDb(Options)) + { + db.Database.EnsureCreated(); + db.Tickets.Add(new Ticket { Id = 5, Delivery = new Delivery { Status = ProbeStatus.Cancelled, Tier = ProbeTier.Premium } }); + db.SaveChanges(); + } + + using (var db = new SetDb(Options)) + { + var ticket = db.Tickets.Single(t => t.Delivery.Status == ProbeStatus.Cancelled); + ReferenceEquals(ticket.Delivery.Tier, ProbeTier.Premium).ShouldBeTrue(); + db.Database.SqlQueryRaw("SELECT Delivery AS Value FROM Tickets WHERE Id = 5").Single().ShouldBe("""{"Status":"Cancelled","Tier":"Premium"}"""); + } + } + + /// + /// A facet configured on a closed set's column is kept, as on any string column: the text mapping is + /// looked up with the facets. Looked up without them, Npgsql answered text for each. + /// + [Theory] + [InlineData(nameof(FacetRow.Id), "character varying(20)")] + [InlineData(nameof(FacetRow.Status), "character varying(12)")] + [InlineData(nameof(FacetRow.Tier), "character varying(7)")] + public void A_configured_column_facet_of_a_closed_set_is_kept_on_PostgreSQL(string property, string storeType) + { + using var db = new FacetDb(new DbContextOptionsBuilder().UseNpgsql("Host=localhost;Database=none").UseTypeKit().Options); + var column = db.Model.FindEntityType(typeof(FacetRow))!.FindProperty(property)!; + + column.GetRelationalTypeMapping().StoreType.ShouldBe(storeType); + column.GetColumnType().ShouldBe(storeType); + column.GetTypeMapping().Converter.ShouldNotBeNull().ProviderClrType.ShouldBe(typeof(string)); + } + + public sealed class FacetRow + { + public ProbeLevel Id { get; set; } = ProbeLevel.Low; + + public ProbeStatus Status { get; set; } = ProbeStatus.Open; + + public ProbeTier Tier { get; set; } = ProbeTier.Basic; + } + + /// A maximum length on a key and on a property, and a column type. + public sealed class FacetDb(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var row = modelBuilder.Entity(); + row.Property(r => r.Id).HasMaxLength(20); + row.Property(r => r.Status).HasMaxLength(12); + row.Property(r => r.Tier).HasColumnType("character varying(7)"); + } + } + + public sealed class Delivery + { + public ProbeStatus Status { get; set; } = ProbeStatus.Open; + + public ProbeTier Tier { get; set; } = ProbeTier.Basic; + } + + public sealed class Ticket + { + public int Id { get; set; } + + public ProbeStatus Status { get; set; } = ProbeStatus.Open; + + public ProbeTier Tier { get; set; } = ProbeTier.Basic; + + public ProbeLevel? Level { get; set; } + + public List History { get; set; } = []; + + public Delivery Delivery { get; set; } = new(); + } + + public sealed class SetDb(DbContextOptions options) : DbContext(options) + { + public DbSet Tickets => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().ComplexProperty(ticket => ticket.Delivery, delivery => delivery.ToJson()); + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj index 53f74a4..7782386 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj @@ -16,6 +16,9 @@ + + + diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/DesignTime.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/DesignTime.cs new file mode 100644 index 0000000..704da53 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/DesignTime.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// What dotnet ef does at design time, in process: its services, and the code it writes compiled back. +internal static class DesignTime +{ + /// + /// The design-time services for a context, in the order and with the lookup of EF's + /// DesignTimeServicesBuilder: the context, the provider's services (named by the provider assembly's + /// attribute), then EF's own, which are only added where missing. + /// + public static ServiceProvider Services(DbContext db) + { + var services = new ServiceCollection().AddDbContextDesignTimeServices(db); + var provider = Assembly.Load(new AssemblyName(db.GetService().Name)); + var providerServices = provider.GetCustomAttribute().ShouldNotBeNull($"{provider} names no design-time services."); + ((IDesignTimeServices)Activator.CreateInstance(provider.GetType(providerServices.TypeName, throwOnError: true)!)!).ConfigureDesignTimeServices(services); + services.AddEntityFrameworkDesignTimeServices(); + + return services.BuildServiceProvider(); + } + + /// The context's compiled model, as dotnet ef dbcontext optimize writes it, compiled and loaded. + public static IModel CompiledModel(DbContext db) + { + using var designTime = Services(db); + var options = new CompiledModelCodeGenerationOptions + { + ContextType = db.GetType(), + ModelNamespace = $"CompiledModel{Guid.NewGuid():N}", + Language = "C#", + UseNullableReferenceTypes = true, + }; + var files = designTime.GetRequiredService().Select(options) + .GenerateModel(db.GetService().Model, options); + + var instance = Compile([.. files.Select(file => file.Code)], db.GetType().Assembly) + .GetTypes() + .Single(type => type.Name == $"{db.GetType().Name}Model") + .GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)!; + + return (IModel)instance.GetValue(null)!; + } + + public static Assembly Compile(IReadOnlyList code, Assembly context) + { + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator) + .Append(context.Location) + .Distinct(StringComparer.Ordinal) + .Select(path => MetadataReference.CreateFromFile(path)); + var compilation = CSharpCompilation.Create( + $"DesignTime{Guid.NewGuid():N}", + code.Select(source => CSharpSyntaxTree.ParseText(source)), + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + + using var image = new MemoryStream(); + var emitted = compilation.Emit(image); + emitted.Success.ShouldBeTrue( + $"The generated code does not compile:{Environment.NewLine}{string.Join(Environment.NewLine, emitted.Diagnostics)}{Environment.NewLine}{string.Join(Environment.NewLine, code)}"); + + return Assembly.Load(image.ToArray()); + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ForeignConverterTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ForeignConverterTests.cs new file mode 100644 index 0000000..21be5b7 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ForeignConverterTests.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using System.Linq.Expressions; +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// .Value is the column only where the column holds .Value: where TypeKit's converter +/// stores the value object. A property converted by a converter of the application's own holds +/// something else, and the translation is refused rather than compared with what the column does not +/// hold. +/// +/// +/// Measured on SQLite before the check: with "C:" + c.Value stored, t.Code.Value == "abc" +/// became "t"."Code" = 'abc' and found no row, where the row holds abc in memory; with a +/// quantity stored as text, t.Quantity.Value > 10 became "t"."Quantity" > 10 and found +/// the row holding 7. +/// +public sealed class ForeignConverterTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + public static TheoryData Conversions => [ForeignConversion.Lambda, ForeignConversion.ConverterType, ForeignConversion.ModelConvention]; + + [Theory] + [MemberData(nameof(Conversions))] + public void Value_on_a_column_another_converter_stores_is_refused(ForeignConversion conversion) + { + using var db = Seeded(conversion); + + Refusal(() => db.Coded.Count(c => c.Code.Value == "abc")).ShouldContain("could not be translated"); + Refusal(() => db.Coded.Count(c => c.Quantity.Value > 10)).ShouldContain("could not be translated"); + Refusal(() => db.Coded.Count(c => c.Quantity.GetValue() > 10)).ShouldContain("could not be translated"); + } + + /// The column still answers for the value object itself, through the application's converter. + [Theory] + [MemberData(nameof(Conversions))] + public void The_value_object_itself_still_compares_through_the_application_s_converter(ForeignConversion conversion) + { + using var db = Seeded(conversion); + + db.Coded.Count(c => c.Code == CodeText.From("abc")).ShouldBe(1); + db.Coded.Count(c => c.Quantity == ShipmentNo.From(7)).ShouldBe(1); + } + + /// The control: TypeKit's converter named explicitly, as the README shows, still translates .Value. + [Fact] + public void Value_on_a_column_TypeKit_s_converter_stores_explicitly_translates() + { + using var db = Seeded(ForeignConversion.ExplicitTypeKit); + + db.Coded.Count(c => c.Code.Value == "abc").ShouldBe(1); + db.Coded.Count(c => c.Quantity.Value > 10).ShouldBe(0); + db.Coded.Where(c => c.Code.Value == "abc").ToQueryString().ShouldContain("WHERE \"c\".\"Code\" = 'abc'"); + } + + /// + /// Beside a column the application converts, the columns TypeKit maps still translate: one over a + /// string, and one over an enum, whose converter is TypeKit's composed onto the enum's own. + /// + [Theory] + [MemberData(nameof(Conversions))] + public void Value_on_the_columns_TypeKit_maps_still_translates(ForeignConversion conversion) + { + using var db = Seeded(conversion); + + db.Model.FindEntityType(typeof(Coded))!.FindProperty(nameof(Coded.Day))!.GetTypeMapping().Converter.ShouldNotBeNull() + .ConvertToProviderExpression.Body.ShouldNotBeAssignableTo("the enum's mapping is expected to carry a converter of its own"); + + db.Coded.Count(c => c.Label.Value == "abc").ShouldBe(1); + db.Coded.Count(c => c.Day.Value == DayOfWeek.Friday).ShouldBe(1); + db.Coded.Count(c => c.Day.Value == DayOfWeek.Monday).ShouldBe(0); + } + + /// + /// A compiled model (dotnet ef dbcontext optimize) creates every converter again, from the + /// expressions it wrote out as C#. TypeKit's are still told from the application's, the one composed + /// onto an enum's included. + /// + [Fact] + public void In_a_compiled_model_Value_translates_only_where_TypeKit_s_converter_stores_the_column() + { + using var design = Seeded(ForeignConversion.Lambda); + var compiled = DesignTime.CompiledModel(design); + using var db = new ForeignConvertersDb( + new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().UseModel(compiled).Options, ForeignConversion.Lambda); + + db.Model.ShouldBeSameAs(compiled); + db.Coded.Count(c => c.Label.Value == "abc").ShouldBe(1); + db.Coded.Count(c => c.Day.Value == DayOfWeek.Friday).ShouldBe(1); + Refusal(() => db.Coded.Count(c => c.Code.Value == "abc")).ShouldContain("could not be translated"); + Refusal(() => db.Coded.Count(c => c.Quantity.Value > 10)).ShouldContain("could not be translated"); + } + + public void Dispose() => _connection.Dispose(); + + private ForeignConvertersDb Seeded(ForeignConversion conversion) + { + _connection.Open(); + var db = new ForeignConvertersDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit() + .ReplaceService() + .Options, conversion); + db.Database.EnsureCreated(); + db.Add(new Coded { Code = CodeText.From("abc"), Quantity = ShipmentNo.From(7), Label = LabelText.From("abc"), Day = ProbeWeekday.From(DayOfWeek.Friday) }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + return db; + } + + private static string Refusal(Func query) => Should.Throw(() => query()).Message; +} + +/// EF caches a model per context type; these need one per conversion. +public sealed class ModelPerConversion : IModelCacheKeyFactory +{ + public object Create(DbContext context, bool designTime) => + (context.GetType(), (context as ForeignConvertersDb)?.Conversion, designTime); +} + +public enum ForeignConversion +{ + /// HasConversion(to, from) with lambdas. + Lambda, + + /// HasConversion<TConverter>(). + ConverterType, + + /// ConfigureConventions: Properties<T>().HaveConversion<TConverter>(). + ModelConvention, + + /// HasConversion<ValueObjectConverter<,>>(): TypeKit's own, named. + ExplicitTypeKit, +} + +public sealed class Coded +{ + public int Id { get; set; } + + public CodeText Code { get; set; } = null!; + + public ShipmentNo Quantity { get; set; } + + /// Never configured: TypeKit maps it. + public LabelText Label { get; set; } = null!; + + /// Never configured: TypeKit's converter composed onto the enum's. + public ProbeWeekday Day { get; set; } +} + +/// +/// Record classes: a struct over a string cannot be a property in a compiled model (the package README's +/// first EF limitation). +/// +public sealed partial record CodeText : IValue; + +public sealed partial record LabelText : IValue; + +/// Stores a code upper-cased: not what .Value answers. +public sealed class UpperCodeConverter() : ValueConverter(code => code.Value.ToUpperInvariant(), text => CodeText.From(text.ToLowerInvariant())); + +/// Stores a number as text. +public sealed class TextQuantityConverter() + : ValueConverter(quantity => quantity.Value.ToString(CultureInfo.InvariantCulture), text => ShipmentNo.From(int.Parse(text, CultureInfo.InvariantCulture))); + +public sealed class ForeignConvertersDb(DbContextOptions options, ForeignConversion conversion) : DbContext(options) +{ + public DbSet Coded => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + if (conversion != ForeignConversion.ModelConvention) return; + + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var coded = modelBuilder.Entity(); + + switch (conversion) + { + case ForeignConversion.Lambda: + coded.Property(c => c.Code).HasConversion(PrefixedCode, code => CodeText.From(code.Substring(2))); + coded.Property(c => c.Quantity).HasConversion(quantity => quantity.Value.ToString(CultureInfo.InvariantCulture), text => ShipmentNo.From(int.Parse(text, CultureInfo.InvariantCulture))); + break; + case ForeignConversion.ConverterType: + coded.Property(c => c.Code).HasConversion(); + coded.Property(c => c.Quantity).HasConversion(); + break; + case ForeignConversion.ExplicitTypeKit: + coded.Property(c => c.Code).HasConversion>(); + coded.Property(c => c.Quantity).HasConversion>(); + break; + } + } + + public ForeignConversion Conversion => conversion; + + private static readonly Expression> PrefixedCode = code => "C:" + code.Value; +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs new file mode 100644 index 0000000..d5cad7a --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -0,0 +1,239 @@ +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// A value object that declares its own CompareTo cannot be compared or ordered in SQL, where the +/// column orders by the wrapped value: the query is refused, naming the value object and .Value. +/// +/// +/// Measured on SQLite before the refusal, with holding 1, 2 and 3 (its +/// CompareTo orders them 3, 2, 1): Rank > 2 answered 3 where memory answers 1, +/// CompareTo(2) > 0 the same, OrderBy(Rank) 1, 2, 3 where memory sorts 3, 2, 1, and +/// Max(Rank) 3 where memory answers 1. +/// +public sealed class HandWrittenOrderingTests : IDisposable +{ + private static readonly ProbeDescending Two = ProbeDescending.From(2); + + private static readonly Dictionary> Refused = new() + { + ["greater than"] = db => db.Ranked.Where(r => r.Rank > Two).ToList(), + ["less than or equal"] = db => db.Ranked.Where(r => r.Rank <= Two).ToList(), + ["an optional one"] = db => db.Ranked.Where(r => r.Optional < Two).ToList(), + ["CompareTo"] = db => db.Ranked.Where(r => r.Rank.CompareTo(Two) > 0).ToList(), + ["in a projection"] = db => db.Ranked.Select(r => r.Rank >= Two).ToList(), + ["OrderBy"] = db => db.Ranked.OrderBy(r => r.Rank).ToList(), + ["OrderByDescending"] = db => db.Ranked.OrderByDescending(r => r.Rank).ToList(), + ["ThenBy"] = db => db.Ranked.OrderBy(r => r.Id).ThenBy(r => r.Rank).ToList(), + ["ThenByDescending"] = db => db.Ranked.OrderBy(r => r.Id).ThenByDescending(r => r.Optional).ToList(), + ["Order"] = db => db.Ranked.Select(r => r.Rank).Order().ToList(), + ["Min"] = db => db.Ranked.Min(r => r.Rank), + ["Max"] = db => db.Ranked.Select(r => r.Rank).Max(), + ["a record class"] = db => db.Ranked.OrderBy(r => r.Label).ToList(), + + // Over a navigation the operators are Enumerable's: the first answered 1 where memory answers 3, + // the second 3 where memory answers 1. + ["OrderBy over a navigation"] = db => db.Parents.Select(p => p.Children.OrderBy(c => c.Rank).Select(c => c.Rank.Value).First()).ToList(), + ["Max over a navigation"] = db => db.Parents.Select(p => p.Children.Max(c => c.Rank)).ToList(), + + // Boxed, the key still orders by CompareTo in memory, 3, 2, 1; EF dropped the cast and answered 1, 2, 3. + ["OrderBy a key cast to object"] = db => db.Ranked.OrderBy(r => (object)r.Rank).ToList(), + ["ThenBy a key cast to object"] = db => db.Ranked.OrderBy(r => r.Id % 1).ThenBy(r => (object)r.Rank).ToList(), + }; + + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + public static TheoryData RefusedQueries => [.. Refused.Keys]; + + [Theory] + [MemberData(nameof(RefusedQueries))] + public void A_query_that_orders_a_value_object_by_its_own_CompareTo_is_refused(string query) + { + using var db = Seeded(); + + var refusal = Should.Throw(() => Refused[query](db)).Message; + + refusal.ShouldContain("its ordering is its own CompareTo, which the database cannot run"); + refusal.ShouldContain(query == "a record class" ? "ProbeReversedLabel" : "ProbeDescending"); + refusal.ShouldContain("Compare or order by .Value explicitly"); + } + + /// + /// .Value says the wrapped value's order is meant, equality is no ordering, and a value object + /// ordered by what it wraps orders in SQL as in memory. + /// + [Fact] + public void Ordering_by_Value_equality_and_a_generated_ordering_still_translate() + { + using var db = Seeded(); + + db.Ranked.Where(r => r.Rank.Value > 2).Select(r => r.Rank.Value).ToList().ShouldBe([3]); + db.Ranked.OrderByDescending(r => r.Rank.Value).Select(r => r.Rank.Value).ToList().ShouldBe([3, 2, 1]); + db.Ranked.Select(r => r.Rank).OrderBy(rank => rank.Value).ToList().ShouldBe([ProbeDescending.From(1), Two, ProbeDescending.From(3)]); + db.Ranked.Max(r => r.Rank.Value).ShouldBe(3); + db.Ranked.Count(r => r.Rank == Two).ShouldBe(1); + db.Ranked.Count(r => r.Rank != Two).ShouldBe(2); + + db.Ranked.Where(r => r.Count > ProbeCount.From(1)).OrderBy(r => r.Count).Select(r => r.Count.Value).ToList().ShouldBe([2, 3]); + db.Ranked.Max(r => r.Count).ShouldBe(ProbeCount.From(3)); + + db.Parents.Select(p => p.Children.OrderByDescending(c => c.Rank.Value).Select(c => c.Rank.Value).First()).ToList().ShouldBe([3]); + db.Parents.Select(p => p.Children.Max(c => c.Rank.Value)).ToList().ShouldBe([3]); + } + + /// Two value objects captured from the query's closure compare in memory, before EF sees the query. + [Fact] + public void A_comparison_EF_evaluates_in_memory_is_not_refused() + { + using var db = Seeded(); + var one = ProbeDescending.From(1); + + db.Ranked.Count(r => one > Two).ShouldBe(3); + } + + /// + /// EF adds a query filter to the query after the interceptor has seen it, so a filter that compares + /// such a value object answered by the column's order: Rank > From(2) kept 3 where memory + /// keeps 1. The filter is refused when the model is built, named or not, naming the entity type. + /// + [Theory] + [InlineData(typeof(FilteredByRankDb), "the query filter of FilteredRank")] + [InlineData(typeof(NamedFilterByRankDb), "the query filter \"AboveTwo\" of FilteredRank")] + [InlineData(typeof(FilteredByChildRankDb), "the query filter of RankParent")] + public void A_query_filter_that_orders_by_its_own_CompareTo_is_refused(Type context, string filter) + { + _connection.Open(); + using var db = (DbContext)Activator.CreateInstance(context, new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options)!; + + var refusal = Should.Throw(() => + { + db.Database.EnsureCreated(); + return db.Set().Select(r => r.Rank.Value).ToList(); + }).Message; + + refusal.ShouldContain($"ProbeDescending is compared with > in {filter}"); + refusal.ShouldContain("its ordering is its own CompareTo, which the database cannot run"); + refusal.ShouldContain("Compare or order by .Value explicitly"); + } + + /// + /// A filter by .Value is the column's order, and a comparison that reads nothing of the entity + /// is evaluated in memory, where CompareTo runs: Floor > From(2) is true for the + /// descending order, so the rows above 1 are kept, where SQL's 1 > 2 would have kept none. + /// + [Fact] + public void A_query_filter_by_Value_or_in_memory_is_not_refused() + { + _connection.Open(); + using var db = new FilteredByValueDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options); + db.Database.EnsureCreated(); + foreach (var value in new[] { 1, 2, 3 }) db.Add(new FilteredRank { Rank = ProbeDescending.From(value) }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + db.Set().Select(r => r.Rank.Value).Order().ToList().ShouldBe([2, 3]); + } + + public void Dispose() => _connection.Dispose(); + + private RankedDb Seeded() + { + _connection.Open(); + var db = new RankedDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options); + db.Database.EnsureCreated(); + + foreach (var value in new[] { 1, 2, 3 }) + db.Add(new Ranked { Rank = ProbeDescending.From(value), Count = ProbeCount.From(value), Label = ProbeReversedLabel.From($"{value}") }); + + db.Add(new RankParent { Children = [new RankChild { Rank = ProbeDescending.From(1) }, new RankChild { Rank = ProbeDescending.From(3) }] }); + + db.SaveChanges(); + db.ChangeTracker.Clear(); + + return db; + } +} + +public sealed class Ranked +{ + public int Id { get; set; } + + /// Ordered by its own CompareTo, descending. + public ProbeDescending Rank { get; set; } + + public ProbeDescending? Optional { get; set; } + + /// A record class ordered by its own CompareTo. + public ProbeReversedLabel Label { get; set; } = null!; + + /// Ordered by what it wraps. + public ProbeCount Count { get; set; } +} + +public sealed class RankParent +{ + public int Id { get; set; } + + public List Children { get; set; } = []; +} + +public sealed class RankChild +{ + public int Id { get; set; } + + public int ParentId { get; set; } + + public ProbeDescending Rank { get; set; } +} + +public sealed class RankedDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Ranked => Set(); + + public DbSet Parents => Set(); +} + +public sealed class FilteredRank +{ + public int Id { get; set; } + + public ProbeDescending Rank { get; set; } +} + +/// A query filter that compares by the hand-written ordering. +public sealed class FilteredByRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank > ProbeDescending.From(2)); +} + +/// The same as a named filter, beside one that is allowed. +public sealed class NamedFilterByRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity() + .HasQueryFilter("Positive", r => r.Rank.Value > 0) + .HasQueryFilter("AboveTwo", r => r.Rank > ProbeDescending.From(2)); +} + +/// A filter on another entity type, over its navigation. +public sealed class FilteredByChildRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity().HasQueryFilter(p => p.Children.Max(c => c.Rank) > ProbeDescending.From(2)); + } +} + +/// Filters that are allowed: by .Value, and a comparison of the context's own value, evaluated in memory. +public sealed class FilteredByValueDb(DbContextOptions options) : DbContext(options) +{ + public ProbeDescending Floor { get; set; } = ProbeDescending.From(1); + + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => Floor > ProbeDescending.From(2) && r.Rank.Value > 1); +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs new file mode 100644 index 0000000..915f018 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs @@ -0,0 +1,610 @@ +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// A value-object key over an integer is generated as a key of that integer is, in every mapping of an +/// inheritance hierarchy, compared with the same hierarchy keyed by a plain int, in the models +/// of SQLite, Npgsql and SQL Server. +/// +/// +/// EF decides a hierarchy root's key generation again when it sets the root's mapping strategy, in a +/// convention that runs while the model is finalized, and SQLite's provider decides from the CLR type: +/// the root of a TPH hierarchy keyed by a value object went back to never generated, and the second +/// entity added threw for a duplicate key, while PostgreSQL kept its identity column. +/// +public sealed class KeyGenerationTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + /// The providers a model is built for. SQL Server's is built without a server, for the model alone. + private static readonly string[] Providers = ["sqlite", "npgsql", "sqlserver"]; + + public static TheoryData Hierarchies + { + get + { + var hierarchies = new TheoryData(); + foreach (var provider in Providers) + { + hierarchies.Add(provider, typeof(TphByValueObject), typeof(TphByInt)); + hierarchies.Add(provider, typeof(TptByValueObject), typeof(TptByInt)); + hierarchies.Add(provider, typeof(TpcByValueObject), typeof(TpcByInt)); + hierarchies.Add(provider, typeof(AbstractTpcByValueObject), typeof(AbstractTpcByLong)); + } + return hierarchies; + } + } + + /// + /// The same decision as for the wrapped type: generated under TPH and TPT on every provider, and under + /// TPC only on PostgreSQL and SQL Server, from a sequence, since SQLite has none and each table would + /// number its own rows. An abstract TPC root included, whose key is mapped only to its derived types' tables. + /// + [Theory] + [MemberData(nameof(Hierarchies))] + public void A_hierarchy_root_s_key_is_generated_as_an_int_key_is(string provider, Type keyedByValueObject, Type keyedByInt) + { + using var db = Create(provider); + + var valueObjectKey = Key(db, keyedByValueObject); + var intKey = Key(db, keyedByInt); + + valueObjectKey.ValueGenerated.ShouldBe(intKey.ValueGenerated, $"{keyedByValueObject.Name} on {provider}"); + valueObjectKey.GetDefaultValueSql()?.Replace(keyedByValueObject.Name, "Root", StringComparison.Ordinal) + .ShouldBe(intKey.GetDefaultValueSql()?.Replace(keyedByInt.Name, "Root", StringComparison.Ordinal), $"{keyedByValueObject.Name} on {provider}"); + } + + /// + /// An abstract root has no table of its own, only its derived types' tables, and the provider's + /// sequence is the key's default in each of them: the convention looked for the root's own table, + /// found none, and took the generation away, so on PostgreSQL every entity was stored with 0. + /// + [Theory] + [InlineData("npgsql", """nextval('"AbstractTpcByValueObjectSequence"')""")] + [InlineData("sqlserver", "NEXT VALUE FOR [AbstractTpcByValueObjectSequence]")] + public void An_abstract_TPC_root_s_key_is_generated_from_its_sequence(string provider, string sequenceDefault) + { + using var db = Create(provider); + + var key = Key(db, typeof(AbstractTpcByValueObject)); + + key.ValueGenerated.ShouldBe(ValueGenerated.OnAdd); + key.GetDefaultValueSql().ShouldBe(sequenceDefault); + } + + /// What the review reproduced, on a database: a base entity and a derived one, each given its own key. + [Fact] + public void On_SQLite_a_TPH_root_s_key_is_generated_by_the_database() + { + _connection.Open(); + using var db = Create("sqlite"); + db.Database.EnsureCreated(); + + var root = new TphByValueObject { Name = "a" }; + var derived = new TphByValueObjectDerived { Name = "b", Extra = "x" }; + db.AddRange(root, derived); + db.SaveChanges(); + + root.Id.Value.ShouldBeGreaterThan(0); + derived.Id.Value.ShouldBeGreaterThan(0); + derived.Id.ShouldNotBe(root.Id); + } + + [Fact] + public void On_SQLite_a_TPT_root_s_key_is_generated_by_the_database() + { + _connection.Open(); + using var db = Create("sqlite"); + db.Database.EnsureCreated(); + + var root = new TptByValueObject { Name = "a" }; + var derived = new TptByValueObjectDerived { Name = "b", Extra = "x" }; + db.AddRange(root, derived); + db.SaveChanges(); + + root.Id.Value.ShouldBeGreaterThan(0); + derived.Id.ShouldNotBe(root.Id); + } + + public static TheoryData IntegerKeys + { + get + { + var keys = new TheoryData(); + foreach (var provider in Providers) + foreach (var (byValueObject, byWrapped) in IntegerKeysDb.Pairs) + keys.Add(provider, byValueObject, byWrapped); + return keys; + } + } + + /// + /// EF generates a key of every integer type but byte, unsigned ones included + /// (ValueGenerationConvention), and a key over one follows it. Those over uint, + /// ulong, ushort and sbyte were never generated. char, which EF counts + /// as an integer too, is the application's to assign (). + /// + [Theory] + [MemberData(nameof(IntegerKeys))] + public void A_key_over_an_integer_type_is_generated_as_a_key_of_that_type_is(string provider, Type keyedByValueObject, Type keyedByWrapped) + { + using var db = new IntegerKeysDb(Options(provider)); + + Key(db, keyedByValueObject).ValueGenerated.ShouldBe(Key(db, keyedByWrapped).ValueGenerated, $"{keyedByValueObject.Name} on {provider}"); + } + + public static TheoryData AllProviders => [.. Providers]; + + /// + /// No provider generates a char key: PostgreSQL refuses an identity on character(1), and + /// a plain one on SQL Server or SQLite reaches the insert without a value. Marked generated, the value + /// object's key on SQL Server got a character of a GUID string at Add, which was then stored + /// (EF picks the generator by the column's type, nvarchar). So it is not generated: the key the + /// application set is the key added, a default one included, as in 1.5.1. + /// + [Theory] + [MemberData(nameof(AllProviders))] + public void A_key_over_char_is_the_application_s_to_assign(string provider) + { + using var db = new CharAndULongKeysDb(Options(provider)); + + var unassigned = new KeyedByChar(); + var assigned = new KeyedByChar { Id = CharKey.From('B') }; + db.AddRange(unassigned, assigned); + + // The entry's value, which EF inserts; a temporary one is kept there, not on the entity. + var added = db.Entry(unassigned).Property(e => e.Id); + added.CurrentValue.Value.ShouldBe('\0', $"on {provider}, Add invented a key"); + added.IsTemporary.ShouldBeFalse($"on {provider}"); + unassigned.Id.Value.ShouldBe('\0', $"on {provider}, Add invented a key"); + assigned.Id.Value.ShouldBe('B'); + Key(db, typeof(KeyedByChar)).ValueGenerated.ShouldBe(ValueGenerated.Never, $"on {provider}"); + } + + /// The application's own configuration of a char key still wins: generated on add, or its own generator. + [Theory] + [MemberData(nameof(AllProviders))] + public void A_char_key_the_application_configures_keeps_its_configuration(string provider) + { + using var db = new CharAndULongKeysDb(Options(provider)); + + Key(db, typeof(GeneratedOnAddCharKey)).ValueGenerated.ShouldBe(ValueGenerated.OnAdd, $"on {provider}"); + + var own = new OwnGeneratorCharKey(); + db.Add(own); + own.Id.Value.ShouldBe('A', $"on {provider}"); + } + + /// + /// Pinned as documented, not as wanted: where the provider stores a ulong as a decimal + /// (SQL Server, PostgreSQL), EF gives a value object's default key a temporary decimal, which is + /// negative, and converting it back throws at Add; a plain ulong key gets a temporary + /// ulong. Configured ValueGeneratedNever(), the key the application assigns is added. + /// + [Theory] + [InlineData("sqlserver")] + [InlineData("npgsql")] + public void A_default_key_over_ulong_throws_at_Add_where_the_column_is_a_decimal(string provider) + { + using var db = new CharAndULongKeysDb(Options(provider)); + + Should.Throw(() => db.Add(new KeyedByUInt64())); + + var assigned = new AssignedUInt64Key { Id = UInt64Key.From(42) }; + db.Add(assigned); + + assigned.Id.Value.ShouldBe(42ul); + db.Entry(assigned).Property(e => e.Id).IsTemporary.ShouldBeFalse(); + Key(db, typeof(AssignedUInt64Key)).ValueGenerated.ShouldBe(ValueGenerated.Never); + } + + /// + /// Each integer type SQLite fills a key of, added twice. Not char: EF's own char key asks + /// SQLite for a TEXT column with AUTOINCREMENT, which it refuses. + /// + [Fact] + public void On_SQLite_a_key_over_each_integer_type_is_generated_by_the_database() + { + _connection.Open(); + using var db = new SqliteIntegerKeysDb(Options("sqlite")); + db.Database.EnsureCreated(); + + KeyedByInt16[] shorts = [new(), new()]; + KeyedByInt64[] longs = [new(), new()]; + KeyedBySByte[] sbytes = [new(), new()]; + KeyedByUInt16[] ushorts = [new(), new()]; + KeyedByUInt32[] uints = [new(), new()]; + KeyedByUInt64[] ulongs = [new(), new()]; + db.AddRange([.. shorts, .. longs, .. sbytes, .. ushorts, .. uints, .. ulongs]); + db.SaveChanges(); + + shorts.Select(e => e.Id.Value).ShouldBe([(short)1, (short)2]); + longs.Select(e => e.Id.Value).ShouldBe([1L, 2L]); + sbytes.Select(e => e.Id.Value).ShouldBe([(sbyte)1, (sbyte)2]); + ushorts.Select(e => e.Id.Value).ShouldBe([(ushort)1, (ushort)2]); + uints.Select(e => e.Id.Value).ShouldBe([1u, 2u]); + ulongs.Select(e => e.Id.Value).ShouldBe([1ul, 2ul]); + } + + public static TheoryData ForeignKeyKeys + { + get + { + var keys = new TheoryData(); + foreach (var provider in Providers) + foreach (var (byValueObject, byWrapped) in ForeignKeyKeysDb.Pairs) + keys.Add(provider, byValueObject, byWrapped); + return keys; + } + } + + /// + /// A key that is also a foreign key takes its value from the principal, so EF never generates it, + /// and a key over an integer is no exception: the dependent of a one-to-one sharing its principal's + /// key, one splitting its principal's table, and an owned type, whose key is its owner's. + /// + /// + /// Without the convention's foreign-key check each of them was generated on add on SQLite (measured); + /// Npgsql and SQL Server reset it themselves when they choose the key's value-generation strategy. + /// + [Theory] + [MemberData(nameof(ForeignKeyKeys))] + public void A_key_that_is_also_a_foreign_key_is_never_generated(string provider, Type dependentByValueObject, Type dependentByWrapped) + { + using var db = new ForeignKeyKeysDb(Options(provider)); + + var valueObjectKey = Key(db, dependentByValueObject); + + valueObjectKey.IsForeignKey().ShouldBeTrue(); + valueObjectKey.ValueGenerated.ShouldBe(Key(db, dependentByWrapped).ValueGenerated, $"{dependentByValueObject.Name} on {provider}"); + valueObjectKey.ValueGenerated.ShouldBe(ValueGenerated.Never, $"{dependentByValueObject.Name} on {provider}"); + } + + public void Dispose() => _connection.Dispose(); + + private HierarchyKeysDb Create(string provider) => new(Options(provider)); + + private DbContextOptions Options(string provider) where TDb : DbContext => provider switch + { + "sqlite" => new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options, + "npgsql" => new DbContextOptionsBuilder().UseNpgsql("Host=localhost;Database=none").UseTypeKit().Options, + "sqlserver" => new DbContextOptionsBuilder().UseSqlServer("Server=localhost;Database=none").UseTypeKit().Options, + _ => throw new ArgumentOutOfRangeException(nameof(provider), provider, null), + }; + + private static IProperty Key(DbContext db, Type root) => db.Model.FindEntityType(root)!.FindPrimaryKey()!.Properties.Single(); +} + +public class TphByValueObject +{ + public ShipmentNo Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TphByValueObjectDerived : TphByValueObject +{ + public string Extra { get; set; } = ""; +} + +public class TphByInt +{ + public int Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TphByIntDerived : TphByInt +{ + public string Extra { get; set; } = ""; +} + +public class TptByValueObject +{ + public ShipmentNo Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TptByValueObjectDerived : TptByValueObject +{ + public string Extra { get; set; } = ""; +} + +public class TptByInt +{ + public int Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TptByIntDerived : TptByInt +{ + public string Extra { get; set; } = ""; +} + +public class TpcByValueObject +{ + public ShipmentNo Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TpcByValueObjectDerived : TpcByValueObject +{ + public string Extra { get; set; } = ""; +} + +public class TpcByInt +{ + public int Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TpcByIntDerived : TpcByInt +{ + public string Extra { get; set; } = ""; +} + +/// A TPC root that is abstract, so mapped to no table of its own: the review's shape, keyed over long. +public abstract class AbstractTpcByValueObject +{ + public Int64Key Id { get; set; } + + public string Name { get; set; } = ""; +} + +public sealed class AbstractTpcByValueObjectCar : AbstractTpcByValueObject +{ + public int Doors { get; set; } +} + +public sealed class AbstractTpcByValueObjectBike : AbstractTpcByValueObject +{ + public int Gears { get; set; } +} + +public abstract class AbstractTpcByLong +{ + public long Id { get; set; } + + public string Name { get; set; } = ""; +} + +public sealed class AbstractTpcByLongCar : AbstractTpcByLong +{ + public int Doors { get; set; } +} + +public sealed class AbstractTpcByLongBike : AbstractTpcByLong +{ + public int Gears { get; set; } +} + +/// The abstract TPC root alone, for a database. +public sealed class AbstractTpcDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity(); + } +} + +/// +/// Each mapping strategy twice, keyed by a value object over int and by int; TPH is EF's +/// default. And an abstract TPC root twice, over long. +/// +public sealed class HierarchyKeysDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTptMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTptMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity(); + } +} + +public readonly partial record struct Int16Key : IValue; + +public readonly partial record struct Int64Key : IValue; + +public readonly partial record struct SByteKey : IValue; + +public readonly partial record struct UInt16Key : IValue; + +public readonly partial record struct UInt32Key : IValue; + +public readonly partial record struct UInt64Key : IValue; + +public readonly partial record struct ByteKey : IValue; + +public readonly partial record struct CharKey : IValue; + +public class KeyedByInt16 { public Int16Key Id { get; set; } } + +public class KeyedByShort { public short Id { get; set; } } + +public class KeyedByInt64 { public Int64Key Id { get; set; } } + +public class KeyedByLong { public long Id { get; set; } } + +public class KeyedBySByte { public SByteKey Id { get; set; } } + +public class KeyedBySbyte { public sbyte Id { get; set; } } + +public class KeyedByUInt16 { public UInt16Key Id { get; set; } } + +public class KeyedByUshort { public ushort Id { get; set; } } + +public class KeyedByUInt32 { public UInt32Key Id { get; set; } } + +public class KeyedByUint { public uint Id { get; set; } } + +public class KeyedByUInt64 { public UInt64Key Id { get; set; } } + +public class KeyedByUlong { public ulong Id { get; set; } } + +public class KeyedByByte { public ByteKey Id { get; set; } } + +public class KeyedByPlainByte { public byte Id { get; set; } } + +public class KeyedByChar { public CharKey Id { get; set; } } + +public class KeyedByPlainChar { public char Id { get; set; } } + +/// +/// A key over each integer type, beside a key of that type itself, for the model only. Not char, +/// which no provider generates and a value object's key leaves to the application (). +/// +public sealed class IntegerKeysDb(DbContextOptions options) : DbContext(options) +{ + public static readonly (Type ByValueObject, Type ByWrapped)[] Pairs = + [ + (typeof(KeyedByInt16), typeof(KeyedByShort)), + (typeof(KeyedByInt64), typeof(KeyedByLong)), + (typeof(KeyedBySByte), typeof(KeyedBySbyte)), + (typeof(KeyedByUInt16), typeof(KeyedByUshort)), + (typeof(KeyedByUInt32), typeof(KeyedByUint)), + (typeof(KeyedByUInt64), typeof(KeyedByUlong)), + (typeof(KeyedByByte), typeof(KeyedByPlainByte)), + ]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + foreach (var (byValueObject, byWrapped) in Pairs) + { + modelBuilder.Entity(byValueObject); + modelBuilder.Entity(byWrapped); + } + } +} + +/// A char key configured to be generated on add. +public class GeneratedOnAddCharKey { public CharKey Id { get; set; } } + +/// A char key given a value generator of the application's own. +public class OwnGeneratorCharKey { public CharKey Id { get; set; } } + +/// A ulong key the application assigns, as the README says to configure it. +public class AssignedUInt64Key { public UInt64Key Id { get; set; } } + +/// Hands out the letter A, as an application's own generator would hand out its keys. +public sealed class LetterKeyGenerator : Microsoft.EntityFrameworkCore.ValueGeneration.ValueGenerator +{ + public override bool GeneratesTemporaryValues => false; + + public override CharKey Next(Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry) => CharKey.From('A'); +} + +/// Keys over char and ulong, by convention and as an application configures them. +public sealed class CharAndULongKeysDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity().Property(e => e.Id).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(e => e.Id).HasValueGenerator(); + modelBuilder.Entity(); + modelBuilder.Entity().Property(e => e.Id).ValueGeneratedNever(); + } +} + +/// The keys over the integer types SQLite fills in. +public sealed class SqliteIntegerKeysDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + } +} + +/// The keys over the integer types PostgreSQL has an identity column for, beyond int and long. +public sealed class PostgreSqlIntegerKeysDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + } +} + +public class SharedKeyPrincipal { public ShipmentNo Id { get; set; } } + +public class SharedKeyDependent { public ShipmentNo Id { get; set; } } + +public class SharedKeyPrincipalInt { public int Id { get; set; } } + +public class SharedKeyDependentInt { public int Id { get; set; } } + +public class SplitHead { public ShipmentNo Id { get; set; } } + +public class SplitBody { public ShipmentNo Id { get; set; } } + +public class SplitHeadInt { public int Id { get; set; } } + +public class SplitBodyInt { public int Id { get; set; } } + +public class Owner { public ShipmentNo Id { get; set; } public OwnedPart Part { get; set; } = new(); } + +public class OwnedPart { public string Note { get; set; } = ""; } + +public class OwnerInt { public int Id { get; set; } public OwnedPartInt Part { get; set; } = new(); } + +public class OwnedPartInt { public string Note { get; set; } = ""; } + +/// +/// Three dependents whose key is a foreign key to their principal's, each beside the same shape keyed +/// by int: a one-to-one sharing the key, a one-to-one splitting the table, and an owned type. +/// +public sealed class ForeignKeyKeysDb(DbContextOptions options) : DbContext(options) +{ + public static readonly (Type ByValueObject, Type ByWrapped)[] Pairs = + [ + (typeof(SharedKeyDependent), typeof(SharedKeyDependentInt)), + (typeof(SplitBody), typeof(SplitBodyInt)), + (typeof(OwnedPart), typeof(OwnedPartInt)), + ]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasOne().WithOne().HasForeignKey(d => d.Id); + modelBuilder.Entity().HasOne().WithOne().HasForeignKey(d => d.Id); + + modelBuilder.Entity().ToTable("Split").HasOne().WithOne().HasForeignKey(b => b.Id); + modelBuilder.Entity().ToTable("Split"); + modelBuilder.Entity().ToTable("SplitInt").HasOne().WithOne().HasForeignKey(b => b.Id); + modelBuilder.Entity().ToTable("SplitInt"); + + modelBuilder.Entity().OwnsOne(o => o.Part); + modelBuilder.Entity().OwnsOne(o => o.Part); + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/MigrationSnapshotTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/MigrationSnapshotTests.cs index c4fc1d8..5c365ab 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/MigrationSnapshotTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/MigrationSnapshotTests.cs @@ -1,15 +1,11 @@ using System.Reflection; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations.Design; using Microsoft.EntityFrameworkCore.Migrations.Operations; -using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; @@ -122,36 +118,14 @@ private static string Describe(MigrationOperation operation) /// The first migration, as dotnet ef migrations add writes it, with the provider's design-time services. private static ScaffoldedMigration Scaffold(DbContext db) { - // The order and the lookup of EF's DesignTimeServicesBuilder: the context, the provider's services - // (named by the provider assembly's attribute), then EF's own, which are only added where missing. - var services = new ServiceCollection().AddDbContextDesignTimeServices(db); - var provider = Assembly.Load(new AssemblyName(db.GetService().Name)); - var providerServices = provider.GetCustomAttribute().ShouldNotBeNull($"{provider} names no design-time services."); - ((IDesignTimeServices)Activator.CreateInstance(provider.GetType(providerServices.TypeName, throwOnError: true)!)!).ConfigureDesignTimeServices(services); - services.AddEntityFrameworkDesignTimeServices(); - - using var designTime = services.BuildServiceProvider(); + using var designTime = DesignTime.Services(db); return designTime.GetRequiredService().ScaffoldMigration("Initial", "SnapshotProbe"); } private static ModelSnapshot Compile(string code, Assembly context) { - var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator) - .Append(context.Location) - .Distinct(StringComparer.Ordinal) - .Select(path => MetadataReference.CreateFromFile(path)); - var compilation = CSharpCompilation.Create( - $"Snapshot{Guid.NewGuid():N}", - [CSharpSyntaxTree.ParseText(code)], - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); - - using var image = new MemoryStream(); - var emitted = compilation.Emit(image); - emitted.Success.ShouldBeTrue($"The scaffolded snapshot does not compile:{Environment.NewLine}{string.Join(Environment.NewLine, emitted.Diagnostics)}{Environment.NewLine}{code}"); - - var snapshot = Assembly.Load(image.ToArray()).GetTypes().Single(type => type.IsSubclassOf(typeof(ModelSnapshot))); + var snapshot = DesignTime.Compile([code], context).GetTypes().Single(type => type.IsSubclassOf(typeof(ModelSnapshot))); return (ModelSnapshot)Activator.CreateInstance(snapshot)!; } diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs index 1e4994c..01f2659 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs @@ -124,6 +124,54 @@ public async Task An_integer_key_is_an_identity_column() second.Id.ShouldNotBe(first.Id); } + /// + /// A key over each integer type PostgreSQL has an identity column for, added twice: sbyte as + /// smallint, ushort as integer, uint as bigint, as their own keys are. + /// Not ulong, a numeric(20,0) that cannot be an identity, nor char, a + /// character(1) PostgreSQL refuses one on, either of them for a plain key too. + /// + [Fact] + public async Task A_key_over_each_integer_type_with_an_identity_is_numbered_by_the_database() + { + var cancellation = TestContext.Current.CancellationToken; + + await using var db = new PostgreSqlIntegerKeysDb(database.Options("typekit_keys")); + await db.Database.EnsureCreatedAsync(cancellation); + + KeyedByInt16[] shorts = [new(), new()]; + KeyedBySByte[] sbytes = [new(), new()]; + KeyedByUInt16[] ushorts = [new(), new()]; + KeyedByUInt32[] uints = [new(), new()]; + db.AddRange([.. shorts, .. sbytes, .. ushorts, .. uints]); + await db.SaveChangesAsync(cancellation); + + shorts.Select(e => e.Id.Value).ShouldBe([(short)1, (short)2], ignoreOrder: true); + sbytes.Select(e => e.Id.Value).ShouldBe([(sbyte)1, (sbyte)2], ignoreOrder: true); + ushorts.Select(e => e.Id.Value).ShouldBe([(ushort)1, (ushort)2], ignoreOrder: true); + uints.Select(e => e.Id.Value).ShouldBe([1u, 2u], ignoreOrder: true); + } + + /// + /// An abstract TPC root's key is numbered from the hierarchy's sequence, across its derived types' + /// tables, as a long key is. Without the generation the first car was stored with 0 and the + /// second refused as a duplicate. + /// + [Fact] + public async Task An_abstract_TPC_root_s_key_is_numbered_from_its_sequence() + { + var cancellation = TestContext.Current.CancellationToken; + + await using var db = new AbstractTpcDb(database.Options("typekit_tpc")); + await db.Database.EnsureCreatedAsync(cancellation); + + AbstractTpcByValueObject[] vehicles = + [new AbstractTpcByValueObjectCar { Name = "a" }, new AbstractTpcByValueObjectBike { Name = "b" }, new AbstractTpcByValueObjectCar { Name = "c" }]; + db.AddRange(vehicles); + await db.SaveChangesAsync(cancellation); + + vehicles.Select(vehicle => vehicle.Id.Value).ShouldBe([1L, 2L, 3L], ignoreOrder: true); + } + /// One PostgreSQL container and one schema for the class. public sealed class Database : IAsyncLifetime { @@ -132,6 +180,13 @@ public sealed class Database : IAsyncLifetime public TestDb Create() => new(_options ?? throw new InvalidOperationException("The database is not started.")); + /// Another database in the same container, for a model of its own. + public DbContextOptions Options(string databaseName) where TDb : DbContext => + new DbContextOptionsBuilder() + .UseNpgsql(new NpgsqlConnectionStringBuilder(_container.GetConnectionString()) { Database = databaseName }.ConnectionString) + .UseTypeKit() + .Options; + public async Task<(Customer Customer, Order Order)> Seed(Action? adjust = null) { var customer = new Customer { Id = CustomerId.New(), Name = $"customer {Guid.NewGuid():N}" }; diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs new file mode 100644 index 0000000..2270987 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs @@ -0,0 +1,162 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// FirstOrNone, FirstOrError and FirstOrNoneAsync on a query ask the database +/// for one row and let it run the condition. On the IEnumerable overloads, which were the only +/// ones, db.Shipments.FirstOrNone(s => …) read the whole table and ran the condition in +/// memory, with no warning. +/// +public sealed class QueryOptionTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + private readonly List _commands = []; + + public QueryOptionTests() + { + _connection.Open(); + using var db = Db(); + db.Database.EnsureCreated(); + db.Shipments.AddRange(new Shipment { Carrier = "alpha" }, new Shipment { Carrier = "beta" }, new Shipment { Carrier = "gamma" }); + db.SaveChanges(); + _commands.Clear(); + } + + public void Dispose() => _connection.Dispose(); + + private TestDb Db() => + new(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit() + .LogTo(_commands.Add, [RelationalEventId.CommandExecuted]).Options); + + /// EF passes the limit as a parameter, LIMIT @p, with 1 for its value. + [Fact] + public void FirstOrNone_runs_the_condition_in_the_query_and_reads_one_row() + { + using var db = Db(); + + db.Shipments.FirstOrNone(s => s.Carrier == "beta").TryGetValue(out var found).ShouldBeTrue(); + + found.Carrier.ShouldBe("beta"); + var sql = _commands.ShouldHaveSingleItem(); + sql.ShouldContain("WHERE \"s\".\"Carrier\" = 'beta'"); + sql.ShouldContain("LIMIT @p"); + } + + [Fact] + public void FirstOrNone_without_a_condition_reads_one_row_and_None_reads_none() + { + using var db = Db(); + + db.Shipments.OrderBy(s => s.Carrier).FirstOrNone().TryGetValue(out var first).ShouldBeTrue(); + first.Carrier.ShouldBe("alpha"); + _commands.ShouldHaveSingleItem().ShouldContain("LIMIT @p"); + + db.Shipments.FirstOrNone(s => s.Carrier == "delta").TryGetValue(out _).ShouldBeFalse(); + } + + /// A condition only the database can run, which in memory throws: proof that it is not run there. + [Fact] + public async Task A_condition_only_the_database_can_run_is_translated() + { + await using var db = Db(); + + db.Shipments.FirstOrNone(s => EF.Functions.Like(s.Carrier, "g%")).TryGetValue(out var sync).ShouldBeTrue(); + sync.Carrier.ShouldBe("gamma"); + + (await db.Shipments.FirstOrNoneAsync(s => EF.Functions.Like(s.Carrier, "b%"), TestContext.Current.CancellationToken)).TryGetValue(out var async).ShouldBeTrue(); + async.Carrier.ShouldBe("beta"); + + db.Shipments.FirstOrError(s => EF.Functions.Like(s.Carrier, "a%"), "none").TryGetValue(out var result, out _).ShouldBeTrue(); + result.Carrier.ShouldBe("alpha"); + } + + /// + /// What the documentation says of a condition EF cannot read. A lambda written at the call is an + /// expression and binds to the query's overload; a Func held in a variable, or a method group, + /// binds to the sequence's, which reads every row and runs the condition in memory. No analyzer + /// tells it yet, so this pins the behaviour the pages describe. + /// + [Fact] + public void A_Func_or_a_method_group_reads_every_row_and_runs_the_condition_in_memory() + { + using var db = Db(); + Func isBeta = s => s.Carrier == "beta"; + + db.Shipments.FirstOrNone(isBeta).TryGetValue(out var held).ShouldBeTrue(); + held.Carrier.ShouldBe("beta"); + var read = _commands.ShouldHaveSingleItem(); + read.ShouldNotContain("WHERE"); + read.ShouldNotContain("LIMIT"); + + _commands.Clear(); + db.Shipments.SingleOrNone(IsGamma).TryGetValue(out var grouped).ShouldBeTrue(); + grouped.Carrier.ShouldBe("gamma"); + _commands.ShouldHaveSingleItem().ShouldNotContain("WHERE"); + + _commands.Clear(); + db.Shipments.FirstOrError(isBeta, "none").TryGetValue(out _, out _).ShouldBeTrue(); + _commands.ShouldHaveSingleItem().ShouldNotContain("WHERE"); + + _commands.Clear(); + System.Linq.Expressions.Expression> isAlpha = s => s.Carrier == "alpha"; + db.Shipments.FirstOrNone(isAlpha).TryGetValue(out _).ShouldBeTrue(); + _commands.ShouldHaveSingleItem().ShouldContain("WHERE", Case.Sensitive, "an expression held in a variable still runs in the query"); + } + + private static bool IsGamma(Shipment shipment) => shipment.Carrier == "gamma"; + + /// The database runs the condition and is asked for two rows: enough to tell one from many. + [Fact] + public async Task SingleOrNone_asks_for_two_rows_and_refuses_more_than_one() + { + await using var db = Db(); + + db.Shipments.SingleOrNone(s => s.Carrier == "beta").TryGetValue(out var found).ShouldBeTrue(); + found.Carrier.ShouldBe("beta"); + var sql = _commands.ShouldHaveSingleItem(); + sql.ShouldContain("WHERE \"s\".\"Carrier\" = 'beta'"); + sql.ShouldContain("LIMIT @p"); + + Should.Throw(() => db.Shipments.SingleOrNone()); + db.Shipments.SingleOrNone(s => s.Carrier == "delta").TryGetValue(out _).ShouldBeFalse(); + + (await db.Shipments.SingleOrNoneAsync(s => EF.Functions.Like(s.Carrier, "g%"), TestContext.Current.CancellationToken)).TryGetValue(out var async).ShouldBeTrue(); + async.Carrier.ShouldBe("gamma"); + await Should.ThrowAsync(() => db.Shipments.SingleOrNoneAsync(TestContext.Current.CancellationToken)); + (await db.Shipments.Where(s => s.Carrier == "delta").SingleOrNoneAsync(TestContext.Current.CancellationToken)).TryGetValue(out _).ShouldBeFalse(); + } + + [Fact] + public void ElementAtOrNone_skips_to_the_row_in_the_query() + { + using var db = Db(); + + db.Shipments.OrderBy(s => s.Carrier).ElementAtOrNone(1).TryGetValue(out var second).ShouldBeTrue(); + second.Carrier.ShouldBe("beta"); + var sql = _commands.ShouldHaveSingleItem(); + sql.ShouldContain("LIMIT @p"); + sql.ShouldContain("OFFSET @p"); + + db.Shipments.ElementAtOrNone(3).TryGetValue(out _).ShouldBeFalse(); + _commands.Clear(); + db.Shipments.ElementAtOrNone(-1).TryGetValue(out _).ShouldBeFalse(); + _commands.ShouldBeEmpty("a negative index asks nothing of the database"); + } + + [Fact] + public async Task FirstOrNoneAsync_reads_one_row_and_answers_None_for_nothing_found() + { + await using var db = Db(); + + (await db.Shipments.FirstOrNoneAsync(s => s.Carrier == "gamma", TestContext.Current.CancellationToken)).TryGetValue(out var found).ShouldBeTrue(); + found.Carrier.ShouldBe("gamma"); + _commands.ShouldHaveSingleItem().ShouldContain("LIMIT @p"); + + (await db.Shipments.Where(s => s.Carrier == "delta").FirstOrNoneAsync(TestContext.Current.CancellationToken)).TryGetValue(out _).ShouldBeFalse(); + db.Shipments.FirstOrError(s => s.Carrier == "delta", "none").TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBe("none"); + } +} diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index 79b2f17..f6ff2d0 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -1,3 +1,4 @@ +using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.ValueObjects; namespace CodoMetis.TypeKit.Generators.Probes; @@ -76,6 +77,15 @@ public static partial class ProbeContainer public int CompareTo(ProbeDescending other) => other.Value.CompareTo(Value); } +/// +/// The comparison seam on a record class, whose operand is nullable: the declared ordering must be +/// recognised there too. +/// +public sealed partial record ProbeReversedLabel : IValue +{ + public int CompareTo(ProbeReversedLabel? other) => other is null ? 1 : string.CompareOrdinal(other.Value, Value); +} + /// /// Declares its own ToString(), the formatting seam, to hide what it wraps: it is kept, and no /// formatting interface is generated, so interpolation and string.Format reach it too. A record @@ -185,3 +195,147 @@ public abstract record ProbeMaskingRecord /// What every derived record prints. public sealed override string ToString() => "***"; } + +/// A closed set with no data and no constructor: the generators add a private one. +public sealed partial class ProbeStatus : IClosedSet +{ + public static readonly ProbeStatus Open = new(); + public static readonly ProbeStatus Shipped = new(); + public static readonly ProbeStatus Cancelled = new(); +} + +/// A closed set declared in two parts, this one and SplitProbes.cs: All lists the member of either. +public sealed partial class ProbeSplit : IClosedSet +{ + public static readonly ProbeSplit First = new(); +} + +/// Two nested sets of one simple name, told apart by their containers. +public static class ProbeOrder +{ + public sealed partial class Status : IClosedSet + { + public static readonly Status Open = new(); + } +} + +/// See . +public static class ProbeShipment +{ + public sealed partial class Status : IClosedSet + { + public static readonly Status Sent = new(); + } +} + +/// A closed set with constant data per member, through a private constructor. +public sealed partial class ProbeTier : IClosedSet +{ + public static readonly ProbeTier Basic = new(1, null); + public static readonly ProbeTier Premium = new(2, "Priority support."); + + private ProbeTier(int rank, string? perk) + { + Rank = rank; + Perk = perk; + } + + public int Rank { get; } + + public string? Perk { get; } +} + +/// A closed set with a hand-written ToString(), the one seam: kept, while the name stays what JSON and parsing use. +public sealed partial class ProbeLabelled : IClosedSet +{ + public static readonly ProbeLabelled Low = new("low priority"); + public static readonly ProbeLabelled High = new("high priority"); + + private readonly string _label; + + private ProbeLabelled(string label) => _label = label; + + /// The label, not the name. + public override string ToString() => _label; +} + +/// A closed set whose own code makes an instance beside its members, which therefore has no name. +public sealed partial class ProbeLevel : IClosedSet +{ + public static readonly ProbeLevel Low = new(); + public static readonly ProbeLevel High = new(); + + /// Not a member: the private constructor is still the set's own to call. + public static ProbeLevel Unlisted() => new(); +} + +/// +/// A closed set whose members are created in every form CMTK1019 accepts, with a comment, a qualified +/// name, an object initializer and a literal holding brackets, and which declares overloads of the +/// generated Parse and Match with other signatures: its own, kept. +/// +public sealed partial class ProbePriority : IClosedSet +{ + public static readonly ProbePriority Low = new(/* the lowest (1) */ 1); + public static readonly ProbePriority Normal = new global::CodoMetis.TypeKit.Generators.Probes.ProbePriority(2) { Label = "normal (the default)" }; + public static readonly ProbePriority High = new(3) { Label = "high" }; + + private ProbePriority(int rank) => Rank = rank; + + public int Rank { get; } + + public string? Label { get; private init; } + + /// The member of that rank: another signature than the generated Parse(string). + public static ProbePriority Parse(int rank) => rank switch { 1 => Low, 2 => Normal, _ => High }; + + /// A switch that answers nothing: another signature than the generated Match<TResult>. + public void Match(Action low, Action normal, Action high) + { + if (ReferenceEquals(this, Low)) low(); + else if (ReferenceEquals(this, Normal)) normal(); + else high(); + } +} + +/// +/// A closed set whose members are named as C# keywords once lower-cased: Match's parameters +/// are new and default, written @new: and @default: at a call. +/// +public sealed partial class ProbeKeyword : IClosedSet +{ + public static readonly ProbeKeyword New = new(); + public static readonly ProbeKeyword Default = new(); +} + +/// +/// A fault set: each member's kind, from which a host derives its status, and a description where the +/// member has one. Locked must not explain itself, so it has none. +/// +public sealed partial class ProbeOrderFault : IFaultSet +{ + public static readonly ProbeOrderFault NotFound = new(FaultKind.NotFound, "The order does not exist."); + public static readonly ProbeOrderFault NameTaken = new(FaultKind.Conflict, "Another order has that name."); + public static readonly ProbeOrderFault Locked = new(FaultKind.Forbidden, null); + + private ProbeOrderFault(FaultKind kind, string? description) + { + Kind = kind; + Description = description; + } + + public FaultKind Kind { get; } + + public string? Description { get; } +} + +/// A fault set with a member whose kind is default, which is no kind: refused where the faults are mapped. +public sealed partial class ProbeKindlessFault : IFaultSet +{ + public static readonly ProbeKindlessFault Known = new(FaultKind.Invalid); + public static readonly ProbeKindlessFault Unset = new(default); + + private ProbeKindlessFault(FaultKind kind) => Kind = kind; + + public FaultKind Kind { get; } +} diff --git a/test/CodoMetis.TypeKit.Generators.Probes/SplitProbes.cs b/test/CodoMetis.TypeKit.Generators.Probes/SplitProbes.cs new file mode 100644 index 0000000..16982d2 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Probes/SplitProbes.cs @@ -0,0 +1,7 @@ +namespace CodoMetis.TypeKit.Generators.Probes; + +/// The second part of , in its own file: its member's field initializer runs apart from the first part's. +public sealed partial class ProbeSplit +{ + public static readonly ProbeSplit Second = new(); +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/AllocationAndNumberHandlingTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/AllocationAndNumberHandlingTests.cs index 731b0db..504fdb2 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/AllocationAndNumberHandlingTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/AllocationAndNumberHandlingTests.cs @@ -170,6 +170,36 @@ public void A_number_follows_the_options_number_handling_exactly_as_the_wrapped_ } } + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public sealed record RawCounted(int Count); + + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public sealed record CountedOnTheType(ProbeCount Count); + + public sealed record CountedOnTheProperty([property: JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] ProbeCount Count); + + public sealed record Counted(ProbeCount Count); + + /// + /// What the docs state as the one exception to "the wrapped type's JSON": number handling given by + /// an attribute. On the containing type it reaches the numbers the serializer writes itself and not + /// a value object, whose converter cannot see it; on the property the serializer refuses it for + /// anything but a number. The options carry it to the value object. Pinned so the docs cannot say + /// more than holds. + /// + [Fact] + public void Number_handling_by_attribute_does_not_reach_a_value_object_and_the_options_do() + { + JsonSerializer.Serialize(new RawCounted(5)).ShouldBe("""{"Count":"5"}"""); + JsonSerializer.Serialize(new CountedOnTheType(ProbeCount.From(5))).ShouldBe("""{"Count":5}"""); + Should.Throw(() => JsonSerializer.Deserialize("""{"Count":"5"}""")); + Should.Throw(() => JsonSerializer.Serialize(new CountedOnTheProperty(ProbeCount.From(5)))); + + var options = new JsonSerializerOptions { NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString }; + JsonSerializer.Serialize(new Counted(ProbeCount.From(5)), options).ShouldBe("""{"Count":"5"}"""); + JsonSerializer.Deserialize("""{"Count":"5"}""", options)!.Count.ShouldBe(ProbeCount.From(5)); + } + private static string Outcome(Func action) { try diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 66ecc78..e14a448 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -31,6 +31,8 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1005", "Outer.Middle.DeeplyNested")] [InlineData("CMTK1005", "ArrayBacked")] [InlineData("CMTK1005", "NullableBacked")] + [InlineData("CMTK1005", "ObjectBacked")] + [InlineData("CMTK1005", "EnumBacked")] [InlineData("CMTK1005", "DerivesFromAValueObject")] [InlineData("CMTK1005", "WrapsItself")] [InlineData("CMTK1005", "CycleOne")] @@ -44,6 +46,11 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1005", "TupleBacked")] [InlineData("CMTK1005", "NamedTupleBacked")] [InlineData("CMTK1005", "Named.Value")] + [InlineData("CMTK1005", "VoNames.Parse")] + [InlineData("CMTK1005", "VoNames.From")] + [InlineData("CMTK1005", "VoNames.TryFrom")] + [InlineData("CMTK1005", "VoNames.CompareTo")] + [InlineData("CMTK1005", "VoNames.MinValue")] [InlineData("CMTK1005", "FileLocal")] [InlineData("CMTK1005", "FileLocalBelowRegion")] [InlineData("CMTK1005", "FileLocalBelowPragma")] @@ -74,10 +81,12 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1011", "HandWrittenFrom")] [InlineData("CMTK1011", "HandWrittenValue")] [InlineData("CMTK1011", "HandWrittenParse")] + [InlineData("CMTK1011", "HandWrittenParseOverASet")] [InlineData("CMTK1011", "HandWrittenFormat")] [InlineData("CMTK1011", "HandWrittenMinValue")] [InlineData("CMTK1011", "HandWrittenJsonConverter")] [InlineData("CMTK1011", "HandWrittenTypeConverter")] + [InlineData("CMTK1011", "HandWrittenOrdering")] [InlineData("CMTK1011", "HandWrittenInterface")] [InlineData("CMTK1011", "ExplicitCreate")] [InlineData("CMTK1011", "ExplicitCreateBesidePublic")] @@ -104,9 +113,108 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1012", "WithRequiredMember")] [InlineData("CMTK1012", "WithInheritedState")] [InlineData("CMTK1012", "WithEvent")] + [InlineData("CMTK1013", "SetNotPartial")] + [InlineData("CMTK1014", "SetNotSealed")] + [InlineData("CMTK1015", "SetWithoutMembers")] + [InlineData("CMTK1016", "SetWithPublicConstructor")] + [InlineData("CMTK1017", "SetWithProperty")] + [InlineData("CMTK1017", "SetWithMutableField")] + [InlineData("CMTK1017", "SetWithInternalMember")] + [InlineData("CMTK1018", "SetGeneric")] + [InlineData("CMTK1016", "SetWithInternalConstructor")] + [InlineData("CMTK1019", "SetWithAlias.Legacy")] + [InlineData("CMTK1019", "SetWithFactoryMember.B")] + [InlineData("CMTK1019", "SetWithUninitializedMember.B")] + [InlineData("CMTK1019", "SetWithTrailingCall.B")] + [InlineData("CMTK1020", "SetRecord")] + [InlineData("CMTK1014", "SetAbstract")] + [InlineData("CMTK1022", "AbstractRecord")] + [InlineData("CMTK1022", "AbstractClass")] + [InlineData("CMTK1022", "AbstractValidated")] + [InlineData("CMTK1022", "AbstractThroughInterface")] + [InlineData("CMTK1022", "AbstractHolder.AbstractNested")] + [InlineData("CMTK1011", "SetWithName")] + [InlineData("CMTK1011", "SetWithExplicitAll")] + [InlineData("CMTK1011", "SetWithParsable")] + [InlineData("CMTK1011", "SetWithTypeConverterAttribute")] + [InlineData("CMTK1011", "SetWithJsonConverterAttribute")] + [InlineData("CMTK1021", "SetWithMemberNamedParse.Parse")] + [InlineData("CMTK1021", "SetWithMemberNamedToString.ToString")] + [InlineData("CMTK1021", "SetWithMemberNamedAll.All")] + [InlineData("CMTK1021", "SetWithCasedMembers.open")] + [InlineData("CMTK1021", "SetWithCaseVariants.EXPRESS")] + [InlineData("CMTK1021", "SetNames.Name")] + [InlineData("CMTK1021", "SetNames.Match")] + [InlineData("CMTK1011", "SetWithIgnoreCaseTryParse")] + [InlineData("CMTK1011", "SetWithIgnoreCaseParse")] public void A_declaration_that_cannot_be_generated_is_an_error(string id, string type) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.Contains($"'{type}"), $"{id} on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); + /// + /// A member named as a generated member failed the aspect (LAMA0506 beside Match, Parse and + /// TryParse, LAMA0611 beside ToString), or was CMTK1011 telling the author to remove it. Two members + /// whose names differ only in case are one name to TryParse(name, ignoreCase: true); where + /// the difference is the first letter's, they also gave Match one parameter twice (LAMA0611, CS0100), + /// and where it is elsewhere (Express, EXPRESS) the set was generated and the + /// ignore-case parse found the first declared. + /// + [Theory] + [InlineData("SetWithMemberNamedParse.Parse", "has the name of a member the generators introduce")] + [InlineData("SetWithMemberNamedAll.All", "has the name of a member the generators introduce")] + [InlineData("SetWithCasedMembers.open", "differs from 'SetWithCasedMembers.Open' only in case, which Match's parameters and TryParse(name, ignoreCase: true) cannot tell apart")] + [InlineData("SetWithCaseVariants.EXPRESS", "differs from 'SetWithCaseVariants.Express' only in case, which TryParse(name, ignoreCase: true) cannot tell apart")] + public void A_member_name_the_generators_take_is_refused_with_the_reason(string member, string reason) => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1021" && error.Message.StartsWith($"'{member}' {reason}, so ") && error.Message.Contains("rename the member"), consumer.Output); + + /// + /// What the generators implement and attach, written by hand, failed the aspect or the generated + /// code: IParsable twice, an attribute twice (CS0579). The error names each. + /// + [Theory] + [InlineData("SetWithParsable", "the interface IParsable")] + [InlineData("SetWithTypeConverterAttribute", "the attribute TypeConverter")] + [InlineData("SetWithJsonConverterAttribute", "the attribute JsonConverter")] + public void What_the_generators_implement_or_attach_is_refused_by_name_on_a_set(string type, string declared) => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1011" && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(declared), consumer.Output); + + /// + /// A member whose name starts with the Kelvin sign (U+212A) and one that starts with K are two names + /// to TryParse(name, ignoreCase: true), which ignores case ordinally, and one to Match, + /// whose parameters lower-case the first letter: both are kelvin, and the set failed inside + /// the generated code (LAMA0611, CS0100). CMTK1021 groups by each transform. + /// + [Fact] + public void A_member_whose_Match_parameter_another_has_is_refused_with_the_reason() + { + var kelvinSign = ((char)0x212A).ToString(); + + consumer.Errors.ShouldContain(error => error.Id == "CMTK1021" + && error.Message.StartsWith($"'SetWithKelvinSign.{kelvinSign}elvin' gives Match the parameter name 'kelvin' that 'SetWithKelvinSign.Kelvin' gives it too, so ") + && error.Message.Contains("rename the member"), consumer.Output); + } + + [Fact] + public void A_member_is_never_told_to_be_removed_as_a_hand_written_generated_member() => + consumer.Errors.ShouldNotContain(error => error.Id == "CMTK1011" && error.Message.Contains("SetWithMemberNamed"), consumer.Output); + + /// + /// Before weaving, where Metalama runs analyzers, the generated Match does not exist in the project + /// that declares the set; CMTK0012 still reports a call by position, with the parameter names the + /// generators give, which the named call beside it compiles with. + /// + [Fact] + public void CMTK0012_reports_a_call_by_position_with_the_generators_names_in_the_declaring_project() => + consumer.Errors.ShouldContain(error => error.Id == "CMTK0012" && error.Message.StartsWith("'SetToMatch.Match' is given an argument by position") && error.Message.Contains("(@new:, open:)"), consumer.Output); + + /// + /// A member assigned in the static constructor compiles, and the generators named it before that + /// body ran: Closed.Match took the open branch, and Parse("Open") was written as + /// "Closed". CMTK0013 reports it in the project that declares the set, beside the weaving. + /// + [Fact] + public void CMTK0013_reports_a_member_assigned_in_the_static_constructor() => + consumer.Errors.ShouldContain(error => error.Id == "CMTK0013" && error.Message.StartsWith("'SetReassigned.Open' is written outside its declaration"), consumer.Output); + /// /// Every value object over another one is refused, so a cycle would be refused without this /// reason too, but not told that it loops, or through which types. @@ -129,6 +237,60 @@ public void A_value_object_over_a_value_object_is_refused_with_what_it_reaches(s public void A_generic_wrapped_type_is_refused_with_the_reason(string type, string reason) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith($"'{type}'") && error.Message.Contains(reason) && error.Message.Contains("compares by reference"), consumer.Output); + /// + /// A type named as a member the generators introduce into it failed inside the generated code + /// (LAMA0611, CS0542: a member cannot have the name of its type), naming nothing the user wrote: + /// a closed set named Name, a value object named Parse. Each is refused by name. + /// + [Theory] + [InlineData("CMTK1021", "SetNames.Name", "'SetNames.Name' has the name of a member the generators introduce into it, and a member cannot have the name of its type, so 'SetNames.Name' is not generated: rename the set.")] + [InlineData("CMTK1005", "VoNames.Parse", "'VoNames.Parse' cannot be generated as a value object: a value object cannot be named 'Parse', the name of a member the generators introduce, since a member cannot have the name of its type; rename it.")] + public void A_type_named_as_a_generated_member_is_refused_by_name(string id, string type, string message) => + consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith(message), $"{id} on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); + + /// + /// System.Enum is no enum: its generic Enum.Parse<TEnum>(string) was taken for a + /// Parse of its own, and the generated parsing failed (LAMA0611, CS0411). + /// + [Fact] + public void A_value_object_over_System_Enum_is_refused_with_the_reason() => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith("'EnumBacked' cannot be generated as a value object: the wrapped type 'Enum' is the base of every enum and no enum itself; wrap the enum type"), consumer.Output); + + /// + /// The companion class names the value object outside it, and an [Obsolete(error: true)] on + /// the value object, or on a type it is nested in, failed there (LAMA0611, CS0619). The companion + /// carries the attribute now, and inside an obsolete declaration C# reports no use of one. + /// + [Theory] + [InlineData("Retired")] + [InlineData("RetiredHolder")] + public void An_obsolete_value_object_is_generated(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}"), consumer.Output); + + /// + /// A value object over object compiled, and its converter wrote OverObject.From(5) as + /// {} where an object property holding 5 is written as 5: the serializer writes an + /// object by its run-time type, which a converter called directly does not. + /// + [Fact] + public void A_value_object_over_object_is_refused_with_the_reason() => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith("'ObjectBacked' cannot be generated as a value object: the wrapped type 'object' holds a value of any type") + && error.Message.Contains("wrap the type the value has"), consumer.Output); + + /// + /// An interface or an abstract class is written and read as the serializer writes and reads it (its + /// declared members, polymorphic where the type declares it, refused on reading where the serializer + /// refuses it), so it is wrapped. A guard against the refusal of object reaching further. A type + /// whose only static Parse(string) is generic is wrapped without parsing: the generic one was + /// taken for its own and failed inside the generated code (LAMA0611, CS0411), as for System.Enum. + /// + [Theory] + [InlineData("InterfaceBacked")] + [InlineData("AbstractClassBacked")] + [InlineData("GenericParserBacked")] + public void An_interface_or_an_abstract_class_is_wrapped(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); + /// /// Metalama writes its override of the record's ToString() into every part of a declaration, /// and the second copy failed inside the generated code (LAMA0611, CS0111). @@ -154,10 +316,12 @@ public void A_part_a_source_generator_adds_does_not_count() => [InlineData("HandWrittenFrom", "HandWrittenFrom.From(string)")] [InlineData("HandWrittenValue", "HandWrittenValue.Value")] [InlineData("HandWrittenParse", "HandWrittenParse.Parse(string, IFormatProvider?)")] + [InlineData("HandWrittenParseOverASet", "HandWrittenParseOverASet.Parse(string, IFormatProvider?)")] [InlineData("HandWrittenFormat", "HandWrittenFormat.ToString(string?, IFormatProvider?)")] [InlineData("HandWrittenMinValue", "HandWrittenMinValue.MinValue")] [InlineData("HandWrittenJsonConverter", "the attribute JsonConverter")] [InlineData("HandWrittenTypeConverter", "the attribute TypeConverter")] + [InlineData("HandWrittenOrdering", "the attribute HandWrittenOrdering")] [InlineData("HandWrittenInterface", "the interface IValueObject")] [InlineData("ExplicitCreate", "Create only as an explicit implementation of IValidatedValue")] [InlineData("ExplicitCreateBesidePublic", "Create both as a static method and as an explicit implementation of IValidatedValue, so it is not generated: every generated way in calls ExplicitCreateBesidePublic.Create and generic code calling T.Create reaches the explicit one")] @@ -276,6 +440,19 @@ public void A_seam_or_a_foreign_bound_is_not_refused(string type, string alsoNot public void A_hand_written_constructor_is_refused_by_name(string type, string constructor) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1009" && error.Message.StartsWith($"'{type}' declares {constructor},"), $"CMTK1009 on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); + /// + /// An abstract type with a marker was given no aspect and no word: it compiled with no member + /// generated, and a value held as it, of a sealed type derived from it, had no Value and was + /// written as {}. It is refused where it is declared, once, as a value object it cannot be. + /// + [Fact] + public void An_abstract_value_object_is_refused_once_with_the_reason() => + consumer.Errors.Where(error => error.Message.Contains("'AbstractRecord'")) + .ShouldHaveSingleItem(consumer.Output) + .ShouldSatisfyAllConditions( + error => error.Id.ShouldBe("CMTK1022"), + error => error.Message.ShouldStartWith("'AbstractRecord' is abstract, so it is not generated: an abstract type cannot be a value object. A value held as 'AbstractRecord' would have no Value, and its JSON would be '{}'.")); + /// A static constructor makes no instance, so the value object is still generated. [Fact] public void A_static_constructor_is_allowed() => @@ -284,12 +461,37 @@ public void A_static_constructor_is_allowed() => /// /// The declarations are refused with the errors above, never by an aspect that failed on them: /// an exception in an aspect or a compile error in the code it generated surfaces as LAMA0041 or - /// LAMA0611, which tells the user nothing about their declaration. + /// LAMA0611, which tells the user nothing about their declaration. CS0535 is the one compiler + /// error that accompanies a refused closed set: its interface has members the generators would + /// have implemented, and the CMTK error beside it says why they did not. A declaration whose error is + /// the compiler's own is built apart (). /// [Fact] public void Every_error_is_one_of_the_intended_ones() => consumer.Errors.Select(error => error.Id).Distinct().Order() - .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012"], ignoreOrder: false, customMessage: consumer.Output); + .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK0012", "CMTK0013", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CMTK1021", "CMTK1022", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); + + /// + /// CS0535 is allowed above only for a refused closed set, whose interface members the generators + /// did not implement. A value object or a generated set that lost a member the same way is a + /// regression the list of ids alone would let through. + /// + /// + /// A compiler error in a project hid every LAMA0611 inside a declaration's own file, so the build + /// that holds the value objects has none: what an aspect generated that does not compile is seen + /// there, by . + /// + [Fact] + public void The_declarations_build_without_a_compiler_error() => + consumer.DeclarationErrors.ShouldNotContain(error => error.Id.StartsWith("CS", StringComparison.Ordinal), consumer.Output); + + [Fact] + public void CS0535_names_only_a_refused_closed_set() => + consumer.Errors.Where(error => error.Id == "CS0535") + .ShouldAllBe(error => RefusedClosedSet().IsMatch(error.Message), consumer.Output); + + [GeneratedRegex(@"^'(?:Consumer\.)?Set\w+(?:\.\w+)?(?:)?' does not implement interface member ")] + private static partial Regex RefusedClosedSet(); /// /// A generator error cannot carry a help link as an analyzer rule does (a Metalama 2026.1 @@ -303,7 +505,7 @@ public void Every_build_error_links_to_the_Build_errors_table() var errors = consumer.Errors.Where(error => error.Id.StartsWith("CMTK1", StringComparison.Ordinal)).ToList(); - errors.Select(error => error.Id).Distinct().Count().ShouldBe(13, $"Not every CMTK1000-CMTK1012 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); + errors.Select(error => error.Id).Distinct().Count().ShouldBe(23, $"Not every CMTK1000-CMTK1022 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); errors.Where(error => !error.Message.EndsWith($". See {link}", StringComparison.Ordinal)) .Select(error => $"{error.Id}: {error.Message}") .ShouldBeEmpty("These errors do not end with the link to the Build errors table."); @@ -368,10 +570,30 @@ public sealed partial class Consumer : IAsyncLifetime private const string Declarations = """ using CodoMetis.TypeKit; + using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.ValueObjects; namespace Consumer; + // Generated; the calls below are CMTK0012's, in the project that declares the set. + public sealed partial class SetToMatch : IClosedSet { public static readonly SetToMatch New = new(); public static readonly SetToMatch Open = new(); } + + public static class MatchCalls + { + public static int ByPosition(SetToMatch set) => set.Match(() => 1, () => 2); + + public static int Named(SetToMatch set) => set.Match(@new: () => 1, open: () => 2); + } + + // Generated, and named before the static constructor's body, which then swaps a member: CMTK0013's. + public sealed partial class SetReassigned : IClosedSet + { + public static readonly SetReassigned Open = new(); + public static readonly SetReassigned Closed = new(); + + static SetReassigned() { Open = Closed; } + } + public readonly record struct NotPartial : IValue; public readonly partial struct NotRecord : IValue; @@ -405,12 +627,43 @@ public static class Outer { public static class Middle { public readonly part // refusal, the generated JSON converter and parsing fail to compile (LAMA0611/0612). public readonly partial record struct NullableBacked : IValue; + // Written as {} where an object property holding the same value is written as that value. + public readonly partial record struct ObjectBacked : IValue; + + // Written and read as the serializer writes and reads the declared type: wrapped. + public readonly partial record struct InterfaceBacked : IValue; + + public readonly partial record struct AbstractClassBacked : IValue; + + public sealed class GenericParser { public static T Parse(string text) where T : new() => new(); } + + public readonly partial record struct GenericParserBacked : IValue; + // A record class that can be derived from: equality across a hierarchy is not value // equality, and a derived value object failed inside the generated code (LAMA0611). public partial record NotSealed : IValue; public sealed partial record DerivesFromAValueObject : NotSealed; + // Abstract: given no aspect and no word, while a value held as one, of a sealed type + // derived from it, had no Value and was written as {}. + public abstract partial record AbstractRecord : IValue; + + public sealed partial record DerivesFromAbstract : AbstractRecord; + + public abstract class AbstractClass : IValue { } + + public abstract partial record AbstractValidated : IValidatedValue + { + public static Result Create(int value) => Result.Error(Fault.Refused); + } + + public interface IAbstractIdentifier : IValue { } + + public abstract partial record AbstractThroughInterface : IAbstractIdentifier; + + public static class AbstractHolder { public abstract partial record AbstractNested : IValue; } + // A value object that reaches itself through what it wraps has no finite form: its JSON // converter serializes the wrapped value through the options, which is its own converter // again. As a record class it compiled without a word; as a struct it failed inside the @@ -429,6 +682,15 @@ public sealed partial record CycleTwo : IValue; // from the same project, parsing, comparison and the type converter were silently missing. public readonly partial record struct WrapsAValueObject : IValue; + // Over a closed set of this project the value object parses through the set, which it sees + // because ClosedSetAspect runs first; a Parse of its own was kept silently while it did not. + public sealed partial class SetToWrap : IClosedSet { public static readonly SetToWrap A = new(); } + + public readonly partial record struct HandWrittenParseOverASet : IValue + { + public static HandWrittenParseOverASet Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + } + // Companion classes are named after the whole nesting chain, so these two coexist. public sealed class Order { public readonly partial record struct Id : IValue; } @@ -521,6 +783,33 @@ public sealed partial record ListBacked : IValue; } + // Named as a member the generators introduce: LAMA0611 (CS0542) inside the generated code. + public static class VoNames + { + public readonly partial record struct Parse : IValue; + + public readonly partial record struct From : IValue; + + public readonly partial record struct TryFrom : IValidatedValue + { + public static Result Create(int value) => Result.Error(Fault.Refused); + } + + public readonly partial record struct CompareTo : IValue; + + public readonly partial record struct MinValue : IValue; + } + + // Enum.Parse(string) was taken for a Parse of its own: LAMA0611 (CS0411). + public readonly partial record struct EnumBacked : IValue; + + // The companion class named the value object outside it: LAMA0611 (CS0619). + [System.Obsolete("Retired.", error: true)] + public readonly partial record struct Retired : IValue; + + [System.Obsolete("Retired.", error: true)] + public static class RetiredHolder { public readonly partial record struct Inner : IValue; } + file readonly partial record struct FileLocal : IValue; // A directive above the declaration, or a file-local type around it: the modifiers were read @@ -603,6 +892,10 @@ public static Result Create(string value) => [System.ComponentModel.TypeConverter(typeof(System.ComponentModel.Int32Converter))] public readonly partial record struct HandWrittenTypeConverter : IValue; + // Says the ordering is hand-written where it is not, and EF would refuse to order it. + [CodoMetis.TypeKit.CompilerServices.HandWrittenOrdering] + public readonly partial record struct HandWrittenOrdering : IValue; + public readonly partial record struct HandWrittenInterface : IValue, IValueObject { public int Value => 0; @@ -1039,12 +1332,97 @@ public Chained() : this(Fine.From(1)) { } } """; + /// + /// The closed sets the generators refuse, built apart: a refused set does not implement its + /// interface (CS0535), and beside a compiler error Metalama reported no error in the code it + /// generated into a declaration's own file, so in one project with the value objects a LAMA0611 + /// there went unseen (measured 2026-10-13 with a generic static Parse taken for the wrapped type's). + /// + private const string RefusedSets = + """ + using CodoMetis.TypeKit.ClosedSets; + + namespace Consumer; + + public sealed class SetNotPartial : IClosedSet { public static readonly SetNotPartial A = new(); } + + public partial class SetNotSealed : IClosedSet { public static readonly SetNotSealed A = new(); } + + public sealed partial class SetWithoutMembers : IClosedSet { } + + public sealed partial class SetWithPublicConstructor : IClosedSet { public static readonly SetWithPublicConstructor A = new(); public SetWithPublicConstructor() { } } + + public sealed partial class SetWithProperty : IClosedSet { public static readonly SetWithProperty A = new(); public static SetWithProperty B => A; } + + public sealed partial class SetWithMutableField : IClosedSet { public static readonly SetWithMutableField A = new(); public static SetWithMutableField B = A; } + + public sealed partial class SetGeneric : IClosedSet> { public static readonly SetGeneric A = new(); } + + public sealed partial class SetWithInternalConstructor : IClosedSet { public static readonly SetWithInternalConstructor A = new(); internal SetWithInternalConstructor() { } } + + public sealed partial class SetWithAlias : IClosedSet { public static readonly SetWithAlias Open = new(); public static readonly SetWithAlias Legacy = Open; } + + public sealed partial class SetWithFactoryMember : IClosedSet { public static readonly SetWithFactoryMember A = new(); public static readonly SetWithFactoryMember B = Make(); private static SetWithFactoryMember Make() => A; } + + public sealed partial class SetWithUninitializedMember : IClosedSet { public static readonly SetWithUninitializedMember A = new(); public static readonly SetWithUninitializedMember B; static SetWithUninitializedMember() { B = new(); } } + + public sealed partial class SetWithTrailingCall : IClosedSet { public static readonly SetWithTrailingCall A = new(); public static readonly SetWithTrailingCall B = new SetWithTrailingCall().Same(); private SetWithTrailingCall Same() => A; } + + public sealed partial record SetRecord : IClosedSet { public static readonly SetRecord A = new(); } + + // Abstract, so a closed set it is not: refused as unsealed, never skipped. + public abstract partial class SetAbstract : IClosedSet { } + + public sealed partial class SetWithName : IClosedSet { public static readonly SetWithName A = new(); public string Name => "A"; } + + public sealed partial class SetWithExplicitAll : IClosedSet { public static readonly SetWithExplicitAll A = new(); static System.Collections.Generic.IReadOnlyList IClosedSet.All => [A]; } + + public sealed partial class SetWithParsable : IClosedSet, System.IParsable { public static readonly SetWithParsable A = new(); } + + [System.ComponentModel.TypeConverter(typeof(System.ComponentModel.StringConverter))] + public sealed partial class SetWithTypeConverterAttribute : IClosedSet { public static readonly SetWithTypeConverterAttribute A = new(); } + + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public sealed partial class SetWithJsonConverterAttribute : IClosedSet { public static readonly SetWithJsonConverterAttribute A = new(); } + + public sealed partial class SetWithMemberNamedParse : IClosedSet { public static readonly SetWithMemberNamedParse A = new(); public static readonly SetWithMemberNamedParse Parse = new(); } + + public sealed partial class SetWithMemberNamedToString : IClosedSet { public static readonly new SetWithMemberNamedToString ToString = new(); } + + public sealed partial class SetWithMemberNamedAll : IClosedSet { public static readonly SetWithMemberNamedAll All = new(); } + + public sealed partial class SetWithCasedMembers : IClosedSet { public static readonly SetWithCasedMembers Open = new(); public static readonly SetWithCasedMembers open = new(); } + + // Read as a member, it was none: its name threw and TryParse never found it. + public sealed partial class SetWithInternalMember : IClosedSet { public static readonly SetWithInternalMember A = new(); internal static readonly SetWithInternalMember Draft = new(); } + + public sealed partial class SetWithCaseVariants : IClosedSet { public static readonly SetWithCaseVariants Express = new(); public static readonly SetWithCaseVariants EXPRESS = new(); } + + // The second member's first letter is the Kelvin sign (U+212A), written as an escape: ignoring + // case ordinally tells it from K, and Match's parameters, lower-cased, do not (LAMA0611, CS0100). + public sealed partial class SetWithKelvinSign : IClosedSet { public static readonly SetWithKelvinSign Kelvin = new(); public static readonly SetWithKelvinSign \u212Aelvin = new(); } + + public sealed partial class SetWithIgnoreCaseTryParse : IClosedSet { public static readonly SetWithIgnoreCaseTryParse A = new(); public static bool TryParse(string? name, bool ignoreCase, out SetWithIgnoreCaseTryParse? member) { member = A; return true; } } + + public sealed partial class SetWithIgnoreCaseParse : IClosedSet { public static readonly SetWithIgnoreCaseParse A = new(); public static SetWithIgnoreCaseParse Parse(string name, bool ignoreCase) => A; } + + // Named as a generated member: LAMA0611 (CS0542) inside the generated code. + public static class SetNames + { + public sealed partial class Name : IClosedSet { public static readonly Name A = new(); } + + public sealed partial class Match : IClosedSet { public static readonly Match A = new(); } + } + """; + /// /// Under the ignored artifacts/ rather than the temp folder: on macOS that is behind the /// /var symlink, and NuGet's relative project paths then resolve against the wrong side. /// private readonly string _directory = Path.Combine(RepositoryRoot(), "artifacts", $"consumer-{Guid.NewGuid():N}"); + private readonly string _setsDirectory = Path.Combine(RepositoryRoot(), "artifacts", $"consumer-{Guid.NewGuid():N}"); + /// A second file, so a declaration can be split across files. private const string OtherFile = """ @@ -1061,16 +1439,47 @@ public readonly partial record struct SplitAcrossFiles } """; + /// The errors of both builds. public IReadOnlyList Errors { get; private set; } = []; + /// The errors of the build without the refused closed sets, where no compiler error hides a LAMA0611. + public IReadOnlyList DeclarationErrors { get; private set; } = []; + public string Output { get; private set; } = ""; public async ValueTask InitializeAsync() { - Directory.CreateDirectory(_directory); + var declarations = Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations, ["OtherFile.cs"] = OtherFile }); + var sets = Build(_setsDirectory, new Dictionary { ["RefusedSets.cs"] = RefusedSets }); + + var (declarationOutput, declarationErrors) = await declarations; + var (setOutput, setErrors) = await sets; + + DeclarationErrors = declarationErrors; + Errors = [.. declarationErrors.Concat(setErrors).Distinct()]; + Output = declarationOutput + setOutput; + } + + public ValueTask DisposeAsync() + { + foreach (var directory in new[] { _directory, _setsDirectory }) + if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); + + return ValueTask.CompletedTask; + } + + /// + /// Builds a consumer of the generators and the analyzer in from + /// , and reads its errors. A declaration whose build error is the + /// compiler's own (CS0111) belongs in a consumer of its own: beside it, Metalama reported no error + /// in the code it generated into a declaration's own file, which the other guards rely on. + /// + internal static async Task<(string Output, IReadOnlyList Errors)> Build(string directory, IReadOnlyDictionary sources) + { + Directory.CreateDirectory(directory); var src = Path.Combine(RepositoryRoot(), "src"); - await File.WriteAllTextAsync(Path.Combine(_directory, "Consumer.csproj"), + await File.WriteAllTextAsync(Path.Combine(directory, "Consumer.csproj"), $""" @@ -1084,16 +1493,16 @@ await File.WriteAllTextAsync(Path.Combine(_directory, "Consumer.csproj"), """); - await File.WriteAllTextAsync(Path.Combine(_directory, "Declarations.cs"), Declarations); - await File.WriteAllTextAsync(Path.Combine(_directory, "OtherFile.cs"), OtherFile); + foreach (var (name, source) in sources) + await File.WriteAllTextAsync(Path.Combine(directory, name), source); // Only errors are read, so the rules that ship as warnings or a suggestion are raised here. - await File.WriteAllTextAsync(Path.Combine(_directory, ".editorconfig"), + // CMTK0003 ships as an error, and is left to its own severity, so its reports here prove it. + await File.WriteAllTextAsync(Path.Combine(directory, ".editorconfig"), """ root = true [*.cs] - dotnet_diagnostic.CMTK0003.severity = error dotnet_diagnostic.CMTK0005.severity = error dotnet_diagnostic.CMTK0006.severity = error dotnet_diagnostic.CMTK0007.severity = error @@ -1103,9 +1512,9 @@ await File.WriteAllTextAsync(Path.Combine(_directory, ".editorconfig"), // Stop MSBuild's upward search here, so the repository's own build settings (warnings as // errors, documentation, central package management) do not apply to the consumer. - await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Build.props"), ""); - await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Build.targets"), ""); - await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Packages.props"), + await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Build.props"), ""); + await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Build.targets"), ""); + await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Packages.props"), "false"); // --no-dependencies and RestoreRecursive=false: the referenced projects are already built @@ -1114,7 +1523,7 @@ await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Packages.props" // passed on: without it the consumer builds in Debug and, in a Release run such as CI's, // finds no referenced assembly at all. using var process = Process.Start(new ProcessStartInfo( - "dotnet", ["build", _directory, "-c", Configuration, "--no-dependencies", "-p:RestoreRecursive=false", "-nodeReuse:false", "-clp:NoSummary"]) + "dotnet", ["build", directory, "-c", Configuration, "--no-dependencies", "-p:RestoreRecursive=false", "-nodeReuse:false", "-clp:NoSummary"]) { RedirectStandardOutput = true, RedirectStandardError = true, @@ -1125,16 +1534,11 @@ await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Packages.props" var errors = process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); - Output = await output + await errors; - Errors = [.. DiagnosticLine().Matches(Output).Select(match => new Diagnostic(match.Groups["id"].Value, match.Groups["message"].Value)).Distinct()]; + var text = await output + await errors; + IReadOnlyList found = [.. DiagnosticLine().Matches(text).Select(match => new Diagnostic(match.Groups["id"].Value, match.Groups["message"].Value)).Distinct()]; - Errors.ShouldNotBeEmpty($"The consumer build reported no errors at all, so it did not run as intended:{Environment.NewLine}{Output}"); - } - - public ValueTask DisposeAsync() - { - if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); - return ValueTask.CompletedTask; + found.ShouldNotBeEmpty($"The consumer build reported no errors at all, so it did not run as intended:{Environment.NewLine}{text}"); + return (text, found); } /// The configuration this test assembly, and so the projects it references, was built in. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs new file mode 100644 index 0000000..439da71 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using CodoMetis.TypeKit.ClosedSets; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// A set whose static field reads All: All is filled after every member exists. +public sealed partial class EarlyAllStatus : IClosedSet +{ + public static readonly EarlyAllStatus Open = new(); + public static readonly IReadOnlyList Snapshot = All; +} + +/// +/// A set whose static field parses the name of a member declared below it, still null there. A field of +/// the set's own type must be created with new (CMTK1019), so the parse is held in a list. +/// +public sealed partial class EarlyParseStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = [Parse("Closed")]; + public static readonly EarlyParseStatus Closed = new(); +} + +/// See , through TryParse. +public sealed partial class EarlyTryParseStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = TryParse("Closed", out var closed) ? [closed] : []; + public static readonly EarlyTryParseStatus Closed = new(); +} + +/// See , through the JSON converter. +public sealed partial class EarlyJsonStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = JsonSerializer.Deserialize>("""["Closed"]""")!; + public static readonly EarlyJsonStatus Closed = new(); +} + +/// See , through Parse(name, ignoreCase). +public sealed partial class EarlyIgnoreCaseParseStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = [Parse("closed", ignoreCase: true)]; + public static readonly EarlyIgnoreCaseParseStatus Closed = new(); +} + +/// See , through TryParse(name, ignoreCase, out member). +public sealed partial class EarlyIgnoreCaseTryParseStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = TryParse("closed", ignoreCase: true, out var closed) ? [closed] : []; + public static readonly EarlyIgnoreCaseTryParseStatus Closed = new(); +} + +/// See , through the explicit IParsable<TSelf>.TryParse. +public sealed partial class EarlyParsableStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = EarlyReads.Parsable("Closed"); + public static readonly EarlyParsableStatus Closed = new(); +} + +/// See , through the JSON converter's dictionary-key read. +public sealed partial class EarlyJsonKeyStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = [.. JsonSerializer.Deserialize>("{\"Closed\":1}")!.Keys]; + public static readonly EarlyJsonKeyStatus Closed = new(); +} + +/// Generic code that reads a set through IParsable<TSelf>, as a binder does. +public static class EarlyReads +{ + public static IReadOnlyList Parsable(string name) where T : IParsable => T.TryParse(name, null, out var member) ? [member] : []; +} + +/// +/// A closed set's static field initializers run while its members are still being created. All, +/// filled once every member exists, was null there, and a field that read it held null for the +/// process's life, typed as never null; a parse of a member declared below handed out null, and +/// TryParse answered true with it, as did the ignore-case overloads and IParsable, and a +/// dictionary-key read threw . Each refuses, naming the set and +/// linking the README. +/// +public sealed class ClosedSetInitializationTests +{ + public static TheoryData Forms => ["All", "Parse", "TryParse", "JSON", "Parse ignoring case", "TryParse ignoring case", "IParsable", "JSON key"]; + + private static readonly Dictionary Touch)> Touch = new() + { + ["All"] = ("EarlyAllStatus", () => EarlyAllStatus.Snapshot), + ["Parse"] = ("EarlyParseStatus", () => EarlyParseStatus.Defaults), + ["TryParse"] = ("EarlyTryParseStatus", () => EarlyTryParseStatus.Defaults), + ["JSON"] = ("EarlyJsonStatus", () => EarlyJsonStatus.Defaults), + ["Parse ignoring case"] = ("EarlyIgnoreCaseParseStatus", () => EarlyIgnoreCaseParseStatus.Defaults), + ["TryParse ignoring case"] = ("EarlyIgnoreCaseTryParseStatus", () => EarlyIgnoreCaseTryParseStatus.Defaults), + ["IParsable"] = ("EarlyParsableStatus", () => EarlyParsableStatus.Defaults), + ["JSON key"] = ("EarlyJsonKeyStatus", () => EarlyJsonKeyStatus.Defaults), + }; + + [Theory] + [MemberData(nameof(Forms))] + public void Reading_the_set_in_its_own_static_initializer_is_refused(string form) + { + var (set, touch) = Touch[form]; + + var refusal = Should.Throw(touch).InnerException.ShouldBeOfType(); + + refusal.Message.ShouldStartWith($"{set} is still being initialized"); + refusal.Message.ShouldEndWith("#a-closed-set-read-before-it-is-initialized"); + } +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs new file mode 100644 index 0000000..3788b60 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -0,0 +1,407 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.CompilerServices; +using CodoMetis.TypeKit.Generators.Probes; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// A set whose names differ from their upper and lower cases by an i, which tr-TR does not case as English does. +public sealed partial class DocumentKind : IClosedSet +{ + public static readonly DocumentKind Invoice = new(); + public static readonly DocumentKind I = new(); +} + +/// +/// The closed-set family, generated for and . +/// Measured first in spikes/ClosedSets; each claim there is a test here. +/// +public sealed class ClosedSetTests +{ + [Fact] + public void All_holds_the_members_themselves_in_declaration_order() + { + ProbeStatus.All.Select(member => member.Name).ShouldBe(["Open", "Shipped", "Cancelled"]); + ReferenceEquals(ProbeStatus.All[1], ProbeStatus.Shipped).ShouldBeTrue(); + ProbeTier.All.Select(member => member.Name).ShouldBe(["Basic", "Premium"]); + } + + /// + /// All cannot be written through a cast: a member replaced there would be listed, offered + /// and documented while Parse still answered the original. A write that succeeds is undone + /// before the test fails, so the set other tests read stays whole. + /// + [Fact] + public void All_cannot_be_written_through_a_cast() + { + ProbeStatus.All.ShouldNotBeAssignableTo(); + + var list = (IList)ProbeStatus.All; + var replaced = false; + try + { + list[0] = ProbeStatus.Cancelled; + replaced = true; + } + catch (NotSupportedException) + { + } + finally + { + if (replaced) list[0] = ProbeStatus.Open; + } + + replaced.ShouldBeFalse("All was written through IList"); + Should.Throw(() => list.Clear()); + ProbeStatus.All[0].ShouldBeSameAs(ProbeStatus.Open); + } + + [Fact] + public void Name_QualifiedName_and_ToString_come_from_the_field() + { + ProbeStatus.Open.Name.ShouldBe("Open"); + ProbeStatus.Cancelled.QualifiedName.ShouldBe("ProbeStatus.Cancelled"); + ProbeStatus.Shipped.ToString().ShouldBe("Shipped"); + $"{ProbeTier.Premium}".ShouldBe("Premium"); + } + + [Fact] + public void Constant_data_comes_through_the_declared_constructor() + { + ProbeTier.Premium.Rank.ShouldBe(2); + ProbeTier.Premium.Perk.ShouldBe("Priority support."); + ProbeTier.Basic.Perk.ShouldBeNull(); + } + + [Fact] + public void Match_takes_one_delegate_per_member_and_picks_the_branch() + { + ProbeStatus.Shipped.Match(open: () => "o", shipped: () => "s", cancelled: () => "c").ShouldBe("s"); + ProbeTier.Basic.Match(basic: () => 1, premium: () => 2).ShouldBe(1); + } + + /// + /// All was a field initializer of the part the generators introduced it into, which ran before + /// the fields of a part in another file: All listed them as null, and the names the satellites + /// read threw. It is filled with the names, after every part's fields. + /// + [Fact] + public void A_set_declared_in_two_files_lists_the_members_of_both() + { + ProbeSplit.All.ShouldBe([ProbeSplit.First, ProbeSplit.Second]); + ProbeSplit.All.ShouldAllBe(member => member != null); + ProbeSplit.TryParse("Second", out var second).ShouldBeTrue(); + second.Name.ShouldBe("Second"); + new GeneratedClosedSetAttribute().Names.ShouldBe(["First", "Second"]); + } + + /// Two nested sets of one simple name are told apart by their containers, in a log and in a fault's identifier. + [Fact] + public void A_nested_set_is_qualified_by_its_containing_types() + { + ProbeOrder.Status.Open.QualifiedName.ShouldBe("ProbeOrder.Status.Open"); + ProbeShipment.Status.Sent.QualifiedName.ShouldBe("ProbeShipment.Status.Sent"); + ProbeStatus.Open.QualifiedName.ShouldBe("ProbeStatus.Open"); + } + + [Fact] + public void TryParse_and_Parse_find_a_member_by_name_and_refuse_without_quoting() + { + ProbeStatus.TryParse("Shipped", out var shipped).ShouldBeTrue(); + ReferenceEquals(shipped, ProbeStatus.Shipped).ShouldBeTrue(); + ProbeStatus.TryParse("shipped", out _).ShouldBeFalse("names compare ordinally"); + ProbeStatus.TryParse(null, out _).ShouldBeFalse(); + + ReferenceEquals(ProbeStatus.Parse("Open"), ProbeStatus.Open).ShouldBeTrue(); + + var refusal = Should.Throw(() => ProbeStatus.Parse("Nope")); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("Nope"); + refusal.Message.ShouldContain("#a-closed-set-has-no-member-of-that-name"); + } + + /// + /// A header or a query string a client typed: the ordinal parse stays the default, and the + /// ignore-case one is opted into at the call. Unknown names are refused as the ordinal one refuses them. + /// + [Fact] + public void TryParse_and_Parse_ignore_case_when_asked() + { + ProbeStatus.TryParse("shipped", ignoreCase: true, out var shipped).ShouldBeTrue(); + ReferenceEquals(shipped, ProbeStatus.Shipped).ShouldBeTrue(); + ProbeStatus.TryParse("SHIPPED", ignoreCase: false, out _).ShouldBeFalse("ordinal when not asked"); + ProbeStatus.TryParse("shipped", ignoreCase: true, out _).ShouldBeTrue(); + ProbeStatus.TryParse("Shipped", ignoreCase: false, out _).ShouldBeTrue(); + ProbeStatus.TryParse("drone", ignoreCase: true, out _).ShouldBeFalse(); + ProbeStatus.TryParse(null, ignoreCase: true, out _).ShouldBeFalse(); + + ReferenceEquals(ProbeStatus.Parse("OPEN", ignoreCase: true), ProbeStatus.Open).ShouldBeTrue(); + ReferenceEquals(ProbeStatus.Parse("Open", ignoreCase: false), ProbeStatus.Open).ShouldBeTrue(); + Should.Throw(() => ProbeStatus.Parse("OPEN", ignoreCase: false)); + + var refusal = Should.Throw(() => ProbeStatus.Parse("nope", ignoreCase: true)); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("nope"); + } + + /// + /// Ignoring case is ordinal, never the current culture's: in tr-TR i and I are not + /// one letter's two cases (each pairs with a dotted or dotless counterpart), and a comparison in the current + /// culture refused INVOICE, invoice and i for members named Invoice and + /// I, which an English-speaking server accepted. + /// + [Fact] + public void Ignoring_case_is_ordinal_whatever_the_current_culture() + { + var (culture, uiCulture) = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture); + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("tr-TR"); + + try + { + ReferenceEquals(DocumentKind.Parse("INVOICE", ignoreCase: true), DocumentKind.Invoice).ShouldBeTrue(); + ReferenceEquals(DocumentKind.Parse("invoice", ignoreCase: true), DocumentKind.Invoice).ShouldBeTrue(); + ReferenceEquals(DocumentKind.Parse("i", ignoreCase: true), DocumentKind.I).ShouldBeTrue(); + + DocumentKind.TryParse("INVOICE", ignoreCase: true, out var invoice).ShouldBeTrue(); + ReferenceEquals(invoice, DocumentKind.Invoice).ShouldBeTrue(); + DocumentKind.TryParse("i", ignoreCase: true, out var i).ShouldBeTrue(); + ReferenceEquals(i, DocumentKind.I).ShouldBeTrue(); + } + finally + { + (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture) = (culture, uiCulture); + } + } + + /// What is stored or sent is the name as it is: JSON, the type converter and IParsable stay ordinal. + [Fact] + public void JSON_the_type_converter_and_IParsable_stay_ordinal() + { + Should.Throw(() => JsonSerializer.Deserialize("\"shipped\"")); + Should.Throw(() => System.ComponentModel.TypeDescriptor.GetConverter(typeof(ProbeStatus)).ConvertFromInvariantString("shipped")); + TryParseAs("shipped", out _).ShouldBeFalse(); + } + + [Fact] + public void JSON_is_the_name_and_reads_the_member_itself_back() + { + var json = JsonSerializer.Serialize(new Holder(ProbeStatus.Shipped, ProbeTier.Premium)); + json.ShouldBe("""{"Status":"Shipped","Tier":"Premium"}"""); + + var back = JsonSerializer.Deserialize(json)!; + ReferenceEquals(back.Status, ProbeStatus.Shipped).ShouldBeTrue(); + ReferenceEquals(back.Tier, ProbeTier.Premium).ShouldBeTrue(); + + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"Status":"Nope","Tier":"Basic"}""")); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("Nope"); + + Should.Throw(() => JsonSerializer.Deserialize("""{"Status":3,"Tier":"Basic"}""")).Message.ShouldContain("ProbeStatus"); + } + + [Fact] + public void Members_are_singletons_and_the_constructor_is_private() + { + (ProbeStatus.Open == ProbeStatus.Parse("Open")).ShouldBeTrue(); + typeof(ProbeStatus).GetConstructors().ShouldBeEmpty("no public constructor"); + typeof(ProbeStatus).IsSealed.ShouldBeTrue(); + } + + [Fact] + public void The_attribute_names_the_members_and_reaches_the_set_typed() + { + var attribute = typeof(ProbeTier).GetCustomAttributes(inherit: false).OfType().ShouldHaveSingleItem(); + + attribute.SetType.ShouldBe(typeof(ProbeTier)); + attribute.Names.ShouldBe(["Basic", "Premium"]); + attribute.Accept(new Counter()).ShouldBe(2); + } + + [Fact] + public void Generic_code_reaches_a_set_through_the_interface() + { + Names().ShouldBe("Open,Shipped,Cancelled"); + Names().ShouldBe("Basic,Premium"); + } + + /// + /// A found member is not null to the caller's nullable analysis: the generated TryParse says + /// so, as the interface does. Without it this test does not compile (CS8602, an error here). + /// + [Fact] + public void A_member_TryParse_found_is_not_null_to_the_compiler() + { + if (ProbeStatus.TryParse("Shipped", out var shipped)) shipped.Name.ShouldBe("Shipped"); + else throw new InvalidOperationException("Shipped is a member."); + } + + /// The one seam: a hand-written ToString() is kept, and the name is still what JSON and parsing use. + [Fact] + public void A_hand_written_ToString_is_kept_and_the_name_still_travels() + { + ProbeLabelled.High.ToString().ShouldBe("high priority"); + $"{ProbeLabelled.Low}".ShouldBe("low priority"); + + ProbeLabelled.High.Name.ShouldBe("High"); + JsonSerializer.Serialize(ProbeLabelled.High).ShouldBe("\"High\""); + ReferenceEquals(ProbeLabelled.Parse("High"), ProbeLabelled.High).ShouldBeTrue(); + } + + /// + /// An instance the set's own code made beside its members has no name. Before, its name was null: + /// JSON wrote null, a nullable column stored one, and both read back as nothing. + /// + [Fact] + public void An_instance_that_is_not_a_member_is_refused_wherever_its_name_is_read() + { + var unlisted = ProbeLevel.Unlisted(); + const string link = "#an-instance-that-is-not-a-member-of-its-set"; + + Should.Throw(() => unlisted.Name).Message.ShouldEndWith(link); + Should.Throw(() => unlisted.QualifiedName).Message.ShouldEndWith(link); + Should.Throw(() => unlisted.ToString()).Message.ShouldEndWith(link); + Should.Throw(() => unlisted.Match(low: () => 1, high: () => 2)).Message.ShouldEndWith(link); + Should.Throw(() => JsonSerializer.Serialize(unlisted)).Message.ShouldEndWith(link); + + ProbeLevel.High.Name.ShouldBe("High"); + } + + /// + /// Every form of new CMTK1019 accepts is generated, and an overload of a generated method + /// with another signature is the set's own: kept beside the generated one, not CMTK1011. + /// + [Fact] + public void Members_created_in_any_form_and_overloads_of_another_signature_are_kept() + { + ProbePriority.All.Select(member => member.Name).ShouldBe(["Low", "Normal", "High"]); + ProbePriority.Normal.Label.ShouldBe("normal (the default)"); + + ReferenceEquals(ProbePriority.Parse(2), ProbePriority.Normal).ShouldBeTrue(); + ReferenceEquals(ProbePriority.Parse("High"), ProbePriority.High).ShouldBeTrue(); + + var picked = ""; + ProbePriority.Low.Match(low: () => picked = "low", normal: () => picked = "normal", high: () => picked = "high"); + picked.ShouldBe("low"); + ProbePriority.High.Match(low: () => 1, normal: () => 2, high: () => 3).ShouldBe(3); + } + + /// + /// A set is a class, so a JSON null is the serializer's to read, as for any class: null, unless + /// RespectNullableAnnotations refuses it for a property that is not nullable. The converter + /// cannot refuse it itself without refusing it for an OrderStatus? property too. + /// + [Fact] + public void A_JSON_null_is_the_serializer_s_to_read() + { + const string json = """{"Status":null,"Tier":"Basic"}"""; + + JsonSerializer.Deserialize(json)!.Status.ShouldBeNull(); + Should.Throw(() => JsonSerializer.Deserialize(json, new JsonSerializerOptions { RespectNullableAnnotations = true })); + JsonSerializer.Deserialize(json, new JsonSerializerOptions { RespectNullableAnnotations = true })!.Status.ShouldBeNull(); + } + + /// + /// Every delegate is checked before a branch is picked, as Option's and Result's are: a null for a + /// member not matched passed until that member first arrived. + /// + [Fact] + public void Match_refuses_a_null_delegate_whichever_member_it_is_called_on() + { + foreach (var member in ProbeStatus.All) + { + Should.Throw(() => member.Match(open: null!, shipped: () => 2, cancelled: () => 3)).ParamName.ShouldBe("open"); + Should.Throw(() => member.Match(open: () => 1, shipped: null!, cancelled: () => 3)).ParamName.ShouldBe("shipped"); + Should.Throw(() => member.Match(open: () => 1, shipped: () => 2, cancelled: null!)).ParamName.ShouldBe("cancelled"); + } + } + + /// A dictionary key is the member's name, as the value is; the serializer refused a set as a key both ways before. + [Fact] + public void A_dictionary_key_is_the_name_and_reads_the_member_itself_back() + { + var counts = new Dictionary { [ProbeStatus.Open] = 1, [ProbeStatus.Cancelled] = 3 }; + + var json = JsonSerializer.Serialize(counts); + json.ShouldBe("""{"Open":1,"Cancelled":3}"""); + + var back = JsonSerializer.Deserialize>(json)!; + back.Keys.ShouldBe([ProbeStatus.Open, ProbeStatus.Cancelled]); + back[ProbeStatus.Cancelled].ShouldBe(3); + + // As the value: no naming policy renames a key, which would not read back. + JsonSerializer.Serialize(counts, new JsonSerializerOptions { DictionaryKeyPolicy = JsonNamingPolicy.CamelCase }).ShouldBe(json); + + var refusal = Should.Throw(() => JsonSerializer.Deserialize>("""{"Nope":1}""")); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("Nope"); + + Should.Throw(() => JsonSerializer.Serialize(new Dictionary { [ProbeLevel.Unlisted()] = 1 })) + .Message.ShouldEndWith("#an-instance-that-is-not-a-member-of-its-set"); + } + + /// A member's name lower-cased may be a keyword; the parameter is still that name, escaped where it is written. + [Fact] + public void A_member_whose_parameter_is_a_keyword_is_matched_by_the_escaped_name() + { + ProbeKeyword.Default.Match(@new: () => "new", @default: () => "default").ShouldBe("default"); + ProbeKeyword.New.Name.ShouldBe("New"); + typeof(ProbeKeyword).GetMethod("Match")!.GetParameters().Select(parameter => parameter.Name).ShouldBe(["new", "default"]); + } + + /// Generic code that parses through reaches a set, implemented explicitly beside its own Parse. + [Fact] + public void Generic_code_parses_a_set_through_IParsable() + { + ReferenceEquals(ParseAs("Shipped"), ProbeStatus.Shipped).ShouldBeTrue(); + TryParseAs("Cancelled", out var cancelled).ShouldBeTrue(); + ReferenceEquals(cancelled, ProbeStatus.Cancelled).ShouldBeTrue(); + TryParseAs("shipped", out _).ShouldBeFalse("names compare ordinally"); + TryParseAs(null, out _).ShouldBeFalse(); + + var refusal = Should.Throw(() => ParseAs("Nope")); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("Nope"); + Should.Throw(() => ParseAs(null!)); + + typeof(ProbeStatus).GetMethods().Where(method => method.Name == "Parse").Select(method => method.GetParameters().Length) + .ShouldBe([1, 2], "the set's own Parse(name) and Parse(name, ignoreCase); the provider overload is explicit"); + } + + /// + /// What reads a string through , the reflection-based configuration + /// binder and MVC among them, finds a set by its type converter alone: without one, a set bound to + /// null with no error. + /// + [Fact] + public void The_type_converter_reads_and_writes_the_name() + { + var converter = TypeDescriptor.GetConverter(typeof(ProbeStatus)); + + converter.CanConvertFrom(typeof(string)).ShouldBeTrue(); + converter.CanConvertTo(typeof(string)).ShouldBeTrue(); + ReferenceEquals(converter.ConvertFromInvariantString("Shipped"), ProbeStatus.Shipped).ShouldBeTrue(); + converter.ConvertToInvariantString(ProbeStatus.Cancelled).ShouldBe("Cancelled"); + + var refusal = Should.Throw(() => converter.ConvertFromInvariantString("Nope")); + refusal.Message.ShouldNotContain("Nope"); + Should.Throw(() => TypeDescriptor.GetConverter(typeof(ProbeLevel)).ConvertToInvariantString(ProbeLevel.Unlisted())) + .Message.ShouldEndWith("#an-instance-that-is-not-a-member-of-its-set"); + } + + private static T ParseAs(string s) where T : IParsable => T.Parse(s, null); + + private static bool TryParseAs(string? s, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out T result) where T : IParsable => T.TryParse(s, null, out result); + + private static string Names() where TSet : class, IClosedSet => string.Join(",", TSet.All.Select(member => member.Name)); + + private sealed class Counter : IClosedSetVisitor + { + public int Visit() where TSet : class, IClosedSet => TSet.All.Count; + } + + private sealed record Holder(ProbeStatus Status, ProbeTier Tier); + + private sealed record OptionalHolder(ProbeStatus? Status, ProbeTier Tier); +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetWrappingTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetWrappingTests.cs new file mode 100644 index 0000000..2275ca3 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetWrappingTests.cs @@ -0,0 +1,180 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// A closed set of this project, which the value objects below wrap. +public sealed partial class LocalStatus : IClosedSet +{ + public static readonly LocalStatus Open = new(); + public static readonly LocalStatus Shipped = new(); + public static readonly LocalStatus Cancelled = new(); +} + +/// A nested closed set of this project. +public static class LocalOrder +{ + public sealed partial class Stage : IClosedSet + { + public static readonly Stage Draft = new(); + public static readonly Stage Shipped = new(); + } +} + +/// A struct value object over a set of its own project. +public readonly partial record struct StatusOfOrder : IValue; + +/// A record-class value object over a set of its own project. +public sealed partial record StatusLabel : IValue; + +/// A value object over a nested set of its own project. +public readonly partial record struct StageOfOrder : IValue; + +public enum ShippableFault { Cancelled } + +/// A validated value object over a set of its own project: a cancelled order ships nothing. +public readonly partial record struct ShippableStatus : IValidatedValue +{ + public static Result Create(LocalStatus value) => + ReferenceEquals(value, LocalStatus.Cancelled) ? Result.Error(ShippableFault.Cancelled) : new ShippableStatus(value); +} + +/// A value object over a closed set of a referenced project. +public readonly partial record struct OverProbeStatus : IValue; + +/// +/// A value object over a closed set is generated in full wherever the set is declared (decision 60). +/// ClosedSetAspect ran last, after the value-object aspects, which then did not see the set's +/// IParsable, Parse and type converter: over a set of its own project a value object had no +/// parsing and the base TypeConverter, while over the same set in a referenced project it had both. +/// It runs first now. +/// +/// +/// Everything that would be missing is reached through reflection or a generic constraint checked at +/// run time, so with the old order these fail rather than stop the project compiling. +/// +public sealed class ClosedSetWrappingTests +{ + public static TheoryData Wrappers => + [ + (typeof(StatusOfOrder), "Shipped", LocalStatus.Shipped), + (typeof(StatusLabel), "Shipped", LocalStatus.Shipped), + (typeof(StageOfOrder), "Shipped", LocalOrder.Stage.Shipped), + (typeof(ShippableStatus), "Shipped", LocalStatus.Shipped), + (typeof(OverProbeStatus), "Shipped", ProbeStatus.Shipped), + ]; + + [Theory] + [MemberData(nameof(Wrappers))] + public void It_parses_the_members_name_through_IParsable_and_its_own_Parse(Type type, string name, object member) + { + Value(Parse(type, name)).ShouldBeSameAs(member); + Value(StaticParse(type, name)).ShouldBeSameAs(member); + TryParse(type, name, out var parsed).ShouldBeTrue(); + Value(parsed!).ShouldBeSameAs(member); + + // As the set itself parses: ordinally, and with no span parsing, which the set does not have. + TryParse(type, name.ToUpperInvariant(), out _).ShouldBeFalse(); + type.GetInterfaces().ShouldNotContain(implemented => implemented.IsGenericType && implemented.GetGenericTypeDefinition() == typeof(ISpanParsable<>)); + } + + [Theory] + [MemberData(nameof(Wrappers))] + public void Its_own_type_converter_reads_and_writes_the_members_name(Type type, string name, object member) + { + var converter = TypeDescriptor.GetConverter(type); + + converter.GetType().ShouldNotBe(typeof(TypeConverter), "the value object's own type converter, not the base one"); + var converted = converter.ConvertFromInvariantString(name).ShouldNotBeNull(); + Value(converted).ShouldBeSameAs(member); + converter.ConvertToInvariantString(converted).ShouldBe(name); + } + + [Theory] + [MemberData(nameof(Wrappers))] + public void Its_JSON_is_the_members_name_as_a_value_and_as_a_key(Type type, string name, object member) + { + var json = $"\"{name}\""; + var read = JsonSerializer.Deserialize(json, type).ShouldNotBeNull(); + Value(read).ShouldBeSameAs(member); + JsonSerializer.Serialize(read, type).ShouldBe(json); + + var dictionaryType = typeof(Dictionary<,>).MakeGenericType(type, typeof(int)); + var keys = JsonSerializer.Deserialize($"{{{json}:1}}", dictionaryType).ShouldNotBeNull(); + JsonSerializer.Serialize(keys, dictionaryType).ShouldBe($"{{{json}:1}}"); + } + + /// Every way in applies Create: a validated value object refuses the member its rules refuse. + [Fact] + public void Every_way_in_applies_Create() + { + var parse = Should.Throw(() => Parse(typeof(ShippableStatus), "Cancelled")); + parse.Message.ShouldContain(nameof(ShippableFault.Cancelled)); + + Should.Throw(() => StaticParse(typeof(ShippableStatus), "Cancelled")); + TryParse(typeof(ShippableStatus), "Cancelled", out _).ShouldBeFalse(); + Should.Throw(() => TypeDescriptor.GetConverter(typeof(ShippableStatus)).ConvertFromInvariantString("Cancelled")); + Should.Throw(() => JsonSerializer.Deserialize("\"Cancelled\"")); + } + + /// A name that is no member is refused without quoting it, by every way in. + [Theory] + [MemberData(nameof(Wrappers))] + public void A_name_that_is_no_member_is_refused_without_quoting_it(Type type, string name, object member) + { + _ = (name, member); + const string secret = "Secret-Not-A-Member"; + + var parse = Should.Throw(() => Parse(type, secret)); + var converter = Should.Throw(() => TypeDescriptor.GetConverter(type).ConvertFromInvariantString(secret)); + var json = Should.Throw(() => JsonSerializer.Deserialize($"\"{secret}\"", type)); + + foreach (var refusal in new[] { parse, converter, json }) + { + for (Exception? exception = refusal; exception is not null; exception = exception.InnerException) + exception.Message.ShouldNotContain(secret); + } + + parse.Message.ShouldStartWith($"{type.Name} could not read the input as "); + } + + private static object Value(object valueObject) => + valueObject.GetType().GetProperty("Value")!.GetValue(valueObject)!; + + /// T.Parse in generic code, constrained to IParsable<T>; the constraint is checked when the method is made. + private static object Parse(Type type, string text) => + Unwrapped(() => typeof(ClosedSetWrappingTests).GetMethod(nameof(ParseAs), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type).Invoke(null, [text])!); + + private static bool TryParse(Type type, string text, out object? result) + { + object?[] arguments = [text, null]; + var parsed = (bool)Unwrapped(() => typeof(ClosedSetWrappingTests).GetMethod(nameof(TryParseAs), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type).Invoke(null, arguments)!); + result = arguments[1]; + return parsed; + } + + /// The value object's own public Parse(string, IFormatProvider?). + private static object StaticParse(Type type, string text) => + Unwrapped(() => type.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, [typeof(string), typeof(IFormatProvider)]).ShouldNotBeNull($"{type.Name} has no Parse(string, IFormatProvider?)").Invoke(null, [text, null])!); + + private static T ParseAs(string text) where T : IParsable => T.Parse(text, null); + + private static bool TryParseAs(string text, out T? result) where T : IParsable => T.TryParse(text, null, out result); + + private static T Unwrapped(Func call) + { + try + { + return call(); + } + catch (TargetInvocationException exception) when (exception.InnerException is { } inner) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(inner).Throw(); + throw; + } + } +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/DefaultInstanceFormattingTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/DefaultInstanceFormattingTests.cs new file mode 100644 index 0000000..e432dac --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/DefaultInstanceFormattingTests.cs @@ -0,0 +1,101 @@ +using System.ComponentModel; +using System.Globalization; +using System.Net; +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// A value object over a reference type that is span and UTF-8 formattable. +public readonly partial record struct DefaultedVersion : IValue; + +/// See . +public readonly partial record struct DefaultedAddress : IValue; + +/// +/// A default instance is not supposed to exist (CMTK0001), but EF Core, MVC and serializers make one, +/// and over a reference type it holds null. Its text is "" wherever it is formatted, as its +/// ToString() over a string always was: ToString(), interpolation, IFormattable, +/// both TryFormats, Convert.ToString, IConvertible and the type converter threw +/// over a Uri, a Version or an IPAddress, and +/// Convert.ToString and IConvertible over a string too. +/// +public sealed class DefaultInstanceFormattingTests +{ +#pragma warning disable CMTK0001 // The default instance is the subject. + public static TheoryData Wrapped => ["string", "Uri", "Version", "IPAddress"]; + + private static readonly Dictionary Defaults = new() + { + ["string"] = default(ProbeName), + ["Uri"] = default(ProbeUri), + ["Version"] = default(DefaultedVersion), + ["IPAddress"] = default(DefaultedAddress), + }; + + [Theory] + [MemberData(nameof(Wrapped))] + public void A_default_instance_over_a_reference_type_formats_as_an_empty_string(string wrapped) + { + var instance = Defaults[wrapped]; + + instance.ToString().ShouldBe(""); + $"{instance}".ShouldBe(""); + string.Format(CultureInfo.InvariantCulture, "{0}", instance).ShouldBe(""); + ((IFormattable)instance).ToString(null, null).ShouldBe(""); + Convert.ToString(instance, CultureInfo.InvariantCulture).ShouldBe(""); + TypeDescriptor.GetConverter(instance.GetType()).ConvertToInvariantString(instance).ShouldBe(""); + + if (instance is ISpanFormattable span) + { + span.TryFormat(new char[8], out var charsWritten, default, null).ShouldBeTrue(); + charsWritten.ShouldBe(0); + } + + if (instance is IUtf8SpanFormattable utf8) + { + utf8.TryFormat(new byte[8], out var bytesWritten, default, null).ShouldBeTrue(); + bytesWritten.ShouldBe(0); + } + } + + /// The cases above that a span or UTF-8 format would answer do run, so they cannot pass by being skipped. + [Fact] + public void The_span_formats_are_among_the_cases() + { + Defaults.Values.Count(instance => instance is ISpanFormattable).ShouldBe(3); + Defaults.Values.Count(instance => instance is IUtf8SpanFormattable).ShouldBe(2); + } + + /// + /// Over a string, IConvertible answers for the null a default instance holds as + /// answers for a null object: "" as text, the target type's default as + /// a number, as its type code. + /// + [Fact] + public void A_default_instance_over_a_string_converts_as_Convert_converts_a_null_object() + { + var convertible = (IConvertible)default(ProbeName); + + convertible.ToString(null).ShouldBe(""); + convertible.ToType(typeof(string), null).ShouldBe(""); + convertible.ToInt32(null).ShouldBe(Convert.ToInt32((object?)null, CultureInfo.InvariantCulture)); + convertible.ToBoolean(null).ShouldBe(Convert.ToBoolean((object?)null, CultureInfo.InvariantCulture)); + convertible.GetTypeCode().ShouldBe(Convert.GetTypeCode(null)); + } + + /// A value is converted as before: by its own IConvertible. + [Fact] + public void A_value_over_a_string_converts_as_the_string_does() + { + var convertible = (IConvertible)ProbeName.From("42"); + + convertible.ToInt32(null).ShouldBe(42); + convertible.ToString(null).ShouldBe("42"); + convertible.GetTypeCode().ShouldBe(TypeCode.String); + convertible.ToType(typeof(string), null).ShouldBe("42"); + convertible.ToType(typeof(long), null).ShouldBe(42L); + Should.Throw(() => ((IConvertible)ProbeName.From("x")).ToInt32(null)); + } +#pragma warning restore CMTK0001 +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/EqualityOperatorBuildTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/EqualityOperatorBuildTests.cs new file mode 100644 index 0000000..f910271 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/EqualityOperatorBuildTests.cs @@ -0,0 +1,82 @@ +namespace CodoMetis.TypeKit.Generators.Tests; + +/// +/// A hand-written operator == or != between two of a value object's type collides with the +/// record's own, which C# reports as CS0111 naming only op_Equality. CMTK1011 beside it names the +/// operator and says why a value object's equality is its wrapped value's. +/// +/// +/// In a consumer of its own: beside a CS0111, Metalama reported no error in the code it generated into +/// a declaration's own file (a LAMA0611 that exists to catch went +/// unreported, measured 2026-10-13), so these declarations would blind the shared consumer. +/// +public sealed class EqualityOperatorBuildTests(EqualityOperatorBuildTests.Consumer consumer) : IClassFixture +{ + [Theory] + [InlineData("WithEqualityOperator", "WithEqualityOperator.operator ==(WithEqualityOperator, WithEqualityOperator), WithEqualityOperator.operator !=(WithEqualityOperator, WithEqualityOperator), so")] + [InlineData("WithEqualityOperatorClass", "WithEqualityOperatorClass.operator ==(WithEqualityOperatorClass?, WithEqualityOperatorClass?), WithEqualityOperatorClass.operator !=(WithEqualityOperatorClass?, WithEqualityOperatorClass?), so")] + public void A_hand_written_equality_operator_is_refused_by_name(string type, string members) => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1011" && error.Message.StartsWith($"'{type}' declares {members}") + && error.Message.Contains("a value object's equality is its wrapped value's"), consumer.Output); + + /// An operator between the value object and another type is the user's own, and C#'s, and is kept. + [Fact] + public void An_operator_with_another_type_is_not_refused() => + consumer.Errors.ShouldNotContain(error => error.Message.Contains("'WithMixedOperator'"), consumer.Output); + + /// Nothing but the refusals and C#'s own CS0111, which only the two operator fixtures cause. + [Fact] + public void Every_error_is_one_of_the_intended_ones() + { + consumer.Errors.Select(error => error.Id).Distinct().Order().ShouldBe(["CMTK1011", "CS0111"], ignoreOrder: false, customMessage: consumer.Output); + consumer.Errors.Where(error => error.Id == "CS0111") + .ShouldAllBe(error => error.Message.StartsWith("Type 'WithEqualityOperator") && (error.Message.Contains("'op_Equality'") || error.Message.Contains("'op_Inequality'")), consumer.Output); + } + + /// The consumer, built once per test run. + public sealed class Consumer : IAsyncLifetime + { + private const string Declarations = + """ + using CodoMetis.TypeKit.ValueObjects; + + namespace Consumer; + + public readonly partial record struct WithEqualityOperator : IValue + { + public static bool operator ==(WithEqualityOperator left, WithEqualityOperator right) => true; + + public static bool operator !=(WithEqualityOperator left, WithEqualityOperator right) => false; + } + + public sealed partial record WithEqualityOperatorClass : IValue + { + public static bool operator ==(WithEqualityOperatorClass? left, WithEqualityOperatorClass? right) => true; + + public static bool operator !=(WithEqualityOperatorClass? left, WithEqualityOperatorClass? right) => false; + } + + public readonly partial record struct WithMixedOperator : IValue + { + public static bool operator ==(WithMixedOperator left, int right) => left.Value == right; + + public static bool operator !=(WithMixedOperator left, int right) => left.Value != right; + } + """; + + private readonly string _directory = Path.Combine(BuildOutcomeTests.Consumer.RepositoryRoot(), "artifacts", $"consumer-{Guid.NewGuid():N}"); + + public IReadOnlyList Errors { get; private set; } = []; + + public string Output { get; private set; } = ""; + + public async ValueTask InitializeAsync() => + (Output, Errors) = await BuildOutcomeTests.Consumer.Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations }); + + public ValueTask DisposeAsync() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + return ValueTask.CompletedTask; + } + } +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs index 86f9ad3..db35523 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs @@ -62,6 +62,37 @@ public void A_null_format_provider_means_the_invariant_culture_in_every_format() } } + /// + /// What the docs state about Parse(x.ToString(), null): the text is the wrapped type's + /// default text under the invariant culture, so it gives the value back where that text is + /// lossless, and not for TimeOnly, DateTime and DateTimeOffset, whose short + /// patterns drop seconds or a fraction, as the BCL types' own do. "O" reads back for the + /// two that keep their offset or have none. Pinned so the docs cannot say more than holds. + /// + [Fact] + public void Parse_of_ToString_gives_the_value_back_where_the_wrapped_types_text_is_lossless() + { + static bool RoundTrips(T value) where T : IParsable, IEquatable => T.Parse(value.ToString()!, null).Equals(value); + + RoundTrips(ProbeAmount.From(1.500m)).ShouldBeTrue(); + RoundTrips(ProbeRatio.From(0.1 + 0.2)).ShouldBeTrue(); + RoundTrips(ProbeId.From(Guid.Parse("0199a3f4-1c00-7000-8000-000000000001"))).ShouldBeTrue(); + RoundTrips(ProbeDate.From(new DateOnly(2026, 10, 13))).ShouldBeTrue(); + RoundTrips(ProbeDuration.From(new TimeSpan(1, 2, 3, 4, 567))).ShouldBeTrue(); + RoundTrips(ProbeUri.From(new Uri("https://example.com/a?b=c#d"))).ShouldBeTrue(); + + var time = ProbeTime.From(new TimeOnly(3, 4, 5, 123)); + time.ToString().ShouldBe("03:04"); + RoundTrips(time).ShouldBeFalse(); + ProbeTime.Parse(time.ToString("O", null), null).ShouldBe(time); + + RoundTrips(ProbeTimestamp.From(new DateTime(2026, 10, 13, 3, 4, 5, 123, DateTimeKind.Utc))).ShouldBeFalse("the fraction of a second is lost"); + + var moment = ProbeMoment.From(new DateTimeOffset(2026, 10, 13, 3, 4, 5, 123, TimeSpan.FromHours(2))); + RoundTrips(moment).ShouldBeFalse("the fraction of a second is lost"); + ProbeMoment.Parse(moment.ToString("O", null), null).ShouldBe(moment); + } + [Fact] public void A_span_formattable_value_formats_into_a_span_and_into_UTF_8() { @@ -188,6 +219,24 @@ public void A_hand_written_CompareTo_is_the_seam_and_the_rest_follows_it() typeof(ProbeDescending).GetInterfaces().ShouldContain(typeof(IComparisonOperators)); } + /// + /// The seam taken is said on the type, for code that orders where CompareTo cannot run: the + /// EF satellite refuses to compare or order such a value object in SQL, where the column's order is + /// the wrapped value's. Only there: a value object ordered by what it wraps carries nothing. + /// + [Fact] + public void A_hand_written_CompareTo_is_said_on_the_type() + { + typeof(ProbeDescending).GetCustomAttribute().ShouldNotBeNull(); + typeof(ProbeReversedLabel).GetCustomAttribute().ShouldNotBeNull(); + + typeof(ProbeCount).GetCustomAttribute().ShouldBeNull(); + typeof(ProbeLabel).GetCustomAttribute().ShouldBeNull(); + typeof(ProbePin).GetCustomAttribute().ShouldBeNull(); + + (ProbeReversedLabel.From("a") > ProbeReversedLabel.From("b")).ShouldBeTrue(); + } + [Fact] public void The_non_generic_CompareTo_orders_null_first_and_refuses_another_type() { diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index fe8682c..e32a27f 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -305,6 +305,7 @@ ProbeDescending Value : Int32 attribute CompilerGeneratedAttribute attribute GeneratedValueObjectAttribute + attribute HandWrittenOrderingAttribute attribute JsonConverterAttribute attribute TypeConverterAttribute implements IComparable @@ -604,6 +605,45 @@ ProbeIsbn static op_Inequality(ProbeIsbn, ProbeIsbn) : Boolean extension static ProbeIsbnExtensions.GetValue(ProbeIsbn) : ProbeIsbnText extension static ProbeIsbnExtensions.ValueOrNull(ProbeIsbn?) : ProbeIsbnText +ProbeKeyword + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested ProbeKeywordJsonConverter : JsonConverter + nested ProbeKeywordTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeKeyword + static Parse(String, Boolean) : ProbeKeyword + static TryParse(String, Boolean, out ProbeKeyword) : Boolean + static TryParse(String, out ProbeKeyword) : Boolean +ProbeKindlessFault + Kind : FaultKind + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IFault + implements IFaultSet + implements IParsable + nested ProbeKindlessFaultJsonConverter : JsonConverter + nested ProbeKindlessFaultTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeKindlessFault + static Parse(String, Boolean) : ProbeKindlessFault + static TryParse(String, Boolean, out ProbeKindlessFault) : Boolean + static TryParse(String, out ProbeKindlessFault) : Boolean ProbeLabel $() : ProbeLabel CompareTo(Object) : Int32 @@ -643,6 +683,43 @@ ProbeLabel static op_LessThanOrEqual(ProbeLabel, ProbeLabel) : Boolean extension static ProbeLabelExtensions.GetValue(ProbeLabel) : String extension static ProbeLabelExtensions.ValueOrNull(ProbeLabel) : String +ProbeLabelled + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested ProbeLabelledJsonConverter : JsonConverter + nested ProbeLabelledTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeLabelled + static Parse(String, Boolean) : ProbeLabelled + static TryParse(String, Boolean, out ProbeLabelled) : Boolean + static TryParse(String, out ProbeLabelled) : Boolean +ProbeLevel + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested ProbeLevelJsonConverter : JsonConverter + nested ProbeLevelTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeLevel + static Parse(String, Boolean) : ProbeLevel + static TryParse(String, Boolean, out ProbeLevel) : Boolean + static TryParse(String, out ProbeLevel) : Boolean + static Unlisted() : ProbeLevel ProbeLocalDate CompareTo(Object) : Int32 CompareTo(ProbeLocalDate) : Int32 @@ -768,6 +845,46 @@ ProbeName static op_LessThanOrEqual(ProbeName, ProbeName) : Boolean extension static ProbeNameExtensions.GetValue(ProbeName) : String extension static ProbeNameExtensions.ValueOrNull(ProbeName?) : String +ProbeOrder.Status + Match(Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested StatusJsonConverter : JsonConverter + nested StatusTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeOrder.Status + static Parse(String, Boolean) : ProbeOrder.Status + static TryParse(String, Boolean, out ProbeOrder.Status) : Boolean + static TryParse(String, out ProbeOrder.Status) : Boolean +ProbeOrderFault + Description : String + Kind : FaultKind + Match(Func, Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IFault + implements IFaultSet + implements IParsable + nested ProbeOrderFaultJsonConverter : JsonConverter + nested ProbeOrderFaultTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeOrderFault + static Parse(String, Boolean) : ProbeOrderFault + static TryParse(String, Boolean, out ProbeOrderFault) : Boolean + static TryParse(String, out ProbeOrderFault) : Boolean ProbeOverride CompareTo(Object) : Int32 CompareTo(ProbeOverride) : Int32 @@ -906,6 +1023,28 @@ ProbePin static op_LessThanOrEqual(ProbePin, ProbePin) : Boolean extension static ProbePinExtensions.GetValue(ProbePin) : Int32 extension static ProbePinExtensions.ValueOrNull(ProbePin?) : Int32? +ProbePriority + Label : String + Match(Action, Action, Action) : Void + Match(Func, Func, Func) : TResult + Name : String + QualifiedName : String + Rank : Int32 + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested ProbePriorityJsonConverter : JsonConverter + nested ProbePriorityTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(Int32) : ProbePriority + static Parse(String) : ProbePriority + static Parse(String, Boolean) : ProbePriority + static TryParse(String, Boolean, out ProbePriority) : Boolean + static TryParse(String, out ProbePriority) : Boolean ProbeRatio CompareTo(Object) : Int32 CompareTo(ProbeRatio) : Int32 @@ -958,6 +1097,46 @@ ProbeRatio static op_LessThanOrEqual(ProbeRatio, ProbeRatio) : Boolean extension static ProbeRatioExtensions.GetValue(ProbeRatio) : Double extension static ProbeRatioExtensions.ValueOrNull(ProbeRatio?) : Double? +ProbeReversedLabel + $() : ProbeReversedLabel + CompareTo(Object) : Int32 + CompareTo(ProbeReversedLabel) : Int32 + Equals(Object) : Boolean + Equals(ProbeReversedLabel) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + Value : String + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute HandWrittenOrderingAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IParsable + implements IPlainValueObject + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ProbeReversedLabelJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ProbeReversedLabelTypeConverter : TypeConverter + static From(String) : ProbeReversedLabel + static Parse(String, IFormatProvider) : ProbeReversedLabel + static TryParse(String, IFormatProvider, out ProbeReversedLabel) : Boolean + static op_Equality(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_GreaterThan(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_GreaterThanOrEqual(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_Inequality(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_LessThan(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_LessThanOrEqual(ProbeReversedLabel, ProbeReversedLabel) : Boolean + extension static ProbeReversedLabelExtensions.GetValue(ProbeReversedLabel) : String + extension static ProbeReversedLabelExtensions.ValueOrNull(ProbeReversedLabel) : String ProbeSecret $() : ProbeSecret CompareTo(Object) : Int32 @@ -995,6 +1174,24 @@ ProbeSecret static op_LessThanOrEqual(ProbeSecret, ProbeSecret) : Boolean extension static ProbeSecretExtensions.GetValue(ProbeSecret) : String extension static ProbeSecretExtensions.ValueOrNull(ProbeSecret) : String +ProbeShipment.Status + Match(Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested StatusJsonConverter : JsonConverter + nested StatusTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeShipment.Status + static Parse(String, Boolean) : ProbeShipment.Status + static TryParse(String, Boolean, out ProbeShipment.Status) : Boolean + static TryParse(String, out ProbeShipment.Status) : Boolean ProbeSku Equals(Object) : Boolean Equals(ProbeSku) : Boolean @@ -1053,6 +1250,62 @@ ProbeSpan static op_Inequality(ProbeSpan, ProbeSpan) : Boolean extension static ProbeSpanExtensions.GetValue(ProbeSpan) : ProbeSpanText extension static ProbeSpanExtensions.ValueOrNull(ProbeSpan?) : ProbeSpanText +ProbeSplit + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested ProbeSplitJsonConverter : JsonConverter + nested ProbeSplitTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeSplit + static Parse(String, Boolean) : ProbeSplit + static TryParse(String, Boolean, out ProbeSplit) : Boolean + static TryParse(String, out ProbeSplit) : Boolean +ProbeStatus + Match(Func, Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested ProbeStatusJsonConverter : JsonConverter + nested ProbeStatusTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeStatus + static Parse(String, Boolean) : ProbeStatus + static TryParse(String, Boolean, out ProbeStatus) : Boolean + static TryParse(String, out ProbeStatus) : Boolean +ProbeTier + Match(Func, Func) : TResult + Name : String + Perk : String + QualifiedName : String + Rank : Int32 + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested ProbeTierJsonConverter : JsonConverter + nested ProbeTierTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeTier + static Parse(String, Boolean) : ProbeTier + static TryParse(String, Boolean, out ProbeTier) : Boolean + static TryParse(String, out ProbeTier) : Boolean ProbeTime CompareTo(Object) : Int32 CompareTo(ProbeTime) : Int32 diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs index be9e314..cc39a70 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs @@ -45,7 +45,7 @@ public void The_generated_surface_matches_the_snapshot() private static string Render(Assembly assembly) { var valueObjects = assembly.GetTypes() - .Where(type => type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValueObject<,>))) + .Where(type => type.GetInterfaces().Any(i => i.IsGenericType && (i.GetGenericTypeDefinition() == typeof(IValueObject<,>) || i.GetGenericTypeDefinition() == typeof(CodoMetis.TypeKit.ClosedSets.IClosedSet<>)))) .OrderBy(type => type.FullName, StringComparer.Ordinal) .ToList(); diff --git a/test/CodoMetis.TypeKit.Generators.Tests/JsonParityTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/JsonParityTests.cs index d93abd5..20b26d1 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/JsonParityTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/JsonParityTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Numerics; using System.Text.Json; using System.Text.Json.Serialization; using CodoMetis.TypeKit.Generators.Probes; @@ -9,6 +10,19 @@ namespace CodoMetis.TypeKit.Generators.Tests; +// The number families the probes do not cover, for the number-handling cells below. +public readonly partial record struct ParityByte : IValue; +public readonly partial record struct ParitySByte : IValue; +public readonly partial record struct ParityShort : IValue; +public readonly partial record struct ParityUShort : IValue; +public readonly partial record struct ParityUInt : IValue; +public readonly partial record struct ParityLong : IValue; +public readonly partial record struct ParityULong : IValue; +public readonly partial record struct ParityFloat : IValue; +public readonly partial record struct ParityHalf : IValue; +public readonly partial record struct ParityInt128 : IValue; +public readonly partial record struct ParityUInt128 : IValue; + /// /// A value object writes exactly the bytes the serializer writes for the value it wraps, under the same /// options, as a value, as a property and as a dictionary key, and reads what the serializer reads. @@ -86,6 +100,64 @@ public void A_converter_the_host_registers_is_the_one_that_writes() JsonSerializer.Serialize(new Dictionary { [ProbeName.From("Ab c")] = 1 }, Families["string"].Options("camel-case keys")).ShouldBe("{\"ab c\":1}"); } + /// + /// Every number type the plan lists as one the serializer applies number handling to. Only the + /// serializer applies it, so the plan sends these through the serializer wherever the handling + /// changes the output; a type missing from its list is written by its converter directly, and a + /// value object wrote 123 where a is written "123". + /// + private static readonly Dictionary Numbers = new() + { + ["byte"] = NumberCase(ParityByte.From(123), (byte)123), + ["sbyte"] = NumberCase(ParitySByte.From(-12), (sbyte)-12), + ["short"] = NumberCase(ParityShort.From(-1234), (short)-1234), + ["ushort"] = NumberCase(ParityUShort.From(1234), (ushort)1234), + ["int"] = NumberCase(ProbeCount.From(42), 42), + ["uint"] = NumberCase(ParityUInt.From(123u), 123u), + ["long"] = NumberCase(ParityLong.From(9_007_199_254_740_993L), 9_007_199_254_740_993L), + ["ulong"] = NumberCase(ParityULong.From(18_000_000_000_000_000_000UL), 18_000_000_000_000_000_000UL), + ["float"] = NumberCase(ParityFloat.From(0.25f), 0.25f), + ["double"] = NumberCase(ProbeRatio.From(0.25), 0.25), + ["decimal"] = NumberCase(ProbeAmount.From(1.50m), 1.50m), + ["Half"] = NumberCase(ParityHalf.From((Half)0.5), (Half)0.5), + ["Int128"] = NumberCase(ParityInt128.From(Int128.MaxValue), Int128.MaxValue), + ["UInt128"] = NumberCase(ParityUInt128.From(UInt128.MaxValue), UInt128.MaxValue), + }; + + private static readonly JsonNumberHandling[] NumberHandlings = + [ + JsonNumberHandling.Strict, + JsonNumberHandling.AllowReadingFromString, + JsonNumberHandling.WriteAsString, + JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString, + JsonNumberHandling.AllowNamedFloatingPointLiterals, + JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.AllowReadingFromString, + ]; + + public static TheoryData NumberCells => + [ + .. from family in Numbers.Keys + from handling in NumberHandlings + select (family, handling) + ]; + + [Fact] + public void Every_number_family_and_every_number_handling_is_a_cell() => NumberCells.Count.ShouldBe(14 * 6); + + [Theory] + [MemberData(nameof(NumberCells))] + public void A_number_value_object_follows_the_options_number_handling_as_its_wrapped_type(string family, JsonNumberHandling handling) + { + var @case = Numbers[family]; + + @case.AssertParity(new JsonSerializerOptions { NumberHandling = handling }, $"{family} under {handling}"); + } + + private static ParityCase NumberCase(TValueObject valueObject, T raw) + where TValueObject : IValueObject + where T : notnull, INumberBase => + new(valueObject, raw, number => number.ToString(null, CultureInfo.InvariantCulture), text => T.Parse(text, CultureInfo.InvariantCulture), nodaTime: false); + private static ParityCase Case(TValueObject valueObject, T raw, Func format, Func parse, bool nodaTime = false) where TValueObject : IValueObject where T : notnull => diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ThrowingCreateTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ThrowingCreateTests.cs new file mode 100644 index 0000000..dab0462 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/ThrowingCreateTests.cs @@ -0,0 +1,72 @@ +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; +using NodaTime; + +namespace CodoMetis.TypeKit.Generators.Tests; + +public enum ThrowingFault { Refused } + +/// Parse strategy: the wrapped type's static Parse(string). +public readonly partial record struct ThrowingSku : IValidatedValue +{ + public static Result Create(ProbeSkuText value) => + value.Text == ThrowingCreateTests.Sku ? throw new InvalidOperationException("Create threw.") : new ThrowingSku(value); +} + +/// Parse strategy: the wrapped type's string constructor. +public readonly partial record struct ThrowingIsbn : IValidatedValue +{ + public static Result Create(ProbeIsbnText value) => + value.Text == ThrowingCreateTests.Isbn ? throw new InvalidOperationException("Create threw.") : new ThrowingIsbn(value); +} + +/// Parse strategy: the wrapped type's [TypeConverter]. +public readonly partial record struct ThrowingDate : IValidatedValue +{ + public static Result Create(LocalDate value) => + value == new LocalDate(2026, 10, 13) ? throw new InvalidOperationException("Create threw.") : new ThrowingDate(value); +} + +/// Parse strategy: the wrapped type's own TryParse, which already let it through. +public readonly partial record struct ThrowingCount : IValidatedValue +{ + public static Result Create(int value) => + value == 13 ? throw new InvalidOperationException("Create threw.") : new ThrowingCount(value); +} + +/// +/// An exception Create throws is not a refusal, and leaves TryParse as it leaves +/// Parse, whatever the wrapped type parses through. Over a wrapped type without a TryParse +/// of its own (a type converter, a static Parse, a string constructor) a bare catch around +/// the inner parse and the wrapping made it a false, while over an int it was thrown. Only +/// the wrapped type's refusal of the text is caught now. +/// +public sealed class ThrowingCreateTests +{ + public const string Sku = "THROW00"; + public const string Isbn = "THROW00000000"; + + public static TheoryData Strategies => ["static Parse", "string constructor", "type converter", "TryParse"]; + + private static readonly Dictionary Parse, Func TryParse, string Throwing, string Fine, string Unreadable)> Cases = new() + { + ["static Parse"] = (text => ThrowingSku.Parse(text, null), text => ThrowingSku.TryParse(text, null, out _), Sku, "ABC1234", "short"), + ["string constructor"] = (text => ThrowingIsbn.Parse(text, null), text => ThrowingIsbn.TryParse(text, null, out _), Isbn, "9780000000001", "short"), + ["type converter"] = (text => ThrowingDate.Parse(text, null), text => ThrowingDate.TryParse(text, null, out _), "2026-10-13", "2026-10-14", "not a date"), + ["TryParse"] = (text => ThrowingCount.Parse(text, null), text => ThrowingCount.TryParse(text, null, out _), "13", "14", "x"), + }; + + [Theory] + [MemberData(nameof(Strategies))] + public void An_exception_from_Create_leaves_TryParse_as_it_leaves_Parse(string strategy) + { + var (parse, tryParse, throwing, fine, unreadable) = Cases[strategy]; + + Should.Throw(() => parse(throwing)).Message.ShouldBe("Create threw."); + Should.Throw(() => tryParse(throwing)).Message.ShouldBe("Create threw."); + + tryParse(fine).ShouldBeTrue(); + tryParse(unreadable).ShouldBeFalse("text the wrapped type cannot read is still a false"); + Should.Throw(() => parse(unreadable)); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/ClosedSetMemberAssignedSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/ClosedSetMemberAssignedSamples.cs new file mode 100644 index 0000000..35c752a --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/ClosedSetMemberAssignedSamples.cs @@ -0,0 +1,33 @@ +using CodoMetis.TypeKit.ClosedSets; + +namespace ReadmeSamples.Analyzers.ClosedSetMemberAssigned; + +#pragma warning disable CMTK0013 // what the sample shows the rule reporting +// sample: CodoMetis.TypeKit.Analyzers/cmtk0013 +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Closed = new(); + + static OrderStatus() { Open = Closed; } // CMTK0013: Open now holds the instance named "Closed" +} +// end sample +#pragma warning restore CMTK0013 + +/// What the README says the suppressed rule would have prevented, read from the woven set. +public sealed class ClosedSetMemberAssignedTests +{ + [Fact] + public void Open_holds_the_instance_named_Closed() + { + OrderStatus.Open.Name.ShouldBe("Closed"); + } + + [Fact] + public void Closed_takes_the_open_branch_and_Parse_disagrees_with_All() + { + OrderStatus.Closed.Match(open: () => "open", closed: () => "closed").ShouldBe("open"); + OrderStatus.Parse("Open").ToString().ShouldBe("Closed"); + OrderStatus.All[0].ShouldNotBeSameAs(OrderStatus.Parse("Open")); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/PositionalMatchSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/PositionalMatchSamples.cs new file mode 100644 index 0000000..3ade1bf --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/PositionalMatchSamples.cs @@ -0,0 +1,34 @@ +using CodoMetis.TypeKit.ClosedSets; + +namespace ReadmeSamples.Analyzers.PositionalMatch; + +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} + +public static class Labels +{ + public static (string Label, string Named) Of(OrderStatus status) + { +#pragma warning disable CMTK0012 // what the sample shows the rule reporting + // sample: CodoMetis.TypeKit.Analyzers/cmtk0012 + var label = status.Match(() => "Open", () => "On its way", () => "Cancelled"); // CMTK0012: by position + var named = status.Match(open: () => "Open", shipped: () => "On its way", cancelled: () => "Cancelled"); + // end sample +#pragma warning restore CMTK0012 + + return (label, named); + } +} + +public sealed class PositionalMatchTests +{ + [Fact] + public void Both_calls_pick_the_branch_of_the_member_while_the_order_is_unchanged() + { + Labels.Of(OrderStatus.Shipped).ShouldBe(("On its way", "On its way")); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/RequiredConstructorSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/RequiredConstructorSamples.cs new file mode 100644 index 0000000..2f433eb --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/RequiredConstructorSamples.cs @@ -0,0 +1,24 @@ +using CodoMetis.TypeKit; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Analyzers.RequiredConstructor; + +#pragma warning disable CMTK0011 // what the sample shows the rule reporting +#pragma warning disable CS9113 // the primary constructor's parameter is the point, and unused +// sample: CodoMetis.TypeKit.Analyzers/cmtk0011 +[RequireParameterlessConstructor] +public interface IEndpoint { void Map(IEndpointRouteBuilder app); } + +[RequireParameterlessConstructor(ConstructorAccessibility.Internal)] +public abstract class Module { } + +public sealed class OrderEndpoint(ILogger log) : IEndpoint // CMTK0011: a primary constructor, so no parameterless one +{ + public void Map(IEndpointRouteBuilder app) { } +} + +public sealed class Identity : Module; // CMTK0011: the implicit constructor is public, Module requires internal +// end sample +#pragma warning restore CS9113 +#pragma warning restore CMTK0011 diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/RuleSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/RuleSamples.cs index 1c58858..063b92f 100644 --- a/test/CodoMetis.TypeKit.Samples/Analyzers/RuleSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/RuleSamples.cs @@ -24,6 +24,10 @@ public interface IOrders Task> CancelAsync(OrderId id); Result Cancel(OrderId id); + + IEnumerable> CancelEach(IEnumerable ids); + + IAsyncEnumerable> CancelEachAsync(IEnumerable ids); } public interface IOrderIds @@ -103,6 +107,21 @@ public static async Task DroppedCollections(IOrders orders, IEnumerable // end sample } + /// The silent part is compiled outside the suppression, so a report on it fails the build. + public static async Task DroppedByWaitingOrLooping(IOrders orders, IEnumerable ids, OrderId id) + { +#pragma warning disable CMTK0003 // what the sample shows the rule reporting + // sample: CodoMetis.TypeKit.Analyzers/cmtk0003-waits + orders.CancelAsync(id).Wait(); // CMTK0003: blocks until the Result exists, then forgets it + Task.WaitAll(ids.Select(orders.CancelAsync)); // CMTK0003: the tasks become plain ones + await foreach (var outcome in orders.CancelEachAsync(ids)) { } // CMTK0003: every Result, read by nobody + // end sample +#pragma warning restore CMTK0003 + // sample: CodoMetis.TypeKit.Analyzers/cmtk0003-waits + await foreach (var _ in orders.CancelEachAsync(ids)) { } // a variable named _ says the drop is meant + // end sample + } + public static void DefaultFilledArray(int count, IEnumerable rows) { #pragma warning disable CMTK0005 // what the sample shows the rule reporting diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/TypeArgumentConstraintSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/TypeArgumentConstraintSamples.cs new file mode 100644 index 0000000..3cb8ce8 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/TypeArgumentConstraintSamples.cs @@ -0,0 +1,20 @@ +using CodoMetis.TypeKit; + +namespace ReadmeSamples.Analyzers.TypeArgumentConstraints; + +public abstract record Command; + +public sealed record OrderPlaced; + +#pragma warning disable CMTK0010 // what the sample shows the rule reporting +// sample: CodoMetis.TypeKit.Analyzers/cmtk0010 +public abstract class Decider<[Abstract] TCommand, [Record] TEvent> { } + +public sealed record PlaceOrder : Command; + +public sealed class OrderDecider : Decider { } // CMTK0010: PlaceOrder is not abstract + +public abstract class Leaky : Decider { } // CMTK0010: TCommand does not carry [Abstract] +public abstract class Sound<[Abstract] TCommand> : Decider { } +// end sample +#pragma warning restore CMTK0010 diff --git a/test/CodoMetis.TypeKit.Samples/AspNetCore/AspNetCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/AspNetCore/AspNetCoreSamples.cs index 727e19c..7f1a30c 100644 --- a/test/CodoMetis.TypeKit.Samples/AspNetCore/AspNetCoreSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/AspNetCore/AspNetCoreSamples.cs @@ -6,6 +6,10 @@ // sample: CodoMetis.TypeKit.AspNetCore/every-document using Microsoft.AspNetCore.OpenApi; // OpenApiOptions +// end sample +// sample: CodoMetis.TypeKit.AspNetCore/bind-required +using Microsoft.AspNetCore.Mvc.ModelBinding; // BindRequired + // end sample using System.Net; using System.Text; @@ -18,6 +22,8 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -197,6 +203,75 @@ public async Task A_body_without_a_value_object_is_refused_only_with_the_option( } } +// Internal, so no host of this assembly discovers it unasked; the test below adds it. +[ApiController] +internal sealed class FindOrderController(IOrders orders) : ControllerBase +{ + // sample: CodoMetis.TypeKit.AspNetCore/bind-required + [HttpGet("/mvc/orders")] + public OrderDto Find([FromQuery, BindRequired] OrderId id, [FromQuery] Quantity? limit) => orders.Find(id, limit); + // end sample +} + +/// +/// What the README's binding section says about a value missing from an MVC query: with [BindRequired] +/// a 400 naming the parameter, and a nullable parameter null. MvcBindingTests in the AspNetCore tests +/// pins the default MVC binds without either. +/// +public sealed class BindRequiredTests +{ + [Fact] + public async Task A_required_value_object_missing_from_the_query_is_a_400_and_an_optional_one_is_null() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + var orders = new RecordingOrders(); + builder.Services.AddSingleton(orders); + builder.Services.AddControllers().ConfigureApplicationPartManager(parts => + { + parts.ApplicationParts.Clear(); + parts.FeatureProviders.Add(new OnlyFindOrder()); + }); + + await using var app = builder.Build(); + app.MapControllers(); + await app.StartAsync(TestContext.Current.CancellationToken); + var client = app.GetTestClient(); + + using var missing = await client.GetAsync("/mvc/orders", TestContext.Current.CancellationToken); + missing.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await missing.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldContain("A value for the 'id' parameter or property was not provided."); + orders.Calls.ShouldBe(0, "the action never ran"); + + var id = Guid.NewGuid(); + using var found = await client.GetAsync($"/mvc/orders?id={id}", TestContext.Current.CancellationToken); + found.StatusCode.ShouldBe(HttpStatusCode.OK); + orders.Calls.ShouldBe(1); + orders.Limit.ShouldBeNull(); + } + + private sealed class RecordingOrders : IOrders + { + public int Calls { get; private set; } + + public Quantity? Limit { get; private set; } + + public OrderDto Find(OrderId id, Quantity? limit = null) + { + Calls++; + Limit = limit; + return new OrderDto(id, [], []); + } + } + + private sealed class OnlyFindOrder : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) => + feature.Controllers.Add(typeof(FindOrderController).GetTypeInfo()); + } +} + public sealed partial class AspNetCoreTests { private const string Readme = "src/CodoMetis.TypeKit.AspNetCore/README.md"; diff --git a/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs new file mode 100644 index 0000000..4569a5f --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs @@ -0,0 +1,315 @@ +using System.Net; +using System.Text.Json.Nodes; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ClosedSets; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Web.Faults; + +public enum PaymentFault { Declined, Duplicate } + +// sample: CodoMetis.TypeKit.AspNetCore/fault-set +public sealed partial class AccountFault : IFaultSet +{ + public static readonly AccountFault NotFound = new(FaultKind.NotFound, "The account does not exist."); + public static readonly AccountFault EmailTaken = new(FaultKind.Conflict, "Another account uses that email address."); + public static readonly AccountFault Locked = new(FaultKind.Forbidden, null); // a refusal that must not explain itself + + private AccountFault(FaultKind kind, string? description) { Kind = kind; Description = description; } + + public FaultKind Kind { get; } + public string? Description { get; } +} +// end sample + +public sealed record Account(int Id, string Email); + +public sealed record NewAccount(string Email); + +public interface IAccounts +{ + Result Open(NewAccount body); + + Result> Rename(int id, string email); +} + +// sample: CodoMetis.TypeKit.AspNetCore/one-fault-for-two-causes +public enum SignInFault { UnknownUser, WrongPassword } // the domain's: which check refused +public enum LoginFault { InvalidCredentials } // what the client is told + +// end sample +public sealed record Credentials(string User, string Password); + +public sealed record Session(string Token); + +public interface ISignIn +{ + Result SignIn(Credentials credentials); +} + +/// The README's sign-in: the domain tells its two refusals apart, the response does not. +public static class SignIns +{ + public static void Responses(WebApplicationBuilder builder) + { + // sample: CodoMetis.TypeKit.AspNetCore/one-fault-for-two-causes + builder.Services.AddFaultResponses(_ => StatusCodes.Status401Unauthorized); + + // end sample + } + + public static void Endpoints(WebApplication app, ISignIn accounts) + { + // sample: CodoMetis.TypeKit.AspNetCore/one-fault-for-two-causes + app.MapPost("/login", (Credentials credentials) => accounts.SignIn(credentials).MapError(_ => LoginFault.InvalidCredentials).ToHttpResult()); + // end sample + } +} + +public static class Faults +{ + public static void Responses(WebApplicationBuilder builder) + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-response + builder.Services.AddFaultResponses(fault => fault switch + { + PaymentFault.Declined => FaultResponse.Status(StatusCodes.Status402PaymentRequired).WithType(new Uri("https://example.com/problems/declined")), + PaymentFault.Duplicate => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), + }); + // end sample + + // sample: CodoMetis.TypeKit.AspNetCore/fault-set-mapping + builder.Services.AddFaultResponses(); // every member by its kind, its description as the detail + // end sample + } + + public static void Endpoints(WebApplication app, IAccounts accounts) + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-status + app.MapPost("/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult()) + .WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); + // end sample + + // sample: CodoMetis.TypeKit.AspNetCore/fault-status-group + var v3 = app.MapGroup("/v3").WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); + v3.MapPost("/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult()); // answers with AccountFault: the 422 applies + v3.MapGet("/health", () => "ok"); // answers with none: left alone + // end sample + + // sample: CodoMetis.TypeKit.AspNetCore/fault-success + app.MapPost("/v2/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult(account => TypedResults.Created($"/accounts/{account.Id}", account))); + // end sample + + // sample: CodoMetis.TypeKit.AspNetCore/fault-detail + app.MapPut("/accounts/{id:int}/email", (int id, NewAccount body) => accounts.Rename(id, body.Email).ToHttpResult()); + // end sample + } + + public static Result> Taken() + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-detail-domain + Result> taken = Result.Error(Fault.Of(AccountFault.EmailTaken, "The address has been in use since 3 May.")); + // end sample + + return taken; + } + + public static void Shaped(WebApplicationBuilder builder) + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-feature + builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => + { + if (context.HttpContext.Features.Get() is { } fault) + context.ProblemDetails.Extensions["code"] = $"ACC-{fault.Name}"; // the host's own extension, for this fault only + }); + builder.Services.Configure(options => options.QualifiedFaultNames = true); // "fault": "AccountFault.EmailTaken" + // end sample + } +} + +// Internal, so no host of this assembly discovers it: the sample is the action's declaration. +[ApiController] +internal sealed class AccountsController(IAccounts accounts) : ControllerBase +{ + // sample: CodoMetis.TypeKit.AspNetCore/fault-status-mvc + [HttpPost("/mvc/accounts")] + [FaultStatus(nameof(AccountFault.EmailTaken), StatusCodes.Status422UnprocessableEntity)] + public Results, FaultProblem> Open(NewAccount body) => accounts.Open(body).ToHttpResult(); + // end sample +} + +/// A domain that answers every call with one outcome, for the tests below. +public sealed class FixedAccounts(Result open) : IAccounts +{ + public Result Open(NewAccount body) => open; + + public Result> Rename(int id, string email) => Faults.Taken(); +} + +public sealed class FaultResponseSampleTests +{ + private static async Task StartAsync(Result open, bool shaped = false) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi(options => options.AddTypeKit()); + Faults.Responses(builder); + if (shaped) Faults.Shaped(builder); + + var app = builder.Build(); + app.MapOpenApi(); + Faults.Endpoints(app, new FixedAccounts(open)); + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + private static StringContent Body() => new("""{"email":"ann@example.com"}""", System.Text.Encoding.UTF8, "application/json"); + + [Fact] + public async Task A_fault_set_is_answered_by_kind_and_the_endpoint_s_status_where_it_sets_one() + { + await using var app = await StartAsync(Result.Error(AccountFault.EmailTaken)); + + using var strict = await app.GetTestClient().PostAsync("/accounts", Body(), TestContext.Current.CancellationToken); + strict.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity, "WithFaultStatus"); + + using var byKind = await app.GetTestClient().PostAsync("/v2/accounts", Body(), TestContext.Current.CancellationToken); + byKind.StatusCode.ShouldBe(HttpStatusCode.Conflict, "the member's kind"); + var problem = JsonNode.Parse(await byKind.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + problem["detail"]!.GetValue().ShouldBe("Another account uses that email address."); + } + + /// The README's group: its status applies to the endpoint that answers with the fault type, and leaves the health check alone. + [Fact] + public async Task A_group_s_status_applies_to_its_fault_endpoint_and_leaves_its_health_check_alone() + { + await using var app = await StartAsync(Result.Error(AccountFault.EmailTaken)); + var client = app.GetTestClient(); + + using var refused = await client.PostAsync("/v3/accounts", Body(), TestContext.Current.CancellationToken); + refused.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + (await client.GetStringAsync("/v3/health", TestContext.Current.CancellationToken)).ShouldBe("ok"); + + var paths = JsonNode.Parse(await client.GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken))!["paths"]!; + paths["/v3/accounts"]!["post"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "403", "404", "422"]); + paths["/v3/health"]!["get"]!["responses"]!.AsObject().Select(response => response.Key).ShouldBe(["200"]); + } + + [Fact] + public async Task The_endpoint_s_own_success_is_a_201_with_its_location() + { + await using var app = await StartAsync(new Account(7, "ann@example.com")); + + using var created = await app.GetTestClient().PostAsync("/v2/accounts", Body(), TestContext.Current.CancellationToken); + created.StatusCode.ShouldBe(HttpStatusCode.Created); + created.Headers.Location!.ToString().ShouldBe("/accounts/7"); + } + + [Fact] + public async Task An_occurrence_s_detail_replaces_the_member_s_description() + { + await using var app = await StartAsync(new Account(7, "ann@example.com")); + + using var response = await app.GetTestClient().PutAsync("/accounts/7/email", Body(), TestContext.Current.CancellationToken); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict); + problem["detail"]!.GetValue().ShouldBe("The address has been in use since 3 May."); + } + + [Fact] + public async Task The_host_shapes_the_problem_and_qualifies_the_fault_name() + { + await using var app = await StartAsync(Result.Error(AccountFault.EmailTaken), shaped: true); + + using var response = await app.GetTestClient().PostAsync("/v2/accounts", Body(), TestContext.Current.CancellationToken); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + + problem["fault"]!.GetValue().ShouldBe("AccountFault.EmailTaken"); + problem["code"]!.GetValue().ShouldBe("ACC-AccountFault.EmailTaken"); + } + + [Fact] + public async Task A_type_URI_reaches_the_problem_and_the_document() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi(options => options.AddTypeKit()); + Faults.Responses(builder); + await using var app = builder.Build(); + app.MapOpenApi(); + app.MapPost("/payments", () => Result.Error(PaymentFault.Declined).ToHttpResult()); + await app.StartAsync(TestContext.Current.CancellationToken); + + using var response = await app.GetTestClient().PostAsync("/payments", content: null, TestContext.Current.CancellationToken); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + response.StatusCode.ShouldBe(HttpStatusCode.PaymentRequired); + problem["type"]!.GetValue().ShouldBe("https://example.com/problems/declined"); + + var document = JsonNode.Parse(await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken))!; + document["paths"]!["/payments"]!["post"]!["responses"]!["402"]!["description"]!.GetValue() + .ShouldBe("Payment Required. Problem types: https://example.com/problems/declined"); + } +} + +/// +/// What the README's "Faults a client must not tell apart" says: one status for two causes still names +/// which it was, and mapped to one member at the boundary, the two answer alike, the trace id aside. +/// +public sealed class SignInSampleTests +{ + private sealed class Refusing : ISignIn + { + public Result SignIn(Credentials credentials) => + Result.Error(credentials.User == "ann" ? SignInFault.WrongPassword : SignInFault.UnknownUser); + } + + [Fact] + public async Task Two_causes_mapped_to_one_fault_answer_alike_where_one_status_alone_tells_them_apart() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + SignIns.Responses(builder); + builder.Services.AddFaultResponses(_ => StatusCodes.Status401Unauthorized); + + await using var app = builder.Build(); + var accounts = new Refusing(); + SignIns.Endpoints(app, accounts); + app.MapPost("/login-telling", (Credentials credentials) => accounts.SignIn(credentials).ToHttpResult()); // one status, the domain's faults + await app.StartAsync(TestContext.Current.CancellationToken); + + var unknownUser = await AnswerAsync(app, "/login", "nobody"); + var wrongPassword = await AnswerAsync(app, "/login", "ann"); + unknownUser.Status.ShouldBe(HttpStatusCode.Unauthorized); + unknownUser.Body["fault"]!.GetValue().ShouldBe("InvalidCredentials"); + wrongPassword.Status.ShouldBe(unknownUser.Status); + JsonNode.DeepEquals(wrongPassword.Body, unknownUser.Body) + .ShouldBeTrue($"{wrongPassword.Body.ToJsonString()} tells itself apart from {unknownUser.Body.ToJsonString()}"); + + (await AnswerAsync(app, "/login-telling", "nobody")).Body["fault"]!.GetValue().ShouldBe("UnknownUser"); + (await AnswerAsync(app, "/login-telling", "ann")).Body["fault"]!.GetValue().ShouldBe("WrongPassword"); + } + + private static async Task<(HttpStatusCode Status, JsonObject Body)> AnswerAsync(WebApplication app, string path, string user) + { + using var content = new StringContent($$"""{"user":"{{user}}","password":"secret"}""", System.Text.Encoding.UTF8, "application/json"); + using var response = await app.GetTestClient().PostAsync(path, content, TestContext.Current.CancellationToken); + var body = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!.AsObject(); + body.Remove("traceId"); + + return (response.StatusCode, body); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs new file mode 100644 index 0000000..2df768e --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs @@ -0,0 +1,67 @@ +using CodoMetis.TypeKit; + +namespace ReadmeSamples.Base.Catching; + +public static class Outcomes +{ + public static async Task> RunAsync(string source, string target, HttpClient client, Uri url) + { + // sample: CodoMetis.TypeKit/catch + Result parsed = Result.Catch(() => int.Parse("42")); // Success: 42 + Result refused = Result.Catch(() => int.Parse("x")); // Error: FormatException + Result narrowed = Result.Catch(() => int.Parse("x")); // only that type; any other propagates + Result copied = Result.Catch(() => File.Copy(source, target)); // a command: a success, or the exception + Result fetched = await Result.CatchAsync(() => client.GetByteArrayAsync(url)); // waits for the task; its exception is the error + // end sample + + _ = (parsed, refused, narrowed, copied); + return fetched; + } +} + +public sealed class CatchTests +{ + private const string Readme = "src/CodoMetis.TypeKit/README.md"; + + private static string Render(Result result) where T : notnull => + result.Match(value => $"Success: {value}", error => $"Error: {error.GetType().Name}"); + + [Fact] + public void Each_line_answers_what_its_comment_states() + { + Result.Catch(() => int.Parse("42")).ShouldBeShownIn(Readme, Render); + Result.Catch(() => int.Parse("x")).ShouldBeShownIn(Readme, Render); + } + + [Fact] + public async Task The_sample_runs_as_written() + { + var source = Path.GetTempFileName(); + var target = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + using var client = new HttpClient(new RefusingHandler()); + var fetched = await Outcomes.RunAsync(source, target, client, new Uri("http://127.0.0.1:1/unreachable")); + + File.Exists(target).ShouldBeTrue(); + fetched.TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBeOfType(); + } + finally + { + File.Delete(source); + File.Delete(target); + } + } + + /// + /// Refuses every request at once. A real socket with a short timeout raced: when the timeout fired + /// before the connection was refused, HttpClient threw a cancellation, which CatchAsync lets + /// through, and the test failed on a loaded machine. + /// + private sealed class RefusingHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromException(new HttpRequestException("Connection refused.")); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Base/OptionSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/OptionSamples.cs index 981f5d9..5fd9612 100644 --- a/test/CodoMetis.TypeKit.Samples/Base/OptionSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Base/OptionSamples.cs @@ -7,8 +7,17 @@ namespace ReadmeSamples.Base.Options; public interface ICustomerRepository { Option FindByEmail(string email); + + Task> FindByEmailAsync(string email); } +public interface IAudit +{ + Task SeenAsync(Customer customer); +} + +public enum SignInFault { Unknown } + public sealed class Customer { public required string Name { get; init; } @@ -53,4 +62,16 @@ from rate in discounts.GetValueOrNone(c.Tier) // TryGetValue as a select rate; // end sample } + + public static async Task> UseAsync(ICustomerRepository repository, IAudit audit, string email) + { + // sample: CodoMetis.TypeKit/option-async + Result active = await repository.FindByEmailAsync(email) // Task> + .FilterAsync(c => c.IsActive) + .TapAsync(c => audit.SeenAsync(c)) // waited for + .ToResultAsync(SignInFault.Unknown); // into a Result pipeline + // end sample + + return active; + } } diff --git a/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs new file mode 100644 index 0000000..ad0a059 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using CodoMetis.TypeKit; + +namespace ReadmeSamples.Base.Patches; + +public sealed class Order +{ + public string Name { get; set; } = ""; + public string? Nickname { get; set; } + public int Quantity { get; set; } +} + +// sample: CodoMetis.TypeKit/patch +public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); +// end sample + +public static class Applying +{ + public static void Apply(Order order, OrderPatch patch) + { + // sample: CodoMetis.TypeKit/patch-apply + patch.Name.Tap(name => order.Name = name); // a clear cannot reach a required field + patch.Nickname.IfPresent(nickname => order.Nickname = nickname); // null clears an optional one + order.Quantity = patch.Quantity.Match( + quantity => quantity, + () => throw new InvalidOperationException("Quantity cannot be cleared."), + () => order.Quantity); + // end sample + } +} + +public sealed class PatchTests +{ + private const string Readme = "src/CodoMetis.TypeKit/README.md"; + + private static string Render(Patch patch) where T : notnull => patch.Match(value => $"Value: {value}", () => "Null", () => "Absent"); + + [Fact] + public void A_document_reads_as_the_comments_state() + { + // sample: CodoMetis.TypeKit/patch-read + var patch = JsonSerializer.Deserialize("""{"nickname":null,"quantity":2}""", JsonSerializerOptions.Web)!; + var name = patch.Name; // Absent, the field was not mentioned + var nickname = patch.Nickname; // Null, the field was null + var quantity = patch.Quantity; // Value: 2 + // end sample + + patch.Name.ShouldBeShownIn(Readme, Render); + patch.Nickname.ShouldBeShownIn(Readme, Render); + patch.Quantity.ShouldBeShownIn(Readme, Render); + _ = (name, nickname, quantity); + } + + [Fact] + public void Applying_sets_clears_and_keeps() + { + var order = new Order { Name = "Old", Nickname = "nick", Quantity = 1 }; + + Applying.Apply(order, JsonSerializer.Deserialize("""{"name":"New","nickname":null}""", JsonSerializerOptions.Web)!); + + order.Name.ShouldBe("New"); + order.Nickname.ShouldBeNull(); + order.Quantity.ShouldBe(1); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Base/TextSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/TextSamples.cs new file mode 100644 index 0000000..4811a6c --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Base/TextSamples.cs @@ -0,0 +1,55 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Base.Text; + +// sample: CodoMetis.TypeKit/text-value-object +public readonly partial record struct Description : IValidatedValue +{ + public static Result Create(string value) => + value.AsText().Map(text => new Description(text)); +} +// end sample + +public static class Guards +{ + public static (Result Name, Result Subject, Result Missing) Run() + { + // sample: CodoMetis.TypeKit/text + var name = " Jean\u00A0Paul ".AsText(); // Success: Jean Paul, trimmed, the no-break space folded to an ordinary one + var subject = "subject\u200Bid".AsIdentifier(); // Error: Hidden, a zero-width space + var missing = ((string?)null).AsText(); // Error: Blank + // end sample + + return (name, subject, missing); + } +} + +public sealed class TextTests +{ + private const string Readme = "src/CodoMetis.TypeKit/README.md"; + + private static string Render(Result result) => + result.Match(text => $"Success: {text}", fault => $"Error: {fault}"); + + [Fact] + public void Each_guard_answers_what_its_comment_states() + { + " Jean\u00A0Paul ".AsText().ShouldBeShownIn(Readme, Render); + "subject\u200Bid".AsIdentifier().ShouldBeShownIn(Readme, Render); + ((string?)null).AsText().ShouldBeShownIn(Readme, Render); + + var (name, subject, missing) = Guards.Run(); + Render(name).ShouldStartWith("Success: Jean Paul"); + Render(subject).ShouldBe("Error: Hidden"); + Render(missing).ShouldBe("Error: Blank"); + } + + [Fact] + public void The_value_object_applies_the_guard() + { + Description.Create(" Jean\u00A0Paul ").Match(d => d.Value, _ => "").ShouldBe("Jean Paul"); + Description.TryFrom("\u200B").IsNone().ShouldBeTrue(); + Description.Create("Mu\u0308ller").ShouldBe(Description.Create("M\u00FCller")); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs new file mode 100644 index 0000000..1e4fb9e --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs @@ -0,0 +1,258 @@ +// sample: aspnet-core/usings +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.AspNetCore.Http.HttpResults; +// end sample +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Docs.Web; + +// sample: aspnet-core/types +public readonly partial record struct CustomerId : IValue; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value.Trim()) : Result.Error(EmailFault.NoAt); +} + +public enum PageFault { NotPositive, TooLarge } + +public readonly partial record struct PageSize : IValidatedValue +{ + public static Result Create(int value) => + value <= 0 ? Result.Error(PageFault.NotPositive) : value > 100 ? Result.Error(PageFault.TooLarge) : new PageSize(value); +} + +public enum CustomerFault { NotFound, EmailTaken } + +public sealed record RegisterCustomer(Email Email, string Name); + +public sealed record CustomerDto(CustomerId Id, Email Email, string Name); +// end sample + +// sample: aspnet-core/service +public interface ICustomers +{ + Result Find(CustomerId id); + + Result Register(RegisterCustomer request); + + IReadOnlyList List(Email? email, PageSize? page); +} +// end sample + +// sample: aspnet-core/mvc +[ApiController] +[Route("mvc/customers")] +public sealed class CustomersController(ICustomers customers) : ControllerBase +{ + [HttpGet("{id}")] + public Results, FaultProblem> Get([FromRoute] CustomerId id) => customers.Find(id).ToHttpResult(); // the generated TypeConverter binds it + + [HttpGet] + public IReadOnlyList List([FromQuery] Email? email, [FromQuery, BindRequired] PageSize page) => customers.List(email, page); // without [BindRequired], no page is a default PageSize + + [HttpPost] + public Results, FaultProblem> Post(RegisterCustomer body) => customers.Register(body).ToHttpResult(); +} +// end sample + +public static class Host +{ + public static void Configure(WebApplicationBuilder builder) + { + // sample: aspnet-core/setup + builder.Services.AddOpenApi(options => options.AddTypeKit()); // the document: every value object as the schema of what it wraps + + builder.Services.AddFaultResponses(fault => fault switch // a Result as a response: the status per fault + { + CustomerFault.NotFound => StatusCodes.Status404NotFound, + CustomerFault.EmailTaken => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), + }); + // end sample + } + + public static void Map(WebApplication app, ICustomers customers) + { +#pragma warning disable ASP0020 // the value objects are declared in this project, as the page says + // sample: aspnet-core/minimal-api + app.MapGet("/customers/{id}", (CustomerId id) => customers.Find(id).ToHttpResult()); // a route parameter, through the generated TryParse + app.MapGet("/customers", (Email? email, PageSize? page) => customers.List(email, page)); // query parameters, optional + app.MapPost("/customers", (RegisterCustomer body) => customers.Register(body).ToHttpResult()); // a body, through the generated JSON converter + // end sample +#pragma warning restore ASP0020 + } + + public static bool Outcome(ICustomers customers) + { + // sample: aspnet-core/handler-outcome + var response = customers.Find(CustomerId.New()).ToHttpResult(); + + bool notFound = response.Result is FaultProblem { Fault: CustomerFault.NotFound }; // true + // end sample + + return notFound; + } + + public static void Customize(WebApplicationBuilder builder) + { + // sample: aspnet-core/customize + builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["service"] = "customers"); + // end sample + } +} + +/// One registered customer; a second registration of the same address is a conflict. +public sealed class InMemoryCustomers : ICustomers +{ + public CustomerDto Ann { get; } = new(CustomerId.New(), Email.FromKnownGood("ann@example.com"), "Ann"); + + public Result Find(CustomerId id) => id == Ann.Id ? Ann : Result.Error(CustomerFault.NotFound); + + public Result Register(RegisterCustomer request) => + request.Email == Ann.Email ? Result.Error(CustomerFault.EmailTaken) : new CustomerDto(CustomerId.New(), request.Email, request.Name); + + public IReadOnlyList List(Email? email, PageSize? page) => email is null || email == Ann.Email ? [Ann] : []; +} + +public sealed partial class AspNetCoreTests +{ + private const string Readme = "docs/aspnet-core.md"; + + private static async Task<(WebApplication App, InMemoryCustomers Customers)> StartAsync(bool customize = false) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + var customers = new InMemoryCustomers(); + builder.Services.AddSingleton(customers); + builder.Services.AddControllers().AddApplicationPart(typeof(CustomersController).Assembly); + Host.Configure(builder); + if (customize) Host.Customize(builder); + + var app = builder.Build(); + app.MapOpenApi(); + Host.Map(app, customers); + app.MapControllers(); + await app.StartAsync(TestContext.Current.CancellationToken); + + return (app, customers); + } + + /// The table under "What a refused value answers", row by row, against the running host. + [Fact] + public async Task A_refused_value_answers_as_the_table_says() + { + var (app, customers) = await StartAsync(); + await using var _ = app; + var client = app.GetTestClient(); + var token = TestContext.Current.CancellationToken; + + (await client.GetAsync($"/customers/{customers.Ann.Id}", token)).StatusCode.ShouldBe(HttpStatusCode.OK); + (await client.GetAsync("/customers/not-an-id", token)).StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await client.GetAsync("/customers?page=500", token)).StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await client.GetAsync("/customers?page=50&email=ann@example.com", token)).StatusCode.ShouldBe(HttpStatusCode.OK); + + using var refused = await client.PostAsync("/customers", Body("""{"email":"nobody","name":"Bo"}"""), token); + refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await refused.Content.ReadAsStringAsync(token)).ShouldBeEmpty("a minimal API logs the JsonException and answers an empty 400"); + + using var missing = await client.PostAsync("/customers", Body("""{"name":"Bo"}"""), token); + missing.StatusCode.ShouldBe(HttpStatusCode.OK, "without RespectRequiredConstructorParameters the handler sees a default Email"); + + using var mvc = await client.PostAsync("/mvc/customers", Body("""{"email":"nobody","name":"Bo"}"""), token); + mvc.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problem = await mvc.Content.ReadAsStringAsync(token); + problem.ShouldContain("NoAt"); + problem.ShouldContain("Email"); + problem.ShouldNotContain("nobody"); + (await client.GetAsync($"/mvc/customers/{customers.Ann.Id}", token)).StatusCode.ShouldBe(HttpStatusCode.OK); + + using var unpaged = await client.GetAsync("/mvc/customers", token); + unpaged.StatusCode.ShouldBe(HttpStatusCode.BadRequest, "page is [BindRequired]; without it MVC would call the action with a default PageSize"); + (await unpaged.Content.ReadAsStringAsync(token)).ShouldContain("A value for the 'page' parameter or property was not provided."); + (await client.GetAsync("/mvc/customers?page=50", token)).StatusCode.ShouldBe(HttpStatusCode.OK); + } + + /// The problem the page shows, read from its first JSON block, against what the host answers for an unknown id. + [Fact] + public async Task A_fault_is_answered_with_the_problem_the_page_shows() + { + var (app, _) = await StartAsync(); + await using var _ = app; + var token = TestContext.Current.CancellationToken; + + using var response = await app.GetTestClient().GetAsync($"/customers/{Guid.Empty}", token); + var answered = JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject(); + var shown = JsonBlocks()[0].AsObject(); + + ((int)response.StatusCode).ShouldBe(shown["status"]!.GetValue()); + answered.Select(member => member.Key).Order().ShouldBe(shown.Select(member => member.Key).Order()); + foreach (var (name, value) in shown.Where(member => member.Key != "traceId")) + JsonNode.DeepEquals(answered[name], value).ShouldBeTrue($"{name}: {answered[name]?.ToJsonString()} where the page shows {value?.ToJsonString()}"); + + using var conflict = await app.GetTestClient().PostAsync("/customers", Body("""{"email":"ann@example.com","name":"Ann"}"""), token); + conflict.StatusCode.ShouldBe(HttpStatusCode.Conflict); + } + + [Fact] + public void A_handler_s_outcome_is_read_without_a_host() => + Host.Outcome(new InMemoryCustomers()).ShouldBeShownIn(Readme, found => found ? "true" : "false", "response.Result is FaultProblem { Fault: CustomerFault.NotFound }"); + + [Fact] + public async Task The_host_s_problem_details_customisation_reaches_a_fault() + { + var (app, _) = await StartAsync(customize: true); + await using var _ = app; + var token = TestContext.Current.CancellationToken; + + using var response = await app.GetTestClient().GetAsync($"/customers/{Guid.Empty}", token); + var answered = JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject(); + + answered["service"]!.GetValue().ShouldBe("customers"); + answered["fault"]!.GetValue().ShouldBe("NotFound"); + } + + /// The operation the page shows, read from its second JSON block: every keyword it shows, the document has. + [Fact] + public async Task The_document_says_what_the_page_shows() + { + var (app, _) = await StartAsync(); + await using var _ = app; + + var json = await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken); + var document = JsonNode.Parse(json)!; + var shown = JsonBlocks()[1].AsObject(); + + foreach (var (path, item) in shown) document["paths"]![path].ShouldContainSchema(item!, path); + document.Component("CustomerId").ShouldContainSchema(JsonNode.Parse("""{"type":"string","format":"uuid"}""")!, "CustomerId"); + document.Component("ProblemDetails")["properties"].ShouldContainSchema(JsonNode.Parse("""{"fault":{"type":"string"}}""")!, "ProblemDetails, which the page says describes fault"); + var page = document["paths"]!["/customers"]!["get"]!["parameters"]!.AsArray().Single(parameter => parameter!["name"]!.GetValue() == "page"); + page!["schema"]!["type"]!.ToJsonString().ShouldContain("integer", Case.Sensitive, "a query parameter is described as a parameter of the wrapped type, inline"); + } + + private static List JsonBlocks() => + [.. JsonBlock().Matches(SampleOutputs.Readme(Readme)).Select(block => JsonNode.Parse(block.Groups["body"].Value.TrimStart().StartsWith('{') ? block.Groups["body"].Value : $"{{{block.Groups["body"].Value}}}")!)]; + + private static StringContent Body(string json) => new(json, Encoding.UTF8, "application/json"); + + [GeneratedRegex(@"^```json\n(?.*?)\n```", RegexOptions.Multiline | RegexOptions.Singleline)] + private static partial Regex JsonBlock(); +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs new file mode 100644 index 0000000..9ef24f1 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs @@ -0,0 +1,210 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodoMetis.TypeKit.ClosedSets; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Docs.ClosedSets; + +// sample: closed-sets/declaring +public sealed partial class Shipping : IClosedSet +{ + public static readonly Shipping Standard = new(days: 5, fee: 0m); + public static readonly Shipping Express = new(days: 1, fee: 9.90m); + public static readonly Shipping Pickup = new(days: 0, fee: 0m); + + private Shipping(int days, decimal fee) { Days = days; Fee = fee; } + + public int Days { get; } + public decimal Fee { get; } + + public DateOnly ArrivesBy(DateOnly ordered) => ordered.AddDays(Days); +} +// end sample + +// sample: closed-sets/wire +public sealed record Quote(Shipping Method, Dictionary Fees); +// end sample + +// sample: closed-sets/database +public sealed class Order +{ + public int Id { get; set; } + public Shipping Method { get; set; } = Shipping.Standard; + public List Offered { get; set; } = []; +} +// end sample + +public sealed class ShopDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); +} + +// sample: closed-sets/configuration +public sealed class CheckoutOptions +{ + public Shipping Default { get; set; } = Shipping.Standard; +} +// end sample + +public static class Usage +{ + public static (string Name, string Qualified, string All, bool Same, bool Known, bool Typed) Members() + { + // sample: closed-sets/members + string name = Shipping.Express.Name; // "Express" + string qualified = Shipping.Express.QualifiedName; // "Shipping.Express" + string all = string.Join(" ", Shipping.All.Select(s => s.Name)); // "Standard Express Pickup" + bool same = Shipping.Parse("Express") == Shipping.Express; // true, members are singletons + bool known = Shipping.TryParse("Drone", out _); // false, no such member + bool typed = Shipping.TryParse("express", ignoreCase: true, out _); // true, when asked: a header or a query string as a client typed it + // end sample + + return (name, qualified, all, same, known, typed); + } + + /// The block's last line, which throws: the same region, apart, so the lines above can run. + public static void ParseRefused() + { + // sample: closed-sets/members + Shipping.Parse("Drone"); // FormatException naming Shipping and member, never the name + // end sample + } + + public static string Label(Shipping shipping) + { + // sample: closed-sets/match + string label = shipping.Match( + standard: () => "Ships within a week", + express: () => "Arrives tomorrow", + pickup: () => "Ready at the counter"); + // end sample + + return label; + } + + public static string Wire() + { + // sample: closed-sets/wire-use + string json = JsonSerializer.Serialize(new Quote(Shipping.Express, new() { [Shipping.Standard] = 0m, [Shipping.Express] = 9.90m })); // {"Method":"Express","Fees":{"Standard":0,"Express":9.90}} + // end sample + + return json; + } + + public static void WireRefused() + { + // sample: closed-sets/wire-use + JsonSerializer.Deserialize("""{"Method":"Drone","Fees":{}}"""); // JsonException naming Shipping and member, never the name + // end sample + } + + public static IQueryable Query(ShopDb db) => + // sample: closed-sets/database-use + db.Orders.Where(o => o.Method == Shipping.Express); // WHERE o."Method" = 'Express' + // end sample + + public static string Configured() + { + // sample: closed-sets/configuration-use + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Checkout:Default"] = "Express" }).Build(); + string chosen = configuration.GetSection("Checkout").Get()!.Default.Name; // "Express" + // end sample + + return chosen; + } +} + +public sealed class ClosedSetsTests +{ + private const string Page = "docs/closed-sets.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Lower(bool value) => value ? "true" : "false"; + + [Fact] + public void A_set_answers_what_the_comments_say() + { + var (name, qualified, all, same, known, typed) = Usage.Members(); + + name.ShouldBeShownIn(Page, Quoted, "Shipping.Express.Name"); + qualified.ShouldBeShownIn(Page, Quoted, "Shipping.Express.QualifiedName"); + all.ShouldBeShownIn(Page, Quoted, "string.Join(\" \", Shipping.All.Select(s => s.Name))"); + same.ShouldBeShownIn(Page, Lower, "Shipping.Parse(\"Express\") == Shipping.Express"); + known.ShouldBeShownIn(Page, Lower, "Shipping.TryParse(\"Drone\", out _)"); + typed.ShouldBeShownIn(Page, Lower, "Shipping.TryParse(\"express\", ignoreCase: true, out _)"); + SampleOutputs.ShouldRefuseAsShownIn(() => Shipping.Parse("Drone"), Page, input: "Drone"); + Should.Throw(Usage.ParseRefused); + + Shipping.Express.ArrivesBy(new DateOnly(2026, 10, 13)).ShouldBe(new DateOnly(2026, 10, 14)); + } + + [Fact] + public void Match_picks_the_member_s_branch() + { + Usage.Label(Shipping.Express).ShouldBe("Arrives tomorrow"); + Usage.Label(Shipping.Pickup).ShouldBe("Ready at the counter"); + } + + [Fact] + public void On_the_wire_a_member_is_its_name_as_the_comments_say() + { + Usage.Wire().ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new Quote(Shipping.Express, new() { [Shipping.Standard] = 0m, [Shipping.Express] = 9.90m }))"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("""{"Method":"Drone","Fees":{}}"""), Page, input: "Drone"); + Should.Throw(Usage.WireRefused); + + var back = JsonSerializer.Deserialize(Usage.Wire())!; + ReferenceEquals(back.Method, Shipping.Express).ShouldBeTrue(); + back.Fees[Shipping.Express].ShouldBe(9.90m); + } + + [Fact] + public void The_query_is_the_SQL_the_comment_says() + { + using var db = new ShopDb(SampleOutputs.NpgsqlWithoutServer()); + + db.Orders.Where(o => o.Method == Shipping.Express).ShouldBeTheSqlShownIn(Page); + db.Model.FindEntityType(typeof(Order))!.FindProperty(nameof(Order.Method))!.GetColumnType().ShouldBe("text"); + db.Model.FindEntityType(typeof(Order))!.FindProperty(nameof(Order.Offered))!.GetColumnType().ShouldBe("text[]", "a primitive collection of names: text[] on PostgreSQL"); + } + + [Fact] + public void Configuration_binds_a_set_as_the_comment_says() => + Usage.Configured().ShouldBeShownIn(Page, Quoted, "configuration.GetSection(\"Checkout\").Get()!.Default.Name"); + + /// What the page says of the document: a string enum of the names, with nothing registered per set. + [Fact] + public async Task The_document_describes_a_set_as_a_string_enum() + { + var document = await SampleOutputs.OpenApiDocumentAsync( + builder => builder.Services.AddOpenApi(options => options.AddTypeKit()), + app => app.MapPost("/quotes", (Quote quote) => quote.Method.Name)); + + document.Component(nameof(Shipping)).ToJsonString().ShouldBe("""{"enum":["Standard","Express","Pickup"],"type":"string"}"""); + } + + /// What the page says of a route: a minimal-API parameter binds through the generated TryParse, and an unknown name is a 400. + [Fact] + public async Task A_route_parameter_binds_by_name() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + await using var app = builder.Build(); +#pragma warning disable ASP0020 // Shipping has TryParse once generated: the route analyzer reads the source before weaving + app.MapGet("/delivery/{method}", (Shipping method) => method.Days); +#pragma warning restore ASP0020 + await app.StartAsync(TestContext.Current.CancellationToken); + + (await app.GetTestClient().GetFromJsonAsync("/delivery/Express", TestContext.Current.CancellationToken)).ShouldBe(1); + (await app.GetTestClient().GetAsync("/delivery/Drone", TestContext.Current.CancellationToken)).StatusCode.ShouldBe(System.Net.HttpStatusCode.BadRequest); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/EfCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/EfCoreSamples.cs new file mode 100644 index 0000000..17f6e79 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/EfCoreSamples.cs @@ -0,0 +1,201 @@ +// sample: ef-core/usings +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.EntityFrameworkCore; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +// end sample +using Microsoft.EntityFrameworkCore.Metadata; + +namespace ReadmeSamples.Docs.EfCore; + +// sample: ef-core/types +public readonly partial record struct InvoiceNo : IValue; + +public readonly partial record struct CustomerId : IValue; + +public readonly partial record struct Amount : IValue; + +public readonly partial record struct Currency : IValue; + +public readonly partial record struct IssuedOn : IValue; + +public readonly partial record struct Discount : IValue; + +public enum ReferenceFault { Blank, NotUpperCase } + +public readonly partial record struct Reference : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(ReferenceFault.Blank); + if (value != value.ToUpperInvariant()) return Result.Error(ReferenceFault.NotUpperCase); // a rule added after rows were written + + return new Reference(value); + } +} +// end sample + +// sample: ef-core/model +public class Invoice +{ +#pragma warning disable CMTK0006 // the database assigns the key on insert + public InvoiceNo No { get; set; } // key: bigint, generated by the database +#pragma warning restore CMTK0006 + public required CustomerId Customer { get; set; } // uuid + public required Reference Reference { get; set; } // text, applies Create on input and is read back without it + public required Amount Total { get; set; } // numeric(18,2), from ConfigureConventions + public required Currency Currency { get; set; } // character varying(3), from ConfigureConventions + public required IssuedOn IssuedOn { get; set; } // date + public Discount? Discount { get; set; } // integer, nullable +} + +public sealed class BillingDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Invoices => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder builder) + { + builder.Properties().HavePrecision(18, 2); // by the value object's type: EF applies a convention by CLR type, so Properties() would miss it + builder.Properties().HaveMaxLength(3); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(invoice => invoice.No); + modelBuilder.Entity().HasData(new Invoice // seed data takes value objects as it takes primitives + { + No = InvoiceNo.From(1), + Customer = CustomerId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), + Reference = Reference.FromKnownGood("INV-1"), + Total = Amount.From(10m), + Currency = Currency.From("EUR"), + IssuedOn = IssuedOn.From(new DateOnly(2026, 1, 1)), + }); + } +} +// end sample + +public static class Usage +{ + public static async Task<(long Assigned, string Verdict, string Stored, Option DiscountRead)> WalkthroughAsync() + { + // sample: ef-core/keys + await using var connection = new SqliteConnection("Data Source=:memory:"); // one connection keeps the in-memory database alive + await connection.OpenAsync(); + await using var db = new BillingDb(new DbContextOptionsBuilder().UseSqlite(connection).UseTypeKit().Options); + await db.Database.EnsureCreatedAsync(); + + var invoice = new Invoice + { + Customer = CustomerId.New(), + Reference = Reference.FromKnownGood("INV-2"), + Total = Amount.From(250m), + Currency = Currency.From("EUR"), + IssuedOn = IssuedOn.From(new DateOnly(2026, 10, 13)), + }; + db.Invoices.Add(invoice); + await db.SaveChangesAsync(); + + long assigned = invoice.No.Value; // 2, assigned by the database; the seeded invoice is 1 + // end sample + + // sample: ef-core/reading-back + await db.Database.ExecuteSqlAsync($"UPDATE Invoices SET Reference = 'inv-2' WHERE No = 2"); // a row from before the upper-case rule + db.ChangeTracker.Clear(); + + var read = await db.Invoices.SingleAsync(i => i.No == InvoiceNo.From(2)); // maps, without Create + string verdict = read.Reference.Revalidate().Match(_ => "valid", fault => $"refused: {fault}"); // "refused: NotUpperCase" + string stored = read.Reference.Value; // "inv-2", as the row holds it + // end sample + + // sample: ef-core/option + Option discount = read.Discount.ToOption(); // None + // end sample + + return (assigned, verdict, stored, discount); + } + + public static void Queries(BillingDb db, Currency eur, List customers) + { + // sample: ef-core/queries + db.Invoices.Where(i => i.Total.Value >= 100m && i.Currency == eur); // WHERE i."Total" >= 100.0 AND i."Currency" = @eur + db.Invoices.Where(i => i.Reference.Value.EndsWith("-1")); // WHERE i."Reference" LIKE '%-1' + db.Invoices.Where(i => i.IssuedOn.Value.Year == 2026); // WHERE date_part('year', i."IssuedOn")::int = 2026 + db.Invoices.Where(i => i.Discount.ValueOrNull() == null); // WHERE i."Discount" IS NULL + db.Invoices.Where(i => customers.Contains(i.Customer)); // WHERE i."Customer" = ANY (@customers) + db.Invoices.OrderByDescending(i => i.IssuedOn).ThenBy(i => i.No); + db.Invoices.Select(i => new { i.No, Total = i.Total.Value }); // the wrapped value projected + db.Invoices.GroupBy(i => i.Currency).Select(g => new { Currency = g.Key, Sum = g.Sum(i => i.Total.Value) }); // an aggregate over the column + // end sample + } +} + +public sealed class EfCoreTests +{ + private const string Readme = "docs/ef-core.md"; + + /// Each property's comment in the model sample names what the model maps it to on PostgreSQL. + [Fact] + public void The_columns_are_what_the_model_s_comments_say() + { + using var db = new BillingDb(SampleOutputs.NpgsqlWithoutServer()); + var invoice = db.Model.FindEntityType(typeof(Invoice))!; + var comments = SampleOutputs.PropertyComments(Readme, "public class Invoice"); + IProperty Property(string name) => invoice.FindProperty(name)!; + + comments.Keys.ShouldBe(invoice.GetProperties().Select(property => property.Name), ignoreOrder: true); + + invoice.FindPrimaryKey()!.Properties.ShouldBe([Property(nameof(Invoice.No))]); + Property(nameof(Invoice.No)).ValueGenerated.ShouldBe(ValueGenerated.OnAdd); + comments[nameof(Invoice.No)].ShouldBe($"key: {Property(nameof(Invoice.No)).GetColumnType()}, generated by the database"); + comments[nameof(Invoice.Customer)].ShouldBe(Property(nameof(Invoice.Customer)).GetColumnType()); + comments[nameof(Invoice.Reference)].ShouldStartWith($"{Property(nameof(Invoice.Reference)).GetColumnType()}, "); + comments[nameof(Invoice.Total)].ShouldStartWith($"{Property(nameof(Invoice.Total)).GetColumnType()}, "); + comments[nameof(Invoice.Currency)].ShouldStartWith($"{Property(nameof(Invoice.Currency)).GetColumnType()}, "); + comments[nameof(Invoice.IssuedOn)].ShouldBe(Property(nameof(Invoice.IssuedOn)).GetColumnType()); + Property(nameof(Invoice.Discount)).IsNullable.ShouldBeTrue(); + comments[nameof(Invoice.Discount)].ShouldBe($"{Property(nameof(Invoice.Discount)).GetColumnType()}, nullable"); + } + + [Fact] + public void The_queries_are_the_SQL_the_comments_say() + { + using var db = new BillingDb(SampleOutputs.NpgsqlWithoutServer()); + var eur = Currency.From("EUR"); + List customers = [CustomerId.New()]; + + db.Invoices.Where(i => i.Total.Value >= 100m && i.Currency == eur).ShouldBeTheSqlShownIn(Readme); + db.Invoices.Where(i => i.Reference.Value.EndsWith("-1")).ShouldBeTheSqlShownIn(Readme); + db.Invoices.Where(i => i.IssuedOn.Value.Year == 2026).ShouldBeTheSqlShownIn(Readme); + db.Invoices.Where(i => i.Discount.ValueOrNull() == null).ShouldBeTheSqlShownIn(Readme); + db.Invoices.Where(i => customers.Contains(i.Customer)).ShouldBeTheSqlShownIn(Readme); + db.Invoices.OrderByDescending(i => i.IssuedOn).ThenBy(i => i.No).ToQueryString().ShouldContain("ORDER BY i.\"IssuedOn\" DESC, i.\"No\""); + db.Invoices.Select(i => new { i.No, Total = i.Total.Value }).ToQueryString().ShouldContain("SELECT i.\"No\", i.\"Total\""); + db.Invoices.GroupBy(i => i.Currency).Select(g => new { Currency = g.Key, Sum = g.Sum(i => i.Total.Value) }).ToQueryString().ShouldContain("sum(i.\"Total\")"); + Usage.Queries(db, eur, customers); + } + + [Fact] + public async Task The_walkthrough_answers_what_the_comments_say() + { + var (assigned, verdict, stored, discount) = await Usage.WalkthroughAsync(); + + assigned.ShouldBeShownIn(Readme, value => value.ToString(), "invoice.No.Value"); + verdict.ShouldBeShownIn(Readme, text => $"\"{text}\"", "read.Reference.Revalidate().Match(_ => \"valid\", fault => $\"refused: {fault}\")"); + stored.ShouldBeShownIn(Readme, text => $"\"{text}\"", "read.Reference.Value"); + discount.ShouldBeShownIn(Readme, option => option.IsNone() ? "None" : "Some", "read.Discount.ToOption()"); + } + + [Fact] + public async Task The_seed_is_written_with_the_schema() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var db = new BillingDb(new DbContextOptionsBuilder().UseSqlite(connection).UseTypeKit().Options); + await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + + var seeded = await db.Invoices.SingleAsync(TestContext.Current.CancellationToken); + (seeded.No, seeded.Reference, seeded.Total, seeded.Currency).ShouldBe((InvoiceNo.From(1), Reference.FromKnownGood("INV-1"), Amount.From(10m), Currency.From("EUR"))); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/JsonSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/JsonSamples.cs new file mode 100644 index 0000000..ef07450 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/JsonSamples.cs @@ -0,0 +1,235 @@ +// sample: json/usings +using System.Text.Json; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +// end sample +using System.Text.Json.Serialization.Metadata; + +namespace ReadmeSamples.Docs.Json; + +// sample: json/types +public readonly partial record struct Quantity : IValue; + +public readonly partial record struct Weekday : IValue; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); +} + +public sealed record Shipment(Quantity Boxes, Weekday Day, Dictionary Plan); + +public sealed record Primitives(int Boxes, DayOfWeek Day, Dictionary Plan); +// end sample + +// sample: json/parcel +public sealed record Parcel(Quantity Boxes, Email? Contact); +// end sample + +// sample: json/product-code +public enum CodeFault { NotUpperCase } + +public readonly partial record struct ProductCode : IValidatedValue +{ + public static Result Create(string value) => + value == value.ToUpperInvariant() ? new ProductCode(value) : Result.Error(CodeFault.NotUpperCase); // the rule added after "abc" was stored +} +// end sample + +// sample: json/profile +public sealed record Profile(Option Nickname); +// end sample + +// sample: json/option-converter +public sealed class OptionAsNullableConverter : JsonConverter> where T : notnull +{ + public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null + ? Option.None() + : Option.Some(JsonSerializer.Deserialize(ref reader, (JsonTypeInfo)options.GetTypeInfo(typeof(T)))!); // the typed overload: no reflection, so Native AOT keeps it + + public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options) + { + if (value.TryGetValue(out var content)) JsonSerializer.Serialize(writer, content, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); + else writer.WriteNullValue(); + } +} +// end sample + +// sample: json/contexts +[JsonSerializable(typeof(Shipment))] +internal partial class ShipmentJson : JsonSerializerContext; + +[JsonSerializable(typeof(Shipment))] +[JsonSerializable(typeof(DayOfWeek))] // listed, so the string enum converter reaches what Weekday wraps +[JsonSourceGenerationOptions(UseStringEnumConverter = true)] +internal partial class NamedShipmentJson : JsonSerializerContext; +// end sample + +public static class Usage +{ + public static (string Json, bool Parity, Shipment Quoted) Parity() + { + // sample: json/parity + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + var shipment = new Shipment(Quantity.From(3), Weekday.From(DayOfWeek.Friday), new() { [Weekday.From(DayOfWeek.Friday)] = Quantity.From(3) }); + var primitives = new Primitives(3, DayOfWeek.Friday, new() { [DayOfWeek.Friday] = 3 }); + + string json = JsonSerializer.Serialize(shipment, options); // {"boxes":3,"day":"Friday","plan":{"Friday":3}} + bool parity = json == JsonSerializer.Serialize(primitives, options); // true, the same bytes as the primitives + Shipment quoted = JsonSerializer.Deserialize("""{"boxes":"3","day":"Friday","plan":{}}""", options)!; // "3" read as 3: the web defaults allow a quoted number + // end sample + + return (json, parity, quoted); + } + + public static void Refusals() + { + // sample: json/refusals + JsonSerializer.Deserialize("\"nobody\""); // JsonException naming Email and NoAt, never the text + JsonSerializer.Deserialize("\"many\""); // JsonException naming Quantity and Int32, with no inner exception that quotes it + JsonSerializer.Deserialize("null"); // JsonException naming Quantity and null, refused for a struct + // end sample + } + + public static int NullAndMissing() + { + // sample: json/null-and-missing + var parcel = JsonSerializer.Deserialize("""{"Contact":null}""")!; + int boxes = parcel.Boxes.Value; // 0, a missing property calls no converter and stays default + + var strict = new JsonSerializerOptions { RespectRequiredConstructorParameters = true }; + JsonSerializer.Deserialize("""{"Contact":null}""", strict); // JsonException naming Parcel and Boxes + // end sample + + return boxes; + } + + public static string Stored() + { + // sample: json/stored + var store = new JsonSerializerOptions { Converters = { new StoredJsonConverterFactory() } }; + + ProductCode stored = JsonSerializer.Deserialize("\"abc\"", store); // read without the rules + string state = stored.Revalidate().Match(_ => "still valid", fault => $"now refused: {fault}"); // "now refused: NotUpperCase" + JsonSerializer.Deserialize("\"abc\""); // JsonException naming ProductCode and NotUpperCase, the same document as input + // end sample + + return state; + } + + public static void Refused() + { + // sample: json/option-refused + JsonSerializer.Serialize(new Profile(Option.Some("Annie"))); // NotSupportedException naming Option and nullable + // end sample + } + + public static (string Written, string Absent, Profile Back) Wire() + { + // sample: json/option-wire + var wire = new JsonSerializerOptions { Converters = { new OptionAsNullableConverter() } }; + + string written = JsonSerializer.Serialize(new Profile(Option.Some("Annie")), wire); // {"Nickname":"Annie"} + string absent = JsonSerializer.Serialize(new Profile(Option.None()), wire); // {"Nickname":null} + Profile back = JsonSerializer.Deserialize("""{"Nickname":null}""", wire)!; // None + // end sample + + return (written, absent, back); + } + + public static (string Numbered, string Named) Contexts(Shipment shipment) + { + // sample: json/contexts-use + string numbered = JsonSerializer.Serialize(shipment, ShipmentJson.Default.Shipment); // {"Boxes":3,"Day":5,"Plan":{"Friday":3}} + string named = JsonSerializer.Serialize(shipment, NamedShipmentJson.Default.Shipment); // {"Boxes":3,"Day":"Friday","Plan":{"Friday":3}} + // end sample + + return (numbered, named); + } +} + +public sealed class JsonTests +{ + private const string Readme = "docs/json.md"; + + private static string Json(string json) => json; + + [Fact] + public void The_JSON_is_the_primitives_JSON_as_the_comments_say() + { + var (json, parity, quoted) = Usage.Parity(); + + json.ShouldBeShownIn(Readme, Json, "JsonSerializer.Serialize(shipment, options)"); + parity.ShouldBeShownIn(Readme, p => p ? "true" : "false", "json == JsonSerializer.Serialize(primitives, options)"); + quoted.Boxes.ShouldBe(Quantity.From(3)); + quoted.Day.ShouldBe(Weekday.From(DayOfWeek.Friday)); + } + + [Fact] + public void Each_refusal_is_the_exception_the_comment_says() + { + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("\"nobody\""), Readme, input: "nobody"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("\"many\""), Readme, input: "many"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("null"), Readme, input: "\"null\""); + Should.Throw(Usage.Refusals); + } + + [Fact] + public void A_missing_property_stays_default_until_the_options_say_otherwise() + { + var parcel = JsonSerializer.Deserialize("""{"Contact":null}""")!; + var strict = new JsonSerializerOptions { RespectRequiredConstructorParameters = true }; + + parcel.Boxes.Value.ShouldBeShownIn(Readme, boxes => boxes.ToString()); + parcel.Contact.ShouldBeNull(); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("""{"Contact":null}""", strict), Readme, input: "Annie"); + Should.Throw(() => Usage.NullAndMissing()); + } + + [Fact] + public void A_stored_document_reads_without_the_rules_as_the_comments_say() + { + var store = new JsonSerializerOptions { Converters = { new StoredJsonConverterFactory() } }; + var stored = JsonSerializer.Deserialize("\"abc\"", store); + + stored.Value.ShouldBe("abc"); + stored.Revalidate().Match(_ => "still valid", fault => $"now refused: {fault}").ShouldBeShownIn(Readme, state => $"\"{state}\""); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("\"abc\""), Readme, input: "abc"); + Should.Throw(() => Usage.Stored()); + } + + [Fact] + public void An_Option_is_refused_unless_a_converter_is_registered() + { + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Serialize(new Profile(Option.Some("Annie"))), Readme, input: "Annie"); + Should.Throw(Usage.Refused); + + var (written, absent, back) = Usage.Wire(); + var wire = new JsonSerializerOptions { Converters = { new OptionAsNullableConverter() } }; + + JsonSerializer.Serialize(new Profile(Option.Some("Annie")), wire).ShouldBeShownIn(Readme, Json); + JsonSerializer.Serialize(new Profile(Option.None()), wire).ShouldBeShownIn(Readme, Json); + JsonSerializer.Deserialize("""{"Nickname":null}""", wire)!.ShouldBeShownIn(Readme, profile => profile.Nickname.IsNone() ? "None" : "Some", "JsonSerializer.Deserialize(\"\"\"{\"Nickname\":null}\"\"\", wire)!"); + (written, absent, back.Nickname.IsNone()).ShouldBe(("""{"Nickname":"Annie"}""", """{"Nickname":null}""", true)); + JsonSerializer.Deserialize("""{"Nickname":"Bo"}""", wire)!.Nickname.ShouldBe(Option.Some("Bo")); + } + + [Fact] + public void A_context_lists_the_value_objects_and_the_enum_decides_the_names() + { + var shipment = new Shipment(Quantity.From(3), Weekday.From(DayOfWeek.Friday), new() { [Weekday.From(DayOfWeek.Friday)] = Quantity.From(3) }); + + var (numbered, named) = Usage.Contexts(shipment); + + numbered.ShouldBeShownIn(Readme, Json, "JsonSerializer.Serialize(shipment, ShipmentJson.Default.Shipment)"); + named.ShouldBeShownIn(Readme, Json, "JsonSerializer.Serialize(shipment, NamedShipmentJson.Default.Shipment)"); + var back = JsonSerializer.Deserialize(named, NamedShipmentJson.Default.Shipment)!; + (back.Boxes, back.Day).ShouldBe((shipment.Boxes, shipment.Day)); + back.Plan.ShouldBe(shipment.Plan); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/LoggingSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/LoggingSamples.cs new file mode 100644 index 0000000..30fd15f --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/LoggingSamples.cs @@ -0,0 +1,180 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +// sample: logging-and-secrets/serilog-using +using Serilog; +// end sample +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting.Display; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace ReadmeSamples.Docs.Logging; + +// sample: logging-and-secrets/token +public readonly partial record struct AccessToken : IValue +{ + public override string ToString() => "AccessToken(***)"; // kept; no formatting interface is generated, so every string path reaches this +} + +public sealed record Session(AccessToken Token, string Name); +// end sample + +// sample: logging-and-secrets/pin +public enum PinFault { TooShort } + +public readonly partial record struct Pin : IValidatedValue +{ + public static Result Create(string value) => + value.Length >= 4 ? new Pin(value) : Result.Error(PinFault.TooShort); +} +// end sample + +/// A Serilog sink that keeps every message rendered as the console sink would ({Message:lj}): what the Serilog sample reads back. +public sealed class ListLogSink : ILogEventSink +{ + private readonly MessageTemplateTextFormatter formatter = new("{Message:lj}", null); + + public List Messages { get; } = []; + + public void Emit(LogEvent logEvent) + { + var rendered = new StringWriter(); + formatter.Format(logEvent, rendered); + Messages.Add(rendered.ToString()); + } +} + +/// A logger provider that keeps every formatted message: what the Microsoft.Extensions.Logging sample reads back. +public sealed class ListSink : ILoggerProvider, ILogger +{ + public List Messages { get; } = []; + + public ILogger CreateLogger(string categoryName) => this; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Messages.Add(formatter(state, exception)); + + public void Dispose() + { + } +} + +public static class Usage +{ + public static (string Interpolated, string Converted, string Record, string Json) Printing() + { + // sample: logging-and-secrets/printing + var token = AccessToken.From("tok-123"); + + string interpolated = $"token {token}"; // "token AccessToken(***)" + string converted = Convert.ToString(token, CultureInfo.InvariantCulture)!; // "AccessToken(***)" + string record = new Session(token, "Ann").ToString(); // Session { Token = AccessToken(***), Name = Ann } + string json = JsonSerializer.Serialize(token); // "tok-123", a conversion, not display + // end sample + + return (interpolated, converted, record, json); + } + + public static string Serilog(AccessToken token) + { + // sample: logging-and-secrets/serilog + var events = new ListLogSink(); // an ILogEventSink of your own that keeps the rendered messages + using var logger = new LoggerConfiguration() + .Destructure.WithTypeKit() + .WriteTo.Sink(events) + .CreateLogger(); + + logger.Information("Opened {@Session}", new Session(token, "Ann")); + string logged = events.Messages.Single(); // Opened {"Token":"AccessToken(***)","Name":"Ann","$type":"Session"} + // end sample + + return logged; + } + + public static string Mel(AccessToken token, ListSink sink) + { + // sample: logging-and-secrets/mel + using var factory = LoggerFactory.Create(logging => logging.AddProvider(sink)); // sink: a provider of your own that keeps the formatted messages + ILogger log = factory.CreateLogger("sessions"); + + log.LogInformation("Opened for {Token}", token); + string message = sink.Messages.Single(); // "Opened for AccessToken(***)" + // end sample + + return message; + } + + public static string Refusals(AccessToken token) + { + // sample: logging-and-secrets/refusals + Pin.Parse("12", null); // FormatException naming Pin and TooShort, never the digits + JsonSerializer.Deserialize("\"12\""); // JsonException naming Pin and TooShort + + var typed = "12"; + Pin.FromKnownGood(typed); // InvalidOperationException naming typed and TooShort, the expression and not what it held + + string printed = Option.Some(token).ToString(); // nothing of the token: an option prints no content, and neither does a result + // end sample + + return printed; + } +} + +public sealed class LoggingTests +{ + private const string Readme = "docs/logging-and-secrets.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + [Fact] + public void Every_string_path_prints_the_mask_and_JSON_carries_the_value() + { + var (interpolated, converted, record, json) = Usage.Printing(); + var token = AccessToken.From("tok-123"); + + $"token {token}".ShouldBeShownIn(Readme, Quoted); + Convert.ToString(token, CultureInfo.InvariantCulture)!.ShouldBeShownIn(Readme, Quoted, "Convert.ToString(token, CultureInfo.InvariantCulture)!"); + new Session(token, "Ann").ToString().ShouldBe(SampleOutputs.Shown(Readme, "new Session(token, \"Ann\").ToString()")); + JsonSerializer.Serialize(token).ShouldBeShownIn(Readme, text => text); + (interpolated, converted, record, json).ShouldBe(("token AccessToken(***)", "AccessToken(***)", "Session { Token = AccessToken(***), Name = Ann }", "\"tok-123\"")); + } + + [Fact] + public void Serilog_logs_the_mask_where_it_destructures() + { + var logged = Usage.Serilog(AccessToken.From("tok-123")); + + logged.ShouldBe(SampleOutputs.Shown(Readme, "events.Messages.Single()")); + logged.ShouldNotContain("tok-123"); + } + + [Fact] + public void Microsoft_Extensions_Logging_prints_the_mask() + { + var message = Usage.Mel(AccessToken.From("tok-123"), new ListSink()); + + message.ShouldBeShownIn(Readme, Quoted, "sink.Messages.Single()"); + message.ShouldNotContain("tok-123"); + } + + [Fact] + public void No_refusal_quotes_the_input() + { + var typed = "12"; + + SampleOutputs.ShouldRefuseAsShownIn(() => Pin.Parse("12", null), Readme, input: "12"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("\"12\""), Readme, input: "12"); + SampleOutputs.ShouldRefuseAsShownIn(() => Pin.FromKnownGood(typed), Readme, input: "12"); + Should.Throw(() => Usage.Refusals(AccessToken.From("tok-123"))); + + Option.Some(AccessToken.From("tok-123")).ToString().ShouldNotContain("tok-123"); + Result.Success(AccessToken.From("tok-123")).ToString().ShouldNotContain("tok-123"); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs new file mode 100644 index 0000000..11655e3 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs @@ -0,0 +1,272 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace ReadmeSamples.Docs.MergePatches; + +// sample: merge-patches/validate +public sealed record ContactRequest(Patch Email, Patch Floor); +// end sample + +// sample: merge-patches/types +public enum EmailFault { NoAt } + +public sealed partial record Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); +} + +public sealed record ContactPatch(Patch Email, Patch Phone, Patch Floor); +// end sample + +// sample: merge-patches/apply +public sealed class Contact +{ + public required Email Email { get; set; } + public string? Phone { get; set; } + public int? Floor { get; set; } +} + +public enum ContactFault { EmailRequired } + +public static class ContactPatching +{ + public static Result Apply(this Contact contact, ContactPatch patch) + { + if (patch.Email.State == PatchState.Null) return ContactFault.EmailRequired; // a required field refuses a clear + + patch.Email.Tap(email => contact.Email = email); + patch.Phone.IfPresent(phone => contact.Phone = phone); // an optional one: null clears it + patch.Floor.IfPresent(floor => contact.Floor = floor); // a struct's clear arrives as int?, null, never as 0 + return Result.Success(); + } +} +// end sample + +// sample: merge-patches/writing +public sealed record ContactChange( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Phone, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Floor); +// end sample + +// sample: merge-patches/context +public sealed record Address(string Street, string City); + +public sealed record MovePatch(Patch
Address, Patch Floor); + +[JsonSerializable(typeof(MovePatch))] +[JsonSerializable(typeof(Address))] // a class inside a patch is listed: the generator never sees through the patch +internal partial class MoveJson : JsonSerializerContext; +// end sample + +/// The same context without , for the refusal the page describes. +[JsonSerializable(typeof(MovePatch))] +internal partial class MoveJsonWithoutAddress : JsonSerializerContext; + +public static class Usage +{ + public static (PatchState Email, PatchState Phone, PatchState Floor) Read() + { + // sample: merge-patches/read + ContactPatch patch = JsonSerializer.Deserialize("""{"phone":null,"floor":3}""", JsonSerializerOptions.Web)!; + PatchState email = patch.Email.State; // Absent, not in the document + PatchState phone = patch.Phone.State; // Null, a clear + PatchState floor = patch.Floor.State; // Value + // end sample + + return (email, phone, floor); + } + + public static (Result Applied, string? Phone, int? Floor, Result Refused, Contact Contact) Apply() + { + // sample: merge-patches/apply-use + var contact = new Contact { Email = Email.FromKnownGood("ann@example.com"), Phone = "555-0100", Floor = 2 }; + ContactPatch clearPhone = JsonSerializer.Deserialize("""{"phone":null,"floor":3}""", JsonSerializerOptions.Web)!; + ContactPatch clearEmail = JsonSerializer.Deserialize("""{"email":null}""", JsonSerializerOptions.Web)!; + + Result applied = contact.Apply(clearPhone); // Success + string? phone = contact.Phone; // null, cleared + int? floor = contact.Floor; // 3 + Result refused = contact.Apply(clearEmail); // Error: EmailRequired + // end sample + + return (applied, phone, floor, refused, contact); + } + + public static void Refused() + { + // sample: merge-patches/value-object + JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web); // JsonException naming Email and NoAt, never the text + // end sample + } + + public static (Result, EmailFault> Refused, Result, EmailFault> Clear, Result, EmailFault> Untouched, Patch Tenfold) Validate() + { + // sample: merge-patches/validate-use + ContactRequest nobody = JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web)!; + ContactRequest cleared = JsonSerializer.Deserialize("""{"email":null}""", JsonSerializerOptions.Web)!; + ContactRequest moved = JsonSerializer.Deserialize("""{"floor":3}""", JsonSerializerOptions.Web)!; + + Result, EmailFault> refused = nobody.Email.Traverse(Email.Create); // Error: NoAt, the endpoint's own fault + Result, EmailFault> clear = cleared.Email.Traverse(Email.Create); // Success: Null, nothing to validate + Result, EmailFault> untouched = moved.Email.Traverse(Email.Create); // Success: Absent + Patch tenfold = moved.Floor.Map(floor => floor * 10); // Value: 30, a clear and an absence kept as they are + // end sample + + return (refused, clear, untouched, tenfold); + } + + public static (string Clear, string Set) Write() + { + // sample: merge-patches/writing-use + string clear = JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web); // {"phone":null} + string set = JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web); // {"floor":4} + // end sample + + return (clear, set); + } + + /// The block's last line, which throws: the same region, apart, so the lines above can run. + public static void WriteRefused() + { + // sample: merge-patches/writing-use + JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))); // NotSupportedException naming Email and WhenWritingDefault + // end sample + } + + public static void Configure(WebApplicationBuilder builder) + { + // sample: merge-patches/endpoint + builder.Services.AddOpenApi(options => options.AddTypeKit()); + // end sample + } + + public static void Map(WebApplication app) + { + // sample: merge-patches/endpoint + app.MapPatch("/contacts/{id}", (int id, ContactPatch patch) => TypedResults.NoContent()); + // end sample + } + + public static (string City, string Floor) Context() + { + // sample: merge-patches/context-use + MovePatch move = JsonSerializer.Deserialize("""{"Address":{"Street":"Main 1","City":"Bern"}}""", MoveJson.Default.MovePatch)!; + string city = move.Address.Match(address => address.City, () => "cleared", () => "unchanged"); // "Bern" + string floor = move.Floor.Match(f => f.ToString(), () => "cleared", () => "unchanged"); // "unchanged" + // end sample + + return (city, floor); + } +} + +public sealed class MergePatchesTests +{ + private const string Page = "docs/merge-patches.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Outcome(Result result) => result.Match(() => "Success", fault => $"Error: {fault}"); + + [Fact] + public void A_body_reads_as_the_three_states_the_comments_say() + { + var (email, phone, floor) = Usage.Read(); + + email.ShouldBeShownIn(Page, state => state.ToString(), "patch.Email.State"); + phone.ShouldBeShownIn(Page, state => state.ToString(), "patch.Phone.State"); + floor.ShouldBeShownIn(Page, state => state.ToString(), "patch.Floor.State"); + } + + [Fact] + public void Applying_a_patch_does_what_the_comments_say() + { + var (applied, phone, floor, refused, contact) = Usage.Apply(); + + applied.ShouldBeShownIn(Page, Outcome, "contact.Apply(clearPhone)"); + phone.ShouldBeShownIn(Page, p => p ?? "null", "contact.Phone"); + floor.ShouldBeShownIn(Page, f => f?.ToString() ?? "null", "contact.Floor"); + refused.ShouldBeShownIn(Page, Outcome, "contact.Apply(clearEmail)"); + + contact.Email.Value.ShouldBe("ann@example.com", "an absent field leaves the value as it is"); + } + + /// A struct's clear is null, not its default: IfPresent hands a Patch<int>'s clear over as int?. + [Fact] + public void A_cleared_struct_field_is_null_not_zero() + { + var contact = new Contact { Email = Email.FromKnownGood("a@b"), Floor = 2 }; + + _ = contact.Apply(JsonSerializer.Deserialize("""{"floor":null}""", JsonSerializerOptions.Web)!); + + contact.Floor.ShouldBeNull(); + } + + [Fact] + public void Validating_a_patch_answers_what_the_comments_say() + { + var (refused, clear, untouched, tenfold) = Usage.Validate(); + string Validated(Result, EmailFault> result) => result.Match(patch => $"Success: {patch.State}", fault => $"Error: {fault}"); + + refused.ShouldBeShownIn(Page, Validated, "nobody.Email.Traverse(Email.Create)"); + clear.ShouldBeShownIn(Page, Validated, "cleared.Email.Traverse(Email.Create)"); + untouched.ShouldBeShownIn(Page, Validated, "moved.Email.Traverse(Email.Create)"); + tenfold.ShouldBeShownIn(Page, patch => patch.Match(value => $"Value: {value}", () => "Null", () => "Absent"), "moved.Floor.Map(floor => floor * 10)"); + } + + [Fact] + public void A_value_object_inside_a_patch_keeps_its_rule() + { + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web), Page, input: "nobody"); + Should.Throw(Usage.Refused).Path.ShouldBe("$.email", "the page says the path is the field's"); + } + + [Fact] + public void Writing_a_patch_answers_what_the_comments_say() + { + var (clear, set) = Usage.Write(); + + clear.ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web)"); + set.ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web)"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))), Page, input: "555"); + Should.Throw(Usage.WriteRefused); + } + + /// What the page says of the document: every patch property inlined as its argument's schema, admitting null, none required, an Email as the string it wraps. + [Fact] + public async Task The_document_describes_a_patch_as_the_page_says() + { + var document = await SampleOutputs.OpenApiDocumentAsync(Usage.Configure, Usage.Map); + var patch = document.Component(nameof(ContactPatch)); + + patch["required"].ShouldBeNull(patch.ToJsonString()); + foreach (var (name, type) in new[] { ("email", "string"), ("phone", "string"), ("floor", "integer") }) + { + var property = patch["properties"]![name]!; + property["$ref"].ShouldBeNull($"{name} is inlined: {property.ToJsonString()}"); + // The argument's own schema, admitting null: ASP.NET's web defaults describe an int as ["integer","string"]. + var types = property["type"]!.AsArray().Select(node => node!.GetValue()).ToList(); + types.ShouldContain("null", $"{name}: {property.ToJsonString()}"); + types.ShouldContain(type, $"{name}: {property.ToJsonString()}"); + } + } + + [Fact] + public void A_source_generated_context_reads_a_patch_as_the_comments_say() + { + var (city, floor) = Usage.Context(); + + city.ShouldBeShownIn(Page, Quoted, "move.Address.Match(address => address.City, () => \"cleared\", () => \"unchanged\")"); + floor.ShouldBeShownIn(Page, Quoted, "move.Floor.Match(f => f.ToString(), () => \"cleared\", () => \"unchanged\")"); + + // Without the class listed, the serializer refuses with its own message naming it, as the page says. + Should.Throw(() => JsonSerializer.Deserialize("""{"Address":{"Street":"Main 1","City":"Bern"}}""", MoveJsonWithoutAddress.Default.MovePatch)) + .Message.ShouldContain("Address"); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs new file mode 100644 index 0000000..b413f34 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs @@ -0,0 +1,58 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.Migrating; + +public readonly partial record struct OrderId : IValue; + +public enum OrderFault { Unknown } + +// sample: migrating-to-2/abstract +// public abstract partial record Money : IValue; 1.5 built these; 2.0: CMTK1022 +// public sealed partial record Euro : Money; + +public abstract record Money; +public sealed partial record Euro : Money, IValue; // the marker on the sealed type, which is generated +// end sample + +public interface IOrders +{ + Task> CancelAsync(OrderId id); + + IAsyncEnumerable> CancelEachAsync(IEnumerable ids); +} + +public sealed record Order(OrderId Id, decimal Total, DateTimeOffset Placed) +{ + public bool IsLarge() => Total > 1_000; +} + +public static class Usage +{ + /// The silent part is compiled outside the suppression, so a report on it fails the build. + public static async Task Dropped(IOrders orders, OrderId id, IEnumerable ids) + { +#pragma warning disable CMTK0003 // what the page shows the rule reporting + // sample: migrating-to-2/dropped + await orders.CancelAsync(id); // CMTK0003: an error since 2.0 + orders.CancelAsync(id).Wait(); // CMTK0003: new in 2.0, waited for and forgotten + + // end sample +#pragma warning restore CMTK0003 + // sample: migrating-to-2/dropped + _ = await orders.CancelAsync(id); // the drop, said in the code + await foreach (var _ in orders.CancelEachAsync(ids)) { } // a loop variable named _ says it too + // end sample + } + + public static (Option Large, Option Local, Option Latest) Queries(IQueryable orders) + { + // sample: migrating-to-2/queries + Option large = orders.FirstOrNone(o => o.Total > 100); // 2.0: WHERE and LIMIT 1 in the query; 1.5 read every row + Option local = orders.AsEnumerable().FirstOrNone(o => o.IsLarge()); // a condition the database cannot run, in memory as before + Option latest = orders.OrderByDescending(o => o.Placed).FirstOrNone(); // where 1.5 took LastOrNone(), now a compile error on a query + // end sample + + return (large, local, latest); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/NativeAotSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/NativeAotSamples.cs new file mode 100644 index 0000000..5c3e67c --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/NativeAotSamples.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.CompilerServices; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Extensions.Configuration; + +namespace ReadmeSamples.Docs.Aot; + +// sample: native-aot/json-context +public readonly partial record struct OrderId : IValue; + +public readonly partial record struct Quantity : IValue; + +public sealed record OrderLine(OrderId Order, Quantity Quantity); + +[JsonSerializable(typeof(OrderLine))] // the shape; neither Guid nor int is needed for the JSON +internal partial class OrdersJson : JsonSerializerContext; +// end sample + +// sample: native-aot/config-type +public enum HostFault { Blank } + +public readonly partial record struct SmtpHost : IValidatedValue +{ + public static Result Create(string value) => + string.IsNullOrWhiteSpace(value) ? Result.Error(HostFault.Blank) : new SmtpHost(value.Trim()); +} +// end sample + +public static class Usage +{ + public static (string Json, bool RoundTrip) Context() + { + // sample: native-aot/json-use + string json = JsonSerializer.Serialize(new OrderLine(OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), Quantity.From(3)), OrdersJson.Default.OrderLine); // {"Order":"0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee","Quantity":3} + OrderLine back = JsonSerializer.Deserialize(json, OrdersJson.Default.OrderLine)!; + bool roundTrip = back == new OrderLine(OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), Quantity.From(3)); // true + // end sample + + return (json, roundTrip); + } + + public static string Configuration() + { + // sample: native-aot/config-use + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Smtp:Host"] = " mail.example.com " }).Build(); + + Result host = SmtpHost.Create(configuration["Smtp:Host"] ?? ""); // the wrapped type bound, then Create + string bound = host.Match(h => h.Value, fault => fault.ToString()); // "mail.example.com" + // end sample + + return bound; + } + + // sample: native-aot/describe + static string Describe(Type type) => + type.GetCustomAttribute() is { } valueObject + ? $"{valueObject.ValueObjectType.Name} wraps {valueObject.WrappedType.Name}" + : $"{type.Name} is no value object"; + // end sample + + public static (string Described, string Plain) Discovery() + { + // sample: native-aot/describe-use + string described = Describe(typeof(OrderId)); // "OrderId wraps Guid" + string plain = Describe(typeof(Guid)); // "Guid is no value object" + // end sample + + return (described, plain); + } +} + +public sealed class NativeAotTests +{ + private const string Readme = "docs/native-aot.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + [Fact] + public void A_context_that_lists_the_shape_alone_writes_the_value_objects() + { + var (json, roundTrip) = Usage.Context(); + + json.ShouldBeShownIn(Readme, text => text, "JsonSerializer.Serialize(new OrderLine(OrderId.From(Guid.Parse(\"0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee\")), Quantity.From(3)), OrdersJson.Default.OrderLine)"); + roundTrip.ShouldBeShownIn(Readme, value => value ? "true" : "false", "back == new OrderLine(OrderId.From(Guid.Parse(\"0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee\")), Quantity.From(3))"); + OrdersJson.Default.GetTypeInfo(typeof(Guid)).ShouldBeNull("the context lists no wrapped type, and the converter needs none"); + } + + [Fact] + public void Configuration_binds_the_wrapped_type_and_Create_applies() => + Usage.Configuration().ShouldBeShownIn(Readme, Quoted, "host.Match(h => h.Value, fault => fault.ToString())"); + + [Fact] + public void A_value_object_is_recognised_by_its_attribute() + { + var (described, plain) = Usage.Discovery(); + + described.ShouldBeShownIn(Readme, Quoted, "Describe(typeof(OrderId))"); + plain.ShouldBeShownIn(Readme, Quoted, "Describe(typeof(Guid))"); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs new file mode 100644 index 0000000..785a7a6 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs @@ -0,0 +1,240 @@ +using CodoMetis.TypeKit; +using System.Reflection; +using System.Text.RegularExpressions; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.Options; + +// sample: option/types +public readonly partial record struct CustomerId : IValue; + +public sealed record Address(string Town, string Country); + +public sealed record Customer(CustomerId Id, string Name, string Tier, Option Nickname, Option
Address); +// end sample + +public enum LookupFault { Unknown } + +// sample: option/boundary +public sealed record CustomerDto(string Name, string? Nickname); // the wire says absent with null + +// end sample +public sealed class Shop +{ + public Customer Ann { get; } = new(CustomerId.New(), "Ann", "gold", Option.Some("Annie"), Option.Some(new Address("Berlin", "DE"))); + + public Dictionary Customers { get; } + + public List Visits { get; } = []; + + public Shop() => Customers = new Dictionary { [Ann.Id] = Ann }; + + public (Option Found, Option Unknown, Option Gold, Option Nickname, Option Parsed, Option None) Create(string text) + { + var customers = Customers; + var ann = Ann; + + // sample: option/create + Option found = customers.GetValueOrNone(ann.Id); // Some + Option unknown = customers.GetValueOrNone(CustomerId.New()); // None + Option gold = customers.Values.FirstOrNone(c => c.Tier == "gold"); // Some, the first match (or None) + Option nickname = ann.Nickname; // Some + Option parsed = int.TryParse(text, out var number) ? Option.Some(number) : Option.None(); // None, taking its type from the conditional + Option none = Option.None(); // where nothing supplies the type + // end sample + + return (found, unknown, gold, nickname, parsed, none); + } + + public (string Greeting, string Tier, int? Limit, int Sum, bool Known) Reach(Option found, Option unknown, Option parsed) + { + var visits = Visits; + + // sample: option/reach + string greeting = found.Match(customer => $"Hello again {customer.Name}", () => "Hello"); // "Hello again Ann" + string tier = unknown.Map(customer => customer.Tier).Or("standard"); // "standard" + int? limit = parsed.OrNull(); // null, an int? for an Option + int sum = found.Map(customer => customer.Name.Length).OrDefault(); // 3, a struct's default for None + bool known = found.IsSome(); // true + + if (found.TryGetValue(out var customer)) visits.Add(customer.Id); // customer is non-null in the branch + + foreach (var each in unknown.AsEnumerable()) visits.Add(each.Id); // runs once for Some, never for None + // end sample + + return (greeting, tier, limit, sum, known); + } + + public (Option Discount, Option Country, Option<(string Name, string Town)> Label, Option Seen, Option Greeting, List Nicknames, List Known) Combine(Option found) + { + var visits = Visits; + var customers = Customers; + var ids = new List { Ann.Id, CustomerId.New() }; + + // sample: option/combine + Option discount = found.Filter(customer => customer.Tier == "gold").Map(_ => 0.1m); // Some, Ann is gold + Option country = found.Bind(customer => customer.Address).Map(address => address.Country); // Some, the Option
flattened and then mapped + Option<(string Name, string Town)> label = found.Zip(found.Bind(c => c.Address), (c, a) => (c.Name, a.Town)); // Some, only when both are + Option seen = found.Tap(customer => visits.Add(customer.Id)); // runs for Some, hands the option on + IEnumerable nicknames = customers.Values.Choose(customer => customer.Nickname); // "Annie", the nicknames of those who have one: a filter and a map in one + IEnumerable known = ids.Choose(id => customers.GetValueOrNone(id)); // the customers the ids find, the unknown ones dropped + + Option greeting = + from customer in found + from address in customer.Address // a later from sees the earlier ones + where address.Country == "DE" + select $"{customer.Name} in {address.Town}"; // Some + // end sample + + return (discount, country, label, seen, greeting, nicknames.ToList(), known.ToList()); + } + + public Task> FindAsync(CustomerId id) => Task.FromResult(Customers.GetValueOrNone(id)); + + public async Task Pending() + { + var shop = this; + var ann = Ann; + + // sample: option/pending + Option loaded = await shop.FindAsync(ann.Id); // the await returns to the caller's context + string shown = loaded.Map(customer => customer.Name).Or("unknown"); // "Ann", the selector on that context too + // end sample + + return shown; + } + + // sample: option/boundary + static Customer FromDto(CustomerDto dto) => new(CustomerId.New(), dto.Name, "standard", dto.Nickname.ToOption(), Option.None()); + + static CustomerDto ToDto(Customer customer) => new(customer.Name, customer.Nickname.OrNull()); + // end sample + + public (Customer FromWire, CustomerDto ToWire, Result Required) Boundary() + { + var customers = Customers; + var fromWire = FromDto(new CustomerDto("Bo", null)); + var toWire = ToDto(Ann); + + // sample: option/to-result + Result required = customers.GetValueOrNone(CustomerId.New()).ToResult(LookupFault.Unknown); // Error: Unknown + // end sample + + return (fromWire, toWire, required); + } +} + +public sealed class OptionTests +{ + private const string Readme = "docs/option.md"; + + private static string SomeOrNone(Option option) where T : notnull => option.IsSome() ? "Some" : "None"; + + private static string Quoted(string text) => $"\"{text}\""; + + [Fact] + public void Creating_answers_what_the_comments_say() + { + var shop = new Shop(); + var customers = shop.Customers; + var ann = shop.Ann; + var text = "x"; + + customers.GetValueOrNone(ann.Id).ShouldBeShownIn(Readme, SomeOrNone); + customers.GetValueOrNone(CustomerId.New()).ShouldBeShownIn(Readme, SomeOrNone); + customers.Values.FirstOrNone(c => c.Tier == "gold").ShouldBeShownIn(Readme, SomeOrNone); + ann.Nickname.ShouldBeShownIn(Readme, SomeOrNone); + (int.TryParse(text, out var number) ? Option.Some(number) : Option.None()).ShouldBeShownIn(Readme, SomeOrNone); + + var (found, unknown, gold, nickname, parsed, none) = shop.Create(text); + (found, unknown.IsNone(), gold, nickname, parsed.IsNone(), none.IsNone()).ShouldBe((Option.Some(ann), true, Option.Some(ann), Option.Some("Annie"), true, true)); + } + + [Fact] + public void Reaching_the_value_answers_what_the_comments_say() + { + var shop = new Shop(); + var (found, unknown, _, _, parsed, _) = shop.Create("x"); + + found.Match(customer => $"Hello again {customer.Name}", () => "Hello").ShouldBeShownIn(Readme, Quoted); + unknown.Map(customer => customer.Tier).Or("standard").ShouldBeShownIn(Readme, Quoted); + parsed.OrNull().ShouldBeShownIn(Readme, limit => limit?.ToString() ?? "null"); + found.Map(customer => customer.Name.Length).OrDefault().ShouldBeShownIn(Readme, sum => sum.ToString()); + found.IsSome().ShouldBeShownIn(Readme, known => known ? "true" : "false"); + + var (greeting, tier, limit, sum, known) = shop.Reach(found, unknown, parsed); + (greeting, tier, limit, sum, known).ShouldBe(("Hello again Ann", "standard", null, 3, true)); + shop.Visits.ShouldBe([shop.Ann.Id], "TryGetValue took the branch once, and the foreach over None ran never"); + } + + [Fact] + public void Combining_answers_what_the_comments_say() + { + var shop = new Shop(); + var (found, _, _, _, _, _) = shop.Create("x"); + + found.Filter(customer => customer.Tier == "gold").Map(_ => 0.1m).ShouldBeShownIn(Readme, SomeOrNone); + found.Bind(customer => customer.Address).Map(address => address.Country).ShouldBeShownIn(Readme, SomeOrNone); + found.Zip(found.Bind(c => c.Address), (c, a) => (c.Name, a.Town)).ShouldBeShownIn(Readme, SomeOrNone); + + var (discount, country, label, seen, greeting, nicknames, known) = shop.Combine(found); + (discount, country, label, seen, greeting).ShouldBe((Option.Some(0.1m), Option.Some("DE"), Option.Some(("Ann", "Berlin")), found, Option.Some("Ann in Berlin"))); + shop.Visits.ShouldBe([shop.Ann.Id], "Tap ran once"); + nicknames.ShouldBe(["Annie"], "Choose kept the one nickname and dropped nothing else"); + known.ShouldBe([shop.Ann], "the unknown id was dropped"); + } + + [Fact] + public async Task A_pending_option_answers_what_the_comment_says() + { + var shop = new Shop(); + + var shown = await shop.Pending(); + + shown.ShouldBeShownIn(Readme, Quoted, "loaded.Map(customer => customer.Name).Or(\"unknown\")"); + } + + [Fact] + public void The_boundary_converts_as_the_page_says() + { + var shop = new Shop(); + var customers = shop.Customers; + + customers.GetValueOrNone(CustomerId.New()).ToResult(LookupFault.Unknown) + .ShouldBeShownIn(Readme, result => result.Match(customer => $"Success: {customer.Name}", fault => $"Error: {fault}")); + + var (fromWire, toWire, required) = shop.Boundary(); + fromWire.Nickname.IsNone().ShouldBeTrue(); + toWire.ShouldBe(new CustomerDto("Ann", "Annie")); + required.TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBe(LookupFault.Unknown); + } + + /// + /// The page's surface table names every public member of Option<T>, of the static class + /// Option and of the extensions, read from the types, so a member added to the package without + /// a row fails here. + /// + [Fact] + public void The_surface_table_names_every_public_member() + { + var page = SampleOutputs.Readme(Readme); + var surface = page[page.IndexOf("## The surface", StringComparison.Ordinal)..]; + + var members = PublicMembers(typeof(Option<>), typeof(Option), typeof(ValueTypeOptionExtensions), typeof(SequenceOptionExtensions)); + + members.Length.ShouldBeGreaterThanOrEqualTo(20, string.Join(", ", members)); + members.Where(member => !Regex.IsMatch(surface, $@"`{Regex.Escape(member)}[`(<,]")).ShouldBeEmpty("Every public member has a row in the surface table."); + } + + /// Public method names, operators, accessors and the record's own members aside. + internal static string[] PublicMembers(params Type[] types) => + [ + .. types.SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + .Where(method => !method.IsSpecialName) + .Select(method => method.Name) + .Where(name => name is not ("Equals" or "GetHashCode" or "ToString" or "Deconstruct" or "PrintMembers")) + .Distinct() + .Order(StringComparer.Ordinal) + ]; +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs new file mode 100644 index 0000000..ad22515 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs @@ -0,0 +1,407 @@ +using CodoMetis.TypeKit; +using System.Diagnostics; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.Results; + +// sample: result/types +public readonly partial record struct CartId : IValue; + +public readonly partial record struct OrderId : IValue; + +public enum CheckoutFault { CartUnknown, CartEmpty, OutOfStock, PaymentRefused } + +public sealed record Line(string Sku, int Quantity, decimal Price); + +public sealed record Cart(CartId Id, IReadOnlyList Lines); + +public sealed record Reservation(OrderId Id, decimal Total); + +public sealed record Receipt(OrderId Id, decimal Total); +// end sample + +public sealed record LineRequest(string Sku, int Quantity); + +// sample: result/api-fault +public enum ApiFault { NotFound, Invalid, Unavailable } + +static class Faults +{ + public static ApiFault ToApi(CheckoutFault fault) => fault switch + { + CheckoutFault.CartUnknown => ApiFault.NotFound, + CheckoutFault.CartEmpty or CheckoutFault.OutOfStock => ApiFault.Invalid, + CheckoutFault.PaymentRefused => ApiFault.Unavailable, + _ => throw new UnreachableException(), + }; +} +// end sample + +/// The checkout the page's examples call: two carts, a stock, a limit on one payment. +public sealed class Checkout +{ + private readonly Dictionary carts; + private readonly Dictionary stock = new() { ["A"] = (5, 10m), ["B"] = (5, 10m) }; + private readonly decimal limit = 100m; + + public Cart Cart { get; } = new(CartId.New(), [new Line("A", 2, 10m), new Line("B", 1, 10m)]); + + public Cart Empty { get; } = new(CartId.New(), []); + + public Checkout() => carts = new Dictionary { [Cart.Id] = Cart, [Empty.Id] = Empty }; + + public Result FindCart(CartId id) => carts.GetValueOrNone(id).ToResult(CheckoutFault.CartUnknown); + + public Task> FindCartAsync(CartId id) => Task.FromResult(FindCart(id)); + + public Task> LoadCartAsync(CartId id, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(FindCart(id)); + } + + private bool InStock(Cart cart) => cart.Lines.All(line => stock.TryGetValue(line.Sku, out var item) && item.Stock >= line.Quantity); + + // sample: result/two-shapes + public Result Reserve(Cart cart) // a value, or a fault + { + if (cart.Lines.Count == 0) return Result.Error(CheckoutFault.CartEmpty); + if (!InStock(cart)) return Result.Error(CheckoutFault.OutOfStock); + + return new Reservation(OrderId.New(), cart.Lines.Sum(line => line.Quantity * line.Price)); // a bare value is a success + } + + public Result Charge(decimal total) => // a success or a fault, and no value + total <= limit ? Result.Success() : CheckoutFault.PaymentRefused; // a bare error converts here, where nothing can be confused with it + // end sample + + public Task> ReserveAsync(Cart cart) => Task.FromResult(Reserve(cart)); + + public Task> ChargeAsync(decimal total) => Task.FromResult(Charge(total)); + + public Result Invoice(CartId id) => FindCart(id).Bind(_ => Result.Success()); + + public Result ToLine(LineRequest request) => + stock.TryGetValue(request.Sku, out var item) && item.Stock >= request.Quantity + ? new Line(request.Sku, request.Quantity, item.Price) + : Result.Error(CheckoutFault.OutOfStock); +} + +public static class Usage +{ + public static (string Outcome, ResultState State, Option Maybe) Reach(Checkout checkout, Cart cart, List audit) + { + // sample: result/reach + var reserved = checkout.Reserve(cart); + + string outcome = reserved.Match(reservation => $"reserved {reservation.Total}", fault => $"refused: {fault}"); // "reserved 30" + ResultState state = reserved.State; // Success + + if (reserved.TryGetValue(out var reservation, out _) && checkout.Charge(reservation.Total)) audit.Add("charged"); // a Result converts to bool; a valued one never does + + Option maybe = reserved.ToOption(); // the fault dropped, where only presence matters + // end sample + + return (outcome, state, maybe); + } + + public static (string Paid, string Ledger, string Empty, Result Settled, Result ForApi) Pipeline(Checkout checkout, CartId cartId, CartId emptyCartId, List audit) + { + // sample: result/pipeline + Result receipt = checkout.FindCart(cartId) // Result + .Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty) // a rule on the value: break it, and this is the error + .Bind(checkout.Reserve) // a step that can fail itself + .Map(reservation => new Receipt(reservation.Id, reservation.Total)) // a step that cannot + .Tap(r => audit.Add($"receipt {r.Total}")) // on a success only + .TapError(fault => audit.Add($"refused: {fault}")); // on an error only + + Result charged = receipt.Bind(r => checkout.Charge(r.Total)); // a command after a query + + string paid = receipt.Match(r => $"paid {r.Total}", fault => $"refused: {fault}"); // "paid 30" + string ledger = charged.Match(() => "charged", fault => $"not charged: {fault}"); // "charged" + string empty = checkout.FindCart(emptyCartId).Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty).Bind(checkout.Reserve).Match(r => "reserved", fault => $"refused: {fault}"); // "refused: CartEmpty" + // end sample + + // sample: result/recover + Result settled = charged.Recover(fault => fault == CheckoutFault.PaymentRefused ? checkout.Invoice(cartId) : fault); // Success + // end sample + + // sample: result/map-error + Result forApi = receipt.MapError(Faults.ToApi); // Success, untouched; an error would be the ApiFault + // end sample + + return (paid, ledger, empty, settled, forApi); + } + + public static async Task PipelineAsync(Checkout checkout, CartId cartId, List audit) + { + // sample: result/async + Result chargedAsync = await checkout.FindCartAsync(cartId) // Task> + .EnsureAsync(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty) // a rule on a pending result + .BindAsync(cart => checkout.ReserveAsync(cart)) // an asynchronous step + .MapAsync(reservation => reservation.Total) // a synchronous step on a pending result + .BindAsync(total => checkout.ChargeAsync(total)) // a command: Result remains + .TapErrorAsync(fault => audit.Add($"not charged: {fault}")); + + string asyncLedger = chargedAsync.Match(() => "charged", fault => $"not charged: {fault}"); // "charged" + // end sample + + return asyncLedger; + } + + public static async Task OnTheCallersContext(Checkout checkout, CartId cartId) + { + // sample: result/context + Result found = await checkout.FindCartAsync(cartId); // the await returns to the caller's context + string lineCount = found.Map(cart => $"{cart.Lines.Count} lines").Match(text => text, fault => $"refused: {fault}"); // "2 lines", the selector on that context too + // end sample + + return lineCount; + } + + public static (Result, CheckoutFault> Lines, Result, CheckoutFault> All, Result Amount, Result Bulk, string Summary, string Stopped) Collect( + Checkout checkout, Cart cart, List requested, List refused, List> reservations, Result unitPrice, Result quantity) + { + // sample: result/collect + Result, CheckoutFault> lines = requested.Traverse(checkout.ToLine); // every request to a line, or the first fault + Result, CheckoutFault> all = reservations.Sequence(); // a list of results to a result of a list + Result amount = unitPrice.Zip(quantity, (price, count) => price * count); // both successes, or the first error + Result bulk = cart.Lines.FirstOrError(line => line.Quantity >= 10, CheckoutFault.OutOfStock); // the first match, or the error + + string summary = requested.Traverse(checkout.ToLine).Match(list => $"{list.Count} lines", fault => $"refused: {fault}"); // "2 lines" + string stopped = refused.Traverse(checkout.ToLine).Match(list => $"{list.Count} lines", fault => $"refused: {fault}"); // "refused: OutOfStock" + // end sample + + return (lines, all, amount, bulk, summary, stopped); + } + + public static async Task<(Result, CheckoutFault> Reserved, Result Charged, Result, CheckoutFault> Found)> CollectAsync( + Checkout checkout, List carts, List totals, List ids, CancellationToken cancellationToken) + { + // sample: result/collect-async + Result, CheckoutFault> reserved = await carts.TraverseAsync(checkout.ReserveAsync); // one at a time, in order; nothing starts after a fault + Result charged = await totals.TraverseAsync(checkout.ChargeAsync); // a command per element, likewise + Result, CheckoutFault> found = await ids.TraverseAsync(checkout.LoadCartAsync, cancellationToken); // the token checked before each, and handed to it + // end sample + + return (reserved, charged, found); + } + + public static async Task<(Result Url, Result Narrowed, Result AsFault, Result Written, Result Read)> Catching(string text, string path) + { + // sample: result/catch + Result url = Result.Catch(() => new Uri(text)); // Error: UriFormatException + Result narrowed = Result.Catch(() => new Uri(text)); // that type only; any other propagates + Result asFault = Result.Catch(() => new Uri(text)).MapError(_ => ApiFault.Invalid); // Error: Invalid + Result written = Result.Catch(() => File.WriteAllText(path, "receipt")); // Success, a command + Result read = await Result.CatchAsync(() => File.ReadAllTextAsync(path)); // Success: receipt + // end sample + + return (url, narrowed, asFault, written, read); + } + + public static async Task<(Result Saved, int Failures)> CatchingAll(string draft, string copy) + { + // sample: result/catch-all + Task both = Task.WhenAll(File.WriteAllTextAsync(draft, "a"), File.WriteAllTextAsync(copy, "b")); + Result saved = await Result.CatchAsync(() => both); // Error: DirectoryNotFoundException, the first of the two + int failures = both.Exception?.InnerExceptions.Count ?? 0; // 2, every failure is on the task + // end sample + + return (saved, failures); + } + + public static Result Query(Checkout checkout, CartId cartId) + { + // sample: result/query + Result receipt = + from cart in checkout.FindCart(cartId) + from reservation in checkout.Reserve(cart) // each from sees the earlier ranges + select new Receipt(reservation.Id, reservation.Total); + // end sample + + return receipt; + } +} + +public sealed class ResultTests +{ + private const string Readme = "docs/result.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Outcome(Result result) where T : notnull where TError : notnull => + result.Match(value => $"Success: {value}", error => $"Error: {(error is Exception e ? e.GetType().Name : error)}"); + + private static string Outcome(Result result) where TError : notnull => + result.Match(() => "Success", error => $"Error: {error}"); + + [Fact] + public void Reaching_the_outcome_answers_what_the_comments_say() + { + var checkout = new Checkout(); + var cart = checkout.Cart; + var audit = new List(); + var reserved = checkout.Reserve(cart); + + reserved.Match(reservation => $"reserved {reservation.Total}", fault => $"refused: {fault}").ShouldBeShownIn(Readme, Quoted); + reserved.State.ShouldBeShownIn(Readme, state => state.ToString()); + + var (outcome, state, maybe) = Usage.Reach(checkout, cart, audit); + (outcome, state, maybe.IsSome()).ShouldBe(("reserved 30", ResultState.Success, true)); + audit.ShouldBe(["charged"]); + + checkout.Reserve(checkout.Empty).State.ShouldBe(ResultState.Error); + checkout.Charge(1_000m).TryGetError(out var refused).ShouldBeTrue(); + refused.ShouldBe(CheckoutFault.PaymentRefused); + } + + [Fact] + public void The_pipeline_answers_what_the_comments_say() + { + var checkout = new Checkout(); + var audit = new List(); + + var (paid, ledger, empty, settled, forApi) = Usage.Pipeline(checkout, checkout.Cart.Id, checkout.Empty.Id, audit); + + paid.ShouldBeShownIn(Readme, Quoted, "receipt.Match(r => $\"paid {r.Total}\", fault => $\"refused: {fault}\")"); + ledger.ShouldBeShownIn(Readme, Quoted, "charged.Match(() => \"charged\", fault => $\"not charged: {fault}\")"); + empty.ShouldBeShownIn(Readme, Quoted, "checkout.FindCart(emptyCartId).Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty).Bind(checkout.Reserve).Match(r => \"reserved\", fault => $\"refused: {fault}\")"); + settled.ShouldBeShownIn(Readme, Outcome, "charged.Recover(fault => fault == CheckoutFault.PaymentRefused ? checkout.Invoice(cartId) : fault)"); + forApi.ShouldBeShownIn(Readme, result => result.Match(_ => "Success", fault => $"Error: {fault}"), "receipt.MapError(Faults.ToApi)"); + audit.ShouldBe(["receipt 30"], "the empty cart's chain has no TapError"); + + checkout.Charge(1_000m).Recover(fault => fault == CheckoutFault.PaymentRefused ? checkout.Invoice(checkout.Cart.Id) : fault).State.ShouldBe(ResultState.Success, "Recover ran on the error"); + checkout.Reserve(checkout.Empty).MapError(Faults.ToApi).TryGetValue(out _, out var api).ShouldBeFalse(); + api.ShouldBe(ApiFault.Invalid); + } + + [Fact] + public async Task The_async_chain_answers_what_the_comment_says() + { + var checkout = new Checkout(); + var audit = new List(); + + var ledger = await Usage.PipelineAsync(checkout, checkout.Cart.Id, audit); + + ledger.ShouldBeShownIn(Readme, Quoted, "chargedAsync.Match(() => \"charged\", fault => $\"not charged: {fault}\")"); + audit.ShouldBeEmpty(); + + (await Usage.PipelineAsync(checkout, checkout.Empty.Id, audit)).ShouldBe("not charged: CartEmpty"); + audit.ShouldBe(["not charged: CartEmpty"]); + } + + [Fact] + public async Task Continuing_after_the_await_answers_what_the_comment_says() + { + var checkout = new Checkout(); + + var lineCount = await Usage.OnTheCallersContext(checkout, checkout.Cart.Id); + + lineCount.ShouldBeShownIn(Readme, Quoted, "found.Map(cart => $\"{cart.Lines.Count} lines\").Match(text => text, fault => $\"refused: {fault}\")"); + (await Usage.OnTheCallersContext(checkout, CartId.New())).ShouldBe("refused: CartUnknown"); + } + + [Fact] + public void Collecting_answers_what_the_comments_say() + { + var checkout = new Checkout(); + List requested = [new("A", 1), new("B", 2)]; + List refused = [new("A", 1), new("Z", 1), new("B", 2)]; + List> reservations = [checkout.Reserve(checkout.Cart), checkout.Reserve(checkout.Empty)]; + Result unitPrice = 10m; + Result quantity = 3; + + var (lines, all, amount, bulk, summary, stopped) = Usage.Collect(checkout, checkout.Cart, requested, refused, reservations, unitPrice, quantity); + + summary.ShouldBeShownIn(Readme, Quoted, "requested.Traverse(checkout.ToLine).Match(list => $\"{list.Count} lines\", fault => $\"refused: {fault}\")"); + stopped.ShouldBeShownIn(Readme, Quoted, "refused.Traverse(checkout.ToLine).Match(list => $\"{list.Count} lines\", fault => $\"refused: {fault}\")"); + lines.TryGetValue(out var list, out _).ShouldBeTrue(); + list.Select(line => line.Sku).ShouldBe(["A", "B"]); + all.TryGetValue(out _, out var sequenced).ShouldBeFalse(); + sequenced.ShouldBe(CheckoutFault.CartEmpty); + amount.TryGetValue(out var product, out _).ShouldBeTrue(); + product.ShouldBe(30m); + bulk.TryGetValue(out _, out var noBulk).ShouldBeFalse(); + noBulk.ShouldBe(CheckoutFault.OutOfStock); + } + + [Fact] + public async Task Collecting_asynchronously_runs_one_operation_at_a_time_and_stops_at_a_fault() + { + var checkout = new Checkout(); + + var (reserved, charged, found) = await Usage.CollectAsync(checkout, [checkout.Cart, checkout.Empty], [10m, 20m], [checkout.Cart.Id], TestContext.Current.CancellationToken); + + reserved.TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBe(CheckoutFault.CartEmpty); + ((bool)charged).ShouldBeTrue(); + found.TryGetValue(out var carts, out _).ShouldBeTrue(); + carts.ShouldBe([checkout.Cart]); + } + + [Fact] + public async Task Catch_answers_what_the_comments_say() + { + var text = "not a url"; + var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var (url, narrowed, asFault, written, read) = await Usage.Catching(text, path); + + url.ShouldBeShownIn(Readme, Outcome, "Result.Catch(() => new Uri(text))"); + asFault.ShouldBeShownIn(Readme, Outcome, "Result.Catch(() => new Uri(text)).MapError(_ => ApiFault.Invalid)"); + written.ShouldBeShownIn(Readme, Outcome, "Result.Catch(() => File.WriteAllText(path, \"receipt\"))"); + read.ShouldBeShownIn(Readme, Outcome, "await Result.CatchAsync(() => File.ReadAllTextAsync(path))"); + narrowed.TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBeOfType(); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task CatchAsync_over_WhenAll_answers_what_the_comments_say() + { + var missing = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var draft = Path.Combine(missing, "draft.txt"); + var copy = Path.Combine(missing, "copy.txt"); + + var (saved, failures) = await Usage.CatchingAll(draft, copy); + + saved.ShouldBeShownIn(Readme, result => result.Match(() => "Success", error => $"Error: {error.GetType().Name}"), "await Result.CatchAsync(() => both)"); + failures.ShouldBeShownIn(Readme, count => count.ToString(System.Globalization.CultureInfo.InvariantCulture), "both.Exception?.InnerExceptions.Count ?? 0"); + saved.TryGetError(out var first).ShouldBeTrue(); + first.Message.ShouldContain("draft.txt", Case.Sensitive, "the first operation's failure, not the second's"); + } + + [Fact] + public void Query_syntax_binds_each_step() + { + var checkout = new Checkout(); + + Usage.Query(checkout, checkout.Cart.Id).TryGetValue(out var receipt, out _).ShouldBeTrue(); + receipt.Total.ShouldBe(30m); + Usage.Query(checkout, CartId.New()).TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBe(CheckoutFault.CartUnknown); + } + + /// + /// The page's surface tables name every public member of both result shapes and of the static class + /// Result, extensions included, read from the types, so a member added without a row fails here. + /// + [Fact] + public void The_surface_tables_name_every_public_member() + { + var page = SampleOutputs.Readme(Readme); + var surface = page[page.IndexOf("## The surface", StringComparison.Ordinal)..]; + + var members = Options.OptionTests.PublicMembers(typeof(Result<,>), typeof(Result<>), typeof(Result)); + + members.Length.ShouldBeGreaterThanOrEqualTo(30, string.Join(", ", members)); + members.Where(member => !System.Text.RegularExpressions.Regex.IsMatch(surface, $@"`{System.Text.RegularExpressions.Regex.Escape(member)}[`(<,]")) + .ShouldBeEmpty("Every public member has a row in a surface table."); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs new file mode 100644 index 0000000..c5ce6fe --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs @@ -0,0 +1,144 @@ +// sample: testing/usings +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.Testing; +using CodoMetis.TypeKit.ValueObjects; +// end sample +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Docs.Testing; + +// sample: testing/product-code +public enum CodeFault { Blank, NotUpperCase } + +public readonly partial record struct ProductCode : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(CodeFault.Blank); + if (value != value.ToUpperInvariant()) return Result.Error(CodeFault.NotUpperCase); + + return new ProductCode(value.Trim()); + } +} +// end sample + +// sample: testing/code-request +public sealed record CodeRequest(ProductCode Code); +// end sample + +// sample: testing/code-patch +public sealed record CodePatch(Patch Code, Patch Note, Patch Quantity); +// end sample + +/// The page's tests, run as written. +public sealed class ProductCodeTests +{ + // sample: testing/assertions + [Fact] + public void A_code_is_created_or_refused() + { + ProductCode code = ProductCode.Create("ABC").ShouldBeSuccess(); // the value, for the test's own assertions + code.Value.ShouldBe("ABC"); + + ProductCode.Create("abc").ShouldBeError(CodeFault.NotUpperCase); // this error, or the test fails + CodeFault fault = ProductCode.Create(" ").ShouldBeError(); // any error, handed back + fault.ShouldBe(CodeFault.Blank); + + ProductCode.TryFrom("ABC").ShouldBeSome().ShouldBe(code); + ProductCode.TryFrom("abc").ShouldBeNone(); + } + // end sample + + // sample: testing/patch + [Fact] + public void A_patch_says_what_the_request_mentioned() + { + CodePatch patch = JsonSerializer.Deserialize("""{"note":null,"quantity":2}""", JsonSerializerOptions.Web)!; + + patch.Quantity.ShouldBeValue().ShouldBe(2); // the value, for the test's own assertions + patch.Note.ShouldBeNull(); // a clear + patch.Code.ShouldBeAbsent(); // not in the document + } + // end sample + + // sample: testing/table + [Theory] + [InlineData("ABC", null)] + [InlineData(" ABC ", null)] + [InlineData("", CodeFault.Blank)] + [InlineData(" ", CodeFault.Blank)] + [InlineData("abc", CodeFault.NotUpperCase)] + public void Create_applies_each_rule(string input, CodeFault? refused) + { + var created = ProductCode.Create(input); + + if (refused is { } fault) created.ShouldBeError(fault); + else created.ShouldBeSuccess().Value.ShouldBe(input.Trim()); + } + // end sample + + // sample: testing/ways-in + [Fact] + public void Every_way_in_applies_the_rules() + { + ProductCode.TryParse("abc", null, out _).ShouldBeFalse(); + Should.Throw(() => ProductCode.Parse("abc", null)).Message.ShouldContain("NotUpperCase"); + Should.Throw(() => JsonSerializer.Deserialize("\"abc\"")).Message.ShouldContain("NotUpperCase"); + Should.Throw(() => ProductCode.FromKnownGood("abc")).Message.ShouldContain("NotUpperCase"); + ProductCode.FromKnownGood("ABC").Revalidate().ShouldBeSuccess(); + } + // end sample + + // sample: testing/endpoint + [Fact] + public async Task A_refused_body_is_a_400() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + await using var app = builder.Build(); + app.MapPost("/codes", (CodeRequest request) => request.Code.Value); + await app.StartAsync(TestContext.Current.CancellationToken); + + var client = app.GetTestClient(); + var accepted = await client.PostAsJsonAsync("/codes", new { code = "ABC" }, TestContext.Current.CancellationToken); + var refused = await client.PostAsJsonAsync("/codes", new { code = "abc" }, TestContext.Current.CancellationToken); + + accepted.StatusCode.ShouldBe(HttpStatusCode.OK); + refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + // end sample +} + +public static class Usage +{ + public static string Failure() + { + // sample: testing/failure + string failure = Result.Catch(() => ProductCode.Create("abc").ShouldBeSuccess()).Match(_ => "", error => error.Message); // Expected a success, but the Result is the error NotUpperCase. + // end sample + + return failure; + } +} + +public sealed class TestingTests +{ + private const string Readme = "docs/testing.md"; + + [Fact] + public void The_failure_says_what_the_outcome_was() + { + var failure = Usage.Failure(); + + failure.ShouldBe(SampleOutputs.Shown(Readme, "Result.Catch(() => ProductCode.Create(\"abc\").ShouldBeSuccess()).Match(_ => \"\", error => error.Message)")); + Should.Throw(() => ProductCode.Create("abc").ShouldBeSuccess()); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs new file mode 100644 index 0000000..d7be3c1 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs @@ -0,0 +1,133 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.Text; + +// sample: text/required-optional +public sealed record ProfileRequest(string? Name, string? Note); // bound from a request: nothing enforces the non-nullability + +// end sample +// sample: text/value-objects +public enum NameFault { Blank, Hidden, TooLong } + +public readonly partial record struct DisplayName : IValidatedValue +{ + public static Result Create(string value) => + value.AsText() + .MapError(fault => fault == TextFault.Blank ? NameFault.Blank : NameFault.Hidden) // the guard's faults become the field's + .Ensure(text => text.Length <= 50, NameFault.TooLong) // a rule of this field + .Map(text => new DisplayName(text)); +} + +public readonly partial record struct SubjectId : IValidatedValue +{ + public static Result Create(string value) => value.AsIdentifier().Map(id => new SubjectId(id)); +} +// end sample + +public static class Usage +{ + public static (Result Composed, Result Folded, Result Joined, Result Hidden, Result Filler, Result Bare, Result Issued, Result Forged) Guards() + { + // sample: text/guards + Result composed = "Zoe\u0308".AsText(); // Success: Zoë, composed to NFC: one code point for ë + Result folded = "Jean\u2003Paul".AsText(); // Success: Jean Paul, an em space folded to a space + Result joined = "\U0001F469\u200D\U0001F4BB".AsText(); // Success: 👩‍💻, the joiner holds the sequence together + Result hidden = "Zoe\u200B".AsText(); // Error: Hidden, a zero-width space + Result filler = "\u3164".AsText(); // Error: Hidden, a Hangul filler that IsNullOrWhiteSpace accepts + Result bare = "\u200D".AsText(); // Error: Hidden, a joiner with nothing to join + Result issued = " a1b2-c3 ".AsIdentifier(); // Success: a1b2-c3, trimmed and otherwise as issued + Result forged = "sub\u200Dject".AsIdentifier(); // Error: Hidden, a joiner in an identifier + // end sample + + return (composed, folded, joined, hidden, filler, bare, issued, forged); + } + + // sample: text/required-optional + static Result Required(string? text) => text.AsText(); // Blank is the error for a required field + + static Result, TextFault> Optional(string? text) => + text.AsText() + .Map(clean => Option.Some(clean)) + .Recover(fault => fault == TextFault.Blank ? Option.None() : Result.Error(fault)); // Blank is absence, Hidden still a refusal + // end sample + + public static (string Name, string Note, string Tagged) Fields(ProfileRequest request) + { + // sample: text/required-optional-use + string name = Required(request.Name).Match(text => text, fault => fault.ToString()); // "Blank" + string note = Optional(request.Note).Match(text => text.Match(t => t, () => "none"), fault => fault.ToString()); // "none" + string tagged = Optional("note\u200B").Match(text => text.Match(t => t, () => "none"), fault => fault.ToString()); // "Hidden" + // end sample + + return (name, note, tagged); + } + + public static (string Shown, string Refused, bool Equal) ValueObjects() + { + // sample: text/value-objects-use + string shown = DisplayName.Create(" Zoe\u0308\u2003Q ").Match(name => name.Value, fault => fault.ToString()); // "Zoë Q" + string refused = DisplayName.Create(new string('x', 51)).Match(name => name.Value, fault => fault.ToString()); // "TooLong" + bool equal = DisplayName.FromKnownGood("Mu\u0308ller") == DisplayName.FromKnownGood("M\u00FCller"); // true, one spelling after NFC + // end sample + + return (shown, refused, equal); + } +} + +public sealed class TextTests +{ + private const string Readme = "docs/text.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Outcome(Result result) => result.Match(text => $"Success: {text}", fault => $"Error: {fault}"); + + [Fact] + public void Each_guard_answers_what_its_comment_states() + { + "Zoe\u0308".AsText().ShouldBeShownIn(Readme, Outcome); + "Jean\u2003Paul".AsText().ShouldBeShownIn(Readme, Outcome); + "\U0001F469\u200D\U0001F4BB".AsText().ShouldBeShownIn(Readme, Outcome); + "Zoe\u200B".AsText().ShouldBeShownIn(Readme, Outcome); + "\u3164".AsText().ShouldBeShownIn(Readme, Outcome); + "\u200D".AsText().ShouldBeShownIn(Readme, Outcome); + " a1b2-c3 ".AsIdentifier().ShouldBeShownIn(Readme, Outcome); + "sub\u200Dject".AsIdentifier().ShouldBeShownIn(Readme, Outcome); + + var (composed, _, _, _, _, _, _, _) = Usage.Guards(); + composed.TryGetValue(out var zoe, out _).ShouldBeTrue(); + zoe.ShouldBe("Zoë", "composed: one code point"); + zoe.Length.ShouldBe(3); + string.IsNullOrWhiteSpace("\u3164").ShouldBeFalse("IsNullOrWhiteSpace takes the filler for text, which is why the guard exists"); + } + + [Fact] + public void A_required_and_an_optional_field_answer_what_the_comments_say() + { + var (name, note, tagged) = Usage.Fields(new ProfileRequest(null, " ")); + + name.ShouldBeShownIn(Readme, Quoted, "Required(request.Name).Match(text => text, fault => fault.ToString())"); + note.ShouldBeShownIn(Readme, Quoted, "Optional(request.Note).Match(text => text.Match(t => t, () => \"none\"), fault => fault.ToString())"); + tagged.ShouldBeShownIn(Readme, Quoted, "Optional(\"note\\u200B\").Match(text => text.Match(t => t, () => \"none\"), fault => fault.ToString())"); + + Usage.Fields(new ProfileRequest(" Ann ", " a note ")).ShouldBe(("Ann", "a note", "Hidden")); + } + + [Fact] + public void A_value_object_over_the_guard_answers_what_the_comments_say() + { + var (shown, refused, equal) = Usage.ValueObjects(); + + shown.ShouldBeShownIn(Readme, Quoted, "DisplayName.Create(\" Zoe\\u0308\\u2003Q \").Match(name => name.Value, fault => fault.ToString())"); + refused.ShouldBeShownIn(Readme, Quoted, "DisplayName.Create(new string('x', 51)).Match(name => name.Value, fault => fault.ToString())"); + equal.ShouldBeShownIn(Readme, e => e ? "true" : "false", "DisplayName.FromKnownGood(\"Mu\\u0308ller\") == DisplayName.FromKnownGood(\"M\\u00FCller\")"); + + DisplayName.TryFrom("\u200B").IsNone().ShouldBeTrue(); + DisplayName.Create("").TryGetValue(out _, out var blank).ShouldBeFalse(); + blank.ShouldBe(NameFault.Blank); + SubjectId.Create(" sub-1 ").Match(id => id.Value, fault => fault.ToString()).ShouldBe("sub-1"); + SubjectId.Create("sub\u200D1").TryGetValue(out _, out var joiner).ShouldBeFalse(); + joiner.ShouldBe(TextFault.Hidden); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/TypeConstraintsSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/TypeConstraintsSamples.cs new file mode 100644 index 0000000..06b2cb4 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/TypeConstraintsSamples.cs @@ -0,0 +1,111 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.TypeConstraints; + +#pragma warning disable CMTK0010 // what the page shows the rule reporting +// sample: type-constraints/aggregate +public abstract record AccountEvent; + +public sealed record Deposited(decimal Amount) : AccountEvent; + +public readonly partial record struct AccountId : IValue; + +public abstract class Aggregate<[Abstract] TEvent, [Sealed] TId> +{ + public List Pending { get; } = []; +} + +public sealed class Account : Aggregate; // fine: an abstract record, a struct + +public sealed class Ledger : Aggregate; // CMTK0010: Deposited is not abstract +// end sample + +// sample: type-constraints/inferred +public static class Snapshots +{ + public static string Describe<[Record] TState>(TState state) => state!.ToString()!; // a record prints its members +} + +public sealed class Balance { public decimal Amount { get; init; } } + +public sealed record Statement(decimal Amount); +// end sample + +// sample: type-constraints/forwarding +public abstract class Leaky : Aggregate; // CMTK0010: TEvent does not carry [Abstract] + +public abstract class Sound<[Abstract] TEvent> : Aggregate; // the requirement follows the chain + +public sealed class Sealed : Aggregate where T : struct; // struct proves [Sealed], and [Concrete] +// end sample +#pragma warning restore CMTK0010 + +#pragma warning disable CMTK0011 // what the page shows the rule reporting +#pragma warning disable CS9113 // the constructor's parameter is the point, and unused +// sample: type-constraints/constructor +[RequireParameterlessConstructor] +public interface IJob +{ + Task RunAsync(CancellationToken cancellationToken); +} + +[RequireParameterlessConstructor(ConstructorAccessibility.Internal)] +public abstract class Plugin; + +public sealed class Nightly : IJob // fine: no constructor declared, so the implicit one is public +{ + public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed class Report : IJob // CMTK0011: no parameterless constructor; the fix adds one +{ + public Report(TimeProvider clock) { } + + public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed class Importer : Plugin; // CMTK0011: the implicit constructor is public, Plugin requires internal +// end sample +#pragma warning restore CS9113 +#pragma warning restore CMTK0011 + +public static class Usage +{ + public static (string Shown, string Hidden) Inferred() + { +#pragma warning disable CMTK0010 // what the page shows the rule reporting + // sample: type-constraints/inferred-use + string shown = Snapshots.Describe(new Statement(10m)); // "Statement { Amount = 10 }" + string hidden = Snapshots.Describe(new Balance()); // CMTK0010: Balance is not a record, inferred at the call + // end sample +#pragma warning restore CMTK0010 + + return (shown, hidden); + } +} + +public sealed class TypeConstraintsTests +{ + private const string Readme = "docs/type-constraints.md"; + + [Fact] + public void A_record_prints_as_the_comment_says() + { + var (shown, hidden) = Usage.Inferred(); + + shown.ShouldBeShownIn(Readme, text => $"\"{text}\"", "Snapshots.Describe(new Statement(10m))"); + hidden.ShouldBe("ReadmeSamples.Docs.TypeConstraints.Balance", "a class prints its name, which is what the rule guards against"); + } + + /// The attributes carry no run-time check: the types the page marks as reported still exist and run. + [Fact] + public void The_guarantee_is_the_build() + { + new Ledger().Pending.ShouldBeEmpty(); + new Account().Pending.Add(new Deposited(1m)); + typeof(Aggregate<,>).GetGenericArguments()[0].GetCustomAttributes(typeof(AbstractAttribute), false).ShouldNotBeEmpty(); + typeof(Report).GetConstructor(Type.EmptyTypes).ShouldBeNull("what CMTK0011 reports"); + typeof(Nightly).GetConstructor(Type.EmptyTypes).ShouldNotBeNull(); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ValueObjectsSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ValueObjectsSamples.cs new file mode 100644 index 0000000..a3c6fa3 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/ValueObjectsSamples.cs @@ -0,0 +1,332 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +using System.Diagnostics; +using System.Numerics; + +namespace ReadmeSamples.Docs.ValueObjects; + +// sample: value-objects/struct-or-class +public readonly partial record struct Sku : IValue; // a struct: never null, and default is CMTK0001 + +public sealed partial record Note : IValue; // a class: for a string column in an EF Core compiled model +// end sample + +// sample: value-objects/identifiers +public readonly partial record struct OrderId : IValue; + +public readonly partial record struct InvoiceNo : IValue; +// end sample + +// sample: value-objects/email +public enum EmailFault { Blank, NoAt, TooLong } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(EmailFault.Blank); + + var normalised = value.Trim().ToLowerInvariant(); // equal addresses hold the same wrapped value + if (!normalised.Contains('@')) return Result.Error(EmailFault.NoAt); + if (normalised.Length > 254) return Result.Error(EmailFault.TooLong); + + return new Email(normalised); // the private constructor is generated + } + + public string Domain => Value[(Value.IndexOf('@') + 1)..]; // computed from Value: fine. A second field is CMTK1012 +} +// end sample + +// sample: value-objects/fault-with-data +public abstract record QuantityFault +{ + public sealed record NotPositive : QuantityFault; + + public sealed record AboveLimit(int Limit) : QuantityFault; // the limit, never the value given +} + +public readonly partial record struct Quantity : IValidatedValue +{ + public const int Limit = 1_000; + + public static Result Create(int value) + { + if (value <= 0) return Result.Error(new QuantityFault.NotPositive()); + if (value > Limit) return Result.Error(new QuantityFault.AboveLimit(Limit)); + + return new Quantity(value); + } +} +// end sample + +// sample: value-objects/numbers +public readonly partial record struct Weight : IValue; // any decimal, with MinValue and MaxValue + +public enum PercentageFault { BelowZero, AboveHundred } + +public readonly partial record struct Percentage : IValidatedValue +{ + public static Result Create(int value) + { + if (value < 0) return Result.Error(PercentageFault.BelowZero); + if (value > 100) return Result.Error(PercentageFault.AboveHundred); + + return new Percentage(value); + } + + public override string ToString() => $"{Value} %"; // kept; then no formatting interface is generated, so interpolation reaches it too +} +// end sample + +// sample: value-objects/other-wrapped-types +public readonly partial record struct Weekday : IValue; + +public readonly partial record struct DueOn : IValue; + +public readonly partial record struct Homepage : IValue; +// end sample + +// sample: value-objects/own-compare-to +public readonly partial record struct Priority : IValue +{ + public int CompareTo(Priority other) => other.Value.CompareTo(Value); // 1 ranks above 2; the operators follow +} +// end sample + +// sample: value-objects/nested +public sealed partial class Shipment +{ + public readonly partial record struct Id : IValue; // Shipment.Id; its companion class is ShipmentIdExtensions + + public required Id Key { get; init; } +} +// end sample + +/// The request the factory sample reads: a bound shape, whose text is input. +public sealed record RegisterRequest(string Email); + +public static class Usage +{ + public static (OrderId Id, OrderId Imported, InvoiceNo No, int Version) Identifiers() + { + // sample: value-objects/identifiers-use + var id = OrderId.New(); // a version 7 Guid, which sorts by creation time + var imported = OrderId.New(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); // its timestamp, for a migrated record + var no = InvoiceNo.From(42); + + int version = id.Value.Version; // 7 + // end sample + + return (id, imported, no, version); + } + + public static (string Lowered, bool Same, string Domain) Emails() + { + // sample: value-objects/email-use + string lowered = Email.Create(" Ops@Example.com ").Match(email => email.Value, fault => fault.ToString()); // "ops@example.com" + bool same = Email.FromKnownGood("Ops@Example.com") == Email.FromKnownGood("ops@example.com"); // true + string domain = Email.FromKnownGood("ops@example.com").Domain; // "example.com" + // end sample + + return (lowered, same, domain); + } + + // sample: value-objects/fault-with-data-use + static string Describe(QuantityFault fault) => fault switch + { + QuantityFault.NotPositive => "must be positive", + QuantityFault.AboveLimit { Limit: var limit } => $"at most {limit}", + _ => throw new UnreachableException(), + }; + + // end sample + public static string Quantities() + { + // sample: value-objects/fault-with-data-use + string explained = Quantity.Create(5_000).Match(quantity => quantity.ToString(), Describe); // "at most 1000" + // end sample + + return explained; + } + + public static (bool Widest, string Shown, bool Ordered) Numbers() + { + // sample: value-objects/numbers-use + bool widest = Weight.MaxValue.Value == decimal.MaxValue; // true + string shown = $"{Percentage.FromKnownGood(42)}"; // "42 %" + bool ordered = Percentage.FromKnownGood(5) < Percentage.FromKnownGood(50); // true, the wrapped type's ordering + // end sample + + return (widest, shown, ordered); + } + + public static (Weekday Day, Weekday Fifth, DueOn Due, bool Sooner, bool Sortable) OtherWrappedTypes() + { + // sample: value-objects/other-wrapped-types-use + var day = Weekday.Parse("Friday", null); // Friday + var fifth = Weekday.Parse("5", null); // Friday, as Enum.TryParse reads a number + var due = DueOn.Parse("2026-10-13", null); // 2026-10-13 + bool sooner = DueOn.From(new DateOnly(2026, 1, 1)) < due; // true + bool sortable = typeof(IComparable).IsAssignableFrom(typeof(Homepage)); // false, a Uri has no ordering + // end sample + + return (day, fifth, due, sooner, sortable); + } + + public static void Factories(RegisterRequest request) + { + // sample: value-objects/factories + Result fromRequest = Email.Create(request.Email); // the fault says which rule refused it + Option maybe = Email.TryFrom(request.Email); // is it valid? + Email support = Email.FromKnownGood("support@example.com"); // a refusal here is a bug at this line + InvoiceNo first = InvoiceNo.From(1); // no rules + Result stillValid = support.Revalidate(); // today's rules, applied again + // end sample + + _ = (fromRequest, maybe, first, stillValid); + } + + public static (Priority First, bool Urgent) Priorities() + { + // sample: value-objects/own-compare-to-use + var first = new[] { Priority.From(3), Priority.From(1) }.Min(); // 3 + bool urgent = Priority.From(1) > Priority.From(2); // true + // end sample + + return (first, urgent); + } + + // sample: value-objects/generic + static Result, TFault> CreateAll(IEnumerable values) + where TSelf : IValidatedValue + where T : notnull + where TFault : notnull => + values.Traverse(TSelf.Create); // the first fault stops it; nothing after it is created + + // end sample + public static string Generic() + { + // sample: value-objects/generic-use + string outcome = CreateAll(["ops@example.com", "nobody"]).Match(list => $"{list.Count} valid", fault => $"refused: {fault}"); // "refused: NoAt" + // end sample + + return outcome; + } +} + +public sealed class ValueObjectsTests +{ + private const string Readme = "docs/value-objects.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Bool(bool value) => value ? "true" : "false"; + + [Fact] + public void An_identifier_is_what_the_comments_say() + { + var (id, imported, no, version) = Usage.Identifiers(); + + id.Value.Version.ShouldBeShownIn(Readme, v => v.ToString()); + version.ShouldBe(7); + imported.Value.Version.ShouldBe(7); + no.Value.ShouldBe(42); + Sku.From("A-1").ShouldBe(Sku.From("A-1")); + Note.From("n").ShouldBe(Note.From("n")); + } + + [Fact] + public void An_email_is_normalised_as_the_comments_say() + { + var (lowered, same, domain) = Usage.Emails(); + + Email.Create(" Ops@Example.com ").Match(email => email.Value, fault => fault.ToString()).ShouldBeShownIn(Readme, Quoted); + (Email.FromKnownGood("Ops@Example.com") == Email.FromKnownGood("ops@example.com")).ShouldBeShownIn(Readme, Bool); + Email.FromKnownGood("ops@example.com").Domain.ShouldBeShownIn(Readme, Quoted); + (lowered, same, domain).ShouldBe(("ops@example.com", true, "example.com")); + + Email.Create("").TryGetValue(out _, out var blank).ShouldBeFalse(); + blank.ShouldBe(EmailFault.Blank); + Email.Create(new string('a', 250) + "@x.test").TryGetValue(out _, out var tooLong).ShouldBeFalse(); + tooLong.ShouldBe(EmailFault.TooLong); + } + + [Fact] + public void A_fault_carries_the_rule_s_data_as_the_comment_says() + { + Usage.Quantities().ShouldBeShownIn(Readme, Quoted, "Quantity.Create(5_000).Match(quantity => quantity.ToString(), Describe)"); + + Quantity.Create(0).TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBeOfType(); + Quantity.Create(5_000).TryGetValue(out _, out var above).ShouldBeFalse(); + above.ShouldBe(new QuantityFault.AboveLimit(1_000)); + } + + [Fact] + public void Numbers_are_what_the_comments_say() + { + var (widest, shown, ordered) = Usage.Numbers(); + + (Weight.MaxValue.Value == decimal.MaxValue).ShouldBeShownIn(Readme, Bool); + $"{Percentage.FromKnownGood(42)}".ShouldBeShownIn(Readme, Quoted); + (Percentage.FromKnownGood(5) < Percentage.FromKnownGood(50)).ShouldBeShownIn(Readme, Bool); + (widest, shown, ordered).ShouldBe((true, "42 %", true)); + + typeof(Percentage).GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IMinMaxValue<>)).ShouldBeFalse("a validated value object has no MinValue and MaxValue"); + typeof(Weight).GetInterfaces().ShouldContain(typeof(IMinMaxValue)); + Weight.From(1.5m).ToString().ShouldBe("1.5"); + } + + [Fact] + public void Other_wrapped_types_parse_and_compare_as_the_comments_say() + { + var (day, fifth, due, sooner, sortable) = Usage.OtherWrappedTypes(); + + Weekday.Parse("Friday", null).ShouldBeShownIn(Readme, d => d.Value.ToString()); + Weekday.Parse("5", null).ShouldBeShownIn(Readme, d => d.Value.ToString()); + DueOn.Parse("2026-10-13", null).ShouldBeShownIn(Readme, d => d.Value.ToString("yyyy-MM-dd")); + (DueOn.From(new DateOnly(2026, 1, 1)) < due).ShouldBeShownIn(Readme, Bool); + typeof(IComparable).IsAssignableFrom(typeof(Homepage)).ShouldBeShownIn(Readme, Bool); + (day, fifth, sooner, sortable).ShouldBe((Weekday.From(DayOfWeek.Friday), Weekday.From(DayOfWeek.Friday), true, false)); + Homepage.Parse("https://example.com/", null).Value.ShouldBe(new Uri("https://example.com/")); + } + + [Fact] + public void The_factories_answer_as_the_table_says() + { + Usage.Factories(new RegisterRequest("nobody")); + + Email.Create("nobody").TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBe(EmailFault.NoAt); + Email.TryFrom("nobody").IsNone().ShouldBeTrue(); + var typed = "nobody"; + var refusal = Should.Throw(() => Email.FromKnownGood(typed)).Message; + refusal.ShouldContain("typed", Case.Sensitive, "the call site's expression"); + refusal.ShouldNotContain("nobody", Case.Sensitive, "never the value"); + Email.FromKnownGood("support@example.com").Revalidate().TryGetValue(out var revalidated, out _).ShouldBeTrue(); + revalidated.ShouldBe(Email.FromKnownGood("support@example.com")); + } + + [Fact] + public void A_hand_written_CompareTo_orders_as_the_comments_say() + { + var (first, urgent) = Usage.Priorities(); + + new[] { Priority.From(3), Priority.From(1) }.Min().ShouldBeShownIn(Readme, p => p.Value.ToString()); + (Priority.From(1) > Priority.From(2)).ShouldBeShownIn(Readme, Bool); + (first, urgent).ShouldBe((Priority.From(3), true)); + } + + [Fact] + public void A_nested_value_object_has_its_companion_named_after_the_chain() + { + var shipment = new Shipment { Key = Shipment.Id.New() }; + + shipment.Key.Value.Version.ShouldBe(7); + typeof(Shipment).Assembly.GetType("ReadmeSamples.Docs.ValueObjects.ShipmentIdExtensions").ShouldNotBeNull(); + } + + [Fact] + public void Generic_code_stops_at_the_first_fault_as_the_comment_says() => + Usage.Generic().ShouldBeShownIn(Readme, Quoted, "CreateAll([\"ops@example.com\", \"nobody\"]).Match(list => $\"{list.Count} valid\", fault => $\"refused: {fault}\")"); +} diff --git a/test/CodoMetis.TypeKit.Samples/EntityFrameworkCore/EntityFrameworkCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/EntityFrameworkCore/EntityFrameworkCoreSamples.cs index 612d1f5..4015a95 100644 --- a/test/CodoMetis.TypeKit.Samples/EntityFrameworkCore/EntityFrameworkCoreSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/EntityFrameworkCore/EntityFrameworkCoreSamples.cs @@ -13,6 +13,8 @@ namespace ReadmeSamples.EfCore; public enum CodeFault { Blank } +public enum OrderFault { NotFound } + public readonly partial record struct ProductCode : IValidatedValue { public static Result Create(string value) => @@ -137,6 +139,17 @@ public static void Query(ShopDb db, OrderId id, List ids, Tag tag) // end sample } + public static async Task<(Option Order, Option Earliest, Result Found)> FindOne(ShopDb db, OrderId id) + { + // sample: CodoMetis.TypeKit.EntityFrameworkCore/first-or-none + Option order = await db.Orders.FirstOrNoneAsync(o => o.Id == id); // the database runs the condition and returns one row + Option earliest = db.Orders.OrderBy(o => o.PlacedAt).FirstOrNone(); // the same, synchronous + Result found = db.Orders.FirstOrError(o => o.Id == id, OrderFault.NotFound); + // end sample + + return (order, earliest, found); + } + public static async Task FindRefused(ShopDb db, IRefusalLog log) { // sample: CodoMetis.TypeKit.EntityFrameworkCore/revalidate diff --git a/test/CodoMetis.TypeKit.Samples/Generators/ClosedSetSamples.cs b/test/CodoMetis.TypeKit.Samples/Generators/ClosedSetSamples.cs new file mode 100644 index 0000000..913d81f --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Generators/ClosedSetSamples.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using CodoMetis.TypeKit.ClosedSets; + +namespace ReadmeSamples.Generators.ClosedSets; + +// sample: CodoMetis.TypeKit.Generators/closed-set +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} + +public sealed partial class Tier : IClosedSet +{ + public static readonly Tier Basic = new(1, null); + public static readonly Tier Premium = new(2, "Priority support."); + + private Tier(int rank, string? perk) { Rank = rank; Perk = perk; } + + public int Rank { get; } + public string? Perk { get; } +} +// end sample + +public static class Using +{ + public static (string Label, string Names, bool Singleton, string Json) Run(OrderStatus status) + { + // sample: CodoMetis.TypeKit.Generators/closed-set-use + var label = status.Match(open: () => "Open", shipped: () => "On its way", cancelled: () => "Cancelled"); // every member, or it does not compile + var names = OrderStatus.All.Select(s => s.Name); // Open Shipped Cancelled + var same = OrderStatus.Parse("Shipped") == OrderStatus.Shipped; // True, members are singletons + var json = JsonSerializer.Serialize(Tier.Premium); // "Premium" + // end sample + + return (label, string.Join(", ", names), same, json); + } +} + +public sealed class ClosedSetTests +{ + private const string Readme = "src/CodoMetis.TypeKit.Generators/README.md"; + + [Fact] + public void The_sample_answers_what_its_comments_state() + { + var (label, names, singleton, json) = Using.Run(OrderStatus.Shipped); + + label.ShouldBe("On its way"); + OrderStatus.All.Select(s => s.Name).ShouldBeShownIn(Readme, all => string.Join(" ", all)); + (OrderStatus.Parse("Shipped") == OrderStatus.Shipped).ShouldBeShownIn(Readme, b => b.ToString()); + JsonSerializer.Serialize(Tier.Premium).ShouldBeShownIn(Readme, j => j); + names.ShouldBe("Open, Shipped, Cancelled"); + singleton.ShouldBeTrue(); + json.ShouldBe("\"Premium\""); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Root/IntroSamples.cs b/test/CodoMetis.TypeKit.Samples/Root/IntroSamples.cs index 7d9ed71..7b5b15a 100644 --- a/test/CodoMetis.TypeKit.Samples/Root/IntroSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Root/IntroSamples.cs @@ -1,4 +1,5 @@ using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.ValueObjects; namespace ReadmeSamples.Root.Intro; @@ -14,3 +15,12 @@ public static Result Create(string value) => value.Contains('@') ? new Email(value.Trim()) : Result.Error(EmailFault.NoAt); } // end sample + +// sample: README/closed-set +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} +// end sample diff --git a/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs b/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs index 1a195c7..971af9e 100644 --- a/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs @@ -2,11 +2,15 @@ using CodoMetis.TypeKit.Testing; // end sample +using System.Text.Json; using System.Text.RegularExpressions; +using CodoMetis.TypeKit; using ReadmeSamples.Root.QuickStart; namespace ReadmeSamples.Testing; +public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); + public sealed partial class TestingSamples { private const string Readme = "src/CodoMetis.TypeKit.Testing/README.md"; @@ -28,6 +32,21 @@ public void The_assertions_pass_as_the_README_shows() fault.ShouldBe(EmailFault.Blank); } + /// The README's patch block, run: each line passes, or this test fails with its assertion. + [Fact] + public void The_patch_assertions_pass_as_the_README_shows() + { + // sample: CodoMetis.TypeKit.Testing/patch + OrderPatch patch = JsonSerializer.Deserialize("""{"nickname":null,"quantity":2}""", JsonSerializerOptions.Web)!; + + int quantity = patch.Quantity.ShouldBeValue(); // the value, handed back + patch.Nickname.ShouldBeNull(); // a clear + patch.Name.ShouldBeAbsent(); // not in the document + // end sample + + quantity.ShouldBe(2); + } + /// The failure message the README quotes, read from the README, is the one the assertion throws. [Fact] public void A_failure_says_what_the_README_quotes() diff --git a/test/CodoMetis.TypeKit.Serilog.Tests/DestructuringTests.cs b/test/CodoMetis.TypeKit.Serilog.Tests/DestructuringTests.cs index df36bd6..c1ec60f 100644 --- a/test/CodoMetis.TypeKit.Serilog.Tests/DestructuringTests.cs +++ b/test/CodoMetis.TypeKit.Serilog.Tests/DestructuringTests.cs @@ -70,6 +70,38 @@ public void Scalars_and_other_types_are_left_to_Serilog() Render("{@Plain}", new Plain("Ann", 42)).ShouldBe("Plain { Name: \"Ann\", Age: 42 }"); } + public sealed record Order(ProbeStatus Status, ProbeTier Tier); + + /// + /// A closed set is logged as what it prints, its name, as Microsoft.Extensions.Logging logs it. + /// Destructured by its properties, it was { Rank: 2, Perk: "…", Name: …, QualifiedName: … }: + /// its constant data reached the log through a path the set never chose. + /// + [Fact] + public void A_closed_set_is_logged_as_its_name_when_destructured() + { + Render("{@Tier}", ProbeTier.Premium).ShouldBe("\"Premium\""); + Render("{@Order}", new Order(ProbeStatus.Shipped, ProbeTier.Basic)).ShouldBe("Order { Status: \"Shipped\", Tier: \"Basic\" }"); + Render("{@List}", new List { ProbeStatus.Open, ProbeStatus.Cancelled }).ShouldBe("[\"Open\", \"Cancelled\"]"); + } + + /// A hand-written ToString() is the set's seam, and what it prints is what is logged. + [Fact] + public void A_closed_set_with_its_own_ToString_is_logged_as_what_it_prints() => + Render("{@Level}", ProbeLabelled.High).ShouldBe("\"high priority\""); + + /// + /// An instance that is no member has no name, and its ToString() throws. Serilog catches it + /// and says so in the event: the log shows the defect rather than a null, and the call still logs. + /// + [Fact] + public void An_instance_that_is_no_member_is_logged_as_the_failure_to_capture_it() => + Render("{@Level}", ProbeLevel.Unlisted()).ShouldBe("\"Capturing the property value threw an exception: InvalidOperationException\""); + + [Fact] + public void Without_it_a_destructured_closed_set_shows_its_data() => + Render(configure: null, "{@Tier}", ProbeTier.Premium).ShouldContain("Priority support."); + private static string Render(string template, object? value) => Render(configure: destructure => destructure.WithTypeKit(), template, value); diff --git a/test/CodoMetis.TypeKit.Testing.Tests/AssertionTests.cs b/test/CodoMetis.TypeKit.Testing.Tests/AssertionTests.cs index e953e7a..6d27f03 100644 --- a/test/CodoMetis.TypeKit.Testing.Tests/AssertionTests.cs +++ b/test/CodoMetis.TypeKit.Testing.Tests/AssertionTests.cs @@ -41,7 +41,7 @@ public void Some_is_not_None_and_its_value_is_not_printed() => [Fact] public void An_error_is_no_success_and_is_named() => Should.Throw(() => Error.ShouldBeSuccess()) - .Message.ShouldBe("Expected a success, but the Result is the error NotFound."); + .Message.ShouldBe("Expected a success, but the Result is the error NotFound."); [Fact] public void An_error_hands_back_its_error() => Error.ShouldBeError().ShouldBe(Fault.NotFound); @@ -49,7 +49,7 @@ public void An_error_is_no_success_and_is_named() => [Fact] public void A_success_is_no_error_and_its_value_is_not_printed() => Should.Throw(() => Success.ShouldBeError()) - .Message.ShouldBe("Expected an error, but the Result is a success (its value is not printed)."); + .Message.ShouldBe("Expected an error, but the Result is a success (its value is not printed)."); [Fact] public void The_expected_error_passes() => Error.ShouldBeError(Fault.NotFound); @@ -57,7 +57,7 @@ public void A_success_is_no_error_and_its_value_is_not_printed() => [Fact] public void Another_error_is_named_beside_the_expected_one() => Should.Throw(() => Error.ShouldBeError(Fault.AlreadyShipped)) - .Message.ShouldBe("Expected the error AlreadyShipped, but the Result is the error NotFound."); + .Message.ShouldBe("Expected the error AlreadyShipped, but the Result is the error NotFound."); [Fact] public void A_success_is_not_the_expected_error() => @@ -70,7 +70,7 @@ public void A_success_is_not_the_expected_error() => [Fact] public void A_command_error_is_no_success_and_is_named() => Should.Throw(() => Result.Error(Fault.AlreadyShipped).ShouldBeSuccess()) - .Message.ShouldBe("Expected a success, but the Result is the error AlreadyShipped."); + .Message.ShouldBe("Expected a success, but the Result is the error AlreadyShipped."); [Fact] public void A_command_error_hands_back_its_error() => @@ -79,12 +79,12 @@ public void A_command_error_hands_back_its_error() => [Fact] public void A_command_success_is_no_error() => Should.Throw(() => Result.Success().ShouldBeError(Fault.NotFound)) - .Message.ShouldBe("Expected an error, but the Result is a success."); + .Message.ShouldBe("Expected an error, but the Result is a success."); [Fact] public void A_command_error_other_than_the_expected_is_named() => Should.Throw(() => Result.Error(Fault.AlreadyShipped).ShouldBeError(Fault.NotFound)) - .Message.ShouldBe("Expected the error NotFound, but the Result is the error AlreadyShipped."); + .Message.ShouldBe("Expected the error NotFound, but the Result is the error AlreadyShipped."); public static TheoryData UninitializedAssertions => [.. Uninitialize.Keys]; @@ -110,4 +110,26 @@ public void An_uninitialized_result_fails_every_assertion_as_uninitialized(strin [Fact] public void The_assertions_are_enumerated() => Uninitialize.Count.ShouldBe(6); + + /// + /// A type is named as C# writes it from outside: after its nesting chain, with each containing + /// type's own type arguments. A type nested in a generic one has no arity of its own, and cutting + /// its name at a backtick threw instead of failing. + /// + [Fact] + public void A_type_nested_in_a_generic_one_is_named_as_CSharp_writes_it() => + Should.Throw(() => Option.None.Inner>().ShouldBeSome()) + .Message.ShouldBe("Expected Some, but the Option.Inner> is None."); + + [Fact] + public void A_generic_type_nested_in_a_generic_one_takes_its_own_arguments() => + Should.Throw(() => Option.None.Pair[]>().ShouldBeSome()) + .Message.ShouldBe("Expected Some, but the Option.Pair[]> is None."); + + public static class Outer + { + public sealed class Inner; + + public sealed class Pair; + } } diff --git a/test/CodoMetis.TypeKit.Testing.Tests/CodoMetis.TypeKit.Testing.Tests.csproj b/test/CodoMetis.TypeKit.Testing.Tests/CodoMetis.TypeKit.Testing.Tests.csproj index 1716084..d18fbde 100644 --- a/test/CodoMetis.TypeKit.Testing.Tests/CodoMetis.TypeKit.Testing.Tests.csproj +++ b/test/CodoMetis.TypeKit.Testing.Tests/CodoMetis.TypeKit.Testing.Tests.csproj @@ -4,4 +4,12 @@ + + + + + diff --git a/test/CodoMetis.TypeKit.Testing.Tests/FaultAssertionTests.cs b/test/CodoMetis.TypeKit.Testing.Tests/FaultAssertionTests.cs new file mode 100644 index 0000000..ee2e3b4 --- /dev/null +++ b/test/CodoMetis.TypeKit.Testing.Tests/FaultAssertionTests.cs @@ -0,0 +1,82 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace CodoMetis.TypeKit.Testing.Tests; + +/// +/// ShouldBeError(expected) on a Result<T, Fault<TFault>> takes a fault. A bare +/// reason converted to a fault without a detail, so ShouldBeError(OrderFault.NotFound) compiled +/// and failed on every fault that carried one, reporting two faults with the same reason. +/// +public sealed class FaultAssertionTests +{ + public enum OrderFault { NotFound, AlreadyShipped } + + private static Result> Gone => Result.Error(Fault.Of(OrderFault.NotFound, "Order 7 is gone.")); + + [Fact] + public void The_expected_fault_passes() => Gone.ShouldBeError(Fault.Of(OrderFault.NotFound, "Order 7 is gone.")); + + [Fact] + public void The_reason_is_asserted_on_the_fault_handed_back() => Gone.ShouldBeError().Reason.ShouldBe(OrderFault.NotFound); + + [Fact] + public void A_fault_with_another_detail_is_another_error() => + Should.Throw(() => Gone.ShouldBeError(Fault.Of(OrderFault.NotFound))) + .Message.ShouldStartWith("Expected the error Fault { Reason = NotFound, Detail = }, but"); + + [Theory] + [InlineData("Result> result) => result.ShouldBeError(OrderFault.NotFound);")] + [InlineData("Result> result) => result.ShouldBeError(OrderFault.NotFound);")] + public void A_bare_reason_is_not_the_expected_fault(string signatureEnd) + { + var problems = Compile($$""" + using CodoMetis.TypeKit; + using CodoMetis.TypeKit.Testing; + + public enum OrderFault { NotFound } + + public static class Checks + { + public static void Assert({{signatureEnd}} + } + """); + + problems.ShouldNotBeEmpty(); + problems.ShouldAllBe(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void The_compilation_is_sound() => + Compile(""" + using CodoMetis.TypeKit; + using CodoMetis.TypeKit.Testing; + + public enum OrderFault { NotFound } + + public static class Checks + { + public static void Assert(Result> result) => result.ShouldBeError(Fault.Of(OrderFault.NotFound)); + } + """).ShouldBeEmpty(); + + private static IReadOnlyList Compile(string source) => + [ + .. CSharpCompilation.Create( + "Consumer", + [CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp14))], + References.Value, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)) + .GetDiagnostics() + .Where(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning), + ]; + + private static readonly Lazy References = new(() => + [ + .. ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator) + .Append(typeof(Option<>).Assembly.Location) + .Append(typeof(ResultAssertions).Assembly.Location) + .Distinct(StringComparer.Ordinal) + .Select(path => MetadataReference.CreateFromFile(path)), + ]); +} diff --git a/test/CodoMetis.TypeKit.Testing.Tests/PatchAssertionTests.cs b/test/CodoMetis.TypeKit.Testing.Tests/PatchAssertionTests.cs new file mode 100644 index 0000000..5675ca4 --- /dev/null +++ b/test/CodoMetis.TypeKit.Testing.Tests/PatchAssertionTests.cs @@ -0,0 +1,53 @@ +namespace CodoMetis.TypeKit.Testing.Tests; + +/// +/// The patch assertions hand back the value a field carried, and otherwise fail naming the state the +/// field was in, never the value. +/// +public sealed class PatchAssertionTests +{ + /// A value whose text would show if a message printed it. + private const string Secret = "s3cr3t-value"; + + [Fact] + public void A_value_is_handed_back() => Patch.Value(Secret).ShouldBeValue().ShouldBe(Secret); + + [Fact] + public void A_clear_is_no_value_and_is_named() => + Should.Throw(() => Patch.Null().ShouldBeValue()) + .Message.ShouldBe("Expected a value, but the Patch is Null: the field was cleared."); + + [Fact] + public void An_absent_field_is_no_value_and_is_named() => + Should.Throw(() => Patch.Absent().ShouldBeValue()) + .Message.ShouldBe("Expected a value, but the Patch is Absent: the field was not mentioned."); + + [Fact] + public void A_clear_is_Null() => Patch.Null().ShouldBeNull(); + + [Fact] + public void A_value_is_not_Null_and_is_not_printed() => + Should.Throw(() => Patch.Value(Secret).ShouldBeNull()) + .Message.ShouldBe("Expected Null, a clear, but the Patch holds a value (not printed)."); + + [Fact] + public void An_absent_field_is_not_Null() => + Should.Throw(() => Patch.Absent().ShouldBeNull()) + .Message.ShouldBe("Expected Null, a clear, but the Patch is Absent: the field was not mentioned."); + + [Fact] + public void An_absent_field_is_Absent() => Patch.Absent().ShouldBeAbsent(); + + [Fact] + public void A_value_is_not_Absent_and_is_not_printed() => + Should.Throw(() => Patch.Value(Secret).ShouldBeAbsent()) + .Message.ShouldBe("Expected Absent, but the Patch holds a value (not printed)."); + + [Fact] + public void A_clear_is_not_Absent() => + Should.Throw(() => Patch.Null().ShouldBeAbsent()) + .Message.ShouldBe("Expected Absent, but the Patch is Null: the field was cleared."); + + [Fact] + public void A_struct_patch_hands_back_its_value() => Patch.Value(3).ShouldBeValue().ShouldBe(3); +} diff --git a/test/CodoMetis.TypeKit.Tests/AsynchronousContextTests.cs b/test/CodoMetis.TypeKit.Tests/AsynchronousContextTests.cs new file mode 100644 index 0000000..9b2ca8b --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/AsynchronousContextTests.cs @@ -0,0 +1,291 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// Where the callbacks of the asynchronous members run, and that nothing resumes on the caller's +/// (decision 62). +/// +/// +/// +/// Every await in the package is ConfigureAwait(false). A callback therefore runs where the task +/// it waited for completed: at once, on the caller's thread and context, when that task had already +/// completed, and without the caller's context, wherever the task completed, when it was still pending. +/// In a UI or a Blazor component the second is the one that breaks, by timing, so it is documented and +/// pinned here, member by member: a change to it is a decision, not an accident. +/// +/// +/// The caller's context here never runs what is posted to it, as a thread blocked on the chain +/// (.Result, .Wait(), GetAwaiter().GetResult()) never does. A member that resumed on it +/// would hang that caller, which is why ConfigureAwait(false) stays; such a post fails here at once. +/// The cases are written by hand, and the completeness test holds them to every public member of the +/// assembly that returns a task. +/// +/// +public sealed class AsynchronousContextTests +{ + private static readonly Option Some = Option.Some(1); + + private static readonly Result Ok = Result.Success(); + private static readonly Result Failed = Result.Error("e"); + + private static readonly Result Value = Result.Success(1); + private static readonly Result Error = Result.Error("e"); + + private static readonly Dictionary> Cases = new() + { + ["Result.MapAsync(Task>, Func)"] = p => p.First(Value).MapAsync(x => p.Seen(x)), + ["Result.MapAsync(Task>, Func>)"] = p => p.First(Value).MapAsync(x => p.Later(p.Seen(x))), + ["Result.BindAsync(Task>, Func>)"] = p => p.First(Value).BindAsync(x => p.Seen(Value)), + ["Result.BindAsync(Task>, Func>>)"] = p => p.First(Value).BindAsync(x => p.Later(p.Seen(Value))), + ["Result.BindAsync(Task>, Func>)"] = p => p.First(Value).BindAsync(x => p.Seen(Ok)), + ["Result.BindAsync(Task>, Func>>)"] = p => p.First(Value).BindAsync(x => p.Later(p.Seen(Ok))), + ["Result.MapErrorAsync(Task>, Func)"] = p => p.First(Error).MapErrorAsync(e => p.Seen(e.Length)), + ["Result.RecoverAsync(Task>, Func>)"] = p => p.First(Error).RecoverAsync(e => p.Seen(Value)), + ["Result.RecoverAsync(Task>, Func>>)"] = p => p.First(Error).RecoverAsync(e => p.Later(p.Seen(Value))), + ["Result.EnsureAsync(Task>, Func, TError)"] = p => p.First(Value).EnsureAsync(x => p.Seen(true), "e"), + ["Result.TapErrorAsync(Task>, Action)"] = p => p.First(Error).TapErrorAsync(e => p.Seen(e)), + ["Result.TapErrorAsync(Task>, Func)"] = p => p.First(Error).TapErrorAsync(e => p.Later(p.Seen(e))), + ["Result.TapAsync(Task>, Action)"] = p => p.First(Value).TapAsync(x => p.Seen(x)), + ["Result.TapAsync(Task>, Func)"] = p => p.First(Value).TapAsync(x => p.Later(p.Seen(x))), + + ["Result.MapAsync(Task>, Func)"] = p => p.First(Ok).MapAsync(() => p.Seen(1)), + ["Result.MapAsync(Task>, Func>)"] = p => p.First(Ok).MapAsync(() => p.Later(p.Seen(1))), + ["Result.BindAsync(Task>, Func>)"] = p => p.First(Ok).BindAsync(() => p.Seen(Ok)), + ["Result.BindAsync(Task>, Func>>)"] = p => p.First(Ok).BindAsync(() => p.Later(p.Seen(Ok))), + ["Result.MapErrorAsync(Task>, Func)"] = p => p.First(Failed).MapErrorAsync(e => p.Seen(e.Length)), + ["Result.RecoverAsync(Task>, Func>)"] = p => p.First(Failed).RecoverAsync(e => p.Seen(Ok)), + ["Result.RecoverAsync(Task>, Func>>)"] = p => p.First(Failed).RecoverAsync(e => p.Later(p.Seen(Ok))), + ["Result.TapErrorAsync(Task>, Action)"] = p => p.First(Failed).TapErrorAsync(e => p.Seen(e)), + ["Result.TapErrorAsync(Task>, Func)"] = p => p.First(Failed).TapErrorAsync(e => p.Later(p.Seen(e))), + ["Result.TapAsync(Task>, Action)"] = p => p.First(Ok).TapAsync(() => p.Seen(0)), + ["Result.TapAsync(Task>, Func)"] = p => p.First(Ok).TapAsync(() => p.Later(p.Seen(0))), + + // The first element's operation is what the second element's selector waits for. + ["Result.TraverseAsync(IEnumerable, Func>>)"] = + p => new[] { 1, 2 }.TraverseAsync(x => x == 1 ? p.First(Value) : p.Later(p.Seen(Value))), + ["Result.TraverseAsync(IEnumerable, Func>>, CancellationToken)"] = + p => new[] { 1, 2 }.TraverseAsync((x, _) => x == 1 ? p.First(Value) : p.Later(p.Seen(Value)), CancellationToken.None), + ["Result.TraverseAsync(IEnumerable, Func>>)"] = + p => new[] { 1, 2 }.TraverseAsync(x => x == 1 ? p.First(Ok) : p.Later(p.Seen(Ok))), + ["Result.TraverseAsync(IEnumerable, Func>>, CancellationToken)"] = + p => new[] { 1, 2 }.TraverseAsync((x, _) => x == 1 ? p.First(Ok) : p.Later(p.Seen(Ok)), CancellationToken.None), + + // The delegate runs at once, on the caller's context; the task it returns is awaited without it. + ["Result.CatchAsync(Func>)"] = p => Result.CatchAsync(() => p.Later(p.Seen(1))), + ["Result.CatchAsync(Func)"] = p => Result.CatchAsync(() => (Task)p.Later(p.Seen(1))), + ["Result.CatchAsync(Func>)"] = p => Result.CatchAsync(() => p.Later(p.Seen(1))), + ["Result.CatchAsync(Func)"] = p => Result.CatchAsync(() => (Task)p.Later(p.Seen(1))), + + ["Option.MapAsync(Task>, Func)"] = p => p.First(Some).MapAsync(x => p.Seen(x)), + ["Option.MapAsync(Task>, Func>)"] = p => p.First(Some).MapAsync(x => p.Later(p.Seen(x))), + ["Option.BindAsync(Task>, Func>)"] = p => p.First(Some).BindAsync(x => p.Seen(Some)), + ["Option.BindAsync(Task>, Func>>)"] = p => p.First(Some).BindAsync(x => p.Later(p.Seen(Some))), + ["Option.FilterAsync(Task>, Func)"] = p => p.First(Some).FilterAsync(x => p.Seen(true)), + ["Option.TapAsync(Task>, Action)"] = p => p.First(Some).TapAsync(x => p.Seen(x)), + ["Option.TapAsync(Task>, Func)"] = p => p.First(Some).TapAsync(x => p.Later(p.Seen(x))), + ["Option.ToResultAsync(Task>, TError)"] = p => p.First(Some).ToResultAsync("e"), + + // On a known result or option the callback runs at once; what it returns is awaited without the context. + ["Option.MapAsync(Func>)"] = p => Some.MapAsync(x => p.Later(p.Seen(x))), + ["Option.BindAsync(Func>>)"] = p => Some.BindAsync(x => p.Later(p.Seen(Some))), + ["Option.TapAsync(Func)"] = p => Some.TapAsync(x => p.Later(p.Seen(x))), + + ["Result.TapAsync(Func)"] = p => Ok.TapAsync(() => p.Later(p.Seen(0))), + ["Result.TapErrorAsync(Func)"] = p => Failed.TapErrorAsync(e => p.Later(p.Seen(e))), + ["Result.MapAsync(Func>)"] = p => Ok.MapAsync(() => p.Later(p.Seen(1))), + ["Result.BindAsync(Func>>)"] = p => Ok.BindAsync(() => p.Later(p.Seen(Ok))), + ["Result.RecoverAsync(Func>>)"] = p => Failed.RecoverAsync(e => p.Later(p.Seen(Ok))), + + ["Result.TapAsync(Func)"] = p => Value.TapAsync(x => p.Later(p.Seen(x))), + ["Result.TapErrorAsync(Func)"] = p => Error.TapErrorAsync(e => p.Later(p.Seen(e))), + ["Result.MapAsync(Func>)"] = p => Value.MapAsync(x => p.Later(p.Seen(x))), + ["Result.BindAsync(Func>>)"] = p => Value.BindAsync(x => p.Later(p.Seen(Value))), + ["Result.BindAsync(Func>>)"] = p => Value.BindAsync(x => p.Later(p.Seen(Ok))), + ["Result.RecoverAsync(Func>>)"] = p => Error.RecoverAsync(e => p.Later(p.Seen(Value))), + }; + + /// Members that take no callback: only that nothing resumes on the caller's context is checked. + private static readonly HashSet WithoutCallback = ["Option.ToResultAsync(Task>, TError)"]; + + public static TheoryData CaseNames => [.. Cases.Keys]; + + [Theory] + [MemberData(nameof(CaseNames))] + public async Task A_callback_runs_where_the_task_it_waited_for_completed_and_nothing_resumes_on_the_caller_s_context(string member) + { + foreach (var pending in new[] { true, false }) + { + var mode = pending ? "the task it waits for still pending" : "the task it waits for already completed"; + var probe = new Probe(pending); + var caller = new BlockedContext(); + + var chain = Start(Cases[member], probe, caller); + probe.Open(); + + var first = await Task.WhenAny(chain, caller.Posted, Task.Delay(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken)); + + caller.Posts.ShouldBe(0, $"{member}, {mode}, resumed on the caller's context, which a caller blocked on the chain never runs: it hangs."); + first.ShouldBeSameAs(chain, $"{member}, {mode}, did not complete."); + await chain; + + if (WithoutCallback.Contains(member)) continue; + + probe.Called.ShouldBeTrue($"{member}, {mode}: the case never reached its callback."); + if (probe.Waited) + probe.Context.ShouldNotBeSameAs(caller, $"{member}: a callback after a pending task runs where that task completed, without the caller's context."); + else + probe.Context.ShouldBeSameAs(caller, $"{member}: a callback with nothing pending before it runs at once, on the caller's context."); + } + } + + /// The documented case, spelled out: a selector after a pending result does not see the caller's context, and the caller's own await returns to it. + [Fact] + public async Task A_selector_after_a_pending_result_runs_without_the_caller_s_context_and_the_caller_s_await_returns_to_it() + { + var caller = new RunningContext(); + SynchronizationContext? selector = null; + SynchronizationContext? after = null; + + await caller.Run(async () => + { + var gate = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var chain = gate.Task.MapAsync(x => { selector = SynchronizationContext.Current; return x + 1; }); + _ = Task.Run(() => gate.SetResult(Value)); + + (await chain).Match(x => x, _ => -1).ShouldBe(2); + after = SynchronizationContext.Current; + }); + + selector.ShouldNotBeSameAs(caller, "the selector ran on the caller's context"); + after.ShouldBeSameAs(caller, "the caller's await captures its own context, whatever the package does"); + } + + [Fact] + public void Every_public_member_that_returns_a_task_has_a_case() + { + var members = AsynchronousMembers().ToList(); + + members.Count.ShouldBeGreaterThanOrEqualTo(50); + members.Except(Cases.Keys).ShouldBeEmpty("These public members return a task and have no case here."); + Cases.Keys.Except(members).ShouldBeEmpty("These cases name no public member of the assembly."); + } + + private static Task Start(Func chain, Probe probe, SynchronizationContext caller) + { + var previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(caller); + try + { + return chain(probe); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + } + + private static IEnumerable AsynchronousMembers() + { + var methods = + from type in typeof(Option<>).Assembly.GetExportedTypes() + where !type.Name.Contains('<') && type.GetCustomAttribute() is null + from method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + where typeof(Task).IsAssignableFrom(method.ReturnType) + where method.GetCustomAttribute() is not { IsError: true } + select (Type: type, Method: method, Signature: $"({string.Join(", ", method.GetParameters().Select(p => Describe(p.ParameterType)))})"); + + // Overloads that differ only in their type parameters (CatchAsync beside CatchAsync) + // are keyed with them, as in NullDelegateTests. + var byKey = methods.ToLookup(m => $"{Describe(m.Type)}.{m.Method.Name}{m.Signature}"); + + return from overloads in byKey + from m in overloads + let name = overloads.Count() > 1 && m.Method.IsGenericMethod + ? $"{m.Method.Name}<{string.Join(", ", m.Method.GetGenericArguments().Select(a => a.Name))}>" + : m.Method.Name + select $"{Describe(m.Type)}.{name}{m.Signature}"; + } + + private static string Describe(Type type) => + type.IsByRef ? Describe(type.GetElementType()!) + : type.IsGenericType ? $"{type.Name[..type.Name.IndexOf('`')]}<{string.Join(", ", type.GetGenericArguments().Select(Describe))}>" + : type.Name; + + /// + /// Hands a case the task its callback waits for, pending or completed, and records where the callback ran. + /// Every task a callback returns stays pending until , so the await after it suspends. + /// + private sealed class Probe(bool pending) + { + private readonly TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Whether the case waited for a task that was still pending before its callback. + public bool Waited { get; private set; } + + public bool Called { get; private set; } + + public SynchronizationContext? Context { get; private set; } + + public Task First(T value) + { + Waited = pending; + return pending ? Later(value) : Task.FromResult(value); + } + + public async Task Later(T value) + { + await gate.Task.ConfigureAwait(false); + return value; + } + + public T Seen(T value) + { + Called = true; + Context = SynchronizationContext.Current; + return value; + } + + public void Open() => gate.SetResult(); + } + + /// A caller blocked on the chain: what is posted to its context never runs. + private sealed class BlockedContext : SynchronizationContext + { + private readonly TaskCompletionSource posted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int posts; + + public int Posts => Volatile.Read(ref posts); + + public Task Posted => posted.Task; + + public override void Post(SendOrPostCallback d, object? state) + { + Interlocked.Increment(ref posts); + posted.TrySetResult(); + } + + public override void Send(SendOrPostCallback d, object? state) => throw new NotSupportedException("Nothing sends to the caller's context."); + } + + /// A caller's context that runs what is posted to it, on the thread pool, as itself. + private sealed class RunningContext : SynchronizationContext + { + public override void Post(SendOrPostCallback d, object? state) => + ThreadPool.QueueUserWorkItem(_ => + { + SetSynchronizationContext(this); + d(state); + }); + + /// Starts on this context, as a component's event handler starts on its renderer's. + public Task Run(Func body) + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Post(_ => started.SetResult(body()), null); + + return started.Task.Unwrap(); + } + } +} diff --git a/test/CodoMetis.TypeKit.Tests/ChooseTests.cs b/test/CodoMetis.TypeKit.Tests/ChooseTests.cs new file mode 100644 index 0000000..4d68d8d --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/ChooseTests.cs @@ -0,0 +1,97 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// Choose: the values of the options in a sequence, or of the options a selector answers, +/// in order and without the Nones. A filter and a map in one, lazy as a LINQ operator is. +/// +public sealed class ChooseTests +{ + private static Option Even(int n) => n % 2 == 0 ? Option.Some(n) : Option.None(); + + [Fact] + public void Choose_keeps_the_values_of_the_options_that_hold_one_in_order() + { + Option[] options = [Option.Some(1), Option.None(), Option.Some(3), Option.None(), Option.Some(5)]; + + options.Choose().ShouldBe([1, 3, 5]); + Array.Empty>().Choose().ShouldBeEmpty(); + new[] { Option.None() }.Choose().ShouldBeEmpty(); + } + + [Fact] + public void Choose_with_a_selector_keeps_what_it_answers_Some_for() + { + new[] { 1, 2, 3, 4, 5, 6 }.Choose(Even).ShouldBe([2, 4, 6]); + new[] { 1, 3 }.Choose(Even).ShouldBeEmpty(); + Array.Empty().Choose(Even).ShouldBeEmpty(); + + var customers = new Dictionary { [1] = "Ann", [3] = "Bo" }; + new[] { 1, 2, 3 }.Choose(id => customers.GetValueOrNone(id)).ShouldBe(["Ann", "Bo"]); + } + + /// A LINQ operator reads its source when it is enumerated, not when it is called, and only as far as the caller reads. + [Fact] + public void Choose_is_lazy() + { + var seen = new List(); + + IEnumerable Counted() + { + for (var n = 1; n <= 10; n++) + { + seen.Add(n); + yield return n; + } + } + + var chosen = Counted().Choose(Even); + seen.ShouldBeEmpty("nothing is read until the result is enumerated"); + + chosen.First().ShouldBe(2); + seen.ShouldBe([1, 2], "the source is read only as far as the first value found"); + } + + /// The arguments are checked when the call is made, as LINQ's are, not when the sequence is first read. + [Fact] + public void Choose_checks_its_arguments_before_the_sequence_is_read() + { + Should.Throw(() => ((IEnumerable)null!).Choose(Even)).ParamName.ShouldBe("source"); + Should.Throw(() => ((IEnumerable>)null!).Choose()).ParamName.ShouldBe("options"); + Should.Throw(() => new[] { 1 }.Choose((Func>)null!)).ParamName.ShouldBe("selector"); + } + + /// + /// Only what the selector finds must be non-null; the elements it reads may be null, and its answer + /// for a null is its own. The elements' T was notnull, so a sequence of string? + /// or int? warned (CS8714, and CS8622 for the lambda) where warnings are errors. + /// + [Fact] + public void Choose_with_a_selector_reads_a_sequence_with_null_elements() + { + string?[] nicknames = ["Annie", null, "Bo"]; + int?[] floors = [3, null, 5]; + + nicknames.Choose(nickname => nickname.ToOption()).ShouldBe(["Annie", "Bo"]); + floors.Choose(floor => floor.ToOption()).ShouldBe([3, 5]); + nicknames.Choose(nickname => nickname is null ? Option.Some("none") : Option.None()).ShouldBe(["none"]); + } + + /// The same calls in a consumer's compilation, nullable annotations on: no warning at all. + [Theory] + [InlineData("IEnumerable nicknames", "nicknames.Choose(nickname => nickname is null ? Option.None() : Option.Some(nickname))")] + [InlineData("IEnumerable nicknames", "nicknames.Choose(nickname => nickname.ToOption())")] + [InlineData("IEnumerable floors", "floors.Choose(floor => floor.ToOption())")] + [InlineData("IEnumerable floors", "floors.Choose(floor => floor is { } value ? Option.Some(value * 10) : Option.None())")] + [InlineData("IEnumerable numbers", "numbers.Choose(n => n > 0 ? Option.Some(n) : Option.None())")] + [InlineData("IEnumerable> options", "options.Choose()")] + public void Choose_compiles_without_a_warning_over_nullable_and_non_nullable_elements(string parameter, string call) => + ConsumerCompilation.Of($$""" + using System.Collections.Generic; + using CodoMetis.TypeKit; + + public static class Checks + { + public static object Run({{parameter}}) => {{call}}; + } + """).Problems().ShouldBeEmpty(); +} diff --git a/test/CodoMetis.TypeKit.Tests/FaultTests.cs b/test/CodoMetis.TypeKit.Tests/FaultTests.cs new file mode 100644 index 0000000..f9b0d97 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/FaultTests.cs @@ -0,0 +1,106 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// : a reason and a sentence about one occurrence. A bare reason converted +/// to a fault without a detail, so comparing a fault with a reason, directly or through a collection, an +/// equality comparer or an assertion, compiled and was false whenever the fault carried a detail. No +/// reason converts now, and the mixed comparisons are compile errors that name Reason. +/// +public sealed class FaultTests +{ + private enum OrderFault { NotFound, NameTaken } + + private static string Consumer(string member) => + $$""" + using System; + using System.Collections.Generic; + using System.Linq; + using CodoMetis.TypeKit; + + public enum OrderFault { NotFound, NameTaken } + + public static class Checks + { + public static {{member}} + } + """; + + [Theory] + [InlineData("bool Same(Fault fault) => fault == OrderFault.NotFound;")] + [InlineData("bool Same(Fault fault) => fault != OrderFault.NotFound;")] + [InlineData("bool Same(Fault fault) => OrderFault.NotFound == fault;")] + [InlineData("bool Same(Fault fault) => OrderFault.NotFound != fault;")] + [InlineData("bool Same(Fault fault) => fault.Equals(OrderFault.NotFound);")] + public void Comparing_a_fault_with_a_bare_reason_is_a_compile_error_naming_Reason(string member) + { + var compilation = ConsumerCompilation.Of(Consumer(member)); + + compilation.ErrorIds().ShouldBe(["CS0619"], compilation.Problems()); + compilation.Problems().ShouldContain("fault.Reason"); + } + + [Theory] + [InlineData("Fault Make() => OrderFault.NotFound;", "CS0029")] + [InlineData("bool Same(Fault? fault) => fault == OrderFault.NotFound;", "CS0019")] + [InlineData("bool Holds(List> faults) => faults.Contains(OrderFault.NotFound);", "CS1503")] + [InlineData("bool Holds(HashSet> faults) => faults.Contains(OrderFault.NotFound);", "CS1503")] + [InlineData("bool Holds(Fault[] faults) => faults.Contains(OrderFault.NotFound);", "CS1929")] + [InlineData("bool Same(Fault fault) => EqualityComparer>.Default.Equals(fault, OrderFault.NotFound);", "CS0176")] + public void A_bare_reason_is_not_a_fault(string member, string error) + { + var compilation = ConsumerCompilation.Of(Consumer(member)); + + compilation.ErrorIds().ShouldBe([error], compilation.Problems()); + } + + [Theory] + [InlineData("bool Same(Fault fault) => fault.Reason == OrderFault.NotFound;")] + [InlineData("Fault Make() => Fault.Of(OrderFault.NotFound);")] + [InlineData("bool Holds(List> faults) => faults.Exists(fault => fault.Reason == OrderFault.NotFound);")] + [InlineData("bool Same(Fault fault, Fault other) => fault == other;")] + [InlineData("bool Same(Fault fault) => fault == Fault.Of(OrderFault.NotFound, \"Order 7 is gone.\");")] + public void Comparing_the_reason_or_two_faults_compiles(string member) => + ConsumerCompilation.Of(Consumer(member)).Problems().ShouldBeEmpty(); + + /// + /// No detail is null, so a blank one is a mistake, and refused where it is made: a detail of + /// spaces is written into a problem's detail as a sentence that says nothing. + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n")] + public void A_blank_detail_is_refused(string detail) + { + var refusal = Should.Throw(() => Fault.Of(OrderFault.NotFound, detail)); + + refusal.ParamName.ShouldBe("detail"); + refusal.ShouldNotBeOfType(); + } + + /// A reason that is a class, a closed set's member, can be null, and a fault of no reason is refused. + [Fact] + public void A_null_reason_is_refused() + { + var refusal = Should.Throw(() => Fault.Of(null!)); + + refusal.ParamName.ShouldBe("reason"); + refusal.Message.ShouldStartWith("A fault's reason"); + refusal.Message.ShouldNotContain("Option"); + } + + [Fact] + public void A_fault_without_a_detail_has_none() + { + Fault.Of(OrderFault.NotFound).Detail.ShouldBeNull(); + Fault.Of(OrderFault.NotFound, null).Detail.ShouldBeNull(); + } + + [Fact] + public void Two_faults_are_equal_by_reason_and_detail() + { + Fault.Of(OrderFault.NotFound, "Order 7 is gone.").ShouldBe(Fault.Of(OrderFault.NotFound, "Order 7 is gone.")); + Fault.Of(OrderFault.NotFound, "Order 7 is gone.").ShouldNotBe(Fault.Of(OrderFault.NotFound)); + Fault.Of(OrderFault.NotFound, "Order 7 is gone.").Reason.ShouldBe(OrderFault.NotFound); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/InvariantGlobalizationTests.cs b/test/CodoMetis.TypeKit.Tests/InvariantGlobalizationTests.cs new file mode 100644 index 0000000..289d878 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/InvariantGlobalizationTests.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// AsText() where text cannot be composed. In globalization-invariant mode, which a Native AOT +/// application often turns on with InvariantGlobalization, string.Normalize returns its +/// input unchanged, and AsText() answered Zoe and a combining diaeresis as a success, +/// uncomposed: one name, two spellings, silently. It throws there now. +/// +/// +/// The mode is fixed when a process starts, so the test starts this test assembly again with +/// DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1, filtered to itself, and holds the child to having +/// run it and passed. In that child, or in a whole run started in the mode, the same test makes its +/// assertions directly, after confirming that the mode is really on. +/// +public sealed class InvariantGlobalizationTests +{ + private const string InvariantMode = "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"; + + [Fact] + public async Task AsText_throws_where_text_cannot_be_composed_and_AsIdentifier_still_answers() + { + if (Environment.GetEnvironmentVariable(InvariantMode) != "1") + { + await RunInInvariantMode(nameof(AsText_throws_where_text_cannot_be_composed_and_AsIdentifier_still_answers)); + return; + } + + Should.Throw(() => CultureInfo.GetCultureInfo("de-DE"), "the process is not in globalization-invariant mode, so this run tests nothing"); + "Zoe\u0308".Normalize(System.Text.NormalizationForm.FormC).ShouldBe("Zoe\u0308", "string.Normalize composes in this process, so this run tests nothing"); + + var refusal = Should.Throw(() => "Zoe\u0308".AsText()); + refusal.Message.ShouldContain("globalization-invariant mode"); + refusal.Message.ShouldContain("InvariantGlobalization"); + refusal.Message.ShouldContain("Turn invariant mode off and make ICU available"); + refusal.Message.ShouldContain("ICU alone does nothing"); + RefusalLinkTests.ShouldEndWithLinkTo(refusal.Message, RefusalLinkTests.TextWithoutComposition); + + Should.Throw(() => "Zoe".AsText(), "whatever the string, so that the first call finds out"); + Should.Throw(() => ((string?)null).AsText(), "whatever the string, so that the first call finds out"); + + " Zoe\u0308 ".AsIdentifier().Match(id => id, fault => fault.ToString()).ShouldBe("Zoe\u0308", "AsIdentifier composes nothing and needs no globalization data"); + "Zoe\u200B".AsIdentifier().Match(id => id, fault => fault.ToString()).ShouldBe(nameof(TextFault.Hidden)); + } + + /// + /// Runs one test of this assembly in a process in globalization-invariant mode, with the dotnet host + /// of the runtime running this one, and fails unless it ran and passed. Started directly, the test + /// assembly is xunit's own console runner, whose filter and summary these are. + /// + private static async Task RunInInvariantMode(string method) + { + using var process = Process.Start(new ProcessStartInfo( + DotnetHost(), + ["exec", typeof(InvariantGlobalizationTests).Assembly.Location, + "-method", $"{typeof(InvariantGlobalizationTests).FullName}.{method}", + "-noLogo", "-noColor", "-noAutoReporters"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + Environment = { [InvariantMode] = "1", ["DOTNET_CLI_UI_LANGUAGE"] = "en" }, + })!; + + var output = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken); + var errors = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken); + await process.WaitForExitAsync(TestContext.Current.CancellationToken); + + var report = await output + await errors; + process.ExitCode.ShouldBe(0, $"The test failed in globalization-invariant mode:{Environment.NewLine}{report}"); + report.ShouldContain("Total: 1, Errors: 0, Failed: 0, Skipped: 0", Case.Sensitive, $"The child did not run this one test:{Environment.NewLine}{report}"); + } + + /// The dotnet host beside the shared runtime running this process, else the one on the PATH. + private static string DotnetHost() + { + var root = Path.GetFullPath(Path.Combine(RuntimeEnvironment.GetRuntimeDirectory(), "..", "..", "..")); + var host = Path.Combine(root, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); + + return File.Exists(host) ? host : "dotnet"; + } +} diff --git a/test/CodoMetis.TypeKit.Tests/NotWireTypeTests.cs b/test/CodoMetis.TypeKit.Tests/NotWireTypeTests.cs index 52d31c5..11c3b96 100644 --- a/test/CodoMetis.TypeKit.Tests/NotWireTypeTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NotWireTypeTests.cs @@ -299,8 +299,10 @@ public void A_source_generated_context_still_takes_a_converter_from_the_options( [Fact] public void Every_exported_struct_of_the_assembly_carries_the_refusing_converter() { + // Patch is the one wire type of the package, on purpose (PatchTests): it says what a request + // mentioned, which is what a document carries. var structs = typeof(Option<>).Assembly.GetExportedTypes() - .Where(type => type is { IsValueType: true, IsEnum: false }) + .Where(type => type is { IsValueType: true, IsEnum: false } && type != typeof(Patch<>)) .Select(Close) .ToList(); diff --git a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs index 4784056..a25e6b8 100644 --- a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs @@ -39,6 +39,10 @@ public sealed class NullDelegateTests // A pending result that fails: only a delegate checked before the task is awaited reports itself // rather than the task's own exception. private static Task> PendingFault => Task.FromException>(new TimeoutException()); + + private static Task> PendingSome => Task.FromResult(Some); + private static Task> PendingNone => Task.FromResult(None); + private static Task> PendingOptionFault => Task.FromException>(new TimeoutException()); private static Task> PendingCommandFault => Task.FromException>(new TimeoutException()); private static readonly Dictionary[]> Cases = new() @@ -50,6 +54,20 @@ public sealed class NullDelegateTests ["Option.Tap(Action): action"] = [() => Some.Tap(Null>()), () => None.Tap(Null>())], ["Option.Filter(Func): predicate"] = [() => Some.Filter(Null>()), () => None.Filter(Null>())], + ["Option.MapAsync(Func>): selector"] = [() => Some.MapAsync(Null>>()), () => None.MapAsync(Null>>())], + ["Option.BindAsync(Func>>): selector"] = [() => Some.BindAsync(Null>>>()), () => None.BindAsync(Null>>>())], + ["Option.TapAsync(Func): action"] = [() => Some.TapAsync(Null>()), () => None.TapAsync(Null>())], + + ["Option.MapAsync(Task>, Func): selector"] = [() => PendingSome.MapAsync(Null>()), () => PendingNone.MapAsync(Null>()), () => PendingOptionFault.MapAsync(Null>())], + ["Option.MapAsync(Task>, Func>): selector"] = [() => PendingSome.MapAsync(Null>>()), () => PendingNone.MapAsync(Null>>()), () => PendingOptionFault.MapAsync(Null>>())], + ["Option.BindAsync(Task>, Func>): selector"] = + [() => PendingSome.BindAsync(Null>>()), () => PendingNone.BindAsync(Null>>()), () => PendingOptionFault.BindAsync(Null>>())], + ["Option.BindAsync(Task>, Func>>): selector"] = + [() => PendingSome.BindAsync(Null>>>()), () => PendingNone.BindAsync(Null>>>()), () => PendingOptionFault.BindAsync(Null>>>())], + ["Option.FilterAsync(Task>, Func): predicate"] = [() => PendingSome.FilterAsync(Null>()), () => PendingNone.FilterAsync(Null>()), () => PendingOptionFault.FilterAsync(Null>())], + ["Option.TapAsync(Task>, Action): action"] = [() => PendingSome.TapAsync(Null>()), () => PendingNone.TapAsync(Null>()), () => PendingOptionFault.TapAsync(Null>())], + ["Option.TapAsync(Task>, Func): action"] = [() => PendingSome.TapAsync(Null>()), () => PendingNone.TapAsync(Null>()), () => PendingOptionFault.TapAsync(Null>())], + ["Option.Select(Option, Func): selector"] = [() => Some.Select(Null>()), () => None.Select(Null>())], ["Option.SelectMany(Option, Func>): selector"] = [() => Some.SelectMany(Null>>()), () => None.SelectMany(Null>>())], ["Option.SelectMany(Option, Func>, Func): selector"] = @@ -68,8 +86,44 @@ public sealed class NullDelegateTests ["Option.Zip(Option, Option, Option, Option, Option, Option, Func): selector"] = [() => Some.Zip(Some, Some, Some, Some, Some, Null>()), () => None.Zip(Some, Some, Some, Some, Some, Null>())], ["Option.FirstOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.FirstOrNone(Null>()), () => Array.Empty().FirstOrNone(Null>())], + ["Option.SingleOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.SingleOrNone(Null>()), () => Array.Empty().SingleOrNone(Null>())], + ["Result.Traverse(Option, Func>): selector"] = + [() => Some.Traverse(Null>>()), () => None.Traverse(Null>>())], ["Option.LastOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.LastOrNone(Null>()), () => Array.Empty().LastOrNone(Null>())], + ["Patch.Match(Func, Func, Func): onValue"] = + [() => Patch.Value(1).Match(Null>(), () => 0, () => 0), () => Patch.Null().Match(Null>(), () => 0, () => 0), () => Patch.Absent().Match(Null>(), () => 0, () => 0)], + ["Patch.Match(Func, Func, Func): onNull"] = + [() => Patch.Value(1).Match(x => x, Null>(), () => 0), () => Patch.Null().Match(x => x, Null>(), () => 0), () => Patch.Absent().Match(x => x, Null>(), () => 0)], + ["Patch.Match(Func, Func, Func): onAbsent"] = + [() => Patch.Value(1).Match(x => x, () => 0, Null>()), () => Patch.Null().Match(x => x, () => 0, Null>()), () => Patch.Absent().Match(x => x, () => 0, Null>())], + ["Patch.Tap(Action): action"] = [() => { Patch.Value(1).Tap(Null>()); return null; }, () => { Patch.Null().Tap(Null>()); return null; }, () => { Patch.Absent().Tap(Null>()); return null; }], + ["Patch.IfPresent(Patch, Action): action"] = [() => { Patch.Value("a").IfPresent(Null>()); return null; }, () => { Patch.Null().IfPresent(Null>()); return null; }, () => { Patch.Absent().IfPresent(Null>()); return null; }], + ["Patch.Map(Func): selector"] = [() => Patch.Value(1).Map(Null>()), () => Patch.Null().Map(Null>()), () => Patch.Absent().Map(Null>())], + ["Result.Traverse(Patch, Func>): selector"] = + [() => Patch.Value(1).Traverse(Null>>()), () => Patch.Null().Traverse(Null>>()), () => Patch.Absent().Traverse(Null>>())], + ["SequenceOptionExtensions.Choose(IEnumerable, Func>): selector"] = [() => new[] { 1 }.Choose(Null>>()), () => Array.Empty().Choose(Null>>())], + ["Result.TraverseAsync(IEnumerable, Func>>): selector"] = + [() => new[] { 1 }.TraverseAsync(Null>>>()), () => Array.Empty().TraverseAsync(Null>>>())], + ["Result.TraverseAsync(IEnumerable, Func>>, CancellationToken): selector"] = + [() => new[] { 1 }.TraverseAsync(Null>>>(), CancellationToken.None), () => Array.Empty().TraverseAsync(Null>>>(), CancellationToken.None)], + ["Result.TraverseAsync(IEnumerable, Func>>): selector"] = + [() => new[] { 1 }.TraverseAsync(Null>>>()), () => Array.Empty().TraverseAsync(Null>>>())], + ["Result.TraverseAsync(IEnumerable, Func>>, CancellationToken): selector"] = + [() => new[] { 1 }.TraverseAsync(Null>>>(), CancellationToken.None), () => Array.Empty().TraverseAsync(Null>>>(), CancellationToken.None)], + ["Patch.IfPresent(Patch, Action>): action"] = [() => { Patch.Value(1).IfPresent(Null>()); return null; }, () => { Patch.Null().IfPresent(Null>()); return null; }, () => { Patch.Absent().IfPresent(Null>()); return null; }], + + // Catch has no branches: the delegate is checked before anything runs. Overloads that differ + // only in type parameters are keyed with them. + ["Result.Catch(Func): action"] = [() => Result.Catch(Null>())], + ["Result.Catch(Action): action"] = [() => Result.Catch(Null())], + ["Result.Catch(Func): action"] = [() => Result.Catch(Null>())], + ["Result.Catch(Action): action"] = [() => Result.Catch(Null())], + ["Result.CatchAsync(Func>): action"] = [() => Result.CatchAsync(Null>>())], + ["Result.CatchAsync(Func): action"] = [() => Result.CatchAsync(Null>())], + ["Result.CatchAsync(Func>): action"] = [() => Result.CatchAsync(Null>>())], + ["Result.CatchAsync(Func): action"] = [() => Result.CatchAsync(Null>())], + ["Result.Match(Func, Func): onSuccess"] = [() => Ok.Match(Null>(), _ => 0), () => Failed.Match(Null>(), _ => 0)], ["Result.Match(Func, Func): onError"] = [() => Ok.Match(() => 0, Null>()), () => Failed.Match(() => 0, Null>())], ["Result.Match(Func, Func): onSuccess"] = [() => Ok.Match(Null>(), () => 0), () => Failed.Match(Null>(), () => 0)], @@ -216,15 +270,31 @@ public void Every_delegate_parameter_of_the_assembly_has_a_case() Cases.Keys.Except(parameters).ShouldBeEmpty("These cases name no public method of the assembly."); } - private static IEnumerable DelegateParameters() => - from type in typeof(Option<>).Assembly.GetExportedTypes() - // The compiler's grouping types for extension blocks carry skeleton members; the callable - // methods are the static ones on the enclosing class. - where !type.Name.Contains('<') && type.GetCustomAttribute() is null - from method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) - from parameter in method.GetParameters() - where typeof(Delegate).IsAssignableFrom(parameter.ParameterType) - select $"{Describe(type)}.{method.Name}({string.Join(", ", method.GetParameters().Select(p => Describe(p.ParameterType)))}): {parameter.Name}"; + private static IEnumerable DelegateParameters() + { + var methods = + from type in typeof(Option<>).Assembly.GetExportedTypes() + // The compiler's grouping types for extension blocks carry skeleton members; the callable + // methods are the static ones on the enclosing class. + where !type.Name.Contains('<') && type.GetCustomAttribute() is null + from method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + // An overload that exists to be a compile error (Catch over a Task) cannot be called. + where method.GetCustomAttribute() is not { IsError: true } + select (Type: type, Method: method, Signature: $"({string.Join(", ", method.GetParameters().Select(p => Describe(p.ParameterType)))})"); + + // Overloads that differ only in their type parameters (Catch beside Catch) + // are keyed with them; every other method keeps its bare name. + var byKey = methods.ToLookup(m => $"{Describe(m.Type)}.{m.Method.Name}{m.Signature}"); + + return from overloads in byKey + from m in overloads + let name = overloads.Count() > 1 && m.Method.IsGenericMethod + ? $"{m.Method.Name}<{string.Join(", ", m.Method.GetGenericArguments().Select(a => a.Name))}>" + : m.Method.Name + from parameter in m.Method.GetParameters() + where typeof(Delegate).IsAssignableFrom(parameter.ParameterType) + select $"{Describe(m.Type)}.{name}{m.Signature}: {parameter.Name}"; + } private static string Describe(Type type) => type.IsByRef ? Describe(type.GetElementType()!) diff --git a/test/CodoMetis.TypeKit.Tests/NullTaskTests.cs b/test/CodoMetis.TypeKit.Tests/NullTaskTests.cs new file mode 100644 index 0000000..b203c37 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/NullTaskTests.cs @@ -0,0 +1,55 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// A delegate that returns no task is refused by every asynchronous combinator, as CatchAsync +/// refuses it: an naming the combinator and the delegate, and +/// linking the README. Awaited, the null was a naming nothing. +/// +public sealed class NullTaskTests +{ + private static readonly Option Some = Option.Some(1); + private static readonly Result Success = Result.Success(1); + private static readonly Result Failure = Result.Error("e"); + private static readonly Result CommandSuccess = Result.Success(); + private static readonly Result CommandFailure = Result.Error("e"); + private static readonly int[] Elements = [1, 2]; + + public static TheoryData Calls => [.. Call.Keys]; + + private static readonly Dictionary Run)> Call = new() + { + ["TraverseAsync(selector)"] = ("TraverseAsync", "selector", () => Elements.TraverseAsync(_ => (Task>)null!)), + ["TraverseAsync(selector, token)"] = ("TraverseAsync", "selector", () => Elements.TraverseAsync((_, _) => (Task>)null!, CancellationToken.None)), + ["command TraverseAsync(selector)"] = ("TraverseAsync", "selector", () => Elements.TraverseAsync(_ => (Task>)null!)), + ["command TraverseAsync(selector, token)"] = ("TraverseAsync", "selector", () => Elements.TraverseAsync((_, _) => (Task>)null!, CancellationToken.None)), + ["Option.MapAsync"] = ("MapAsync", "selector", () => Some.MapAsync(_ => (Task)null!)), + ["Option.BindAsync"] = ("BindAsync", "selector", () => Some.BindAsync(_ => (Task>)null!)), + ["Option.TapAsync"] = ("TapAsync", "action", () => Some.TapAsync(_ => null!)), + ["Result.MapAsync"] = ("MapAsync", "selector", () => Success.MapAsync(_ => (Task)null!)), + ["Result.BindAsync"] = ("BindAsync", "selector", () => Success.BindAsync(_ => (Task>)null!)), + ["Result.BindAsync to a command"] = ("BindAsync", "selector", () => Success.BindAsync(_ => (Task>)null!)), + ["Result.TapAsync"] = ("TapAsync", "action", () => Success.TapAsync(_ => null!)), + ["Result.TapErrorAsync"] = ("TapErrorAsync", "action", () => Failure.TapErrorAsync(_ => null!)), + ["Result.RecoverAsync"] = ("RecoverAsync", "selector", () => Failure.RecoverAsync(_ => null!)), + ["Result.MapAsync"] = ("MapAsync", "selector", () => CommandSuccess.MapAsync(() => (Task)null!)), + ["Result.BindAsync"] = ("BindAsync", "selector", () => CommandSuccess.BindAsync(() => null!)), + ["Result.TapAsync"] = ("TapAsync", "action", () => CommandSuccess.TapAsync(() => null!)), + ["Result.TapErrorAsync"] = ("TapErrorAsync", "action", () => CommandFailure.TapErrorAsync(_ => null!)), + ["Result.RecoverAsync"] = ("RecoverAsync", "selector", () => CommandFailure.RecoverAsync(_ => null!)), + ["a pending Option's MapAsync"] = ("MapAsync", "selector", () => Task.FromResult(Some).MapAsync(_ => (Task)null!)), + ["a pending Result's BindAsync"] = ("BindAsync", "selector", () => Task.FromResult(Success).BindAsync(_ => (Task>)null!)), + }; + + [Theory] + [MemberData(nameof(Calls))] + public async Task A_delegate_that_returns_no_task_is_refused_by_name(string call) + { + var (method, parameter, run) = Call[call]; + + var refusal = await Should.ThrowAsync(run); + + refusal.ParamName.ShouldBe(parameter); + refusal.Message.ShouldStartWith($"The delegate given to {method} returned no task"); + refusal.Message.ShouldContain("#a-null-in-an-option-or-a-result"); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/OptionAsyncTests.cs b/test/CodoMetis.TypeKit.Tests/OptionAsyncTests.cs new file mode 100644 index 0000000..06d9cdc --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/OptionAsyncTests.cs @@ -0,0 +1,92 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// An option's asynchronous steps, and the continuations of a Task<Option<T>>: a +/// repository's lookup continues as a pending result does, where MapAsync on one failed to +/// infer its type arguments against the Result overloads (CS0411) and nothing else existed. +/// +public sealed class OptionAsyncTests +{ + private static Task> PendingSome => Task.FromResult(Option.Some(2)); + private static Task> PendingNone => Task.FromResult(Option.None()); + + private static async Task> LaterAsync(Option option) + { + await Task.Yield(); + return option; + } + + [Fact] + public async Task A_pending_option_maps_binds_and_filters_once_known() + { + (await PendingSome.MapAsync(x => x * 10)).ShouldBe(Option.Some(20)); + (await PendingSome.MapAsync(async x => { await Task.Yield(); return x + 1; })).ShouldBe(Option.Some(3)); + (await PendingSome.BindAsync(x => x > 1 ? Option.Some(x.ToString()) : Option.None())).ShouldBe(Option.Some("2")); + (await PendingSome.BindAsync(x => LaterAsync(Option.Some(x + 5)))).ShouldBe(Option.Some(7)); + (await PendingSome.FilterAsync(x => x > 5)).ShouldBe(Option.None()); + (await LaterAsync(Option.Some(9)).FilterAsync(x => x > 5)).ShouldBe(Option.Some(9)); + } + + [Fact] + public async Task None_passes_through_without_calling_anything() + { + static int Never(int _) => throw new InvalidOperationException("called on None"); + + (await PendingNone.MapAsync(Never)).ShouldBe(Option.None()); + (await PendingNone.MapAsync(x => Task.FromResult(Never(x)))).ShouldBe(Option.None()); + (await PendingNone.BindAsync(x => Option.Some(Never(x)))).ShouldBe(Option.None()); + (await PendingNone.BindAsync(x => Task.FromResult(Option.Some(Never(x))))).ShouldBe(Option.None()); + (await PendingNone.FilterAsync(x => Never(x) > 0)).ShouldBe(Option.None()); + (await PendingNone.TapAsync(x => { Never(x); })).ShouldBe(Option.None()); + (await PendingNone.TapAsync(x => Task.FromResult(Never(x)))).ShouldBe(Option.None()); + (await Option.None().MapAsync(x => Task.FromResult(Never(x)))).ShouldBe(Option.None()); + } + + /// A side effect that returns a task is waited for: it has run when the chain's await returns. + [Fact] + public async Task An_asynchronous_side_effect_is_waited_for() + { + var seen = 0; + + var option = await PendingSome.TapAsync(async x => { await Task.Delay(10); seen = x; }); + + seen.ShouldBe(2); + option.ShouldBe(Option.Some(2)); + (await Option.Some(4).TapAsync(async x => { await Task.Yield(); seen = x; })).ShouldBe(Option.Some(4)); + seen.ShouldBe(4); + } + + [Fact] + public async Task ToResultAsync_turns_absence_into_the_error() + { + (await PendingSome.ToResultAsync("missing")).ShouldBe(Result.Success(2)); + (await PendingNone.ToResultAsync("missing")).ShouldBe(Result.Error("missing")); + } + + /// The error is refused whether or not the option has a value, and before the task is awaited, as ToResult refuses it. + [Fact(Timeout = 10_000)] + public async Task ToResultAsync_refuses_a_null_error_before_the_option_is_known() + { + // Never completes: only a check made before the await can answer. The test's token ends it if not. + var never = new TaskCompletionSource>().Task.WaitAsync(TestContext.Current.CancellationToken); + + (await Should.ThrowAsync(() => never.ToResultAsync((string)null!))).ParamName.ShouldBe("error"); + await Should.ThrowAsync(() => PendingSome.ToResultAsync((string)null!)); + } + + [Fact] + public async Task A_selector_whose_task_produces_null_is_refused() + { + await Should.ThrowAsync(() => Option.Some(1).MapAsync(_ => Task.FromResult((string)null!))); + await Should.ThrowAsync(() => PendingSome.MapAsync(_ => (string)null!)); + } + + [Fact] + public async Task A_faulted_lookup_surfaces_its_own_exception() + { + var faulted = Task.FromException>(new TimeoutException()); + + await Should.ThrowAsync(() => faulted.MapAsync(x => x)); + await Should.ThrowAsync(() => faulted.ToResultAsync("missing")); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/OptionLookupTests.cs b/test/CodoMetis.TypeKit.Tests/OptionLookupTests.cs new file mode 100644 index 0000000..57c55a8 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/OptionLookupTests.cs @@ -0,0 +1,115 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// SingleOrNone and ElementAtOrNone, the options CMTK0009 has to offer for +/// SingleOrDefault and ElementAtOrDefault, and Transpose/Traverse between +/// an option and a result, for an optional input that is validated when present. +/// +public sealed class OptionLookupTests +{ + private enum Fault { Refused } + + [Fact] + public void SingleOrNone_answers_the_only_element_or_None_and_refuses_more_than_one() + { + new[] { 7 }.SingleOrNone().ShouldBe(Option.Some(7)); + Array.Empty().SingleOrNone().ShouldBe(Option.None()); + new[] { 1, 2, 3 }.SingleOrNone(x => x == 2).ShouldBe(Option.Some(2)); + new[] { 1, 2, 3 }.SingleOrNone(x => x > 5).ShouldBe(Option.None()); + + // More than one is a defect, not absence: as SingleOrDefault, it throws. + Should.Throw(() => new[] { 1, 2 }.SingleOrNone()).Message.ShouldContain("FirstOrNone"); + Should.Throw(() => new[] { 1, 2, 3 }.SingleOrNone(x => x > 1)); + } + + [Fact] + public void ElementAtOrNone_answers_the_element_or_None_past_either_end() + { + IReadOnlyList list = [10, 20, 30]; + IEnumerable sequence = Enumerable.Range(1, 3).Select(x => x * 10); + + list.ElementAtOrNone(1).ShouldBe(Option.Some(20)); + sequence.ElementAtOrNone(2).ShouldBe(Option.Some(30)); + list.ElementAtOrNone(3).ShouldBe(Option.None()); + sequence.ElementAtOrNone(3).ShouldBe(Option.None()); + list.ElementAtOrNone(-1).ShouldBe(Option.None()); + sequence.ElementAtOrNone(-1).ShouldBe(Option.None()); + list.ElementAtOrNone(^1).ShouldBe(Option.Some(30)); + sequence.ElementAtOrNone(^3).ShouldBe(Option.Some(10)); + sequence.ElementAtOrNone(^1).ShouldBe(Option.Some(30)); + list.ElementAtOrNone(^4).ShouldBe(Option.None()); + sequence.ElementAtOrNone(^4).ShouldBe(Option.None()); + sequence.ElementAtOrNone(^0).ShouldBe(Option.None()); + sequence.ElementAtOrNone(new Index(1)).ShouldBe(Option.Some(20)); + } + + /// On a query, a negative index is None without a query: Skip(-1) skips nothing and would answer the first element. + [Fact] + public void On_a_query_they_answer_as_on_a_sequence() + { + var query = new[] { 10, 20, 30 }.AsQueryable(); + + query.SingleOrNone(x => x == 20).ShouldBe(Option.Some(20)); + query.SingleOrNone(x => x > 99).ShouldBe(Option.None()); + Should.Throw(() => query.SingleOrNone()); + query.ElementAtOrNone(2).ShouldBe(Option.Some(30)); + query.ElementAtOrNone(3).ShouldBe(Option.None()); + query.ElementAtOrNone(-1).ShouldBe(Option.None()); + } + + [Fact] + public void Transpose_turns_an_optional_result_inside_out() + { + Option.None>().Transpose().ShouldBe(Result, Fault>.Success(Option.None())); + Option.Some(Result.Success(3)).Transpose().ShouldBe(Result, Fault>.Success(Option.Some(3))); + Option.Some(Result.Error(Fault.Refused)).Transpose().ShouldBe(Result, Fault>.Error(Fault.Refused)); + } + + [Fact] + public void Transpose_turns_a_result_of_an_option_inside_out() + { + Result, Fault>.Success(Option.None()).Transpose().ShouldBe(Option.None>()); + Result, Fault>.Success(Option.Some(3)).Transpose().ShouldBe(Option.Some(Result.Success(3))); + Result, Fault>.Error(Fault.Refused).Transpose().ShouldBe(Option.Some(Result.Error(Fault.Refused))); + } + + [Fact] + public void Traverse_validates_an_optional_input_only_when_present() + { + static Result Create(string text) => text.Length > 0 ? Result.Success(text) : Result.Error(Fault.Refused); + + Option.Some("a").Traverse(Create).ShouldBe(Result, Fault>.Success(Option.Some("a"))); + Option.Some("").Traverse(Create).ShouldBe(Result, Fault>.Error(Fault.Refused)); + Option.None().Traverse(_ => throw new InvalidOperationException("called on None")) + .ShouldBe(Result, Fault>.Success(Option.None())); + } + + /// An uninitialized result inside an option is a bug at the site that made it, refused as everywhere else. + [Fact] + public void An_uninitialized_result_inside_is_refused() + { + Should.Throw(() => Option.Some(default(Result)).Transpose()); + Should.Throw(() => Option.Some(1).Traverse(_ => default(Result))); + Should.Throw(() => default(Result, Fault>).Transpose()); + } + + /// A method group infers both type arguments, the shape the README shows. + [Fact] + public void Traverse_infers_from_a_method_group() => + ConsumerCompilation.Of( + """ + using CodoMetis.TypeKit; + + enum PhoneFault { Malformed } + + readonly record struct Phone(string Text) + { + public static Result Create(string text) => new Phone(text); + } + + static class Consumer + { + static Result, PhoneFault> Read(string? phone) => phone.ToOption().Traverse(Phone.Create); + } + """).Problems().ShouldBeEmpty(); +} diff --git a/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs b/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs new file mode 100644 index 0000000..e60b615 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs @@ -0,0 +1,77 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// Map and Traverse over a : a field that carried a value is +/// mapped or validated, and a clear or an absent field passes through as it is. A patch over what the +/// request sent becomes a patch over the domain's type, with the validation's own fault. +/// +public sealed class PatchCombinationTests +{ + private static Result Positive(int n) => n > 0 ? n : Result.Error($"{n} is not positive"); + + private static string Show(Patch patch) where T : notnull => patch.Match(value => $"Value({value})", () => "Null", () => "Absent"); + + [Fact] + public void Map_changes_the_value_and_keeps_a_clear_and_an_absence() + { + Show(Patch.Value(2).Map(n => n * 10)).ShouldBe("Value(20)"); + Show(Patch.Null().Map(n => n * 10)).ShouldBe("Null"); + Show(Patch.Absent().Map(n => n * 10)).ShouldBe("Absent"); + + Show(Patch.Value("4").Map(int.Parse)).ShouldBe("Value(4)"); + } + + [Fact] + public void Map_runs_the_selector_for_a_value_only() + { + var calls = 0; + + Patch.Null().Map(n => ++calls); + Patch.Absent().Map(n => ++calls); + calls.ShouldBe(0); + + Patch.Value(1).Map(n => ++calls); + calls.ShouldBe(1); + } + + /// A clear is the Null state, never a null value: a selector that answers null is refused, as Option.Map's is. + [Fact] + public void Map_refuses_a_selector_that_returns_null() + { + var refusal = Should.Throw(() => Patch.Value(1).Map(_ => (string)null!)); + + refusal.ParamName.ShouldBe("selector"); + refusal.Message.ShouldContain("Patch.Null()"); + } + + [Fact] + public void Traverse_validates_a_value_and_keeps_its_fault() + { + Result, string> valid = Patch.Value(3).Traverse(Positive); + valid.Match(Show, error => error).ShouldBe("Value(3)"); + + Result, string> refused = Patch.Value(-3).Traverse(Positive); + refused.Match(Show, error => error).ShouldBe("-3 is not positive"); + } + + [Fact] + public void Traverse_passes_a_clear_and_an_absence_through_as_successes() + { + var calls = 0; + Result Counted(int n) { calls++; return Positive(n); } + + Patch.Null().Traverse(Counted).Match(Show, error => error).ShouldBe("Null"); + Patch.Absent().Traverse(Counted).Match(Show, error => error).ShouldBe("Absent"); + calls.ShouldBe(0, "there is nothing to validate"); + } + + /// The usual selector is a value object's Create, a method group over the wrapped type. + [Fact] + public void Traverse_takes_a_factory_as_a_method_group() + { + static Result Trimmed(string text) => text.Trim().Length > 0 ? text.Trim() : Result.Error("blank"); + + Patch.Value(" a ").Traverse(Trimmed).Match(Show, error => error).ShouldBe("Value(a)"); + Patch.Value(" ").Traverse(Trimmed).Match(Show, error => error).ShouldBe("blank"); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/PatchNullabilityTests.cs b/test/CodoMetis.TypeKit.Tests/PatchNullabilityTests.cs new file mode 100644 index 0000000..b61b9c2 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/PatchNullabilityTests.cs @@ -0,0 +1,119 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// What a consumer's compiler says about and nullability. A value is never +/// null, a clear being the Null state, so every member hands the value as the value's type; over a +/// nullable type it handed a string? to Match, Tap, Map and Traverse, and +/// p.Map(s => s.Trim()) on a Patch<string?> warned (CS8602) while +/// Traverse(Email.Create) did not match (CS8622), in a build where warnings are errors. +/// +/// +/// Overload resolution ignores nullability, so no overload hands a Patch<string?> its value as a +/// string without warning a Patch<string> (design/patch.md has the measurements). T +/// is notnull instead, as an option's is: the nullable declaration is warned, once, where it is written. +/// +public sealed class PatchNullabilityTests +{ + private const string Types = """ + using System; + using CodoMetis.TypeKit; + + public enum F { Bad } + public readonly record struct Code(string Value); + public sealed record Name(string Value); + """; + + /// Each member, on a patch over , its value used as non-null. + private static string EveryMember(string type, string use, string check) => + $$""" + {{Types}} + + public static class Checks + { + static Result<{{type}}, F> Check({{type}} value) => {{check}} ? value : Result.Error(F.Bad); + + public static void Run(Patch<{{type}}> patch) + { + Patch mapped = patch.Map(value => {{use}}); + Result, F> validated = patch.Traverse(Check); + int matched = patch.Match(value => {{use}}, () => 0, () => -1); + if (patch.TryGetValue(out var held)) _ = {{use.Replace("value", "held")}}; + patch.Tap(value => _ = {{use}}); + Option<{{type}}> option = patch.ToOption(); + Patch<{{type}}> same = patch.IfPresent(value => _ = value); + } + } + """; + + [Theory] + [InlineData("string", "value.Length", "value.Length > 0")] + [InlineData("Name", "value.Value.Length", "value.Value.Length > 0")] + [InlineData("int", "value + 1", "value > 0")] + [InlineData("Code", "value.Value.Length", "value.Value.Length > 0")] + public void Every_member_hands_the_value_as_the_value_s_type_without_a_warning(string type, string use, string check) => + ConsumerCompilation.Of(EveryMember(type, use, check)).Problems().ShouldBeEmpty(); + + /// + /// The one place a nullable type argument is told: its declaration, each of them, whether a + /// property, a parameter or a factory's type argument. + /// + [Fact] + public void A_patch_over_a_nullable_type_is_warned_where_it_is_declared() + { + var compilation = ConsumerCompilation.Of($$""" + {{Types}} + + public sealed class Request + { + public Patch Nickname { get; init; } + public Patch Owner { get; init; } + public Patch Floor { get; init; } + } + + public static class Clears + { + public static Patch Nickname() => Patch.Null(); + } + """); + + var warnings = compilation.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning).ToList(); + + warnings.Select(warning => warning.Id).Distinct().ShouldBe(["CS8714"], compilation.Problems()); + warnings.Select(warning => warning.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).ShouldContain(message => message.Contains("'string?'"), compilation.Problems()); + warnings.Select(warning => warning.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).ShouldContain(message => message.Contains("'Name?'"), compilation.Problems()); + warnings.Select(warning => warning.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).ShouldContain(message => message.Contains("'int?'"), compilation.Problems()); + warnings.Count.ShouldBe(5, compilation.Problems()); + } + + /// + /// Which member each call binds to, so that a member added later is seen to change a binding: the + /// clear reaches IfPresent as null, through T?, over a class and over a struct. + /// + [Theory] + [InlineData("string", "System.Action")] + [InlineData("Name", "System.Action")] + [InlineData("int", "System.Action")] + [InlineData("Code", "System.Action")] + public void IfPresent_hands_a_clear_over_as_null_over_a_class_and_a_struct(string type, string action) + { + var compilation = ConsumerCompilation.Of($$""" + {{Types}} + + public static class Checks + { + public static void Run(Patch<{{type}}> patch) => patch.IfPresent(value => { }); + } + """); + + compilation.Problems().ShouldBeEmpty(); + + var tree = compilation.SyntaxTrees.Single(); + var call = tree.GetRoot(TestContext.Current.CancellationToken).DescendantNodes().OfType().Single(); + var method = (IMethodSymbol)compilation.GetSemanticModel(tree).GetSymbolInfo(call, TestContext.Current.CancellationToken).Symbol!; + + method.Parameters.Single().Type.ToDisplayString().ShouldBe(action); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/PatchTests.cs b/test/CodoMetis.TypeKit.Tests/PatchTests.cs new file mode 100644 index 0000000..a9dd0f4 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/PatchTests.cs @@ -0,0 +1,292 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// , a JSON Merge Patch field: absent, null or a value. Measured first in +/// spikes/MergePatch, under the reflection resolver and a source-generated context, and under +/// Native AOT; each claim there is a test here, Native AOT in the consumer smoke test. +/// +public sealed class PatchTests +{ + private static readonly JsonSerializerOptions Reflection = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + private static readonly JsonSerializerOptions Generated = new(JsonSerializerDefaults.Web) { TypeInfoResolver = PatchJson.Default }; + + public static TheoryData Resolvers => ["reflection", "source-generated"]; + + private static JsonSerializerOptions Options(string resolver) => resolver == "reflection" ? Reflection : Generated; + + private static string Show(Patch patch) where T : notnull => patch.Match(value => $"Value({value})", () => "Null", () => "Absent"); + + [Fact] + public void Default_is_absent_and_the_states_are_told_apart() + { + Patch absent = default; + + absent.State.ShouldBe(PatchState.Absent); + Patch.Absent().ShouldBe(absent); + Patch.Null().State.ShouldBe(PatchState.Null); + Patch.Value(3).State.ShouldBe(PatchState.Value); + Patch.Value(null).State.ShouldBe(PatchState.Null, "a null value is the Null state"); + } + + [Fact] + public void Match_TryGetValue_Tap_and_IfPresent_follow_the_state() + { + var seen = new List(); + + Show(Patch.Value("Ada")).ShouldBe("Value(Ada)"); + Show(Patch.Null()).ShouldBe("Null"); + Show(Patch.Absent()).ShouldBe("Absent"); + + Patch.Value("Ada").TryGetValue(out var value).ShouldBeTrue(); + value.ShouldBe("Ada"); + Patch.Null().TryGetValue(out _).ShouldBeFalse(); + + Patch.Value("Ada").Tap(seen.Add); + Patch.Null().Tap(seen.Add); + Patch.Absent().Tap(seen.Add); + seen.ShouldBe(["Ada"]); + + seen.Clear(); + Patch.Value("Ada").IfPresent(v => seen.Add(v ?? "")); + Patch.Null().IfPresent(v => seen.Add(v ?? "")); + Patch.Absent().IfPresent(v => seen.Add(v ?? "")); + seen.ShouldBe(["Ada", ""]); + } + + /// + /// One name per concept (decision 18): a side effect on the value is Tap, as on Option + /// and Result, and returns the patch as theirs return the receiver. It was IfValue, + /// returning nothing, and IfPresent, the form a clear reaches, returned nothing beside it. + /// + [Fact] + public void The_side_effects_are_named_and_shaped_as_Option_s_Tap() + { + typeof(Patch).GetMethods().Select(method => method.Name).ShouldNotContain("IfValue"); + typeof(Patch).GetMethod(nameof(Patch.Tap))!.ReturnType.ShouldBe(typeof(Patch)); + typeof(Option).GetMethod(nameof(Option.Tap))!.ReturnType.ShouldBe(typeof(Option)); + typeof(Patch).GetMethods() + .Where(method => method.Name == "IfPresent") + .ShouldAllBe(method => method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(Patch<>)); + typeof(Patch).GetMethods().Count(method => method.Name == "IfPresent").ShouldBe(2); + } + + [Fact] + public void Tap_and_IfPresent_return_the_patch_unchanged() + { + foreach (var patch in new[] { Patch.Value("Ada"), Patch.Null(), Patch.Absent() }) + { + patch.Tap(_ => { }).ShouldBe(patch); + patch.IfPresent(_ => { }).ShouldBe(patch); + } + + Patch.Value(3).IfPresent(_ => { }).ShouldBe(Patch.Value(3)); + Patch.Null().IfPresent(_ => { }).ShouldBe(Patch.Null()); + } + + /// + /// A clear over a struct reaches the action as null. It reached it as default(T): a quantity + /// cleared to zero, or a struct value object handed over uninitialized. + /// + [Fact] + public void IfPresent_over_a_struct_clears_with_null_never_with_default() + { + int? quantity = 5; + + Patch.Null().IfPresent(q => quantity = q); + quantity.ShouldBeNull(); + + Patch.Value(3).IfPresent(q => quantity = q); + quantity.ShouldBe(3); + + Patch.Absent().IfPresent(q => quantity = q); + quantity.ShouldBe(3); + } + + [Fact] + public void ToOption_is_Some_for_a_value_and_None_for_a_clear_or_an_absence() + { + Patch.Value(3).ToOption().ShouldBe(Option.Some(3)); + Patch.Null().ToOption().IsNone().ShouldBeTrue(); + Patch.Absent().ToOption().IsNone().ShouldBeTrue(); + } + + [Fact] + public void Equality_is_by_state_and_value_and_ToString_prints_the_state_only() + { + Patch.Value("secret").ShouldBe(Patch.Value("secret")); + Patch.Value("secret").ShouldNotBe(Patch.Value("other")); + Patch.Null().ShouldNotBe(Patch.Absent()); + + Patch.Value("secret").ToString().ShouldBe("Patch { State = Value }"); + Patch.Absent().ToString().ShouldBe("Patch { State = Absent }"); + } + + [Theory] + [MemberData(nameof(Resolvers))] + public void An_absent_property_reads_as_Absent_a_null_as_Null_and_a_value_as_Value(string resolver) + { + var options = Options(resolver); + + var absent = JsonSerializer.Deserialize("{}", options)!; + Show(absent.Name).ShouldBe("Absent"); + Show(absent.Quantity).ShouldBe("Absent"); + Show(absent.Code).ShouldBe("Absent"); + Show(absent.Address).ShouldBe("Absent"); + + var nulls = JsonSerializer.Deserialize("""{"name":null,"quantity":null,"code":null,"address":null}""", options)!; + Show(nulls.Name).ShouldBe("Null"); + Show(nulls.Quantity).ShouldBe("Null"); + Show(nulls.Code).ShouldBe("Null"); + Show(nulls.Address).ShouldBe("Null"); + + var values = JsonSerializer.Deserialize("""{"name":"Ada","quantity":3,"code":"ABC","address":{"city":"Oslo"}}""", options)!; + Show(values.Name).ShouldBe("Value(Ada)"); + Show(values.Quantity).ShouldBe("Value(3)"); + Show(values.Code).ShouldBe("Value(ABC)"); + Show(values.Address).ShouldBe("Value(Address { City = Oslo })"); + } + + /// A type with a converter of its own, a value object's shape, is read with it, whatever the resolver; what it refuses is refused as anywhere else, the input not quoted. + [Theory] + [MemberData(nameof(Resolvers))] + public void A_type_with_its_own_converter_inside_a_patch_is_read_with_it_and_its_refusal_stands(string resolver) + { + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"code":"abc"}""", Options(resolver))); + + refusal.Message.ShouldContain("Code refused"); + refusal.Message.ShouldNotContain("abc"); + } + + /// + /// The converter reads the value with a nested call, which reports the path within the value it + /// reads, $, and the serializer kept it: an HTTP 400 for a bad field of a PATCH could not name + /// the field. The path is the patched property's now, as for a property of the wrapped type, and the + /// message names no other. + /// + [Theory] + [MemberData(nameof(Resolvers))] + public void A_value_the_wrapped_type_cannot_read_is_refused_at_the_property_s_path(string resolver) + { + var options = Options(resolver); + + var plain = Should.Throw(() => JsonSerializer.Deserialize("""{"quantity":"SECRET-7f3a"}""", options)); + var patch = Should.Throw(() => JsonSerializer.Deserialize("""{"quantity":"SECRET-7f3a"}""", options)); + + plain.Path.ShouldBe("$.quantity"); + patch.Path.ShouldBe(plain.Path); + patch.LineNumber.ShouldBe(plain.LineNumber); + patch.BytePositionInLine.ShouldBe(plain.BytePositionInLine); + patch.Message.ShouldStartWith("The JSON value could not be converted to System.Int32."); + patch.Message.ShouldNotContain("Path:"); + patch.Message.ShouldNotContain("SECRET"); + } + + /// A value object's own refusal is the message the caller sees, at the property's path. + [Theory] + [MemberData(nameof(Resolvers))] + public void A_value_object_s_refusal_inside_a_patch_keeps_its_message_at_the_property_s_path(string resolver) + { + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"name":"Ada","code":"abc"}""", Options(resolver))); + + refusal.Path.ShouldBe("$.code"); + refusal.Message.ShouldBe("Code refused the JSON value (NotUpperCase)."); + } + + /// In a list of patches the path names the element and its property. + [Theory] + [MemberData(nameof(Resolvers))] + public void A_refusal_in_an_array_of_patches_names_the_element(string resolver) + { + var options = Options(resolver); + + Should.Throw(() => JsonSerializer.Deserialize>("""[{}, {"quantity":"x"}]""", options)).Path.ShouldBe("$[1].quantity"); + Should.Throw(() => JsonSerializer.Deserialize>("""[{}, {"code":"abc"}]""", options)).Path.ShouldBe("$[1].code"); + } + + /// + /// A converter cannot extend the serializer's path, so inside a patched object the path ends at the + /// patched property: coarser than the property within, and never the nested call's `$.city`, which + /// names a property the document does not have at its root. + /// + [Theory] + [MemberData(nameof(Resolvers))] + public void Inside_a_patched_object_the_path_ends_at_the_patched_property(string resolver) + { + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"address":{"city":5}}""", Options(resolver))); + + refusal.Path.ShouldBe("$.address"); + refusal.Message.ShouldNotContain("Path:"); + } + + [Theory] + [MemberData(nameof(Resolvers))] + public void Writing_leaves_an_absent_patch_out_under_WhenWritingDefault_and_refuses_it_otherwise(string resolver) + { + var options = Options(resolver); + + JsonSerializer.Serialize(new Outgoing(Patch.Null(), Patch.Absent()), options).ShouldBe("""{"name":null}"""); + JsonSerializer.Serialize(new Outgoing(Patch.Value("Ada"), Patch.Value(2)), options).ShouldBe("""{"name":"Ada","quantity":2}"""); + + var refusal = Should.Throw(() => JsonSerializer.Serialize(new BareOutgoing(Patch.Absent()), options)); + refusal.Message.ShouldContain("WhenWritingDefault"); + } + + /// The patch's converter hides its argument from the source generator, so a class not listed in the context is the serializer's own refusal, naming the context. + [Fact] + public void A_class_the_source_generated_context_never_saw_is_refused_by_the_serializer_naming_the_context() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = WithoutAddressJson.Default }; + + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"address":{"city":"Oslo"}}""", options)); + + refusal.Message.ShouldContain(nameof(Address)); + refusal.Message.ShouldContain(nameof(WithoutAddressJson)); + } + + public sealed record Address(string City); + + /// A value object's shape: a struct with a converter of its own that refuses, naming the type and never the input. + [JsonConverter(typeof(CodeConverter))] + public readonly record struct Code + { + public string Value { get; } + public Code(string value) => Value = value == value.ToUpperInvariant() ? value : throw new JsonException("Code refused the JSON value (NotUpperCase)."); + public override string ToString() => Value; + } + + public sealed class CodeConverter : JsonConverter + { + public override Code Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => new(reader.GetString()!); + public override void Write(Utf8JsonWriter writer, Code value, JsonSerializerOptions options) => writer.WriteStringValue(value.Value); + } + + public sealed record Update(Patch Name, Patch Quantity, Patch Code, Patch
Address); + + /// The same field without a patch, whose refusal is the one a patch's should match. + public sealed record Plain(int Quantity); + + public sealed record UpdateWithoutAddress(Patch
Address); + + public sealed record Outgoing( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Name, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Quantity); + + public sealed record BareOutgoing(Patch Name); +} + +// A class a patch wraps is listed, since the patch's converter hides it from the generator; a primitive +// and a type with its own converter need not be. +[JsonSerializable(typeof(PatchTests.Update))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(PatchTests.Plain))] +[JsonSerializable(typeof(PatchTests.Address))] +[JsonSerializable(typeof(PatchTests.Outgoing))] +[JsonSerializable(typeof(PatchTests.BareOutgoing))] +internal partial class PatchJson : JsonSerializerContext; + +[JsonSerializable(typeof(PatchTests.UpdateWithoutAddress))] +internal partial class WithoutAddressJson : JsonSerializerContext; diff --git a/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs b/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs new file mode 100644 index 0000000..541ce49 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs @@ -0,0 +1,100 @@ +using Microsoft.CodeAnalysis; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// FirstOrNone and FirstOrError on an hand the condition and +/// the limit to the query provider. On the overloads, the only ones there +/// were, a query was read in full and its condition run in memory; the EF Core tests show the SQL. +/// +public sealed class QueryableOptionTests +{ + private static string Consumer(string member) => + $$""" + using System; + using System.Linq; + using CodoMetis.TypeKit; + + static class Consumer + { + static {{member}} + } + """; + + public static TheoryData Calls => new() + { + { "Option First(IQueryable query) => query.FirstOrNone(x => x > 1);", "Option" }, + { "Option First(IQueryable query) => query.FirstOrNone();", "Option" }, + { "Result First(IQueryable query) => query.FirstOrError(x => x > 1, \"none\");", "Result" }, + { "Option First(IOrderedQueryable query) => query.FirstOrNone(x => x > 1);", "Option" }, + }; + + [Theory] + [MemberData(nameof(Calls))] + public void A_query_binds_to_the_overload_that_takes_an_expression(string member, string declaringClass) + { + var compilation = ConsumerCompilation.Of(Consumer(member)); + compilation.Problems().ShouldBeEmpty(); + + var (_, method) = compilation.TheOnlyCall(); + + var block = method.ContainingType.ShouldNotBeNull(); + block.IsExtension.ShouldBeTrue(); + block.ContainingType.Name.ShouldBe(declaringClass); + block.ExtensionParameter.ShouldNotBeNull().Type.OriginalDefinition.ToDisplayString().ShouldBe("System.Linq.IQueryable"); + method.Parameters.Where(parameter => parameter.Name == "predicate") + .ShouldAllBe(parameter => parameter.Type.OriginalDefinition.ToDisplayString() == "System.Linq.Expressions.Expression"); + } + + /// A query has no last element until it is ordered; the overloads that would read every row are compile errors that name what to do instead. + [Theory] + [InlineData("Option Last(IQueryable query) => query.LastOrNone();", "FirstOrNone")] + [InlineData("Option Last(IQueryable query) => query.LastOrNone(x => x > 1);", "FirstOrNone")] + [InlineData("Result Last(IQueryable query) => query.LastOrError(x => x > 1, \"none\");", "FirstOrError")] + [InlineData("Option Last(IQueryable query) => query.ElementAtOrNone(^1);", "ElementAtOrNone(int)")] + public void LastOrNone_and_LastOrError_on_a_query_are_compile_errors(string member, string advice) + { + var compilation = ConsumerCompilation.Of(Consumer(member)); + + compilation.ErrorIds().ShouldBe(["CS0619"], compilation.Problems()); + compilation.Problems().ShouldContain(advice); + compilation.Problems().ShouldContain("AsEnumerable()"); + } + + [Fact] + public void Reading_a_query_on_purpose_still_has_LastOrNone() => + ConsumerCompilation.Of(Consumer("Option Last(IQueryable query) => query.AsEnumerable().LastOrNone();")).Problems().ShouldBeEmpty(); + + /// An index from the start is the query's own overload; one from the end, which has no query, is a compile error, and the sequence overload stays reachable on purpose. + [Fact] + public void ElementAtOrNone_from_the_start_is_the_query_s_and_from_the_end_needs_AsEnumerable() + { + var (_, fromStart) = ConsumerCompilation.Of(Consumer("Option At(IQueryable query) => query.ElementAtOrNone(2);")).TheOnlyCall(); + fromStart.ContainingType.ExtensionParameter.ShouldNotBeNull().Type.OriginalDefinition.ToDisplayString().ShouldBe("System.Linq.IQueryable"); + + ConsumerCompilation.Of(Consumer("Option At(IQueryable query) => query.AsEnumerable().ElementAtOrNone(^1);")).Problems().ShouldBeEmpty(); + } + + [Fact] + public void A_query_answers_its_first_match_or_None() + { + IQueryable query = new[] { 3, 1, 4, 1, 5 }.AsQueryable(); + + query.FirstOrNone().ShouldBe(Option.Some(3)); + query.FirstOrNone(x => x > 3).ShouldBe(Option.Some(4)); + query.FirstOrNone(x => x > 9).ShouldBe(Option.None()); + query.FirstOrError(x => x > 3, "none").ShouldBe(Result.Success(4)); + query.FirstOrError(x => x > 9, "none").ShouldBe(Result.Error("none")); + Array.Empty().AsQueryable().FirstOrNone().ShouldBe(Option.None()); + } + + [Fact] + public void A_null_first_element_a_null_condition_and_a_null_error_are_refused() + { + var query = new[] { (string)null! }.AsQueryable(); + + Should.Throw(() => query.FirstOrNone()); + Should.Throw(() => new[] { "a" }.AsQueryable().FirstOrNone(null!)).ParamName.ShouldBe("predicate"); + Should.Throw(() => new[] { "a" }.AsQueryable().FirstOrError(x => x == "b", (string)null!)).ParamName.ShouldBe("error"); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs index 3d63b42..2e0b615 100644 --- a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs +++ b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs @@ -41,6 +41,16 @@ public sealed partial class RefusalLinkTests private const string KnownGood = "FromKnownGood refused a value"; private const string Unreadable = "A value object could not read the input"; private const string NullToValueObject = "A null given to a value object"; + private const string ClosedSetMember = "A closed set has no member of that name"; + private const string ClosedSetNotAMember = "An instance that is not a member of its set"; + private const string ClosedSetInitializing = "A closed set read before it is initialized"; + private const string TaskGivenToCatch = "A task given to Catch"; + + /// + /// The one refusal no call in this process can make: AsText() refuses only in globalization-invariant + /// mode, so checks its link in a process started in that mode. + /// + internal const string TextWithoutComposition = "AsText in globalization-invariant mode"; private enum Fault { TooLong } @@ -48,6 +58,10 @@ private enum Fault { TooLong } private static readonly Dictionary Refusal)> Refusals = new() { + ["Closed set Parse of an unknown name"] = (ClosedSetMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember("OrderStatus")), + ["Closed set JSON of an unknown name"] = (ClosedSetMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMemberInJson("OrderStatus")), + ["Closed set instance with no name"] = (ClosedSetNotAMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NotAMember("OrderStatus")), + ["Closed set read while initializing"] = (ClosedSetInitializing, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing("OrderStatus")), ["Option written as JSON"] = (InJson, () => JsonSerializer.Serialize(Option.Some(1))), ["Option read from JSON"] = (InJson, () => JsonSerializer.Deserialize>("1")), ["Option? written as a JSON null"] = (InJson, () => JsonSerializer.Serialize?>(null)), @@ -61,6 +75,8 @@ private enum Fault { TooLong } ["Option.Some(null)"] = (NullContent, () => Option.Some(null!)), ["Map to null"] = (NullContent, () => Option.Some("x").Map(_ => (string)null!)), + ["Patch Map to null"] = (NullContent, () => Patch.Value("x").Map(_ => (string)null!)), + ["CatchAsync over no task"] = (NullContent, () => Result.CatchAsync(() => null!).GetAwaiter().GetResult()), ["FirstOrNone over a null"] = (NullContent, () => new string[] { null! }.FirstOrNone()), ["ToResult(null)"] = (NullContent, () => Option.Some(1).ToResult((string)null!)), ["Result.Success(null)"] = (NullContent, () => Result.Success(null!)), @@ -89,6 +105,10 @@ private enum Fault { TooLong } ["unreadable text, static Parse"] = (Unreadable, () => GeneratedParsing.Guarded("x", static text => int.Parse(text, CultureInfo.InvariantCulture))), ["unreadable text, type converter"] = (Unreadable, () => GeneratedParsing.ConvertFromString("x", null)), + ["Catch over a spelled task"] = (TaskGivenToCatch, () => Result.Catch, InvalidOperationException>(() => Task.FromResult(1))), + ["Catch over a ValueTask"] = (TaskGivenToCatch, () => Result.Catch(() => ValueTask.CompletedTask)), + ["CatchAsync over a task of a task"] = (TaskGivenToCatch, () => Result.CatchAsync(() => Task.FromResult(Task.FromResult(1))).GetAwaiter().GetResult()), + ["From(null)"] = (NullToValueObject, () => throw GeneratedFactories.NullRefused("value")), ["a JSON null"] = (NullToValueObject, () => throw GeneratedJson.NullToken()), }; @@ -108,14 +128,23 @@ public void A_refusal_ends_its_message_with_a_link_to_its_subsection(string refu var thrown = Record.Exception(attempt).ShouldNotBeNull($"{refusal} threw nothing."); var own = thrown.GetBaseException(); - var message = OwnMessage(own); + var link = ShouldEndWithLinkTo(OwnMessage(own), heading, refusal); + + thrown.Message.ShouldContain(link, Case.Sensitive, $"{refusal}: the message the caller sees lost the link."); + } + + /// That ends with a link to 's subsection, and that the README has it. + /// The link. + internal static string ShouldEndWithLinkTo(string message, string heading, string? refusal = null) + { + refusal ??= heading; var link = LinkAtTheEnd().Match(message); link.Success.ShouldBeTrue($"{refusal}: the message does not end with a link to the package README: {message}"); link.Groups["anchor"].Value.ShouldBe(Slug(heading), $"{refusal} links to another subsection than '{heading}'."); Anchors().ShouldContain(link.Groups["anchor"].Value, $"{refusal} links to #{link.Groups["anchor"].Value}, which no heading of the README produces, so the link opens the top of the page."); - thrown.Message.ShouldContain(link.Value, Case.Sensitive, $"{refusal}: the message the caller sees lost the link."); + return link.Value; } /// A subsection nothing links to is either stale or a refusal that lost its link. @@ -126,7 +155,7 @@ public void Every_subsection_of_Why_does_this_throw_is_what_a_refusal_links_to() subsections.Count.ShouldBeGreaterThanOrEqualTo(7, $"The README has no '## {WhyDoesThisThrow}' with its subsections."); subsections.Select(Slug).Except([Slug(BuildErrorOnly)]).Order() - .ShouldBe(Refusals.Values.Select(entry => Slug(entry.Heading)).Distinct().Order(), + .ShouldBe(Refusals.Values.Select(entry => Slug(entry.Heading)).Append(Slug(TextWithoutComposition)).Distinct().Order(), "The subsections of the README and the headings the refusals link to differ."); } diff --git a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs new file mode 100644 index 0000000..04f41f9 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs @@ -0,0 +1,523 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// Result.Catch: the one place an exception becomes data. Measured first in +/// spikes/ResultCatch; each claim there is a test here. +/// +public sealed class ResultCatchTests +{ + private static readonly InvalidOperationException Boom = new("boom"); + + private static int Throws() => throw Boom; + + private static async Task ThrowsLaterAsync() + { + await Task.Yield(); + throw Boom; + } + + [Fact] + public void Catch_infers_the_value_type_and_answers_the_value() + { + Result parsed = Result.Catch(() => int.Parse("42")); + + parsed.TryGetValue(out var value, out _).ShouldBeTrue(); + value.ShouldBe(42); + } + + [Fact] + public void Catch_answers_the_exception_thrown_as_the_instance_thrown() + { + var caught = Result.Catch(int () => throw Boom); + + caught.TryGetValue(out _, out var error).ShouldBeFalse(); + ReferenceEquals(error, Boom).ShouldBeTrue("nothing wraps the exception"); + } + + [Fact] + public void Catch_of_an_action_is_the_value_less_result() + { + Result acted = Result.Catch(() => { }); + ((bool)acted).ShouldBeTrue(); + + Result failed = Result.Catch(() => { Throws(); }); + failed.TryGetError(out var error).ShouldBeTrue(); + error.ShouldBeSameAs(Boom); + } + + [Fact] + public void A_narrowed_Catch_catches_that_type_and_lets_any_other_propagate() + { + Result refused = Result.Catch(() => int.Parse("x")); + refused.TryGetValue(out _, out var format).ShouldBeFalse(); + format.ShouldBeOfType(); + + var other = Should.Throw(() => Result.Catch(Throws)); + other.ShouldBeSameAs(Boom); + other.StackTrace!.ShouldContain(nameof(Throws), Case.Sensitive, "the stack is the original throw's"); + + Result failedCommand = Result.Catch(() => { int.Parse("x"); }); + failedCommand.TryGetError(out _).ShouldBeTrue(); + Should.Throw(() => Result.Catch(() => { Throws(); })); + } + + /// + /// A lambda with an expression body converts to , so the one-type-argument + /// form over a value-returning call would discard the value, and a ValueTask's exception with + /// it. That is a compile error naming the valued form; a statement lambda is a command. + /// + [Theory] + [InlineData("Result.Catch(() => int.Parse(text))", "CS0619")] + [InlineData("Result.Catch(() => Items.Remove(text))", "CS0619")] + [InlineData("Result.Catch(() => { Items.Remove(text); })", "")] + [InlineData("Result.Catch(() => Items.Clear())", "")] + [InlineData("Result.Catch(() => int.Parse(text))", "")] + public void A_one_type_argument_Catch_over_a_value_is_a_compile_error_naming_the_valued_form(string call, string error) + { + var compilation = ConsumerCompilation.Of($$""" + using System; + using System.Collections.Generic; + using CodoMetis.TypeKit; + + static class Consumer + { + static readonly List Items = []; + static object Call(string text) => {{call}}; + } + """); + + compilation.ErrorIds().ShouldBe(error.Length == 0 ? [] : [error], compilation.Problems()); + if (error.Length > 0) compilation.Problems().ShouldContain("Catch"); + } + + /// + /// A task's type spelled as the value, or inferred where no overload can name it (a + /// ValueTask), would make the task's exception arrive after a success was returned. It is + /// refused before the delegate runs, and the message names CatchAsync; CatchAsync + /// whose task's value is another task is refused the same way. + /// + [Fact] + public async Task A_task_as_the_value_is_refused_before_the_delegate_runs() + { + var ran = false; + Task Fails() { ran = true; return Task.FromException(Boom); } + + Should.Throw(() => Result.Catch, InvalidOperationException>(Fails)).Message.ShouldContain("Use CatchAsync."); + Should.Throw(() => Result.Catch>(Fails)); + Should.Throw(() => Result.Catch(Fails)); + Should.Throw(() => Result.Catch(() => { ran = true; return new ValueTask(1); })); + Should.Throw(() => Result.Catch(() => { ran = true; return ValueTask.CompletedTask; })); + (await Should.ThrowAsync(() => Result.CatchAsync(() => Task.FromResult(Fails())))).Message.ShouldContain("Await the inner task"); + await Should.ThrowAsync(() => Result.CatchAsync, InvalidOperationException>(() => Task.FromResult(Fails()))); + + ran.ShouldBeFalse(); + } + + /// + /// A delegate that returns no task is refused before anything is awaited, in every overload, as a + /// delegate that returns a null value is: it was an error holding the NullReferenceException the + /// await threw. A delegate that throws before it returns a task is still that exception as the error. + /// + [Fact] + public async Task CatchAsync_refuses_a_delegate_that_returns_no_task_and_keeps_one_that_throws_first() + { + (await Should.ThrowAsync(() => Result.CatchAsync(() => null!))).ParamName.ShouldBe("action"); + (await Should.ThrowAsync(() => Result.CatchAsync(() => null!))).ParamName.ShouldBe("action"); + (await Should.ThrowAsync(() => Result.CatchAsync(() => null!))).ParamName.ShouldBe("action"); + (await Should.ThrowAsync(() => Result.CatchAsync(() => null!))).ParamName.ShouldBe("action"); + + // Method groups, since a lambda that only throws converts to the valued and the command form alike. + static Task ThrowsForAValue() => throw new InvalidOperationException("before any task"); + static Task ThrowsForACommand() => throw new InvalidOperationException("before any task"); + + var thrown = await Result.CatchAsync(ThrowsForAValue); + thrown.Match(_ => "", error => error.Message).ShouldBe("before any task"); + + var command = await Result.CatchAsync(ThrowsForACommand); + command.Match(() => "", error => error.Message).ShouldBe("before any task"); + } + + [Fact] + public void A_cancellation_is_not_an_outcome() + { + Should.Throw(() => Result.Catch(int () => throw new OperationCanceledException())); + Should.Throw(() => Result.Catch(void () => throw new OperationCanceledException())); + Should.Throw(() => Result.Catch(int () => throw new OperationCanceledException())); + Should.Throw(() => Result.Catch(void () => throw new OperationCanceledException())); + } + + [Fact] + public async Task CatchAsync_catches_an_exception_thrown_after_the_first_await() + { + var later = await Result.CatchAsync(ThrowsLaterAsync); + later.TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBeSameAs(Boom); + + var command = await Result.CatchAsync(async () => { await Task.Yield(); Throws(); }); + command.TryGetError(out _).ShouldBeTrue(); + + var narrowed = await Result.CatchAsync(ThrowsLaterAsync); + narrowed.TryGetValue(out _, out var typed).ShouldBeFalse(); + typed.ShouldBeSameAs(Boom); + + var narrowedCommand = await Result.CatchAsync(async () => { await Task.Yield(); Throws(); }); + narrowedCommand.TryGetError(out _).ShouldBeTrue(); + + var completed = await Result.CatchAsync(async () => { await Task.Yield(); return 7; }); + completed.TryGetValue(out var value, out _).ShouldBeTrue(); + value.ShouldBe(7); + } + + /// + /// CatchAsync keeps await's semantics: the error is what + /// try { await task; } catch (Exception e) catches, so for a task that faulted with several + /// exceptions, as Task.WhenAll's does, the first of them, and a narrowed form looks at that one + /// alone. The task's holds them all, which is what a caller holds the + /// task for where every failure matters. + /// + [Fact] + public async Task CatchAsync_over_a_task_with_several_exceptions_answers_the_one_await_throws() + { + var first = new InvalidOperationException("first"); + var second = new FormatException("second"); + + var both = Task.WhenAll(Task.FromException(first), Task.FromException(second)); + Exception? awaited = null; + try { await both; } + catch (Exception exception) { awaited = exception; } + + (await Result.CatchAsync(() => both)).TryGetError(out var error).ShouldBeTrue(); + error.ShouldBeSameAs(first); + error.ShouldBeSameAs(awaited); + both.Exception!.InnerExceptions.ShouldBe([first, second], "every failure is on the task"); + + var values = Task.WhenAll(Task.FromException(first), Task.FromException(second)); + (await Result.CatchAsync(() => values)).TryGetValue(out _, out var valued).ShouldBeFalse(); + valued.ShouldBeSameAs(first); + (await Result.CatchAsync(() => values)).TryGetValue(out _, out var narrowed).ShouldBeFalse(); + narrowed.ShouldBeSameAs(first); + (await Should.ThrowAsync(() => Result.CatchAsync(() => values))).ShouldBeSameAs(first, "the second exception is never looked at"); + (await Result.CatchAsync(() => both)).TryGetError(out var command).ShouldBeTrue(); + command.ShouldBeSameAs(first); + } + + [Fact] + public async Task CatchAsync_lets_a_cancelled_token_through_as_cancellation() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Delay(10, cts.Token); return 1; })); + await Should.ThrowAsync(() => Result.CatchAsync(() => Task.Delay(10, cts.Token))); + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Delay(10, cts.Token); return 1; })); + await Should.ThrowAsync(() => Result.CatchAsync(() => Task.Delay(10, cts.Token))); + } + + /// + /// HttpClient reports its Timeout elapsing as a over a + /// : the call failed, and nobody cancelled it. A handler that waits + /// for its token leaves the timeout as the only way out, so this does not race. + /// + [Fact] + public async Task CatchAsync_answers_an_HttpClient_timeout_as_the_error() + { + using var client = new HttpClient(new WaitingHandler()) { Timeout = TimeSpan.FromMilliseconds(1) }; + + var fetched = await Result.CatchAsync(() => client.GetByteArrayAsync(new Uri("http://timeout.test/"))); + + fetched.TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBeOfType().InnerException.ShouldBeOfType(); + } + + [Fact] + public async Task Every_overload_answers_a_timeout_as_the_error() + { + Result.Catch(int () => throw Timeout).TryGetValue(out _, out var valued).ShouldBeFalse(); + valued.ShouldBeSameAs(Timeout); + Result.Catch(void () => throw Timeout).TryGetError(out var command).ShouldBeTrue(); + command.ShouldBeSameAs(Timeout); + Result.Catch(() => throw Timeout).TryGetValue(out _, out var narrowed).ShouldBeFalse(); + narrowed.ShouldBeSameAs(Timeout); + Result.Catch(void () => throw Timeout).TryGetError(out var narrowedCommand).ShouldBeTrue(); + narrowedCommand.ShouldBeSameAs(Timeout); + + (await Result.CatchAsync(TimesOutLaterAsync)).TryGetValue(out _, out var later).ShouldBeFalse(); + later.ShouldBeSameAs(Timeout); + (await Result.CatchAsync(TimesOutLaterCommandAsync)).TryGetError(out var laterCommand).ShouldBeTrue(); + laterCommand.ShouldBeSameAs(Timeout); + (await Result.CatchAsync(TimesOutLaterAsync)).TryGetValue(out _, out var laterNarrowed).ShouldBeFalse(); + laterNarrowed.ShouldBeSameAs(Timeout); + (await Result.CatchAsync(TimesOutLaterCommandAsync)).TryGetError(out var laterNarrowedCommand).ShouldBeTrue(); + laterNarrowedCommand.ShouldBeSameAs(Timeout); + } + + /// A timeout is still the exception it is: a narrowed catch of another type lets it propagate. + [Fact] + public async Task A_narrowed_catch_of_another_type_lets_a_timeout_propagate() + { + Should.Throw(() => Result.Catch(() => throw Timeout)); + await Should.ThrowAsync(() => Result.CatchAsync(TimesOutLaterAsync)); + } + + /// Only a timeout is the error: a cancellation over any other inner exception, or none, still passes through. + [Fact] + public async Task A_cancellation_that_is_no_timeout_still_passes_through() + { + var cancelled = new OperationCanceledException("cancelled", new IOException("the stream closed")); + + Should.Throw(() => Result.Catch(int () => throw cancelled)); + Should.Throw(() => Result.Catch(void () => throw new TaskCanceledException())); + await Should.ThrowAsync(() => Result.CatchAsync(async Task () => { await Task.Yield(); throw cancelled; })); + } + + private static Task Cancelled => Task.FromCanceled(new CancellationToken(canceled: true)); + + private static Task CancelledCommand => Task.FromCanceled(new CancellationToken(canceled: true)); + + private static void WaitsForACancelledTask() => CancelledCommand.Wait(); + + private static int ReadsACancelledTask() => Cancelled.Result; + + /// + /// Task.Wait() and .Result throw a cancelled task's cancellation inside an + /// . One that holds nothing but cancellations is a cancellation, and + /// passes through every overload as it was thrown: it was the error, so Recover ran on a + /// cancelled call. + /// + [Fact] + public async Task A_cancellation_that_Wait_or_Result_wraps_passes_through_every_overload() + { + Should.Throw(() => Result.Catch(() => Cancelled.Result)).InnerException.ShouldBeOfType(); + Should.Throw(() => Result.Catch(() => CancelledCommand.Wait())).InnerException.ShouldBeOfType(); + Should.Throw(() => Result.Catch(() => Cancelled.Result)); + Should.Throw(() => Result.Catch(() => Cancelled.Result)); + Should.Throw(() => Result.Catch(() => CancelledCommand.Wait())); + Should.Throw(() => Result.Catch(() => CancelledCommand.Wait())); + + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Yield(); return Cancelled.Result; })); + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Yield(); CancelledCommand.Wait(); })); + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Yield(); return Cancelled.Result; })); + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Yield(); CancelledCommand.Wait(); })); + } + + /// Nothing catches and rethrows it: the instance thrown arrives, with the stack of its throw. + [Fact] + public void A_wrapped_cancellation_arrives_as_it_was_thrown() + { + var wrapped = new AggregateException(new OperationCanceledException(), new AggregateException(new TaskCanceledException())); + + Should.Throw(() => Result.Catch(int () => throw wrapped)).ShouldBeSameAs(wrapped, "nested aggregates of cancellations are a cancellation too"); + Should.Throw(() => Result.Catch(void () => throw wrapped)).ShouldBeSameAs(wrapped); + + Should.Throw(() => Result.Catch(ReadsACancelledTask)).StackTrace!.ShouldContain(nameof(ReadsACancelledTask), Case.Sensitive, "the stack is the original throw's"); + Should.Throw(() => Result.Catch(WaitsForACancelledTask)).StackTrace!.ShouldContain(nameof(WaitsForACancelledTask), Case.Sensitive, "the stack is the original throw's"); + } + + /// + /// A cancellation beside a failure, as Task.WaitAll throws for one task cancelled and one + /// faulted, is not a cancellation alone: the aggregate is the error, as it was thrown. + /// + [Fact] + public async Task An_aggregate_that_mixes_a_cancellation_with_a_failure_is_the_error() + { + Result.Catch(() => Task.WaitAll(CancelledCommand, Task.FromException(Boom))).TryGetError(out var waited).ShouldBeTrue(); + waited.ShouldBeOfType().InnerExceptions.Select(inner => inner.GetType()).ShouldBe([typeof(TaskCanceledException), typeof(InvalidOperationException)]); + + var mixed = new AggregateException(new OperationCanceledException(), Boom); + + Result.Catch(int () => throw mixed).TryGetValue(out _, out var valued).ShouldBeFalse(); + valued.ShouldBeSameAs(mixed); + Result.Catch(void () => throw mixed).TryGetError(out var command).ShouldBeTrue(); + command.ShouldBeSameAs(mixed); + Result.Catch(() => throw mixed).TryGetValue(out _, out var narrowed).ShouldBeFalse(); + narrowed.ShouldBeSameAs(mixed); + Result.Catch(void () => throw mixed).TryGetError(out var narrowedCommand).ShouldBeTrue(); + narrowedCommand.ShouldBeSameAs(mixed); + + (await Result.CatchAsync(async Task () => { await Task.Yield(); throw mixed; })).TryGetValue(out _, out var later).ShouldBeFalse(); + later.ShouldBeSameAs(mixed); + (await Result.CatchAsync(async () => { await Task.Yield(); throw mixed; })).TryGetError(out var laterCommand).ShouldBeTrue(); + laterCommand.ShouldBeSameAs(mixed); + } + + /// + /// A timeout is the error wrapped or not (decision 46). An aggregate that holds one, alone or beside + /// cancellations, is not a cancellation alone, and is the error as it was thrown. + /// + [Fact] + public async Task A_wrapped_timeout_is_the_error() + { + Result.Catch(() => Task.FromException(Timeout).Result).TryGetValue(out _, out var read).ShouldBeFalse(); + read.ShouldBeOfType().InnerException.ShouldBeSameAs(Timeout); + + var beside = new AggregateException(new OperationCanceledException(), Timeout); + Result.Catch(void () => throw beside).TryGetError(out var command).ShouldBeTrue(); + command.ShouldBeSameAs(beside); + Result.Catch(() => throw beside).TryGetValue(out _, out var narrowed).ShouldBeFalse(); + narrowed.ShouldBeSameAs(beside); + (await Result.CatchAsync(async Task () => { await Task.Yield(); throw beside; })).TryGetValue(out _, out var later).ShouldBeFalse(); + later.ShouldBeSameAs(beside); + } + + /// + /// .Result and Wait() on a cancelled task throw a new + /// naming the task, without the exception the task was cancelled with (measured on .NET 10): for an + /// call whose Timeout elapsed, the is gone + /// from what is thrown. It is still the task's, and the timeout is the error, as CatchAsync answers it. + /// + [Fact] + public void An_HttpClient_timeout_read_through_Result_is_the_error() + { + using var client = new HttpClient(new WaitingHandler()) { Timeout = TimeSpan.FromMilliseconds(1) }; + + var fetched = Result.Catch(() => client.GetByteArrayAsync(new Uri("http://timeout.test/")).Result); + + fetched.TryGetValue(out _, out var error).ShouldBeFalse(); + var thrown = error.ShouldBeOfType().InnerException.ShouldBeOfType(); + thrown.InnerException.ShouldBeNull("the runtime's own exception for a cancelled task holds no inner exception"); + + Should.Throw(() => Result.Catch(() => client.GetByteArrayAsync(new Uri("http://timeout.test/"), new CancellationToken(canceled: true)).Result)); + } + + private static readonly TaskCanceledException Timeout = new("The request was canceled due to the configured HttpClient.Timeout elapsing.", new TimeoutException()); + + private static async Task TimesOutLaterAsync() + { + await Task.Yield(); + throw Timeout; + } + + private static async Task TimesOutLaterCommandAsync() + { + await Task.Yield(); + throw Timeout; + } + + /// Answers nothing until its token is cancelled, which only the client's timeout does. + private sealed class WaitingHandler : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken); + throw new InvalidOperationException("The client's timeout is the only way out."); + } + } + + /// A success over null is the delegate's defect, not an outcome, and is not caught. + [Fact] + public async Task A_delegate_that_returns_null_is_refused_rather_than_caught() + { + Should.Throw(() => Result.Catch(() => (string)null!)); + Should.Throw(() => Result.Catch(() => null!)); + await Should.ThrowAsync(() => Result.CatchAsync(() => Task.FromResult((string)null!))); + await Should.ThrowAsync(() => Result.CatchAsync(() => Task.FromResult((string)null!))); + } + + [Fact] + public void The_combinators_are_unchanged_so_a_throwing_selector_propagates() + { + Should.Throw(() => Result.Catch(() => 1).Map(_ => Throws())); + Should.Throw(() => Result.Catch(() => 1).Bind(_ => Throws())); + } + + /// + /// A delegate returning a task given to Catch would bind T to the task and catch + /// nothing it throws, and given to the command form Catch<TException> would be + /// discarded, an async lambda as async void. The overloads over a task-returning + /// delegate are more specific, so the compiler picks them, and they are errors that name + /// CatchAsync. A ValueTask has no such overload (it would make every async lambda + /// ambiguous): the command form's refusal of any value covers it, and the valued form refuses it at + /// run time. + /// + [Theory] + [InlineData("Result.Catch(() => FetchAsync())")] + [InlineData("Result.Catch(FetchAsync)")] + [InlineData("Result.Catch(() => DoAsync())")] + [InlineData("Result.Catch(DoAsync)")] + [InlineData("Result.Catch(async () => await FetchAsync())")] + [InlineData("Result.Catch(async () => await DoAsync())")] + [InlineData("Result.Catch(() => FetchAsync())")] + [InlineData("Result.Catch(FetchAsync)")] + [InlineData("Result.Catch(() => DoAsync())")] + [InlineData("Result.Catch(DoAsync)")] + [InlineData("Result.Catch(async () => await DoAsync())")] + [InlineData("Result.Catch(async () => { await DoAsync(); })")] + [InlineData("Result.Catch(() => DoValueAsync())")] + [InlineData("Result.Catch(() => FetchValueAsync())")] + public void A_task_returning_delegate_given_to_Catch_is_a_compile_error_naming_CatchAsync(string call) + { + var compilation = ConsumerCompilation.Of($$""" + using System; + using System.Threading.Tasks; + using CodoMetis.TypeKit; + + static class Consumer + { + static Task FetchAsync() => Task.FromResult(1); + static Task DoAsync() => Task.CompletedTask; + static ValueTask FetchValueAsync() => new(1); + static ValueTask DoValueAsync() => ValueTask.CompletedTask; + static object Call() => {{call}}; + } + """); + + compilation.ErrorIds().ShouldBe(["CS0619"], compilation.Problems()); + compilation.Problems().ShouldContain("CatchAsync"); + } + + /// + /// The valued form with the value's type spelled cannot take a task at all: the compiler's own + /// conversion error, with no overload to make it compile. A refusal overload there would make a + /// lambda that only throws, or returns null!, ambiguous. + /// + [Theory] + [InlineData("Result.Catch(() => FetchAsync())")] + [InlineData("Result.Catch(FetchAsync)")] + [InlineData("Result.Catch(async () => await FetchAsync())")] + [InlineData("Result.Catch(() => FetchValueAsync())")] + public void A_task_given_to_the_valued_Catch_with_its_value_type_spelled_does_not_compile(string call) + { + var compilation = ConsumerCompilation.Of($$""" + using System; + using System.Threading.Tasks; + using CodoMetis.TypeKit; + + static class Consumer + { + static Task FetchAsync() => Task.FromResult(1); + static ValueTask FetchValueAsync() => new(1); + static object Call() => {{call}}; + } + """); + + compilation.ErrorIds().ShouldNotBeEmpty(compilation.Problems()); + } + + /// + /// The cost of the trap: a lambda that only throws has no return type. With a type argument it is + /// ambiguous between Func<T> and the trap's Func<Task<T>>; without + /// one, the compiler prefers the non-void delegate for an expression body and lands on the trap. + /// Stating the return type resolves both, and the call sites of real code have one. + /// + [Theory] + [InlineData("Result.Catch(() => throw new Exception())", "CS0121")] + [InlineData("Result.Catch(int () => throw new Exception())", "")] + [InlineData("Result.Catch(() => throw new Exception())", "CS0619")] + [InlineData("Result.Catch(void () => throw new Exception())", "")] + [InlineData("Result.Catch(() => throw new Exception())", "CS0121")] + [InlineData("Result.Catch(void () => throw new Exception())", "")] + public void A_lambda_that_only_throws_states_its_return_type(string call, string error) + { + var compilation = ConsumerCompilation.Of($$""" + using System; + using CodoMetis.TypeKit; + + static class Consumer + { + static object Call() => {{call}}; + } + """); + + compilation.ErrorIds().ShouldBe(error.Length == 0 ? [] : [error], compilation.Problems()); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/ResultTraverseAsyncTests.cs b/test/CodoMetis.TypeKit.Tests/ResultTraverseAsyncTests.cs new file mode 100644 index 0000000..ecea484 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/ResultTraverseAsyncTests.cs @@ -0,0 +1,136 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// TraverseAsync: an asynchronous operation that may fail, applied to every element one at a +/// time and in order, stopping at the first error, so the next operation is never started after one +/// failed. Operations that may all run at once are Task.WhenAll and Sequence(). +/// +public sealed class ResultTraverseAsyncTests +{ + private static Result Ok(int value) => Result.Success(value); + + private static Result Fail(string error) => Result.Error(error); + + [Fact] + public async Task TraverseAsync_awaits_each_operation_in_order_and_collects_the_values() + { + var order = new List(); + + async Task> Parse(string text) + { + order.Add($"start {text}"); + await Task.Yield(); + order.Add($"end {text}"); + return int.TryParse(text, out var n) ? Ok(n) : Fail(text); + } + + var parsed = await new[] { "1", "2", "3" }.TraverseAsync(Parse); + + parsed.Match(values => values, _ => []).ShouldBe([1, 2, 3]); + order.ShouldBe(["start 1", "end 1", "start 2", "end 2", "start 3", "end 3"], "one operation at a time, each finished before the next starts"); + } + + [Fact] + public async Task TraverseAsync_stops_before_the_next_operation_after_an_error() + { + var started = new List(); + + Task> Parse(string text) + { + started.Add(text); + return Task.FromResult(int.TryParse(text, out var n) ? Ok(n) : Fail(text)); + } + + var parsed = await new[] { "1", "x", "y", "4" }.TraverseAsync(Parse); + + parsed.Match(_ => "", error => error).ShouldBe("x"); + started.ShouldBe(["1", "x"]); + } + + [Fact] + public async Task TraverseAsync_with_a_command_runs_until_one_fails() + { + var sent = new List(); + + Task> Send(int n) + { + if (n == 3) return Task.FromResult(Result.Error($"refused {n}")); + + sent.Add(n); + return Task.FromResult(Result.Success()); + } + + (await new[] { 1, 2, 3, 4 }.TraverseAsync(Send)).TryGetError(out var error).ShouldBeTrue(); + error.ShouldBe("refused 3"); + sent.ShouldBe([1, 2]); + + ((bool)await new[] { 1, 2 }.TraverseAsync(Send)).ShouldBeTrue(); + ((bool)await Array.Empty().TraverseAsync(Send)).ShouldBeTrue(); + } + + [Fact] + public async Task TraverseAsync_over_an_empty_sequence_is_an_empty_success() + { + var values = await Array.Empty().TraverseAsync(text => Task.FromResult(Ok(text.Length))); + + values.Match(list => list.Count, _ => -1).ShouldBe(0); + } + + /// + /// The token is handed to each operation and checked before each: once cancelled, the next + /// operation is not started, and the cancellation propagates rather than becoming an error. + /// + [Fact] + public async Task TraverseAsync_hands_the_token_on_and_stops_once_cancelled() + { + using var cancellation = new CancellationTokenSource(); + var started = new List(); + + Task> Load(int n, CancellationToken token) + { + token.ShouldBe(cancellation.Token, "the operation receives the caller's token"); + started.Add(n); + if (n == 2) cancellation.Cancel(); + return Task.FromResult(Ok(n)); + } + + await Should.ThrowAsync(() => new[] { 1, 2, 3 }.TraverseAsync(Load, cancellation.Token)); + started.ShouldBe([1, 2], "the third operation is not started after the token was cancelled"); + + using var commandCancellation = new CancellationTokenSource(); + + Task> Send(int n, CancellationToken token) + { + token.ShouldBe(commandCancellation.Token); + started.Add(n); + if (n == 2) commandCancellation.Cancel(); + return Task.FromResult(Result.Success()); + } + + started.Clear(); + await Should.ThrowAsync(() => new[] { 1, 2, 3 }.TraverseAsync(Send, commandCancellation.Token)); + started.ShouldBe([1, 2]); + } + + [Fact] + public async Task TraverseAsync_with_a_token_that_is_not_cancelled_runs_every_operation() + { + var values = await new[] { 1, 2 }.TraverseAsync((n, _) => Task.FromResult(Ok(n * 10)), CancellationToken.None); + values.Match(list => list, _ => []).ShouldBe([10, 20]); + + var command = await new[] { 1, 2 }.TraverseAsync((_, _) => Task.FromResult(Result.Success()), CancellationToken.None); + ((bool)command).ShouldBeTrue(); + + var refused = await new[] { 1, 2 }.TraverseAsync((n, _) => Task.FromResult(n == 2 ? Fail("two") : Ok(n)), CancellationToken.None); + refused.Match(_ => "", error => error).ShouldBe("two"); + } + + [Fact] + public async Task TraverseAsync_with_a_cancelled_token_starts_nothing() + { + var started = 0; + + await Should.ThrowAsync(() => new[] { 1 }.TraverseAsync((n, _) => { started++; return Task.FromResult(Ok(n)); }, new CancellationToken(canceled: true))); + started.ShouldBe(0); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs new file mode 100644 index 0000000..f7c0e0a --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs @@ -0,0 +1,416 @@ +using System.Text; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// The two guards a string passes before it is text an application keeps. +/// +/// +/// +/// The character table is the point of this file. Every entry is a code point that walks through +/// IsNullOrWhiteSpace and survives Trim inside a value, and between them they are refused +/// for five different structural reasons: category Control, category Format, a category +/// that claims "letter", "mark" or "symbol" while the glyph is empty, a plane above the BMP, and a code +/// point string.Normalize throws on. Pinning them by value rather than by category means a +/// runtime that reclassifies one fails here instead of quietly reopening the defect. +/// +/// +/// Every entry is written as an escape and named in the second column, because the subject of this +/// file is characters a reader cannot see. The six above U+FFFF are the ones a plausible +/// implementation gets wrong: CharUnicodeInfo.GetUnicodeCategory over a +/// answers Surrogate for either half of a pair and never the category of the character the +/// pair encodes, so a per-char scan accepts all six. +/// +/// +public sealed class StringTextExtensionsTests +{ + /// Refused by both guards, whichever way the value arrived. + public static TheoryData Hidden => new() + { + { "\u0000", "NUL" }, + { "\u0009", "TAB" }, + { "\u000A", "LINE FEED" }, + { "\u000D", "CARRIAGE RETURN" }, + { "\u000D\u000A", "CARRIAGE RETURN, LINE FEED" }, + { "\u00AD", "SOFT HYPHEN" }, + { "\u034F", "COMBINING GRAPHEME JOINER" }, + { "\u115F", "HANGUL CHOSEONG FILLER" }, + { "\u1160", "HANGUL JUNGSEONG FILLER" }, + { "\u17B4", "KHMER VOWEL INHERENT AQ" }, + { "\u17B5", "KHMER VOWEL INHERENT AA" }, + { "\u180E", "MONGOLIAN VOWEL SEPARATOR" }, + { "\u200B", "ZERO WIDTH SPACE" }, + { "\u200E", "LEFT-TO-RIGHT MARK" }, + { "\u200F", "RIGHT-TO-LEFT MARK" }, + { "\u202E", "RIGHT-TO-LEFT OVERRIDE" }, + { "\u2028", "LINE SEPARATOR" }, + { "\u2029", "PARAGRAPH SEPARATOR" }, + { "\u2060", "WORD JOINER" }, + { "\u2800", "BRAILLE PATTERN BLANK" }, + { "\u3164", "HANGUL FILLER" }, + { "\uFEFF", "ZERO WIDTH NO-BREAK SPACE" }, + { "\uFFA0", "HALFWIDTH HANGUL FILLER" }, + { "\uFFFE", "NONCHARACTER FFFE, a byte-order mark read in the wrong byte order" }, + { "\U00016FE4", "KHITAN SMALL SCRIPT FILLER" }, + { "\U0001D173", "MUSICAL SYMBOL BEGIN BEAM" }, + { "\U000E0001", "LANGUAGE TAG" }, + { "\U000E0041", "TAG LATIN CAPITAL A" }, + { "\U000E007F", "CANCEL TAG" }, + { "\U000F0000", "PRIVATE USE" } + }; + + /// + /// The two characters the split is about: orthography in Persian, Arabic and several Indic + /// scripts, and the glue inside an emoji sequence, so a name may carry them and a value another + /// system issued may not. + /// + public static TheoryData Joiners => new() + { + { "\u200C", "ZERO WIDTH NON-JOINER" }, + { "\u200D", "ZERO WIDTH JOINER" } + }; + + /// + /// Unicode's prepended concatenation marks, of Unicode 16, whose tables the runtime follows: format + /// characters by category, yet each is drawn, as a sign that spans the digits or letters after it. + /// Refused as hidden, they refused Arabic and Syriac text the guard says it keeps. + /// + public static TheoryData PrependedConcatenationMarks => new() + { + { "\u0600", "ARABIC NUMBER SIGN" }, + { "\u0601", "ARABIC SIGN SANAH" }, + { "\u0602", "ARABIC FOOTNOTE MARKER" }, + { "\u0603", "ARABIC SIGN SAFHA" }, + { "\u0604", "ARABIC SIGN SAMVAT" }, + { "\u0605", "ARABIC NUMBER MARK ABOVE" }, + { "\u06DD", "ARABIC END OF AYAH" }, + { "\u070F", "SYRIAC ABBREVIATION MARK" }, + { "\u0890", "ARABIC POUND MARK ABOVE" }, + { "\u0891", "ARABIC PIASTRE MARK ABOVE" }, + { "\u08E2", "ARABIC DISPUTED END OF AYAH" }, + { "\U000110BD", "KAITHI NUMBER SIGN" }, + { "\U000110CD", "KAITHI NUMBER SIGN ABOVE" } + }; + + /// + /// Every entry renders exactly as an ordinary space and, before folding, made two values out of + /// one name. Confusable letters across scripts are deliberately not folded. + /// + public static TheoryData Spaces => new() + { + { "\u00A0", "NO-BREAK SPACE" }, + { "\u2002", "EN SPACE" }, + { "\u2007", "FIGURE SPACE" }, + { "\u202F", "NARROW NO-BREAK SPACE" }, + { "\u3000", "IDEOGRAPHIC SPACE" } + }; + + [Theory] + [MemberData(nameof(Hidden))] + public void A_character_that_renders_as_nothing_is_refused_as_text(string character, string name) => + Refusal($"Muster{character}mann".AsText()).ShouldBe(TextFault.Hidden, name); + + [Theory] + [MemberData(nameof(Hidden))] + public void A_character_that_renders_as_nothing_is_refused_as_an_identifier(string character, string name) => + Refusal($"subject{character}id".AsIdentifier()).ShouldBe(TextFault.Hidden, name); + + /// + /// The mark spans the digits after it, here the Arabic-Indic one, two and three; with nothing after + /// it, it draws itself, so a value of the mark alone is a character of its own and is kept too. + /// + [Theory] + [MemberData(nameof(PrependedConcatenationMarks))] + public void A_prepended_concatenation_mark_is_drawn_and_kept(string mark, string name) + { + var numbered = $"{mark}\u0661\u0662\u0663"; + + Kept(numbered.AsText()).ShouldBe(numbered, name); + Kept(numbered.AsIdentifier()).ShouldBe(numbered, name); + Kept(mark.AsText()).ShouldBe(mark, $"{name}, with nothing after it"); + Kept(mark.AsIdentifier()).ShouldBe(mark, $"{name}, with nothing after it"); + } + + [Theory] + [MemberData(nameof(Joiners))] + public void A_joiner_is_orthography_in_text_and_noise_in_an_identifier(string character, string name) + { + var written = $"Muster{character}mann"; + + Kept(written.AsText()).ShouldBe(written, name); + Refusal(written.AsIdentifier()).ShouldBe(TextFault.Hidden, name); + } + + /// + /// A zero-width joiner inside an emoji sequence is the concrete reason the permissive guard + /// exists: refusing it would refuse a character people type on a phone keyboard. + /// + [Fact] + public void An_emoji_sequence_survives_as_text() + { + const string developer = "\U0001F469\u200D\U0001F4BB"; + + Kept(developer.AsText()).ShouldBe(developer); + } + + /// + /// Characters that render only beside a base, as a whole value: a joiner, a variation selector or + /// a combining mark with nothing to join, select or sit on renders as nothing, and the value is + /// hidden rather than blank, whichever guard reads it. + /// + [Theory] + [InlineData("\u200D", "ZERO WIDTH JOINER")] + [InlineData("\u200C\u200D", "ZERO WIDTH NON-JOINER, ZERO WIDTH JOINER")] + [InlineData(" \u200D ", "ZERO WIDTH JOINER between spaces")] + [InlineData("\uFE0F", "VARIATION SELECTOR-16")] + [InlineData("\uFE0E\uFE0F", "VARIATION SELECTOR-15, VARIATION SELECTOR-16")] + [InlineData("\U000E0100", "VARIATION SELECTOR-17")] + [InlineData("\u180B", "MONGOLIAN FREE VARIATION SELECTOR ONE")] + [InlineData("\u200D\uFE0F", "ZERO WIDTH JOINER, VARIATION SELECTOR-16")] + [InlineData("\u0301", "COMBINING ACUTE ACCENT")] + [InlineData("\u0301\u200D", "COMBINING ACUTE ACCENT, ZERO WIDTH JOINER")] + public void A_value_that_renders_no_character_of_its_own_is_hidden(string value, string name) + { + Refusal(value.AsText()).ShouldBe(TextFault.Hidden, name); + Refusal(value.AsIdentifier()).ShouldBe(TextFault.Hidden, name); + } + + /// + /// A variation selector chooses how the character before it is drawn, and draws nothing itself: + /// after a space, another selector, a joiner or nothing at all, it selects nothing and is a + /// hidden character in visible text. + /// + [Theory] + [InlineData("\uFE0FMustermann", "first")] + [InlineData("Muster \uFE0Fmann", "after a space")] + [InlineData("Muster\u2764\uFE0F\uFE0Fmann", "after another selector")] + [InlineData("Muster\u200D\uFE0Fmann", "after a joiner")] + [InlineData("Muster \U000E0100mann", "an ideographic selector after a space")] + public void A_variation_selector_that_selects_nothing_is_hidden(string value, string where) + { + Refusal(value.AsText()).ShouldBe(TextFault.Hidden, where); + Refusal(value.AsIdentifier()).ShouldBe(TextFault.Hidden, where); + } + + /// + /// What the joiners and the selectors are for survives as text: emoji presentation, a flag and a + /// keycap, an ideographic variant, a Persian word with its non-joiner, a Devanagari conjunct, and + /// an Arabic letter shown in its joining form on its own, where the joiner ends the value. + /// + [Theory] + [InlineData("\u2764\uFE0F", "RED HEART, emoji presentation")] + [InlineData("\u2764\uFE0E", "RED HEART, text presentation")] + [InlineData("\U0001F3F3\uFE0F\u200D\U0001F308", "RAINBOW FLAG")] + [InlineData("1\uFE0F\u20E3", "KEYCAP DIGIT ONE")] + [InlineData("\u845B\U000E0100", "an ideographic variation sequence")] + [InlineData("\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645", "Persian, with a non-joiner")] + [InlineData("\u0915\u094D\u200D\u0937", "Devanagari, a conjunct with a joiner")] + [InlineData("\u0647\u200D", "Arabic HEH in its initial form")] + public void A_variation_or_joining_sequence_survives_as_text(string value, string name) => + Kept(value.AsText()).ShouldBe(value, name); + + /// + /// The two faults stay apart because the caller decides what a blank means: a required field + /// turns it into an error, an optional one into absence. Collapsing them would make every + /// optional field swallow a hidden character. + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n")] + public void Text_with_nothing_in_it_is_blank_rather_than_hidden(string value) + { + Refusal(value.AsText()).ShouldBe(TextFault.Blank); + Refusal(value.AsIdentifier()).ShouldBe(TextFault.Blank); + } + + /// + /// The extension is the door a bound request property goes through, and binding enforces no + /// non-nullability, so the one input a caller cannot see coming is absorbed here. + /// + [Fact] + public void A_null_is_blank_rather_than_a_throw() + { + string? missing = null; + + Refusal(missing.AsText()).ShouldBe(TextFault.Blank); + Refusal(missing.AsIdentifier()).ShouldBe(TextFault.Blank); + } + + [Fact] + public void Text_that_is_nothing_but_a_hidden_character_is_hidden_rather_than_blank() => + Refusal("\u200B".AsText()).ShouldBe(TextFault.Hidden); + + /// + /// The half no character check can give. Without composition these are two names, two rows and + /// two entries in a unique index a database compares byte by byte, and they render identically + /// on every screen either way. + /// + [Fact] + public void Text_is_composed_so_that_one_name_has_one_spelling() + { + const string composed = "M\u00FCller"; + const string decomposed = "Mu\u0308ller"; + + decomposed.ShouldNotBe(composed); + + var normalized = Kept(decomposed.AsText()); + normalized.ShouldBe(composed); + normalized.Length.ShouldBe(6); + } + + /// + /// The one place the two guards disagree about content rather than about joiners: a value + /// another system issued is compared against what that system sends next, so this side does + /// not get to canonicalise it. + /// + [Fact] + public void An_identifier_is_left_in_the_spelling_it_arrived_in() + { + const string decomposed = "Mu\u0308ller"; + + Kept(decomposed.AsIdentifier()).ShouldBe(decomposed); + } + + /// + /// Not a category question: an unpaired surrogate is not a character at all. + /// string.Normalize throws on one, so refusing it here is what keeps a malformed string + /// from becoming an unhandled exception further down. + /// + [Fact] + public void An_unpaired_surrogate_is_refused_rather_than_thrown_over() + { + Refusal("Muster\uD834mann".AsText()).ShouldBe(TextFault.Hidden); + Refusal("Muster\uD834mann".AsIdentifier()).ShouldBe(TextFault.Hidden); + } + + /// + /// string.Normalize throws on two things, an unpaired surrogate and U+FFFE, and + /// AsText() threw on the second. Every scalar value after a letter, through both guards: + /// nothing a client can send makes either throw. + /// + [Fact] + public void No_scalar_value_makes_either_guard_throw() + { + var swept = 0; + var thrown = new List(); + + for (var value = 0; value <= 0x10FFFF; value++) + { + if (!Rune.IsValid(value)) continue; + + var text = string.Concat("a", char.ConvertFromUtf32(value)); + swept++; + + try + { + _ = text.AsText(); + _ = text.AsIdentifier(); + } + catch (Exception exception) + { + thrown.Add($"U+{value:X4}: {exception.GetType().Name}"); + } + } + + swept.ShouldBe(0x110000 - 0x800, "every scalar value: all code points but the surrogates"); + thrown.ShouldBeEmpty(); + } + + /// + /// Composition reorders a run of combining marks by their classes in time that grows with the + /// square of the run: a letter and 160,000 marks of each of two classes took a minute. No script + /// stacks more than a few on one character, and UAX #15's Stream-Safe Text Format bounds a run at + /// 30, so a longer one is refused, by both guards, before anything is composed. + /// + [Fact] + public void More_than_30_combining_marks_in_a_row_are_hidden() + { + var thirty = "a" + string.Concat(Enumerable.Repeat("\u0301\u0316", 15)); + var thirtyOne = thirty + "\u0301"; + + Kept(thirty.AsText()).ShouldBe(thirty.Normalize(NormalizationForm.FormC)); + Kept(thirty.AsIdentifier()).ShouldBe(thirty); + Refusal(thirtyOne.AsText()).ShouldBe(TextFault.Hidden); + Refusal(thirtyOne.AsIdentifier()).ShouldBe(TextFault.Hidden); + } + + /// A run is of marks in a row: a base character between two runs of 30 ends the first. + [Fact] + public void Runs_of_marks_on_separate_characters_are_counted_apart() + { + var marks = string.Concat(Enumerable.Repeat("\u0301\u0316", 15)); + var two = $"a{marks}b{marks}"; + + Kept(two.AsText()).ShouldBe(two.Normalize(NormalizationForm.FormC)); + Kept(two.AsIdentifier()).ShouldBe(two); + } + + /// + /// The shape that pinned a core: a letter, then marks of one class, then marks of another, which + /// composition reorders. Refused at the 31st mark, it answers at once; composed, it answered a + /// success after 25 seconds. + /// + [Fact] + public void A_long_stack_of_marks_is_refused_rather_than_composed() + { + var stacked = "a" + new string('\u0301', 100_000) + new string('\u0316', 100_000); + + Refusal(stacked.AsText()).ShouldBe(TextFault.Hidden); + Refusal(stacked.AsIdentifier()).ShouldBe(TextFault.Hidden); + } + + /// + /// The form is C, not KC: compatibility characters are what the person wrote, and folding a + /// ligature, a superscript or a fullwidth letter to its plain form changes the text, where + /// composing changes only its encoding. + /// + [Theory] + [InlineData("\uFB01le", "LATIN SMALL LIGATURE FI")] + [InlineData("x\u00B2", "SUPERSCRIPT TWO")] + [InlineData("\uFF21\uFF22", "FULLWIDTH LATIN CAPITAL LETTER A, B")] + public void A_compatibility_character_is_kept_as_written(string value, string name) => + Kept(value.AsText()).ShouldBe(value, name); + + [Fact] + public void Surrounding_whitespace_is_not_content() + { + Kept(" Erika Mustermann \n".AsText()).ShouldBe("Erika Mustermann"); + Kept(" 7f3a \n".AsIdentifier()).ShouldBe("7f3a"); + } + + [Theory] + [MemberData(nameof(Spaces))] + public void A_space_that_renders_as_an_ordinary_one_is_folded_to_it(string character, string name) => + Kept($"Jean{character}Paul".AsText()).ShouldBe("Jean Paul", name); + + /// + /// The written path folds; the identifier path alters nothing beyond the trim, because a value + /// another system issued is compared against what that system sends next time. + /// + [Fact] + public void An_identifier_keeps_its_own_spacing() + { + const string nonBreaking = "subject\u00A0id"; + + Kept(nonBreaking.AsIdentifier()).ShouldBe(nonBreaking); + } + + /// Text with nothing to fold or compose comes back as the same instance, not a copy. + [Fact] + public void Clean_text_is_returned_as_it_is() + { + const string clean = "Erika Mustermann"; + + ReferenceEquals(Kept(clean.AsText()), clean).ShouldBeTrue(); + } + + private static string Kept(Result result) => + result.Match(text => text, fault => throw new ShouldAssertException($"Refused as {fault}.")); + + private static TextFault Refusal(Result result) => + result.Match(text => throw new ShouldAssertException($"Kept \"{Shown(text)}\"."), fault => fault); + + /// The text a failure prints, cut short: a refusal that failed prints the value, which can be 200,000 marks. + private static string Shown(string text) => text.Length <= 80 ? text : $"{text[..80]}... ({text.Length} chars)"; +} diff --git a/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs b/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs index e0ffae3..8c7e732 100644 --- a/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs +++ b/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs @@ -81,6 +81,9 @@ public sealed class UninitializedResultTests { ["Select(Result, Func)"] = () => Task.FromResult(default(Result).Select(x => x)), ["ToOption(Result)"] = () => Task.FromResult(default(Result).ToOption()), + ["Transpose(Option>)"] = () => Task.FromResult(Option.Some(default(Result)).Transpose()), + ["Transpose(Result, TError>)"] = () => Task.FromResult(default(Result, string>).Transpose()), + ["Traverse(Option, Func>)"] = () => Task.FromResult(Option.Some(1).Traverse(_ => default(Result))), ["SelectMany(Result, Func>, Func)"] = () => Task.FromResult(default(Result).SelectMany(_ => Value, (x, _) => x)), @@ -123,6 +126,11 @@ public sealed class UninitializedResultTests ["Traverse(IEnumerable, Func>)"] = () => Task.FromResult(new[] { 1 }.Traverse(_ => default(Result))), ["Traverse(IEnumerable, Func>)"] = () => Task.FromResult(new[] { 1 }.Traverse(_ => default(Result))), + ["Traverse(Patch, Func>)"] = () => Task.FromResult(Patch.Value(1).Traverse(_ => default(Result))), + ["TraverseAsync(IEnumerable, Func>>)"] = () => new[] { 1 }.TraverseAsync(_ => Task.FromResult(default(Result))), + ["TraverseAsync(IEnumerable, Func>>, CancellationToken)"] = () => new[] { 1 }.TraverseAsync((_, _) => Task.FromResult(default(Result)), CancellationToken.None), + ["TraverseAsync(IEnumerable, Func>>)"] = () => new[] { 1 }.TraverseAsync(_ => Task.FromResult(default(Result))), + ["TraverseAsync(IEnumerable, Func>>, CancellationToken)"] = () => new[] { 1 }.TraverseAsync((_, _) => Task.FromResult(default(Result)), CancellationToken.None), ["Sequence(IEnumerable>)"] = () => Task.FromResult(new[] { Value, default }.Sequence()), ["Sequence(IEnumerable>)"] = () => Task.FromResult(new[] { Result.Success(), default }.Sequence()), }; diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index 962fe1c..41cf41d 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -16,14 +16,15 @@ # # Four consumers: # core references only CodoMetis.TypeKit: Option and Result work, no Metalama arrives, and -# CMTK0001 and CMTK0002 fire (docs/plan.md §10). +# CMTK0001 and CMTK0002 fire (design/analyzers.md). # layered a domain library references CodoMetis.TypeKit.Generators, and an app reaches everything # only through that library: the app uses the generated members, the transitive fabric # generates a value object the app declares itself, and CMTK0001 fires in the app, in the -# domain library, and in a Razor component of a third project. +# domain library, and in a Razor component of a third project; CMTK0011 and CMTK0013 in +# the domain library, on code Metalama wove. # host a web host with both satellites: EF Core maps the value objects and translates .Value, # and the OpenAPI document describes them. -# aot the same ground published with Native AOT (docs/plan.md §11): no trim or AOT warning +# aot the same ground published with Native AOT (design/native-aot.md): no trim or AOT warning # from a package or from the code woven into the consumer, and the native binary does # what the JIT build does. # @@ -409,11 +410,33 @@ public static class Guard return id; } } + +// Woven, and named before the static constructor's body, which then swaps a member. +public sealed partial class Swapped : CodoMetis.TypeKit.ClosedSets.IClosedSet +{ + public static readonly Swapped Open = new(); + public static readonly Swapped Closed = new(); + + static Swapped() { Open = Closed; } +} + +[RequireParameterlessConstructor] +public interface IEndpoint; + +public abstract class EndpointBase { protected EndpointBase(int port) { } } + +// No parameterless constructor, and a base that base() does not reach. +public sealed class Orders : EndpointBase, IEndpoint +{ + public Orders(int port) : base(port) { } +} CSHARP run_failing dotnet build --no-incremental echo "$output" | grep -F "Guard.cs(" > "$work/layered.declaring" || true assert_contains "error CMTK0001" "$work/layered.declaring" "CMTK0001 fires in the declaring project for a local typed by a generated member" +assert_contains "error CMTK0013: 'Swapped.Open' is written outside its declaration" "$work/layered.declaring" "CMTK0013 fires in the declaring project for a member assigned in a woven set's static constructor" +assert_contains "error CMTK0011" "$work/layered.declaring" "CMTK0011 fires for a type without the constructor its interface requires" rm Guard.cs # By hand: the razorclasslib template takes no --framework. @@ -549,7 +572,7 @@ assert_contains "found=ABC:2" "$work/host.out" "value objects round-trip thro # generators weave into a consumer, and they cannot tell that a run-time path still works. Under # Native AOT an interface nothing uses is removed from a type, and reflection over it finds nothing: # that is how the OpenAPI satellite once described every value object as {} with no warning at any -# build (docs/plan.md §11). So this publishes a consumer with PublishAot, reads the publish warnings, +# build (design/native-aot.md). So this publishes a consumer with PublishAot, reads the publish warnings, # and runs the native binary. # # The value objects live in namespace Shop, so a warning about woven code names Shop.; a warning in @@ -565,10 +588,31 @@ run dotnet add package NodaTime --version "$(central_version NodaTime)" cat > Shop.cs <<'CSHARP' using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.ValueObjects; namespace Shop; +// A closed set: its name in JSON, as a value and a dictionary key, a string enum in OpenAPI, and a +// text column in EF Core, a JSON column's included. +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); +} + +// A fault set: each member's kind gives its status, its description the problem's detail. +public sealed partial class AccountFault : IFaultSet +{ + public static readonly AccountFault NotFound = new(FaultKind.NotFound, "The account does not exist."); + public static readonly AccountFault EmailTaken = new(FaultKind.Conflict, null); + + private AccountFault(FaultKind kind, string? description) { Kind = kind; Description = description; } + + public FaultKind Kind { get; } + public string? Description { get; } +} + // One per JSON strategy: a reader method (Guid), the serializer's contract for a number, the // fallback with a built-in converter (Uri, an enum), a string, and parsing through a TypeConverter. public readonly partial record struct OrderId : IValue; @@ -641,6 +685,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; using CodoMetis.TypeKit.ValueObjects; using Shop; @@ -670,6 +715,12 @@ await Check("stored", async () => return JsonSerializer.Deserialize("\"abc\"", store)!.Value; }); await Check("option-refused", async () => JsonSerializer.Serialize(new Holder(Option.Some(1)), ShopJson.Default.Holder)); +await Check("set-json", async () => JsonSerializer.Serialize(new Tracking(OrderStatus.Shipped, new() { [OrderStatus.Open] = 1 }), ShopJson.Default.Tracking)); +await Check("set-json-read", async () => +{ + var tracking = JsonSerializer.Deserialize("""{"Status":"Open","Counts":{"Shipped":2}}""", ShopJson.Default.Tracking)!; + return $"{ReferenceEquals(tracking.Status, OrderStatus.Open)} {tracking.Counts[OrderStatus.Shipped]}"; +}); // Parsing, including through a TypeConverter (NodaTime's LocalDate). await Check("parse", async () => $"{Quantity.Parse("42", null).Value} {Weekday.Parse("Monday", null).Value} {Link.Parse("https://p.test/", null).Value} {Day.Parse("2026-09-27", null).Value:yyyy-MM-dd}"); @@ -690,12 +741,24 @@ builder.Services.AddFaultResponses(fault => fault switch CodeFault.NotUpperCase => StatusCodes.Status422UnprocessableEntity, _ => throw new ArgumentOutOfRangeException(nameof(fault)), }); +// A fault set answered by its kinds, and the fault on the request for the host's own extension. +builder.Services.AddFaultResponses(); +builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => +{ + if (context.HttpContext.Features.Get() is { } fault) context.ProblemDetails.Extensions["code"] = $"ACC-{fault.Name}"; +}); await using var app = builder.Build(); app.MapOpenApi(); app.MapGet("/orders/{id}", (OrderId id, Quantity? limit) => new OrderView(id, [Quantity.From(1)], Amount.From(1.5m))); app.MapPost("/orders", (OrderView body) => body.Amount); +app.MapPost("/tracking", (Tracking body) => body.Status.Name); +app.MapPatch("/orders/{id}", (OrderId id, OrderPatch patch) => $"{patch.Code.Match(code => $"Value({code.Value})", () => "Null", () => "Absent")} {patch.Note.Match(note => $"Value({note})", () => "Null", () => "Absent")}"); app.MapGet("/codes/{text}", (string text) => Code.Create(text).ToHttpResult()); +app.MapGet("/accounts/{id}", (string id) => (id == "taken" ? Result.Error(AccountFault.EmailTaken) : Result.Error(AccountFault.NotFound)).ToHttpResult()); +app.MapGet("/strict/accounts/{id}", (string id) => Result.Error(AccountFault.EmailTaken).ToHttpResult()).WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); +app.MapGet("/detailed/accounts/{id}", (string id) => Result>.Error(Fault.Of(AccountFault.EmailTaken, "In use since 3 May.")).ToHttpResult()); +app.MapPost("/accounts", () => Result.Success("a1").ToHttpResult(id => TypedResults.Created($"/accounts/{id}", id))); await app.StartAsync(); using var http = new HttpClient { BaseAddress = new Uri(app.Urls.First()) }; @@ -710,8 +773,14 @@ var schemas = document?["components"]?["schemas"]; Console.WriteLine($"openapi-component={schemas?["OrderId"]?.ToJsonString() ?? "missing"}"); Console.WriteLine($"openapi-parameter={document?["paths"]?["/orders/{id}"]?["get"]?["parameters"]?[0]?["schema"]?.ToJsonString() ?? "missing"}"); Console.WriteLine($"openapi-items={schemas?["OrderView"]?["properties"]?["quantities"]?["items"]?.ToJsonString() ?? "missing"}"); +Console.WriteLine($"openapi-set={schemas?["OrderStatus"]?.ToJsonString() ?? "missing"}"); await Check("bound", async () => ((int)(await http.GetAsync($"/orders/{Guid.NewGuid()}?limit=3")).StatusCode).ToString()); await Check("malformed", async () => ((int)(await http.GetAsync("/orders/nope")).StatusCode).ToString()); +// A merge patch through the context: the patch's converter hides Code and string from the generator, +// so the value object reads with its own converter and the string with the serializer's built-in one. +await Check("patch", async () => await (await http.PatchAsync($"/orders/{Guid.NewGuid()}", new StringContent("""{"code":"ABC","note":null}""", System.Text.Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync()); +await Check("patch-refused", async () => ((int)(await http.PatchAsync($"/orders/{Guid.NewGuid()}", new StringContent("""{"code":"abc"}""", System.Text.Encoding.UTF8, "application/json"))).StatusCode).ToString()); +Console.WriteLine($"openapi-patch={schemas?["OrderPatch"]?["properties"]?["code"]?.ToJsonString() ?? "missing"} required={schemas?["OrderPatch"]?["required"]?.ToJsonString() ?? "none"}"); await Check("result-success", async () => await http.GetStringAsync("/codes/ABC")); await Check("result-fault", async () => { @@ -720,6 +789,21 @@ await Check("result-fault", async () => return $"{(int)response.StatusCode} {response.Content.Headers.ContentType?.MediaType} {problem?["fault"]}"; }); Console.WriteLine($"result-responses={string.Join(",", document?["paths"]?["/codes/{text}"]?["get"]?["responses"]?.AsObject().Select(r => r.Key) ?? [])}"); +async Task Problem(string path) +{ + var response = await http.GetAsync(path); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync()); + return $"{(int)response.StatusCode} {problem?["fault"]} {problem?["detail"]?.ToString() ?? "-"} {problem?["code"]}"; +} +await Check("fault-kind", () => Problem("/accounts/missing")); +await Check("fault-strict", () => Problem("/strict/accounts/x")); +await Check("fault-detail", () => Problem("/detailed/accounts/x")); +await Check("fault-created", async () => +{ + var response = await http.PostAsync("/accounts", content: null); + return $"{(int)response.StatusCode} {response.Headers.Location}"; +}); +Console.WriteLine($"fault-responses={string.Join(",", document?["paths"]?["/strict/accounts/{id}"]?["get"]?["responses"]?.AsObject().Select(r => r.Key) ?? [])}"); await app.StopAsync(); public sealed record Line(OrderId Id, Code Code, Name Name, Quantity Quantity, Amount Amount, Link Link, Weekday Weekday, @@ -727,17 +811,24 @@ public sealed record Line(OrderId Id, Code Code, Name Name, Quantity Quantity, A public sealed record OrderView(OrderId Id, List Quantities, Amount Amount); +public sealed record OrderPatch(Patch Code, Patch Note); + public sealed record Holder(Option Value); +public sealed record Tracking(OrderStatus Status, Dictionary Counts); + // Lists no wrapped type: the generated converters must not need one. [JsonSerializable(typeof(Line))] [JsonSerializable(typeof(Holder))] [JsonSerializable(typeof(Code))] [JsonSerializable(typeof(Amount))] +[JsonSerializable(typeof(Tracking))] internal partial class ShopJson : JsonSerializerContext; // The web host's: what the endpoints exchange, and what their value objects wrap, for the schemas. [JsonSerializable(typeof(OrderView))] +[JsonSerializable(typeof(OrderPatch))] +[JsonSerializable(typeof(Tracking))] [JsonSerializable(typeof(Amount))] [JsonSerializable(typeof(Code))] [JsonSerializable(typeof(string))] @@ -758,6 +849,7 @@ run dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version "$ef_versi run dotnet add package Microsoft.EntityFrameworkCore.Design --version "$ef_version" cat > Program.cs <<'CSHARP' +using CodoMetis.TypeKit; using Microsoft.EntityFrameworkCore; using Shop; @@ -771,13 +863,28 @@ var id = OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")); await using var db = new StoreDb(); await db.Database.OpenConnectionAsync(); -await db.Database.ExecuteSqlRawAsync("CREATE TABLE Orders (Id TEXT NOT NULL PRIMARY KEY, Code TEXT NOT NULL, Quantity INTEGER NOT NULL)"); +await db.Database.ExecuteSqlRawAsync("CREATE TABLE Orders (Id TEXT NOT NULL PRIMARY KEY, Code TEXT NOT NULL, Quantity INTEGER NOT NULL, Status TEXT NOT NULL, Delivery TEXT NOT NULL)"); await Check("ef-insert", async () => { - db.Orders.Add(new Order { Id = id, Code = Code.FromKnownGood("ABC"), Quantity = Quantity.From(2) }); + db.Orders.Add(new Order { Id = id, Code = Code.FromKnownGood("ABC"), Quantity = Quantity.From(2), Status = OrderStatus.Shipped, Delivery = new() { Status = OrderStatus.Open, Quantity = Quantity.From(3) } }); return (await db.SaveChangesAsync()).ToString(); }); db.ChangeTracker.Clear(); +await Check("ef-set-stored", async () => +{ + await using var command = db.Database.GetDbConnection().CreateCommand(); + command.CommandText = "SELECT Status || ' ' || Delivery FROM Orders"; + return (string)(await command.ExecuteScalarAsync())!; +}); +var shipped = OrderStatus.Shipped; +await Check("ef-set-parameter", async () => (await db.Orders.Where(o => o.Status == shipped).SingleAsync()).Status.Name); +db.ChangeTracker.Clear(); +await Check("ef-json-column", async () => +{ + var order = await db.Orders.Where(o => o.Delivery.Status == OrderStatus.Open).SingleAsync(); + return $"{order.Delivery.Status.Name} {order.Delivery.Quantity.Value}"; +}); +db.ChangeTracker.Clear(); await Check("ef-value", async () => (await db.Orders.Where(o => o.Code.Value.StartsWith("AB")).SingleAsync()).Quantity.Value.ToString()); db.ChangeTracker.Clear(); await Check("ef-parameter", async () => (await db.Orders.Where(o => o.Id == id).SingleAsync()).Code.Value); @@ -788,6 +895,14 @@ await Check("ef-stored", async () => return (await db.Orders.Where(o => o.Id == id).SingleAsync()).Code.Value; }); db.ChangeTracker.Clear(); +// The query helpers compose the query inside the package, where EF's precompiler, which works on +// the application's own call sites, never sees it: measured here, with the workaround the docs give. +await Check("ef-first-or-none", () => Task.FromResult(db.Orders.FirstOrNone(o => o.Code.Value.StartsWith("lo")).IsSome().ToString())); +db.ChangeTracker.Clear(); +await Check("ef-first-or-none-async", async () => (await db.Orders.FirstOrNoneAsync(o => o.Code.Value.StartsWith("lo"))).IsSome().ToString()); +db.ChangeTracker.Clear(); +await Check("ef-first-or-none-written", async () => (await db.Orders.Where(o => o.Code.Value.StartsWith("lo")).Take(1).ToListAsync()).FirstOrNone().IsSome().ToString()); +db.ChangeTracker.Clear(); await db.Database.ExecuteSqlRawAsync("CREATE TABLE Shipments (Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, Carrier TEXT NOT NULL)"); await Check("ef-identity", async () => { @@ -805,6 +920,15 @@ public class Order public OrderId Id { get; set; } public Code Code { get; set; } = null!; public Quantity Quantity { get; set; } + public OrderStatus Status { get; set; } = OrderStatus.Open; + public Delivery Delivery { get; set; } = new(); +} + +// Mapped to a JSON column: a closed set and a value object inside it. +public class Delivery +{ + public OrderStatus Status { get; set; } = OrderStatus.Open; + public Quantity Quantity { get; set; } } // A key over an integer, which UseTypeKit() makes an identity key as it would be for an int. @@ -820,6 +944,9 @@ public sealed class StoreDb : DbContext public DbSet Shipments => Set(); protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite("Data Source=:memory:").UseTypeKit(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().ComplexProperty(o => o.Delivery, delivery => delivery.ToJson()); } CSHARP @@ -853,16 +980,33 @@ assert_contains "openapi=200" "$work/aot.out" "the OpenAPI assert_contains 'openapi-component={"type":"string","format":"uuid"}' "$work/aot.out" "OrderId is a uuid component" assert_contains 'openapi-parameter={"type":"string","format":"uuid"}' "$work/aot.out" "the route parameter is a uuid" assert_contains 'openapi-items={"$ref":"#/components/schemas/Quantity"}' "$work/aot.out" "a list of value objects keeps its items" +assert_contains 'openapi-set={"enum":["Open","Shipped"],"type":"string"}' "$work/aot.out" "a closed set is a string enum of its members' names" assert_contains "bound=200" "$work/aot.out" "a route parameter binds through the generated TryParse" assert_contains 'result-success="ABC"' "$work/aot.out" "ToHttpResult answers a success with its value" assert_contains "result-fault=422 application/problem+json NotUpperCase" "$work/aot.out" "and a fault as problem details with its mapped status" assert_contains "result-responses=200,400,422" "$work/aot.out" "the document lists the value and each mapped status" +assert_contains "fault-kind=404 NotFound The account does not exist. ACC-NotFound" "$work/aot.out" "a fault set is answered by its kind, its description the detail, the host's extension from the fault feature" +assert_contains "fault-strict=422 EmailTaken - ACC-EmailTaken" "$work/aot.out" "an endpoint's status replaces the kind's" +assert_contains "fault-detail=409 EmailTaken In use since 3 May. ACC-EmailTaken" "$work/aot.out" "a Fault's detail reaches the problem" +assert_contains "fault-created=201 /accounts/a1" "$work/aot.out" "the endpoint's own success, with its Location" +assert_contains "fault-responses=200,404,422" "$work/aot.out" "the document lists the endpoint's status in place of the kind's" assert_contains "malformed=400" "$work/aot.out" "a malformed route parameter is a 400" +assert_contains 'patch=Value(ABC) Null' "$work/aot.out" "a Patch reads a value object and a null through the context under Native AOT" +assert_contains 'set-json={"Status":"Shipped","Counts":{"Open":1}}' "$work/aot.out" "a closed set writes its name through the context, as a value and as a dictionary key" +assert_contains "set-json-read=True 2" "$work/aot.out" "and reads the members themselves back" +assert_contains "patch-refused=400" "$work/aot.out" "a value object a patch wraps still refuses" +assert_contains 'openapi-patch={"type":["null","string"],"description":"A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it."} required=none' "$work/aot.out" "a patch property is its wrapped type's schema, inlined and nullable, and not required" assert_contains "ef-insert=1" "$work/aot.out" "EF Core writes value objects through the compiled model" assert_contains "ef-value=2" "$work/aot.out" ".Value translates in a precompiled query" assert_contains "ef-parameter=ABC" "$work/aot.out" "a value-object parameter is mapped at run time" assert_contains "ef-stored=lower" "$work/aot.out" "a stored value the rules refuse is read without them" assert_contains "ef-identity=1,2" "$work/aot.out" "a key over an integer is generated by the database" +assert_contains 'ef-set-stored=Shipped {"Quantity":3,"Status":"Open"}' "$work/aot.out" "a closed set is its name in a column and in a JSON column, through the compiled model" +assert_contains "ef-set-parameter=Shipped" "$work/aot.out" "a closed-set parameter is mapped at run time" +assert_contains "ef-json-column=Open 3" "$work/aot.out" "a JSON column reads a closed set and a value object back in a precompiled query" +assert_contains "ef-first-or-none=THREW InvalidOperationException: Query wasn't precompiled" "$work/aot.out" "a query helper is not precompiled, as the docs say: the base package's FirstOrNone on a query" +assert_contains "ef-first-or-none-async=THREW InvalidOperationException: Query wasn't precompiled" "$work/aot.out" "and the EF satellite's FirstOrNoneAsync" +assert_contains "ef-first-or-none-written=True" "$work/aot.out" "the docs' workaround, the operators written at the call site, runs" [[ $failed -eq 0 ]] || { echo; echo "consumer smoke test FAILED"; exit 1; } echo