diff --git a/CHANGELOG.md b/CHANGELOG.md index 12eb00bc..b39b96ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,30 @@ 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.4.0 — Unreleased +## 2.3.1 — 2026-10-31 ### Changed -- Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.4.0. +- Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1. +- Analyzers: CMTK0005 and CMTK0006 now warn for a type parameter bound by a value object beside `new()`, as in `where T : IValue, new()`, which CMTK0001 already reported: `new T[n]` and a `T` field nothing sets. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). +- Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#equality)). +- Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a closed set over a base class that declares a member the generators add now fails the build with CMTK1011 naming it, where it failed with a Metalama error that named nothing you wrote: its `Parse`, `TryParse`, `Match` or `All`, and an `IParsable` it implements. A base class's `ToString()` that isn't sealed now fails the build too (CMTK1011), so your build can fail when you upgrade: the generated `ToString()` replaced it without a word. Declare `ToString()` in the set, or seal the base's, which is then kept ([Closed sets](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#closed-sets)). +- Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, or read back through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +- Analyzers: CMTK0006 now warns when a constructor sets a member only on another object it creates, `new Order(...) { Id = id }` or `other with { Id = id }`, which it took as setting its own. In a build that treats warnings as errors, that can report code that built before ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)). +- EF Core: a query filter that orders values your context holds without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`, builds again; EF works it out in memory by your `CompareTo`. With a lambda, EF orders those values in the database, and the filter still fails ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +- Analyzers: CMTK0005 now also warns at `Array.CreateInstance(typeof(OrderId), n)`, `Array.CreateInstanceFromArrayType(typeof(OrderId[]), n)` and `ArrayPool.Rent(n)`, which create default slots as `new OrderId[n]` does. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). +- Generators: an overload of a generated member that C# tells apart by its type parameters, or by a parameter that is `ref`, `in` or `out` where the generated one's isn't (or the other way round), is now kept beside the generated member, on a value object, a closed set or a base of either, where the build failed with CMTK1011: a `Parse(string, IFormatProvider?)`, a `From(in int)`, a `TryParse` whose last parameter isn't `out` ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a generic `ToString()`, or a generic or by-`in` `CompareTo(TSelf)`, is no longer taken for the one you may write: a value object printed what a record prints (`Level { Value = 3 }`), a closed set its type name, and the ordering was marked hand-written, so EF Core refused to order by it. A generic or by-reference overload of `TryFrom`, `Revalidate`, `CompareTo(object)`, `Equals`, `GetHashCode` or `PrintMembers` is kept beside the generated member, where the build failed with CMTK1011 or CMTK1008 ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a member of a base record or of a closed set's base class that the type can't see, a `private` one such as a `private const int MaxValue`, doesn't count as its own: it stands in for no generated member, so the checks above leave it alone and the type is generated as in 2.3.0 ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). + +### Fixed + +- Analyzers: CMTK0005 no longer reports an array with a constant zero in any one dimension, such as `new OrderId[0, count]`, or a zero written as `0L` or, in a `stackalloc`, `(byte)0`: neither holds an element ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). +- Analyzers: CMTK0006 no longer reports an explicit interface implementation that every constructor assigns through a cast, `((IHasId)this).Id = id` ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)). +- OpenAPI: each element of a list, an array or a dictionary of `Patch` is described as a patch property is, the argument's schema admitting null, where it admitted anything. A nullable `Patch?` is its argument's schema admitting null, where it was an empty required component; System.Text.Json reads a null there as a `null` patch, as it reads a property left out ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). ## 2.3.0 — 2026-10-29 diff --git a/Directory.Build.props b/Directory.Build.props index b5a6ba1b..7333e245 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.4.0 + 2.3.1 CaffeinatedCoder Copyright (c) 2026 CaffeinatedCoder MIT diff --git a/README.md b/README.md index 04d5db6f..9974e69a 100644 --- a/README.md +++ b/README.md @@ -375,7 +375,7 @@ your projects. Thanks to the Metalama team. ## Status -2.3.0 is the current version. 1.0.0, released on 28 September 2026, was the first. +2.3.1 is the current version. 1.0.0, released on 28 September 2026, was the first. [Migrating from 2.0 to 2.1](docs/migrating-to-2-1.md) lists what builds or runs differently since 2.0, and [Migrating from 1.5 to 2.0](docs/migrating-to-2.md) what changed since 1.5. diff --git a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md new file mode 100644 index 00000000..e9771b58 --- /dev/null +++ b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md @@ -0,0 +1,148 @@ +# 0121. A generated member written by hand is refused wherever the value object reaches it + +Date: 2026-10-30 +Status: accepted + +Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191), under the 2.3.1 epic +[#198](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/198), found while +[#188](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/pull/188) closed the same gap for the arithmetic +operators, where an interface's explicit implementation is kept, from an outside review of that pull request; +widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195), +[#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197) and +[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194), from a second outside review, by +[#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192), and by +[#205](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/205) from a sweep, before 2.3.1 was tagged; refines +[decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to +[decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses a member the +generators introduce written by hand, and an explicit implementation of an interface they implement, on the value +object and, since phase 11, on a base record (CMTK1011, CMTK1008 for the comparison interfaces). +`ValueObjectDeclaration` listed the value object's members for the first, and the value object's and its base +records' for the second, and `CreateDeclarations` the value object's own. + +## What was measured + +On `release/2.4.0` at `97c87c1`, 2026-10-30, with a consumer of the generators, the code these members are +generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches: + +- **An interface's explicit implementation was replaced without a word.** An interface can implement a member + of a base interface explicitly, and every type that declares it reaches that default until it declares a + public member of its own. The generated member is one, for each of the eleven interfaces the generators + implement: `IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable`, `ISpanParsable`, + `IUtf8SpanParsable`, `IMinMaxValue`, `IComparable`, `IComparable`, `IComparisonOperators` (each answered + the generated member's result through a generic call, where the interface's threw or answered its own) and + `IConvertible` (below). The same held two interfaces down and through an interface of a base record. +- **Another assembly's were missed too, a base record's included.** Metalama lists no private member of a type + in another assembly, and an explicit implementation is private, so the listing saw none in a referenced + project. An interface there was replaced as above. A base record there was replaced or kept answering the + interface: an `IFormattable` call on a value object over a referenced record that implements it printed `1`, + where the base answered its own text, and `Max()` over a referenced `MinMaxBase` reached the base's, + which threw. A closed set over a referenced base class that implements `IClosedSet` explicitly listed + two members in `All` and none through `T.All`, named `Red` "base" through the interface, and EF Core stored + "base" and read no member's name back. The rule has had this gap since phase 11. + `INamedType.TryFindImplementationForInterfaceMember` finds every one of them. +- **A base record's public member counted for nothing** (#197). Its public static `MinValue` and `MaxValue`, with + `IMinMaxValue` or without, were the value object's, and no `MinValue` or `MaxValue` was generated. Its + public `Value`, `From`, `Parse`, `TryParse`, `ToString(string?, IFormatProvider?)`, `TryFormat` or + `CompareTo(object)` failed the aspect (LAMA0500), naming nothing the user wrote. From another assembly, a + public static member that implements a static abstract one reads as an explicit implementation, since the + compiler records every such implementation as one, so it was refused as "the explicit implementation of ...". +- **A base record's `Create` gave two rule sets** (#195). Where the value object reaches the marker only through + a base record of another project that implements `Create` explicitly, `TryFrom("x")` ran the value object's + `Create`, which accepted, and generic `T.Create("x")` the base's, which refused. Declaring the marker on the value + object as well re-implements it, and both agree. In the same project such a base carries the marker and is + refused already (CMTK1022). +- **A seam a base record declares stood in for the generated member** (#194). A base's `CompareTo(TSelf)` was not + seen as the seam: no `[HandWrittenOrdering]`, so an EF Core query ordered by the column (`[1, 2]`) where memory + ordered `[2, 1]`; the generated `CompareTo(TSelf)` was not introduced, since one existed, and the generated + operators and `CompareTo(object)` called `left.CompareTo(right)`, which C# binds to the generated + `CompareTo(object)`, declared on the more derived type, so `<` overflowed the stack and ended the process. A + base's `TryFrom(T)` and `Revalidate()` were the value object's, and the generated ones, which run `Create`, were + not introduced. A base's `CompareTo(object)` failed the aspect (LAMA0500). A base's `FromKnownGood(T)` is another + signature than the generated one, which takes the caller's expression too, and is hidden by it. +- **`IConvertible` declared through an interface** failed the aspect (LAMA0041), as declaring it directly did + before CMTK1011 named it. So did `IValueObject<,>`, `IValueObjectMaterializer<,>` and `IPlainValueObject<,>` + declared through an interface of the user's, one interface down or two, of this project or another, or by a base + record (LAMA0512, and LAMA0041 for the materializer), the shape of an `IEntityId : IValueObject` written to constrain generic code (#192). So did a closed set's `IParsable` declared through an interface (LAMA0512). A + closed set's interface that implements `IClosedSet.Name` or `TryParse` explicitly was replaced by the + generated members without a word. +- **A closed set's base class counted for nothing** (#205, measured on `release/2.3.1` at `dbdd44e`). + `ClosedSetTypes` read the set's own members. A base class's `Parse(string)`, `Parse(string, bool)`, + `TryParse(string?, bool, out TSelf)`, `Match`, a static `All`, a protected `_name`, an `IParsable` it + implements, and its `sealed` `ToString()` failed the aspect (LAMA0500, LAMA0502, LAMA0503, LAMA0512), in this + project and from another. Its `ToString()` that is not sealed was replaced by the generated one without a word, + where a value object's base record's is CMTK1011. A base's private `_name` and `_all`, and a `[JsonConverter]` on + it, built and still do. +- **An interface's `==`, `!=` and `IEquatable.Equals` are replaced by the record's own**, in a plain record + as in a value object, so the generators change nothing there. For the same reason + `ValueObjectContractAspect.ExplicitlyImplementsEquatableBetween` has no gap: a base record's synthesized + `Equals(TBase)` implements `IEquatable` before any interface's default (a `HashSet` of the base held + both value objects). +- **.NET implements three such members itself**: `INumberBase` implements `IUtf8SpanFormattable.TryFormat` + and `IUtf8SpanParsable.Parse` and `TryParse` explicitly, as bridges to the UTF-16 members. A scan of the + interfaces of `System.Private.CoreLib` for explicit implementations of the eleven found no other; the reference + assembly carries the same three. + +## Decision + +**CMTK1011, and CMTK1008 for the comparison interfaces, refuse a member the generators introduce wherever the +value object reaches it**: written on it, or in a base record, public or explicit, or as an explicit implementation +in an interface it implements, directly or through another, in this assembly or another. The error names the +member and where it is, "in its base type `X`" or "in its interface `X`", and a public member by its own name, +never as an explicit implementation. A closed set's `IClosedSet` is held the same way. + +- **Found by the interface mapping, never by listing members.** For each member of a generated interface the + value object implements, `TryFindImplementationForInterfaceMember` gives the implementation C# maps it to; one + other than the value object's own public member is refused. The record's own `==` is what the mapping finds for + an interface's `==`, so that case needs no rule of its own. A base record's public member of a generated name + that implements no interface is found by the same names and signatures as on the value object. +- **A seam counts where the value object declares it.** A base record's `CompareTo(TSelf)` or `CompareTo(object)` + is CMTK1008, and its `TryFrom(T)` or `Revalidate()` CMTK1011, which says to declare it on the value object, calling + the base's if that is what is meant. Taking a base's as the seam was the alternative: it would need the generated + operators to call it past C#'s overload resolution and the ordering marker to follow it, for a seam the value + object can declare in one line. A base record's `sealed` `ToString()` stays the seam, since C# keeps it in every + derived record and the value object cannot declare its own. +- **A base record's `Create` counts.** One the value object reaches through the marker, explicit or beside its own, + is CMTK1011, which says to declare the marker on the value object itself. A base's public `Create` alone is one + rule set, which the generated code calls too, and builds. +- **A closed set's base class counts as a value object's base record does** (#205). Its members of a generated + name or signature, other than private ones, and an `IParsable` it implements are CMTK1011, naming the + base. Its `sealed` `ToString()` is the seam; one that is not sealed is CMTK1011, which says to declare + `ToString()` in the set or seal the base's. Taking an unsealed one as the seam was the alternative: C# would keep + it in a derived class, but the set has printed its member's name over it since closed sets shipped, and taking it + would change what such a set prints in a patch, without a word. +- **`IConvertible`, the three contracts and a set's `IParsable` declared through an interface or by a base + record** are CMTK1011, naming the interface or the base, where the aspect failed. Accepting the contracts through + an interface, which the generated `Value` would implement, was the alternative; it would add behaviour to a patch + for a constraint that `IValueObject` itself serves. An interface derived from a marker is how a value + object may be found, and stays allowed: neither marker derives from a contract. +- **Not refused: an interface's explicit implementation of a member of an interface of its own library.** That + is .NET's `INumberBase`: every .NET number replaces its bridges with its own UTF-8 members, as the + generated ones do, and refusing them would leave no value object able to implement `INumberBase`. The + test is by assembly symbol, never by name. +- **Not this decision:** a hand-written `Equals(TBase)` is + [#196](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/196)'s. + +**The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1: a value object or a closed set +that reaches an interface's explicit implementation of a generated member, or a base record's in another +assembly; a value object over a base record whose public `MinValue` or `MaxValue` stood in for the generated one; +one whose generic `T.Create` reached a base's `Create` beside its own; one over a base record that declares +`CompareTo(TSelf)`, `TryFrom` or `Revalidate`; and a closed set over a base class whose `ToString()` is not sealed, +whose generated `ToString()` replaced the base's. Each is code that never runs (an +interface's default the generated member replaced) or a second answer beside the generated one (a base's kept +member), in every build that contains it, so the error names a defect, never correct code. The defect shipped in +2.3.0, so its fix ships in the patch 2.3.1, not in a minor: waiting would keep a wrong answer in every 2.3 build, +and decision 119's reason, an error that names a defect in every build that contains it, holds after a tag as it +does before one. The changelog's Changed section says which +declarations fail and what to write instead. The forms that failed the aspect before fail the build with a CMTK +error now, which breaks no build. + +`BuildOutcomeTests` holds it: one value object per interface, two interfaces down, through a base record's +interface, a record class over a reference type, and from another assembly (`IProbeFormats`, `IProbeBounded`, +`IProbeOrders`, `ProbeFormattableRecord` and `ProbeMinMaxRecord` in `CodoMetis.TypeKit.Generators.Probes`); a +base record's public member of each kind, in this project and in a library the generators do not run in, which +the fixture builds beside its consumers; a base record's `Create`; a base record's seams, and its `FromKnownGood`, which builds; a closed set through an interface of either +assembly and over the library's base class; a closed set over a base class of either assembly that declares each +generated member (#205); and the cases that build: a `ToString()` seam, a `Uri`'s missing +ordering, an interface's `==`, an `INumberBase` value object, one `Create` over a base record, and a closed set over a base class's +`sealed` `ToString()`, private fields or `[JsonConverter]`. diff --git a/design/adr/README.md b/design/adr/README.md index 245695ff..9abd10ba 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,3 +127,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted | | [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted | +| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); a closed set's base class counts as a base record does, its `sealed` `ToString()` the seam and one not sealed refused (#205); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197, #205) | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 3c1a23bd..70b46134 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -169,6 +169,35 @@ 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. + - **Wherever the value object reaches it (decided 2026-10-30, [decision 121](adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md)).** + An interface's explicit implementation is the default every type declaring the interface reaches until it + declares a public member, so the generated one replaced it without a word; a base record's public member of + a generated name was kept as the value object's (`MaxValue`) or failed the aspect (LAMA0500); and a base + record's `Create` from another project was a second rule set for generic `T.Create`. A seam counts where the + value object declares it: a base record's `CompareTo(TSelf)`, `TryFrom` or `Revalidate` stood in for the + generated member, and an inherited `CompareTo` made `<` overflow the stack (#194). A base's member the value + object cannot see (`IsAccessibleFrom`), a private one, reaches nothing: C# hides it, the generated member sits + beside it, and 2.3.0 generated the type, so it is not counted, on a value object or a closed set (#230). Found by the interface + mapping (`TryFindImplementationForInterfaceMember`), never by listing members: Metalama lists no private + member of a type in another assembly, so a base record's from a referenced project was missed as well. + `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>` and `IPlainValueObject<,>` declared through an + interface or by a base record, and a closed set's `IParsable` through an interface, are CMTK1011 where + they failed the aspect (LAMA0041, LAMA0512, #192). A closed set's base class counts as a base record does: its + members of a generated name or signature and its `IParsable` are CMTK1011, its `sealed` `ToString()` is + the seam and one not sealed is CMTK1011 (#205). Not refused: an interface's `==`, which the record's own + replaces anyway, and `INumberBase`'s UTF-8 bridges. + - **A signature is what C# overloads by (#226).** A hand-written method matches a generated one only with no type + parameters, as the generated one has none, and with each parameter by reference where the generated one's is + `out` and by value where it is by value. A generic `Parse(string, IFormatProvider?)`, a `From(in int)` + and a `TryParse` whose last parameter is by value were CMTK1011, on the type, a base record and a closed set, + though Metalama introduces the generated member beside each (measured 2026-10-31). `ref` or `in` against `out` is + no overload in C# (CS0663), and nullability none at all, so both stay CMTK1011. + The other checks by name and parameters, the seams and what a record synthesizes, read only a method without + type parameters whose parameters are all by value (`ValueObjectDeclaration.IsByValueAndNotGeneric`), as every + generated member but `TryParse` and `TryFormat` is: a generic `ToString()` was taken for the seam, so + the value object printed what a record prints and a closed set its type name, a generic or by-`in` + `CompareTo(TSelf)` marked the ordering hand-written, and a generic or by-reference `TryFrom`, `Revalidate`, + `CompareTo(object)`, `Equals`, `GetHashCode` or `PrintMembers` was CMTK1011 or CMTK1008. - **Equality over a base record is generated (decided 2026-10-14, [decision 75](adr/0075-equality-over-base-record-generated.md)).** A derived record's synthesized equality calls the base's, which compares the base's fields and whatever the base declares. A base comparing by reference made two value objects holding 42 unequal diff --git a/design/closed-sets.md b/design/closed-sets.md index 09f9766b..2ff3e871 100644 --- a/design/closed-sets.md +++ b/design/closed-sets.md @@ -12,7 +12,7 @@ The consumer's guide is [docs/closed-sets.md](../docs/closed-sets.md). - **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` + hand (the one seam; a base class's `sealed` one counts, one not sealed is refused), 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): diff --git a/design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md b/design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md new file mode 100644 index 00000000..d8baeb19 --- /dev/null +++ b/design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md @@ -0,0 +1,26 @@ +--- +verified: 2026-10-31 +pins: + - Microsoft.AspNetCore.OpenApi 10.0.12 + - sdk 10.0.4xx +source: System.Text.Json in the .NET 10 runtime, the contract of a Nullable from the reflection resolver and a JSON null read into T? as a property, an element and the root, over a T whose converter handles null, measured; and the schemas Microsoft.AspNetCore.OpenApi hands its schema transformers below a Nullable>, measured in PatchSchemaTests +turned-on: the fix of issue 232 (pull request 237), where a nullable patch is described as its argument admitting null but not as a merge-patch field, the container check reads the kind before the element type, and the argument's elements below a nullable patch are skipped +test: test/CodoMetis.TypeKit.Facts/KB057.cs +--- + +# KB-057. A `Nullable` contract has `T` as its element type, and a JSON null read into it is null whatever `T`'s converter does + +The contract System.Text.Json gives a `Nullable`: + +- has `Kind` `None` and `ElementType` `T`, for a built-in `T` and one with a converter of its own; `T`'s own + contract has no element type; +- reads a JSON null as null, without calling `T`'s converter, even one whose `HandleNull` is true. That holds as a + property, as an element (`List`) and at the root. A plain `T` hands the same null to its converter. + +So a `T?` property the request sets to null reads the same as one it leaves out. For `Patch?` that means a +null is never the `Null` state, and a request can't clear the field through it. + +Microsoft.AspNetCore.OpenApi walks a schema's `items` and `additionalProperties` with the contract's element type, +whatever its kind. Below a nullable schema it hands them to every schema transformer as `T`: the numbers of a +`Patch?` reached the patch transformer as a `Patch` and gained a null. `PatchSchemaTests` holds that +consequence (its guard's seeds), and this entry's test the serializer's part. diff --git a/design/knowledge/KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md b/design/knowledge/KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md new file mode 100644 index 00000000..e3ede511 --- /dev/null +++ b/design/knowledge/KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md @@ -0,0 +1,23 @@ +--- +verified: 2026-10-31 +pins: + - Microsoft.EntityFrameworkCore.Sqlite 10.0.12 +source: measured with plain EF Core on SQLite (`ToQueryString`), and with a value object whose `CompareTo` is descending in `HandWrittenOrderingTests` +turned-on: the EF satellite's refusal of an ordering in a query filter (`HandWrittenOrdering.OrderingRunsInMemory`, issue 225), which lets an ordering over the context's own values through only when it takes no lambda +test: test/CodoMetis.TypeKit.Facts/KB058.cs +--- + +# KB-058. EF orders captured values in SQL only when the chain takes a lambda + +A LINQ chain over a collection a query captures, `values.Max()`, `values.Min()`, `values.Order().First()` or +`values.First()`, is evaluated in memory: the SQL holds one parameter with the answer. With a lambda, +`values.OrderBy(v => v).First()` or `values.OrderByDescending(v => v).First()`, EF sends the collection's elements and +orders them in SQL: for an `int[]`, one parameter per element in a `VALUES` list (EF 10's default), `ORDER BY "v"."Value" +DESC LIMIT 1`. `values.Max(v => v)` over an `int[]` throws a `NullReferenceException` inside EF. A query filter that +reads the context's own values, `HasQueryFilter(r => r.P == Values.Max())`, is keyed on the same distinction. + +For a value object whose `CompareTo` is its own, the first kind orders by `CompareTo` and the second by the stored +value: over 1, 2, 3 with a descending `CompareTo`, `Values.Max()` is 1 and `Values.OrderByDescending(v => v).First()` +answered 3. `MaxBy(v => v)` over the parameter is not translated at all. One chain with a lambda, +`values.Select(v => (object)v).Max()`, was evaluated in memory too; the satellite still refuses it, since the +distinction it can rely on is the lambda. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index c9e4b6fd..1ca47cc7 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -85,3 +85,5 @@ test and later for the harness's `Baseline.Fact`. | [KB-052](KB-052-sql-server-takes-no-max-of-a-bit.md) | SQL Server takes neither `MAX` nor `MIN` of a `bit` | SQL Server 2022, EF SqlServer 10.0.12 | `KB052`, with Docker | | [KB-053](KB-053-greatest-and-least-need-compatibility-level-160.md) | EF translates `Greatest` and `Least` on SQL Server only from compatibility level 160 | EF SqlServer 10.0.12 | `KB053` | | [KB-054](KB-054-datetime2-keeps-every-tick-and-forgets-the-kind.md) | `datetime2` keeps every tick of a `DateTime` and reads it back with `Kind` Unspecified | SQL Server 2022, EF SqlServer 10.0.12 | `KB054`, with Docker | +| [KB-057](KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md) | A `Nullable` contract has `T` as its element type, and a JSON null read into it is null whatever `T`'s converter does | OpenApi 10.0.12, SDK 10.0.4xx | `KB057` | +| [KB-058](KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md) | EF orders captured values in SQL only when the chain takes a lambda: `values.Max()` runs in memory, `values.Max(v => v)` in SQL | EF Sqlite 10.0.12 | `KB058` | diff --git a/design/patch.md b/design/patch.md index 5fc4981f..9f070298 100644 --- a/design/patch.md +++ b/design/patch.md @@ -59,7 +59,7 @@ names what is missing. | Where | How | Held by | |---|---|---| -| OpenAPI | The argument's schema, inlined, admitting null, required only where the serializer requires it, in 3.1 and 3.0 | `PatchSchemaTests` | +| OpenAPI | The argument's schema, inlined, admitting null, required only where the serializer requires it, in 3.1 and 3.0; each element of a list or a dictionary of patches as a property is, in 2.0 too; a nullable patch admits the same, without the merge-patch description, since the serializer reads its null as null | `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 | — | diff --git a/docs/comparison.md b/docs/comparison.md index a14ce08b..3f55fd02 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -14,7 +14,8 @@ Every fact here was read from each library's source, at the release named in the - **13 October 2026** for closed sets. - **14 October 2026**, again, for OpenAPI and Native AOT. - **29 October 2026** for SuperStrong.Types. Again for Thinktecture.Runtime.Extensions, at 10.6.0, and - LanguageExt, at 5.0.0-beta-78. And again for TypeKit's own columns, at 2.3.0. + LanguageExt, at 5.0.0-beta-78. +- **31 October 2026** for TypeKit's own columns, at 2.3.1. What was read for each library, and when, is kept in the repository's [verified facts](../design/knowledge/README.md). Each table below lists the entries it rests on. @@ -43,7 +44,7 @@ Sources: FluentResults [KB-018](../design/knowledge/KB-018-fluentresults.md), On ## Value objects in detail -| | TypeKit 2.3.0 | Vogen 8.0.7 (the 9.0.0-beta.2 tag the same; not on NuGet) | Thinktecture.Runtime.Extensions 10.6.0 | StronglyTypedId 1.0.0-beta08 | SuperStrong.Types 1.0.0 | +| | TypeKit 2.3.1 | Vogen 8.0.7 (the 9.0.0-beta.2 tag the same; not on NuGet) | Thinktecture.Runtime.Extensions 10.6.0 | StronglyTypedId 1.0.0-beta08 | SuperStrong.Types 1.0.0 | |---|---|---|---|---|---| | 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 | `[StrongType]` on a partial class, not a record or a struct | | Validation | `Create` returns a `Result`, and every generated way of creating a value runs it | `Validate` returns a `Validation`. `From` throws | A `ValidateFactoryArguments` partial method | None | A `Define()` method built from validators such as `IsPositive()`. `From` throws, `TryFrom` returns `false`, and JSON, parsing, the type converter and EF Core reads run it | @@ -70,7 +71,7 @@ register means describing it in the document by hand, type by type. ## Option and Result in detail -| | TypeKit 2.3.0 | LanguageExt 4.4.9 (5.0.0-beta-78) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | +| | TypeKit 2.3.1 | LanguageExt 4.4.9 (5.0.0-beta-78) | 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 fails the build when written in code (CMTK0001) | `Option`: `None` | `Maybe`: none. `Result`: a success holding `default(T)` | A success holding `default(T)` | @@ -82,7 +83,7 @@ Sources: LanguageExt [KB-015](../design/knowledge/KB-015-languageext-option.md), ## Closed sets in detail -| | TypeKit 2.3.0 | Ardalis.SmartEnum 8.2.0 | Intellenum 2.0.1 | Thinktecture.Runtime.Extensions 10.6.0 | +| | TypeKit 2.3.1 | Ardalis.SmartEnum 8.2.0 | Intellenum 2.0.1 | Thinktecture.Runtime.Extensions 10.6.0 | |---|---|---|---|---| | 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 | diff --git a/docs/merge-patches.md b/docs/merge-patches.md index d8b5a387..7ae87ea2 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -194,6 +194,14 @@ OpenAPI 3.1 and 3.0: A value object inside a patch is described through its underlying type, so `email` is a `string`. +A patch inside a list or a dictionary is described the same way, element by element. So a +`Dictionary>`, where a request sets a key with a value and clears it with `null`, +is an object whose values are a string or `null`. + +A nullable `Patch?` accepts the same values. But System.Text.Json reads a `null` there as a +`null` patch, the same as a property the request leaves out, so you can't tell a clear from no change. +Use `Patch` for a field a request can clear. + **Good to know** - **This isn't ASP.NET Core's JSON Patch** (RFC 6902, `application/json-patch+json`), which is a list diff --git a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md index 10d875a9..96921277 100644 --- a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md @@ -62,4 +62,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------------------------------------------------------------- CMTK0014 | Usage | Warning | UndefinedEnumValueAnalyzer, a FaultKind or ConstructorAccessibility that names no member -## Release 2.3.0 +## Release 2.3.1 diff --git a/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs index 3a45eede..1707b0b0 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using Microsoft.CodeAnalysis; @@ -16,10 +17,14 @@ namespace CodoMetis.TypeKit.Analyzers; /// CMTK0001 sees default written out, and none of these forms writes it: new OrderId[n], /// stackalloc OrderId[n], GC.AllocateUninitializedArray<OrderId>(n), /// GC.AllocateArray and Array.Resize (measured 2026-09-28), which adds default slots -/// whenever the new size is larger, and the rule cannot tell whether it is. An array with initial -/// elements, a constant length or size of zero and a collection expression start with no default -/// element and are not reported. A warning, since filling such an array in a loop straight after is -/// correct code the rule cannot tell apart. +/// whenever the new size is larger, and the rule cannot tell whether it is. Also +/// Array.CreateInstance(typeof(OrderId), n), Array.CreateInstanceFromArrayType(typeof(OrderId[]), n) +/// and ArrayPool<OrderId>.Rent(n) (#228): the first two only where a typeof names the element, +/// since a Type variable could be anything, and a rented array is reported as a fresh one, all +/// default, though a reused one holds what it was returned with. An array with initial +/// elements, a constant length or size of zero in any one dimension and a collection expression start +/// with no default element and are not reported. A warning, since filling such an array in a loop +/// straight after is correct code the rule cannot tell apart. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class DefaultFilledCollectionAnalyzer : DiagnosticAnalyzer @@ -52,9 +57,10 @@ public override void Initialize(AnalysisContext context) var gc = start.Compilation.GetTypeByMetadataName("System.GC"); var array = start.Compilation.GetTypeByMetadataName("System.Array"); + var pool = start.Compilation.GetTypeByMetadataName("System.Buffers.ArrayPool`1"); start.RegisterOperationAction(operation => AnalyzeArrayCreation(operation, symbols), OperationKind.ArrayCreation); - start.RegisterOperationAction(operation => AnalyzeInvocation(operation, symbols, gc, array), OperationKind.Invocation); + start.RegisterOperationAction(operation => AnalyzeInvocation(operation, symbols, gc, array, pool), OperationKind.Invocation); // IOperation has no public node for stackalloc; the syntax is read instead. start.RegisterSyntaxNodeAction(node => AnalyzeStackAlloc(node, symbols), SyntaxKind.StackAllocArrayCreationExpression); @@ -66,38 +72,91 @@ private static void AnalyzeArrayCreation(OperationAnalysisContext context, TypeK var creation = (IArrayCreationOperation)context.Operation; if (creation.Initializer is not null || creation.Type is not IArrayTypeSymbol { ElementType: var element }) return; - if (creation.DimensionSizes.All(size => size.ConstantValue is { HasValue: true, Value: 0 })) return; + // Any dimension of zero leaves the array with no element: new T[0, n] holds none. + if (creation.DimensionSizes.Any(size => IsZero(size.ConstantValue))) return; if (Diagnose(context.ContainingSymbol, creation.Syntax, "An array created with a length", element, symbols) is { } diagnostic) context.Report(diagnostic); } - private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitSymbols symbols, INamedTypeSymbol? gc, INamedTypeSymbol? array) + private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitSymbols symbols, INamedTypeSymbol? gc, INamedTypeSymbol? array, INamedTypeSymbol? arrayPool) { var invocation = (IInvocationOperation)context.Operation; var method = invocation.TargetMethod; - if (method.TypeArguments.Length != 1) return; + string? form = null; + ITypeSymbol? element = null; - string? form = null; - var size = -1; - - if (SymbolEqualityComparer.Default.Equals(method.ContainingType, gc) && AllocatingMethods.Contains(method.Name)) + if (method.TypeArguments.Length == 1 && SymbolEqualityComparer.Default.Equals(method.ContainingType, gc) && AllocatingMethods.Contains(method.Name)) + { + // (int length, bool pinned = false) + form = $"GC.{method.Name}"; + element = method.TypeArguments[0]; + } + else if (method.TypeArguments.Length == 1 && SymbolEqualityComparer.Default.Equals(method.ContainingType, array) && method.Name == "Resize") + { + // (ref T[]? array, int newSize) + form = "Array.Resize"; + element = method.TypeArguments[0]; + } + else if (SymbolEqualityComparer.Default.Equals(method.ContainingType, array) && method.Name is "CreateInstance" or "CreateInstanceFromArrayType") { - form = $"GC.{method.Name}"; - size = 0; // (int length, bool pinned = false) + // (Type elementType or arrayType, the lengths: one to three ints, int[] or long[], then int[] lowerBounds or nothing). + // Only a typeof names the element; a Type held in a variable could be anything. + var type = invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == 0)?.Value; + while (type is IConversionOperation conversion) type = conversion.Operand; + + form = $"Array.{method.Name}"; + element = (type as ITypeOfOperation)?.TypeOperand; + if (method.Name == "CreateInstanceFromArrayType") element = (element as IArrayTypeSymbol)?.ElementType; } - else if (SymbolEqualityComparer.Default.Equals(method.ContainingType, array) && method.Name == "Resize") + else if (method.Name == "Rent" && PoolOf(method, arrayPool) is { } pool) { - form = "Array.Resize"; - size = 1; // (ref T[]? array, int newSize) + // (int minimumLength): a fresh array from the pool holds nothing but default slots. + form = "ArrayPool.Rent"; + element = pool.TypeArguments[0]; } - if (form is null) return; + if (form is null || element is null) return; + + // A length of zero in any dimension creates no slot, as new T[0] and new T[0, n] do not. + if (Lengths(invocation).Any(length => IsZero(length.ConstantValue))) return; + + if (Diagnose(context.ContainingSymbol, invocation.Syntax, form, element, symbols) is { } diagnostic) context.Report(diagnostic); + } + + /// The ArrayPool<T> whose Rent is or overrides. + private static INamedTypeSymbol? PoolOf(IMethodSymbol method, INamedTypeSymbol? arrayPool) + { + for (var current = method; current is not null; current = current.OverriddenMethod) + { + if (SymbolEqualityComparer.Default.Equals(current.ContainingType.OriginalDefinition, arrayPool)) return current.ContainingType; + } - // A length of zero creates no slot, as new T[0] does not. - if (invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == size)?.Value.ConstantValue is { HasValue: true, Value: 0 }) return; + return null; + } - if (Diagnose(context.ContainingSymbol, invocation.Syntax, form, method.TypeArguments[0], symbols) is { } diagnostic) context.Report(diagnostic); + /// + /// The length arguments of an allocating call: every int or long after the element, and the + /// elements of a params or written-out array of lengths, but not the lower bounds. + /// + private static IEnumerable Lengths(IInvocationOperation invocation) + { + foreach (var argument in invocation.Arguments) + { + if (argument.Parameter is not { } parameter || parameter.Name == "lowerBounds" || parameter.RefKind != RefKind.None) continue; + + switch (parameter.Type) + { + case { SpecialType: SpecialType.System_Int32 or SpecialType.System_Int64 }: + yield return argument.Value; + break; + + case IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_Int32 or SpecialType.System_Int64 } + when argument.Value is IArrayCreationOperation { Initializer: { } lengths }: + foreach (var length in lengths.ElementValues) yield return length; + break; + } + } } private static void AnalyzeStackAlloc(SyntaxNodeAnalysisContext context, TypeKitSymbols symbols) @@ -107,13 +166,32 @@ private static void AnalyzeStackAlloc(SyntaxNodeAnalysisContext context, TypeKit if (stackAlloc.Initializer is not null || stackAlloc.Type is not ArrayTypeSyntax arrayType) return; var sizes = arrayType.RankSpecifiers.SelectMany(rank => rank.Sizes); - if (sizes.All(size => context.SemanticModel.GetConstantValue(size, context.CancellationToken) is { HasValue: true, Value: 0 })) return; + if (sizes.Any(size => IsZero(context.SemanticModel.GetConstantValue(size, context.CancellationToken)))) return; if (context.SemanticModel.GetTypeInfo(arrayType.ElementType, context.CancellationToken).Type is not { } element) return; if (Diagnose(context.ContainingSymbol, stackAlloc, "A stackalloc with a length", element, symbols) is { } diagnostic) context.Report(diagnostic); } + /// + /// A constant zero of any integral type: a dimension may be a long, uint or ulong, + /// and a size read from syntax keeps its type before the conversion to int, (byte)0 or '\0'. + /// + private static bool IsZero(Optional constant) => + constant.HasValue && constant.Value switch + { + int value => value == 0, + long value => value == 0, + uint value => value == 0, + ulong value => value == 0, + short value => value == 0, + ushort value => value == 0, + byte value => value == 0, + sbyte value => value == 0, + char value => value == 0, + _ => false, + }; + private static Diagnostic? Diagnose(ISymbol? containingSymbol, SyntaxNode node, string form, ITypeSymbol element, TypeKitSymbols symbols) { if (!symbols.IsNoDefaultStruct(element)) return null; diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index b78e309d..e6923964 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -30,7 +30,7 @@ project. The two packages share a version and move together. | CMTK0002 | Error | A type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet`, but the compilation doesn't reference `CodoMetis.TypeKit.Generators`. So nothing is generated for it: no field, no `Value`, no factory, or for a closed set no name and no `Match`. | | CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection or a tuple of them that the statement made, dropped. It counts as dropped as a statement, awaited or not; when its `Task` is converted to a plain `Task`, also where a method group is the conversion; through `Wait`; in a loop that never reads it; and in a branch of a conditional, a `switch` expression or a `??` that picked the call. Also an assertion of `CodoMetis.TypeKit.Testing` on a task, left without `await`. `_` says you mean the drop. | | CMTK0004 | Error | `Materialize`, which rebuilds a value object without its rules, called or referenced anywhere but the EF Core package. | -| CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero isn't reported. | +| CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero, in any dimension, isn't reported. | | CMTK0006 | Warning | A field or auto-property of such a type, in a class, that no initializer, `required` or constructor sets. Also a `required` one that a `[SetsRequiredMembers]` constructor doesn't set. | | CMTK0007 | Info | `FromKnownGood` given a value that comes straight from a parameter. | | CMTK0008 | Warning | The values inside two different value objects compared, `order.CustomerId.Value == product.Id.Value`, or the two value objects themselves compared through `Equals(object)`. | @@ -302,8 +302,11 @@ OrderId[] kept = [.. rows.Select(r => OrderId.From(r.Id))]; // built from valu ``` `stackalloc`, `GC.AllocateUninitializedArray`, `GC.AllocateArray` and `Array.Resize` are reported -too. `Array.Resize` adds default slots whenever the new size is larger, which the rule can't tell. A -constant length or size of zero creates no slot, as `new OrderId[0]` doesn't, and isn't reported. +too, and so are `Array.CreateInstance` and `Array.CreateInstanceFromArrayType` when a `typeof` names +the element, and `ArrayPool.Rent`. A `Type` held in a variable could be anything, so it isn't +reported. `Array.Resize` adds default slots whenever the new size is larger, which the rule can't tell. A +constant length or size of zero creates no slot, as `new OrderId[0]` doesn't, and isn't reported. That +holds for any one dimension: `new OrderId[0, count]` holds no element either. It's a warning, because an array filled in a loop straight after it is created is correct, and the rule can't tell it from one that isn't. @@ -325,6 +328,10 @@ object through it and then set the properties. `required` works with EF Core's c precompiled queries, and makes System.Text.Json reject a document without the property. A Blazor component's `[Parameter]` of a value-object type is reported too, and `required` works there. +An explicit interface implementation is assigned the only way a class can reach it, through a cast: +`((IHasId)this).Id = id` in every constructor keeps it quiet. A local that holds `this` isn't +followed, so `IHasId self = this; self.Id = id;` is still reported. + The message suggests `required` only where it compiles. That leaves out a get-only property or a `readonly` field (CS9034), and a member, or a setter, less visible than the class (CS9032). There the message says to initialize the member or assign it in every constructor. diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs index 6952b38d..505d68b5 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs @@ -296,8 +296,12 @@ private static bool IsDeclaredBy(IMethodSymbol method, ImmutableArray - public bool IsNoDefaultStruct(ITypeSymbol type) => - (type.IsValueType || type is ITypeParameterSymbol { HasValueTypeConstraint: true }) && DefaultRestriction(type) is not null; + /// + /// The types CMTK0001 judges, so CMTK0005 and CMTK0006 cover "such a type" as the README says: a + /// parameter bound by a value object beside new() is one too. Reading struct alone left + /// where T : IValue<int>, new() silent here while CMTK0001 reported it (#207). + /// + public bool IsNoDefaultStruct(ITypeSymbol type) => DefaultRestriction(type) is not null; /// /// Why a default is not a valid instance, as CMTK0001 says it, or diff --git a/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs index 5b7c9f22..d510591a 100644 --- a/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs @@ -33,7 +33,9 @@ namespace CodoMetis.TypeKit.Analyzers; /// serializers materialize through before they set the properties, which is how entities declare it. /// A record's copy constructor copies every member and is exempt too. A constructor that chains to /// another with this(…) assigns what that one assigns. An assignment on any path counts, so -/// the rule errs towards silence. +/// the rule errs towards silence. An assignment through a cast of this counts too, an interface's +/// member as the class's implementation of it: ((IHasId)this).Id = id is the only way a class +/// reaches its explicit implementation (#231). A local holding this is not followed. /// /// /// A required member is set by every object initializer, except behind a constructor marked @@ -281,11 +283,11 @@ public static Assignments Of(ImmutableArray blocks, INamedTypeSymbol switch (operation) { case IAssignmentOperation assignment: - Collect(assignment.Target, members); + Collect(assignment.Target, members, type); break; case IArgumentOperation { Parameter.RefKind: RefKind.Out or RefKind.Ref } argument: - Collect(argument.Value, members); + Collect(argument.Value, members, type); break; case IInvocationOperation { TargetMethod.MethodKind: MethodKind.Constructor, Instance: IInstanceReferenceOperation } chained @@ -326,22 +328,36 @@ private static bool Accepts(IMethodSymbol constructor, int count) => constructor.Parameters.Count(parameter => !parameter.IsOptional && !parameter.IsParams) <= count && (count <= constructor.Parameters.Length || constructor.Parameters.LastOrDefault()?.IsParams == true); - private static void Collect(IOperation target, HashSet members) + private static void Collect(IOperation target, HashSet members, INamedTypeSymbol type) { switch (target) { - case IFieldReferenceOperation { Instance: IInstanceReferenceOperation } field: + case IFieldReferenceOperation field when OnThis(field.Instance): members.Add(field.Field.AssociatedSymbol ?? field.Field); break; - case IPropertyReferenceOperation { Instance: IInstanceReferenceOperation } property: - members.Add(property.Property); + // ((IHasId)this).Id = id reaches the interface's member; the class holds its implementation. + case IPropertyReferenceOperation property when OnThis(property.Instance): + members.Add(property.Property.ContainingType.TypeKind == TypeKind.Interface + ? type.FindImplementationForInterfaceMember(property.Property) ?? property.Property + : property.Property); break; case ITupleOperation tuple: - foreach (var element in tuple.Elements) Collect(element, members); + foreach (var element in tuple.Elements) Collect(element, members, type); break; } } + + /// + /// this, under any casts: a class reaches its explicit implementations only through one. An object + /// initializer's receiver is an instance reference too, of the object it creates, not this one. + /// + private static bool OnThis(IOperation? instance) + { + while (instance is IConversionOperation conversion) instance = conversion.Operand; + + return instance is IInstanceReferenceOperation { ReferenceKind: InstanceReferenceKind.ContainingTypeInstance }; + } } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs index 17865cc1..a220d4e9 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; @@ -7,7 +8,8 @@ namespace CodoMetis.TypeKit.AspNetCore; /// /// A property in the document: its argument's schema, inlined, required only /// where the serializer requires it, and admitting null, since the Null state is part of the wire format whatever the -/// argument is. +/// argument is. The same for each element of a list or a dictionary of patches, and for a nullable patch, which the +/// serializer reads as the argument or null but not as a merge-patch field (). /// /// /// @@ -25,6 +27,14 @@ namespace CodoMetis.TypeKit.AspNetCore; /// internal sealed class PatchSchemaTransformer : IOpenApiSchemaTransformer { + /// + /// The argument's elements below a nullable patch's schema, which ASP.NET visits again as the patch: the serializer + /// gives a an element type, T, and ASP.NET visits items and + /// additionalProperties as that type. Filled as a patch, a Patch<int[]>?'s numbers admitted null + /// (KB-057). + /// + private static readonly ConditionalWeakTable ArgumentElements = new(); + /// A patch is inlined where it is used, never hoisted into a component of its own. public static void Apply(OpenApiOptions options) { @@ -32,7 +42,17 @@ public static void Apply(OpenApiOptions options) options.CreateSchemaReferenceId = type => IsPatch(type.Type) ? null : inner(type); } - private static bool IsPatch(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Patch<>); + private static bool IsPatch(Type type) => Argument(type) is not null; + + /// + /// The argument of a , or of a nullable one, whose JSON is the same values or null. The serializer + /// reads a null into a nullable patch as null, never the patch's Null state, as it leaves an absent property: so a + /// nullable patch admits null without being described as a merge-patch field (KB-057). + /// + private static Type? Argument(Type type) => + (Nullable.GetUnderlyingType(type) ?? type) is { IsGenericType: true } patch && patch.GetGenericTypeDefinition() == typeof(Patch<>) + ? patch.GetGenericArguments()[0] + : null; /// /// The argument's schema, as it is, with null admitted beside every value it admits. @@ -89,11 +109,23 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC } } - if (!IsPatch(info.Type)) return; + // A container of patches arrives without its element, as one of value objects does: ASP.NET leaves out + // items or additionalProperties for a converter-backed element before any transformer runs. The patch's + // own schema is put back, only where it is missing; asked for, it is filled below, as a property's is. + // The kind first: the serializer gives a Nullable an element type too, T. + if (info.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary && info.ElementType is { } element && IsPatch(element)) + { + if (info.Kind == JsonTypeInfoKind.Enumerable && schema.Items is null) + schema.Items = await PolymorphicBaseBranch.GetOrCreateSchemaAsync(context, element, parameterDescription: null, cancellationToken); + else if (info.Kind == JsonTypeInfoKind.Dictionary && schema.AdditionalProperties is null) + schema.AdditionalProperties = await PolymorphicBaseBranch.GetOrCreateSchemaAsync(context, element, parameterDescription: null, cancellationToken); + return; + } + + if (Argument(info.Type) is not { } argument || ArgumentElements.TryGetValue(schema, out _)) return; // What the argument is described as comes first, the host's word or a converter's that replaces a // value object's generated one, as it does where the argument is a property of its own. - var argument = info.Type.GetGenericArguments()[0]; // The argument is held by the patch alone, where ASP.NET asks no transformer of it: a document and a // controller that describe it differently are told apart here. DescribedOptionsAgreement.ThrowIfDisagreeing(context.ApplicationServices, argument, info.Options); @@ -106,6 +138,13 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC schema.Properties?.Clear(); AdmitNull(schema, inner); + if (Nullable.GetUnderlyingType(info.Type) is not null) + { + foreach (var below in new[] { schema.Items, schema.AdditionalProperties }) + if (below is not null) ArgumentElements.AddOrUpdate(below, argument); + return; + } + 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 231481f0..97b8d980 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -150,6 +150,10 @@ described as `T`'s schema: A value object inside a patch is described through its underlying type. That's also what a host on source-generated JSON can do, because the patch's converter hides the value object from the context. +Each element of a list, an array or a dictionary of patches is described as a patch property is. A +nullable patch, `Patch?`, admits the same values and null, but isn't described as a merge patch: +a `null` there reads as a `null` patch, the same as a property left out. + **A described type.** ASP.NET can't see through a JSON converter you write yourself. It writes the type as `{}`, or with only the `format` its CLR type maps to. It also drops a list's `items` and a dictionary's `additionalProperties` of that type. The same goes for a converter a library registers for diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs index 09a9e8cf..d20033d1 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; @@ -110,13 +111,20 @@ protected override Expression VisitMethodCall(MethodCallExpression node) 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) + else if (IsLinq(method) && OrderedType(method) is { } ordered && !OrderingRunsInMemory(node)) { 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}"); + // value object's CompareTo, while EF drops the conversion and orders by the column. So are + // elements converted before the operator, Select(r => (object)r.Rank).Max(), whose key is the + // element itself or which has no key (#206), and a key that reads the boxed value back: through a ??, + // a ?:, a member of a projection, a grouping's key or elements, a join, or a subquery's First. + var key = KeySelector(node); + IEnumerable read = key is not null ? ReadTypes(key.Body, Bound(node, _scope)) + : method.IsGenericMethod ? ElementTypes(node.Arguments[0], _scope, method.GetGenericArguments()[0]) + : []; + foreach (var type in read) Refuse(type, $"ordered by {method.Name}"); } else if (IsDbFunction(method) && !RunsInMemory(node)) { @@ -134,9 +142,23 @@ protected override Expression VisitMethodCall(MethodCallExpression node) Refuse(element, $"compared in a row value by {method.Name}"); } - return base.VisitMethodCall(node); + // The operator's lambdas are visited knowing what their parameters stand for, so that an operator inside + // one, g.Max() in Select(g => g.Max()), reads the elements its source was built from. + var outer = _scope; + _scope = Bound(node, outer); + try + { + return base.VisitMethodCall(node); + } + finally + { + _scope = outer; + } } + /// What the parameters of the lambdas around the node being visited stand for. + private Scope _scope = Scope.Empty; + /// A function of EF.Functions: an extension of , whichever provider declares it. private static bool IsDbFunction(MethodInfo method) => method.IsStatic && method.GetParameters() is [{ ParameterType: var functions }, ..] && functions == typeof(DbFunctions); @@ -160,6 +182,196 @@ private static IEnumerable TupleElements(Type tuple) private static Expression StripQuotes(Expression expression) => expression is UnaryExpression { NodeType: ExpressionType.Quote } quote ? StripQuotes(quote.Operand) : expression; + /// What an expression of the query stands for, as far as the query says: a value, or a sequence. + private abstract record Meaning; + + /// An expression, read where its lambdas' parameters stand for what says. + private sealed record Value(Expression Expression, Scope Scope) : Meaning; + + /// A grouping (its keys, and its elements), or with no keys, a sequence a lambda is given whole. + private sealed record Group(IReadOnlyList Keys, IReadOnlyList Elements) : Meaning; + + /// What each lambda parameter in reach stands for; a parameter it does not name is read as its own type. + private sealed class Scope(ImmutableDictionary> bound) + { + public static readonly Scope Empty = new(ImmutableDictionary>.Empty); + + public Scope With(ParameterExpression parameter, IReadOnlyList meanings) => new(bound.SetItem(parameter, meanings)); + + public IReadOnlyList Of(ParameterExpression parameter) => bound.TryGetValue(parameter, out var meanings) ? meanings : []; + } + + /// + /// with the parameters of a LINQ operator's lambdas bound: to the elements of the source + /// for a selector, key or predicate; for Join and GroupJoin, each side's to its sequence; for + /// SelectMany's result selector, the collection's elements; for GroupBy's, the key and the elements. + /// + private static Scope Bound(MethodCallExpression call, Scope scope) + { + if (!IsLinq(call.Method) || !call.Method.IsGenericMethod || call.Method.Name is nameof(Enumerable.Aggregate)) return scope; + + var lambdas = Lambdas(call); + if (lambdas.Count == 0) return scope; + + var source = Elements(call.Arguments[0], scope); + switch (call.Method.Name) + { + case nameof(Queryable.Join) or nameof(Queryable.GroupJoin) when lambdas is [var outerKey, var innerKey, var result]: + var inner = Elements(call.Arguments[1], scope); + return scope.With(outerKey.Parameters[0], source).With(innerKey.Parameters[0], inner).With(result.Parameters[0], source) + .With(result.Parameters[1], call.Method.Name is nameof(Queryable.Join) ? inner : [new Group([], inner)]); + case nameof(Queryable.SelectMany): + var collection = scope.With(lambdas[0].Parameters[0], source); + return lambdas is [var selector, var resultSelector] + ? collection.With(resultSelector.Parameters[0], source).With(resultSelector.Parameters[1], Elements(selector.Body, collection)) + : collection; + case nameof(Queryable.GroupBy): + var (keys, elements) = Grouping(lambdas, scope, source); + var grouped = lambdas.Where(lambda => lambda.Parameters.Count == 1).Aggregate(scope, (bound, lambda) => bound.With(lambda.Parameters[0], source)); + return lambdas.FirstOrDefault(lambda => lambda.Parameters.Count == 2) is { } resultOf + ? grouped.With(resultOf.Parameters[0], keys).With(resultOf.Parameters[1], [new Group([], elements)]) + : grouped; + default: + return lambdas.Aggregate(scope, (bound, lambda) => bound.With(lambda.Parameters[0], source)); + } + } + + /// A GroupBy's keys, its key selector's body, and its elements, its element selector's body or the source's. + private static (IReadOnlyList Keys, IReadOnlyList Elements) Grouping(IReadOnlyList lambdas, Scope scope, IReadOnlyList source) + { + var selectors = lambdas.Where(lambda => lambda.Parameters.Count == 1).ToList(); + Meaning Read(LambdaExpression selector) => new Value(selector.Body, scope.With(selector.Parameters[0], source)); + + return ([Read(selectors[0])], selectors is [_, var element, ..] ? [Read(element)] : source); + } + + private static List Lambdas(MethodCallExpression call) => [.. call.Arguments.Select(StripQuotes).OfType()]; + + /// + /// What a sequence's elements are, as far as the query says: a Select's body, a join's or a GroupBy's + /// result, a grouping, SelectMany's collection, followed through the operators that pass the elements on, + /// and the elements of a grouping or a sequence a lambda was given; none for a sequence the query does not build. + /// + private static IReadOnlyList Elements(Expression sequence, Scope scope) + { + switch (Unconverted(sequence)) + { + case ParameterExpression parameter: + return [.. scope.Of(parameter).OfType().SelectMany(group => group.Elements)]; + case MethodCallExpression { Method: { IsGenericMethod: true } method } call when IsLinq(method): + var lambdas = Lambdas(call); + switch (method.Name) + { + case nameof(Queryable.Select) when lambdas is [var selector]: + return [new Value(selector.Body, Bound(call, scope))]; + case nameof(Queryable.SelectMany) when lambdas is [var selector]: + return Elements(selector.Body, Bound(call, scope)); + case nameof(Queryable.SelectMany) or nameof(Queryable.Join) or nameof(Queryable.GroupJoin) when lambdas is [.., { Parameters.Count: 2 } result]: + return [new Value(result.Body, Bound(call, scope))]; + case nameof(Queryable.GroupBy) when lambdas is [_, ..]: + if (lambdas.FirstOrDefault(lambda => lambda.Parameters.Count == 2) is { } resultOf) return [new Value(resultOf.Body, Bound(call, scope))]; + var (keys, elements) = Grouping(lambdas, scope, Elements(call.Arguments[0], scope)); + return [new Group(keys, elements)]; + case nameof(Queryable.Cast) or nameof(Queryable.OfType): + var target = method.GetGenericArguments()[0]; + return [.. Elements(call.Arguments[0], scope).Where(element => element is not Value value || ReadTypes(value.Expression, value.Scope).Any(target.IsAssignableFrom))]; + default: + return [.. Upstream(call).SelectMany(upstream => Elements(upstream, scope))]; + } + default: + return []; + } + } + + /// + /// The types a sequence's elements have under their boxing and reference conversions. + /// is the element type the consuming operator reads, for a sequence the query does not build. + /// + private static IEnumerable ElementTypes(Expression sequence, Scope scope, Type declared) => + Elements(sequence, scope) is [_, ..] elements ? elements.SelectMany(element => TypesOf(element, declared)) : [declared]; + + private static IEnumerable TypesOf(Meaning meaning, Type declared) => + meaning is Value value ? ReadTypes(value.Expression, value.Scope) : [declared]; + + /// + /// The types an expression reads under its boxing and reference conversions: both sides of a ?? and a + /// ?:, what a parameter stands for, the expression a member of a projection or a grouping's key was set + /// from, and the element a First and its kind take. + /// + private static IEnumerable ReadTypes(Expression expression, Scope scope) + { + switch (Unconverted(expression)) + { + case BinaryExpression { NodeType: ExpressionType.Coalesce } coalesce: + return [.. ReadTypes(coalesce.Left, scope), .. ReadTypes(coalesce.Right, scope)]; + case ConditionalExpression conditional: + return [.. ReadTypes(conditional.IfTrue, scope), .. ReadTypes(conditional.IfFalse, scope)]; + case ParameterExpression parameter when scope.Of(parameter) is [_, ..] meanings: + return meanings.SelectMany(meaning => TypesOf(meaning, parameter.Type)); + case MemberExpression { Expression: { } instance } member when Members(instance, member.Member, scope) is [_, ..] meanings: + return meanings.SelectMany(meaning => TypesOf(meaning, member.Type)); + case MethodCallExpression call when TakesAnElement(call) && Elements(call.Arguments[0], scope) is [_, ..] elements: + return elements.SelectMany(element => TypesOf(element, call.Type)); + case var read: + return [read.Type]; + } + } + + /// + /// What of was set from: an argument of a new, a + /// binding of a member initializer, a grouping's key, found in the expression itself or in what it stands for. + /// + private static IReadOnlyList Members(Expression instance, MemberInfo member, Scope scope) + { + switch (Unconverted(instance)) + { + case NewExpression { Members: { } members } created: + return [.. members.Select((declared, index) => (declared, index)) + .Where(pair => pair.declared.HasSameMetadataDefinitionAs(member)) + .Select(pair => new Value(created.Arguments[pair.index], scope))]; + case MemberInitExpression initialized: + return [.. initialized.Bindings.OfType() + .Where(binding => binding.Member.HasSameMetadataDefinitionAs(member)) + .Select(binding => new Value(binding.Expression, scope))]; + case ParameterExpression parameter: + return [.. scope.Of(parameter).SelectMany(meaning => MembersOf(meaning, member))]; + case MemberExpression { Expression: { } outer } nested: + return [.. Members(outer, nested.Member, scope).SelectMany(meaning => MembersOf(meaning, member))]; + case MethodCallExpression call when TakesAnElement(call): + return [.. Elements(call.Arguments[0], scope).SelectMany(element => MembersOf(element, member))]; + default: + return []; + } + } + + private static IReadOnlyList MembersOf(Meaning meaning, MemberInfo member) => meaning switch + { + Value value => Members(value.Expression, member, value.Scope), + Group group when member is { Name: nameof(IGrouping<,>.Key), DeclaringType: { IsGenericType: true } declaring } + && declaring.GetGenericTypeDefinition() == typeof(IGrouping<,>) => group.Keys, + _ => [], + }; + + /// An operator that answers one of its source's elements: First, Single, Last, ElementAt and their OrDefault forms. + private static bool TakesAnElement(MethodCallExpression call) => + IsLinq(call.Method) && call.Method.IsGenericMethod + && call.Method.Name is nameof(Queryable.First) or nameof(Queryable.FirstOrDefault) or nameof(Queryable.Single) or nameof(Queryable.SingleOrDefault) + or nameof(Queryable.Last) or nameof(Queryable.LastOrDefault) or nameof(Queryable.ElementAt) or nameof(Queryable.ElementAtOrDefault); + + /// The sequences whose elements an operator passes on as they are: its source, both for Concat and its kind; none for any other. + private static Expression[] Upstream(MethodCallExpression call) => call.Method.Name switch + { + nameof(Queryable.AsQueryable) or nameof(Enumerable.AsEnumerable) or nameof(Queryable.Where) or nameof(Queryable.Distinct) + or nameof(Queryable.Skip) or nameof(Queryable.Take) or nameof(Queryable.SkipWhile) or nameof(Queryable.TakeWhile) + or nameof(Queryable.SkipLast) or nameof(Queryable.TakeLast) or nameof(Queryable.Reverse) or nameof(Queryable.DefaultIfEmpty) + or nameof(Queryable.DistinctBy) or nameof(Queryable.OrderBy) or nameof(Queryable.OrderByDescending) or nameof(Queryable.ThenBy) + or nameof(Queryable.ThenByDescending) or nameof(Queryable.Order) or nameof(Queryable.OrderDescending) => [call.Arguments[0]], + nameof(Queryable.Concat) or nameof(Queryable.Union) or nameof(Queryable.Intersect) or nameof(Queryable.Except) => [call.Arguments[0], call.Arguments[1]], + _ => [], + }; + + private static bool IsLinq(MethodInfo method) => method.DeclaringType == typeof(Queryable) || method.DeclaringType == typeof(Enumerable); + /// 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 @@ -172,6 +384,15 @@ private static Expression Unconverted(Expression expression) => /// private bool RunsInMemory(Expression comparison) => skipsWhatRunsInMemory && !DatabaseReads.In(comparison); + /// + /// Whether EF evaluates an ordering before translating, in a filter: it reads nothing of the database and takes + /// no lambda. Over the context's own values EF evaluates Limits.Max() and Limits.Order().First() in + /// memory, by CompareTo, but sends Limits to the database as a parameter and orders it there for + /// Limits.Max(l => l) or Limits.OrderByDescending(l => l).First(), by the column's order + /// (measured with EF Core 10 on SQLite, #225). An ordering with a lambda that EF happens to evaluate stays refused. + /// + private bool OrderingRunsInMemory(Expression ordering) => RunsInMemory(ordering) && !LambdaFinder.In(ordering); + /// 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 { @@ -206,6 +427,29 @@ private void Refuse(Type type, string how) } /// Whether an expression reads a parameter it does not declare itself, or a query. + /// Whether an expression holds a lambda, quoted or not. + private sealed class LambdaFinder : ExpressionVisitor + { + private bool _found; + + public static bool In(Expression expression) + { + var finder = new LambdaFinder(); + finder.Visit(expression); + + return finder._found; + } + + public override Expression? Visit(Expression? node) => _found || node is null ? node : base.Visit(node); + + protected override Expression VisitLambda(Expression node) + { + _found = true; + + return node; + } + } + private sealed class DatabaseReads : ExpressionVisitor { private readonly HashSet _declared = []; diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 47641fc3..f33da9e2 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -211,11 +211,22 @@ instead, which works everywhere and may add a cast: such as Npgsql's `EF.Functions.GreaterThan((o.Priority, o.Id), (priority, id))`; - `Min` or `Max`. + That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, + and when the cast value reaches the ordering another way, through `??`, a member of a projection, a + grouping's key, a join or a subquery's `First()`, as in + `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory + still orders by your `CompareTo`. + Compare or order by `.Value` where the underlying value's order is what you mean (`OrderBy(o => o.Priority.Value)`), or order in memory after the query. Equality isn't ordering, and stays allowed. A query filter (`HasQueryFilter`, named or not) that does the same fails when the model is built, naming the entity type, since it applies to every query of that type. + One exception: a filter may order values your context holds, without a lambda, as in + `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a + lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that + filter fails too. + ### On SQL Server A value object behaves on SQL Server as the type inside it does there. That isn't always how it diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index bd675fb5..cef30513 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -79,6 +79,14 @@ public override void BuildAspect(IAspectBuilder builder) foreach (var (member, earlier, reason) in ClosedSetTypes.CaseCollisions(members)) problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, reason.Replace("{0}", $"'{target.Name}.{earlier.Name}'")))); + // A base class's ToString() that is not sealed was replaced by the generated one without a word (#205), + // as a value object's base record's was. A sealed one is the seam (DeclaresToString). + if (ValueObjectDeclaration.UnsealedInheritedToString(target) is { } inheritedToString) + problems.Add(HandWrittenGeneratedMember.WithArguments((target, + $"{inheritedToString.ToDisplayString()} in its base type, which is not sealed", + $"its generated ToString() replaces it and prints the member's name; declare ToString() in {target.Name} (it may return base.ToString()), " + + "or seal the base's, which C# keeps in every derived class and the generators then keep too"))); + // 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); diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index 3b2ac823..41d6c12c 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -189,21 +189,52 @@ private static string WithoutComments(string text) /// 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. /// + /// + /// An explicit implementation of IClosedSet<TSelf> in an interface the set implements is the + /// set's too: the generated Name and TryParse replaced an interface's without a word (#191, + /// measured 2026-10-30). IParsable<TSelf> declared through an interface failed the aspect as + /// declaring it does (LAMA0512). + /// + /// A base class's count as the set's own (#205): its Parse, TryParse, Match or All, + /// and an IParsable<TSelf> it implements, failed the aspect (LAMA0500, LAMA0503, LAMA0512) naming + /// nothing the user wrote, in this project and from another (measured 2026-10-30). A member of a base the set cannot + /// see, such as a private _name field or Parse(string), is out of its sight and builds (#230). + /// + /// public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList members) { - var text = TypeFactory.GetType(SpecialType.String); - var boolean = TypeFactory.GetType(SpecialType.Boolean); + (IType, bool) text = (TypeFactory.GetType(SpecialType.String), false); + (IType, bool) boolean = (TypeFactory.GetType(SpecialType.Boolean), false); + (IType, bool) result = (set, true); + var parsable = TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(set); var memberNames = new HashSet(members.Select(member => member.ToDisplayString())); + var generatedNames = GeneratedNames(set); + var bases = ValueObjectDeclaration.SelfAndBases(set).Skip(1).ToList(); + + IEnumerable Declared(Func> find) => + ValueObjectDeclaration.SelfAndBases(set).SelectMany(type => find(type).Where(method => method.IsAccessibleFrom(set)).Select(method => type.Equals(set) + ? ValueObjectDeclaration.Describe(method) + : $"{ValueObjectDeclaration.Describe(method)} in its base type {type.ToDisplayString()}")); + + List found = + [ + .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. generatedNames]).Where(declared => !memberNames.Contains(declared)), + .. bases.SelectMany(type => type.Fields.Concat(type.Properties).Concat(type.Events) + .Where(member => member is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && member.IsAccessibleFrom(set) && generatedNames.Contains(member.Name)) + .Select(member => $"{member.ToDisplayString()} in its base type {type.ToDisplayString()}")), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean)))), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, result) || SameParameters(method, text, boolean, result)))), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count))), + .. ValueObjectDeclaration.ImplementationsOf(set, [TypeFactory.GetNamedType(typeof(IClosedSet<>))]), + .. set.ImplementedInterfaces.Where(implemented => implemented.Equals(parsable)).Select(implemented => $"the interface {implemented.ToDisplayString()}"), + .. set.ImplementedInterfaces.Where(implemented => implemented.AllImplementedInterfaces.Any(inherited => inherited.Equals(parsable))).Select(implemented => $"the interface {parsable.ToDisplayString()} in its interface {implemented.ToDisplayString()}"), + .. bases.Where(type => type.ImplementedInterfaces.SelectMany(implemented => implemented.AllImplementedInterfaces.Prepend(implemented)).Any(inherited => inherited.Equals(parsable))) + .Select(type => $"the interface {parsable.ToDisplayString()} in its base type {type.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()}"), + .. found.Distinct(), .. 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<>)))) @@ -213,10 +244,15 @@ .. set.Attributes.Where(attribute => attribute.Type.Equals(TypeFactory.GetNamedT 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); + /// + /// Parameter types equal, nullability aside, and each by reference where the generated one is out, in a + /// method without type parameters: Metalama refuses to introduce a method beside one of the same signature. A + /// generic Parse<T>(string) or a TryParse(string?, TSelf) is another method (#226). + /// + private static bool SameParameters(IMethod method, params (IType Type, bool Out)[] parameters) => + method.TypeParameters.Count == 0 + && method.Parameters.Count == parameters.Length + && method.Parameters.Zip(parameters, (declared, expected) => declared.Type.ToNonNullable().Equals(expected.Type.ToNonNullable()) && declared.RefKind != RefKind.None == expected.Out).All(same => same); /// The generated Match<TResult>: one Func<TResult> per member. private static bool IsGeneratedMatch(IMethod method, int memberCount) => @@ -226,10 +262,11 @@ private static bool IsGeneratedMatch(IMethod method, int memberCount) => && 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<>))); + /// + /// Whether a ToString() is written by hand: the seam, kept in place of the generated one. A base class's + /// sealed one is the seam too, as for a value object: C# keeps it in every derived class, and generating + /// one failed the aspect (LAMA0502, #205). A base class's one that is not sealed is refused instead + /// (). + /// + public static bool DeclaresToString(INamedType set) => ValueObjectDeclaration.DeclaresToString(set); } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 8d3818be..bae8fa89 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -215,20 +215,53 @@ method that calls `From` or `Create`. A value object may declare its own `TryFrom`, `FromKnownGood`, `Revalidate`, `CompareTo(TSelf)` and `ToString()`. Each one is kept, and whatever depends on it is built from it. +Declare them on the value object itself. A `TryFrom`, `Revalidate` or `CompareTo` in a base record fails +the build (CMTK1011, or CMTK1008 for `CompareTo`), because it would stand in for the generated member +without a word. A base record's `sealed` `ToString()` is the one exception, as described below. + Writing any other generated member or attribute by hand fails the build, naming it (CMTK1011). That covers: - **A plain value object's members**: `From`, `Value`, `Parse` and `TryParse`, `MinValue`/`MaxValue`. - **The attributes**: `[JsonConverter]` and `[TypeConverter]`. -- **The interfaces the generators implement**, and `IConvertible` where they generate it. +- **The contracts the generators implement**: `IValueObject`, `IValueObjectMaterializer`, + `IPlainValueObject`, and `IConvertible` where they generate it. That holds whether you declare one + directly, through an interface of your own or in a base record. To constrain generic code, use + `IValueObject` itself, which every value object implements. - **An explicit 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`. + `IParsable.Parse` or `IFormattable.ToString`. On the value object or a base record, every + caller through the interface would reach it rather than the generated member, and a generic + `T.Parse` would skip `Create`. +- **The same explicit implementation in an interface the value object implements.** The generated + member would replace it, so it would never run. The error names the interface, in your project or + another. +- **Any of these members in a base record**, public or explicit, in your project or another. A base's + `MaxValue` would stand in for the generated one, and generic code calling `T.MaxValue` would reach + it. The error names the base. A base's `private` member is fine: the value object can't see it, so + it stands in for nothing. + +An overload of a generated member is yours, and the generated one sits beside it. C# tells two methods +apart by their type parameters and by whether a parameter is `ref`, `in` or `out`, so a +`Parse(string, IFormatProvider?)`, a `From(in int value)` or a `TryParse` whose last parameter +isn't `out` is kept, on the value object or a base record. A `ref` or `in` where the generated member has +`out` is the same method to C#, as a different nullability is, so it fails the build like the member itself. +The same goes for the members you may write: a generic `ToString()` or a `CompareTo(in TSelf)` isn't +the one that's kept, so the generated `ToString()` and ordering are still there. + +Two explicit implementations in an interface are fine, because a value object loses nothing by +replacing them. A record's own `==` and `!=` replace an interface's, generated or not. And .NET's +`INumberBase` formats and parses UTF-8 through the UTF-16 members, which every .NET number +replaces with its own, as the generated members do. Declare `Create` once, `public static`, and never as an explicit interface implementation. The generated code can't call an explicit one. And beside a public one, generic code calling `T.Create` would reach the explicit one's rules, not the ones every generated way of creating a value runs. +The same goes for a `Create` your value object inherits from a base record in another project. If +the value object only gets the marker through that base, generic code calling `T.Create` reaches the +base's `Create`, while everything generated calls yours, and the build fails (CMTK1011). Declare the +marker on the value object too, and your `Create` is the one both reach. + To change what a value object prints, declare `ToString()`. A record's `PrintMembers(StringBuilder)` without it fails the build (CMTK1011), since the generated `ToString()` prints the underlying value and never calls it. @@ -247,7 +280,9 @@ What a base record declares counts as the value object's own: Equality is the underlying value's, like the ordering, the JSON and the column. So a hand-written `Equals(TSelf)`, `GetHashCode()`, `IEquatable.Equals`, or `operator ==` or `!=` between two of -the type, fails the build (CMTK1011). It would make values equal that sort apart. +the type, fails the build (CMTK1011). It would make values equal that sort apart. So does an `Equals` +you write for one of its base records' types, such as `Equals(DeepBase?)`: a `HashSet` would +compare by it while `==` compares the underlying value. To make values equal that differ only in form, such as "abc" and "ABC", normalise them in `Create`. Equal values then hold the same underlying value. A plain value object becomes an `IValidatedValue` @@ -436,8 +471,13 @@ public sealed partial class OrderStatus : IClosedSet, IParsable(string name)`, is kept too. Any other generated member written by hand + fails the build (CMTK1011), in the set or in a class it derives from. A `private` one in that + class is fine, since the set can't see it. +- **A base class's `ToString()`.** A `sealed` one is kept, since C# keeps it in every derived class. + One that isn't sealed fails the build (CMTK1011), because the generated `ToString()` would replace + it. Declare `ToString()` in the set, or seal the base's. - **An instance beside the members has no name.** If the set's own code creates one, reading its name throws rather than writing a null. - **A JSON `null` reads as `null`**, as for any class, unless the serializer's @@ -569,7 +609,8 @@ The one place the input still appears is System.Text.Json's own: a rejected dict To order differently, declare `CompareTo(TSelf)` yourself. It's kept, and the object overload, the operators and the interfaces are built from it. Any other hand-written comparison member fails the build (CMTK1008), an explicit implementation of `IComparable`, `IComparable` or -`IComparisonOperators` included. So in memory, every comparison agrees with your `CompareTo`. +`IComparisonOperators` included, in an interface the value object implements too. So in memory, +every comparison agrees with your `CompareTo`. A database can't run your `CompareTo`, because it orders the column by the underlying value. So the type carries `[HandWrittenOrdering]`, and an EF Core query that compares or orders it, or takes its @@ -592,10 +633,10 @@ error ends with a link to this table. CMTK1023 is the one warning: | 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 add (`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. | +| CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones, on the value object, a base record or an interface it implements. | | 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 doesn't compile. A part a source generator adds, such as a `[GeneratedRegex]` method's, doesn't count. | -| CMTK1011 | A hand-written member or attribute the generators add (see "Write 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. | +| CMTK1011 | A hand-written member or attribute the generators add (see "Write 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, on the value object, a base record or an interface it implements, 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 underlying 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. | | CMTK1013 | A closed set not declared `partial`. | | CMTK1014 | A closed set not declared `sealed`. A set with subtypes isn't closed. | diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index db8d4e9f..9e32c324 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs @@ -277,12 +277,27 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp return false; } + // A seam is kept where the value object declares it. A base record's stood in for the generated member + // without a word, so it is refused, naming what to declare instead (#194). + var inheritedSeams = ValueObjectDeclaration.InheritedSeams(target, namedValueType, kind); + + if (inheritedSeams.Count > 0) + { + builder.Diagnostics.Report(HandWrittenGeneratedMember.WithArguments((target, string.Join(", ", inheritedSeams), + $"a seam is kept only where the value object declares it, and a base record's would stand in for the generated member without a word; declare it on {target.Name} itself, calling the base's if that is what you mean, or remove it from the base"))); + return false; + } + // The generated code calls {TSelf}.Create(value), which cannot reach an explicit implementation // (LAMA0611, CS1929). Refused rather than called through a constrained type parameter: the // smaller change, and a public Create is what IValidatedValue documents. Beside a public one, an // explicit one is a second rule set: generic code calling T.Create reached it, and every // generated entry point the public one. One Create, public, implements the interface too. - var (createExplicitly, createCallable) = kind == ValueObjectKind.Validated ? ValueObjectDeclaration.CreateDeclarations(target) : (false, false); + // A base record's or an interface's counts (#195). The value object cannot remove it, and declaring the + // marker itself makes its own Create the one that implements it. + var createDeclaration = ""; + var createInherited = false; + var (createExplicitly, createCallable) = kind == ValueObjectKind.Validated ? ValueObjectDeclaration.CreateDeclarations(target, marker, out createDeclaration, out createInherited) : (false, false); if (createExplicitly) { @@ -290,11 +305,15 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp builder.Diagnostics.Report(HandWrittenGeneratedMember.WithArguments(createCallable ? (target, - $"Create both as a static method and as an explicit implementation of {marker.ToDisplayString()}", - $"every generated way in calls {target.Name}.Create and generic code calling T.Create reaches the explicit one, so it would have two rule sets; remove the explicit implementation and keep one Create, {signature}, which implements the interface too") + $"Create both as a static method and as {createDeclaration}", + createInherited + ? $"every generated way in calls {target.Name}.Create and generic code calling T.Create reaches the inherited one, so it would have two rule sets; declare {marker.ToDisplayString()} on {target.Name} itself, so that its own Create implements it" + : $"every generated way in calls {target.Name}.Create and generic code calling T.Create reaches the explicit one, so it would have two rule sets; remove the explicit implementation and keep one Create, {signature}, which implements the interface too") : (target, - $"Create only as an explicit implementation of {marker.ToDisplayString()}", - $"the generated code calls {target.Name}.Create, which cannot reach an explicit implementation; declare it as {signature}"))); + $"Create only as {createDeclaration}", + createInherited + ? $"the generated code calls {target.Name}.Create, which cannot reach an explicit implementation; declare it as {signature}, and {marker.ToDisplayString()} on {target.Name} itself, so that it implements it" + : $"the generated code calls {target.Name}.Create, which cannot reach an explicit implementation; declare it as {signature}"))); return false; } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index 6bcef680..a0c30391 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs @@ -44,7 +44,7 @@ public override void BuildAspect(IAspectBuilder builder) // factories. Any other hand-written comparison member would sit beside generated ones that // need not agree with it, so it is an error naming the seam. Before this, an operator failed // the aspect (LAMA0500, naming no fix) and an object overload was kept silently. - var besideTheSeam = HandWrittenComparisonMembers(builder.Target).ToList(); + var besideTheSeam = HandWrittenComparisonMembers(builder.Target).Distinct().ToList(); if (besideTheSeam.Count > 0) { @@ -136,13 +136,14 @@ private static ValueCompareStrategy ResolveStrategy(INamedType valueType) /// the object overload, the four ordering operators, and an explicit implementation of any member /// of the comparison interfaces. The explicit one was kept beside the generated public one, which /// it hides from every caller through the interface: an explicit IComparable<Rank>.CompareTo - /// sorted a list descending while < kept ascending. + /// sorted a list descending while < kept ascending. A base record's and an interface's count + /// too; the generated one replaced an interface's without a word (#191). /// private static IEnumerable HandWrittenComparisonMembers(INamedType target) { foreach (var method in target.Methods) { - if (method.IsExplicitInterfaceImplementation) continue; + if (method.IsExplicitInterfaceImplementation || !ValueObjectDeclaration.IsByValueAndNotGeneric(method)) continue; var isObjectOverload = method is { Name: nameof(IComparable.CompareTo), Parameters: [{ Type.SpecialType: SpecialType.Object }] }; var isOrderingOperator = method.OperatorKind is OperatorKind.LessThan or OperatorKind.GreaterThan @@ -152,7 +153,23 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe yield return method.ToDisplayString(); } - foreach (var member in ValueObjectDeclaration.ExplicitImplementationsOf(target, + // A base record's CompareTo(object) failed the aspect (LAMA0500), and its CompareTo(TSelf), the seam, was + // used without a word: no [HandWrittenOrdering], so EF Core ordered by the column, and the generated + // operators called the generated CompareTo(object), which C# prefers to the base's, until the stack + // overflowed (#194, measured 2026-10-30). The seam is kept where the value object declares it. A base's + // private CompareTo is hidden from the value object, so it stands in for nothing (#230). + foreach (var type in ValueObjectDeclaration.SelfAndBases(target).Skip(1)) + { + foreach (var method in type.Methods) + { + if (method is { IsStatic: false, IsExplicitInterfaceImplementation: false, Name: nameof(IComparable.CompareTo), Parameters: [{ } other] } + && ValueObjectDeclaration.IsByValueAndNotGeneric(method) && ValueObjectDeclaration.IsHandWritten(method) && method.IsAccessibleFrom(target) + && (other.Type.SpecialType == SpecialType.Object || other.Type.ToNonNullable().Equals(target))) + yield return $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"; + } + } + + foreach (var member in ValueObjectDeclaration.ImplementationsOf(target, [typeof(IComparable<>).ToNamedType(), typeof(IComparable).ToNamedType(), typeof(IComparisonOperators<,,>).ToNamedType()])) yield return member; } @@ -160,6 +177,7 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe /// 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) } + && ValueObjectDeclaration.IsByValueAndNotGeneric(method) && method.Parameters is [var other] && other.Type.Equals(target)); diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectContractAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectContractAspect.cs index c5f440a8..ab795e86 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectContractAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectContractAspect.cs @@ -101,7 +101,7 @@ private static void IntroduceEquality(IAspectBuilder builder, INamed if (type.Equals(direct) || !ExplicitlyImplementsEquatableBetween(direct, type, equatable.MakeGenericInstance(type))) continue; // One the value object declares itself implements the interface already, as C# maps it. - if (builder.Target.Methods.Any(method => method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.ToNonNullable().Equals(type))) continue; + if (builder.Target.Methods.Any(method => method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.ToNonNullable().Equals(type) && ValueObjectDeclaration.IsByValueAndNotGeneric(method))) continue; var hidden = type; builder.IntroduceMethod( diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 79a27700..288a3f58 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -54,13 +54,13 @@ public static bool DeclaresToString(INamedType type) => } private static bool IsHandWrittenToString(IMethod method) => - method is { Name: nameof(ToString), IsStatic: false, IsExplicitInterfaceImplementation: false, Parameters.Count: 0 } && IsHandWritten(method); + method is { Name: nameof(ToString), IsStatic: false, IsExplicitInterfaceImplementation: false, Parameters.Count: 0 } && IsByValueAndNotGeneric(method) && IsHandWritten(method); /// /// A value object and the records it derives from, left out. A struct has /// none: its base is , whose ToString() is nobody's seam. /// - private static IEnumerable SelfAndBases(INamedType target) + internal static IEnumerable SelfAndBases(INamedType target) { yield return target; @@ -198,62 +198,73 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta // are refused below. ValueObjectContractAspect: the equality operators. List generatedInterfaces = [TypeFactory.GetNamedType(typeof(IEqualityOperators<,,>))]; + // A base record's count as the value object's own (#197). Beside a generated one, its public + // Value, From, Parse, TryParse, ToString(string?, IFormatProvider?) or TryFormat failed the aspect + // (LAMA0500), naming nothing the user wrote, and its static MinValue and MaxValue were kept as the + // value object's, with no MinValue or MaxValue generated. One the value object cannot see, a base's + // private member, stands in for nothing: C# hides it, and the generated one sits beside it (#230). + IEnumerable Declared(Func> find) => + SelfAndBases(target).SelectMany(type => find(type).Select(member => type.Equals(target) ? member : $"{member} in its base type {type.ToDisplayString()}")); + // ValueObjectAspect: the field, the entry points; ValueObjectContractAspect: Value; the JSON aspect's nested converter. - found.AddRange(MembersNamed(target, "_value", "Value", "__FromJson", "__FromText", "__TryFromText", $"{target.Name}JsonConverter")); + found.AddRange(Declared(type => MembersNamed(type, target, "_value", "Value", "__FromJson", "__FromText", "__TryFromText", $"{target.Name}JsonConverter"))); if (kind == ValueObjectKind.Plain) { - found.AddRange(MethodsWithSignature(target, "From", valueType)); - found.AddRange(MembersNamed(target, methods: false, "From")); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "From", (valueType, false)))); + found.AddRange(Declared(type => MembersNamed(type, target, methods: false, "From"))); } - var stringType = TypeFactory.GetType(SpecialType.String); - var provider = TypeFactory.GetType(typeof(IFormatProvider)); + // A generated method's parameters, and whether each is out: an overload that takes one by value where the + // generated one takes it by reference, or the other way round, is another method (#226). + (IType, bool) stringType = (TypeFactory.GetType(SpecialType.String), false); + (IType, bool) provider = (TypeFactory.GetType(typeof(IFormatProvider)), false); + (IType, bool) result = (target, true); // ValueObjectParsableAspect and, where parsing is generated, ValueObjectTypeConverterAspect. var parse = ValueObjectParsableAspect.ResolveStrategy(valueType); if (parse != ValueParseStrategy.Unsupported) { - found.AddRange(MethodsWithSignature(target, "Parse", stringType, provider)); - found.AddRange(MethodsWithSignature(target, "TryParse", stringType, provider, target)); - found.AddRange(MembersNamed(target, $"{target.Name}TypeConverter")); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "Parse", stringType, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "TryParse", stringType, provider, result))); + found.AddRange(Declared(type => MembersNamed(type, target, $"{target.Name}TypeConverter"))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IParsable<>))); if (ValueObjectParsableAspect.SupportsSpan(valueType)) { generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(ISpanParsable<>))); - var span = TypeFactory.GetType(typeof(ReadOnlySpan)); - found.AddRange(MethodsWithSignature(target, "Parse", span, provider)); - found.AddRange(MethodsWithSignature(target, "TryParse", span, provider, target)); + (IType, bool) span = (TypeFactory.GetType(typeof(ReadOnlySpan)), false); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "Parse", span, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "TryParse", span, provider, result))); } if (ValueObjectParsableAspect.SupportsUtf8(valueType)) { generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IUtf8SpanParsable<>))); - var utf8 = TypeFactory.GetType(typeof(ReadOnlySpan)); - found.AddRange(MethodsWithSignature(target, "Parse", utf8, provider)); - found.AddRange(MethodsWithSignature(target, "TryParse", utf8, provider, target)); + (IType, bool) utf8 = (TypeFactory.GetType(typeof(ReadOnlySpan)), false); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "Parse", utf8, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "TryParse", utf8, provider, result))); } } // ValueObjectFormattableAspect, unless ToString() is hand-written and it generates nothing. if (!DeclaresToString(target)) { - found.AddRange(MethodsWithSignature(target, nameof(IFormattable.ToString), stringType, provider)); + found.AddRange(Declared(type => MethodsWithSignature(type, target, nameof(IFormattable.ToString), stringType, provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IFormattable))); if (ValueObjectFormattableAspect.ResolveStrategy(valueType) == ValueFormatStrategy.SpanFormattable) { - found.AddRange(MethodsWithSignature(target, nameof(ISpanFormattable.TryFormat), TypeFactory.GetType(typeof(Span)), TypeFactory.GetType(SpecialType.Int32), TypeFactory.GetType(typeof(ReadOnlySpan)), provider)); + found.AddRange(Declared(type => MethodsWithSignature(type, target, nameof(ISpanFormattable.TryFormat), (TypeFactory.GetType(typeof(Span)), false), (TypeFactory.GetType(SpecialType.Int32), true), (TypeFactory.GetType(typeof(ReadOnlySpan)), false), provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(ISpanFormattable))); } if (ValueObjectFormattableAspect.SupportsUtf8(valueType)) { - found.AddRange(MethodsWithSignature(target, nameof(IUtf8SpanFormattable.TryFormat), TypeFactory.GetType(typeof(Span)), TypeFactory.GetType(SpecialType.Int32), TypeFactory.GetType(typeof(ReadOnlySpan)), provider)); + found.AddRange(Declared(type => MethodsWithSignature(type, target, nameof(IUtf8SpanFormattable.TryFormat), (TypeFactory.GetType(typeof(Span)), false), (TypeFactory.GetType(SpecialType.Int32), true), (TypeFactory.GetType(typeof(ReadOnlySpan)), false), provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IUtf8SpanFormattable))); } } @@ -261,36 +272,48 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta // ValueObjectMinMaxValueAspect. if (kind == ValueObjectKind.Plain && ValueObjectMinMaxValueAspect.HasMinMaxValue(valueType)) { - found.AddRange(MembersNamed(target, nameof(IMinMaxValue.MinValue), nameof(IMinMaxValue.MaxValue))); + found.AddRange(Declared(type => MembersNamed(type, target, nameof(IMinMaxValue.MinValue), nameof(IMinMaxValue.MaxValue)))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IMinMaxValue<>))); } // An explicit implementation of an interface the generators implement sat beside the generated // member, which it hides from every caller through the interface: a generic T.Parse bypassed // Create, and interpolation printed through a hand-written IFormattable while ToString() did not. - found.AddRange(ExplicitImplementationsOf(target, generatedInterfaces)); + // An interface's was replaced by the generated member instead, as silently. + found.AddRange(ImplementationsOf(target, generatedInterfaces)); // ValueObjectConvertibleAspect implements every member of IConvertible explicitly, so declaring // the interface at all, with public or explicit members, failed the aspect (LAMA0041: it cannot - // introduce explicit members for an interface it was told to ignore). + // introduce explicit members for an interface it was told to ignore). So did declaring an + // interface derived from it. var convertible = valueType.IsConvertibleTo(typeof(IConvertible)); + var iconvertible = TypeFactory.GetNamedType(typeof(IConvertible)); // The interfaces implemented with OverrideStrategy.Fail, and IConvertible. The marker's own are implied by it. + // Declared through an interface of the user's, as an IEntityId : IValueObject declares + // IValueObject, each failed the aspect as declaring it does (LAMA0512, LAMA0041), naming nothing the user + // wrote (#192). Neither marker derives from one of them, so an interface derived from a marker is not one. + bool Implemented(INamedType @interface) => + @interface.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObject<,>))) + || @interface.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObjectMaterializer<,>))) + || @interface.Definition.Equals(TypeFactory.GetNamedType(typeof(IPlainValueObject<,>))) + || (convertible && @interface.Equals(iconvertible)); + foreach (var implemented in target.ImplementedInterfaces) { - if (implemented.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObject<,>))) - || implemented.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObjectMaterializer<,>))) - || implemented.Definition.Equals(TypeFactory.GetNamedType(typeof(IPlainValueObject<,>))) - || (convertible && implemented.Equals(TypeFactory.GetNamedType(typeof(IConvertible))))) + if (Implemented(implemented)) found.Add($"the interface {implemented.ToDisplayString()}"); + else + found.AddRange(implemented.AllImplementedInterfaces.Where(Implemented).Select(inherited => $"the interface {inherited.ToDisplayString()} in its interface {implemented.ToDisplayString()}")); } - // IConvertible implemented by a base record is implemented already, and the aspect failed the same way. - if (convertible) + // One a base record implements, itself or through an interface, is implemented already, and the aspect + // failed the same way. + foreach (var type in SelfAndBases(target).Skip(1)) { - foreach (var type in SelfAndBases(target).Skip(1)) - if (type.ImplementedInterfaces.Any(implemented => implemented.Equals(TypeFactory.GetNamedType(typeof(IConvertible))))) - found.Add($"the interface IConvertible in its base type {type.ToDisplayString()}"); + found.AddRange(type.ImplementedInterfaces.SelectMany(implemented => implemented.AllImplementedInterfaces.Prepend(implemented).Where(Implemented)) + .Distinct() + .Select(inherited => $"the interface {inherited.ToDisplayString()} in its base type {type.ToDisplayString()}")); } // The attributes the aspects put on the type. @@ -310,35 +333,119 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta } /// - /// The members of that explicitly implement a member of one of - /// , compared by definition, as names them. + /// The implementations that reaches for a member of one of + /// , compared by definition, other than its own public members, which the + /// checks for hand-written members answer: its own explicit implementation, and a base record's or an + /// interface's, explicit or public, in this assembly or another. /// /// + /// /// A base record's count too. The generated member replaced one of IFormattable or /// IEqualityOperators without a word, and one of a static interface (IParsable, /// IMinMaxValue) or of IComparable kept answering the interface: a generic /// T.Parse reached a generic base's, skipping Create, and Comparer<T>.Default /// disagreed with < (measured 2026-09-28). + /// + /// + /// So does an interface's, the default every type that declares the interface reaches until it declares + /// a public member of its own. The generated one replaced it without a word: Max<Bounded>() + /// read the generated MaxValue where IBounded<TSelf>'s threw, and an IFormattable + /// call printed 1 where IFormats answered its own text (#191, measured 2026-10-30 for every + /// interface the generators implement). An interface's == is not one: the record's own replaces it, + /// generated or not, and that is the implementation found. + /// + /// + /// Found by the interface mapping, never by listing members: Metalama lists no private member of a type in + /// another assembly, and an explicit implementation is private, so a base record's and an interface's from + /// a referenced project were replaced or kept without a word (measured 2026-10-30). + /// + /// + /// A base record's public implementation counts as the value object's own. Its public static MaxValue + /// was the value object's, and no MaxValue was generated; its public ToString(string?, + /// IFormatProvider?), CompareTo(object) or Parse failed the aspect (LAMA0500), naming + /// nothing the user wrote (#197, measured 2026-10-30). + /// + /// + /// An interface's explicit implementation of a member of an interface in the same library is not refused: + /// .NET's INumberBase<TSelf> bridges UTF-8 formatting and parsing to the UTF-16 members, which + /// every numeric type of .NET replaces with its own and the generators generate both of. They are the only + /// such members in .NET 10's interfaces (scanned 2026-10-30). + /// /// - internal static IEnumerable ExplicitImplementationsOf(INamedType target, IReadOnlyList interfaces) + internal static IEnumerable ImplementationsOf(INamedType target, IReadOnlyList interfaces) { - bool OfOne(IMember implemented) => interfaces.Any(@interface => implemented.DeclaringType.Definition.Equals(@interface)); - - foreach (var type in SelfAndBases(target)) + foreach (var implemented in target.AllImplementedInterfaces) { - var where = type.Equals(target) ? "" : $" in its base type {type.ToDisplayString()}"; + if (!interfaces.Any(@interface => implemented.Definition.Equals(@interface))) continue; + + foreach (var member in implemented.Methods.Concat(implemented.Properties).Concat(implemented.Events)) + { + if (!target.TryFindImplementationForInterfaceMember(member, out var implementation) || !IsHandWritten(implementation)) continue; - foreach (var method in type.Methods) - if (method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && method.ExplicitInterfaceImplementations.Any(OfOne)) yield return Describe(method) + where; + var declaring = implementation.DeclaringType; - foreach (var property in type.Properties) - if (property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && property.ExplicitInterfaceImplementations.Any(OfOne)) yield return Describe(property) + where; + if (declaring.TypeKind == TypeKind.Interface && declaring.DeclaringAssembly.Equals(implemented.DeclaringAssembly)) continue; - foreach (var @event in type.Events) - if (@event is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && @event.ExplicitInterfaceImplementations.Any(OfOne)) yield return Describe(@event) + where; + if (Elsewhere(target, implementation) is { } where) + yield return (IsPublic(implementation) ? PublicName(implementation, member) : Describe(implementation)) + where; + else if (implementation.IsExplicitInterfaceImplementation) + yield return Describe(implementation); + } } } + /// + /// Where is declared, as an error names it, when it is not + /// itself: " in its base type X" or " in its interface X". + /// + private static string? Elsewhere(INamedType target, IMember implementation) => + implementation.DeclaringType switch + { + var declaring when declaring.Equals(target) => null, + { TypeKind: TypeKind.Interface } declaring => $" in its interface {declaring.ToDisplayString()}", + var declaring => $" in its base type {declaring.ToDisplayString()}" + }; + + /// + /// A public member, which an explicit implementation never is. From another assembly, a public static member + /// that implements a static abstract one reads as an explicit implementation too, since the compiler records + /// every such implementation in metadata as one, so it is told apart by its accessibility. + /// + private static bool IsPublic(IMember member) => member.Accessibility == Accessibility.Public; + + /// + /// A public implementation as an error names it: BoundedBase<Level>.MaxValue. One from another + /// assembly that reads as an explicit implementation displays as BoundedBase<Level>.IMinMaxValue.MaxValue, + /// so it is named by its type and the member it implements. + /// + private static string PublicName(IMember implementation, IMember implemented) + { + if (!implementation.IsExplicitInterfaceImplementation) return implementation.ToDisplayString(); + + var shown = implemented.ToDisplayString(); + var at = shown.IndexOf("." + implemented.Name, StringComparison.Ordinal); + + return at < 0 ? implementation.ToDisplayString() : implementation.DeclaringType.ToDisplayString() + shown.Substring(at); + } + + /// + /// The factory seams of a validated value object that a base record declares rather than the value object: + /// TryFrom(T) and Revalidate(). The generated one is introduced only where none exists, and a + /// base's counted, so it stood in for the value object's without a word: TryFrom answered whatever the + /// base did, which need not call Create, and Revalidate threw where the base's did (#194, + /// measured 2026-10-30). A base's FromKnownGood(T) is hidden by the generated one, whose caller + /// expression parameter makes it another signature, so it is not one, and a base's private one is hidden from the + /// value object, which C# never calls it for (#230). + /// + public static IReadOnlyList InheritedSeams(INamedType target, INamedType valueType, ValueObjectKind kind) => + kind != ValueObjectKind.Validated + ? [] + : [.. SelfAndBases(target).Skip(1).SelectMany(type => type.Methods + .Where(method => IsHandWritten(method) && !method.IsExplicitInterfaceImplementation && IsByValueAndNotGeneric(method) && method.IsAccessibleFrom(target) + && (method is { Name: "TryFrom", IsStatic: true, Parameters: [{ } value] } && value.Type.ToNonNullable().Equals(valueType.ToNonNullable()) + || method is { Name: "Revalidate", IsStatic: false, Parameters.Count: 0 })) + .Select(method => $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"))]; + /// /// How a validated value object declares Create: as an explicit implementation of the /// marker's, and as a static method the generated code can call as {TSelf}.Create(value). @@ -346,11 +453,22 @@ internal static IEnumerable ExplicitImplementationsOf(INamedType target, /// callable one it is a second rule set, which generic code calling T.Create reached while /// every generated entry point applied the other. /// - public static (bool Explicitly, bool Callable) CreateDeclarations(INamedType target) + /// + /// A base record's or an interface's counts, found by the interface mapping: + /// names it, and says it is not the value object's own. Where the value object + /// lists the marker only through a base record of another project, generic T.Create reached the base's + /// explicit implementation, which refused what the value object's own Create accepted (#195, measured + /// 2026-10-30). A base's public one is one rule set alone, which the generated code reaches as + /// {TSelf}.Create, and a second beside the value object's own. + /// + public static (bool Explicitly, bool Callable) CreateDeclarations(INamedType target, INamedType marker, out string declaration, out bool inherited) { var explicitly = false; var callable = false; + declaration = $"an explicit implementation of {marker.ToDisplayString()}"; + inherited = false; + foreach (var method in target.Methods) { if (!method.IsStatic || method.IsImplicitlyDeclared) continue; @@ -366,6 +484,17 @@ public static (bool Explicitly, bool Callable) CreateDeclarations(INamedType tar } } + if (!explicitly + && marker.Methods.FirstOrDefault(method => method.Name == nameof(IValidatedValue<,,>.Create)) is { } create + && target.TryFindImplementationForInterfaceMember(create, out var implementation) + && Elsewhere(target, implementation) is { } where + && (!IsPublic(implementation) || callable)) + { + explicitly = true; + inherited = true; + declaration = IsPublic(implementation) ? PublicName(implementation, create) + where : declaration + where; + } + return (explicitly, callable); } @@ -386,7 +515,7 @@ public static IReadOnlyList PrintMembersDeclaredByHand(INamedType target return [ .. SelfAndBases(target).SelectMany(type => type.Methods - .Where(method => method is { Name: "PrintMembers", IsStatic: false, Parameters: [{ } builder] } && builder.Type.Equals(stringBuilder) && IsHandWritten(method)) + .Where(method => method is { Name: "PrintMembers", IsStatic: false, Parameters: [{ } builder] } && builder.Type.Equals(stringBuilder) && IsByValueAndNotGeneric(method) && IsHandWritten(method)) .Select(method => Describe(method) + (type.Equals(target) ? "" : $" in its base type {type.ToDisplayString()}"))) ]; } @@ -399,17 +528,24 @@ .. SelfAndBases(target).SelectMany(type => type.Methods /// 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. /// + /// + /// An Equals of a base record's type counts too. It implemented IEquatable<TBase>, so a + /// HashSet of the base compared by it while == compared the wrapped value: two holding "a" were + /// equal, and the set held both (#196, measured 2026-10-30). One of the direct base's type is C#'s own CS0111, + /// beside the override the record synthesizes. + /// public static IReadOnlyList EqualityDeclaredByHand(INamedType target) { var equatable = TypeFactory.GetNamedType(typeof(IEquatable<>)); + var ownTypes = SelfAndBases(target).ToList(); return [ .. target.Methods - .Where(method => method is { IsStatic: false, IsImplicitlyDeclared: false } + .Where(method => method is { IsStatic: false, IsImplicitlyDeclared: false } && IsByValueAndNotGeneric(method) && (method.IsExplicitInterfaceImplementation ? method.ExplicitInterfaceImplementations.Any(implemented => implemented.DeclaringType.Definition.Equals(equatable)) - : (method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.Equals(target)) + : (method is { Name: nameof(Equals), Parameters: [{ } other] } && ownTypes.Any(type => other.Type.ToNonNullable().Equals(type))) || method is { Name: nameof(GetHashCode), Parameters.Count: 0 })) .Select(Describe), .. target.Methods @@ -434,7 +570,7 @@ public static IReadOnlyList InheritedEquality(INamedType target) return [ .. SelfAndBases(target).Skip(1).SelectMany(type => type.Methods - .Where(method => method is { IsStatic: false } && IsHandWritten(method) + .Where(method => method is { IsStatic: false } && IsByValueAndNotGeneric(method) && IsHandWritten(method) && (method.IsExplicitInterfaceImplementation ? method.ExplicitInterfaceImplementations.Any(implemented => implemented.DeclaringType.Definition.Equals(equatable)) : (method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.Equals(type)) @@ -512,42 +648,69 @@ internal static string Describe(IMember member) /// Written by the user, not synthesized by the compiler: a record in a referenced assembly reports its /// synthesized members as declared, and marks them [CompilerGenerated]. /// - private static bool IsHandWritten(IMember member) => + internal static bool IsHandWritten(IMember member) => !member.IsImplicitlyDeclared && !member.Attributes.Any(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(CompilerGeneratedAttribute)))); - /// Non-implicit members of with one of , of any kind. - private static IEnumerable MembersNamed(INamedType target, params string[] names) => MembersNamed(target, methods: true, names); + /// + /// A method without type parameters whose parameters are all by value, as every generated method is but + /// TryParse and TryFormat (): C# overloads by arity and by a + /// parameter's ref kind, so a generic CompareTo<T>(object?) or a TryFrom(in int) is another + /// method, which the generated one sits beside, and no seam (#226). + /// + internal static bool IsByValueAndNotGeneric(IMethod method) => + method.TypeParameters.Count == 0 && method.Parameters.All(parameter => parameter.RefKind == RefKind.None); + + /// + /// Non-implicit members of with one of , of any kind, that + /// can see: a base's private member is hidden from the value object (#230). + /// + private static IEnumerable MembersNamed(INamedType target, INamedType seenFrom, params string[] names) => MembersNamed(target, seenFrom, methods: true, names); - internal static IEnumerable MembersNamed(INamedType target, bool methods, params string[] names) + /// Non-implicit members of with one of . + internal static IEnumerable MembersNamed(INamedType target, bool methods, params string[] names) => MembersNamed(target, target, methods, names); + + /// + /// An explicit implementation is 's, which names it, and so is a public static + /// member of another assembly that implements a static abstract one, which reads as explicit there. + /// + private static IEnumerable MembersNamed(INamedType target, INamedType seenFrom, bool methods, params string[] names) { foreach (var field in target.Fields) - if (!field.IsImplicitlyDeclared && Enumerable.Contains(names, field.Name)) yield return field.ToDisplayString(); + if (!field.IsImplicitlyDeclared && Enumerable.Contains(names, field.Name) && field.IsAccessibleFrom(seenFrom)) yield return field.ToDisplayString(); foreach (var property in target.Properties) - if (!property.IsImplicitlyDeclared && Enumerable.Contains(names, property.Name)) yield return property.ToDisplayString(); + if (property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && Enumerable.Contains(names, property.Name) && property.IsAccessibleFrom(seenFrom)) yield return property.ToDisplayString(); foreach (var @event in target.Events) - if (!@event.IsImplicitlyDeclared && Enumerable.Contains(names, @event.Name)) yield return @event.ToDisplayString(); + if (@event is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && Enumerable.Contains(names, @event.Name) && @event.IsAccessibleFrom(seenFrom)) yield return @event.ToDisplayString(); foreach (var nested in target.Types) - if (Enumerable.Contains(names, nested.Name)) yield return $"the nested type {nested.ToDisplayString()}"; + if (Enumerable.Contains(names, nested.Name) && nested.IsAccessibleFrom(seenFrom)) yield return $"the nested type {nested.ToDisplayString()}"; if (!methods) yield break; foreach (var method in target.Methods) - if (!method.IsImplicitlyDeclared && !method.IsExplicitInterfaceImplementation && Enumerable.Contains(names, method.Name)) yield return method.ToDisplayString(); + if (!method.IsImplicitlyDeclared && !method.IsExplicitInterfaceImplementation && Enumerable.Contains(names, method.Name) && method.IsAccessibleFrom(seenFrom)) yield return method.ToDisplayString(); } /// /// Methods named whose parameter types are , - /// whatever they return: a generated member with that signature is either refused by Metalama or - /// skipped in favour of the hand-written one. + /// whatever they return, that can see: a generated member with that signature is + /// either refused by Metalama or skipped in favour of the hand-written one. /// - private static IEnumerable MethodsWithSignature(INamedType target, string name, params IType[] parameters) => + /// + /// The signature C# overloads by (#226): no type parameters, as the generated method has none, and each + /// parameter by reference where the generated one is out, by value where it is by value. A generic + /// Parse<TFormat>(string, IFormatProvider?), a From(in int) and a TryParse whose + /// last parameter is by value are other methods, which the generated ones sit beside. ref or in + /// against out is no overload in C# (CS0663), so it is the same method, as nullability is. + /// + private static IEnumerable MethodsWithSignature(INamedType target, INamedType seenFrom, string name, params (IType Type, bool Out)[] parameters) => target.Methods - .Where(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } + .Where(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, TypeParameters.Count: 0 } && method.Name == name && method.Parameters.Count == parameters.Length - && method.Parameters.Select(parameter => parameter.Type).Zip(parameters, (declared, expected) => declared.Equals(expected)).All(same => same)) + && method.Parameters.Zip(parameters, (declared, expected) => declared.Type.Equals(expected.Type) && declared.RefKind != RefKind.None == expected.Out).All(same => same) + && method.IsAccessibleFrom(seenFrom)) .Select(method => method.ToDisplayString()); } diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs index 061fe502..9e98fadd 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs @@ -31,6 +31,20 @@ public AnalyzerTest(string source, bool referenceTypeKit = true) } } +/// What a sweep over a variant catalogue leaves to the focused tests of its rule. +/// +/// The verifier runs a case that expects a diagnostic a second time, under #pragma warning disable for each id +/// it expects, and checks that nothing is left. Whether a pragma silences a rule is the compiler's, the same for every +/// shape, so a sweep paid for that pass once per row: 28% of DroppedTaskVariantTests' time (#245). The focused tests +/// of each rule keep it. The verifier's other pass, under an <auto-generated> header, never runs here: +/// every analyzer configures generated-code analysis (GeneratedCode.AnalysisFlags), and GeneratedCodeTests +/// holds what each rule does in generated code. +/// +internal static class VariantSweep +{ + public const TestBehaviors Behaviors = TestBehaviors.SkipSuppressionCheck; +} + /// A verifier run of one analyzer and its code fix: the source, then the source as the fix leaves it. internal sealed class CodeFixTest : CSharpCodeFixTest where TAnalyzer : DiagnosticAnalyzer, new() diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj b/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj index a6513c17..e2871153 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj @@ -23,4 +23,15 @@ + + + + + diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs index 140150ef..7807f1bd 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs @@ -1,4 +1,7 @@ +using CodoMetis.TypeKit.Analyzers.Tests.Variants; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Testing; namespace CodoMetis.TypeKit.Analyzers.Tests; @@ -9,6 +12,7 @@ public sealed class DefaultFilledCollectionAnalyzerTests private const string Subjects = """ using System; + using System.Buffers; using CodoMetis.TypeKit; using CodoMetis.TypeKit.ValueObjects; @@ -108,6 +112,223 @@ public static void Run(Plain[] existing) """) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A multidimensional array holds no element when any dimension is a constant zero, not only when + /// every one is: new Plain[0, 2] was reported (#227). Across the input class, an array + /// creation by rank and by which dimension is a constant zero; one with no zero dimension reports. + /// + [Fact] + public Task An_array_with_any_constant_zero_dimension_stays_silent() => + Test( + """ + public static class C + { + public static void Run(int n) + { + const int none = 0; + var first = new Plain[0, 2]; + var second = new Plain[2, 0]; + var both = new Plain[0, 0]; + var variable = new Plain[n, 0]; + var named = new Plain[none, n]; + var middle = new Plain[2, 0, 2]; + var last = new Plain[n, 2, 0]; + var jagged = new Plain[0][]; + var grid = {|#0:new Plain[2, 2]|}; + var cube = {|#1:new Plain[n, 1, 2]|}; + } + } + """, + Cmtk0005(0, "An array created with a length", "Plain"), + Cmtk0005(1, "An array created with a length", "Plain")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The non-generic array factories and the pool create default slots as new Plain[n] does, and + /// none was reported (#228). Across the input class, an expression that creates an array of a value + /// object, by the API that creates it: each overload of Array.CreateInstance and + /// CreateInstanceFromArrayType, Rent on the shared pool, a created one and a derived one. + /// + [Fact] + public Task The_array_factories_and_the_pool_report() => + Test( + """ + public sealed class Pool : ArrayPool + { + public override Plain[] Rent(int minimumLength) => []; + public override void Return(Plain[] array, bool clearArray = false) { } + } + + public static class C + { + public static void Run(int n, int[] lengths, Pool pool) + { + var one = {|#0:Array.CreateInstance(typeof(Plain), n)|}; + var two = {|#1:Array.CreateInstance(typeof(Plain), 2, 2)|}; + var three = {|#2:Array.CreateInstance(typeof(Plain), 2, n, 2)|}; + var four = {|#3:Array.CreateInstance(typeof(Plain), 2, 2, 2, 2)|}; + var held = {|#4:Array.CreateInstance(typeof(Plain), lengths)|}; + var bounded = {|#5:Array.CreateInstance(typeof(Plain), new[] { 2 }, new[] { 0 })|}; + var wide = {|#6:Array.CreateInstance(typeof(Plain), 2L, 2L)|}; + var fromArray = {|#7:Array.CreateInstanceFromArrayType(typeof(Plain[]), n)|}; + var fromGrid = {|#8:Array.CreateInstanceFromArrayType(typeof(Plain[,]), 2, 2)|}; + var fromBounded = {|#9:Array.CreateInstanceFromArrayType(typeof(Plain[]), [2], [1])|}; + var shared = {|#10:ArrayPool.Shared.Rent(n)|}; + var created = {|#11:ArrayPool.Create().Rent(3)|}; + var derived = {|#12:pool.Rent(3)|}; + var options = {|#13:Array.CreateInstance(typeof(Option), n)|}; + } + + public static Array Generic(int n) where T : struct, IValue => {|#14:Array.CreateInstance(typeof(T), n)|}; + } + """, + Cmtk0005(0, "Array.CreateInstance", "Plain"), + Cmtk0005(1, "Array.CreateInstance", "Plain"), + Cmtk0005(2, "Array.CreateInstance", "Plain"), + Cmtk0005(3, "Array.CreateInstance", "Plain"), + Cmtk0005(4, "Array.CreateInstance", "Plain"), + Cmtk0005(5, "Array.CreateInstance", "Plain"), + Cmtk0005(6, "Array.CreateInstance", "Plain"), + Cmtk0005(7, "Array.CreateInstanceFromArrayType", "Plain"), + Cmtk0005(8, "Array.CreateInstanceFromArrayType", "Plain"), + Cmtk0005(9, "Array.CreateInstanceFromArrayType", "Plain"), + Cmtk0005(10, "ArrayPool.Rent", "Plain"), + Cmtk0005(11, "ArrayPool.Rent", "Plain"), + Cmtk0005(12, "ArrayPool.Rent", "Plain"), + Cmtk0005(13, "Array.CreateInstance", "Option"), + Cmtk0005(14, "Array.CreateInstance", "T")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A constant zero length in any dimension creates no slot, and a Type the rule cannot read names + /// no element: a variable, GetType(), a nullable value object, an array of another type. + /// + [Fact] + public Task The_array_factories_with_a_zero_length_or_an_unknown_type_stay_silent() => + Test( + """ + public static class C + { + public static void Run(int n, Type type, Plain a) + { + var one = Array.CreateInstance(typeof(Plain), 0); + var first = Array.CreateInstance(typeof(Plain), 0, n); + var second = Array.CreateInstance(typeof(Plain), n, 0); + var third = Array.CreateInstance(typeof(Plain), n, 2, 0); + var listed = Array.CreateInstance(typeof(Plain), 2, 0, 2, 2); + var bounded = Array.CreateInstance(typeof(Plain), new[] { 0 }, new[] { 5 }); + var wide = Array.CreateInstance(typeof(Plain), 2L, 0L); + var fromArray = Array.CreateInstanceFromArrayType(typeof(Plain[]), 0); + var fromGrid = Array.CreateInstanceFromArrayType(typeof(Plain[,]), n, 0); + var shared = ArrayPool.Shared.Rent(0); + var created = ArrayPool.Create().Rent(minimumLength: 0); + + var variable = Array.CreateInstance(type, n); + var runtime = Array.CreateInstance(a.GetType(), n); + var nullable = Array.CreateInstance(typeof(Plain?), n); + var numbers = Array.CreateInstance(typeof(int), n); + var jagged = Array.CreateInstance(typeof(Plain[]), n); + var other = Array.CreateInstanceFromArrayType(typeof(int[]), n); + var unknown = Array.CreateInstanceFromArrayType(type, n); + var pooled = ArrayPool.Shared.Rent(n); + var instance = Activator.CreateInstance(typeof(Plain)); + } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// ArrayPool<T>.Rent and the typeof factories judge their element as + /// GC.AllocateArray<T> does, across every kind of : each + /// reports exactly where the generic allocator does. typeof takes neither dynamic (CS1962) nor a nullable reference type + /// (CS8639), so those two kinds run Rent alone. + /// + [Theory] + [MemberData(nameof(Kinds))] + public async Task The_factories_judge_each_kind_as_the_generic_allocator_does(string kindName) + { + var kind = TypeArgumentKinds.All.Single(candidate => candidate.Name == kindName); + var typeOf = kind.Argument is not ("dynamic" or "string?"); + var source = + $$""" + #nullable enable + using System.Buffers; + + public static class Use{{kind.Parameters}} {{kind.Where}} + { + public static void M(int n) + { + _ = GC.AllocateArray<{{kind.Argument}}>(n); + _ = ArrayPool<{{kind.Argument}}>.Shared.Rent(n); + {{(typeOf ? $"_ = Array.CreateInstance(typeof({kind.Argument}), n);" : "")}} + {{(typeOf ? $"_ = Array.CreateInstance(typeof({kind.Argument}), n, n);" : "")}} + {{(typeOf ? $"_ = Array.CreateInstanceFromArrayType(typeof({kind.Argument}[]), n);" : "")}} + } + } + """; + + var forms = await ReportedForms(source); + + var reported = forms.Contains("GC.AllocateArray"); + if (kind is KindOfType { Is: var type } && type.HasFlag(Is.Struct) && type.HasFlag(Is.ValueObjectMarker)) reported.ShouldBeTrue("a value object struct is the positive control"); + + string[] factories = typeOf ? ["ArrayPool.Rent", "Array.CreateInstance", "Array.CreateInstance", "Array.CreateInstanceFromArrayType"] : ["ArrayPool.Rent"]; + forms.Where(form => form != "GC.AllocateArray").Order().ShouldBe(reported ? factories.Order() : []); + } + + public static TheoryData Kinds() => [.. TypeArgumentKinds.All.Where(kind => kind.Mentions.All(Declared)).Select(kind => kind.Name)]; + + private static bool Declared(string attribute) => RealTypeKit.Assembly.GetType($"CodoMetis.TypeKit.{attribute}Attribute") is not null; + + /// The form of each CMTK0005 the analyzer reports in , which must compile. + private static async Task ReportedForms(string source) + { + var cancellation = TestContext.Current.CancellationToken; + var references = await ReferenceAssemblies.Net.Net100.ResolveAsync(LanguageNames.CSharp, cancellation); + var compilation = CSharpCompilation.Create( + "Kinds", + [CSharpSyntaxTree.ParseText(source, cancellationToken: cancellation), CSharpSyntaxTree.ParseText(TypeArgumentKinds.Candidates, cancellationToken: cancellation)], + [.. references, RealTypeKit.Reference], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + compilation.GetDiagnostics(cancellation).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ShouldBeEmpty(); + + var diagnostics = await compilation.WithAnalyzers([new DefaultFilledCollectionAnalyzer()]).GetAnalyzerDiagnosticsAsync(cancellation); + return [.. diagnostics.Select(diagnostic => diagnostic.GetMessage().Split(" fills ")[0])]; + } + + /// + /// A zero of any integral type creates no slot: a dimension may be a long, uint or + /// ulong, and a stackalloc size's constant is read before its conversion to int, + /// so 0L and (byte)0 were reported where 0 was not. + /// + [Fact] + public Task A_zero_of_any_integral_type_stays_silent() => + Test( + """ + public static class C + { + public static void Run(int n, Plain[] existing) + { + var asLong = new Plain[0L]; + var asUnsigned = new Plain[0u, 2]; + var asUnsignedLong = new Plain[2, 0ul]; + var asByte = new Plain[(byte)0]; + var asChar = new Plain['\0']; + Span stackByte = stackalloc Plain[(byte)0]; + Span stackShort = stackalloc Plain[(short)0]; + Span stackChar = stackalloc Plain['\0']; + var allocated = GC.AllocateArray((byte)0); + Array.Resize(ref existing, '\0'); + var one = {|#0:new Plain[1L]|}; + Span stackOne = {|#1:stackalloc Plain[(byte)1]|}; + } + } + """, + Cmtk0005(0, "An array created with a length", "Plain"), + Cmtk0005(1, "A stackalloc with a length", "Plain")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// The subjects' own new Plain[1] inside Plain doubles as the exemption for the type /// itself. Nothing here starts with a default element of a no-default struct. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/DroppedTaskVariantTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/DroppedTaskVariantTests.cs index 4f3544e4..34b80ff7 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/DroppedTaskVariantTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/DroppedTaskVariantTests.cs @@ -36,6 +36,7 @@ public Task A_dropped_task_is_reported_in_every_form_and_a_kept_one_never(string var (source, expected) = DroppedTaskShapes.Use(call, form); var test = new AnalyzerTest(source); + test.TestBehaviors = VariantSweep.Behaviors; test.TestState.Sources.Add(("Declarations.cs", DroppedTaskShapes.Declarations)); test.TestState.AdditionalReferences.Add(RealTesting.Reference); test.ExpectedDiagnostics.AddRange( diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs new file mode 100644 index 00000000..b4b35760 --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs @@ -0,0 +1,64 @@ +using CodoMetis.TypeKit.Analyzers.Tests.Variants; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0001, CMTK0005 and CMTK0006 across the variant catalogue of what a type is (): +/// each reports a kind exactly when the others do. +/// +/// +/// The Analyzers README defines CMTK0005 and CMTK0006 over "such a type", CMTK0001's list, which takes in a type +/// parameter whose constraints make it a value object. CMTK0005 and CMTK0006 read a narrower predicate of their own +/// and were silent for where T : IValue<int>, new(), which CMTK0001 reports (#207). The oracle is that +/// list: a struct value object, or a parameter bound by the marker beside struct, unmanaged or +/// new() and no class bound, since a generated class has no public parameterless constructor. +/// +public sealed class NoDefaultKindTests +{ + private static bool HasNoDefault(TypeArgumentKind kind) => kind switch + { + KindOfType type => type.Is.HasFlag(Is.Struct) && type.Is.HasFlag(Is.ValueObjectMarker), + KindOfParameter parameter => parameter.Facts.Declares.HasFlag(Declares.ValueObjectBound) + && (parameter.Facts.Declares & (Declares.Struct | Declares.Unmanaged | Declares.New)) != 0 + && (parameter.Facts.Declares & (Declares.Class | Declares.ClassBound)) == 0, + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; + + public static TheoryData Kinds() => [.. TypeArgumentKinds.All.Where(kind => kind.Mentions.All(Declared)).Select(kind => kind.Name)]; + + private static bool Declared(string attribute) => RealTypeKit.Assembly.GetType($"CodoMetis.TypeKit.{attribute}Attribute") is not null; + + [Theory] + [MemberData(nameof(Kinds))] + public Task CMTK0001_reports_a_default_of_the_kind_as_the_list_says(string kindName) => + Run(kindName, "CMTK0001", DiagnosticSeverity.Error, + (kind, at) => $$"""public static class Use{{kind.Parameters}} {{kind.Where}} { public static {{kind.Argument}} M() => {{at("default(" + kind.Argument + ")")}}; }"""); + + [Theory] + [MemberData(nameof(Kinds))] + public Task CMTK0005_reports_an_array_of_the_kind_as_CMTK0001_does(string kindName) => + Run(kindName, "CMTK0005", DiagnosticSeverity.Warning, + (kind, at) => $$"""public static class Use{{kind.Parameters}} {{kind.Where}} { public static {{kind.Argument}}[] M(int n) => {{at($"GC.AllocateArray<{kind.Argument}>(n)")}}; }"""); + + [Theory] + [MemberData(nameof(Kinds))] + public Task CMTK0006_reports_an_unassigned_field_of_the_kind_as_CMTK0001_does(string kindName) => + Run(kindName, "CMTK0006", DiagnosticSeverity.Warning, + (kind, at) => $$"""public sealed class Holder{{kind.Parameters}} {{kind.Where}} { public {{kind.Argument}} {{at("Field")}}; }"""); + + private static Task Run(string kindName, string id, DiagnosticSeverity severity, Func, string> use) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var kind = TypeArgumentKinds.All.Single(candidate => candidate.Name == kindName); + var reporting = HasNoDefault(kind); + var test = new AnalyzerTest("#nullable enable\n" + use(kind, code => reporting ? $"{{|#0:{code}|}}" : code)); + + test.TestState.Sources.Add(("Candidates.cs", TypeArgumentKinds.Candidates)); + if (reporting) test.ExpectedDiagnostics.Add(new DiagnosticResult(id, severity).WithLocation(0)); + + return test.RunAsync(TestContext.Current.CancellationToken); + } +} diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/QueryDelegateConditionTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/QueryDelegateConditionTests.cs index c0f84da4..550e59e5 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/QueryDelegateConditionTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/QueryDelegateConditionTests.cs @@ -133,6 +133,7 @@ private static Task Sweep(QueryReceiver receiver, string element, string paramet var (source, expected) = QueryDelegateShapes.Use(receiver, element, parameters, where); var test = new AnalyzerTest(TypeArgumentKinds.Candidates); + test.TestBehaviors = VariantSweep.Behaviors; test.TestState.Sources.Add(("Declarations.cs", QueryDelegateShapes.Declarations)); test.TestState.Sources.Add(("Use.cs", source)); test.ExpectedDiagnostics.AddRange(expected.Select(call => new DiagnosticResult("CMTK0009", DiagnosticSeverity.Warning).WithLocation(call.Location))); diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintKindTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintKindTests.cs index b295217c..7b28e0e1 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintKindTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintKindTests.cs @@ -215,6 +215,7 @@ public void The_catalogue_names_are_unique_and_its_pairs_reach_every_verdict() private static Task Run(string subject, string use, bool breaking) { var test = new AnalyzerTest(TypeArgumentKinds.Candidates + subject); + test.TestBehaviors = VariantSweep.Behaviors; test.TestState.Sources.Add(("Use.cs", use)); if (breaking) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs index c87b6a47..0eb1f960 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs @@ -284,6 +284,142 @@ public struct InAStruct { public Plain Id { get; set; } } """) .RunAsync(TestContext.Current.CancellationToken); + private const string Interfaces = + """ + + public interface IHasId { Plain Id { get; set; } } + public interface IAlsoId { Plain Id { get; set; } } + public interface IHas { T Value { get; set; } } + + """; + + /// + /// A class reaches its own explicit interface implementation only through a cast of this, and + /// ((IHasId)this).Id = id was reported although every constructor assigns it (#231). Across the + /// input class, the forms that assign a member in a constructor: plain, this., a cast of + /// this to each interface the member implements (explicitly, implicitly, two at once, a generic + /// one) or to the class itself, deconstruction, and out. + /// + [Fact] + public Task A_member_assigned_through_a_cast_of_this_stays_silent() => + Test( + Interfaces + + """ + public sealed class Explicit : IHasId + { + public Explicit(Plain id) => ((IHasId)this).Id = id; + Plain IHasId.Id { get; set; } + } + + public sealed class Implicit : IHasId + { + public Implicit(Plain id) => ((IHasId)this).Id = id; + public Plain Id { get; set; } + } + + public sealed class Both : IHasId, IAlsoId + { + public Both(Plain id) => ((IAlsoId)this).Id = id; + public Plain Id { get; set; } + } + + public sealed class Generic : IHas + { + public Generic(Plain value) => ((IHas)this).Value = value; + Plain IHas.Value { get; set; } + } + + public sealed class Itself + { + private Plain _field; + public Itself(Plain id) + { + this.Id = id; + ((Itself)this)._field = id; + } + public Plain Id { get; } + } + + public sealed class Deconstructed : IHasId + { + public Deconstructed(Plain id, Plain name) => (((IHasId)this).Id, Name) = (id, name); + Plain IHasId.Id { get; set; } + public Plain Name { get; } + } + + public sealed class ThroughOut + { + private Plain _field; + public ThroughOut() => Init(out ((ThroughOut)this)._field); + private static void Init(out Plain value) => value = Plain.Make(); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The cast maps to the member it reaches and to nothing else: a constructor that does not assign it is + /// still named, an assignment to another instance's member assigns none of this one's, and a local that + /// holds this is not followed, so IHasId self = this; self.Id = id; stays reported. + /// + [Fact] + public Task A_cast_assigns_only_the_member_it_reaches_on_this() => + Test( + Interfaces + + """ + public sealed class OneOfTwo : IHasId + { + public OneOfTwo(Plain id) => ((IHasId)this).Id = id; + public OneOfTwo() { } + Plain IHasId.{|#0:Id|} { get; set; } + } + + public sealed class Other : IHasId + { + public Other(Other other, Plain id) => ((IHasId)other).Id = id; + Plain IHasId.{|#1:Id|} { get; set; } + } + + public sealed class Aliased : IHasId + { + public Aliased(Plain id) + { + IHasId self = this; + self.Id = id; + } + Plain IHasId.{|#2:Id|} { get; set; } + } + """, + Cmtk0006(0, "IHasId.Id", "Plain", "the constructor 'OneOfTwo()'", AssignAdvice), + Cmtk0006(1, "IHasId.Id", "Plain", "the constructor 'Other(Other, Plain)'", AssignAdvice), + Cmtk0006(2, "IHasId.Id", "Plain", "the constructor 'Aliased(Plain)'", AssignAdvice)) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// An object initializer or a with inside a constructor sets the new object's member, not this + /// one's: its receiver is an implicit instance reference, as this is, and counted as assigning. + /// + [Fact] + public Task An_initializer_of_another_instance_assigns_nothing_on_this() => + Test( + """ + public sealed class Twin + { + public Twin() => _ = new Twin(0) { Id = Plain.Make() }; + public Twin(int _) { Id = Plain.Make(); } + public Plain {|#0:Id|} { get; set; } + } + + public sealed record Copied + { + public Copied() => _ = new Copied(0) with { Id = Plain.Make() }; + public Copied(int _) { Id = Plain.Make(); } + public Plain {|#1:Id|} { get; init; } + } + """, + Cmtk0006(0, "Id", "Plain", "the constructor 'Twin()'"), + Cmtk0006(1, "Id", "Plain", "the constructor 'Copied()'")) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task A_compilation_without_TypeKit_is_left_alone() => new AnalyzerTest("public sealed class S { public int N { get; set; } }", referenceTypeKit: false) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentKinds.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentKinds.cs index 2abbdedb..2412498b 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentKinds.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentKinds.cs @@ -260,6 +260,7 @@ public static class Use{{parameters}} {{where}} new KindOfParameter("an unmanaged parameter bound by IValue", "", $"where T : unmanaged, {ValueMarker}", new(Declares.Unmanaged | Declares.InterfaceBound | Declares.ValueObjectBound)), new KindOfParameter("a class parameter bound by IValue", "", $"where T : class, {ValueMarker}", new(Declares.Class | Declares.InterfaceBound | Declares.ValueObjectBound)), new KindOfParameter("a new() parameter bound by IValue", "", $"where T : {ValueMarker}, new()", new(Declares.New | Declares.InterfaceBound | Declares.ValueObjectBound)), + new KindOfParameter("a new() parameter bound by an interface deriving from IValue", "", "where T : IDerivedValue, new()", new(Declares.New | Declares.InterfaceBound | Declares.ValueObjectBound)), new KindOfParameter("a parameter bound by a class and IValue", "", $"where T : Handler, {ValueMarker}", new(Declares.ClassBound | Declares.InterfaceBound | Declares.ValueObjectBound)), new KindOfParameter("a parameter bound by IClosedSet", "", "where T : class, CodoMetis.TypeKit.ClosedSets.IClosedSet", new(Declares.Class | Declares.InterfaceBound | Declares.ClosedSetBound)), new KindOfParameter("T : U, U carrying [Sealed]", "<[Sealed] U, T>", "where T : U", new(Declares.Nothing) { BoundBy = new(Declares.Nothing, "Sealed") }), diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentSiteRun.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentSiteRun.cs index ca86c371..bf2c7bb5 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentSiteRun.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentSiteRun.cs @@ -40,6 +40,7 @@ public static Task Verify(TypeArgumentSubject subject, string siteNam var use = Fill(site.Use, subject, argument); var test = new AnalyzerTest(Fill(TypeArgumentSites.Subjects + subject.Declarations, subject, argument)); + test.TestBehaviors = VariantSweep.Behaviors; test.TestState.Sources.Add((site.File, reported ? use : Unmarked(use))); if (reported) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/xunit.runner.json b/test/CodoMetis.TypeKit.Analyzers.Tests/xunit.runner.json new file mode 100644 index 00000000..82eb3ce9 --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", + "parallelMode": "all" +} diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs index 7fe4c2f2..fc73ee6b 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -48,22 +48,64 @@ 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. + /// + /// One patch per kind of schema: a type, an enum without a type, an enum with one, branches, none at all, and the + /// containers, whose elements are a schema of their own or a component's. + /// 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); + Patch Colour, Patch Status, Patch Size, Patch Numbers, Patch Shape, Patch Anything, Patch Either, + Patch LinesList, Patch> Counts); /// 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); + Colour Colour, ProbeStatus Status, Size Size, int[] Numbers, Shape Shape, object Anything, Either Either, + Lines[] LinesList, Dictionary Counts); + + /// + /// Every place a patch can sit, beside a patch property of the same argument: an element of a list, an array or a + /// dictionary, a nullable element, an element of a list in a dictionary, and a nullable patch property. + /// + public sealed record PatchPositions( + Patch Property, + List> List, + Patch[] Array, + Dictionary> Map, + IReadOnlyDictionary> ReadOnlyMap, + List?> NullableElements, + Dictionary>> Nested, + Patch? Nullable) where T : notnull; + + /// + /// The patch in each position of other than the property itself, and whether it is a + /// nullable patch, which the serializer reads as null for a null, not as the Null state. + /// + private static readonly (string Position, Func Patch, bool Nullable)[] Positions = + [ + ("list", schema => schema["items"], false), + ("array", schema => schema["items"], false), + ("map", schema => schema["additionalProperties"], false), + ("readOnlyMap", schema => schema["additionalProperties"], false), + ("nullableElements", schema => schema["items"], true), + ("nested", schema => schema["additionalProperties"]?["items"], false), + ("nullable", schema => schema, true), + ]; + + [Microsoft.AspNetCore.Mvc.ApiController] + public sealed class PositionsController : Microsoft.AspNetCore.Mvc.ControllerBase + { + [Microsoft.AspNetCore.Mvc.HttpPatch("/mvc/positions/guid")] + public int Patch([Microsoft.AspNetCore.Mvc.FromBody] PatchPositions positions) => 0; + } public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; - private static Task DocumentAsync(OpenApiSpecVersion version) => + private static Task DocumentAsync(OpenApiSpecVersion version, string placement = "components") => ProbeHost.DocumentAsync(openApi => { - openApi.AddTypeKit(); + if (placement == "inlined by the host") openApi.CreateSchemaReferenceId = _ => null; + openApi.AddTypeKit(typeKit => typeKit.InlineValueObjects = placement == "InlineValueObjects"); openApi.AddSchemaTransformer((schema, context, _) => { if (context.JsonTypeInfo.Type != typeof(Either)) return Task.CompletedTask; @@ -77,9 +119,41 @@ private static Task DocumentAsync(OpenApiSpecVersion version) => { 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); - }); + MapPositions(app, "name"); + MapPositions(app, "quantity"); + MapPositions(app, "code"); + MapPositions(app, "id"); + MapPositions(app, "lines"); + MapPositions(app, "colour"); + MapPositions(app, "status"); + MapPositions(app, "size"); + MapPositions(app, "numbers"); + if (placement != "inlined by the host") + { + app.MapPatch("/kinds", int (KindsPatch patch) => 0); + app.MapPost("/kinds", int (Kinds kinds) => 0); + MapPositions(app, "shape"); + } + MapPositions(app, "anything"); + MapPositions(app, "either"); + MapPositions(app, "linesList"); + MapPositions>(app, "counts"); + }, controllers: [typeof(PositionsController)]); + + /// The positions of a patch of , in a body of their own, and a list of patches as the whole body. + private static void MapPositions(WebApplication app, string key) where T : notnull + { + app.MapPatch($"/positions/{key}", int (PatchPositions positions) => 0); + app.MapPatch($"/positions/{key}/body", int (List> body) => 0); + } + + /// A request body's schema, in 3.x the content's and in 2.0 the body parameter's. + private static JsonNode Body(OpenApiJson document, string path) + { + var operation = document.Root["paths"]?[path]?["patch"] ?? throw new InvalidOperationException($"No operation patch {path}."); + return operation["requestBody"]?["content"]?["application/json"]?["schema"] + ?? operation["parameters"]!.AsArray().Single(parameter => parameter!["in"]!.GetValue() == "body")!["schema"]!; + } /// For each property, values its argument accepts and values it refuses, as JSON. private static readonly (string Property, string[] Values)[] Values = @@ -96,6 +170,8 @@ private static readonly (string Property, string[] Values)[] Values = ("shape", ["{\"$type\":\"circle\",\"radius\":1}", "\"x\"", "1"]), ("anything", ["1", "\"x\"", "{}", "[]"]), ("either", ["\"x\"", "1", "true"]), + ("linesList", ["[{\"count\":1}]", "[{}]", "{\"count\":1}"]), + ("counts", ["{\"a\":1}", "{\"a\":\"x\"}", "[1]"]), ]; /// @@ -126,6 +202,111 @@ public async Task A_patch_accepts_null_and_otherwise_exactly_what_its_argument_a } } + public static TheoryData Hosts() + { + var data = new TheoryData(); + foreach (var version in new[] { OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0, OpenApiSpecVersion.OpenApi2_0 }) + foreach (var placement in new[] { "components", "InlineValueObjects", "inlined by the host" }) + data.Add(version, placement); + + return data; + } + + /// + /// A patch anywhere else is described as a patch property is: an element of a list, an array or a dictionary, one in + /// a list in a dictionary, and the element of a list that is the whole body. ASP.NET left the elements out, as it does + /// for any converter-backed element, so a client generator typed a map of patches as a map of anything. A nullable patch, + /// a property or an element, which was an empty component and required, is the same schema without the merge-patch + /// description, since a null there reads as null. Over every argument of the catalogue, in each version, wherever value + /// objects are described, in a minimal API and an MVC controller. + /// + [Theory] + [MemberData(nameof(Hosts))] + public async Task A_patch_in_any_position_is_described_as_a_patch_property_is(OpenApiSpecVersion version, string placement) + { + var document = await DocumentAsync(version, placement); + + // ASP.NET refuses a polymorphic type with no reference id, a plain property of it too (an ArgumentNullException for + // the referenceId, measured with Microsoft.AspNetCore.OpenApi 10.0), so a host that inlines everything has no shape. + var arguments = Values.Select(value => value.Property).Where(property => placement != "inlined by the host" || property != "shape"); + foreach (var path in arguments.Select(property => "/positions/" + property).Append("/mvc/positions/guid")) + { + var positions = document.Resolve(Body(document, path)); + var property = document.Expand(positions["properties"]!["property"])!; + property["description"]!.GetValue().ShouldStartWith("A JSON Merge Patch field", customMessage: $"{path}: the property is the patch's"); + + foreach (var (position, patch, nullable) in Positions) + { + var schema = document.Expand(patch(positions["properties"]![position]!)) + .ShouldNotBeNull($"{path} '{position}': {positions["properties"]![position]!.ToJsonString()}"); + if (!nullable) + { + schema.ShouldDescribeTheSameAs(property, $"{path} '{position}'"); + continue; + } + + schema["description"].ShouldBeNull($"{path} '{position}': a null in a nullable patch reads as null, not as a clear"); + WithoutDescription(schema).ShouldDescribeTheSameAs(WithoutDescription(property), $"{path} '{position}'"); + } + + var required = positions["required"]?.AsArray().Select(node => node!.GetValue()) ?? []; + required.ShouldNotContain("nullable", $"{path}: a nullable patch is optional as a patch is"); + + if (path.StartsWith("/mvc", StringComparison.Ordinal)) continue; + document.Expand(Body(document, path + "/body")["items"]).ShouldNotBeNull($"{path}/body").ShouldDescribeTheSameAs(property, $"{path}/body"); + } + } + + /// + /// What the document says of a map and a list of patches is what the endpoint reads: a value sets a key and a null + /// clears it. A nullable patch reads a null as null, as it reads a property the request leaves out, which is why the + /// document does not call it a merge-patch field. + /// + [Fact] + public async Task A_map_of_patches_reads_a_value_and_a_null_as_the_document_says() + { + await using var host = await ProbeHost.StartAsync(openApi => openApi.AddTypeKit(), endpoints: app => + app.MapPatch("/read", string (PatchPositions patch) => string.Join(" ", + patch.Map.OrderBy(entry => entry.Key).Select(entry => $"{entry.Key}={entry.Value.State}") + .Concat(patch.List.Select(element => element.State.ToString())) + .Append(patch.Nullable?.State.ToString() ?? "none")))); + + using var response = await host.Client.PatchAsync("/read", new StringContent( + """{"map":{"a":"x","b":null},"list":["y",null],"nullable":null}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + using var leftOut = await host.Client.PatchAsync("/read", new StringContent( + """{"map":{},"list":[]}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("a=Value b=Null Value Null none"); + (await leftOut.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("none"); + } + + /// + /// The element of a container of patches accepts null and otherwise exactly what its argument accepts, judged by a + /// JSON Schema validator over the catalogue above, as a patch property is. + /// + [Theory] + [MemberData(nameof(Versions))] + public async Task A_patch_in_a_container_accepts_null_and_otherwise_exactly_what_its_argument_accepts(OpenApiSpecVersion version) + { + var document = await DocumentAsync(version); + + foreach (var (property, values) in Values) + { + var positions = document.Resolve(Body(document, "/positions/" + property)); + var argument = Validator(document, document.Property(nameof(Kinds), property), version); + + foreach (var (position, select, _) in Positions) + { + var schema = select(positions["properties"]![position]!) ?? throw new ShouldAssertException($"'{property}' '{position}' has no element schema"); + var patch = Validator(document, schema, version); + + patch("null").ShouldBeTrue($"'{property}' '{position}' should accept null: {schema.ToJsonString()}"); + foreach (var value in values) + patch(value).ShouldBe(argument(value), $"'{property}' '{position}' should judge {value} as its argument does: {schema.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 @@ -251,6 +432,13 @@ public async Task A_patch_parameter_defaulted_with_default_breaks_the_document() refusal.StackTrace.ShouldNotBeNull().ShouldContain(nameof(System.Text.Json.Schema.JsonSchemaExporter), customMessage: "the exporter throws, not the converter"); } + private static JsonNode WithoutDescription(JsonNode schema) + { + var copy = schema.DeepClone().AsObject(); + copy.Remove("description"); + return copy; + } + private static bool AdmitsNull(JsonNode schema, OpenApiSpecVersion version) => version == OpenApiSpecVersion.OpenApi3_1 ? schema["type"] is JsonArray types && types.Any(type => type!.GetValue() == "null") diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs index 0d662ff2..63dabf06 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -43,6 +43,42 @@ public sealed class HandWrittenOrderingTests : IDisposable ["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(), + // Boxed before the operator, which then reads object: Max answered 3 and Order 1, 2, 3 where memory answers 1 + // and 3, 2, 1 (#206). + ["Max of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).Max()!, + ["Min of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).Min()!, + ["Order of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).Order().ToList(), + ["OrderDescending of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).OrderDescending().ToList(), + ["OrderBy itself of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).OrderBy(x => x).ToList(), + ["Max of values cast to object through Where"] = db => db.Ranked.Select(r => (object)r.Rank).Where(x => x != null).Take(5).Max()!, + ["Max of a record class cast to object"] = db => db.Ranked.Select(r => (object)r.Label).Max()!, + ["Max of values cast to object over a navigation"] = db => db.Parents.Select(p => p.Children.Select(c => (object)c.Rank).Max()).ToList(), + + // The boxed value read back through a ?? or a member of a projection: each answered 1, 2, 3 (Max 3) where memory + // answers 3, 2, 1 (Max 1). A ?: failed translation on SQLite, and is refused before it, as on any provider. + ["OrderBy a coalesced key cast to object"] = db => db.Ranked.OrderBy(r => (object?)r.Optional ?? r.Rank).ToList(), + ["OrderBy a conditional key cast to object"] = db => db.Ranked.OrderBy(r => r.Optional != null ? (object)r.Optional : r.Rank).ToList(), + ["OrderBy a boxed member of a projection"] = db => db.Ranked.Select(r => new { Key = (object)r.Rank }).OrderBy(a => a.Key).ToList(), + ["Max of a boxed member of a projection"] = db => db.Ranked.Select(r => new { Key = (object)r.Rank }).Max(a => a.Key)!, + ["Max of a boxed member read back"] = db => db.Ranked.Select(r => new { Key = (object)r.Rank }).Select(a => a.Key).Max()!, + ["OrderBy a boxed member through Where"] = db => db.Ranked.Select(r => new { Key = (object)r.Rank }).Where(a => a.Key != null).OrderBy(a => a.Key).ToList(), + ["OrderBy a nested boxed member"] = db => db.Ranked.Select(r => new { Inner = new { Key = (object)r.Rank } }).OrderBy(a => a.Inner.Key).ToList(), + ["OrderBy a boxed member of a named type"] = db => db.Ranked.Select(r => new BoxedRank { Key = r.Rank }).OrderBy(b => b.Key).ToList(), + ["OrderBy a coalesced boxed member"] = db => db.Ranked.Select(r => new { Key = (object?)r.Optional ?? r.Rank }).OrderBy(a => a.Key).ToList(), + ["Max of a boxed member over a navigation"] = db => db.Parents.Select(p => p.Children.Select(c => new { Key = (object)c.Rank }).Max(a => a.Key)).ToList(), + + // The boxed value reaching the ordering through another operator's lambda: each answered 1, 2, 3 (Max 3) where + // memory answers 3, 2, 1 (Max 1), measured at d573eef. + ["OrderBy a grouping's boxed key"] = db => db.Ranked.GroupBy(r => (object)r.Rank).OrderBy(g => g.Key).Select(g => g.Key).ToList(), + ["Max of a grouping's boxed elements"] = db => db.Ranked.GroupBy(r => r.Id % 1, r => (object)r.Rank).Select(g => g.Max()).ToList(), + ["OrderBy a GroupBy result's boxed key"] = db => db.Ranked.GroupBy(r => r.Rank, (k, g) => new { Key = (object)k }).OrderBy(a => a.Key).ToList(), + ["OrderBy a Join result's boxed member"] = db => db.Ranked.Join(db.Ranked, a => a.Id, b => b.Id, (a, b) => new { Key = (object)b.Rank }).OrderBy(x => x.Key).ToList(), + ["OrderBy a GroupJoin result's boxed member"] = db => db.Parents.GroupJoin(db.Set(), p => p.Id, c => c.ParentId, (p, cs) => new { Key = (object)cs.First().Rank }).OrderBy(x => x.Key).ToList(), + ["OrderBy a SelectMany result's boxed member"] = db => db.Parents.SelectMany(p => p.Children, (p, c) => new { Key = (object)c.Rank }).OrderBy(x => x.Key).ToList(), + ["Max of boxed values through SelectMany"] = db => db.Parents.SelectMany(p => p.Children.Select(c => (object)c.Rank)).Max()!, + ["OrderBy a subquery's boxed First"] = db => db.Ranked.OrderBy(r => db.Ranked.Where(x => x.Id == r.Id).Select(x => (object)x.Rank).First()).ToList(), + ["OrderBy a navigation's boxed FirstOrDefault"] = db => db.Ranked.OrderBy(r => db.Parents.SelectMany(p => p.Children).Where(c => c.Id == r.Id).Select(c => (object)c.Rank).FirstOrDefault()).ToList(), + // The database's own GREATEST and LEAST (SQLite's max and min) order by the column: the first answered // 2, 2, 3 where memory's order answers 1, 1, 2 (measured on PostgreSQL 17 before the refusal). ["Greatest"] = db => db.Ranked.Select(r => EF.Functions.Greatest(r.Rank, Two)).ToList(), @@ -77,7 +113,7 @@ public void A_query_that_orders_a_value_object_by_its_own_CompareTo_is_refused(s var refusal = Should.Throw(() => Refused[query](db)).Message; refusal.ShouldContain("its ordering is its own CompareTo, which the database cannot run"); - refusal.ShouldContain(query is "a record class" or "Least of a record class" ? "ProbeReversedLabel" : "ProbeDescending"); + refusal.ShouldContain(query is "a record class" or "Least of a record class" or "Max of a record class cast to object" ? "ProbeReversedLabel" : "ProbeDescending"); refusal.ShouldContain("Compare or order by .Value explicitly"); } @@ -132,6 +168,16 @@ public void Ordering_by_Value_equality_and_a_generated_ordering_still_translate( 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)); + // Boxed through a projection or a ??, .Value and a generated ordering are still the column's order. + db.Ranked.Select(r => new { Key = (object)r.Rank.Value }).OrderBy(a => a.Key).Select(a => a.Key).ToList().ShouldBe([1, 2, 3]); + db.Ranked.Select(r => new { Key = (object)r.Count }).OrderBy(a => a.Key).Select(a => a.Key).ToList() + .ShouldBe([ProbeCount.From(1), ProbeCount.From(2), ProbeCount.From(3)]); + db.Ranked.OrderBy(r => (object?)r.Count ?? r.Rank.Value).Select(r => r.Rank.Value).ToList().ShouldBe([1, 2, 3]); + db.Ranked.GroupBy(r => (object)r.Rank.Value).OrderBy(g => g.Key).Select(g => g.Key).ToList().ShouldBe([1, 2, 3]); + db.Ranked.Join(db.Ranked, a => a.Id, b => b.Id, (a, b) => new { Key = (object)b.Count }).OrderBy(x => x.Key).Select(x => x.Key).ToList() + .ShouldBe([ProbeCount.From(1), ProbeCount.From(2), ProbeCount.From(3)]); + db.Ranked.OrderBy(r => db.Ranked.Where(x => x.Id == r.Id).Select(x => (object)x.Rank.Value).First()).Select(r => r.Rank.Value).ToList().ShouldBe([1, 2, 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]); } @@ -156,6 +202,12 @@ public void A_comparison_EF_evaluates_in_memory_is_not_refused() [InlineData(typeof(NamedFilterByRankDb), "compared with >", "the query filter \"AboveTwo\" of FilteredRank")] [InlineData(typeof(FilteredByChildRankDb), "compared with >", "the query filter of RankParent")] [InlineData(typeof(FilteredByGreatestDb), "compared by Greatest", "the query filter of FilteredRank")] + [InlineData(typeof(FilteredByBoxedChildRankDb), "ordered by Max", "the query filter of RankParent")] + [InlineData(typeof(FilteredByProjectedChildRankDb), "ordered by Max", "the query filter of RankParent")] + [InlineData(typeof(FilteredByTableMaxDb), "ordered by Max", "the query filter of FilteredRank")] + [InlineData(typeof(CapturedOrderByDescendingFirstDb), "ordered by OrderByDescending", "the query filter of FilteredRank")] + [InlineData(typeof(CapturedMaxByDb), "ordered by MaxBy", "the query filter of FilteredRank")] + [InlineData(typeof(CapturedBoxedMaxDb), "ordered by Max", "the query filter of FilteredRank")] public void A_query_filter_that_orders_by_its_own_CompareTo_is_refused(Type context, string how, string filter) { _connection.Open(); @@ -202,6 +254,29 @@ public void A_query_filter_by_Value_or_in_memory_is_not_refused() db.Set().Select(r => r.Rank.Value).Order().ToList().ShouldBe([2, 3]); } + /// + /// An ordering over the context's own values that takes no lambda reads nothing of the database, and EF evaluates + /// it in memory, by CompareTo: Limits.Max() over 1, 2, 3 is 1 for the descending order, so the filter + /// keeps the row ranked 1. Each was refused when the model was built (#225). With a lambda, EF orders the values + /// in the database: OrderByDescending(l => l).First() kept 3 where memory keeps 1, so those stay refused. + /// + [Theory] + [InlineData(typeof(CapturedMaxDb), 1)] + [InlineData(typeof(CapturedMinDb), 3)] + [InlineData(typeof(CapturedOrderFirstDb), 3)] + [InlineData(typeof(CapturedOrderDescendingFirstDb), 1)] + public void A_query_filter_that_orders_the_contexts_own_values_runs_in_memory(Type context, int kept) + { + _connection.Open(); + using var db = (DbContext)Activator.CreateInstance(context, 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).ToList().ShouldBe([kept]); + } + public void Dispose() => _connection.Dispose(); private RankedDb Seeded() @@ -294,6 +369,89 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } +/// A query filter over the greatest of the children's ranks, boxed before Max (#206). +public sealed class FilteredByBoxedChildRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity().HasQueryFilter(p => (ProbeDescending)p.Children.Select(c => (object)c.Rank).Max()! == ProbeDescending.From(2)); + } +} + +/// The same, the rank boxed in a projection and read back by its member. +public sealed class FilteredByProjectedChildRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity().HasQueryFilter(p => (ProbeDescending)p.Children.Select(c => new { Key = (object)c.Rank }).Max(a => a.Key)! == ProbeDescending.From(2)); + } +} + +/// A query filter by the greatest rank of the table itself, which the database orders by the column. +public sealed class FilteredByTableMaxDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == Set().Max(x => x.Rank)); +} + +/// Contexts whose query filter orders values the context holds: in memory without a lambda, in the database with one. +public abstract class CapturedLimitsDb(DbContextOptions options) : DbContext(options) +{ + public ProbeDescending[] Limits { get; } = [ProbeDescending.From(1), ProbeDescending.From(2), ProbeDescending.From(3)]; + + public List LimitList { get; } = [ProbeDescending.From(1), ProbeDescending.From(2), ProbeDescending.From(3)]; +} + +public sealed class CapturedMaxDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == Limits.Max()); +} + +public sealed class CapturedMinDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == LimitList.Min()); +} + +public sealed class CapturedOrderFirstDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == Limits.Order().First()); +} + +public sealed class CapturedOrderDescendingFirstDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == LimitList.OrderDescending().First()); +} + +public sealed class CapturedOrderByDescendingFirstDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == LimitList.OrderByDescending(l => l).First()); +} + +public sealed class CapturedMaxByDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == Limits.MaxBy(l => l)); +} + +public sealed class CapturedBoxedMaxDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == (ProbeDescending)Limits.Select(l => (object)l).Max()!); +} + +/// A projection that holds a rank boxed. +public sealed class BoxedRank +{ + public object? Key { get; set; } +} + /// A query filter that keeps the rows whose rank is the greater of it and 2, by the database's GREATEST. public sealed class FilteredByGreatestDb(DbContextOptions options) : DbContext(options) { diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs index f53ac770..b7bf9bc6 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs @@ -32,7 +32,10 @@ public sealed class SqlServer : IAsyncLifetime .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("SQL Server is now ready for client connections")) .Build(); - private readonly ConcurrentDictionary> _databases = new(); + // Lazy, because GetOrAdd may run its factory more than once for callers that arrive together, and two of them + // would drop and create the same database under each other: with the project's rows run in parallel, the first + // rows of a sweep did, "Database 'typekit_queries' already exists" (#251). + private readonly ConcurrentDictionary>> _databases = new(); private string ConnectionString(string database) => new SqlConnectionStringBuilder { @@ -44,7 +47,7 @@ public sealed class SqlServer : IAsyncLifetime }.ConnectionString; /// A database of its own on the server, created empty the first time it is asked for, and its connection string. - public Task DatabaseAsync(string name) => _databases.GetOrAdd(name, CreateAsync); + public Task DatabaseAsync(string name) => _databases.GetOrAdd(name, key => new Lazy>(() => CreateAsync(key))).Value; private async Task CreateAsync(string name) { diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs new file mode 100644 index 00000000..63a54787 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs @@ -0,0 +1,31 @@ +using Microsoft.Data.SqlClient; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// A guard on the harness: a database many callers ask for at once is created once, as the rows of a sweep ask for +/// it when they run in parallel (#251). Created more than once, one caller drops it under another, "Database +/// 'typekit_queries' already exists", or after another has returned, and every row reading it fails. +/// +[Collection(SqlServerCollection.Name)] +[Trait("Needs", "Docker")] +public sealed class SqlServerFixtureTests(SqlServer server) +{ + [Fact] + public async Task A_database_asked_for_by_many_callers_at_once_is_created_once() + { + var name = $"typekit_fixture_{Guid.NewGuid():N}"; + + var answers = await Task.WhenAll(Enumerable.Range(0, 32).Select(_ => Task.Run(() => server.DatabaseAsync(name)))); + + answers.Distinct().ShouldHaveSingleItem(); + + // A creation that lost the race could still drop the database after the winner returned; give it the time. + await Task.Delay(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + await using var connection = new SqlConnection(answers[0]); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT DB_NAME()"; + (await command.ExecuteScalarAsync(TestContext.Current.CancellationToken)).ShouldBe(name); + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs index 55c4fdd1..5f96b0de 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs @@ -94,6 +94,30 @@ public enum Op ThenByDescending, OrderByBoxed, ThenByBoxed, + + /// Select(r => (object)r.P).Order(): boxed before the operator, which then reads object (#206). + OrderBoxed, + + /// Select(r => (object)r.P).OrderBy(x => x): a key that is the element itself. + OrderByItselfBoxed, + + MinBoxed, + MaxBoxed, + + /// OrderBy(r => (object)r.P ?? (object)r.P): the boxed value read through a ??. + OrderByCoalescedBoxed, + + /// Select(r => new BoxedKey { Key = r.P }).OrderBy(b => b.Key): a boxed member of a projection. + OrderByProjectedBoxed, + + /// Select(r => new BoxedKey { Key = r.P }).Max(b => b.Key). + MaxProjectedBoxed, + + /// GroupBy(r => (object)r.P).OrderBy(g => g.Key): a grouping's boxed key. + OrderByGroupKeyBoxed, + + /// Join(rows, a => a.Id, b => b.Id, (a, b) => new BoxedKey { Key = a.P }).OrderBy(b => b.Key): a join's boxed result. + OrderByJoinedBoxed, Order, OrderDescending, Min, @@ -140,7 +164,9 @@ public sealed record QueryCase(WrappedKind Kind, Conversion Conversion, Site Sit /// A comparison, an ordering, or a minimum or maximum: what a value object's own CompareTo decides in memory. public bool Orders => Op is Op.GreaterOrEqual or Op.LessThan or Op.LessOrEqual or Op.LessThanOrNull or Op.CompareTo or Op.OrderBy or Op.OrderByDescending or Op.ThenBy or Op.ThenByDescending - or Op.OrderByBoxed or Op.ThenByBoxed or Op.Order or Op.OrderDescending or Op.Min or Op.Max or Op.MinBy or Op.MaxBy + or Op.OrderByBoxed or Op.ThenByBoxed or Op.OrderBoxed or Op.OrderByItselfBoxed or Op.MinBoxed or Op.MaxBoxed + or Op.OrderByCoalescedBoxed or Op.OrderByProjectedBoxed or Op.MaxProjectedBoxed or Op.OrderByGroupKeyBoxed or Op.OrderByJoinedBoxed + or Op.Order or Op.OrderDescending or Op.Min or Op.Max or Op.MinBy or Op.MaxBy or Op.AnyGreaterOrEqual or Op.CountGreaterOrEqual or Op.ElementMax or Op.ElementOrderFirst or Op.NavigationMin or Op.NavigationMax or Op.NavigationOrderFirst or Op.NavigationOrderDescendingFirst or Op.Greatest or Op.Least or Op.RowGreaterThan or Op.RowLessThan or Op.RowGreaterOrEqual or Op.RowLessOrEqual; @@ -163,7 +189,10 @@ public static class QueryShapes private static readonly Op[] ColumnOps = [ Op.EqualsLiteral, Op.EqualsCaptured, Op.NotEquals, Op.GreaterOrEqual, Op.LessThan, Op.LessOrEqual, Op.CompareTo, - Op.OrderBy, Op.OrderByDescending, Op.ThenBy, Op.ThenByDescending, Op.OrderByBoxed, Op.ThenByBoxed, Op.Order, Op.OrderDescending, + Op.OrderBy, Op.OrderByDescending, Op.ThenBy, Op.ThenByDescending, Op.OrderByBoxed, Op.ThenByBoxed, + Op.OrderBoxed, Op.OrderByItselfBoxed, Op.MinBoxed, Op.MaxBoxed, Op.OrderByCoalescedBoxed, Op.OrderByProjectedBoxed, Op.MaxProjectedBoxed, + Op.OrderByGroupKeyBoxed, Op.OrderByJoinedBoxed, + Op.Order, Op.OrderDescending, Op.Min, Op.Max, Op.MinBy, Op.MaxBy, Op.Contains, Op.GroupBy, Op.Select, Op.Distinct, .. FunctionOps, ]; @@ -220,6 +249,12 @@ private static IEnumerable Enumerate() } } +/// A projection that holds the value read boxed, for the orderings by its member. +public sealed class BoxedKey +{ + public object? Key { get; set; } +} + /// Holds a captured value, so that a query reads it as a parameter, as a closure does. public sealed class Box(T value) { @@ -439,6 +474,20 @@ private static Expression CompareTo(Expression left, Expression right) => Expression Values(Expression ordered) => Q("Select", [Source, type], ordered, key); Expression Unordered() => Q("OrderBy", [Source, typeof(int)], source, Expression.Lambda(Expression.Modulo(Expression.Property(row, "Id"), Expression.Constant(1)), row)); var boxed = Expression.Lambda(Expression.Convert(read, typeof(object)), row); + var boxedValues = Q("Select", [Source, typeof(object)], source, boxed); + var itself = Expression.Parameter(typeof(object), "x"); + var coalesced = Expression.Lambda(Expression.Coalesce(Expression.Convert(read, typeof(object)), Expression.Convert(read, typeof(object))), row); + var holder = Expression.Parameter(typeof(BoxedKey), "b"); + var projected = Q("Select", [Source, typeof(BoxedKey)], source, + Expression.Lambda(Expression.MemberInit(Expression.New(typeof(BoxedKey)), Expression.Bind(typeof(BoxedKey).GetProperty(nameof(BoxedKey.Key))!, Expression.Convert(read, typeof(object)))), row)); + var heldKey = Expression.Lambda(Expression.Property(holder, nameof(BoxedKey.Key)), holder); + var grouping = typeof(IGrouping<,>).MakeGenericType(typeof(object), Source); + var group = Expression.Parameter(grouping, "g"); + var groupKey = Expression.Lambda(Expression.Property(group, "Key"), group); + var other = Expression.Parameter(Source, "o"); + var joined = Q("Join", [Source, Source, typeof(int), typeof(BoxedKey)], source, source, + Expression.Lambda(Expression.Property(row, "Id"), row), Expression.Lambda(Expression.Property(other, "Id"), other), + Expression.Lambda(Expression.MemberInit(Expression.New(typeof(BoxedKey)), Expression.Bind(typeof(BoxedKey).GetProperty(nameof(BoxedKey.Key))!, Expression.Convert(read, typeof(object)))), row, other)); return @case.Op switch { @@ -456,6 +505,15 @@ private static Expression CompareTo(Expression left, Expression right) => Op.ThenByDescending => (Values(Q("ThenByDescending", [Source, type], Unordered(), key)), true), Op.OrderByBoxed => (Values(Q("OrderBy", [Source, typeof(object)], source, boxed)), true), Op.ThenByBoxed => (Values(Q("ThenBy", [Source, typeof(object)], Unordered(), boxed)), true), + Op.OrderBoxed => (Q("Order", [typeof(object)], boxedValues), true), + Op.OrderByItselfBoxed => (Q("OrderBy", [typeof(object), typeof(object)], boxedValues, Expression.Lambda(itself, itself)), true), + Op.MinBoxed => (Q("Min", [typeof(object)], boxedValues), true), + Op.MaxBoxed => (Q("Max", [typeof(object)], boxedValues), true), + Op.OrderByCoalescedBoxed => (Values(Q("OrderBy", [Source, typeof(object)], source, coalesced)), true), + Op.OrderByProjectedBoxed => (Q("Select", [typeof(BoxedKey), typeof(object)], Q("OrderBy", [typeof(BoxedKey), typeof(object)], projected, heldKey), heldKey), true), + Op.MaxProjectedBoxed => (Q("Max", [typeof(BoxedKey), typeof(object)], projected, heldKey), true), + Op.OrderByGroupKeyBoxed => (Q("Select", [grouping, typeof(object)], Q("OrderBy", [grouping, typeof(object)], Q("GroupBy", [Source, typeof(object)], source, boxed), groupKey), groupKey), true), + Op.OrderByJoinedBoxed => (Q("Select", [typeof(BoxedKey), typeof(object)], Q("OrderBy", [typeof(BoxedKey), typeof(object)], joined, heldKey), heldKey), true), Op.Order => (Q("Order", [type], Values(source)), true), Op.OrderDescending => (Q("OrderDescending", [type], Values(source)), true), Op.Min => (Q("Min", [Source, type], source, key), true), diff --git a/test/CodoMetis.TypeKit.Facts/KB057.cs b/test/CodoMetis.TypeKit.Facts/KB057.cs new file mode 100644 index 00000000..122348eb --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB057.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-057: a Nullable<T> contract has T as its element type, though its kind is +/// , and a JSON null read into a T? is null without asking T's converter, +/// even one that handles null: as a property, an element and the root alike, where a plain T hands the null to it. +/// +public sealed class KB057 +{ + /// A struct whose converter reads a null as a state of its own, as a merge-patch field's does. + [JsonConverter(typeof(MarkedConverter))] + public readonly record struct Marked(bool WasNull); + + private sealed class MarkedConverter : JsonConverter + { + public override bool HandleNull => true; + + public override Marked Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var wasNull = reader.TokenType == JsonTokenType.Null; + reader.Skip(); + return new Marked(wasNull); + } + + public override void Write(Utf8JsonWriter writer, Marked value, JsonSerializerOptions options) => writer.WriteNullValue(); + } + + public sealed record Holder(Marked Plain, Marked? Maybe); + + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + + [Fact] + public void A_nullable_contract_has_the_underlying_type_as_its_element_type() + { + var nullable = Options.GetTypeInfo(typeof(Marked?)); + + nullable.Kind.ShouldBe(JsonTypeInfoKind.None); + nullable.ElementType.ShouldBe(typeof(Marked)); + Options.GetTypeInfo(typeof(int?)).ElementType.ShouldBe(typeof(int), "the same for a built-in type"); + Options.GetTypeInfo(typeof(Marked)).ElementType.ShouldBeNull("the underlying type itself has none"); + } + + [Fact] + public void A_null_read_into_a_nullable_never_reaches_a_converter_that_handles_null() + { + var holder = JsonSerializer.Deserialize("""{"plain":null,"maybe":null}""", Options)!; + holder.Plain.WasNull.ShouldBeTrue("a plain T hands the null to its converter"); + holder.Maybe.ShouldBeNull("a T? property reads it as null"); + + JsonSerializer.Deserialize("""{"plain":null}""", Options)!.Maybe.ShouldBeNull("the same as a property left out"); + JsonSerializer.Deserialize>("[null]", Options).ShouldBe([null], "an element too"); + JsonSerializer.Deserialize("null", Options).ShouldBeNull("and the root"); + JsonSerializer.Deserialize("null", Options).WasNull.ShouldBeTrue(); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB058.cs b/test/CodoMetis.TypeKit.Facts/KB058.cs new file mode 100644 index 00000000..21d92084 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB058.cs @@ -0,0 +1,96 @@ +using System.Linq.Expressions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-058: EF Core evaluates a LINQ chain over a collection the query captures in memory when the chain +/// takes no lambda, values.Max(), and sends the result as one parameter; with a lambda, +/// values.OrderByDescending(v => v).First(), it sends the collection's elements and orders them in SQL, and +/// values.Max(v => v) throws inside EF. A query filter over the context's own values does the same. +/// +public sealed class KB058 +{ + public sealed class Row + { + public int Id { get; set; } + + public int P { get; set; } + } + + private sealed class Db(DbContextOptions options) : DbContext(options) + { + public int[] Values { get; } = [1, 2, 3]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity(); + } + + private sealed class MaxFilterDb(DbContextOptions options) : DbContext(options) + { + public int[] Values { get; } = [1, 2, 3]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity().HasQueryFilter(r => r.P == Values.Max()); + } + + private sealed class KeyedMaxFilterDb(DbContextOptions options) : DbContext(options) + { + public int[] Values { get; } = [1, 2, 3]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity().HasQueryFilter(r => r.P == Values.OrderByDescending(v => v).First()); + } + + public static TheoryData Chains => new() + { + { "Max()", false }, + { "Min()", false }, + { "Order().First()", false }, + { "First()", false }, + { "OrderBy(v => v).First()", true }, + { "OrderByDescending(v => v).First()", true }, + }; + + [Theory, MemberData(nameof(Chains))] + public void A_chain_over_captured_values_is_ordered_in_SQL_only_with_a_lambda(string chain, bool inSql) + { + int[] values = [1, 2, 3]; + Expression> filter = chain switch + { + "Max()" => r => r.P == values.Max(), + "Min()" => r => r.P == values.Min(), + "Order().First()" => r => r.P == values.Order().First(), + "First()" => r => r.P == values.First(), + "OrderBy(v => v).First()" => r => r.P == values.OrderBy(v => v).First(), + _ => r => r.P == values.OrderByDescending(v => v).First(), + }; + using var connection = new SqliteConnection("Data Source=:memory:"); + using var db = new Db(new DbContextOptionsBuilder().UseSqlite(connection).Options); + + HoldsTheCollection(db.Set().Where(filter).ToQueryString()).ShouldBe(inSql); + } + + [Fact] + public void A_keyed_Max_over_captured_values_throws_inside_EF() + { + int[] values = [1, 2, 3]; + using var connection = new SqliteConnection("Data Source=:memory:"); + using var db = new Db(new DbContextOptionsBuilder().UseSqlite(connection).Options); + + Should.Throw(() => db.Set().Where(r => r.P == values.Max(v => v)).ToQueryString()); + } + + [Fact] + public void A_query_filter_over_the_contexts_values_is_ordered_in_SQL_only_with_a_lambda() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + + using (var db = new MaxFilterDb(new DbContextOptionsBuilder().UseSqlite(connection).Options)) + HoldsTheCollection(db.Set().ToQueryString()).ShouldBeFalse(); + + using (var db = new KeyedMaxFilterDb(new DbContextOptionsBuilder().UseSqlite(connection).Options)) + HoldsTheCollection(db.Set().ToQueryString()).ShouldBeTrue(); + } + + /// The collection's elements in the SQL: one parameter each in a VALUES list (EF 10's default), or as JSON. + private static bool HoldsTheCollection(string sql) => sql.Contains("VALUES") || sql.Contains("json_each"); +} diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index 252c6a7e..45ef5168 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -186,6 +186,23 @@ public override void Write(System.Text.Json.Utf8JsonWriter writer, ProbeSpanText /// public abstract record ProbeRecordBase; +/// +/// A base record in another assembly whose methods share a generated member's name and parameter types, but are +/// other methods to C#: a generic Parse, a From that takes its value by in, and a +/// TryParse whose last parameter is by value. The generated members sit beside them (#226). +/// +public abstract record ProbeOverloadingRecord +{ + /// A generic overload, never the generated Parse. + public static TSelf Parse(string text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// An overload by in, never the generated From. + public static TSelf From(in int value) => throw new NotSupportedException("hand-written"); + + /// An overload whose last parameter is by value, never the generated TryParse. + public static bool TryParse(string? text, IFormatProvider? provider, TSelf fallback) => throw new NotSupportedException("hand-written"); +} + /// /// A base record in another assembly for the test assembly's value objects, whose sealed /// ToString() is the formatting seam: C# keeps it in every derived record. @@ -221,6 +238,50 @@ public abstract record ProbeIdentityRecord public Guid Identity => _identity; } +/// +/// An interface in another assembly that implements IFormattable explicitly, which a value object +/// declaring it in the build-outcome consumer must refuse (CMTK1011). Metalama lists no private member of a +/// type in another assembly, and an explicit implementation is private, so a check that listed members missed it. +/// +public interface IProbeFormats : IFormattable +{ + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) => "***"; +} + +/// The same for IMinMaxValue, a static interface. +public interface IProbeBounded : System.Numerics.IMinMaxValue where TSelf : IProbeBounded +{ + static TSelf System.Numerics.IMinMaxValue.MinValue => throw new NotSupportedException(); + + static TSelf System.Numerics.IMinMaxValue.MaxValue => throw new NotSupportedException(); +} + +/// The same for IComparable, which CMTK1008 refuses. +public interface IProbeOrders : IComparable +{ + int IComparable.CompareTo(object? obj) => 0; +} + +/// The same for IClosedSet, on a closed set. +public interface IProbeSetNames : IClosedSet where TSelf : class, IProbeSetNames +{ + string IClosedSet.Name => "***"; +} + +/// A base record in another assembly that implements IFormattable explicitly, which a value object derived from it must refuse. +public abstract record ProbeFormattableRecord : IFormattable +{ + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) => "***"; +} + +/// The same for IMinMaxValue, which kept answering the interface. +public abstract record ProbeMinMaxRecord : System.Numerics.IMinMaxValue where TSelf : ProbeMinMaxRecord, System.Numerics.IMinMaxValue +{ + static TSelf System.Numerics.IMinMaxValue.MinValue => throw new NotSupportedException(); + + static TSelf System.Numerics.IMinMaxValue.MaxValue => throw new NotSupportedException(); +} + /// A closed set with no data and no constructor: the generators add a private one. public sealed partial class ProbeStatus : IClosedSet { diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BaseMemberAccessibility.cs b/test/CodoMetis.TypeKit.Generators.Tests/BaseMemberAccessibility.cs new file mode 100644 index 00000000..2b041712 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/BaseMemberAccessibility.cs @@ -0,0 +1,203 @@ +using System.Text; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// +/// A base type's member of a generated member's name or signature, by its accessibility (#230): what the value +/// object or the closed set can see stands in for the generated member and is refused, and what it cannot see, a +/// base's private member, is hidden by C# and stands in for nothing, so the type is generated as 2.3.0 generated +/// it. One base per member and accessibility, one value object or set over each, built in +/// ; what the private ones generate is '. +/// +internal static class BaseMemberAccessibility +{ + /// Every accessibility a member of a base can have, by the name the declarations use for it. + public static IReadOnlyList<(string Name, string Keyword)> Accessibilities { get; } = + [ + ("Private", "private"), + ("PrivateProtected", "private protected"), + ("Protected", "protected"), + ("Internal", "internal"), + ("ProtectedInternal", "protected internal"), + ("Public", "public"), + ]; + + /// + /// The generated members a base record's member stands in for, the declaration of each in a base record + /// <TSelf>, whether the value object is a validated one, and the error that refuses it. + /// + private static readonly (string Name, string Declaration, bool Validated, string Id)[] ValueObjectMembers = + [ + ("Value", "int Value => 0;", false, "CMTK1011"), + ("From", "static TSelf From(int value) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("MinValue", "static int MinValue => 0;", false, "CMTK1011"), + ("MaxValue", "static int MaxValue => 0;", false, "CMTK1011"), + ("Parse", "static TSelf Parse(string text, System.IFormatProvider? provider) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("TryParse", "static bool TryParse(string? text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("SpanParse", "static TSelf Parse(System.ReadOnlySpan text, System.IFormatProvider? provider) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("SpanTryParse", "static bool TryParse(System.ReadOnlySpan text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("Utf8Parse", "static TSelf Parse(System.ReadOnlySpan text, System.IFormatProvider? provider) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("Utf8TryParse", "static bool TryParse(System.ReadOnlySpan text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("Format", "string ToString(string? format, System.IFormatProvider? provider) => \"\";", false, "CMTK1011"), + ("TryFormat", "bool TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("ObjectCompareTo", "int CompareTo(object? other) => 0;", false, "CMTK1008"), + ("CompareTo", "int CompareTo(TSelf? other) => 0;", false, "CMTK1008"), + ("TryFrom", "static Option TryFrom(int value) => throw new System.NotSupportedException();", true, "CMTK1011"), + ("Revalidate", "Result Revalidate() => throw new System.NotSupportedException();", true, "CMTK1011"), + ]; + + /// The generated members of a closed set a base class's member stands in for, declared in a base class <TSelf>. + private static readonly (string Name, string Declaration)[] SetMembers = + [ + ("Parse", "static TSelf Parse(string name) => throw new System.NotSupportedException();"), + // The ignore-case form, which no interface declares: a base's protected TryParse(string?, out TSelf) is also + // CS0737 beside the refusal, as it can't implement IClosedSet.TryParse. + ("TryParse", "static bool TryParse(string? name, bool ignoreCase, out TSelf member) => throw new System.NotSupportedException();"), + ("Match", "TResult Match(System.Func a) => a();"), + ]; + + private static string ValueObject(string member, string accessibility) => $"{member}In{accessibility}Base"; + + private static string Set(string member, string accessibility) => $"SetWith{member}In{accessibility}Base"; + + /// The value objects, for the consumer's declarations build. + public static string ValueObjectSource() + { + var source = new StringBuilder( + """ + using CodoMetis.TypeKit; + using CodoMetis.TypeKit.ValueObjects; + + namespace Consumer; + + public enum AccessibilityFault { Negative } + + // A value object nested in its base record sees the base's private members, as C# does: refused. + public abstract record EnclosingBase + { + private static int MaxValue => 0; + + public sealed partial record NestedInItsBase : EnclosingBase, IValue; + } + + // A library's private and internal members, which no project but the library's sees. + public sealed partial record OverALibrarysHiddenMembers : Library.HiddenMembersBase, IValue; + + """); + + foreach (var (member, declaration, validated, _) in ValueObjectMembers) + { + foreach (var (accessibility, keyword) in Accessibilities) + { + var type = ValueObject(member, accessibility); + source.AppendLine($"public abstract record {type}Record where TSelf : notnull {{ {keyword} {declaration} }}"); + source.AppendLine(validated + ? $"public sealed partial record {type} : {type}Record<{type}>, IValidatedValue<{type}, int, AccessibilityFault> {{ public static Result<{type}, AccessibilityFault> Create(int value) => throw new System.NotSupportedException(); }}" + : $"public sealed partial record {type} : {type}Record<{type}>, IValue;"); + } + } + + return source.ToString(); + } + + /// The closed sets, for the consumer's refused-sets build, where a refused set's CS0535 is expected. + public static string SetSource() + { + var source = new StringBuilder( + """ + using CodoMetis.TypeKit.ClosedSets; + + namespace Consumer; + + public sealed partial class SetOverALibrarysHiddenMembers : Library.HiddenSetMembersBase, IClosedSet { public static readonly SetOverALibrarysHiddenMembers A = new(); } + + """); + + foreach (var (member, declaration) in SetMembers) + { + foreach (var (accessibility, keyword) in Accessibilities) + { + var type = Set(member, accessibility); + source.AppendLine($"public abstract class {type}Class {{ {keyword} {declaration} }}"); + source.AppendLine($"public sealed partial class {type} : {type}Class<{type}>, IClosedSet<{type}> {{ public static readonly {type} A = new(); }}"); + } + } + + return source.ToString(); + } + + /// What the library declares, appended to its file-scoped namespace, which the consumer cannot see. + public const string LibrarySource = + """ + + public abstract record HiddenMembersBase where TSelf : notnull + { + private static int MaxValue => 0; + internal static int MinValue => 0; + private static TSelf Parse(string text, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + internal static bool TryParse(string? text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + private int CompareTo(object? other) => 0; + internal int CompareTo(TSelf? other) => 0; + protected int Uses() => MaxValue + CompareTo(null) + (Parse("", null) is null ? 0 : 1); + } + + public abstract class HiddenSetMembersBase + { + private static TSelf Parse(string name) => throw new System.NotSupportedException(); + internal static bool TryParse(string? name, out TSelf member) => throw new System.NotSupportedException(); + protected static TSelf Uses() => Parse(""); + } + """; + + /// Each value object or set over a member it can see, with the error that refuses it and the member's name. + public static TheoryData Refused() + { + var rows = new TheoryData(); + + foreach (var (member, _, _, id) in ValueObjectMembers) + foreach (var (accessibility, _) in Accessibilities.Where(accessibility => accessibility.Name != "Private")) + rows.Add(accessibility, id, ValueObject(member, accessibility), $"{ValueObject(member, accessibility)}Record<{ValueObject(member, accessibility)}>."); + + foreach (var (member, _) in SetMembers) + foreach (var (accessibility, _) in Accessibilities.Where(accessibility => accessibility.Name != "Private")) + rows.Add(accessibility, "CMTK1011", Set(member, accessibility), $"{Set(member, accessibility)}Class<{Set(member, accessibility)}>."); + + rows.Add("private, seen from a nested type", "CMTK1011", "EnclosingBase.NestedInItsBase", "EnclosingBase.MaxValue in its base type EnclosingBase"); + return rows; + } + + /// Each value object or set over a member it cannot see. + public static TheoryData Generated() => + [ + .. ValueObjectMembers.Select(member => ValueObject(member.Name, "Private")), + .. SetMembers.Select(member => Set(member.Name, "Private")), + "OverALibrarysHiddenMembers", + "SetOverALibrarysHiddenMembers", + ]; +} + +public sealed partial class BuildOutcomeTests +{ + public static TheoryData SeenBaseMembers => BaseMemberAccessibility.Refused(); + + public static TheoryData HiddenBaseMembers => BaseMemberAccessibility.Generated(); + + /// + /// A base's member the value object or set can see, protected, internal or public, stands in for the generated + /// one, and is refused naming the base, as for a public one (#197, #205). + /// + [Theory] + [MemberData(nameof(SeenBaseMembers))] + public void A_base_member_the_type_can_see_is_refused(string accessibility, string id, string type, string member) => + consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member) && error.Message.Contains(" in its base type "), + $"{id} on {type}, {accessibility}. The build reported:{Environment.NewLine}{consumer.Output}"); + + /// + /// A base's private member is hidden from the value object or set, so it stands in for nothing: the type builds, + /// as it did on 2.3.0, where 2.3.1's base-record checks refused it (#230). So does a library's internal one. + /// + [Theory] + [MemberData(nameof(HiddenBaseMembers))] + public void A_base_member_the_type_cannot_see_is_not_refused(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 858d93e9..da9acf41 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -169,15 +169,50 @@ public void A_member_name_the_generators_take_is_refused_with_the_reason(string /// /// 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. + /// code: IParsable twice, an attribute twice (CS0579). The error names each. An interface's + /// explicit implementation of IClosedSet<TSelf> was replaced by the generated member without + /// a word, and IParsable declared through an interface failed the aspect (LAMA0512) (#191). /// [Theory] [InlineData("SetWithParsable", "the interface IParsable")] + [InlineData("SetWithParsableThroughInterface", "the interface IParsable in its interface ISetParsesItself")] + [InlineData("SetWithNameThroughInterface", "the explicit implementation of IClosedSet.Name in its interface ISetNamesItself")] + [InlineData("SetWithNameThroughInterface", "the explicit implementation of IClosedSet.TryParse(string?, out SetWithNameThroughInterface) in its interface ISetNamesItself")] + [InlineData("SetOverLibraryBase", "the explicit implementation of IClosedSet.Name in its base type ExplicitSetBase")] + [InlineData("SetWithNameThroughLibraryInterface", "the explicit implementation of IClosedSet.Name in its interface IProbeSetNames")] + [InlineData("SetOverParsingBase", "ParsingSetBase.Parse(string) in its base type ParsingSetBase")] + [InlineData("SetOverIgnoreCaseParsingBase", "IgnoreCaseParsingSetBase.Parse(string, bool) in its base type IgnoreCaseParsingSetBase")] + [InlineData("SetOverTryParsingBase", "TryParsingSetBase.TryParse(string?, bool, out SetOverTryParsingBase) in its base type TryParsingSetBase")] + [InlineData("SetWithTryParseByRef", "SetWithTryParseByRef.TryParse(string?, ref SetWithTryParseByRef)")] + [InlineData("SetOverTryParsingByRefBase", "TryParsingByRefSetBase.TryParse(string?, bool, ref SetOverTryParsingByRefBase) in its base type TryParsingByRefSetBase")] + [InlineData("SetOverMatchingBase", "MatchingSetBase.Match(Func) in its base type MatchingSetBase")] + [InlineData("SetOverParsableBase", "the interface IParsable in its base type ParsableSetBase")] + [InlineData("SetOverAllListingBase", "AllListingSetBase.All in its base type AllListingSetBase")] + [InlineData("SetOverProtectedFieldBase", "ProtectedFieldSetBase._name in its base type ProtectedFieldSetBase")] + [InlineData("SetOverQualifiedBase", "QualifiedSetBase.QualifiedName in its base type QualifiedSetBase")] + [InlineData("SetOverOpenToStringBase", "OpenToStringSetBase.ToString() in its base type, which is not sealed, so it is not generated: its generated ToString() replaces it")] + [InlineData("SetOverLibraryParsingBase", "LibraryParsingSetBase.Parse(string) in its base type LibraryParsingSetBase")] + [InlineData("SetOverLibraryParsableBase", "the interface IParsable in its base type LibraryParsableSetBase")] + [InlineData("SetOverLibraryOpenToString", "LibraryOpenToStringSetBase.ToString() in its base type, which is not sealed")] [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); + /// + /// What a base class declares that the set cannot reach, or that the generators keep, is no refusal: its + /// sealed ToString() is the seam, as for a value object, where it failed the aspect (LAMA0502, + /// #205); its private _name and _all, and a [JsonConverter] on it, which the set's own + /// replaces, built before and still do (measured 2026-10-30). + /// + [Theory] + [InlineData("SetOverSealedToStringBase")] + [InlineData("SetOverLibrarySealedToString")] + [InlineData("SetOverPrivateFieldsBase")] + [InlineData("SetOverAttributedBase")] + public void What_a_set_s_base_class_declares_out_of_the_generators_way_is_not_refused(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}"), 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, @@ -305,12 +340,19 @@ public void A_part_a_source_generator_adds_does_not_count() => [InlineData("HandWrittenParse", "HandWrittenParse.Parse(string, IFormatProvider?)")] [InlineData("HandWrittenParseOverASet", "HandWrittenParseOverASet.Parse(string, IFormatProvider?)")] [InlineData("HandWrittenFormat", "HandWrittenFormat.ToString(string?, IFormatProvider?)")] + [InlineData("TryParseByRef", "TryParseByRef.TryParse(string?, IFormatProvider?, ref TryParseByRef)")] + [InlineData("TryParseByIn", "TryParseByIn.TryParse(string?, IFormatProvider?, in TryParseByIn)")] + [InlineData("ParseOfOtherNullability", "ParseOfOtherNullability.Parse(string?, IFormatProvider)")] + [InlineData("TryFormatByRef", "TryFormatByRef.TryFormat(Span, ref int, ReadOnlySpan, 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("LenientOverStrictBase", "Create both as a static method and as an explicit implementation of IValidatedValue in its base type StrictCreateBase, so it is not generated: every generated way in calls LenientOverStrictBase.Create and generic code calling T.Create reaches the inherited one, so it would have two rule sets; declare IValidatedValue on LenientOverStrictBase itself, so that its own Create implements it")] + [InlineData("OnlyStrictBase", "Create only as an explicit implementation of IValidatedValue in its base type StrictCreateBase, so it is not generated: the generated code calls OnlyStrictBase.Create, which cannot reach an explicit implementation; declare it as 'public static Result Create(string value)', and IValidatedValue on OnlyStrictBase itself")] + [InlineData("LenientOverPublicBase", "Create both as a static method and as PublicCreateBase.Create(string) in its base type PublicCreateBase, so it is not generated: every generated way in calls LenientOverPublicBase.Create and generic code calling T.Create reaches the inherited one")] [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")] public void A_hand_written_generated_member_is_refused_by_name(string type, string member) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1011" && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); @@ -325,6 +367,8 @@ public void A_hand_written_generated_member_is_refused_by_name(string type, stri [InlineData("CaseInsensitiveEquals", "CaseInsensitiveEquals.Equals(CaseInsensitiveEquals), CaseInsensitiveEquals.GetHashCode(), so", "(declare CaseInsensitiveEquals as IValidatedValue to get one)")] [InlineData("HashCodeOnly", "HashCodeOnly.GetHashCode(), so", "normalise them in Create, so")] [InlineData("ExplicitEquatable", "the explicit implementation of IEquatable.Equals(ExplicitEquatable), so", "(declare ExplicitEquatable as IValidatedValue to get one)")] + [InlineData("EqualsOnClass", "EqualsOnClass.Equals(EqualsOnClass?), so", "(declare EqualsOnClass as IValidatedValue to get one)")] + [InlineData("EqualsDeeperBase", "EqualsDeeperBase.Equals(DeepEqualityBase?), so", "(declare EqualsDeeperBase as IValidatedValue to get one)")] public void A_hand_written_equality_is_refused_by_name(string type, string members, string remedy) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1011" && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(members) && error.Message.Contains("a value object's equality is its wrapped value's") && error.Message.Contains(remedy), consumer.Output); @@ -365,6 +409,74 @@ public void A_base_records_equality_is_named(string type, string members) => public void An_explicit_implementation_of_a_generated_interface_is_refused_by_name(string id, string type, string member) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); + /// + /// An interface's explicit implementation of a generated interface's member is the default every type + /// declaring the interface reaches until it declares a public member of its own, so the generated one + /// replaced it without a word: Max<T>() read the generated MaxValue where the + /// interface's threw, and an IFormattable call printed the value where the interface answered its + /// own text (#191, measured 2026-10-30 for each interface here). It is refused as the value object's own + /// is, naming the interface, two interfaces down and through a base record too. IConvertible + /// declared through an interface failed the aspect (LAMA0041), as declaring it does. + /// + [Theory] + [InlineData("CMTK1011", "FormatsThroughInterface", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IFormatsItself")] + [InlineData("CMTK1011", "SpanFormatsThroughInterface", "the explicit implementation of ISpanFormattable.TryFormat(Span, out int, ReadOnlySpan, IFormatProvider?) in its interface ISpanFormatsItself")] + [InlineData("CMTK1011", "Utf8FormatsThroughInterface", "the explicit implementation of IUtf8SpanFormattable.TryFormat(Span, out int, ReadOnlySpan, IFormatProvider?) in its interface IUtf8FormatsItself")] + [InlineData("CMTK1011", "ParsesThroughInterface", "the explicit implementation of IParsable.Parse(string, IFormatProvider?) in its interface IParsesItself")] + [InlineData("CMTK1011", "SpanParsesThroughInterface", "the explicit implementation of ISpanParsable.Parse(ReadOnlySpan, IFormatProvider?) in its interface ISpanParsesItself")] + [InlineData("CMTK1011", "Utf8ParsesThroughInterface", "the explicit implementation of IUtf8SpanParsable.Parse(ReadOnlySpan, IFormatProvider?) in its interface IUtf8ParsesItself")] + [InlineData("CMTK1011", "BoundedThroughInterface", "the explicit implementation of IMinMaxValue.MinValue in its interface IBoundedItself")] + [InlineData("CMTK1011", "ConvertsThroughInterface", "the interface IConvertible in its interface IConvertsItself")] + [InlineData("CMTK1008", "OrdersThroughInterface", "the explicit implementation of IComparable.CompareTo(object?) in its interface IOrdersItself")] + [InlineData("CMTK1008", "OrdersThroughGenericInterface", "the explicit implementation of IComparable.CompareTo(OrdersThroughGenericInterface) in its interface IOrdersItselfAs")] + [InlineData("CMTK1008", "ComparesThroughInterface", "the explicit implementation of IComparisonOperators.operator <(ComparesThroughInterface, ComparesThroughInterface) in its interface IComparesItself")] + [InlineData("CMTK1011", "FormatsThroughInterfaceOnClass", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IFormatsItself")] + [InlineData("CMTK1008", "OrdersThroughGenericInterfaceOnClass", "the explicit implementation of IComparable.CompareTo(OrdersThroughGenericInterfaceOnClass?) in its interface IOrdersItselfAs")] + [InlineData("CMTK1011", "FormatsTwoInterfacesDown","the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IFormatsItself")] + [InlineData("CMTK1011", "ConvertsTwoInterfacesDown", "the interface IConvertible in its interface IConvertsTwoDown")] + [InlineData("CMTK1011", "FormatsThroughBaseInterface", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IFormatsItself")] + [InlineData("CMTK1011", "BoundedThroughBaseInterface", "the explicit implementation of IMinMaxValue.MinValue in its interface IBoundedItself")] + [InlineData("CMTK1008", "OrdersThroughBaseInterface", "the explicit implementation of IComparable.CompareTo(object?) in its interface IOrdersItself")] + [InlineData("CMTK1011", "ConvertsThroughBaseInterface", "the interface IConvertible in its base type ConvertsItselfBase")] + [InlineData("CMTK1011", "ContractThroughInterface", "the interface IValueObject in its interface IEntityIdentifier")] + [InlineData("CMTK1011", "ContractTwoInterfacesDown", "the interface IValueObject in its interface IAggregateIdentifier")] + [InlineData("CMTK1011", "MaterializerThroughInterface", "the interface IValueObjectMaterializer in its interface IReadsBackItself")] + [InlineData("CMTK1011", "PlainContractThroughInterface", "the interface IPlainValueObject in its interface IFromableItself")] + [InlineData("CMTK1011", "ContractThroughBaseInterface", "the interface IValueObject in its base type EntityIdentifierBase")] + [InlineData("CMTK1011", "ContractThroughLibraryInterface", "the interface IValueObject in its interface ILibraryEntityIdentifier")] + [InlineData("CMTK1011", "FormatsThroughLibraryInterface", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IProbeFormats")] + [InlineData("CMTK1011", "BoundedThroughLibraryInterface", "the explicit implementation of IMinMaxValue.MinValue in its interface IProbeBounded")] + [InlineData("CMTK1008", "OrdersThroughLibraryInterface", "the explicit implementation of IComparable.CompareTo(object?) in its interface IProbeOrders")] + public void An_interface_s_explicit_implementation_of_a_generated_interface_is_refused_by_name(string id, string type, string member) => + consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); + + /// + /// An interface's default the generators do not replace is no refusal: beside ToString(), the seam, + /// no formatting interface is generated, and a Uri has no ordering. The record's own == + /// replaces an interface's in every record, generated or not (measured 2026-10-30). .NET's + /// INumberBase<TSelf> implements IUtf8SpanFormattable and IUtf8SpanParsable + /// explicitly as bridges to the UTF-16 members, which every numeric type of .NET replaces with its own, as + /// the generated ones do: refused, no value object could implement it. + /// + [Theory] + [InlineData("FormatsThroughInterfaceWithToString")] + [InlineData("OrdersThroughInterfaceOverUri")] + [InlineData("EqualsThroughInterface")] + [InlineData("MetersAsNumber")] + public void An_interface_s_default_the_generators_do_not_replace_is_not_refused(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); + + /// + /// One Create that both the generated code and generic T.Create reach builds: the value object's + /// own, where it declares the marker again over a base that implements Create explicitly, and a base's + /// public one alone (#195). + /// + [Theory] + [InlineData("LenientListedOverStrictBase")] + [InlineData("OnlyPublicBase")] + public void One_Create_over_a_base_record_is_not_refused(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); + /// /// What a base record declares counts as the value object's own. Its unsealed ToString() and its /// PrintMembers were replaced by the generated ToString(), printing what they hid; an @@ -383,9 +495,62 @@ public void An_explicit_implementation_of_a_generated_interface_is_refused_by_na [InlineData("CMTK1011", "InheritsConvertible", "the interface IConvertible in its base type ConvertibleBase")] [InlineData("CMTK1008", "InheritsExplicitComparable", "the explicit implementation of IComparable.CompareTo(InheritsExplicitComparable?) in its base type ComparableBase")] [InlineData("CMTK1008", "InheritsExplicitObjectComparable", "the explicit implementation of IComparable.CompareTo(object?) in its base type ObjectComparableBase")] + [InlineData("CMTK1011", "InheritsLibraryFormattable", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its base type ProbeFormattableRecord")] + [InlineData("CMTK1011", "InheritsLibraryMinMax", "the explicit implementation of IMinMaxValue.MinValue in its base type ProbeMinMaxRecord")] + [InlineData("CMTK1011", "InheritsPublicMinMax", "PublicMinMaxItselfBase.MinValue in its base type PublicMinMaxItselfBase")] + [InlineData("CMTK1011", "InheritsPublicFormatting", "PublicFormattingBase.ToString(string?, IFormatProvider?) in its base type PublicFormattingBase")] + [InlineData("CMTK1008", "InheritsPublicObjectOrder", "PublicObjectOrderBase.CompareTo(object?) in its base type PublicObjectOrderBase")] + [InlineData("CMTK1011", "InheritsPublicParsing", "PublicParsingBase.Parse(string, IFormatProvider?) in its base type PublicParsingBase")] + [InlineData("CMTK1011", "InheritsTryParseByRef", "TryParseByRefBase.TryParse(string?, IFormatProvider?, ref InheritsTryParseByRef) in its base type TryParseByRefBase")] + [InlineData("CMTK1011", "InheritsMinMaxNames", "MinMaxNamesBase.MinValue in its base type MinMaxNamesBase")] + [InlineData("CMTK1011", "InheritsValueName", "ValueNameBase.Value in its base type ValueNameBase")] + [InlineData("CMTK1011", "InheritsFromName", "FromNameBase.From(int) in its base type FromNameBase")] + [InlineData("CMTK1011", "InheritsTryFormatName", "TryFormatNameBase.TryFormat(Span, out int, ReadOnlySpan, IFormatProvider?) in its base type TryFormatNameBase")] + [InlineData("CMTK1008", "InheritsCompareTo", "CompareToSeamBase.CompareTo(InheritsCompareTo?) in its base type CompareToSeamBase, but its comparison is generated from CompareTo(InheritsCompareTo)")] + [InlineData("CMTK1008", "InheritsObjectCompareTo", "ObjectCompareToBase.CompareTo(object?) in its base type ObjectCompareToBase")] + [InlineData("CMTK1011", "InheritsTryFrom", "TryFromSeamBase.TryFrom(string) in its base type TryFromSeamBase, so it is not generated: a seam is kept only where the value object declares it")] + [InlineData("CMTK1011", "InheritsRevalidate", "RevalidateSeamBase.Revalidate() in its base type RevalidateSeamBase, so it is not generated: a seam is kept only where the value object declares it")] + [InlineData("CMTK1011", "InheritsLibraryPublicMinMax", "PublicMinMaxBase.MinValue in its base type PublicMinMaxBase")] public void What_a_base_record_declares_is_refused_by_name(string id, string type, string member) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); + /// + /// An interface derived from a marker is a way to find a value object, not a contract the generators + /// implement, so it is generated as usual (#192). + /// + [Fact] + public void A_marker_declared_through_an_interface_is_generated() => + consumer.Errors.ShouldNotContain(error => error.Message.Contains("'ThroughMarkerInterface'") || error.Message.Contains("IIdentifierMarker"), consumer.Output); + + /// + /// A base record's public CompareTo(object) that implements IComparable is found twice, by the + /// interface mapping and as an inherited comparison member (#194), and named once. + /// + [Fact] + public void A_base_member_found_twice_is_named_once() => + consumer.Errors.Where(error => error.Message.StartsWith("'InheritsPublicObjectOrder' declares ", StringComparison.Ordinal)) + .ShouldHaveSingleItem(consumer.Output) + .Message.Split("PublicObjectOrderBase.CompareTo(object?)").Length.ShouldBe(2, consumer.Output); + + /// + /// A public static member of another project that implements a static abstract one reads as an explicit + /// implementation there, since the compiler records every such implementation as one, and the error called it + /// "the explicit implementation of IMinMaxValue..." (#197). Metalama displays it as + /// PublicMinMaxBase<T>.IMinMaxValue.MinValue, so it is named once, by its type and the member. + /// + [Fact] + public void A_public_member_of_another_project_is_named_once_as_public() + { + var declared = consumer.Errors.Where(error => error.Message.StartsWith("'InheritsLibraryPublicMinMax' declares ", StringComparison.Ordinal)) + .ShouldHaveSingleItem(consumer.Output) + .Message.Split(", so it is not generated")[0]["'InheritsLibraryPublicMinMax' declares ".Length..] + .Split(", "); + + declared.Order(StringComparer.Ordinal).ShouldBe( + ["PublicMinMaxBase.MaxValue in its base type PublicMinMaxBase", + "PublicMinMaxBase.MinValue in its base type PublicMinMaxBase"], ignoreOrder: false, customMessage: consumer.Output); + } + /// /// A hand-written PrintMembers was replaced silently with the record's ToString(), which /// calls it, so what it hid was printed. The error points at ToString(), the seam. @@ -417,7 +582,8 @@ public void Instance_state_besides_the_wrapped_value_is_refused_by_name(string t /// The seams stay allowed: Create, TryFrom, FromKnownGood, Revalidate, /// CompareTo(TSelf) and ToString(). A MinValue of another type, or one the value /// object cannot reach, is not the wrapped type's bound: no MinValue is generated, where it - /// failed inside the generated code (LAMA0611, CS1503 and CS0122). + /// failed inside the generated code (LAMA0611, CS1503 and CS0122). A base record's + /// FromKnownGood(T) is hidden by the generated one, so it is no inherited seam (#194). /// [Theory] [InlineData("WithEverySeam", "'WithEverySeam")] @@ -430,6 +596,7 @@ public void Instance_state_besides_the_wrapped_value_is_refused_by_name(string t [InlineData("InheritsSealedToString", "'InheritsSealedToString")] [InlineData("InheritsFormattableBesideSealedToString", "'InheritsFormattableBesideSealedToString")] [InlineData("WithStatelessBase", "Described.")] + [InlineData("InheritsFromKnownGoodName", "FromKnownGoodNameBase")] public void A_seam_or_a_foreign_bound_is_not_refused(string type, string alsoNotNamed) => consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'") || error.Message.Contains(alsoNotNamed), consumer.Output); @@ -473,11 +640,12 @@ public void A_static_constructor_is_allowed() => [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", "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", "CMTK1023", "CS0535"], 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", "CMTK1023", "CS0535", "CS0738"], 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 + /// did not implement, and CS0738 where a base class's member of a generated name has another type + /// (a static int All, #205). 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. /// /// @@ -491,7 +659,7 @@ public void The_declarations_build_without_a_compiler_error() => [Fact] public void CS0535_names_only_a_refused_closed_set() => - consumer.Errors.Where(error => error.Id == "CS0535") + consumer.Errors.Where(error => error.Id is "CS0535" or "CS0738") .ShouldAllBe(error => RefusedClosedSet().IsMatch(error.Message), consumer.Output); [GeneratedRegex(@"^'(?:Consumer\.)?Set\w+(?:\.\w+)?(?:)?' does not implement interface member ")] @@ -882,6 +1050,27 @@ public static Result Create(string value) => public string ToString(string? format, System.IFormatProvider? provider) => ""; } + // No overload in C#: ref or in against the generated out (CS0663), and another nullability (#226). + public readonly partial record struct TryParseByRef : IValue + { + public static bool TryParse(string? s, System.IFormatProvider? provider, ref TryParseByRef result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct TryParseByIn : IValue + { + public static bool TryParse(string? s, System.IFormatProvider? provider, in TryParseByIn result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct ParseOfOtherNullability : IValue + { + public static ParseOfOtherNullability Parse(string? s, System.IFormatProvider provider) => throw new System.NotSupportedException(); + } + + public readonly partial record struct TryFormatByRef : IValue + { + public bool TryFormat(System.Span destination, ref int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => false; + } + public readonly partial record struct HandWrittenMinValue : IValue { public static int MinValue => 0; @@ -1107,6 +1296,431 @@ public abstract record ObjectComparableBase : System.IComparable public sealed partial record InheritsExplicitObjectComparable : ObjectComparableBase, IValue; + // An interface's explicit implementation of a generated interface's member: the default every + // type declaring the interface reaches until it declares a public member of its own, which the + // generated one replaced without a word (#191). Each interface implements every member of the + // generated one, so a refused value object lacks none (CS0535). + public interface IFormatsItself : System.IFormattable + { + string System.IFormattable.ToString(string? format, System.IFormatProvider? formatProvider) => "***"; + } + + public readonly partial record struct FormatsThroughInterface : IValue, IFormatsItself; + + public interface ISpanFormatsItself : System.ISpanFormattable + { + string System.IFormattable.ToString(string? format, System.IFormatProvider? formatProvider) => "***"; + + bool System.ISpanFormattable.TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + } + + public readonly partial record struct SpanFormatsThroughInterface : IValue, ISpanFormatsItself; + + public interface IUtf8FormatsItself : System.IUtf8SpanFormattable + { + bool System.IUtf8SpanFormattable.TryFormat(System.Span utf8Destination, out int bytesWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + } + + public readonly partial record struct Utf8FormatsThroughInterface : IValue, IUtf8FormatsItself; + + public interface IParsesItself : System.IParsable where TSelf : IParsesItself + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct ParsesThroughInterface : IValidatedValue, IParsesItself + { + public static Result Create(string value) => Result.Error(Fault.Refused); + } + + public interface ISpanParsesItself : System.ISpanParsable where TSelf : ISpanParsesItself + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + + static TSelf System.ISpanParsable.Parse(System.ReadOnlySpan s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.ISpanParsable.TryParse(System.ReadOnlySpan s, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct SpanParsesThroughInterface : IValue, ISpanParsesItself; + + public interface IUtf8ParsesItself : System.IUtf8SpanParsable where TSelf : IUtf8ParsesItself + { + static TSelf System.IUtf8SpanParsable.Parse(System.ReadOnlySpan utf8Text, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.IUtf8SpanParsable.TryParse(System.ReadOnlySpan utf8Text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct Utf8ParsesThroughInterface : IValue, IUtf8ParsesItself; + + public interface IBoundedItself : System.Numerics.IMinMaxValue where TSelf : IBoundedItself + { + static TSelf System.Numerics.IMinMaxValue.MinValue => throw new System.NotSupportedException(); + + static TSelf System.Numerics.IMinMaxValue.MaxValue => throw new System.NotSupportedException(); + } + + public readonly partial record struct BoundedThroughInterface : IValue, IBoundedItself; + + public interface IOrdersItself : System.IComparable + { + int System.IComparable.CompareTo(object? obj) => 0; + } + + public readonly partial record struct OrdersThroughInterface : IValue, IOrdersItself; + + public interface IOrdersItselfAs : System.IComparable + { + int System.IComparable.CompareTo(TSelf? other) => 0; + } + + public readonly partial record struct OrdersThroughGenericInterface : IValue, IOrdersItselfAs; + + public interface IComparesItself : System.Numerics.IComparisonOperators where TSelf : IComparesItself + { + static bool System.Numerics.IComparisonOperators.operator <(TSelf left, TSelf right) => false; + + static bool System.Numerics.IComparisonOperators.operator >(TSelf left, TSelf right) => false; + + static bool System.Numerics.IComparisonOperators.operator <=(TSelf left, TSelf right) => false; + + static bool System.Numerics.IComparisonOperators.operator >=(TSelf left, TSelf right) => false; + } + + public readonly partial record struct ComparesThroughInterface : IValue, IComparesItself; + + // IConvertible declared through an interface failed the aspect (LAMA0041), as declaring it does. + public interface IConvertsItself : System.IConvertible + { + System.TypeCode System.IConvertible.GetTypeCode() => System.TypeCode.Int32; + bool System.IConvertible.ToBoolean(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + byte System.IConvertible.ToByte(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + char System.IConvertible.ToChar(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + System.DateTime System.IConvertible.ToDateTime(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + decimal System.IConvertible.ToDecimal(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + double System.IConvertible.ToDouble(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + short System.IConvertible.ToInt16(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + int System.IConvertible.ToInt32(System.IFormatProvider? provider) => 0; + long System.IConvertible.ToInt64(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + sbyte System.IConvertible.ToSByte(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + float System.IConvertible.ToSingle(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + string System.IConvertible.ToString(System.IFormatProvider? provider) => "***"; + object System.IConvertible.ToType(System.Type conversionType, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + ushort System.IConvertible.ToUInt16(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + uint System.IConvertible.ToUInt32(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + ulong System.IConvertible.ToUInt64(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + } + + public readonly partial record struct ConvertsThroughInterface : IValue, IConvertsItself; + + // A record class's, over a reference type. + public sealed partial record FormatsThroughInterfaceOnClass : IValue, IFormatsItself; + + public sealed partial record OrdersThroughGenericInterfaceOnClass : IValue, IOrdersItselfAs; + + // Two interfaces down, and through an interface of a base record. + public interface IFormatsTwoDown : IFormatsItself; + + public readonly partial record struct FormatsTwoInterfacesDown : IValue, IFormatsTwoDown; + + public interface IConvertsTwoDown : IConvertsItself; + + public readonly partial record struct ConvertsTwoInterfacesDown : IValue, IConvertsTwoDown; + + public abstract record FormatsItselfBase : IFormatsItself; + + public sealed partial record FormatsThroughBaseInterface : FormatsItselfBase, IValue; + + public abstract record BoundedItselfBase : IBoundedItself where TSelf : BoundedItselfBase, IBoundedItself; + + public sealed partial record BoundedThroughBaseInterface : BoundedItselfBase, IValue; + + public abstract record OrdersItselfBase : IOrdersItself; + + public sealed partial record OrdersThroughBaseInterface : OrdersItselfBase, IValue; + + public abstract record ConvertsItselfBase : IConvertsItself; + + public sealed partial record ConvertsThroughBaseInterface : ConvertsItselfBase, IValue; + + // A contract the generators implement, declared through an interface of the user's (#192): the + // aspect failed (LAMA0512, LAMA0041), naming nothing the user wrote. Each interface implements the + // contract's members, so a refused value object lacks none (CS0535). + public interface IEntityIdentifier : IValueObject where TSelf : IEntityIdentifier + { + System.Guid IValueObject.Value => System.Guid.Empty; + + static bool IValueObject.operator ==(TSelf left, TSelf right) => false; + + static bool IValueObject.operator !=(TSelf left, TSelf right) => true; + } + + public readonly partial record struct ContractThroughInterface : IValue, IEntityIdentifier; + + public interface IAggregateIdentifier : IEntityIdentifier where TSelf : IAggregateIdentifier; + + public readonly partial record struct ContractTwoInterfacesDown : IValue, IAggregateIdentifier; + + public interface IReadsBackItself : IValueObjectMaterializer where TSelf : IReadsBackItself + { + static TSelf IValueObjectMaterializer.Materialize(int value) => throw new System.NotSupportedException(); + } + + public readonly partial record struct MaterializerThroughInterface : IValue, IReadsBackItself; + + public interface IFromableItself : IPlainValueObject, IValueObject where TSelf : IFromableItself + { + static TSelf IPlainValueObject.From(int value) => throw new System.NotSupportedException(); + + int IValueObject.Value => 0; + } + + public readonly partial record struct PlainContractThroughInterface : IValue, IFromableItself; + + public abstract record EntityIdentifierBase : IEntityIdentifier where TSelf : EntityIdentifierBase; + + public sealed partial record ContractThroughBaseInterface : EntityIdentifierBase, IValue; + + public readonly partial record struct ContractThroughLibraryInterface : IValue, Library.ILibraryEntityIdentifier; + + // An interface derived from the marker is how a value object may be found: generated as usual. + public interface IIdentifierMarker : IValue; + + public readonly partial record struct ThroughMarkerInterface : IIdentifierMarker; + + // An interface's and a base record's of another assembly, where Metalama lists no private + // member, which an explicit implementation is: replaced or kept without a word (#191). + public readonly partial record struct FormatsThroughLibraryInterface : IValue, CodoMetis.TypeKit.Generators.Probes.IProbeFormats; + + public readonly partial record struct BoundedThroughLibraryInterface : IValue, CodoMetis.TypeKit.Generators.Probes.IProbeBounded; + + public readonly partial record struct OrdersThroughLibraryInterface : IValue, CodoMetis.TypeKit.Generators.Probes.IProbeOrders; + + public sealed partial record InheritsLibraryFormattable : CodoMetis.TypeKit.Generators.Probes.ProbeFormattableRecord, IValue; + + public sealed partial record InheritsLibraryMinMax : CodoMetis.TypeKit.Generators.Probes.ProbeMinMaxRecord, IValue; + + // A base record's public implementation of a generated member, or a member of a generated name + // (#197): its static MinValue and MaxValue were the value object's, and no MinValue or MaxValue was + // generated; the others failed the aspect (LAMA0500). Every body throws, so nothing generated is touched. + public abstract record PublicMinMaxItselfBase : System.Numerics.IMinMaxValue where TSelf : PublicMinMaxItselfBase, System.Numerics.IMinMaxValue + { + public static TSelf MinValue => throw new System.NotSupportedException(); + + public static TSelf MaxValue => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsPublicMinMax : PublicMinMaxItselfBase, IValue; + + public abstract record PublicFormattingBase : System.IFormattable + { + public string ToString(string? format, System.IFormatProvider? formatProvider) => "***"; + } + + public sealed partial record InheritsPublicFormatting : PublicFormattingBase, IValue; + + public abstract record PublicObjectOrderBase : System.IComparable + { + public int CompareTo(object? obj) => 0; + } + + public sealed partial record InheritsPublicObjectOrder : PublicObjectOrderBase, IValue; + + public abstract record PublicParsingBase : System.IParsable where TSelf : PublicParsingBase, System.IParsable + { + public static TSelf Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + public static bool TryParse(string? s, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsPublicParsing : PublicParsingBase, IValue; + + public abstract record TryParseByRefBase + { + public static bool TryParse(string? s, System.IFormatProvider? provider, ref TSelf result) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsTryParseByRef : TryParseByRefBase, IValue; + + public abstract record MinMaxNamesBase where TSelf : MinMaxNamesBase + { + public static TSelf MinValue => throw new System.NotSupportedException(); + + public static TSelf MaxValue => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsMinMaxNames : MinMaxNamesBase, IValue; + + public abstract record ValueNameBase + { + public int Value => 0; + } + + public sealed partial record InheritsValueName : ValueNameBase, IValue; + + public abstract record FromNameBase where TSelf : FromNameBase + { + public static TSelf From(int value) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsFromName : FromNameBase, IValue; + + public abstract record TryFormatNameBase + { + public bool TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsTryFormatName : TryFormatNameBase, IValue; + + // A seam a base record declares rather than the value object (#194): its CompareTo(TSelf) left the + // ordering unmarked for EF Core and made < overflow the stack, its TryFrom and Revalidate stood in for + // the generated ones, and its CompareTo(object) failed the aspect (LAMA0500). + public abstract record CompareToSeamBase where TSelf : CompareToSeamBase + { + public int CompareTo(TSelf? other) => 0; + } + + public sealed partial record InheritsCompareTo : CompareToSeamBase, IValue; + + public abstract record ObjectCompareToBase + { + public int CompareTo(object? obj) => 0; + } + + public sealed partial record InheritsObjectCompareTo : ObjectCompareToBase, IValue; + + public abstract record TryFromSeamBase where TSelf : TryFromSeamBase + { + public static Option TryFrom(string value) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsTryFrom : TryFromSeamBase, IValidatedValue + { + public static Result Create(string value) => Result.Error(Fault.Refused); + } + + public abstract record RevalidateSeamBase where TSelf : RevalidateSeamBase + { + public Result Revalidate() => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsRevalidate : RevalidateSeamBase, IValidatedValue + { + public static Result Create(string value) => Result.Error(Fault.Refused); + } + + // A base's FromKnownGood(T) is another signature than the generated one, which takes the caller's + // expression too and hides it, so it stands in for nothing. Generated as usual. + public abstract record FromKnownGoodNameBase where TSelf : FromKnownGoodNameBase + { + public static TSelf FromKnownGood(string value) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsFromKnownGoodName : FromKnownGoodNameBase, IValidatedValue + { + public static Result Create(string value) => new InheritsFromKnownGoodName(value); + } + + // From a project the generators do not run in: read as an explicit implementation there, named as public. + public sealed partial record InheritsLibraryPublicMinMax : Library.PublicMinMaxBase, IValue; + + // Create in a base record of another project (#195): generic code calling T.Create reached the + // base's, which refused what the value object's own accepted. + public sealed partial record LenientOverStrictBase : Library.StrictCreateBase + { + public static Result Create(string value) => Result.Error(Library.LibraryFault.Refused); + } + + public sealed partial record OnlyStrictBase : Library.StrictCreateBase; + + public sealed partial record LenientOverPublicBase : Library.PublicCreateBase + { + public static Result Create(string value) => Result.Error(Library.LibraryFault.Refused); + } + + // One rule set each: the marker declared again, so the value object's own Create implements it, and a + // base's public Create alone, which the generated code calls too. + public sealed partial record LenientListedOverStrictBase : Library.StrictCreateBase, IValidatedValue + { + public static Result Create(string value) => new LenientListedOverStrictBase(value); + } + + public sealed partial record OnlyPublicBase : Library.PublicCreateBase; + + // An interface's default the generators do not replace. Beside ToString(), the seam, no + // formatting interface is generated, and a Uri has no ordering. The record's own == replaces an + // interface's in every record, generated or not. INumberBase, .NET's, implements + // IUtf8SpanFormattable and IUtf8SpanParsable explicitly as bridges to the UTF-16 members, which + // every numeric type of .NET replaces with its own, as the generated ones do. + public readonly partial record struct FormatsThroughInterfaceWithToString : IValue, IFormatsItself + { + public override string ToString() => "***"; + } + + public sealed partial record OrdersThroughInterfaceOverUri : IValue, IOrdersItselfAs; + + public interface IEqualsItself : System.Numerics.IEqualityOperators where TSelf : IEqualsItself + { + static bool System.Numerics.IEqualityOperators.operator ==(TSelf? left, TSelf? right) => true; + + static bool System.Numerics.IEqualityOperators.operator !=(TSelf? left, TSelf? right) => false; + } + + public readonly partial record struct EqualsThroughInterface : IValue, IEqualsItself; + + public readonly partial record struct MetersAsNumber : IValue, System.Numerics.INumberBase + { + public static MetersAsNumber One => throw new System.NotSupportedException(); + public static int Radix => 2; + public static MetersAsNumber Zero => throw new System.NotSupportedException(); + public static MetersAsNumber AdditiveIdentity => throw new System.NotSupportedException(); + public static MetersAsNumber MultiplicativeIdentity => throw new System.NotSupportedException(); + public static MetersAsNumber Abs(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsCanonical(MetersAsNumber value) => true; + public static bool IsComplexNumber(MetersAsNumber value) => false; + public static bool IsEvenInteger(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsFinite(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsImaginaryNumber(MetersAsNumber value) => false; + public static bool IsInfinity(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsInteger(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsNaN(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsNegative(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsNegativeInfinity(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsNormal(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsOddInteger(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsPositive(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsPositiveInfinity(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsRealNumber(MetersAsNumber value) => true; + public static bool IsSubnormal(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsZero(MetersAsNumber value) => throw new System.NotSupportedException(); + public static MetersAsNumber MaxMagnitude(MetersAsNumber x, MetersAsNumber y) => throw new System.NotSupportedException(); + public static MetersAsNumber MaxMagnitudeNumber(MetersAsNumber x, MetersAsNumber y) => throw new System.NotSupportedException(); + public static MetersAsNumber MinMagnitude(MetersAsNumber x, MetersAsNumber y) => throw new System.NotSupportedException(); + public static MetersAsNumber MinMagnitudeNumber(MetersAsNumber x, MetersAsNumber y) => throw new System.NotSupportedException(); + public static MetersAsNumber Parse(System.ReadOnlySpan s, System.Globalization.NumberStyles style, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + public static MetersAsNumber Parse(string s, System.Globalization.NumberStyles style, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + public static bool TryParse(System.ReadOnlySpan s, System.Globalization.NumberStyles style, System.IFormatProvider? provider, out MetersAsNumber result) => throw new System.NotSupportedException(); + public static bool TryParse(string? s, System.Globalization.NumberStyles style, System.IFormatProvider? provider, out MetersAsNumber result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertFromChecked(TOther value, out MetersAsNumber result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertFromSaturating(TOther value, out MetersAsNumber result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertFromTruncating(TOther value, out MetersAsNumber result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertToChecked(MetersAsNumber value, out TOther result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertToSaturating(MetersAsNumber value, out TOther result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertToTruncating(MetersAsNumber value, out TOther result) => throw new System.NotSupportedException(); + public static MetersAsNumber operator +(MetersAsNumber left, MetersAsNumber right) => throw new System.NotSupportedException(); + public static MetersAsNumber operator -(MetersAsNumber left, MetersAsNumber right) => throw new System.NotSupportedException(); + public static MetersAsNumber operator *(MetersAsNumber left, MetersAsNumber right) => throw new System.NotSupportedException(); + public static MetersAsNumber operator /(MetersAsNumber left, MetersAsNumber right) => throw new System.NotSupportedException(); + public static MetersAsNumber operator +(MetersAsNumber value) => throw new System.NotSupportedException(); + public static MetersAsNumber operator -(MetersAsNumber value) => throw new System.NotSupportedException(); + public static MetersAsNumber operator ++(MetersAsNumber value) => throw new System.NotSupportedException(); + public static MetersAsNumber operator --(MetersAsNumber value) => throw new System.NotSupportedException(); + } + // A base record's equality, which the derived record's synthesized one calls. public abstract record ReferenceEqualityBase { @@ -1207,6 +1821,23 @@ public sealed partial record HashCodeOnly : IValidatedValue.Equals(ExplicitEquatable other) => true; } + // A record class's, whose parameter is nullable. + public sealed partial record EqualsOnClass : IValue + { + public bool Equals(EqualsOnClass? other) => true; + } + + // An Equals of a deeper base record's type, which IEquatable and so a collection of the base + // reached, while == compared the wrapped value (#196). The direct base's is C#'s own CS0111. + public abstract record DeepEqualityBase; + + public abstract record MiddleEqualityBase : DeepEqualityBase; + + public sealed partial record EqualsDeeperBase : MiddleEqualityBase, IValue + { + public bool Equals(DeepEqualityBase? other) => ReferenceEquals(this, other); + } + // Instance state besides the wrapped value: JSON, parsing and the materializer carried the // wrapped value alone while the record's equality compared the rest, and a required member // failed inside the generated code (LAMA0611, CS9035). @@ -1415,6 +2046,68 @@ public abstract partial class SetAbstract : IClosedSet { } public sealed partial class SetWithParsable : IClosedSet, System.IParsable { public static readonly SetWithParsable A = new(); } + // An interface's explicit implementation of IClosedSet, which the generated Name and + // TryParse replaced without a word (#191), and IParsable declared through an interface, + // which failed the aspect (LAMA0512). + public interface ISetNamesItself : IClosedSet where TSelf : class, ISetNamesItself + { + string IClosedSet.Name => "***"; + + static bool IClosedSet.TryParse(string? name, out TSelf member) => throw new System.NotSupportedException(); + } + + public sealed partial class SetWithNameThroughInterface : IClosedSet, ISetNamesItself { public static readonly SetWithNameThroughInterface A = new(); } + + public sealed partial class SetWithNameThroughLibraryInterface : IClosedSet, CodoMetis.TypeKit.Generators.Probes.IProbeSetNames { public static readonly SetWithNameThroughLibraryInterface A = new(); } + + // A base class of another project that implements IClosedSet explicitly: All listed none through + // the interface, its Name was the base's, and EF Core stored that and read no member's name back (#191). + public sealed partial class SetOverLibraryBase : Library.ExplicitSetBase { public static readonly SetOverLibraryBase A = new(); } + + // A base class that declares a member the generators introduce into a set (#205): each failed the aspect + // (LAMA0500, LAMA0502, LAMA0512) naming nothing the user wrote, and an unsealed ToString() was replaced + // without a word. A sealed ToString() is the seam, as C# keeps it in every derived class. + public abstract class SealedToStringSetBase { public sealed override string ToString() => "***"; } + public sealed partial class SetOverSealedToStringBase : SealedToStringSetBase, IClosedSet { public static readonly SetOverSealedToStringBase A = new(); } + public abstract class OpenToStringSetBase { public override string ToString() => "***"; } + public sealed partial class SetOverOpenToStringBase : OpenToStringSetBase, IClosedSet { public static readonly SetOverOpenToStringBase A = new(); } + public abstract class ParsingSetBase { public static TSelf Parse(string name) => throw new System.NotSupportedException(); } + public sealed partial class SetOverParsingBase : ParsingSetBase, IClosedSet { public static readonly SetOverParsingBase A = new(); } + public abstract class IgnoreCaseParsingSetBase { public static TSelf Parse(string name, bool ignoreCase) => throw new System.NotSupportedException(); } + public sealed partial class SetOverIgnoreCaseParsingBase : IgnoreCaseParsingSetBase, IClosedSet { public static readonly SetOverIgnoreCaseParsingBase A = new(); } + public abstract class TryParsingSetBase { public static bool TryParse(string? name, bool ignoreCase, out TSelf member) => throw new System.NotSupportedException(); } + public sealed partial class SetOverTryParsingBase : TryParsingSetBase, IClosedSet { public static readonly SetOverTryParsingBase A = new(); } + public sealed partial class SetWithTryParseByRef : IClosedSet { public static readonly SetWithTryParseByRef A = new(); public static bool TryParse(string? name, ref SetWithTryParseByRef member) => throw new System.NotSupportedException(); } + public abstract class TryParsingByRefSetBase { public static bool TryParse(string? name, bool ignoreCase, ref TSelf member) => throw new System.NotSupportedException(); } + public sealed partial class SetOverTryParsingByRefBase : TryParsingByRefSetBase, IClosedSet { public static readonly SetOverTryParsingByRefBase A = new(); } + public abstract class MatchingSetBase { public TResult Match(System.Func only) => only(); } + public sealed partial class SetOverMatchingBase : MatchingSetBase, IClosedSet { public static readonly SetOverMatchingBase A = new(); } + public abstract class ParsableSetBase : System.IParsable where TSelf : ParsableSetBase + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) { result = null!; return false; } + } + public sealed partial class SetOverParsableBase : ParsableSetBase, IClosedSet { public static readonly SetOverParsableBase A = new(); } + public abstract class PrivateFieldsSetBase { private string _name = ""; private object? _all; public override int GetHashCode() => _name.Length + (_all is null ? 0 : 1); } + public sealed partial class SetOverPrivateFieldsBase : PrivateFieldsSetBase, IClosedSet { public static readonly SetOverPrivateFieldsBase A = new(); } + public abstract class QualifiedSetBase { public string QualifiedName => "***"; } + public abstract class AllListingSetBase { public static int All => 0; } + public abstract class ProtectedFieldSetBase { protected string _name = ""; } + public sealed partial class SetOverProtectedFieldBase : ProtectedFieldSetBase, IClosedSet { public static readonly SetOverProtectedFieldBase A = new(); } + public sealed partial class SetOverAllListingBase : AllListingSetBase, IClosedSet { public static readonly SetOverAllListingBase A = new(); } + public sealed partial class SetOverQualifiedBase : QualifiedSetBase, IClosedSet { public static readonly SetOverQualifiedBase A = new(); } + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public abstract class AttributedSetBase { } + public sealed partial class SetOverAttributedBase : AttributedSetBase, IClosedSet { public static readonly SetOverAttributedBase A = new(); } + public sealed partial class SetOverLibrarySealedToString : Library.LibrarySealedToStringSetBase, IClosedSet { public static readonly SetOverLibrarySealedToString A = new(); } + public sealed partial class SetOverLibraryOpenToString : Library.LibraryOpenToStringSetBase, IClosedSet { public static readonly SetOverLibraryOpenToString A = new(); } + public sealed partial class SetOverLibraryParsingBase : Library.LibraryParsingSetBase, IClosedSet { public static readonly SetOverLibraryParsingBase A = new(); } + public sealed partial class SetOverLibraryParsableBase : Library.LibraryParsableSetBase, IClosedSet { public static readonly SetOverLibraryParsableBase A = new(); } + + public interface ISetParsesItself : System.IParsable where TSelf : ISetParsesItself; + + public sealed partial class SetWithParsableThroughInterface : IClosedSet, ISetParsesItself { public static readonly SetWithParsableThroughInterface A = new(); } + [System.ComponentModel.TypeConverter(typeof(System.ComponentModel.StringConverter))] public sealed partial class SetWithTypeConverterAttribute : IClosedSet { public static readonly SetWithTypeConverterAttribute A = new(); } @@ -1459,6 +2152,80 @@ public static class SetNames private readonly string _setsDirectory = Path.Combine(RepositoryRoot(), "artifacts", $"consumer-{Guid.NewGuid():N}"); + private readonly string _libraryDirectory = Path.Combine(RepositoryRoot(), "artifacts", $"library-{Guid.NewGuid():N}"); + + /// + /// A library the generators do not run in, which references the base package alone, as a consumer's + /// shared project may. A base type there that carries a marker or IClosedSet<TSelf> is no + /// value object or set of its own, where in a project with the generators it would be refused + /// (CMTK1022, CMTK1014), and Metalama lists no private member of its types (#191, #195). + /// + private const string Library = + """ + using CodoMetis.TypeKit; + using CodoMetis.TypeKit.ClosedSets; + using CodoMetis.TypeKit.ValueObjects; + + namespace Library; + + public enum LibraryFault { Refused } + + // Generic code calling T.Create reached this, which refused what the value object's own accepted. + public abstract record StrictCreateBase : IValidatedValue where TSelf : StrictCreateBase, IValidatedValue + { + static Result IValidatedValue.Create(string value) => Result.Error(LibraryFault.Refused); + } + + public abstract record PublicCreateBase : IValidatedValue where TSelf : PublicCreateBase, IValidatedValue + { + public static Result Create(string value) => Result.Error(LibraryFault.Refused); + } + + // A value-object contract declared through an interface of another project (#192). + public interface ILibraryEntityIdentifier : IValueObject where TSelf : ILibraryEntityIdentifier + { + System.Guid IValueObject.Value => System.Guid.Empty; + + static bool IValueObject.operator ==(TSelf left, TSelf right) => false; + + static bool IValueObject.operator !=(TSelf left, TSelf right) => true; + } + + // A public static implementation of a static abstract member reads as an explicit one from here. + public abstract record PublicMinMaxBase : System.Numerics.IMinMaxValue where TSelf : PublicMinMaxBase, System.Numerics.IMinMaxValue + { + public static TSelf MinValue => throw new System.NotSupportedException(); + + public static TSelf MaxValue => throw new System.NotSupportedException(); + } + + // Base classes of a closed set that declare a member the generators introduce into it (#205). + public abstract class LibrarySealedToStringSetBase { public sealed override string ToString() => "***"; } + + public abstract class LibraryOpenToStringSetBase { public override string ToString() => "***"; } + + public abstract class LibraryParsingSetBase { public static TSelf Parse(string name) => throw new System.NotSupportedException(); } + + public abstract class LibraryParsableSetBase : System.IParsable where TSelf : LibraryParsableSetBase + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) { result = null!; return false; } + } + + // The set's All listed none, its Name through the interface was the base's, and EF Core stored that. + public abstract class ExplicitSetBase : IClosedSet where TSelf : ExplicitSetBase + { + string IClosedSet.Name => "***"; + + string IClosedSet.QualifiedName => "***"; + + static System.Collections.Generic.IReadOnlyList IClosedSet.All => []; + + static bool IClosedSet.TryParse(string? name, out TSelf member) => throw new System.NotSupportedException(); + } + """; + /// A second file, so a declaration can be split across files. private const string OtherFile = """ @@ -1485,8 +2252,9 @@ public readonly partial record struct SplitAcrossFiles public async ValueTask InitializeAsync() { - var declarations = Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations, ["OtherFile.cs"] = OtherFile }); - var sets = Build(_setsDirectory, new Dictionary { ["RefusedSets.cs"] = RefusedSets }); + var library = await BuildLibrary(_libraryDirectory, Library + BaseMemberAccessibility.LibrarySource); + var declarations = Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations, ["OtherFile.cs"] = OtherFile, ["BaseMemberAccessibility.cs"] = BaseMemberAccessibility.ValueObjectSource() }, library); + var sets = Build(_setsDirectory, new Dictionary { ["RefusedSets.cs"] = RefusedSets, ["BaseMemberAccessibility.cs"] = BaseMemberAccessibility.SetSource() }, library); var (declarationOutput, declarationErrors) = await declarations; var (setOutput, setErrors) = await sets; @@ -1498,7 +2266,7 @@ public async ValueTask InitializeAsync() public ValueTask DisposeAsync() { - foreach (var directory in new[] { _directory, _setsDirectory }) + foreach (var directory in new[] { _directory, _setsDirectory, _libraryDirectory }) if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); return ValueTask.CompletedTask; @@ -1509,8 +2277,9 @@ public ValueTask DisposeAsync() /// , 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. + /// , a project built, is referenced too. /// - internal static async Task<(string Output, IReadOnlyList Errors)> Build(string directory, IReadOnlyDictionary sources) + internal static async Task<(string Output, IReadOnlyList Errors)> Build(string directory, IReadOnlyDictionary sources, string? library = null) { Directory.CreateDirectory(directory); @@ -1528,6 +2297,7 @@ await File.WriteAllTextAsync(Path.Combine(directory, "Consumer.csproj"), + {(library is null ? "" : $"")} """); @@ -1549,18 +2319,66 @@ await File.WriteAllTextAsync(Path.Combine(directory, ".editorconfig"), dotnet_diagnostic.CMTK1023.severity = error """); - // 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 WriteBuildStoppers(directory); + + var (_, text) = await RunBuild(directory); + IReadOnlyList found = [.. DiagnosticLine().Matches(text).Select(match => new Diagnostic(match.Groups["id"].Value, match.Groups["message"].Value)).Distinct()]; + + found.ShouldNotBeEmpty($"The consumer build reported no errors at all, so it did not run as intended:{Environment.NewLine}{text}"); + return (text, found); + } + + /// + /// Builds a library of in that references the base + /// package alone, so the generators do not run in it, and returns its project file. It must build. + /// + internal static async Task BuildLibrary(string directory, string source) + { + Directory.CreateDirectory(directory); + + var project = Path.Combine(directory, "Library.csproj"); + await File.WriteAllTextAsync(project, + $""" + + + net10.0 + enable + enable + + + + + + """); + await File.WriteAllTextAsync(Path.Combine(directory, "Library.cs"), source); + await WriteBuildStoppers(directory); + + var (exitCode, text) = await RunBuild(directory); + exitCode.ShouldBe(0, $"The library the consumers reference did not build:{Environment.NewLine}{text}"); + return project; + } + + /// + /// Stops MSBuild's upward search in , so the repository's own build settings + /// (warnings as errors, documentation, central package management) do not apply there. + /// + private static async Task WriteBuildStoppers(string directory) + { 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 - // by this test run, and restoring them again could rewrite restore output that other - // tests read concurrently. Already built in this run's configuration, which is therefore - // passed on: without it the consumer builds in Debug and, in a Release run such as CI's, - // finds no referenced assembly at all. + /// + /// Builds the project in . --no-dependencies and RestoreRecursive=false: the + /// referenced projects are already built by this test run (the library before its consumers), and + /// restoring them again could rewrite restore output that other tests read concurrently. Already built in + /// this run's configuration, which is therefore passed on: without it the consumer builds in Debug and, in + /// a Release run such as CI's, finds no referenced assembly at all. + /// + private static async Task<(int ExitCode, string Output)> RunBuild(string directory) + { using var process = Process.Start(new ProcessStartInfo( "dotnet", ["build", directory, "-c", Configuration, "--no-dependencies", "-p:RestoreRecursive=false", "-nodeReuse:false", "-clp:NoSummary"]) { @@ -1573,11 +2391,7 @@ await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Packages.props") var errors = process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); - var text = await output + await errors; - IReadOnlyList found = [.. DiagnosticLine().Matches(text).Select(match => new Diagnostic(match.Groups["id"].Value, match.Groups["message"].Value)).Distinct()]; - - found.ShouldNotBeEmpty($"The consumer build reported no errors at all, so it did not run as intended:{Environment.NewLine}{text}"); - return (text, found); + return (process.ExitCode, await output + await errors); } /// The configuration this test assembly, and so the projects it references, was built in. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/FabricTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/FabricTests.cs index a2881620..b3c1664d 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/FabricTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/FabricTests.cs @@ -44,7 +44,8 @@ public void Every_declared_value_object_is_generated(Type type) type.GetInterfaces().ShouldContain(typeof(IValueObject<,>).MakeGenericType(type, underlying)); type.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance).ShouldNotBeNull().PropertyType.ShouldBe(underlying); - type.GetMethod(plain ? "From" : "TryFrom", BindingFlags.Public | BindingFlags.Static).ShouldNotBeNull(); + // By its signature: a value object may declare an overload of it, generic or by reference (#226). + type.GetMethod(plain ? "From" : "TryFrom", 0, BindingFlags.Public | BindingFlags.Static, [underlying]).ShouldNotBeNull(); } /// diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 14f02dab..23b0b8c3 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -1,4 +1,6 @@ # CodoMetis.TypeKit.Generators.Probes +IProbeSetNames + implements IClosedSet ProbeAmount CompareTo(Object) : Int32 CompareTo(ProbeAmount) : Int32 @@ -98,7 +100,7 @@ ProbeArticle ProbeArticleFault Description : String Kind : FaultKind - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -164,7 +166,7 @@ ProbeCode ProbeConcurrencyFault Description : String Kind : FaultKind - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -698,7 +700,7 @@ ProbeIsbn extension static ProbeIsbnExtensions.GetValue(ProbeIsbn) : ProbeIsbnText extension static ProbeIsbnExtensions.ValueOrNull(ProbeIsbn?) : ProbeIsbnText ProbeKeyword - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -717,7 +719,7 @@ ProbeKeyword static TryParse(String, out ProbeKeyword) : Boolean ProbeKindlessFault Kind : FaultKind - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -779,7 +781,7 @@ ProbeLabel extension static ProbeLabelExtensions.GetValue(ProbeLabel) : String extension static ProbeLabelExtensions.ValueOrNull(ProbeLabel) : String ProbeLabelled - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -797,7 +799,7 @@ ProbeLabelled static TryParse(String, Boolean, out ProbeLabelled) : Boolean static TryParse(String, out ProbeLabelled) : Boolean ProbeLevel - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -944,7 +946,7 @@ ProbeName extension static ProbeNameExtensions.GetValue(ProbeName) : String extension static ProbeNameExtensions.ValueOrNull(ProbeName?) : String ProbeOrder.Status - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -964,7 +966,7 @@ ProbeOrder.Status ProbeOrderFault Description : String Kind : FaultKind - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1127,7 +1129,7 @@ ProbePin ProbePriority Label : String Match(Action, Action, Action) : Void - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String Rank : Int32 @@ -1282,7 +1284,7 @@ ProbeSecret extension static ProbeSecretExtensions.GetValue(ProbeSecret) : String extension static ProbeSecretExtensions.ValueOrNull(ProbeSecret) : String ProbeShipment.Status - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1358,7 +1360,7 @@ ProbeSpan extension static ProbeSpanExtensions.GetValue(ProbeSpan) : ProbeSpanText extension static ProbeSpanExtensions.ValueOrNull(ProbeSpan?) : ProbeSpanText ProbeSplit - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1376,7 +1378,7 @@ ProbeSplit static TryParse(String, Boolean, out ProbeSplit) : Boolean static TryParse(String, out ProbeSplit) : Boolean ProbeStatus - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1394,7 +1396,7 @@ ProbeStatus static TryParse(String, Boolean, out ProbeStatus) : Boolean static TryParse(String, out ProbeStatus) : Boolean ProbeTier - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String Perk : String QualifiedName : String @@ -1583,6 +1585,112 @@ ProbeWeekday extension static ProbeWeekdayExtensions.GetValue(ProbeWeekday) : DayOfWeek extension static ProbeWeekdayExtensions.ValueOrNull(ProbeWeekday?) : DayOfWeek? # CodoMetis.TypeKit.Generators.Tests +CompareToOverloadedByArity + CompareTo(CompareToOverloadedByArity) : Int32 + CompareTo(Object) : Int32 + CompareTo(CompareToOverloadedByArity) : Int32 + Equals(CompareToOverloadedByArity) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested CompareToOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested CompareToOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : CompareToOverloadedByArity + static MaxValue : CompareToOverloadedByArity + static MinValue : CompareToOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : CompareToOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : CompareToOverloadedByArity + static Parse(String, IFormatProvider) : CompareToOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out CompareToOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out CompareToOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out CompareToOverloadedByArity) : Boolean + static op_Equality(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_GreaterThan(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_Inequality(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_LessThan(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_LessThanOrEqual(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + extension static CompareToOverloadedByArityExtensions.GetValue(CompareToOverloadedByArity) : Int32 + extension static CompareToOverloadedByArityExtensions.ValueOrNull(CompareToOverloadedByArity?) : Int32? +CompareToOverloadedByIn + CompareTo(CompareToOverloadedByIn) : Int32 + CompareTo(Object) : Int32 + CompareTo(in CompareToOverloadedByIn) : Int32 + Equals(CompareToOverloadedByIn) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested CompareToOverloadedByInJsonConverter : JsonConverter, IStoredJsonConverterSource + nested CompareToOverloadedByInTypeConverter : TypeConverter + new() + static From(Int32) : CompareToOverloadedByIn + static MaxValue : CompareToOverloadedByIn + static MinValue : CompareToOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : CompareToOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : CompareToOverloadedByIn + static Parse(String, IFormatProvider) : CompareToOverloadedByIn + static TryParse(ReadOnlySpan, IFormatProvider, out CompareToOverloadedByIn) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out CompareToOverloadedByIn) : Boolean + static TryParse(String, IFormatProvider, out CompareToOverloadedByIn) : Boolean + static op_Equality(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_GreaterThan(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_GreaterThanOrEqual(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_Inequality(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_LessThan(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_LessThanOrEqual(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + extension static CompareToOverloadedByInExtensions.GetValue(CompareToOverloadedByIn) : Int32 + extension static CompareToOverloadedByInExtensions.ValueOrNull(CompareToOverloadedByIn?) : Int32? DefaultedAddress Equals(DefaultedAddress) : Boolean Equals(Object) : Boolean @@ -1668,7 +1776,7 @@ DefaultedVersion extension static DefaultedVersionExtensions.GetValue(DefaultedVersion) : Version extension static DefaultedVersionExtensions.ValueOrNull(DefaultedVersion?) : Version DocumentKind - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1686,7 +1794,7 @@ DocumentKind static TryParse(String, Boolean, out DocumentKind) : Boolean static TryParse(String, out DocumentKind) : Boolean EarlyAllStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1704,7 +1812,7 @@ EarlyAllStatus static TryParse(String, Boolean, out EarlyAllStatus) : Boolean static TryParse(String, out EarlyAllStatus) : Boolean EarlyIgnoreCaseParseStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1722,7 +1830,7 @@ EarlyIgnoreCaseParseStatus static TryParse(String, Boolean, out EarlyIgnoreCaseParseStatus) : Boolean static TryParse(String, out EarlyIgnoreCaseParseStatus) : Boolean EarlyIgnoreCaseTryParseStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1740,7 +1848,7 @@ EarlyIgnoreCaseTryParseStatus static TryParse(String, Boolean, out EarlyIgnoreCaseTryParseStatus) : Boolean static TryParse(String, out EarlyIgnoreCaseTryParseStatus) : Boolean EarlyJsonKeyStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1758,7 +1866,7 @@ EarlyJsonKeyStatus static TryParse(String, Boolean, out EarlyJsonKeyStatus) : Boolean static TryParse(String, out EarlyJsonKeyStatus) : Boolean EarlyJsonStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1776,7 +1884,7 @@ EarlyJsonStatus static TryParse(String, Boolean, out EarlyJsonStatus) : Boolean static TryParse(String, out EarlyJsonStatus) : Boolean EarlyParsableStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1794,7 +1902,7 @@ EarlyParsableStatus static TryParse(String, Boolean, out EarlyParsableStatus) : Boolean static TryParse(String, out EarlyParsableStatus) : Boolean EarlyParseStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1812,7 +1920,7 @@ EarlyParseStatus static TryParse(String, Boolean, out EarlyParseStatus) : Boolean static TryParse(String, out EarlyParseStatus) : Boolean EarlyTryParseStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1829,8 +1937,386 @@ EarlyTryParseStatus static Parse(String, Boolean) : EarlyTryParseStatus static TryParse(String, Boolean, out EarlyTryParseStatus) : Boolean static TryParse(String, out EarlyTryParseStatus) : Boolean +EqualityOverloadedByArity + CompareTo(EqualityOverloadedByArity) : Int32 + CompareTo(Object) : Int32 + Equals(EqualityOverloadedByArity) : Boolean + Equals(Object) : Boolean + Equals(EqualityOverloadedByArity) : Boolean + GetHashCode() : Int32 + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested EqualityOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested EqualityOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : EqualityOverloadedByArity + static MaxValue : EqualityOverloadedByArity + static MinValue : EqualityOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : EqualityOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : EqualityOverloadedByArity + static Parse(String, IFormatProvider) : EqualityOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out EqualityOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out EqualityOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out EqualityOverloadedByArity) : Boolean + static op_Equality(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_GreaterThan(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_Inequality(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_LessThan(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_LessThanOrEqual(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + extension static EqualityOverloadedByArityExtensions.GetValue(EqualityOverloadedByArity) : Int32 + extension static EqualityOverloadedByArityExtensions.ValueOrNull(EqualityOverloadedByArity?) : Int32? +EqualsOverloadOverADeeperExplicitEquality + $() : EqualsOverloadOverADeeperExplicitEquality + CompareTo(EqualsOverloadOverADeeperExplicitEquality) : Int32 + CompareTo(Object) : Int32 + Equals(EqualsOverloadOverADeeperExplicitEquality) : Boolean + Equals(LocalBetweenRecord) : Boolean + Equals(LocalExplicitEqualityRecord) : Boolean + Equals(Object) : Boolean + Equals(LocalExplicitEqualityRecord) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable + implements IReimplementedEquatable + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested EqualsOverloadOverADeeperExplicitEqualityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested EqualsOverloadOverADeeperExplicitEqualityTypeConverter : TypeConverter + static From(Int32) : EqualsOverloadOverADeeperExplicitEquality + static MaxValue : EqualsOverloadOverADeeperExplicitEquality + static MinValue : EqualsOverloadOverADeeperExplicitEquality + static Parse(ReadOnlySpan, IFormatProvider) : EqualsOverloadOverADeeperExplicitEquality + static Parse(ReadOnlySpan, IFormatProvider) : EqualsOverloadOverADeeperExplicitEquality + static Parse(String, IFormatProvider) : EqualsOverloadOverADeeperExplicitEquality + static TryParse(ReadOnlySpan, IFormatProvider, out EqualsOverloadOverADeeperExplicitEquality) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out EqualsOverloadOverADeeperExplicitEquality) : Boolean + static TryParse(String, IFormatProvider, out EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_Equality(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_GreaterThan(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_GreaterThanOrEqual(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_Inequality(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_LessThan(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_LessThanOrEqual(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + extension static EqualsOverloadOverADeeperExplicitEqualityExtensions.GetValue(EqualsOverloadOverADeeperExplicitEquality) : Int32 + extension static EqualsOverloadOverADeeperExplicitEqualityExtensions.ValueOrNull(EqualsOverloadOverADeeperExplicitEquality) : Int32? +FormatOverloadedByArity + CompareTo(FormatOverloadedByArity) : Int32 + CompareTo(Object) : Int32 + Equals(FormatOverloadedByArity) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FormatOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FormatOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : FormatOverloadedByArity + static MaxValue : FormatOverloadedByArity + static MinValue : FormatOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : FormatOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : FormatOverloadedByArity + static Parse(String, IFormatProvider) : FormatOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out FormatOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FormatOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out FormatOverloadedByArity) : Boolean + static op_Equality(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_GreaterThan(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_Inequality(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_LessThan(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_LessThanOrEqual(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + extension static FormatOverloadedByArityExtensions.GetValue(FormatOverloadedByArity) : Int32 + extension static FormatOverloadedByArityExtensions.ValueOrNull(FormatOverloadedByArity?) : Int32? +FormatOverloadedByIn + CompareTo(FormatOverloadedByIn) : Int32 + CompareTo(Object) : Int32 + Equals(FormatOverloadedByIn) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + ToString(in String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FormatOverloadedByInJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FormatOverloadedByInTypeConverter : TypeConverter + new() + static From(Int32) : FormatOverloadedByIn + static MaxValue : FormatOverloadedByIn + static MinValue : FormatOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : FormatOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : FormatOverloadedByIn + static Parse(String, IFormatProvider) : FormatOverloadedByIn + static TryParse(ReadOnlySpan, IFormatProvider, out FormatOverloadedByIn) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FormatOverloadedByIn) : Boolean + static TryParse(String, IFormatProvider, out FormatOverloadedByIn) : Boolean + static op_Equality(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_GreaterThan(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_GreaterThanOrEqual(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_Inequality(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_LessThan(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_LessThanOrEqual(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + extension static FormatOverloadedByInExtensions.GetValue(FormatOverloadedByIn) : Int32 + extension static FormatOverloadedByInExtensions.ValueOrNull(FormatOverloadedByIn?) : Int32? +FromOverloadedByArity + CompareTo(FromOverloadedByArity) : Int32 + CompareTo(Object) : Int32 + Equals(FromOverloadedByArity) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FromOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FromOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : FromOverloadedByArity + static From(Int32) : FromOverloadedByArity + static MaxValue : FromOverloadedByArity + static MinValue : FromOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByArity + static Parse(String, IFormatProvider) : FromOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out FromOverloadedByArity) : Boolean + static op_Equality(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_GreaterThan(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_Inequality(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_LessThan(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_LessThanOrEqual(FromOverloadedByArity, FromOverloadedByArity) : Boolean + extension static FromOverloadedByArityExtensions.GetValue(FromOverloadedByArity) : Int32 + extension static FromOverloadedByArityExtensions.ValueOrNull(FromOverloadedByArity?) : Int32? +FromOverloadedByIn + CompareTo(FromOverloadedByIn) : Int32 + CompareTo(Object) : Int32 + Equals(FromOverloadedByIn) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FromOverloadedByInJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FromOverloadedByInTypeConverter : TypeConverter + new() + static From(Int32) : FromOverloadedByIn + static From(in Int32) : FromOverloadedByIn + static MaxValue : FromOverloadedByIn + static MinValue : FromOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByIn + static Parse(String, IFormatProvider) : FromOverloadedByIn + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByIn) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByIn) : Boolean + static TryParse(String, IFormatProvider, out FromOverloadedByIn) : Boolean + static op_Equality(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_GreaterThan(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_GreaterThanOrEqual(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_Inequality(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_LessThan(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_LessThanOrEqual(FromOverloadedByIn, FromOverloadedByIn) : Boolean + extension static FromOverloadedByInExtensions.GetValue(FromOverloadedByIn) : Int32 + extension static FromOverloadedByInExtensions.ValueOrNull(FromOverloadedByIn?) : Int32? +FromOverloadedByRef + CompareTo(FromOverloadedByRef) : Int32 + CompareTo(Object) : Int32 + Equals(FromOverloadedByRef) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FromOverloadedByRefJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FromOverloadedByRefTypeConverter : TypeConverter + new() + static From(Int32) : FromOverloadedByRef + static From(ref Int32) : FromOverloadedByRef + static MaxValue : FromOverloadedByRef + static MinValue : FromOverloadedByRef + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByRef + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByRef + static Parse(String, IFormatProvider) : FromOverloadedByRef + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByRef) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByRef) : Boolean + static TryParse(String, IFormatProvider, out FromOverloadedByRef) : Boolean + static op_Equality(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_GreaterThan(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_GreaterThanOrEqual(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_Inequality(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_LessThan(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_LessThanOrEqual(FromOverloadedByRef, FromOverloadedByRef) : Boolean + extension static FromOverloadedByRefExtensions.GetValue(FromOverloadedByRef) : Int32 + extension static FromOverloadedByRefExtensions.ValueOrNull(FromOverloadedByRef?) : Int32? LocalOrder.Stage - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1848,7 +2334,7 @@ LocalOrder.Stage static TryParse(String, Boolean, out LocalOrder.Stage) : Boolean static TryParse(String, out LocalOrder.Stage) : Boolean LocalStatus - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1949,6 +2435,77 @@ MaskedOverALocalRecord static op_LessThanOrEqual(MaskedOverALocalRecord, MaskedOverALocalRecord) : Boolean extension static MaskedOverALocalRecordExtensions.GetValue(MaskedOverALocalRecord) : String extension static MaskedOverALocalRecordExtensions.ValueOrNull(MaskedOverALocalRecord) : String +MaskedSet + Match(Func, Func) : TResult + Name : String + QualifiedName : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested MaskedSetJsonConverter : JsonConverter + nested MaskedSetTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : MaskedSet + static Parse(String, Boolean) : MaskedSet + static TryParse(String, Boolean, out MaskedSet) : Boolean + static TryParse(String, out MaskedSet) : Boolean +ObjectCompareToOverloaded + CompareTo(Object) : Int32 + CompareTo(ObjectCompareToOverloaded) : Int32 + CompareTo(in Object) : Int32 + CompareTo(Object) : Int32 + Equals(Object) : Boolean + Equals(ObjectCompareToOverloaded) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ObjectCompareToOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ObjectCompareToOverloadedTypeConverter : TypeConverter + new() + static From(Int32) : ObjectCompareToOverloaded + static MaxValue : ObjectCompareToOverloaded + static MinValue : ObjectCompareToOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : ObjectCompareToOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : ObjectCompareToOverloaded + static Parse(String, IFormatProvider) : ObjectCompareToOverloaded + static TryParse(ReadOnlySpan, IFormatProvider, out ObjectCompareToOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ObjectCompareToOverloaded) : Boolean + static TryParse(String, IFormatProvider, out ObjectCompareToOverloaded) : Boolean + static op_Equality(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_GreaterThan(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_GreaterThanOrEqual(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_Inequality(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_LessThan(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_LessThanOrEqual(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + extension static ObjectCompareToOverloadedExtensions.GetValue(ObjectCompareToOverloaded) : Int32 + extension static ObjectCompareToOverloadedExtensions.ValueOrNull(ObjectCompareToOverloaded?) : Int32? OverABetweenExplicitEqualityRecord $() : OverABetweenExplicitEqualityRecord CompareTo(Object) : Int32 @@ -2065,6 +2622,61 @@ OverADeeperExplicitEqualityRecord static op_LessThanOrEqual(OverADeeperExplicitEqualityRecord, OverADeeperExplicitEqualityRecord) : Boolean extension static OverADeeperExplicitEqualityRecordExtensions.GetValue(OverADeeperExplicitEqualityRecord) : Int32 extension static OverADeeperExplicitEqualityRecordExtensions.ValueOrNull(OverADeeperExplicitEqualityRecord) : Int32? +OverALibraryOverloadingBase + $() : OverALibraryOverloadingBase + CompareTo(Object) : Int32 + CompareTo(OverALibraryOverloadingBase) : Int32 + Equals(Object) : Boolean + Equals(OverALibraryOverloadingBase) : Boolean + Equals(ProbeOverloadingRecord) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable> + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested OverALibraryOverloadingBaseJsonConverter : JsonConverter, IStoredJsonConverterSource + nested OverALibraryOverloadingBaseTypeConverter : TypeConverter + static From(Int32) : OverALibraryOverloadingBase + static MaxValue : OverALibraryOverloadingBase + static MinValue : OverALibraryOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : OverALibraryOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : OverALibraryOverloadingBase + static Parse(String, IFormatProvider) : OverALibraryOverloadingBase + static TryParse(ReadOnlySpan, IFormatProvider, out OverALibraryOverloadingBase) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out OverALibraryOverloadingBase) : Boolean + static TryParse(String, IFormatProvider, out OverALibraryOverloadingBase) : Boolean + static op_Equality(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_GreaterThan(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_GreaterThanOrEqual(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_Inequality(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_LessThan(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_LessThanOrEqual(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + extension static OverALibraryOverloadingBaseExtensions.GetValue(OverALibraryOverloadingBase) : Int32 + extension static OverALibraryOverloadingBaseExtensions.ValueOrNull(OverALibraryOverloadingBase) : Int32? OverALibraryRecord $() : OverALibraryRecord CompareTo(Object) : Int32 @@ -2326,6 +2938,61 @@ OverAnIdentityRecord static op_LessThanOrEqual(OverAnIdentityRecord, OverAnIdentityRecord) : Boolean extension static OverAnIdentityRecordExtensions.GetValue(OverAnIdentityRecord) : Int32 extension static OverAnIdentityRecordExtensions.ValueOrNull(OverAnIdentityRecord) : Int32? +OverAnOverloadingBase + $() : OverAnOverloadingBase + CompareTo(Object) : Int32 + CompareTo(OverAnOverloadingBase) : Int32 + Equals(Object) : Boolean + Equals(OverAnOverloadingBase) : Boolean + Equals(OverloadingBase) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable> + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested OverAnOverloadingBaseJsonConverter : JsonConverter, IStoredJsonConverterSource + nested OverAnOverloadingBaseTypeConverter : TypeConverter + static From(Int32) : OverAnOverloadingBase + static MaxValue : OverAnOverloadingBase + static MinValue : OverAnOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : OverAnOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : OverAnOverloadingBase + static Parse(String, IFormatProvider) : OverAnOverloadingBase + static TryParse(ReadOnlySpan, IFormatProvider, out OverAnOverloadingBase) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out OverAnOverloadingBase) : Boolean + static TryParse(String, IFormatProvider, out OverAnOverloadingBase) : Boolean + static op_Equality(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_GreaterThan(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_GreaterThanOrEqual(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_Inequality(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_LessThan(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_LessThanOrEqual(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + extension static OverAnOverloadingBaseExtensions.GetValue(OverAnOverloadingBase) : Int32 + extension static OverAnOverloadingBaseExtensions.ValueOrNull(OverAnOverloadingBase) : Int32? OverByte CompareTo(Object) : Int32 CompareTo(OverByte) : Int32 @@ -3009,6 +3676,61 @@ OverParsesVirtually static op_Inequality(OverParsesVirtually, OverParsesVirtually) : Boolean extension static OverParsesVirtuallyExtensions.GetValue(OverParsesVirtually) : IParsesVirtually extension static OverParsesVirtuallyExtensions.ValueOrNull(OverParsesVirtually?) : IParsesVirtually +OverPrivateNames + $() : OverPrivateNames + CompareTo(Object) : Int32 + CompareTo(OverPrivateNames) : Int32 + Equals(Object) : Boolean + Equals(OverPrivateNames) : Boolean + Equals(PrivateNamesBase) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable> + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested OverPrivateNamesJsonConverter : JsonConverter, IStoredJsonConverterSource + nested OverPrivateNamesTypeConverter : TypeConverter + static From(Int32) : OverPrivateNames + static MaxValue : OverPrivateNames + static MinValue : OverPrivateNames + static Parse(ReadOnlySpan, IFormatProvider) : OverPrivateNames + static Parse(ReadOnlySpan, IFormatProvider) : OverPrivateNames + static Parse(String, IFormatProvider) : OverPrivateNames + static TryParse(ReadOnlySpan, IFormatProvider, out OverPrivateNames) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out OverPrivateNames) : Boolean + static TryParse(String, IFormatProvider, out OverPrivateNames) : Boolean + static op_Equality(OverPrivateNames, OverPrivateNames) : Boolean + static op_GreaterThan(OverPrivateNames, OverPrivateNames) : Boolean + static op_GreaterThanOrEqual(OverPrivateNames, OverPrivateNames) : Boolean + static op_Inequality(OverPrivateNames, OverPrivateNames) : Boolean + static op_LessThan(OverPrivateNames, OverPrivateNames) : Boolean + static op_LessThanOrEqual(OverPrivateNames, OverPrivateNames) : Boolean + extension static OverPrivateNamesExtensions.GetValue(OverPrivateNames) : Int32 + extension static OverPrivateNamesExtensions.ValueOrNull(OverPrivateNames) : Int32? OverProbeStatus Equals(Object) : Boolean Equals(OverProbeStatus) : Boolean @@ -3255,6 +3977,29 @@ OverVersion static op_LessThanOrEqual(OverVersion, OverVersion) : Boolean extension static OverVersionExtensions.GetValue(OverVersion) : Version extension static OverVersionExtensions.ValueOrNull(OverVersion?) : Version +OverloadingSet + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested OverloadingSetJsonConverter : JsonConverter + nested OverloadingSetTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : OverloadingSet + static Parse(String, Boolean) : OverloadingSet + static Parse(String) : OverloadingSet + static Parse(String, Boolean) : OverloadingSet + static TryParse(String, Boolean, OverloadingSet) : Boolean + static TryParse(String, Boolean, out OverloadingSet) : Boolean + static TryParse(String, OverloadingSet) : Boolean + static TryParse(String, out OverloadingSet) : Boolean + static TryParse(String, out OverloadingSet) : Boolean ParityByte CompareTo(Object) : Int32 CompareTo(ParityByte) : Int32 @@ -3824,6 +4569,275 @@ ParityUShort static op_LessThanOrEqual(ParityUShort, ParityUShort) : Boolean extension static ParityUShortExtensions.GetValue(ParityUShort) : UInt16 extension static ParityUShortExtensions.ValueOrNull(ParityUShort?) : UInt16? +ParseOverloadedByArity + CompareTo(Object) : Int32 + CompareTo(ParseOverloadedByArity) : Int32 + Equals(Object) : Boolean + Equals(ParseOverloadedByArity) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ParseOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ParseOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : ParseOverloadedByArity + static MaxValue : ParseOverloadedByArity + static MinValue : ParseOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : ParseOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : ParseOverloadedByArity + static Parse(String, IFormatProvider) : ParseOverloadedByArity + static Parse(String, IFormatProvider) : ParseOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out ParseOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ParseOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out ParseOverloadedByArity) : Boolean + static op_Equality(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_GreaterThan(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_Inequality(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_LessThan(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_LessThanOrEqual(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + extension static ParseOverloadedByArityExtensions.GetValue(ParseOverloadedByArity) : Int32 + extension static ParseOverloadedByArityExtensions.ValueOrNull(ParseOverloadedByArity?) : Int32? +ParseOverloadedInARecordClass + $() : ParseOverloadedInARecordClass + CompareTo(Object) : Int32 + CompareTo(ParseOverloadedInARecordClass) : Int32 + Equals(Object) : Boolean + Equals(ParseOverloadedInARecordClass) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ParseOverloadedInARecordClassJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ParseOverloadedInARecordClassTypeConverter : TypeConverter + static From(Int32) : ParseOverloadedInARecordClass + static MaxValue : ParseOverloadedInARecordClass + static MinValue : ParseOverloadedInARecordClass + static Parse(ReadOnlySpan, IFormatProvider) : ParseOverloadedInARecordClass + static Parse(ReadOnlySpan, IFormatProvider) : ParseOverloadedInARecordClass + static Parse(String, IFormatProvider) : ParseOverloadedInARecordClass + static Parse(String, IFormatProvider) : ParseOverloadedInARecordClass + static TryParse(ReadOnlySpan, IFormatProvider, out ParseOverloadedInARecordClass) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ParseOverloadedInARecordClass) : Boolean + static TryParse(String, IFormatProvider, out ParseOverloadedInARecordClass) : Boolean + static op_Equality(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_GreaterThan(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_GreaterThanOrEqual(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_Inequality(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_LessThan(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_LessThanOrEqual(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + extension static ParseOverloadedInARecordClassExtensions.GetValue(ParseOverloadedInARecordClass) : Int32 + extension static ParseOverloadedInARecordClassExtensions.ValueOrNull(ParseOverloadedInARecordClass) : Int32? +PlainOverASeamOverloadingBase + $() : PlainOverASeamOverloadingBase + CompareTo(Object) : Int32 + CompareTo(PlainOverASeamOverloadingBase) : Int32 + Equals(Object) : Boolean + Equals(PlainOverASeamOverloadingBase) : Boolean + Equals(SeamOverloadingBase) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable> + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested PlainOverASeamOverloadingBaseJsonConverter : JsonConverter, IStoredJsonConverterSource + nested PlainOverASeamOverloadingBaseTypeConverter : TypeConverter + static From(Int32) : PlainOverASeamOverloadingBase + static MaxValue : PlainOverASeamOverloadingBase + static MinValue : PlainOverASeamOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : PlainOverASeamOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : PlainOverASeamOverloadingBase + static Parse(String, IFormatProvider) : PlainOverASeamOverloadingBase + static TryParse(ReadOnlySpan, IFormatProvider, out PlainOverASeamOverloadingBase) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out PlainOverASeamOverloadingBase) : Boolean + static TryParse(String, IFormatProvider, out PlainOverASeamOverloadingBase) : Boolean + static op_Equality(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_GreaterThan(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_GreaterThanOrEqual(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_Inequality(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_LessThan(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_LessThanOrEqual(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + extension static PlainOverASeamOverloadingBaseExtensions.GetValue(PlainOverASeamOverloadingBase) : Int32 + extension static PlainOverASeamOverloadingBaseExtensions.ValueOrNull(PlainOverASeamOverloadingBase) : Int32? +PrintMembersOverloadedByArity + $() : PrintMembersOverloadedByArity + CompareTo(Object) : Int32 + CompareTo(PrintMembersOverloadedByArity) : Int32 + Equals(Object) : Boolean + Equals(PrintMembersOverloadedByArity) : Boolean + GetHashCode() : Int32 + PrintMembers(StringBuilder) : Boolean + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested PrintMembersOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested PrintMembersOverloadedByArityTypeConverter : TypeConverter + static From(Int32) : PrintMembersOverloadedByArity + static MaxValue : PrintMembersOverloadedByArity + static MinValue : PrintMembersOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : PrintMembersOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : PrintMembersOverloadedByArity + static Parse(String, IFormatProvider) : PrintMembersOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out PrintMembersOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out PrintMembersOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out PrintMembersOverloadedByArity) : Boolean + static op_Equality(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_GreaterThan(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_Inequality(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_LessThan(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_LessThanOrEqual(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + extension static PrintMembersOverloadedByArityExtensions.GetValue(PrintMembersOverloadedByArity) : Int32 + extension static PrintMembersOverloadedByArityExtensions.ValueOrNull(PrintMembersOverloadedByArity) : Int32? +SetOverAnOverloadingBase + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested SetOverAnOverloadingBaseJsonConverter : JsonConverter + nested SetOverAnOverloadingBaseTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : SetOverAnOverloadingBase + static Parse(String, Boolean) : SetOverAnOverloadingBase + static TryParse(String, Boolean, out SetOverAnOverloadingBase) : Boolean + static TryParse(String, out SetOverAnOverloadingBase) : Boolean +SetOverPrivateNames + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested SetOverPrivateNamesJsonConverter : JsonConverter + nested SetOverPrivateNamesTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : SetOverPrivateNames + static Parse(String, Boolean) : SetOverPrivateNames + static TryParse(String, Boolean, out SetOverPrivateNames) : Boolean + static TryParse(String, out SetOverPrivateNames) : Boolean +SetWithAGenericToString + Match(Func) : TResult + Name : String + QualifiedName : String + ToString() : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested SetWithAGenericToStringJsonConverter : JsonConverter + nested SetWithAGenericToStringTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : SetWithAGenericToString + static Parse(String, Boolean) : SetWithAGenericToString + static TryParse(String, Boolean, out SetWithAGenericToString) : Boolean + static TryParse(String, out SetWithAGenericToString) : Boolean ShippableStatus Equals(Object) : Boolean Equals(ShippableStatus) : Boolean @@ -3855,6 +4869,60 @@ ShippableStatus static op_Inequality(ShippableStatus, ShippableStatus) : Boolean extension static ShippableStatusExtensions.GetValue(ShippableStatus) : LocalStatus extension static ShippableStatusExtensions.ValueOrNull(ShippableStatus?) : LocalStatus +SpanParseOverloaded + CompareTo(Object) : Int32 + CompareTo(SpanParseOverloaded) : Int32 + Equals(Object) : Boolean + Equals(SpanParseOverloaded) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested SpanParseOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested SpanParseOverloadedTypeConverter : TypeConverter + new() + static From(Int32) : SpanParseOverloaded + static MaxValue : SpanParseOverloaded + static MinValue : SpanParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : SpanParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : SpanParseOverloaded + static Parse(String, IFormatProvider) : SpanParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : SpanParseOverloaded + static TryParse(ReadOnlySpan, IFormatProvider, out SpanParseOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, SpanParseOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out SpanParseOverloaded) : Boolean + static TryParse(String, IFormatProvider, out SpanParseOverloaded) : Boolean + static op_Equality(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_GreaterThan(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_GreaterThanOrEqual(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_Inequality(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_LessThan(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_LessThanOrEqual(SpanParseOverloaded, SpanParseOverloaded) : Boolean + extension static SpanParseOverloadedExtensions.GetValue(SpanParseOverloaded) : Int32 + extension static SpanParseOverloadedExtensions.ValueOrNull(SpanParseOverloaded?) : Int32? StageOfOrder Equals(Object) : Boolean Equals(StageOfOrder) : Boolean @@ -4196,3 +5264,422 @@ ThrowingSku static op_Inequality(ThrowingSku, ThrowingSku) : Boolean extension static ThrowingSkuExtensions.GetValue(ThrowingSku) : ProbeSkuText extension static ThrowingSkuExtensions.ValueOrNull(ThrowingSku?) : ProbeSkuText +ToStringOverloadedByArity + CompareTo(Object) : Int32 + CompareTo(ToStringOverloadedByArity) : Int32 + Equals(Object) : Boolean + Equals(ToStringOverloadedByArity) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + ToString() : String + Value : String + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IParsable + implements IPlainValueObject + implements ISpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ToStringOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ToStringOverloadedByArityTypeConverter : TypeConverter + new() + static From(String) : ToStringOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : ToStringOverloadedByArity + static Parse(String, IFormatProvider) : ToStringOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out ToStringOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out ToStringOverloadedByArity) : Boolean + static op_Equality(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_GreaterThan(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_Inequality(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_LessThan(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_LessThanOrEqual(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + extension static ToStringOverloadedByArityExtensions.GetValue(ToStringOverloadedByArity) : String + extension static ToStringOverloadedByArityExtensions.ValueOrNull(ToStringOverloadedByArity?) : String +TryFormatOverloaded + CompareTo(Object) : Int32 + CompareTo(TryFormatOverloaded) : Int32 + Equals(Object) : Boolean + Equals(TryFormatOverloaded) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested TryFormatOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested TryFormatOverloadedTypeConverter : TypeConverter + new() + static From(Int32) : TryFormatOverloaded + static MaxValue : TryFormatOverloaded + static MinValue : TryFormatOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : TryFormatOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : TryFormatOverloaded + static Parse(String, IFormatProvider) : TryFormatOverloaded + static TryParse(ReadOnlySpan, IFormatProvider, out TryFormatOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out TryFormatOverloaded) : Boolean + static TryParse(String, IFormatProvider, out TryFormatOverloaded) : Boolean + static op_Equality(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_GreaterThan(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_GreaterThanOrEqual(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_Inequality(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_LessThan(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_LessThanOrEqual(TryFormatOverloaded, TryFormatOverloaded) : Boolean + extension static TryFormatOverloadedExtensions.GetValue(TryFormatOverloaded) : Int32 + extension static TryFormatOverloadedExtensions.ValueOrNull(TryFormatOverloaded?) : Int32? +TryFromOverloaded + CompareTo(Object) : Int32 + CompareTo(TryFromOverloaded) : Int32 + Equals(Object) : Boolean + Equals(TryFromOverloaded) : Boolean + GetHashCode() : Int32 + Revalidate() : Result + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IParsable + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValidatedValue + implements IValueObject + implements IValueObjectMaterializer + nested TryFromOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested TryFromOverloadedTypeConverter : TypeConverter + new() + static Create(Int32) : Result + static FromKnownGood(Int32, String) : TryFromOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : TryFromOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : TryFromOverloaded + static Parse(String, IFormatProvider) : TryFromOverloaded + static TryFrom(Int32) : Option + static TryFrom(in Int32) : Option + static TryFrom(Int32) : Option + static TryParse(ReadOnlySpan, IFormatProvider, out TryFromOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out TryFromOverloaded) : Boolean + static TryParse(String, IFormatProvider, out TryFromOverloaded) : Boolean + static op_Equality(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_GreaterThan(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_GreaterThanOrEqual(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_Inequality(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_LessThan(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_LessThanOrEqual(TryFromOverloaded, TryFromOverloaded) : Boolean + extension static TryFromOverloadedExtensions.GetValue(TryFromOverloaded) : Int32 + extension static TryFromOverloadedExtensions.ValueOrNull(TryFromOverloaded?) : Int32? +TryParseOverloadedByArity + CompareTo(Object) : Int32 + CompareTo(TryParseOverloadedByArity) : Int32 + Equals(Object) : Boolean + Equals(TryParseOverloadedByArity) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested TryParseOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested TryParseOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : TryParseOverloadedByArity + static MaxValue : TryParseOverloadedByArity + static MinValue : TryParseOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : TryParseOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : TryParseOverloadedByArity + static Parse(String, IFormatProvider) : TryParseOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out TryParseOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out TryParseOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out TryParseOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out TryParseOverloadedByArity) : Boolean + static op_Equality(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_GreaterThan(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_Inequality(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_LessThan(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_LessThanOrEqual(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + extension static TryParseOverloadedByArityExtensions.GetValue(TryParseOverloadedByArity) : Int32 + extension static TryParseOverloadedByArityExtensions.ValueOrNull(TryParseOverloadedByArity?) : Int32? +TryParseOverloadedByValue + CompareTo(Object) : Int32 + CompareTo(TryParseOverloadedByValue) : Int32 + Equals(Object) : Boolean + Equals(TryParseOverloadedByValue) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested TryParseOverloadedByValueJsonConverter : JsonConverter, IStoredJsonConverterSource + nested TryParseOverloadedByValueTypeConverter : TypeConverter + new() + static From(Int32) : TryParseOverloadedByValue + static MaxValue : TryParseOverloadedByValue + static MinValue : TryParseOverloadedByValue + static Parse(ReadOnlySpan, IFormatProvider) : TryParseOverloadedByValue + static Parse(ReadOnlySpan, IFormatProvider) : TryParseOverloadedByValue + static Parse(String, IFormatProvider) : TryParseOverloadedByValue + static TryParse(ReadOnlySpan, IFormatProvider, out TryParseOverloadedByValue) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out TryParseOverloadedByValue) : Boolean + static TryParse(String, IFormatProvider, TryParseOverloadedByValue) : Boolean + static TryParse(String, IFormatProvider, out TryParseOverloadedByValue) : Boolean + static op_Equality(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_GreaterThan(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_GreaterThanOrEqual(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_Inequality(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_LessThan(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_LessThanOrEqual(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + extension static TryParseOverloadedByValueExtensions.GetValue(TryParseOverloadedByValue) : Int32 + extension static TryParseOverloadedByValueExtensions.ValueOrNull(TryParseOverloadedByValue?) : Int32? +Utf8ParseOverloaded + CompareTo(Object) : Int32 + CompareTo(Utf8ParseOverloaded) : Int32 + Equals(Object) : Boolean + Equals(Utf8ParseOverloaded) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested Utf8ParseOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested Utf8ParseOverloadedTypeConverter : TypeConverter + new() + static From(Int32) : Utf8ParseOverloaded + static MaxValue : Utf8ParseOverloaded + static MinValue : Utf8ParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : Utf8ParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : Utf8ParseOverloaded + static Parse(String, IFormatProvider) : Utf8ParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : Utf8ParseOverloaded + static TryParse(ReadOnlySpan, IFormatProvider, Utf8ParseOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out Utf8ParseOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out Utf8ParseOverloaded) : Boolean + static TryParse(String, IFormatProvider, out Utf8ParseOverloaded) : Boolean + static op_Equality(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_GreaterThan(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_GreaterThanOrEqual(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_Inequality(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_LessThan(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_LessThanOrEqual(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + extension static Utf8ParseOverloadedExtensions.GetValue(Utf8ParseOverloaded) : Int32 + extension static Utf8ParseOverloadedExtensions.ValueOrNull(Utf8ParseOverloaded?) : Int32? +ValidatedOverASeamOverloadingBase + $() : ValidatedOverASeamOverloadingBase + CompareTo(Object) : Int32 + CompareTo(ValidatedOverASeamOverloadingBase) : Int32 + Equals(Object) : Boolean + Equals(SeamOverloadingBase) : Boolean + Equals(ValidatedOverASeamOverloadingBase) : Boolean + GetHashCode() : Int32 + Revalidate() : Result + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable> + implements IEquatable + implements IFormattable + implements IParsable + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValidatedValue + implements IValueObject + implements IValueObjectMaterializer + nested ValidatedOverASeamOverloadingBaseJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ValidatedOverASeamOverloadingBaseTypeConverter : TypeConverter + static Create(Int32) : Result + static FromKnownGood(Int32, String) : ValidatedOverASeamOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : ValidatedOverASeamOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : ValidatedOverASeamOverloadingBase + static Parse(String, IFormatProvider) : ValidatedOverASeamOverloadingBase + static TryFrom(Int32) : Option + static TryParse(ReadOnlySpan, IFormatProvider, out ValidatedOverASeamOverloadingBase) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ValidatedOverASeamOverloadingBase) : Boolean + static TryParse(String, IFormatProvider, out ValidatedOverASeamOverloadingBase) : Boolean + static op_Equality(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_GreaterThan(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_GreaterThanOrEqual(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_Inequality(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_LessThan(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_LessThanOrEqual(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + extension static ValidatedOverASeamOverloadingBaseExtensions.GetValue(ValidatedOverASeamOverloadingBase) : Int32 + extension static ValidatedOverASeamOverloadingBaseExtensions.ValueOrNull(ValidatedOverASeamOverloadingBase) : Int32? +ValidatedOverPrivateNames + $() : ValidatedOverPrivateNames + CompareTo(Object) : Int32 + CompareTo(ValidatedOverPrivateNames) : Int32 + Equals(Object) : Boolean + Equals(PrivateNamesBase) : Boolean + Equals(ValidatedOverPrivateNames) : Boolean + GetHashCode() : Int32 + Revalidate() : Result + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable> + implements IEquatable + implements IFormattable + implements IParsable + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValidatedValue + implements IValueObject + implements IValueObjectMaterializer + nested ValidatedOverPrivateNamesJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ValidatedOverPrivateNamesTypeConverter : TypeConverter + static Create(Int32) : Result + static FromKnownGood(Int32, String) : ValidatedOverPrivateNames + static Parse(ReadOnlySpan, IFormatProvider) : ValidatedOverPrivateNames + static Parse(ReadOnlySpan, IFormatProvider) : ValidatedOverPrivateNames + static Parse(String, IFormatProvider) : ValidatedOverPrivateNames + static TryFrom(Int32) : Option + static TryParse(ReadOnlySpan, IFormatProvider, out ValidatedOverPrivateNames) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ValidatedOverPrivateNames) : Boolean + static TryParse(String, IFormatProvider, out ValidatedOverPrivateNames) : Boolean + static op_Equality(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_GreaterThan(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_GreaterThanOrEqual(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_Inequality(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_LessThan(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_LessThanOrEqual(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + extension static ValidatedOverPrivateNamesExtensions.GetValue(ValidatedOverPrivateNames) : Int32 + extension static ValidatedOverPrivateNamesExtensions.ValueOrNull(ValidatedOverPrivateNames) : Int32? diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs index 391ccc88..cac93cca 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs @@ -104,7 +104,7 @@ private static IEnumerable Members(Type type) yield return $"{Static(property.GetMethod!)}{property.Name} : {Display(property.PropertyType)}"; foreach (var method in type.GetMethods(flags).Where(method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal))) - yield return $"{Static(method)}{method.Name}({Parameters(method)}) : {Display(method.ReturnType)}"; + yield return $"{Static(method)}{method.Name}{TypeParameters(method)}({Parameters(method)}) : {Display(method.ReturnType)}"; } /// @@ -116,8 +116,18 @@ private static bool IsDeclared(CustomAttributeData attribute) => private static string Static(MethodBase method) => method.IsStatic ? "static " : ""; + /// + /// A generic method's type parameters, and each parameter's ref kind: a hand-written overload that differs from a + /// generated member only by these sits beside it (#226), and the two read as one line without them. + /// + private static string TypeParameters(MethodInfo method) => + method.IsGenericMethodDefinition ? $"<{string.Join(", ", method.GetGenericArguments().Select(Display))}>" : ""; + private static string Parameters(MethodBase method) => - string.Join(", ", method.GetParameters().Select(parameter => (parameter.IsOut ? "out " : "") + Display(parameter.ParameterType))); + string.Join(", ", method.GetParameters().Select(parameter => RefKind(parameter) + Display(parameter.ParameterType))); + + private static string RefKind(ParameterInfo parameter) => + !parameter.ParameterType.IsByRef ? "" : parameter.IsOut ? "out " : parameter.IsIn ? "in " : "ref "; private static string Display(Type type) { diff --git a/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs index 30d797e8..a1938969 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs @@ -71,6 +71,87 @@ public sealed partial record MaskedOverALocalRecord : LocalMaskingRecord, IValue /// Over a sealed ToString() in another assembly. public sealed partial record MaskedOverALibraryRecord : ProbeMaskingRecord, IValue; +/// A base class in this assembly whose ToString() is sealed. +public abstract class LocalMaskingSetBase +{ + /// What every derived class prints. + public sealed override string ToString() => "***"; +} + +/// A closed set over a sealed ToString(), which failed the aspect (LAMA0502, #205). +public sealed partial class MaskedSet : LocalMaskingSetBase, CodoMetis.TypeKit.ClosedSets.IClosedSet +{ + /// The first member. + public static readonly MaskedSet Open = new(); + + /// The second member. + public static readonly MaskedSet Closed = new(); +} + +/// +/// A base record whose private members have the generated members' names and signatures. C# hides them from a +/// derived record, so they stand in for nothing: 2.3.0 generated the value object beside them, and 2.3.1's +/// base-record checks refused it (CMTK1011, CMTK1008, #230). +/// +public abstract record PrivateNamesBase where TSelf : notnull +{ + private static int MaxValue => 100; + private static int MinValue => 0; + private int Value => -1; + private static TSelf From(int value) => throw new NotSupportedException("hidden"); + private static TSelf Parse(string text, IFormatProvider? provider) => throw new NotSupportedException("hidden"); + private static bool TryParse(string? text, IFormatProvider? provider, out TSelf result) => throw new NotSupportedException("hidden"); + private static TSelf Parse(ReadOnlySpan text, IFormatProvider? provider) => throw new NotSupportedException("hidden"); + private static bool TryParse(ReadOnlySpan text, IFormatProvider? provider, out TSelf result) => throw new NotSupportedException("hidden"); + private string ToString(string? format, IFormatProvider? provider) => "hidden"; + private bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hidden"); + private int CompareTo(object? other) => 0; + private int CompareTo(TSelf? other) => 0; + private static Option TryFrom(int value) => throw new NotSupportedException("hidden"); + private Result Revalidate() => throw new NotSupportedException("hidden"); + + /// What the base uses its private members for. + protected bool InRange(int value) => value >= MinValue && value <= MaxValue && Value < 0 && CompareTo(null) == 0 && ToString(null, null) == "hidden"; +} + +/// Why a value object over is refused. +public enum PrivateNamesFault +{ + /// Below zero. + Negative +} + +/// A plain value object over a base record's private members of generated names. +public sealed partial record OverPrivateNames : PrivateNamesBase, IValue; + +/// A validated value object over a base record's private TryFrom and Revalidate. +public sealed partial record ValidatedOverPrivateNames : PrivateNamesBase, IValidatedValue +{ + /// Zero or more. + public static Result Create(int value) => value < 0 ? Result.Error(PrivateNamesFault.Negative) : new ValidatedOverPrivateNames(value); +} + +/// A base class whose private members have a closed set's generated names and signatures (#230). +public abstract class PrivateSetNamesBase +{ + private static TSelf Parse(string name) => throw new NotSupportedException("hidden"); + private static bool TryParse(string? name, out TSelf member) => throw new NotSupportedException("hidden"); + private TResult Match(Func first, Func second) => throw new NotSupportedException("hidden"); + + /// What the base uses its private members for. + protected static bool Parses(string name) => TryParse(name, out _) || Parse(name) is not null || ((PrivateSetNamesBase)null!).Match(() => 0, () => 1) == 0; +} + +/// A closed set over a base class's private members of generated names. +public sealed partial class SetOverPrivateNames : PrivateSetNamesBase, CodoMetis.TypeKit.ClosedSets.IClosedSet +{ + /// The first member. + public static readonly SetOverPrivateNames First = new(); + + /// The second member. + public static readonly SetOverPrivateNames Second = new(); +} + /// /// What a value object inherits from a base record. Refusals are build errors and live in /// ; this is what is generated. @@ -185,4 +266,67 @@ public void A_sealed_ToString_in_a_base_record_is_the_seam(object value) JsonSerializer.Serialize(value, value.GetType()).ShouldBe("\"SECRET\""); TypeDescriptor.GetConverter(value.GetType()).ConvertToInvariantString(value).ShouldBe("SECRET"); } + + /// + /// A base record's private members stand in for nothing: every generated member is there, and is what a call + /// on the value object reaches (#230). + /// + [Fact] + public void A_base_record_s_private_members_of_generated_names_are_hidden() + { + OverPrivateNames.MaxValue.Value.ShouldBe(int.MaxValue); + OverPrivateNames.MinValue.Value.ShouldBe(int.MinValue); + OverPrivateNames.From(7).Value.ShouldBe(7); + OverPrivateNames.Parse("7", null).ShouldBe(OverPrivateNames.From(7)); + OverPrivateNames.TryParse("7", null, out var parsed).ShouldBeTrue(); + parsed.ShouldBe(OverPrivateNames.From(7)); + OverPrivateNames.Parse("7".AsSpan(), null).ShouldBe(OverPrivateNames.From(7)); + OverPrivateNames.TryParse("7"u8, null, out var utf8).ShouldBeTrue(); + utf8.ShouldBe(OverPrivateNames.From(7)); + OverPrivateNames.From(7).ToString("D3", null).ShouldBe("007"); + + Span buffer = stackalloc char[8]; + OverPrivateNames.From(7).TryFormat(buffer, out var written, "D3", null).ShouldBeTrue(); + buffer[..written].ToString().ShouldBe("007"); + + (OverPrivateNames.From(1) < OverPrivateNames.From(2)).ShouldBeTrue(); + OverPrivateNames.From(1).CompareTo((object)OverPrivateNames.From(2)).ShouldBeLessThan(0); + typeof(OverPrivateNames).IsDefined(typeof(CodoMetis.TypeKit.CompilerServices.HandWrittenOrderingAttribute), inherit: false).ShouldBeFalse(); + } + + /// A base record's private TryFrom and Revalidate are no seams: the generated ones run Create (#230). + [Fact] + public void A_base_record_s_private_TryFrom_and_Revalidate_are_hidden() + { + ValidatedOverPrivateNames.TryFrom(50).IsSome().ShouldBeTrue(); + ValidatedOverPrivateNames.TryFrom(-1).IsNone().ShouldBeTrue(); + ValidatedOverPrivateNames.FromKnownGood(50).Revalidate().TryGetValue(out var revalidated, out _).ShouldBeTrue(); + revalidated.ShouldBe(ValidatedOverPrivateNames.FromKnownGood(50)); + } + + /// A base class's private Parse, TryParse and Match are hidden from a closed set too (#230). + [Fact] + public void A_closed_set_s_base_class_s_private_members_of_generated_names_are_hidden() + { + SetOverPrivateNames.Parse("Second").ShouldBeSameAs(SetOverPrivateNames.Second); + SetOverPrivateNames.TryParse("First", out var member).ShouldBeTrue(); + member.ShouldBeSameAs(SetOverPrivateNames.First); + SetOverPrivateNames.Second.Match(first: () => 1, second: () => 2).ShouldBe(2); + } + + /// + /// A base class's sealed ToString() is a closed set's seam too (#205): it is what the set prints, while + /// its name, parsing and JSON are generated as usual. + /// + [Fact] + public void A_sealed_ToString_in_a_closed_set_s_base_class_is_the_seam() + { + MaskedSet.Closed.ToString().ShouldBe("***"); + $"{MaskedSet.Closed}".ShouldBe("***"); + + MaskedSet.Closed.Name.ShouldBe("Closed"); + MaskedSet.All.ShouldBe([MaskedSet.Open, MaskedSet.Closed]); + MaskedSet.Parse("Closed").ShouldBeSameAs(MaskedSet.Closed); + JsonSerializer.Serialize(MaskedSet.Closed).ShouldBe("\"Closed\""); + } } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs new file mode 100644 index 00000000..29b6cce2 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs @@ -0,0 +1,451 @@ +using System.Text; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; + +namespace CodoMetis.TypeKit.Generators.Tests; + +// What a value object or a closed set declares beside a generated member: a method of the same name and +// parameter types that C# still tells apart, by its type parameters or by a parameter's ref kind. Each was +// refused as the generated member written by hand (CMTK1011, #226); each is another method, and the generated +// one sits beside it. A ref kind against out (ref, in), and nullability, are no overload, and stay refused +// (BuildOutcomeTests). Every hand-written overload here throws, so a call that reached one fails. + +/// Picks a hand-written generic overload. +public sealed class OverloadFormat; + +/// A generic From. +public readonly partial record struct FromOverloadedByArity : IValue +{ + /// Hand-written. + public static FromOverloadedByArity From(int value) => throw new NotSupportedException("hand-written"); +} + +/// A From by in. +public readonly partial record struct FromOverloadedByIn : IValue +{ + /// Hand-written. + public static FromOverloadedByIn From(in int value) => throw new NotSupportedException("hand-written"); +} + +/// A From by ref. +public readonly partial record struct FromOverloadedByRef : IValue +{ + /// Hand-written. + public static FromOverloadedByRef From(ref int value) => throw new NotSupportedException("hand-written"); +} + +/// A generic Parse over text, the issue's scenario. +public readonly partial record struct ParseOverloadedByArity : IValue +{ + /// Hand-written. + public static ParseOverloadedByArity Parse(string text, IFormatProvider? provider) where TFormat : class => throw new NotSupportedException("hand-written"); +} + +/// A generic TryParse over text. +public readonly partial record struct TryParseOverloadedByArity : IValue +{ + /// Hand-written. + public static bool TryParse(string? text, IFormatProvider? provider, out TryParseOverloadedByArity result) => throw new NotSupportedException("hand-written"); +} + +/// A TryParse over text whose last parameter is by value. +public readonly partial record struct TryParseOverloadedByValue : IValue +{ + /// Hand-written. + public static bool TryParse(string? text, IFormatProvider? provider, TryParseOverloadedByValue fallback) => throw new NotSupportedException("hand-written"); +} + +/// A generic Parse and a by-value TryParse over a span. +public readonly partial record struct SpanParseOverloaded : IValue +{ + /// Hand-written. + public static SpanParseOverloaded Parse(ReadOnlySpan text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(ReadOnlySpan text, IFormatProvider? provider, SpanParseOverloaded fallback) => throw new NotSupportedException("hand-written"); +} + +/// A generic Parse and a by-value TryParse over UTF-8. +public readonly partial record struct Utf8ParseOverloaded : IValue +{ + /// Hand-written. + public static Utf8ParseOverloaded Parse(ReadOnlySpan text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(ReadOnlySpan text, IFormatProvider? provider, Utf8ParseOverloaded fallback) => throw new NotSupportedException("hand-written"); +} + +/// A generic ToString(string?, IFormatProvider?). +public readonly partial record struct FormatOverloadedByArity : IValue +{ + /// Hand-written. + public string ToString(string? format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// A ToString(string?, IFormatProvider?) whose format is by in. +public readonly partial record struct FormatOverloadedByIn : IValue +{ + /// Hand-written. + public string ToString(in string? format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// A generic TryFormat, and one whose count is by value, for both the generated TryFormats. +public readonly partial record struct TryFormatOverloaded : IValue +{ + /// Hand-written. + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public bool TryFormat(Span destination, int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public bool TryFormat(Span destination, int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// A record class with a generic Parse. +public sealed partial record ParseOverloadedInARecordClass : IValue +{ + /// Hand-written. + public static ParseOverloadedInARecordClass Parse(string text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// A base record in this assembly whose methods are overloads of the generated ones. +public abstract record OverloadingBase +{ + /// Hand-written. + public static TSelf From(int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static TSelf From(in int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static TSelf Parse(string text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? text, IFormatProvider? provider, TSelf fallback) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static TSelf Parse(ReadOnlySpan text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(ReadOnlySpan text, IFormatProvider? provider, TSelf fallback) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public string ToString(string? format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// Over a base record in this assembly with an overload of each generated member. +public sealed partial record OverAnOverloadingBase : OverloadingBase, IValue; + +/// Over a base record in another assembly with overloads of Parse, From and TryParse. +public sealed partial record OverALibraryOverloadingBase : ProbeOverloadingRecord, IValue; + +/// A closed set with a generic Parse of each form, a generic TryParse, and by-value ones. +public sealed partial class OverloadingSet : IClosedSet +{ + /// The first member. + public static readonly OverloadingSet First = new(); + + /// The second member. + public static readonly OverloadingSet Second = new(); + + /// Hand-written. + public static OverloadingSet Parse(string name) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static OverloadingSet Parse(string name, bool ignoreCase) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? name, out OverloadingSet member) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? name, OverloadingSet fallback) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? name, bool ignoreCase, OverloadingSet fallback) => throw new NotSupportedException("hand-written"); +} + +/// A base class of a closed set whose Parse and TryParse are overloads of the generated ones. +public abstract class OverloadingSetBase +{ + /// Hand-written. + public static TSelf Parse(string name) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? name, bool ignoreCase, TSelf fallback) => throw new NotSupportedException("hand-written"); +} + +/// A closed set over a base class with overloads of the generated Parse and TryParse. +public sealed partial class SetOverAnOverloadingBase : OverloadingSetBase, IClosedSet +{ + /// The first member. + public static readonly SetOverAnOverloadingBase First = new(); + + /// The second member. + public static readonly SetOverAnOverloadingBase Second = new(); +} + +// The seams and the members a record synthesizes, by the same rule: a generic or by-reference overload of +// ToString(), CompareTo, TryFrom, Revalidate, Equals, GetHashCode or PrintMembers is another method. A generic +// ToString() was taken for the seam, and the value object printed what a record prints; a generic or by-in +// CompareTo(TSelf) for the ordering seam, which marks the ordering hand-written; the rest were refused. + +/// A fault for the validated value objects below. +public enum OverloadFault +{ + /// Below zero. + Negative +} + +/// A generic ToString(), which is no seam. +public readonly partial record struct ToStringOverloadedByArity : IValue +{ + /// Hand-written. + public string ToString() => throw new NotSupportedException("hand-written"); +} + +/// A generic CompareTo(TSelf), which is no seam. +public readonly partial record struct CompareToOverloadedByArity : IValue +{ + /// Hand-written. + public int CompareTo(CompareToOverloadedByArity other) => throw new NotSupportedException("hand-written"); +} + +/// A CompareTo(TSelf) by in, which is no seam. +public readonly partial record struct CompareToOverloadedByIn : IValue +{ + /// Hand-written. + public int CompareTo(in CompareToOverloadedByIn other) => throw new NotSupportedException("hand-written"); +} + +/// A generic CompareTo(object?) and one by in. +public readonly partial record struct ObjectCompareToOverloaded : IValue +{ + /// Hand-written. + public int CompareTo(object? other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int CompareTo(in object? other) => throw new NotSupportedException("hand-written"); +} + +/// A generic Equals(TSelf) and GetHashCode(), which are not its equality. +public readonly partial record struct EqualityOverloadedByArity : IValue +{ + /// Hand-written. + public bool Equals(EqualityOverloadedByArity other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int GetHashCode() => throw new NotSupportedException("hand-written"); +} + +/// A record class with a generic PrintMembers, which its synthesized ToString() never calls. +public sealed partial record PrintMembersOverloadedByArity : IValue +{ + /// Hand-written. + public bool PrintMembers(StringBuilder builder) => throw new NotSupportedException("hand-written"); +} + +/// A generic TryFrom and one by in, beside the generated TryFrom(int). +public readonly partial record struct TryFromOverloaded : IValidatedValue +{ + /// Zero or more. + public static Result Create(int value) => value < 0 ? Result.Error(OverloadFault.Negative) : new TryFromOverloaded(value); + + /// Hand-written. + public static Option TryFrom(int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static Option TryFrom(in int value) => throw new NotSupportedException("hand-written"); +} + +/// A base record whose seams, equality and ToString() are overloads of the generated ones. +public abstract record SeamOverloadingBase where TSelf : notnull +{ + /// Hand-written. + public static Option TryFrom(int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static Option TryFrom(in int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public Result Revalidate() => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int CompareTo(object? other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int CompareTo(TSelf? other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public string ToString() => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public bool Equals(SeamOverloadingBase? other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int GetHashCode() => throw new NotSupportedException("hand-written"); +} + +/// A plain value object over a base record whose seams are overloads. +public sealed partial record PlainOverASeamOverloadingBase : SeamOverloadingBase, IValue; + +/// A validated value object over a base record whose TryFrom and Revalidate are overloads. +public sealed partial record ValidatedOverASeamOverloadingBase : SeamOverloadingBase, IValidatedValue +{ + /// Zero or more. + public static Result Create(int value) => value < 0 ? Result.Error(OverloadFault.Negative) : new ValidatedOverASeamOverloadingBase(value); +} + +// CMTK1023 names the base's equality, which the generated one does not call. +#pragma warning disable CMTK1023 +/// +/// Two levels above an explicit IEquatable by reference, with a generic Equals of that base's type: the +/// value object implements the base's interface again all the same, since the generic one implements nothing. +/// +public sealed partial record EqualsOverloadOverADeeperExplicitEquality : LocalBetweenRecord, IValue +{ + /// Hand-written. + public bool Equals(LocalExplicitEqualityRecord? other) => throw new NotSupportedException("hand-written"); +} +#pragma warning restore CMTK1023 + +/// A closed set with a generic ToString(), which is no seam. +public sealed partial class SetWithAGenericToString : IClosedSet +{ + /// The first member. + public static readonly SetWithAGenericToString First = new(); + + /// Hand-written. + public string ToString() => throw new NotSupportedException("hand-written"); +} + +/// +/// The generated members beside a hand-written overload of each, on the type, on a base record of this assembly +/// and of another, and on a closed set and its base class (#226). Refusals are build errors and live in +/// . +/// +public sealed class OverloadTests +{ + public static TheoryData, object> Generated => new() + { + { "From(int)", () => FromOverloadedByArity.From(7).Value, 7 }, + { "From(in int)", () => FromOverloadedByIn.From(7).Value, 7 }, + { "From(ref int)", () => FromOverloadedByRef.From(7).Value, 7 }, + { "Parse(string, IFormatProvider?)", () => ParseOverloadedByArity.Parse("7", null).Value, 7 }, + { "TryParse(string?, IFormatProvider?, out T)", () => TryParseOverloadedByArity.TryParse("7", null, out var parsed) ? parsed.Value : -1, 7 }, + { "TryParse(string?, IFormatProvider?, T)", () => TryParseOverloadedByValue.TryParse("7", null, out var parsed) ? parsed.Value : -1, 7 }, + { "Parse(ReadOnlySpan, IFormatProvider?)", () => SpanParseOverloaded.Parse("7".AsSpan(), null).Value, 7 }, + { "TryParse(ReadOnlySpan, IFormatProvider?, T)", () => SpanParseOverloaded.TryParse("7".AsSpan(), null, out var parsed) ? parsed.Value : -1, 7 }, + { "Parse(ReadOnlySpan, IFormatProvider?)", () => Utf8ParseOverloaded.Parse("7"u8, null).Value, 7 }, + { "TryParse(ReadOnlySpan, IFormatProvider?, T)", () => Utf8ParseOverloaded.TryParse("7"u8, null, out var parsed) ? parsed.Value : -1, 7 }, + { "IParsable.Parse beside Parse", () => ParseThroughInterface("7"), ParseOverloadedByArity.From(7) }, + { "ToString(string?, IFormatProvider?)", () => FormatOverloadedByArity.From(7).ToString("D3", null), "007" }, + { "ToString(in string?, IFormatProvider?)", () => FormatOverloadedByIn.From(7).ToString("D3", null), "007" }, + { "interpolation beside ToString(string?, IFormatProvider?)", () => $"{FormatOverloadedByArity.From(7):D3}", "007" }, + { "TryFormat(Span, out int, ...) and TryFormat(Span, int, ...)", () => Format(TryFormatOverloaded.From(7)), "007" }, + { "TryFormat(Span, int, ...)", () => FormatUtf8(TryFormatOverloaded.From(7)), "007" }, + { "a record class's Parse(string, IFormatProvider?)", () => ParseOverloadedInARecordClass.Parse("7", null).Value, 7 }, + { "a base record's From(int) and From(in int)", () => OverAnOverloadingBase.From(7).Value, 7 }, + { "a base record's Parse(string, IFormatProvider?)", () => OverAnOverloadingBase.Parse("7", null).Value, 7 }, + { "a base record's TryParse(string?, IFormatProvider?, T)", () => OverAnOverloadingBase.TryParse("7", null, out var parsed) ? parsed.Value : -1, 7 }, + { "a base record's Parse(ReadOnlySpan, IFormatProvider?)", () => OverAnOverloadingBase.Parse("7".AsSpan(), null).Value, 7 }, + { "a base record's TryParse(ReadOnlySpan, IFormatProvider?, T)", () => OverAnOverloadingBase.TryParse("7"u8, null, out var parsed) ? parsed.Value : -1, 7 }, + { "a base record's ToString(string?, IFormatProvider?)", () => OverAnOverloadingBase.From(7).ToString("D3", null), "007" }, + { "a base record's TryFormat(Span, out int, ...)", () => Format(OverAnOverloadingBase.From(7)), "007" }, + { "another assembly's From(in int)", () => OverALibraryOverloadingBase.From(7).Value, 7 }, + { "another assembly's Parse(string, IFormatProvider?)", () => OverALibraryOverloadingBase.Parse("7", null).Value, 7 }, + { "another assembly's TryParse(string?, IFormatProvider?, T)", () => OverALibraryOverloadingBase.TryParse("7", null, out var parsed) ? parsed.Value : -1, 7 }, + { "a set's Parse(string)", () => OverloadingSet.Parse("Second"), OverloadingSet.Second }, + { "a set's Parse(string, bool)", () => OverloadingSet.Parse("second", ignoreCase: true), OverloadingSet.Second }, + { "a set's TryParse(string?, out T) and TryParse(string?, T)", () => OverloadingSet.TryParse("Second", out var member) ? member : OverloadingSet.First, OverloadingSet.Second }, + { "a set's TryParse(string?, bool, T)", () => OverloadingSet.TryParse("second", ignoreCase: true, out var member) ? member : OverloadingSet.First, OverloadingSet.Second }, + { "a set's IParsable.Parse beside Parse(string)", () => ParseThroughInterface("Second"), OverloadingSet.Second }, + { "a set's base class's Parse(string)", () => SetOverAnOverloadingBase.Parse("Second"), SetOverAnOverloadingBase.Second }, + { "a set's base class's TryParse(string?, bool, T)", () => SetOverAnOverloadingBase.TryParse("second", ignoreCase: true, out var member) ? member : SetOverAnOverloadingBase.First, SetOverAnOverloadingBase.Second }, + { "ToString() beside ToString()", () => ToStringOverloadedByArity.From("a").ToString(), "a" }, + { "interpolation beside ToString()", () => $"{ToStringOverloadedByArity.From("a")}", "a" }, + { "CompareTo(T) beside CompareTo(T)", () => Math.Sign(CompareToOverloadedByArity.From(1).CompareTo(CompareToOverloadedByArity.From(2))), -1 }, + { "IComparable beside CompareTo(T)", () => Math.Sign(((IComparable)CompareToOverloadedByArity.From(1)).CompareTo(CompareToOverloadedByArity.From(2))), -1 }, + { "CompareTo(T) beside CompareTo(in T)", () => Math.Sign(CompareToOverloadedByIn.From(1).CompareTo(CompareToOverloadedByIn.From(2))), -1 }, + { "IComparable beside CompareTo(in T)", () => Math.Sign(((IComparable)CompareToOverloadedByIn.From(1)).CompareTo(CompareToOverloadedByIn.From(2))), -1 }, + { "CompareTo(object) beside CompareTo(object?) and CompareTo(in object?)", () => Math.Sign(((IComparable)ObjectCompareToOverloaded.From(1)).CompareTo(ObjectCompareToOverloaded.From(2))), -1 }, + { "< beside CompareTo(object?)", () => ObjectCompareToOverloaded.From(1) < ObjectCompareToOverloaded.From(2), true }, + { "Equals(T) beside Equals(T)", () => EqualityOverloadedByArity.From(1).Equals(EqualityOverloadedByArity.From(1)), true }, + { "GetHashCode() beside GetHashCode()", () => EqualityOverloadedByArity.From(1).GetHashCode() == EqualityOverloadedByArity.From(1).GetHashCode(), true }, + { "ToString() beside PrintMembers(StringBuilder)", () => PrintMembersOverloadedByArity.From(1).ToString(), "1" }, + { "TryFrom(int) beside TryFrom(int) and TryFrom(in int)", () => TryFromOverloaded.TryFrom(1).IsSome() && TryFromOverloaded.TryFrom(-1).IsNone(), true }, + { "a base record's TryFrom(int) and TryFrom(in int)", () => ValidatedOverASeamOverloadingBase.TryFrom(1).IsSome() && ValidatedOverASeamOverloadingBase.TryFrom(-1).IsNone(), true }, + { "a base record's Revalidate()", () => ValidatedOverASeamOverloadingBase.FromKnownGood(1).Revalidate().TryGetValue(out _, out _), true }, + { "a base record's CompareTo(object?) and CompareTo(TSelf?)", () => PlainOverASeamOverloadingBase.From(1) < PlainOverASeamOverloadingBase.From(2), true }, + { "a base record's ToString()", () => PlainOverASeamOverloadingBase.From(1).ToString(), "1" }, + { "a base record's Equals(TBase?) and GetHashCode()", () => new HashSet> { PlainOverASeamOverloadingBase.From(1), PlainOverASeamOverloadingBase.From(1) }.Count, 1 }, + { "a deeper base's IEquatable beside Equals(TBase?)", () => new HashSet { EqualsOverloadOverADeeperExplicitEquality.From(42), EqualsOverloadOverADeeperExplicitEquality.From(42) }.Count, 1 }, + { "a set's ToString() beside ToString()", () => $"{SetWithAGenericToString.First}", "First" }, + }; + + /// + /// Each generated member is there beside the hand-written overload, and a call that names neither type arguments + /// nor a ref kind reaches the generated one. + /// + [Theory] + [MemberData(nameof(Generated))] + public void The_generated_member_sits_beside_a_hand_written_overload(string overload, Func generated, object expected) => + generated().ShouldBe(expected, overload); + + public static TheoryData HandWritten => new() + { + { "From(int)", () => FromOverloadedByArity.From(7) }, + { "From(in int)", () => FromOverloadedByIn.From(in Seven) }, + { "Parse(string, IFormatProvider?)", () => ParseOverloadedByArity.Parse("7", null) }, + { "TryParse(string?, IFormatProvider?, T)", () => TryParseOverloadedByValue.TryParse("7", null, TryParseOverloadedByValue.From(1)) }, + { "ToString(string?, IFormatProvider?)", () => FormatOverloadedByArity.From(7).ToString("D3", null) }, + { "TryFormat(Span, int, ...)", () => TryFormatOverloaded.From(7).TryFormat(new char[8], 0, "", null) }, + { "a base record's Parse(string, IFormatProvider?)", () => OverAnOverloadingBase.Parse("7", null) }, + { "another assembly's From(in int)", () => OverALibraryOverloadingBase.From(in Seven) }, + { "a set's Parse(string)", () => OverloadingSet.Parse("Second") }, + { "a set's TryParse(string?, T)", () => OverloadingSet.TryParse("Second", OverloadingSet.First) }, + { "ToString()", () => ToStringOverloadedByArity.From("a").ToString() }, + { "CompareTo(in T)", () => CompareToOverloadedByIn.From(1).CompareTo(in OneByIn) }, + { "TryFrom(int)", () => _ = TryFromOverloaded.TryFrom(1) }, + { "a set's ToString()", () => SetWithAGenericToString.First.ToString() }, + }; + + /// The hand-written overload is kept, and a call that names its type arguments or its ref kind reaches it. + [Theory] + [MemberData(nameof(HandWritten))] + public void The_hand_written_overload_is_kept(string overload, Action handWritten) => + Should.Throw(handWritten, overload).Message.ShouldBe("hand-written"); + + public static TheoryData NotHandWrittenOrdering => [typeof(CompareToOverloadedByArity), typeof(CompareToOverloadedByIn), typeof(PlainOverASeamOverloadingBase)]; + + /// + /// A generic or by-in CompareTo(TSelf) is no seam, so the ordering is the generated one and is not marked + /// hand-written: the EF Core satellite refuses to order by a column whose value object is marked. + /// + [Theory] + [MemberData(nameof(NotHandWrittenOrdering))] + public void An_overload_of_CompareTo_does_not_mark_the_ordering_hand_written(Type type) => + type.IsDefined(typeof(CodoMetis.TypeKit.CompilerServices.HandWrittenOrderingAttribute), inherit: false).ShouldBeFalse(); + + private static readonly int Seven = 7; + + private static readonly CompareToOverloadedByIn OneByIn = CompareToOverloadedByIn.From(1); + + private static T ParseThroughInterface(string text) where T : IParsable => T.Parse(text, null); + + private static string Format(T value) where T : ISpanFormattable + { + Span buffer = stackalloc char[16]; + return value.TryFormat(buffer, out var written, "D3", null) ? new string(buffer[..written]) : ""; + } + + private static string FormatUtf8(T value) where T : IUtf8SpanFormattable + { + Span buffer = stackalloc byte[16]; + return value.TryFormat(buffer, out var written, "D3", null) ? Encoding.UTF8.GetString(buffer[..written]) : ""; + } +} diff --git a/test/oracles/axes.md b/test/oracles/axes.md index 05e1cbff..3f6b3889 100644 --- a/test/oracles/axes.md +++ b/test/oracles/axes.md @@ -32,8 +32,8 @@ footprint, it runs with the suite. |---|---|---|---|---| | JSON | the host's options | defaults, Web, a naming policy, a dictionary-key policy, number handling, a host converter | `JsonParityTests` | reference preservation beside a dictionary key | | JSON | the wrapped type | every built-in the generators wrap, nullable or not | `JsonParityTests`, `JsonTests` | | -| OpenAPI | the version | 3.1, 3.0, 2.0 | `SchemaTests`, `FaultResponseTests` | a withheld fault name under 2.0 | -| OpenAPI | where value objects are described | components, inlined by `InlineValueObjects`, everything inlined by the host | `SchemaTests`, `NullablePositionTests`, `DescribedParameterTests` | `InlineValueObjects` beside a described type's parameter (`DescribedParameterTests` inlines by the host's own delegate) | +| OpenAPI | the version | 3.1, 3.0, 2.0 | `SchemaTests`, `FaultResponseTests`, `PatchSchemaTests` | a withheld fault name under 2.0 | +| OpenAPI | where value objects are described | components, inlined by `InlineValueObjects`, everything inlined by the host | `SchemaTests`, `NullablePositionTests`, `DescribedParameterTests`, `PatchSchemaTests` | `InlineValueObjects` beside a described type's parameter (`DescribedParameterTests` inlines by the host's own delegate) | | HTTP | the host | minimal API, MVC with `[ApiController]`, MVC without | `MvcBindingTests`, `FaultStatusScopeTests` | | | EF | the provider | SQLite, Npgsql, SQL Server | `KeyGenerationVariantTests`, `QueryTranslationVariantTests` | | | EF | where a key sits | `KeyShapes` (`test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/`) | `KeyGenerationVariantTests` | | diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 47a89c5b..b0be1f3c 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1195,6 +1195,105 @@ ], "heldBy": [] }, + { + "id": "C22-a-base-record-s-members", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "**Any of these members in a base record**, public or explicit, in your project or another. A base's `MaxValue` would stand in for the generated one, and generic code calling `T.MaxValue` would reach it. The error names the base.", + "claim": "A generated member a base record declares, public or explicit, in this project or another, fails the build naming the base.", + "oracle": "exception", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, + { + "id": "C22-a-base-s-private-member", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "A base's `private` member is fine: the value object can't see it, so it stands in for nothing.", + "claim": "A base record's private member of a generated member's name or signature does not fail the build: the value object is generated beside it, and every generated member is what a call reaches; a protected, internal or public one is refused.", + "oracle": "claim", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests", + "InheritedMembersTests" + ] + }, + { + "id": "C22-a-contract-through-an-interface", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "That holds whether you declare one directly, through an interface of your own or in a base record.", + "claim": "IValueObject, IValueObjectMaterializer, IPlainValueObject and a generated IConvertible fail the build whether declared directly, through an interface or by a base record.", + "oracle": "exception", + "surfaces": [ + "IValueObject.*", + "IValueObjectMaterializer.*", + "IPlainValueObject.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, + { + "id": "C22-a-seam-in-a-base-record", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "Declare them on the value object itself. A `TryFrom`, `Revalidate` or `CompareTo` in a base record fails the build (CMTK1011, or CMTK1008 for `CompareTo`), because it would stand in for the generated member without a word.", + "claim": "A TryFrom, Revalidate or CompareTo a base record declares fails the build, CMTK1008 for CompareTo.", + "oracle": "exception", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, + { + "id": "C22-a-set-s-base-class-members", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "Any other generated member written by hand fails the build (CMTK1011), in the set or in a class it derives from.", + "claim": "A generated member of a closed set written in a base class fails the build with CMTK1011.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, + { + "id": "C22-a-set-s-base-class-private-member", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "A `private` one in that class is fine, since the set can't see it.", + "claim": "A closed set's base class's private Parse, TryParse or Match does not fail the build, and the set's generated members are what a call reaches; a protected, internal or public one is refused.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests", + "InheritedMembersTests" + ] + }, + { + "id": "C22-a-set-s-base-class-tostring", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "A `sealed` one is kept, since C# keeps it in every derived class. One that isn't sealed fails the build (CMTK1011), because the generated `ToString()` would replace it.", + "claim": "A closed set over a base class's sealed ToString() prints it; over one not sealed, the build fails with CMTK1011.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests", + "InheritedMembersTests" + ] + }, { "id": "C22-abstract", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", @@ -1210,6 +1309,50 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-an-overload-in-a-closed-set", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "**An overload of a generated method with another signature**, such as `Parse(int rank)` or a generic `Parse(string name)`, is kept too.", + "claim": "A closed set's hand-written overload of Parse or TryParse with another signature, generic or by value where the generated one is out, is kept beside the generated one, in the set or its base class.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "OverloadTests", + "BuildOutcomeTests" + ] + }, + { + "id": "C22-an-overload-of-a-generated-member", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "C# tells two methods apart by their type parameters and by whether a parameter is `ref`, `in` or `out`, so a `Parse(string, IFormatProvider?)`, a `From(in int value)` or a `TryParse` whose last parameter isn't `out` is kept, on the value object or a base record. A `ref` or `in` where the generated member has `out` is the same method to C#, as a different nullability is, so it fails the build like the member itself.", + "claim": "A hand-written overload of a generated member that differs by its type parameters or by a ref kind against a by-value parameter is kept beside the generated member, on the value object or a base record; ref or in against out, or another nullability, fails the build with CMTK1011.", + "oracle": "claim", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "OverloadTests", + "BuildOutcomeTests" + ] + }, + { + "id": "C22-an-overload-of-a-seam", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "The same goes for the members you may write: a generic `ToString()` or a `CompareTo(in TSelf)` isn't the one that's kept, so the generated `ToString()` and ordering are still there.", + "claim": "A generic or by-reference overload of ToString() or CompareTo(TSelf) is no seam: the generated ToString() and ordering are introduced beside it, and the ordering is not marked hand-written.", + "oracle": "claim", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*", + "IClosedSet.*" + ], + "heldBy": [ + "OverloadTests" + ] + }, { "id": "C22-closed-set-named-as-generated-member", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", @@ -1225,6 +1368,19 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-create-in-a-base-record", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "If the value object only gets the marker through that base, generic code calling `T.Create` reaches the base's `Create`, while everything generated calls yours, and the build fails (CMTK1011). Declare the marker on the value object too, and your `Create` is the one both reach.", + "claim": "A base record's Create that generic T.Create reaches beside the value object's own fails the build, and declaring the marker on the value object builds.", + "oracle": "exception", + "surfaces": [ + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-equality-operator", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", @@ -1240,6 +1396,20 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-equals-of-a-base-record-s-type", + "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", + "quote": "So does an `Equals` you write for one of its base records' types, such as `Equals(DeepBase?)`: a `HashSet` would compare by it while `==` compares the underlying value.", + "claim": "An Equals a value object declares for one of its base records' types fails the build (CMTK1011).", + "oracle": "exception", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-every-error-links-the-table", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", @@ -1255,6 +1425,21 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-explicit-implementation-in-an-interface", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "**The same explicit implementation in an interface the value object implements.** The generated member would replace it, so it would never run. The error names the interface, in your project or another.", + "claim": "An explicit implementation of a generated interface's member in an interface the value object implements, in this project or another, fails the build naming the interface.", + "oracle": "exception", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*", + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-generic-wrapped", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", @@ -1270,6 +1455,20 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-interface-implementations-that-lose-nothing", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "A record's own `==` and `!=` replace an interface's, generated or not. And .NET's `INumberBase` formats and parses UTF-8 through the UTF-16 members, which every .NET number replaces with its own, as the generated members do.", + "claim": "An interface's `==` and `!=`, and `INumberBase`'s UTF-8 members, do not fail the build.", + "oracle": "claim", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-named-as-generated-member", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", @@ -1863,6 +2062,20 @@ "HandWrittenOrderingTests" ] }, + { + "id": "E05-ordering-boxed", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", + "quote": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, and when the cast value reaches the ordering another way, through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory still orders by your `CompareTo`.", + "claim": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, and when the cast value reaches the ordering another way, through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory still orders by your `CompareTo`.", + "oracle": "exception", + "surfaces": [ + "HandWrittenOrderingAttribute.*" + ], + "heldBy": [ + "HandWrittenOrderingTests", + "QueryTranslationVariantTests" + ] + }, { "id": "E05-query-filter", "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", @@ -1876,6 +2089,19 @@ "HandWrittenOrderingTests" ] }, + { + "id": "E05-query-filter-own-values", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", + "quote": "One exception: a filter may order values your context holds, without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that filter fails too.", + "claim": "One exception: a filter may order values your context holds, without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that filter fails too.", + "oracle": "exception", + "surfaces": [ + "HandWrittenOrderingAttribute.*" + ], + "heldBy": [ + "HandWrittenOrderingTests" + ] + }, { "id": "E05-value-order-allowed", "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", @@ -2333,6 +2559,48 @@ "ForbiddenDefaultInitializationAnalyzerTests" ] }, + { + "id": "K05-a-factory-given-a-typeof", + "source": "src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005", + "quote": "and so are `Array.CreateInstance` and `Array.CreateInstanceFromArrayType` when a `typeof` names the element, and `ArrayPool.Rent`. A `Type` held in a variable could be anything, so it isn't reported.", + "claim": "CMTK0005 reports Array.CreateInstance and Array.CreateInstanceFromArrayType given a typeof of a no-default type, and ArrayPool.Rent of one, and not a Type held in a variable.", + "oracle": "claim", + "surfaces": [ + "IValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "DefaultFilledCollectionAnalyzerTests" + ] + }, + { + "id": "K05-a-zero-dimension-is-silent", + "source": "src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005", + "quote": "That holds for any one dimension: `new OrderId[0, count]` holds no element either.", + "claim": "CMTK0005 does not report an array whose length is a constant zero in any one of its dimensions.", + "oracle": "claim", + "surfaces": [ + "IValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "DefaultFilledCollectionAnalyzerTests" + ] + }, + { + "id": "K06-an-explicit-implementation-through-a-cast", + "source": "src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006", + "quote": "An explicit interface implementation is assigned the only way a class can reach it, through a cast: `((IHasId)this).Id = id` in every constructor keeps it quiet.", + "claim": "CMTK0006 counts an assignment through a cast of this, an interface's member as the class's implementation of it, and does not follow a local holding this.", + "oracle": "claim", + "surfaces": [ + "IValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "UnassignedNoDefaultMemberAnalyzerTests" + ] + }, { "id": "L01-read-as-the-underlying-type-reads", "source": "README.md#what-typekit-guarantees", @@ -3545,6 +3813,19 @@ "PatchSchemaTests" ] }, + { + "id": "P06-element-by-element", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "A patch inside a list or a dictionary is described the same way, element by element.", + "claim": "A patch inside a list or a dictionary is described the same way, element by element.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, { "id": "P06-inlined", "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", @@ -3558,6 +3839,58 @@ "PatchSchemaTests" ] }, + { + "id": "P06-map-of-patches", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "So a `Dictionary>`, where a request sets a key with a value and clears it with `null`, is an object whose values are a string or `null`.", + "claim": "So a `Dictionary>`, where a request sets a key with a value and clears it with `null`, is an object whose values are a string or `null`.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, + { + "id": "P06-nullable-patch-reads-null-as-null", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "But System.Text.Json reads a `null` there as a `null` patch, the same as a property the request leaves out, so you can't tell a clear from no change.", + "claim": "But System.Text.Json reads a `null` there as a `null` patch, the same as a property the request leaves out, so you can't tell a clear from no change.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, + { + "id": "P06-readme-containers", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "quote": "Each element of a list, an array or a dictionary of patches is described as a patch property is.", + "claim": "Each element of a list, an array or a dictionary of patches is described as a patch property is.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, + { + "id": "P06-readme-nullable-patch", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "quote": "A nullable patch, `Patch?`, admits the same values and null, but isn't described as a merge patch: a `null` there reads as a `null` patch, the same as a property left out.", + "claim": "A nullable patch, `Patch?`, admits the same values and null, but isn't described as a merge patch: a `null` there reads as a `null` patch, the same as a property left out.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, { "id": "P06-through-the-underlying-type", "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document",