Skip to content

Commit a0ca1b9

Browse files
eiriktsarpalispull[bot]
authored andcommitted
Make the schema generator reuse algorithm more aggressive to reduce generated schema size. (#108764)
1 parent 4475825 commit a0ca1b9

File tree

5 files changed

+113
-41
lines changed

5 files changed

+113
-41
lines changed

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

Lines changed: 37 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Diagnostics;
66
using System.Diagnostics.CodeAnalysis;
77
using System.Globalization;
8+
using System.Runtime.InteropServices;
89
using System.Text.Json.Nodes;
910
using System.Text.Json.Serialization;
1011
using System.Text.Json.Serialization.Metadata;
@@ -76,9 +77,12 @@ private static JsonSchema MapJsonSchemaCore(
7677
{
7778
Debug.Assert(typeInfo.IsConfigured);
7879

79-
if (cacheResult && state.TryPushType(typeInfo, propertyInfo, out string? existingJsonPointer))
80+
JsonSchemaExporterContext exporterContext = state.CreateContext(typeInfo, propertyInfo, parentPolymorphicTypeInfo);
81+
82+
if (cacheResult && typeInfo.Kind is not JsonTypeInfoKind.None &&
83+
state.TryGetExistingJsonPointer(exporterContext, out string? existingJsonPointer))
8084
{
81-
// We're generating the schema of a recursive type, return a reference pointing to the outermost schema.
85+
// The schema context has already been generated in the schema document, return a reference to it.
8286
return CompleteSchema(ref state, new JsonSchema { Ref = existingJsonPointer });
8387
}
8488

@@ -364,17 +368,12 @@ JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema)
364368
{
365369
schema.MakeNullable();
366370
}
367-
368-
if (cacheResult)
369-
{
370-
state.PopGeneratedType();
371-
}
372371
}
373372

374373
if (state.ExporterOptions.TransformSchemaNode != null)
375374
{
376375
// Prime the schema for invocation by the JsonNode transformer.
377-
schema.ExporterContext = state.CreateContext(typeInfo, propertyInfo, parentPolymorphicTypeInfo);
376+
schema.ExporterContext = exporterContext;
378377
}
379378

380379
return schema;
@@ -409,7 +408,7 @@ private static bool IsPolymorphicTypeThatSpecifiesItselfAsDerivedType(JsonTypeIn
409408
private readonly ref struct GenerationState(JsonSerializerOptions options, JsonSchemaExporterOptions exporterOptions)
410409
{
411410
private readonly List<string> _currentPath = [];
412-
private readonly List<(JsonTypeInfo typeInfo, JsonPropertyInfo? propertyInfo, int depth)> _generationStack = [];
411+
private readonly Dictionary<(JsonTypeInfo, JsonPropertyInfo?), string[]> _generated = new();
413412

414413
public int CurrentDepth => _currentPath.Count;
415414
public JsonSerializerOptions Options { get; } = options;
@@ -432,77 +431,75 @@ public void PopSchemaNode()
432431
}
433432

434433
/// <summary>
435-
/// Pushes the current type/property to the generation stack or returns a JSON pointer if the type is recursive.
434+
/// Registers the current schema node generation context; if it has already been generated return a JSON pointer to its location.
436435
/// </summary>
437-
public bool TryPushType(JsonTypeInfo typeInfo, JsonPropertyInfo? propertyInfo, [NotNullWhen(true)] out string? existingJsonPointer)
436+
public bool TryGetExistingJsonPointer(in JsonSchemaExporterContext context, [NotNullWhen(true)] out string? existingJsonPointer)
438437
{
439-
foreach ((JsonTypeInfo otherTypeInfo, JsonPropertyInfo? otherPropertyInfo, int depth) in _generationStack)
438+
(JsonTypeInfo TypeInfo, JsonPropertyInfo? PropertyInfo) key = (context.TypeInfo, context.PropertyInfo);
439+
#if NET
440+
ref string[]? pathToSchema = ref CollectionsMarshal.GetValueRefOrAddDefault(_generated, key, out bool exists);
441+
#else
442+
bool exists = _generated.TryGetValue(key, out string[]? pathToSchema);
443+
#endif
444+
if (exists)
440445
{
441-
if (typeInfo == otherTypeInfo && propertyInfo == otherPropertyInfo)
442-
{
443-
existingJsonPointer = FormatJsonPointer(_currentPath, depth);
444-
return true;
445-
}
446+
existingJsonPointer = FormatJsonPointer(pathToSchema);
447+
return true;
446448
}
447-
448-
_generationStack.Add((typeInfo, propertyInfo, CurrentDepth));
449+
#if NET
450+
pathToSchema = context._path;
451+
#else
452+
_generated[key] = context._path;
453+
#endif
449454
existingJsonPointer = null;
450455
return false;
451456
}
452457

453-
public void PopGeneratedType()
454-
{
455-
Debug.Assert(_generationStack.Count > 0);
456-
_generationStack.RemoveAt(_generationStack.Count - 1);
457-
}
458-
459458
public JsonSchemaExporterContext CreateContext(JsonTypeInfo typeInfo, JsonPropertyInfo? propertyInfo, JsonTypeInfo? baseTypeInfo)
460459
{
461-
return new JsonSchemaExporterContext(typeInfo, propertyInfo, baseTypeInfo, _currentPath.ToArray());
460+
return new JsonSchemaExporterContext(typeInfo, propertyInfo, baseTypeInfo, [.. _currentPath]);
462461
}
463462

464-
private static string FormatJsonPointer(List<string> currentPathList, int depth)
463+
private static string FormatJsonPointer(ReadOnlySpan<string> path)
465464
{
466-
Debug.Assert(0 <= depth && depth < currentPathList.Count);
467-
468-
if (depth == 0)
465+
if (path.IsEmpty)
469466
{
470467
return "#";
471468
}
472469

473-
using ValueStringBuilder sb = new(initialCapacity: depth * 10);
470+
using ValueStringBuilder sb = new(initialCapacity: path.Length * 10);
474471
sb.Append('#');
475472

476-
for (int i = 0; i < depth; i++)
473+
foreach (string segment in path)
477474
{
478-
ReadOnlySpan<char> segment = currentPathList[i].AsSpan();
475+
ReadOnlySpan<char> span = segment.AsSpan();
479476
sb.Append('/');
480477

481478
do
482479
{
483480
// Per RFC 6901 the characters '~' and '/' must be escaped.
484-
int pos = segment.IndexOfAny('~', '/');
481+
int pos = span.IndexOfAny('~', '/');
485482
if (pos < 0)
486483
{
487-
sb.Append(segment);
484+
sb.Append(span);
488485
break;
489486
}
490487

491-
sb.Append(segment.Slice(0, pos));
488+
sb.Append(span.Slice(0, pos));
492489

493-
if (segment[pos] == '~')
490+
if (span[pos] == '~')
494491
{
495492
sb.Append("~0");
496493
}
497494
else
498495
{
499-
Debug.Assert(segment[pos] == '/');
496+
Debug.Assert(span[pos] == '/');
500497
sb.Append("~1");
501498
}
502499

503-
segment = segment.Slice(pos + 1);
500+
span = span.Slice(pos + 1);
504501
}
505-
while (!segment.IsEmpty);
502+
while (!span.IsEmpty);
506503
}
507504

508505
return sb.ToString();

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace System.Text.Json.Schema
1010
/// </summary>
1111
public readonly struct JsonSchemaExporterContext
1212
{
13-
private readonly string[] _path;
13+
internal readonly string[] _path;
1414

1515
internal JsonSchemaExporterContext(
1616
JsonTypeInfo typeInfo,

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,62 @@ public static IEnumerable<ITestData> GetTestDataCore()
469469
""",
470470
Options: new() { TreatNullObliviousAsNonNullable = true });
471471

472+
SimpleRecord recordValue = new(42, "str", true, 3.14);
473+
yield return new TestData<PocoWithNonRecursiveDuplicateOccurrences>(
474+
Value: new() { Value1 = recordValue, Value2 = recordValue, ArrayValue = [recordValue], ListValue = [recordValue] },
475+
ExpectedJsonSchema: """
476+
{
477+
"type": ["object","null"],
478+
"properties": {
479+
"Value1": {
480+
"type": "object",
481+
"properties": {
482+
"X": { "type": "integer" },
483+
"Y": { "type": "string" },
484+
"Z": { "type": "boolean" },
485+
"W": { "type": "number" }
486+
},
487+
"required": ["X", "Y", "Z", "W"]
488+
},
489+
/* The same type on a different property is repeated to
490+
account for potential metadata resolved from attributes. */
491+
"Value2": {
492+
"type": "object",
493+
"properties": {
494+
"X": { "type": "integer" },
495+
"Y": { "type": "string" },
496+
"Z": { "type": "boolean" },
497+
"W": { "type": "number" }
498+
},
499+
"required": ["X", "Y", "Z", "W"]
500+
},
501+
/* This collection element is the first occurrence
502+
of the type without contextual metadata. */
503+
"ListValue": {
504+
"type": "array",
505+
"items": {
506+
"type": ["object","null"],
507+
"properties": {
508+
"X": { "type": "integer" },
509+
"Y": { "type": "string" },
510+
"Z": { "type": "boolean" },
511+
"W": { "type": "number" }
512+
},
513+
"required": ["X", "Y", "Z", "W"]
514+
}
515+
},
516+
/* This collection element is the second occurrence
517+
of the type which points to the first occurrence. */
518+
"ArrayValue": {
519+
"type": "array",
520+
"items": {
521+
"$ref": "#/properties/ListValue/items"
522+
}
523+
}
524+
}
525+
}
526+
""");
527+
472528
yield return new TestData<PocoWithDescription>(
473529
Value: new() { X = 42 },
474530
ExpectedJsonSchema: """
@@ -1226,6 +1282,14 @@ public class PocoWithRecursiveDictionaryValue
12261282
public Dictionary<string, PocoWithRecursiveDictionaryValue> Children { get; init; } = new();
12271283
}
12281284

1285+
public class PocoWithNonRecursiveDuplicateOccurrences
1286+
{
1287+
public SimpleRecord Value1 { get; set; }
1288+
public SimpleRecord Value2 { get; set; }
1289+
public List<SimpleRecord> ListValue { get; set; }
1290+
public SimpleRecord[] ArrayValue { get; set; }
1291+
}
1292+
12291293
[Description("The type description")]
12301294
public class PocoWithDescription
12311295
{

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Text.Json.Serialization;
1010
using System.Text.Json.Serialization.Metadata;
1111
using System.Text.Json.Serialization.Tests;
12+
using System.Xml.Linq;
1213
using Json.Schema;
1314
using Xunit;
1415
using Xunit.Sdk;
@@ -90,6 +91,13 @@ public void UnsupportedType_ReturnsExpectedSchema(Type type)
9091
Assert.Equal(""""{"$comment":"Unsupported .NET type","not":true}"""", schema.ToJsonString());
9192
}
9293

94+
[Fact]
95+
public void CanGenerateXElementSchema()
96+
{
97+
JsonNode schema = Serializer.DefaultOptions.GetJsonSchemaAsNode(typeof(XElement));
98+
Assert.True(schema.ToJsonString().Length < 100_000);
99+
}
100+
93101
[Fact]
94102
public void TypeWithDisallowUnmappedMembers_AdditionalPropertiesFailValidation()
95103
{

src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSchemaExporterTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Text.Json.Nodes;
1010
using System.Text.Json.Schema.Tests;
1111
using System.Text.Json.Serialization;
12+
using System.Xml.Linq;
1213

1314
namespace System.Text.Json.SourceGeneration.Tests
1415
{
@@ -88,6 +89,7 @@ public sealed partial class JsonSchemaExporterTests_SourceGen()
8889
[JsonSerializable(typeof(PocoWithRecursiveMembers))]
8990
[JsonSerializable(typeof(PocoWithRecursiveCollectionElement))]
9091
[JsonSerializable(typeof(PocoWithRecursiveDictionaryValue))]
92+
[JsonSerializable(typeof(PocoWithNonRecursiveDuplicateOccurrences))]
9193
[JsonSerializable(typeof(PocoWithDescription))]
9294
[JsonSerializable(typeof(PocoWithCustomConverter))]
9395
[JsonSerializable(typeof(PocoWithCustomPropertyConverter))]
@@ -125,6 +127,7 @@ public sealed partial class JsonSchemaExporterTests_SourceGen()
125127
[JsonSerializable(typeof(Dictionary<string, object>))]
126128
[JsonSerializable(typeof(Hashtable))]
127129
[JsonSerializable(typeof(StructDictionary<string, int>))]
130+
[JsonSerializable(typeof(XElement))]
128131
public partial class TestTypesContext : JsonSerializerContext;
129132
}
130133
}

0 commit comments

Comments
 (0)