Skip to content

Commit

Permalink
Add TreatNullObliviousAsNonNullable setting.
Browse files Browse the repository at this point in the history
  • Loading branch information
eiriktsarpalis committed Jun 14, 2024
1 parent af194e5 commit d5f9a62
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 3 deletions.
3 changes: 2 additions & 1 deletion src/libraries/System.Text.Json/ref/System.Text.Json.cs
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,8 @@ public sealed partial class JsonSchemaExporterOptions
{
public JsonSchemaExporterOptions() { }
public static System.Text.Json.Schema.JsonSchemaExporterOptions Default { get { throw null; } }
public System.Func<JsonSchemaExporterContext, System.Text.Json.Nodes.JsonNode, System.Text.Json.Nodes.JsonNode>? TransformSchemaNode { get; init; }
public System.Func<JsonSchemaExporterContext, System.Text.Json.Nodes.JsonNode, System.Text.Json.Nodes.JsonNode>? TransformSchemaNode { get { throw null; } init { } }
public bool TreatNullObliviousAsNonNullable { get { throw null; } init { } }
}
}
namespace System.Text.Json.Serialization
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema)
{
bool isNullableSchema = propertyInfo != null
? propertyInfo.IsGetNullable || propertyInfo.IsSetNullable
: typeInfo.CanBeNull && !parentPolymorphicTypeIsNonNullable;
: typeInfo.CanBeNull && !parentPolymorphicTypeIsNonNullable && !state.ExporterOptions.TreatNullObliviousAsNonNullable;

if (isNullableSchema)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ public sealed class JsonSchemaExporterOptions
/// </summary>
public static JsonSchemaExporterOptions Default { get; } = new();

/// <summary>
/// Determines whether non-nullable schemas should be generated for null oblivious reference types.
/// </summary>
/// <remarks>
/// Defaults to <see langword="false"/>. Due to restrictions in the run-time representation of nullable reference types
/// most occurences are null oblivious and are treated as nullable by the serializer. A notable exception to that rule
/// are nullability annotations of field, property and constructor parameters which are represented in the contract metadata.
/// </remarks>
public bool TreatNullObliviousAsNonNullable { get; init; }

/// <summary>
/// Defines a callback that is invoked for every schema that is generated within the type graph.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,27 @@ public static IEnumerable<ITestData> GetTestDataCore()
}
""");

// Same as above with non-nullable reference type handling
yield return new TestData<PocoWithRecursiveMembers>(
Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } },
AdditionalValues: [new() { Value = 1, Next = null }],
ExpectedJsonSchema: """
{
"type": "object",
"properties": {
"Value": { "type": "integer" },
"Next": {
"type": ["object", "null"],
"properties": {
"Value": { "type": "integer" },
"Next": { "$ref": "#/properties/Next" }
}
}
}
}
""",
Options: new() { TreatNullObliviousAsNonNullable = true });

// Same as above but using an anchor-based reference scheme
yield return new TestData<PocoWithRecursiveMembers>(
Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } },
Expand Down Expand Up @@ -398,6 +419,22 @@ public static IEnumerable<ITestData> GetTestDataCore()
}
""");

// Same as above but with non-nullable reference type handling
yield return new TestData<PocoWithRecursiveCollectionElement>(
Value: new() { Children = [new(), new() { Children = [] }] },
ExpectedJsonSchema: """
{
"type": "object",
"properties": {
"Children": {
"type": "array",
"items": { "$ref" : "#" }
}
}
}
""",
Options: new() { TreatNullObliviousAsNonNullable = true });

yield return new TestData<PocoWithRecursiveDictionaryValue>(
Value: new() { Children = new() { ["key1"] = new(), ["key2"] = new() { Children = new() { ["key3"] = new() } } } },
ExpectedJsonSchema: """
Expand All @@ -412,6 +449,22 @@ public static IEnumerable<ITestData> GetTestDataCore()
}
""");

// Same as above but with non-nullable reference type handling
yield return new TestData<PocoWithRecursiveDictionaryValue>(
Value: new() { Children = new() { ["key1"] = new(), ["key2"] = new() { Children = new() { ["key3"] = new() } } } },
ExpectedJsonSchema: """
{
"type": "object",
"properties": {
"Children": {
"type": "object",
"additionalProperties": { "$ref" : "#" }
}
}
}
""",
Options: new() { TreatNullObliviousAsNonNullable = true });

yield return new TestData<PocoWithDescription>(
Value: new() { X = 42 },
ExpectedJsonSchema: """
Expand Down Expand Up @@ -1390,7 +1443,7 @@ IEnumerable<ITestData> ITestData.GetTestDataForAllValues()
{
yield return this;

if (default(T) is null)
if (default(T) is null && Options?.TreatNullObliviousAsNonNullable != true)
{
yield return this with { Value = default, AdditionalValues = null };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,33 @@ public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData)
AssertDocumentMatchesSchema(schema, instance);
}

[Theory]
[InlineData(typeof(string), "string")]
[InlineData(typeof(int[]), "array")]
[InlineData(typeof(Dictionary<string, int>), "object")]
[InlineData(typeof(SimplePoco), "object")]
public void TreatNullObliviousAsNonNullable_False_MarksReferenceTypesAsNullable(Type referenceType, string expectedType)
{
Assert.True(!referenceType.IsValueType);
var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = false };
JsonNode schema = Serializer.DefaultOptions.GetJsonSchemaAsNode(referenceType, config);
JsonArray arr = Assert.IsType<JsonArray>(schema["type"]);
Assert.Equal([expectedType, "null"], arr.Select(e => (string)e!));
}

[Theory]
[InlineData(typeof(string), "string")]
[InlineData(typeof(int[]), "array")]
[InlineData(typeof(Dictionary<string, int>), "object")]
[InlineData(typeof(SimplePoco), "object")]
public void TreatNullObliviousAsNonNullable_True_MarksReferenceTypesAsNonNullable(Type referenceType, string expectedType)
{
Assert.True(!referenceType.IsValueType);
var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true };
JsonNode schema = Serializer.DefaultOptions.GetJsonSchemaAsNode(referenceType, config);
Assert.Equal(expectedType, (string)schema["type"]!);
}

[Theory]
[InlineData(typeof(Type))]
[InlineData(typeof(MethodInfo))]
Expand Down Expand Up @@ -95,6 +122,23 @@ public void ReferenceHandlePreserve_Enabled_ThrowsNotSupportedException()
Assert.Contains("ReferenceHandler.Preserve", ex.Message);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public void JsonSchemaExporterOptions_DefaultSettings(bool useSingleton)
{
JsonSchemaExporterOptions options = useSingleton ? JsonSchemaExporterOptions.Default : new();

Assert.False(options.TreatNullObliviousAsNonNullable);
Assert.Null(options.TransformSchemaNode);
}

[Fact]
public void JsonSchemaExporterOptions_Default_IsSame()
{
Assert.Same(JsonSchemaExporterOptions.Default, JsonSchemaExporterOptions.Default);
}

protected void AssertValidJsonSchema(Type type, string expectedJsonSchema, JsonNode actualJsonSchema)
{
JsonNode? expectedJsonSchemaNode = JsonNode.Parse(expectedJsonSchema, documentOptions: new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
Expand Down

0 comments on commit d5f9a62

Please sign in to comment.