Generated the JSON contracts of the settings, chats, and SWOT analyses at compile time

This commit is contained in:
Thorsten Sommer committed 2026-11-01 21:09:35 +01:00
1 parent 7b65d96cd8
commit 620afd1611
14 files changed
+180 -26

No files matched your search

@@ -10,13 +10,22 @@ internal static class SwotAnalysisContentPresentation
/// Fails the reading of stored data which holds a null where the result expects a value, so that
/// the result view never meets one.
/// </summary>
internal static readonly JsonSerializerOptions JSON_OPTIONS = new()
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
{
RespectNullableAnnotations = true,
};
TypeInfoResolver = SwotAnalysisJsonContext.Default,
}.AsReadOnly();
/// <summary>
/// Writes the analysis with the defaults of System.Text.Json, which let a null pass, unlike the reading.
/// </summary>
internal static readonly JsonSerializerOptions WRITE_OPTIONS = new JsonSerializerOptions
{
TypeInfoResolver = SwotAnalysisJsonContext.Default,
}.AsReadOnly();
public static ContentPresentation Create(SwotAnalysisResult result) =>
ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, result);
ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, result, WRITE_OPTIONS);
/// <summary>
/// Reads the SWOT analysis stored as the presentation of a text, so it can be shown as a matrix.
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace AIStudio.Assistants.SWOTAnalysis;
/// <summary>
/// The contracts with which System.Text.Json reads and writes a SWOT analysis which a chat stores, built at
/// compile time.
/// </summary>
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
[JsonSerializable(typeof(SwotAnalysisResult))]
internal sealed partial class SwotAnalysisJsonContext : JsonSerializerContext;
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using ERIContentType = AIStudio.Tools.ERIClient.DataModel.ContentType;
namespace AIStudio.Chat;
/// <summary>
/// The contracts with which System.Text.Json reads and writes chats, built at compile time.
/// </summary>
/// <remarks>
/// Two types with the same name need a name of their own for their contract, else the generator builds
/// only the first of them and warns with SYSLIB1031.
/// </remarks>
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
[JsonSerializable(typeof(ChatThread))]
[JsonSerializable(typeof(ERIContentType), TypeInfoPropertyName = "ERIContentType")]
internal sealed partial class ChatJsonContext : JsonSerializerContext;
@@ -33,10 +33,10 @@ public sealed class ContentPresentation
}
}
internal static ContentPresentation Create<T>(ContentPresentationKind kind, T data) => new()
internal static ContentPresentation Create<T>(ContentPresentationKind kind, T data, JsonSerializerOptions options) => new()
{
Kind = kind,
Data = JsonSerializer.SerializeToElement(data),
Data = JsonSerializer.SerializeToElement(data, options),
};
internal ContentPresentation DeepClone() => new()
@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Settings.DataModel.PreviousModels;
using EMailWritingStyles = AIStudio.Assistants.EMail.WritingStyles;
using ERIContentType = AIStudio.Tools.ERIClient.DataModel.ContentType;
using ProviderV4 = AIStudio.Settings.DataModel.PreviousModels.Provider;
using RewriteImproveWritingStyles = AIStudio.Assistants.RewriteImprove.WritingStyles;
namespace AIStudio.Settings;
/// <summary>
/// The contracts with which System.Text.Json reads and writes the settings, built at compile time.
/// </summary>
/// <remarks>
/// Besides the settings and their older versions, which the migrations still read, the values of managed
/// settings get written and read on their own, as the type of their setting. That type is often an interface,
/// such as ISet&lt;string&gt;, which the settings themselves never use. A test fails for a managed setting
/// whose type is missing here.<br/><br/>
///
/// Two types with the same name need a name of their own for their contract, else the generator builds
/// only the first of them and warns with SYSLIB1031.
/// </remarks>
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
[JsonSerializable(typeof(Data))]
[JsonSerializable(typeof(DataV1V3))]
[JsonSerializable(typeof(DataV4))]
[JsonSerializable(typeof(DataV5))]
[JsonSerializable(typeof(ISet<string>))]
[JsonSerializable(typeof(ISet<PreviewFeatures>))]
[JsonSerializable(typeof(ISet<LLMProviders>))]
[JsonSerializable(typeof(ISet<ConfigurableAssistant>))]
[JsonSerializable(typeof(IList<string>))]
[JsonSerializable(typeof(IList<DataAssistantPluginEnterpriseApproval>))]
[JsonSerializable(typeof(IDictionary<string, string>))]
[JsonSerializable(typeof(ProviderV4), TypeInfoPropertyName = "ProviderV4")]
[JsonSerializable(typeof(List<ProviderV4>), TypeInfoPropertyName = "ListProviderV4")]
[JsonSerializable(typeof(EMailWritingStyles), TypeInfoPropertyName = "EMailWritingStyles")]
[JsonSerializable(typeof(RewriteImproveWritingStyles), TypeInfoPropertyName = "RewriteImproveWritingStyles")]
[JsonSerializable(typeof(ERIContentType), TypeInfoPropertyName = "ERIContentType")]
internal sealed partial class SettingsJsonContext : JsonSerializerContext;
@@ -26,11 +26,12 @@ public sealed class SettingsManager
private readonly record struct CurrentSettingsReadResult(Data? SettingsData, SettingsWriteBlockReason FailureReason);
internal static readonly JsonSerializerOptions JSON_OPTIONS = new()
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
{
WriteIndented = true,
Converters = { new TolerantEnumConverter() },
};
TypeInfoResolver = SettingsJsonContext.Default,
}.AsReadOnly();
/// <summary>
/// From this duration on, storing the settings is reported as slow.
@@ -0,0 +1,21 @@
using System.Text.Json;
namespace AIStudio.Tools;
public static class JsonSerializerOptionsExtensions
{
/// <summary>
/// Makes the options read-only and returns them, so that a static field gets its options in one expression.
/// </summary>
/// <remarks>
/// Options with contracts of their own, i.e., with a TypeInfoResolver, should never change after they were
/// declared: whoever used them before such a change would have read or written with other contracts.
/// </remarks>
/// <param name="options">The options, with their TypeInfoResolver set.</param>
/// <returns>The same options, now read-only.</returns>
public static JsonSerializerOptions AsReadOnly(this JsonSerializerOptions options)
{
options.MakeReadOnly();
return options;
}
}
@@ -63,7 +63,7 @@ public static partial class WorkspaceBehaviour
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour));
public static readonly JsonSerializerOptions JSON_OPTIONS = new()
public static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
{
WriteIndented = true,
AllowTrailingCommas = true,
@@ -73,8 +73,9 @@ public static partial class WorkspaceBehaviour
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper),
}
};
},
TypeInfoResolver = ChatJsonContext.Default,
}.AsReadOnly();
private static readonly TimeSpan PREFETCH_DELAY_DURATION = TimeSpan.FromMilliseconds(45);
@@ -54,7 +54,7 @@ public sealed class SwotAnalysisContentPresentationTests
var content = new ContentText
{
Text = "Fallback Markdown",
Presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, "not a SWOT result"),
Presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, "not a SWOT result", JsonSerializerOptions.Default),
};
var presentationWasRead = SwotAnalysisContentPresentation.TryRead(content.Presentation, out _);
+2 -2
View File
@@ -10,7 +10,7 @@ public sealed class ContentPresentationTests
[Test]
public void TheDataHashCodeFollowsNewData()
{
var presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, new { heading = "First" });
var presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, new { heading = "First" }, JsonSerializerOptions.Default);
var firstHashCode = presentation.GetDataHashCode();
presentation.Data = JsonSerializer.SerializeToElement(new { heading = "Second" });
@@ -33,7 +33,7 @@ public sealed class ContentPresentationTests
[Test]
public void ACloneHasTheSameDataHashCode()
{
var presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, new { heading = "First" });
var presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, new { heading = "First" }, JsonSerializerOptions.Default);
Assert.That(presentation.DeepClone().GetDataHashCode(), Is.EqualTo(presentation.GetDataHashCode()));
}
@@ -17,8 +17,20 @@ public static class JsonContractDescription
/// <returns>One line for the type, and one line per property, polymorphism, and callback.</returns>
public static string Describe(JsonTypeInfo typeInfo)
{
//
// For a struct, reflection takes the default of the struct and sets every property, while source generation
// passes the values to the constructor of a positional record struct. Both give the same struct, as long as
// no parameter has a default value of its own, which JsonDefaultsTests checks. A class with required
// properties is alike: source generation sets them while it creates the object, reflection right after.
// Hence, the description leaves out how such a type gets created:
//
var isCreatedWithItsValues = typeInfo.Kind is JsonTypeInfoKind.Object && (typeInfo.Type.IsValueType || typeInfo.Properties.Any(property => property.IsRequired));
var creation = isCreatedWithItsValues
? "created with its values"
: $"converter {ConverterName(typeInfo.Converter)}, created {(typeInfo.CreateObject is null ? "otherwise" : "without arguments")}";
var text = new StringBuilder();
text.AppendLine($"{TypeName(typeInfo.Type)}: {typeInfo.Kind}, converter {ConverterName(typeInfo.Converter)}, created {(typeInfo.CreateObject is null ? "otherwise" : "without arguments")}");
text.AppendLine($"{TypeName(typeInfo.Type)}: {typeInfo.Kind}, {creation}");
text.AppendLine($" handling: numbers {typeInfo.NumberHandling}, unmapped members {typeInfo.UnmappedMemberHandling}, object creation {typeInfo.PreferredPropertyObjectCreationHandling}");
text.AppendLine($" callbacks: serializing {typeInfo.OnSerializing is not null}, serialized {typeInfo.OnSerialized is not null}, deserializing {typeInfo.OnDeserializing is not null}, deserialized {typeInfo.OnDeserialized is not null}");
if (typeInfo.KeyType is { } keyType)
@@ -35,11 +47,32 @@ public static class JsonContractDescription
}
foreach (var property in typeInfo.Properties)
text.AppendLine($" property {property.Name}: {TypeName(property.PropertyType)}, order {property.Order}, get {property.Get is not null}, set {property.Set is not null}, required {property.IsRequired}, extension data {property.IsExtensionData}, conditional {property.ShouldSerialize is not null}, converter {ConverterName(property.CustomConverter)}, constructor parameter {property.AssociatedParameter?.Name ?? "none"}, nullable get {property.IsGetNullable}, nullable set {property.IsSetNullable}, numbers {property.NumberHandling}, object creation {property.ObjectCreationHandling}");
{
//
// An ignored property keeps its name in both contracts, but only reflection keeps its type:
//
if (IsIgnored(property))
{
text.AppendLine($" property {property.Name}: ignored");
continue;
}
var setting = isCreatedWithItsValues
?$"set {property.Set is not null || property.AssociatedParameter is not null}"
: $"set {property.Set is not null}, constructor parameter {property.AssociatedParameter?.Name ?? "none"}";
text.AppendLine($" property {property.Name}: {TypeName(property.PropertyType)}, order {property.Order}, get {property.Get is not null}, {setting}, required {property.IsRequired}, extension data {property.IsExtensionData}, conditional {property.ShouldSerialize is not null}, converter {ConverterName(property.CustomConverter)}, nullable get {property.IsGetNullable}, nullable set {property.IsSetNullable}, numbers {property.NumberHandling}, object creation {property.ObjectCreationHandling}");
}
return text.ToString();
}
/// <summary>
/// Whether System.Text.Json neither writes nor reads a property, e.g., one marked with JsonIgnore.
/// </summary>
/// <param name="property">The property of a contract.</param>
public static bool IsIgnored(JsonPropertyInfo property) => property is { Get: null, Set: null, AssociatedParameter: null };
/// <summary>
/// Returns the name of a type the way C# writes it, e.g., Dictionary&lt;String, Int32&gt;.
/// </summary>
@@ -52,8 +85,19 @@ public static class JsonContractDescription
}
//
// Reflection reads the constructor parameters of a record through converters of its own, whose names end
// with "WithReflection". Source generation does the same with the plain ones, which read the same JSON:
// Reflection reads the constructor parameters of a record through converters of its own: one for up to four
// parameters, and others whose names end with "WithReflection". Source generation reads them all with the
// converter for many parameters, which reads the same JSON. The same goes for immutable collections:
//
private static string ConverterName(JsonConverter? converter) => converter is null ? "none" : TypeName(converter.GetType()).Replace("WithReflection", string.Empty, StringComparison.Ordinal);
private static string ConverterName(JsonConverter? converter)
{
if (converter is null)
return "none";
var name = converter.GetType().Name;
if (name.Contains("ObjectWithParameterizedConstructorConverter", StringComparison.Ordinal))
return $"ObjectWithParameterizedConstructorConverter<{TypeName(converter.Type!)}>";
return TypeName(converter.GetType()).Replace("WithReflection", string.Empty, StringComparison.Ordinal);
}
}
+8 -3
View File
@@ -11,7 +11,11 @@ namespace AIStudio.Tests.Serialization;
/// those properties their defaults, as a new object has them, e.g., a sub-object of the settings instead of
/// null. Source generation in .NET 10 breaks this for init-only properties: it sets them all while creating
/// the object, and a property missing in the JSON gets the default of its type instead (dotnet/runtime#84484).
/// Hence, the types AI Studio stores use set instead of init.
/// Hence, the types AI Studio stores use set instead of init.<br/><br/>
///
/// A new object is created the way reflection creates it: with its parameterless constructor, or, for a
/// struct, as the default of the struct. Source generation passes the values of a positional record struct
/// to its constructor instead, where a parameter with a default value of its own would differ.
/// </remarks>
[TestFixture]
public sealed class JsonDefaultsTests
@@ -19,15 +23,16 @@ public sealed class JsonDefaultsTests
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))]
public void AnEmptyObjectGetsTheDefaultsOfANewOne(JsonTestRoot root)
{
var baseline = JsonReflectionBaseline.Of(root.Options);
var failures = new List<string>();
foreach (var type in JsonTypeGraph.Collect(root.Type, root.Options))
{
//
// A type which gets created through its constructor receives every value as an argument, so nothing
// A class which gets created through its constructor receives every value as an argument, so nothing
// gets lost there. A required property refuses an empty object anyway:
//
var typeInfo = root.Options.GetTypeInfo(type);
if (typeInfo is not { Kind: JsonTypeInfoKind.Object, CreateObject: { } createObject } || typeInfo.Properties.Any(property => property.IsRequired))
if (typeInfo.Kind is not JsonTypeInfoKind.Object || baseline.GetTypeInfo(type).CreateObject is not { } createObject || typeInfo.Properties.Any(property => property.IsRequired))
continue;
var newObject = createObject();
+4 -3
View File
@@ -44,7 +44,8 @@ public static class JsonTestRoots
yield return Case(new(valueType, SettingsManager.JSON_OPTIONS));
// The SWOT analysis, which a chat stores as the presentation of a message:
yield return Case(new(typeof(SwotAnalysisResult), SwotAnalysisContentPresentation.JSON_OPTIONS));
yield return Case(new(typeof(SwotAnalysisResult), SwotAnalysisContentPresentation.JSON_OPTIONS), "read");
yield return Case(new(typeof(SwotAnalysisResult), SwotAnalysisContentPresentation.WRITE_OPTIONS), "written");
// Backups and their restore:
yield return Case(new(typeof(DataBackupManifest), DataBackupFormat.JSON_OPTIONS));
@@ -56,14 +57,14 @@ public static class JsonTestRoots
yield return Case(new(typeof(List<string>), SqliteProvenanceStoreClientImplementation.JSON_OPTIONS));
}
private static TestCaseData Case(JsonTestRoot root)
private static TestCaseData Case(JsonTestRoot root, string? purpose = null)
{
//
// The same System.Text.Json does with the first call of Serialize or Deserialize. Only then, the
// options hand out contracts:
//
root.Options.MakeReadOnly(populateMissingResolver: true);
return new TestCaseData(root).SetArgDisplayNames(root.ToString());
return new TestCaseData(root).SetArgDisplayNames(purpose is null ? root.ToString() : $"{root}, {purpose}");
}
private static IEnumerable<Type> ManagedSettingValueTypes()
+3 -2
View File
@@ -9,7 +9,8 @@ public static class JsonTypeGraph
{
/// <summary>
/// Returns the root and every type it holds: the types of its properties, the keys and elements of its
/// collections, and its derived types, each of them once and in the order they were met.
/// collections, and its derived types, each of them once and in the order they were met. The type of an
/// ignored property doesn't count, since System.Text.Json never meets it.
/// </summary>
/// <param name="root">The type to start with.</param>
/// <param name="options">The options whose contracts tell what a type holds.</param>
@@ -27,7 +28,7 @@ public static class JsonTypeGraph
types.Add(type);
var typeInfo = options.GetTypeInfo(type);
foreach (var property in typeInfo.Properties)
foreach (var property in typeInfo.Properties.Where(property => !JsonContractDescription.IsIgnored(property)))
Visit(property.PropertyType, options, visited, types);
if (typeInfo.KeyType is { } keyType)