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)