diff --git a/app/MindWork AI Studio/Assistants/SWOTAnalysis/SwotAnalysisContentPresentation.cs b/app/MindWork AI Studio/Assistants/SWOTAnalysis/SwotAnalysisContentPresentation.cs index aa4307a92..926706a8f 100644 --- a/app/MindWork AI Studio/Assistants/SWOTAnalysis/SwotAnalysisContentPresentation.cs +++ b/app/MindWork AI Studio/Assistants/SWOTAnalysis/SwotAnalysisContentPresentation.cs @@ -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. /// - internal static readonly JsonSerializerOptions JSON_OPTIONS = new() + internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions { RespectNullableAnnotations = true, - }; + TypeInfoResolver = SwotAnalysisJsonContext.Default, + }.AsReadOnly(); + + /// + /// Writes the analysis with the defaults of System.Text.Json, which let a null pass, unlike the reading. + /// + 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); /// /// Reads the SWOT analysis stored as the presentation of a text, so it can be shown as a matrix. diff --git a/app/MindWork AI Studio/Assistants/SWOTAnalysis/SwotAnalysisJsonContext.cs b/app/MindWork AI Studio/Assistants/SWOTAnalysis/SwotAnalysisJsonContext.cs new file mode 100644 index 000000000..09863024f --- /dev/null +++ b/app/MindWork AI Studio/Assistants/SWOTAnalysis/SwotAnalysisJsonContext.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Assistants.SWOTAnalysis; + +/// +/// The contracts with which System.Text.Json reads and writes a SWOT analysis which a chat stores, built at +/// compile time. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(SwotAnalysisResult))] +internal sealed partial class SwotAnalysisJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ChatJsonContext.cs b/app/MindWork AI Studio/Chat/ChatJsonContext.cs new file mode 100644 index 000000000..23d333175 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ChatJsonContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +using ERIContentType = AIStudio.Tools.ERIClient.DataModel.ContentType; + +namespace AIStudio.Chat; + +/// +/// The contracts with which System.Text.Json reads and writes chats, built at compile time. +/// +/// +/// 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. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(ChatThread))] +[JsonSerializable(typeof(ERIContentType), TypeInfoPropertyName = "ERIContentType")] +internal sealed partial class ChatJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ContentPresentation.cs b/app/MindWork AI Studio/Chat/ContentPresentation.cs index eca78320b..7c6703379 100644 --- a/app/MindWork AI Studio/Chat/ContentPresentation.cs +++ b/app/MindWork AI Studio/Chat/ContentPresentation.cs @@ -33,10 +33,10 @@ public sealed class ContentPresentation } } - internal static ContentPresentation Create(ContentPresentationKind kind, T data) => new() + internal static ContentPresentation Create(ContentPresentationKind kind, T data, JsonSerializerOptions options) => new() { Kind = kind, - Data = JsonSerializer.SerializeToElement(data), + Data = JsonSerializer.SerializeToElement(data, options), }; internal ContentPresentation DeepClone() => new() diff --git a/app/MindWork AI Studio/Settings/SettingsJsonContext.cs b/app/MindWork AI Studio/Settings/SettingsJsonContext.cs new file mode 100644 index 000000000..089328312 --- /dev/null +++ b/app/MindWork AI Studio/Settings/SettingsJsonContext.cs @@ -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; + +/// +/// The contracts with which System.Text.Json reads and writes the settings, built at compile time. +/// +/// +/// 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<string>, which the settings themselves never use. A test fails for a managed setting +/// whose type is missing here.

+/// +/// 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. +///
+[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(Data))] +[JsonSerializable(typeof(DataV1V3))] +[JsonSerializable(typeof(DataV4))] +[JsonSerializable(typeof(DataV5))] +[JsonSerializable(typeof(ISet))] +[JsonSerializable(typeof(ISet))] +[JsonSerializable(typeof(ISet))] +[JsonSerializable(typeof(ISet))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(ProviderV4), TypeInfoPropertyName = "ProviderV4")] +[JsonSerializable(typeof(List), TypeInfoPropertyName = "ListProviderV4")] +[JsonSerializable(typeof(EMailWritingStyles), TypeInfoPropertyName = "EMailWritingStyles")] +[JsonSerializable(typeof(RewriteImproveWritingStyles), TypeInfoPropertyName = "RewriteImproveWritingStyles")] +[JsonSerializable(typeof(ERIContentType), TypeInfoPropertyName = "ERIContentType")] +internal sealed partial class SettingsJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/SettingsManager.cs b/app/MindWork AI Studio/Settings/SettingsManager.cs index fc4ac224c..3c4d7c418 100644 --- a/app/MindWork AI Studio/Settings/SettingsManager.cs +++ b/app/MindWork AI Studio/Settings/SettingsManager.cs @@ -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(); /// /// From this duration on, storing the settings is reported as slow. diff --git a/app/MindWork AI Studio/Tools/JsonSerializerOptionsExtensions.cs b/app/MindWork AI Studio/Tools/JsonSerializerOptionsExtensions.cs new file mode 100644 index 000000000..d6b198651 --- /dev/null +++ b/app/MindWork AI Studio/Tools/JsonSerializerOptionsExtensions.cs @@ -0,0 +1,21 @@ +using System.Text.Json; + +namespace AIStudio.Tools; + +public static class JsonSerializerOptionsExtensions +{ + /// + /// Makes the options read-only and returns them, so that a static field gets its options in one expression. + /// + /// + /// 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. + /// + /// The options, with their TypeInfoResolver set. + /// The same options, now read-only. + public static JsonSerializerOptions AsReadOnly(this JsonSerializerOptions options) + { + options.MakeReadOnly(); + return options; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs index f15c3cf6e..eedf5c70d 100644 --- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs +++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs @@ -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); diff --git a/app/Tests/Assistants/SWOTAnalysis/SwotAnalysisContentPresentationTests.cs b/app/Tests/Assistants/SWOTAnalysis/SwotAnalysisContentPresentationTests.cs index a23143ecd..6b181006d 100644 --- a/app/Tests/Assistants/SWOTAnalysis/SwotAnalysisContentPresentationTests.cs +++ b/app/Tests/Assistants/SWOTAnalysis/SwotAnalysisContentPresentationTests.cs @@ -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 _); diff --git a/app/Tests/Chat/ContentPresentationTests.cs b/app/Tests/Chat/ContentPresentationTests.cs index b67f450a4..dbff5558f 100644 --- a/app/Tests/Chat/ContentPresentationTests.cs +++ b/app/Tests/Chat/ContentPresentationTests.cs @@ -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())); } diff --git a/app/Tests/Serialization/JsonContractDescription.cs b/app/Tests/Serialization/JsonContractDescription.cs index 25a919e11..5980b58cc 100644 --- a/app/Tests/Serialization/JsonContractDescription.cs +++ b/app/Tests/Serialization/JsonContractDescription.cs @@ -17,8 +17,20 @@ public static class JsonContractDescription /// One line for the type, and one line per property, polymorphism, and callback. 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(); } + /// + /// Whether System.Text.Json neither writes nor reads a property, e.g., one marked with JsonIgnore. + /// + /// The property of a contract. + public static bool IsIgnored(JsonPropertyInfo property) => property is { Get: null, Set: null, AssociatedParameter: null }; + /// /// Returns the name of a type the way C# writes it, e.g., Dictionary<String, Int32>. /// @@ -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); + } } \ No newline at end of file diff --git a/app/Tests/Serialization/JsonDefaultsTests.cs b/app/Tests/Serialization/JsonDefaultsTests.cs index f655a92e5..c5986ad24 100644 --- a/app/Tests/Serialization/JsonDefaultsTests.cs +++ b/app/Tests/Serialization/JsonDefaultsTests.cs @@ -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.

+/// +/// 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. /// [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(); 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(); diff --git a/app/Tests/Serialization/JsonTestRoots.cs b/app/Tests/Serialization/JsonTestRoots.cs index 96438acdd..d2324b970 100644 --- a/app/Tests/Serialization/JsonTestRoots.cs +++ b/app/Tests/Serialization/JsonTestRoots.cs @@ -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), 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 ManagedSettingValueTypes() diff --git a/app/Tests/Serialization/JsonTypeGraph.cs b/app/Tests/Serialization/JsonTypeGraph.cs index d22d58f95..a4cde8498 100644 --- a/app/Tests/Serialization/JsonTypeGraph.cs +++ b/app/Tests/Serialization/JsonTypeGraph.cs @@ -9,7 +9,8 @@ public static class JsonTypeGraph { /// /// 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. /// /// The type to start with. /// The options whose contracts tell what a type holds. @@ -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)