// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Core.Serialization.JsonConverters { using System; using System.Collections.Generic; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using WallstopStudios.UnityHelpers.Core.DataStructure.Adapters; /// /// JSON converter factory for SerializableHashSet and SerializableSortedSet types. /// Ensures serialization produces an object with "_items" field rather than a JSON array, /// which is necessary for proper order preservation across serialization cycles. /// public sealed class SerializableSetConverterFactory : JsonConverterFactory { public static readonly SerializableSetConverterFactory Instance = new(); private SerializableSetConverterFactory() { } public override bool CanConvert(Type typeToConvert) { if (!typeToConvert.IsGenericType) { return false; } Type genericDef = typeToConvert.GetGenericTypeDefinition(); return genericDef == typeof(SerializableHashSet<>) || genericDef == typeof(SerializableSortedSet<>); } public override JsonConverter CreateConverter( Type typeToConvert, JsonSerializerOptions options ) { Type elementType = typeToConvert.GetGenericArguments()[0]; Type genericDef = typeToConvert.GetGenericTypeDefinition(); Type converterType; if (genericDef == typeof(SerializableHashSet<>)) { converterType = typeof(SerializableHashSetConverter<>).MakeGenericType(elementType); } else { converterType = typeof(SerializableSortedSetConverter<>).MakeGenericType( elementType ); } return (JsonConverter)Activator.CreateInstance(converterType); } private sealed class SerializableHashSetConverter : JsonConverter> { private const string ItemsPropertyName = SerializableHashSetSerializedPropertyNames.Items; public override SerializableHashSet Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) { if (reader.TokenType == JsonTokenType.Null) { return null; } // Handle array format (legacy/fallback) if (reader.TokenType == JsonTokenType.StartArray) { T[] items = JsonSerializer.Deserialize(ref reader, options); SerializableHashSet set = new(); if (items != null) { SetItemsField(set, items); set.OnAfterDeserialize(); } return set; } // Handle object format with _items property if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException( $"Expected StartObject or StartArray for SerializableHashSet<{typeof(T).Name}>, got {reader.TokenType}" ); } T[] itemsArray = null; while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) { break; } if (reader.TokenType != JsonTokenType.PropertyName) { continue; } string propertyName = reader.GetString(); reader.Read(); if ( string.Equals( propertyName, ItemsPropertyName, StringComparison.OrdinalIgnoreCase ) ) { itemsArray = JsonSerializer.Deserialize(ref reader, options); } else { reader.Skip(); } } SerializableHashSet result = new(); if (itemsArray != null) { SetItemsField(result, itemsArray); result.OnAfterDeserialize(); } return result; } public override void Write( Utf8JsonWriter writer, SerializableHashSet value, JsonSerializerOptions options ) { if (value == null) { writer.WriteNullValue(); return; } // Ensure serialized arrays are up to date value.OnBeforeSerialize(); writer.WriteStartObject(); writer.WritePropertyName(ItemsPropertyName); JsonSerializer.Serialize(writer, value.SerializedItems, options); writer.WriteEndObject(); } private static void SetItemsField(SerializableHashSet set, T[] items) { // Use reflection to set the internal _items field Type type = typeof(SerializableSetBase>); FieldInfo field = type.GetField( ItemsPropertyName, BindingFlags.Instance | BindingFlags.NonPublic ); field?.SetValue(set, items); } } private sealed class SerializableSortedSetConverter : JsonConverter> where T : IComparable { private const string ItemsPropertyName = SerializableHashSetSerializedPropertyNames.Items; public override SerializableSortedSet Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) { if (reader.TokenType == JsonTokenType.Null) { return null; } // Handle array format (legacy/fallback) if (reader.TokenType == JsonTokenType.StartArray) { T[] items = JsonSerializer.Deserialize(ref reader, options); SerializableSortedSet set = new(); if (items != null) { SetItemsField(set, items); set.OnAfterDeserialize(); } return set; } // Handle object format with _items property if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException( $"Expected StartObject or StartArray for SerializableSortedSet<{typeof(T).Name}>, got {reader.TokenType}" ); } T[] itemsArray = null; while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) { break; } if (reader.TokenType != JsonTokenType.PropertyName) { continue; } string propertyName = reader.GetString(); reader.Read(); if ( string.Equals( propertyName, ItemsPropertyName, StringComparison.OrdinalIgnoreCase ) ) { itemsArray = JsonSerializer.Deserialize(ref reader, options); } else { reader.Skip(); } } SerializableSortedSet result = new(); if (itemsArray != null) { SetItemsField(result, itemsArray); result.OnAfterDeserialize(); } return result; } public override void Write( Utf8JsonWriter writer, SerializableSortedSet value, JsonSerializerOptions options ) { if (value == null) { writer.WriteNullValue(); return; } // Ensure serialized arrays are up to date value.OnBeforeSerialize(); writer.WriteStartObject(); writer.WritePropertyName(ItemsPropertyName); JsonSerializer.Serialize(writer, value.SerializedItems, options); writer.WriteEndObject(); } private static void SetItemsField(SerializableSortedSet set, T[] items) { // Use reflection to set the internal _items field Type type = typeof(SerializableSetBase>); FieldInfo field = type.GetField( ItemsPropertyName, BindingFlags.Instance | BindingFlags.NonPublic ); field?.SetValue(set, items); } } } }