From 2ec8b721d5e8bb4bedd3018f7606b464be51957e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:36:03 +0100 Subject: [PATCH 1/4] fix: describe each element of a list or a dictionary of patches as a patch property ASP.NET leaves out items and additionalProperties for a converter-backed element, and PatchSchemaTransformer acted only on a Patch itself. So a Dictionary> was {"type":"object"} and a List> {"type":"array"}, admitting anything, in 3.1, 3.0 and 2.0, while a Patch property was its argument's schema admitting null. The container branch now puts the patch's own schema back where ASP.NET left it out, asked for through the context, so it is filled as a property is: a described or replaced argument, a value object through its wrapped type, 3.0's nullable form. Input class: where a Patch sits. Catalogue: PatchSchemaTests' arguments (12 kinds, now 14 with Lines[] and Dictionary, a list of a component and a dictionary) in 6 positions (property, list, array, Dictionary, IReadOnlyDictionary, a list in a dictionary) plus a list as the whole body, in a minimal API and an MVC controller, over OpenAPI 3.1, 3.0 and 2.0 and the three value-object placements (components, InlineValueObjects, inlined by the host). A host that inlines everything has no polymorphic argument: ASP.NET refuses a plain property of one too. A wire test reads a map and a list of patches as the document says they are read. The nullable patch the catalogue found is the next commit. Reverted per verify-the-guard: the 11 new document rows fail; the dictionary arm alone fails them too. Closes #232 Defect-Class: FCI Found-By: CA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + design/patch.md | 2 +- docs/merge-patches.md | 4 + .../PatchSchemaTransformer.cs | 25 ++- src/CodoMetis.TypeKit.AspNetCore/README.md | 2 + .../PatchSchemaTests.cs | 172 +++++++++++++++++- test/oracles/axes.md | 4 +- test/oracles/claims.json | 39 ++++ 8 files changed, 237 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983987c9..6587bff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ section is the GitHub release's notes. - 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/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - 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/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +### Fixed + +- 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 ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). + ## 2.3.0 — 2026-10-29 ### Added diff --git a/design/patch.md b/design/patch.md index 5fc4981f..9e6f698e 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 | `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/merge-patches.md b/docs/merge-patches.md index d8b5a387..73b2e633 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -194,6 +194,10 @@ 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`. + **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.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs index 17865cc1..ccc15fd1 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -7,7 +7,7 @@ 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. /// /// /// @@ -32,7 +32,13 @@ 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 . + private static Type? Argument(Type 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 +95,22 @@ 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. + if (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) 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); diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 231481f0..3dedc455 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -150,6 +150,8 @@ 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 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/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs index 7fe4c2f2..125c9f8d 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -48,22 +48,57 @@ 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, and an element of a list in a dictionary. + /// + public sealed record PatchPositions( + Patch Property, + List> List, + Patch[] Array, + Dictionary> Map, + IReadOnlyDictionary> ReadOnlyMap, + Dictionary>> Nested) where T : notnull; + + /// The patch in each position of other than the property itself. + private static readonly (string Position, Func Patch)[] Positions = + [ + ("list", schema => schema["items"]), + ("array", schema => schema["items"]), + ("map", schema => schema["additionalProperties"]), + ("readOnlyMap", schema => schema["additionalProperties"]), + ("nested", schema => schema["additionalProperties"]?["items"]), + ]; + + [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 +112,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 +163,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 +195,93 @@ 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. 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) in Positions) + document.Expand(patch(positions["properties"]![position]!)) + .ShouldNotBeNull($"{path} '{position}': {positions["properties"]![position]!.ToJsonString()}") + .ShouldDescribeTheSameAs(property, $"{path} '{position}'"); + + 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. + /// + [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()))))); + + using var response = await host.Client.PatchAsync("/read", new StringContent( + """{"map":{"a":"x","b":null},"list":["y",null]}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("a=Value b=Null Value Null"); + } + + /// + /// 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 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 f5c1df18..079b1b21 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -3671,6 +3671,45 @@ "MergePatchesTests" ] }, + { + "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-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-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": "P07-default-breaks-the-document", "source": "docs/merge-patches.md#required-constructor-parameters", From bb8922fc2469471b71c75ac8c475aa56b5854fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:36:03 +0100 Subject: [PATCH 2/4] fix: describe a nullable patch as its argument admitting null, not as an empty required component Found running the container fix over its input class: a Patch? property was an empty PatchOfstring component, admitting anything, and required, and a List?> had no items. The transformer asked only whether a type was Patch itself; it now sees through Nullable, so a nullable patch is inlined, optional, and its argument's schema admitting null, as an element and as a property. It is not described as a merge-patch field: System.Text.Json reads a null into a Nullable> as null, never the Null state, the same as an absent property (measured by a request in the wire test). The docs say so and point to Patch. Two quirks of the same cause: the serializer gives a Nullable the element type T. So the container check read a nullable patch as a container, and now reads the kind first; and ASP.NET visits a nullable patch's items and additionalProperties as the patch, which made a Patch?'s numbers admit null. Those schemas are recorded and skipped. A registration of a reference's target no test caught was measured unneeded (the Lines component stays clean) and dropped. Catalogue: the previous commit's, with a nullable element and a nullable property (8 positions). Reverted per verify-the-guard: 11 rows fail; each of the nullable argument, the kind check, the description and the guard's items and additionalProperties arms fails 9 or 11. Refs #232 Defect-Class: FCI Found-By: VA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- design/patch.md | 2 +- docs/merge-patches.md | 4 + .../PatchSchemaTransformer.cs | 31 ++++++-- src/CodoMetis.TypeKit.AspNetCore/README.md | 4 +- .../PatchSchemaTests.cs | 74 +++++++++++++------ test/oracles/claims.json | 26 +++++++ 7 files changed, 114 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6587bff0..aabf085e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ section is the GitHub release's notes. ### Fixed -- 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 ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). +- 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/main/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). ## 2.3.0 — 2026-10-29 diff --git a/design/patch.md b/design/patch.md index 9e6f698e..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; each element of a list or a dictionary of patches as a property is, in 2.0 too | `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/merge-patches.md b/docs/merge-patches.md index 73b2e633..7ae87ea2 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -198,6 +198,10 @@ A patch inside a list or a dictionary is described the same way, element by elem `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.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs index ccc15fd1..1a10321a 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. The same for each element of a list or a dictionary of patches. +/// 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,13 @@ 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. + /// + 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) { @@ -34,9 +43,13 @@ public static void Apply(OpenApiOptions options) private static bool IsPatch(Type type) => Argument(type) is not null; - /// The argument of a . + /// + /// 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. + /// private static Type? Argument(Type type) => - type is { IsGenericType: true } patch && patch.GetGenericTypeDefinition() == typeof(Patch<>) + (Nullable.GetUnderlyingType(type) ?? type) is { IsGenericType: true } patch && patch.GetGenericTypeDefinition() == typeof(Patch<>) ? patch.GetGenericArguments()[0] : null; @@ -98,7 +111,8 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC // 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. - if (info.ElementType is { } element && IsPatch(element)) + // 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); @@ -107,7 +121,7 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC return; } - if (Argument(info.Type) is not { } argument) 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. @@ -123,6 +137,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 3dedc455..97b8d980 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -150,7 +150,9 @@ 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. +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 diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs index 125c9f8d..fc73ee6b 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -65,7 +65,7 @@ public sealed record Kinds( /// /// Every place a patch can sit, beside a patch property of the same argument: an element of a list, an array or a - /// dictionary, and an element of a list in a dictionary. + /// dictionary, a nullable element, an element of a list in a dictionary, and a nullable patch property. /// public sealed record PatchPositions( Patch Property, @@ -73,16 +73,23 @@ public sealed record PatchPositions( Patch[] Array, Dictionary> Map, IReadOnlyDictionary> ReadOnlyMap, - Dictionary>> Nested) where T : notnull; + List?> NullableElements, + Dictionary>> Nested, + Patch? Nullable) where T : notnull; - /// The patch in each position of other than the property itself. - private static readonly (string Position, Func Patch)[] Positions = + /// + /// 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"]), - ("array", schema => schema["items"]), - ("map", schema => schema["additionalProperties"]), - ("readOnlyMap", schema => schema["additionalProperties"]), - ("nested", schema => schema["additionalProperties"]?["items"]), + ("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] @@ -208,9 +215,10 @@ public static TheoryData Hosts() /// /// 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. Over every - /// argument of the catalogue, in each version, wherever value objects are described, in a minimal API and an MVC - /// controller. + /// 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))] @@ -227,10 +235,22 @@ public async Task A_patch_in_any_position_is_described_as_a_patch_property_is(Op 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) in Positions) - document.Expand(patch(positions["properties"]![position]!)) - .ShouldNotBeNull($"{path} '{position}': {positions["properties"]![position]!.ToJsonString()}") - .ShouldDescribeTheSameAs(property, $"{path} '{position}'"); + 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"); @@ -239,7 +259,8 @@ public async Task A_patch_in_any_position_is_described_as_a_patch_property_is(Op /// /// 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. + /// 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() @@ -247,12 +268,16 @@ 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()))))); + .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]}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + """{"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"); + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("a=Value b=Null Value Null none"); + (await leftOut.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("none"); } /// @@ -270,7 +295,7 @@ public async Task A_patch_in_a_container_accepts_null_and_otherwise_exactly_what var positions = document.Resolve(Body(document, "/positions/" + property)); var argument = Validator(document, document.Property(nameof(Kinds), property), version); - foreach (var (position, select) in Positions) + 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); @@ -407,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/oracles/claims.json b/test/oracles/claims.json index 079b1b21..6a6b5753 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -3697,6 +3697,19 @@ "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", @@ -3710,6 +3723,19 @@ "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": "P07-default-breaks-the-document", "source": "docs/merge-patches.md#required-constructor-parameters", From 7961c35f478661e4fb78ca65d7ecb274689369ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:39:44 +0100 Subject: [PATCH 3/4] docs: record KB-057, a nullable contract's element type and its null The fix of #232 turned on two facts of System.Text.Json: a Nullable contract reports T as its element type, which Microsoft.AspNetCore.OpenApi walks items and additionalProperties with, and a JSON null read into a T? is null without asking T's converter, even one that handles null. The second is what the merge-patches page now tells a reader of Patch?. KB057 characterizes both against a plain T as the control. Co-Authored-By: Claude Opus 5.5 --- ...-an-element-type-and-reads-null-as-null.md | 26 +++++++++ design/knowledge/README.md | 1 + .../PatchSchemaTransformer.cs | 5 +- test/CodoMetis.TypeKit.Facts/KB057.cs | 58 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md create mode 100644 test/CodoMetis.TypeKit.Facts/KB057.cs 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/README.md b/design/knowledge/README.md index c9e4b6fd..9177e434 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -85,3 +85,4 @@ 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` | diff --git a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs index 1a10321a..a220d4e9 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -30,7 +30,8 @@ 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. + /// additionalProperties as that type. Filled as a patch, a Patch<int[]>?'s numbers admitted null + /// (KB-057). /// private static readonly ConditionalWeakTable ArgumentElements = new(); @@ -46,7 +47,7 @@ public static void Apply(OpenApiOptions options) /// /// 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. + /// 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<>) 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(); + } +} From 19bb13d3376e07dc2be449e593d7fb31ae09a6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:59:37 +0100 Subject: [PATCH 4/4] test: keep the claims sorted by id, as OracleCatalogueTests requires Co-Authored-By: Claude Opus 5.5 --- test/oracles/claims.json | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 6a6b5753..14514cfc 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -3645,10 +3645,10 @@ ] }, { - "id": "P06-inlined", + "id": "P06-element-by-element", "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", - "quote": "**Inlined**, rather than as a component of its own.", - "claim": "**Inlined**, rather than as a component of its own.", + "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" @@ -3658,24 +3658,10 @@ ] }, { - "id": "P06-through-the-underlying-type", - "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", - "quote": "A value object inside a patch is described through its underlying type, so `email` is a `string`.", - "claim": "A value object inside a patch is described through its underlying type, so `email` is a `string`.", - "oracle": "claim", - "surfaces": [ - "TypeKitOpenApiOptionsExtensions.AddTypeKit" - ], - "heldBy": [ - "PatchSchemaTests", - "MergePatchesTests" - ] - }, - { - "id": "P06-element-by-element", + "id": "P06-inlined", "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.", + "quote": "**Inlined**, rather than as a component of its own.", + "claim": "**Inlined**, rather than as a component of its own.", "oracle": "claim", "surfaces": [ "TypeKitOpenApiOptionsExtensions.AddTypeKit" @@ -3736,6 +3722,20 @@ "PatchSchemaTests" ] }, + { + "id": "P06-through-the-underlying-type", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "A value object inside a patch is described through its underlying type, so `email` is a `string`.", + "claim": "A value object inside a patch is described through its underlying type, so `email` is a `string`.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests", + "MergePatchesTests" + ] + }, { "id": "P07-default-breaks-the-document", "source": "docs/merge-patches.md#required-constructor-parameters",