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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSelf, T>`, `IValueObjectMaterializer<TSelf, T>` or `IPlainValueObject<TSelf, T>` declared through an interface of yours, such as an `IEntityId<TSelf> : IValueObject<TSelf, Guid>`, 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<TSelf, T>` 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<T>` is described as a patch property is, the argument's schema admitting null, where it admitted anything. A nullable `Patch<T>?` 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

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T> 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<Patch<int[]>>, 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<T>` 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<T>`:

- 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<T?>`) 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<T>?` 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<int[]>?` reached the patch transformer as a `Patch<int[]>` and gained a null. `PatchSchemaTests` holds that
consequence (its guard's seeds), and this entry's test the serializer's part.
1 change: 1 addition & 0 deletions design/knowledge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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` |
2 changes: 1 addition & 1 deletion design/patch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |
Expand Down
8 changes: 8 additions & 0 deletions docs/merge-patches.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Patch<string>>`, 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<string>?` 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<string>` 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
Expand Down
47 changes: 43 additions & 4 deletions src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization.Metadata;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
Expand All @@ -7,7 +8,8 @@ namespace CodoMetis.TypeKit.AspNetCore;
/// <summary>
/// A <see cref="Patch{T}"/> 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 (<see cref="Argument"/>).
/// </summary>
/// <remarks>
/// <para>
Expand All @@ -25,14 +27,32 @@ namespace CodoMetis.TypeKit.AspNetCore;
/// </remarks>
internal sealed class PatchSchemaTransformer : IOpenApiSchemaTransformer
{
/// <summary>
/// The argument's elements below a nullable patch's schema, which ASP.NET visits again as the patch: the serializer
/// gives a <see cref="Nullable{T}"/> an element type, <c>T</c>, and ASP.NET visits <c>items</c> and
/// <c>additionalProperties</c> as that type. Filled as a patch, a <c>Patch&lt;int[]&gt;?</c>'s numbers admitted null
/// (KB-057).
/// </summary>
private static readonly ConditionalWeakTable<IOpenApiSchema, Type> ArgumentElements = new();

/// <summary>A patch is inlined where it is used, never hoisted into a component of its own.</summary>
public static void Apply(OpenApiOptions options)
{
var inner = options.CreateSchemaReferenceId;
options.CreateSchemaReferenceId = type => IsPatch(type.Type) ? null : inner(type);
}

private static bool IsPatch(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Patch<>);
private static bool IsPatch(Type type) => Argument(type) is not null;

/// <summary>
/// The argument of a <see cref="Patch{T}"/>, 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).
/// </summary>
private static Type? Argument(Type type) =>
(Nullable.GetUnderlyingType(type) ?? type) is { IsGenericType: true } patch && patch.GetGenericTypeDefinition() == typeof(Patch<>)
? patch.GetGenericArguments()[0]
: null;

/// <summary>
/// The argument's schema, as it is, with null admitted beside every value it admits.
Expand Down Expand Up @@ -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<T> 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);
Expand All @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/CodoMetis.TypeKit.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>?`, 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
Expand Down
Loading
Loading