Skip to content

Commit d5f9a62

Browse files
Add TreatNullObliviousAsNonNullable setting.
1 parent af194e5 commit d5f9a62

File tree

5 files changed

+111
-3
lines changed

5 files changed

+111
-3
lines changed

src/libraries/System.Text.Json/ref/System.Text.Json.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -907,7 +907,8 @@ public sealed partial class JsonSchemaExporterOptions
907907
{
908908
public JsonSchemaExporterOptions() { }
909909
public static System.Text.Json.Schema.JsonSchemaExporterOptions Default { get { throw null; } }
910-
public System.Func<JsonSchemaExporterContext, System.Text.Json.Nodes.JsonNode, System.Text.Json.Nodes.JsonNode>? TransformSchemaNode { get; init; }
910+
public System.Func<JsonSchemaExporterContext, System.Text.Json.Nodes.JsonNode, System.Text.Json.Nodes.JsonNode>? TransformSchemaNode { get { throw null; } init { } }
911+
public bool TreatNullObliviousAsNonNullable { get { throw null; } init { } }
911912
}
912913
}
913914
namespace System.Text.Json.Serialization

src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema)
349349
{
350350
bool isNullableSchema = propertyInfo != null
351351
? propertyInfo.IsGetNullable || propertyInfo.IsSetNullable
352-
: typeInfo.CanBeNull && !parentPolymorphicTypeIsNonNullable;
352+
: typeInfo.CanBeNull && !parentPolymorphicTypeIsNonNullable && !state.ExporterOptions.TreatNullObliviousAsNonNullable;
353353

354354
if (isNullableSchema)
355355
{

src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporterOptions.cs

+10
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ public sealed class JsonSchemaExporterOptions
1515
/// </summary>
1616
public static JsonSchemaExporterOptions Default { get; } = new();
1717

18+
/// <summary>
19+
/// Determines whether non-nullable schemas should be generated for null oblivious reference types.
20+
/// </summary>
21+
/// <remarks>
22+
/// Defaults to <see langword="false"/>. Due to restrictions in the run-time representation of nullable reference types
23+
/// most occurences are null oblivious and are treated as nullable by the serializer. A notable exception to that rule
24+
/// are nullability annotations of field, property and constructor parameters which are represented in the contract metadata.
25+
/// </remarks>
26+
public bool TreatNullObliviousAsNonNullable { get; init; }
27+
1828
/// <summary>
1929
/// Defines a callback that is invoked for every schema that is generated within the type graph.
2030
/// </summary>

src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs

+54-1
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,27 @@ public static IEnumerable<ITestData> GetTestDataCore()
288288
}
289289
""");
290290

291+
// Same as above with non-nullable reference type handling
292+
yield return new TestData<PocoWithRecursiveMembers>(
293+
Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } },
294+
AdditionalValues: [new() { Value = 1, Next = null }],
295+
ExpectedJsonSchema: """
296+
{
297+
"type": "object",
298+
"properties": {
299+
"Value": { "type": "integer" },
300+
"Next": {
301+
"type": ["object", "null"],
302+
"properties": {
303+
"Value": { "type": "integer" },
304+
"Next": { "$ref": "#/properties/Next" }
305+
}
306+
}
307+
}
308+
}
309+
""",
310+
Options: new() { TreatNullObliviousAsNonNullable = true });
311+
291312
// Same as above but using an anchor-based reference scheme
292313
yield return new TestData<PocoWithRecursiveMembers>(
293314
Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } },
@@ -398,6 +419,22 @@ public static IEnumerable<ITestData> GetTestDataCore()
398419
}
399420
""");
400421

422+
// Same as above but with non-nullable reference type handling
423+
yield return new TestData<PocoWithRecursiveCollectionElement>(
424+
Value: new() { Children = [new(), new() { Children = [] }] },
425+
ExpectedJsonSchema: """
426+
{
427+
"type": "object",
428+
"properties": {
429+
"Children": {
430+
"type": "array",
431+
"items": { "$ref" : "#" }
432+
}
433+
}
434+
}
435+
""",
436+
Options: new() { TreatNullObliviousAsNonNullable = true });
437+
401438
yield return new TestData<PocoWithRecursiveDictionaryValue>(
402439
Value: new() { Children = new() { ["key1"] = new(), ["key2"] = new() { Children = new() { ["key3"] = new() } } } },
403440
ExpectedJsonSchema: """
@@ -412,6 +449,22 @@ public static IEnumerable<ITestData> GetTestDataCore()
412449
}
413450
""");
414451

452+
// Same as above but with non-nullable reference type handling
453+
yield return new TestData<PocoWithRecursiveDictionaryValue>(
454+
Value: new() { Children = new() { ["key1"] = new(), ["key2"] = new() { Children = new() { ["key3"] = new() } } } },
455+
ExpectedJsonSchema: """
456+
{
457+
"type": "object",
458+
"properties": {
459+
"Children": {
460+
"type": "object",
461+
"additionalProperties": { "$ref" : "#" }
462+
}
463+
}
464+
}
465+
""",
466+
Options: new() { TreatNullObliviousAsNonNullable = true });
467+
415468
yield return new TestData<PocoWithDescription>(
416469
Value: new() { X = 42 },
417470
ExpectedJsonSchema: """
@@ -1390,7 +1443,7 @@ IEnumerable<ITestData> ITestData.GetTestDataForAllValues()
13901443
{
13911444
yield return this;
13921445

1393-
if (default(T) is null)
1446+
if (default(T) is null && Options?.TreatNullObliviousAsNonNullable != true)
13941447
{
13951448
yield return this with { Value = default, AdditionalValues = null };
13961449
}

src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.cs

+44
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,33 @@ public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData)
4141
AssertDocumentMatchesSchema(schema, instance);
4242
}
4343

44+
[Theory]
45+
[InlineData(typeof(string), "string")]
46+
[InlineData(typeof(int[]), "array")]
47+
[InlineData(typeof(Dictionary<string, int>), "object")]
48+
[InlineData(typeof(SimplePoco), "object")]
49+
public void TreatNullObliviousAsNonNullable_False_MarksReferenceTypesAsNullable(Type referenceType, string expectedType)
50+
{
51+
Assert.True(!referenceType.IsValueType);
52+
var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = false };
53+
JsonNode schema = Serializer.DefaultOptions.GetJsonSchemaAsNode(referenceType, config);
54+
JsonArray arr = Assert.IsType<JsonArray>(schema["type"]);
55+
Assert.Equal([expectedType, "null"], arr.Select(e => (string)e!));
56+
}
57+
58+
[Theory]
59+
[InlineData(typeof(string), "string")]
60+
[InlineData(typeof(int[]), "array")]
61+
[InlineData(typeof(Dictionary<string, int>), "object")]
62+
[InlineData(typeof(SimplePoco), "object")]
63+
public void TreatNullObliviousAsNonNullable_True_MarksReferenceTypesAsNonNullable(Type referenceType, string expectedType)
64+
{
65+
Assert.True(!referenceType.IsValueType);
66+
var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true };
67+
JsonNode schema = Serializer.DefaultOptions.GetJsonSchemaAsNode(referenceType, config);
68+
Assert.Equal(expectedType, (string)schema["type"]!);
69+
}
70+
4471
[Theory]
4572
[InlineData(typeof(Type))]
4673
[InlineData(typeof(MethodInfo))]
@@ -95,6 +122,23 @@ public void ReferenceHandlePreserve_Enabled_ThrowsNotSupportedException()
95122
Assert.Contains("ReferenceHandler.Preserve", ex.Message);
96123
}
97124

125+
[Theory]
126+
[InlineData(false)]
127+
[InlineData(true)]
128+
public void JsonSchemaExporterOptions_DefaultSettings(bool useSingleton)
129+
{
130+
JsonSchemaExporterOptions options = useSingleton ? JsonSchemaExporterOptions.Default : new();
131+
132+
Assert.False(options.TreatNullObliviousAsNonNullable);
133+
Assert.Null(options.TransformSchemaNode);
134+
}
135+
136+
[Fact]
137+
public void JsonSchemaExporterOptions_Default_IsSame()
138+
{
139+
Assert.Same(JsonSchemaExporterOptions.Default, JsonSchemaExporterOptions.Default);
140+
}
141+
98142
protected void AssertValidJsonSchema(Type type, string expectedJsonSchema, JsonNode actualJsonSchema)
99143
{
100144
JsonNode? expectedJsonSchemaNode = JsonNode.Parse(expectedJsonSchema, documentOptions: new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });

0 commit comments

Comments
 (0)