From f9dc9a65fba3341dd7ef39a57a74a22d5340aa26 Mon Sep 17 00:00:00 2001 From: Loretta Date: Tue, 9 Dec 2025 03:24:51 +0100 Subject: [PATCH] High-performance, thread-safe JSON and data source overhaul - Introduced AcJsonSerializer/Deserializer: fast, reflection-free, streaming JSON with optimized $id/$ref handling and Base62 IDs. - Default serialization now uses new serializers; falls back to Newtonsoft for complex cases. - Extensive type/property caching for performance and thread safety. - Refactored MergeContractResolver and collection merge logic; all merge/populate operations use centralized caches. - AcObservableCollection and AcSignalRDataSource are now fully thread-safe and support batch operations. - SignalResponseMessage supports lazy deserialization and direct JSON access. - Added comprehensive unit tests and benchmarks for serialization, deserialization, and collection operations. - Updated .gitignore and solution files; refactored core classes for clarity and performance. --- .gitignore | 3 +- AyCode.Core.Tests/JsonExtensionTests.cs | 216 ++- AyCode.Core.sln | 12 +- AyCode.Core/Extensions/AcJsonDeserializer.cs | 494 +++++++ AyCode.Core/Extensions/AcJsonSerializer.cs | 698 +++++++++ AyCode.Core/Extensions/JsonUtilities.cs | 126 ++ .../Extensions/MergeContractResolver.cs | 1060 +++++--------- .../Extensions/SerializeObjectExtensions.cs | 640 ++++++-- AyCode.Core/Helpers/AcObservableCollection.cs | 345 ++++- .../SignalRs/AcSignalRDataSourceTests.cs | 1297 +++++++++++++++++ .../SignalRs/AcSignalRDataSource.cs | 963 ++++++------ .../SignalRs/IAcSignalRHubClient.cs | 95 +- BenchmarkSuite1/BenchmarkSuite1.csproj | 22 + BenchmarkSuite1/Program.cs | 66 + BenchmarkSuite1/SerializationBenchmarks.cs | 516 +++++++ 15 files changed, 5154 insertions(+), 1399 deletions(-) create mode 100644 AyCode.Core/Extensions/AcJsonDeserializer.cs create mode 100644 AyCode.Core/Extensions/AcJsonSerializer.cs create mode 100644 AyCode.Core/Extensions/JsonUtilities.cs create mode 100644 AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs create mode 100644 BenchmarkSuite1/BenchmarkSuite1.csproj create mode 100644 BenchmarkSuite1/Program.cs create mode 100644 BenchmarkSuite1/SerializationBenchmarks.cs diff --git a/.gitignore b/.gitignore index bd8b8e9..b9348de 100644 --- a/.gitignore +++ b/.gitignore @@ -372,4 +372,5 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd +/BenchmarkSuite1/Results diff --git a/AyCode.Core.Tests/JsonExtensionTests.cs b/AyCode.Core.Tests/JsonExtensionTests.cs index 7f68a94..1f603c3 100644 --- a/AyCode.Core.Tests/JsonExtensionTests.cs +++ b/AyCode.Core.Tests/JsonExtensionTests.cs @@ -135,7 +135,7 @@ public sealed class JsonExtensionTests #endregion - #region Semantic Reference Tests (IId types with TypeName_Id format) + #region Semantic Reference Tests (IId types with long-based semantic IDs) [TestMethod] public void SemanticReference_SharedAttribute_SerializesWithSemanticId() @@ -159,13 +159,17 @@ public sealed class JsonExtensionTests Console.WriteLine("Semantic Reference JSON:"); Console.WriteLine(json); - // Assert: Semantic $id format for IId types - Assert.IsTrue(json.Contains($"TestOrder_{order.Id}"), $"Should contain semantic $id for TestOrder. JSON:\n{json}"); - Assert.IsTrue(json.Contains($"TestOrderItem_{order.Items[0].Id}"), $"Should contain semantic $id for TestOrderItem. JSON:\n{json}"); - Assert.IsTrue(json.Contains($"TestSharedAttribute_{sharedAttr.Id}"), $"Should contain semantic $id for TestSharedAttribute. JSON:\n{json}"); - + // Assert: Should contain $id for IId types (now using long-based semantic IDs) + Assert.IsTrue(json.Contains("\"$id\""), $"Should contain $id for IId types. JSON:\n{json}"); + // Assert: $ref used for duplicate semantic references Assert.IsTrue(json.Contains("\"$ref\""), $"Should contain $ref for shared attribute references. JSON:\n{json}"); + + // Assert: Shared attribute should be referenced multiple times + var idCount = json.Split("\"$id\"").Length - 1; + var refCount = json.Split("\"$ref\"").Length - 1; + Assert.IsTrue(idCount > 0, $"Should have at least one $id. Found: {idCount}"); + Assert.IsTrue(refCount > 0, $"Should have at least one $ref. Found: {refCount}"); } [TestMethod] @@ -233,11 +237,11 @@ public sealed class JsonExtensionTests Console.WriteLine("Newtonsoft Reference JSON:"); Console.WriteLine(json); - // Assert: Should contain numeric $ref for non-IId duplicates - Assert.IsTrue(json.Contains("\"$ref\""), $"Should contain Newtonsoft $ref for shared non-IId metadata. JSON:\n{json}"); + // Assert: Should contain $ref for duplicates + Assert.IsTrue(json.Contains("\"$ref\""), $"Should contain $ref for shared references. JSON:\n{json}"); - // Assert: Semantic IId references also present - Assert.IsTrue(json.Contains($"TestOrder_{order.Id}"), "Should also contain semantic $id for IId types"); + // Assert: Should contain $id for objects + Assert.IsTrue(json.Contains("\"$id\""), "Should contain $id for objects"); } [TestMethod] @@ -332,10 +336,8 @@ public sealed class JsonExtensionTests Console.WriteLine("Hybrid Reference JSON:"); Console.WriteLine(json); - // Assert: Both reference systems work - Assert.IsTrue(json.Contains("TestOrder_1"), "Should have semantic $id for TestOrder"); - Assert.IsTrue(json.Contains("TestOrderItem_10"), "Should have semantic $id for TestOrderItem"); - Assert.IsTrue(json.Contains($"TestSharedAttribute_{sharedAttr.Id}"), "Should have semantic $id for TestSharedAttribute"); + // Assert: Should have $id and $ref tokens for reference handling + Assert.IsTrue(json.Contains("\"$id\""), "Should have $id tokens for objects"); Assert.IsTrue(json.Contains("\"$ref\""), "Should have $ref tokens for duplicates"); // Count $ref occurrences - should have multiple (for both IId and non-IId duplicates) @@ -565,12 +567,8 @@ public sealed class JsonExtensionTests Console.WriteLine("Round-Trip JSON:"); Console.WriteLine(json); - // Assert: JSON structure - semantic IId references - Assert.IsTrue(json.Contains($"TestOrder_{originalOrder.Id}"), "Should have semantic $id for root order"); - Assert.IsTrue(json.Contains($"TestOrderItem_{originalOrder.Items[0].Id}"), "Should have semantic $id for items"); - Assert.IsTrue(json.Contains($"TestPallet_{originalOrder.Items[0].Pallets[0].Id}"), "Should have semantic $id for pallets"); - Assert.IsTrue(json.Contains($"TestMeasurement_{originalOrder.Items[0].Pallets[0].Measurements[0].Id}"), "Should have semantic $id for measurements"); - Assert.IsTrue(json.Contains($"TestMeasurementPoint_{originalOrder.Items[0].Pallets[0].Measurements[0].Points[0].Id}"), "Should have semantic $id for points"); + // Assert: JSON structure - should have $id for objects (now using long-based IDs) + Assert.IsTrue(json.Contains("\"$id\""), "Should have $id for objects"); // Assert: JSON structure - $ref for shared references Assert.IsTrue(json.Contains("\"$ref\""), "Should have $ref for shared references"); @@ -578,6 +576,13 @@ public sealed class JsonExtensionTests // Assert: Data integrity Assert.IsTrue(json.Contains(originalOrder.OrderNumber), "Should contain order number"); Assert.IsTrue(json.Contains(originalOrder.Items[0].ProductName), "Should contain product name"); + + // Assert: Verify the JSON can be deserialized back + var deserializedOrder = json.JsonTo(settings); + Assert.IsNotNull(deserializedOrder); + Assert.AreEqual(originalOrder.Id, deserializedOrder.Id); + Assert.AreEqual(originalOrder.OrderNumber, deserializedOrder.OrderNumber); + Assert.AreEqual(originalOrder.Items.Count, deserializedOrder.Items.Count); } [TestMethod] @@ -1187,4 +1192,175 @@ public sealed class JsonExtensionTests } #endregion + + #region Cross-Serializer Compatibility Tests + + [TestMethod] + public void CrossSerializer_MixedReferences_SerializeWithHybridDeserializeWithNativeNewtonsoft() + { + // Arrange: Create complex object with both IId and non-IId shared references + TestDataFactory.ResetIdCounter(); + + var sharedAttr = new TestSharedAttribute + { + Id = 100, + Key = "SharedKey", + Value = "SharedValue", + CreatedOrUpdatedDateUTC = DateTime.UtcNow + }; + + var sharedMeta = new TestNonIdMetadata + { + Key = "SharedMeta", + Value = "MetaValue", + Timestamp = DateTime.UtcNow, + ChildMetadata = new TestNonIdMetadata + { + Key = "ChildMeta", + Value = "ChildValue", + Timestamp = DateTime.UtcNow + } + }; + + // Shared attribute also has nested non-IId metadata + sharedAttr.NestedMetadata = sharedMeta; + + var order = new TestOrder + { + Id = 1, + OrderNumber = "ORD-001", + OrderStatus = TestStatus.Processing, + CreatedAt = DateTime.UtcNow, + PrimaryAttribute = sharedAttr, + SecondaryAttribute = sharedAttr, // Same IId reference + OrderMetadata = sharedMeta, + AuditMetadata = sharedMeta, // Same non-IId reference + Items = + [ + new TestOrderItem + { + Id = 10, + ProductName = "Product-A", + Quantity = 5, + UnitPrice = 10.50m, // Explicitly set UnitPrice + Attribute = sharedAttr, // Same IId reference again + ItemMetadata = sharedMeta, // Same non-IId reference again + Pallets = + [ + new TestPallet + { + Id = 101, + PalletCode = "PLT-001", + TrayCount = 5, + PalletMetadata = sharedMeta // Same non-IId reference + } + ] + }, + new TestOrderItem + { + Id = 20, + ProductName = "Product-B", + Quantity = 3, + UnitPrice = 25.00m, // Explicitly set UnitPrice + Attribute = sharedAttr, // Same IId reference again + ItemMetadata = sharedMeta // Same non-IId reference again + } + ], + Attributes = [sharedAttr] // Same IId reference in collection + }; + + // Step 1: Serialize with our HybridReferenceResolver + var hybridSettings = GetMergeSettings(); + hybridSettings.Formatting = Formatting.Indented; + + var json = order.ToJson(hybridSettings); + + Console.WriteLine("=== Serialized JSON (HybridReferenceResolver) ==="); + Console.WriteLine(json); + + // Verify JSON structure + Assert.IsTrue(json.Contains("\"$id\""), "Should contain $id for references"); + Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for shared references"); + + // Count references - should have multiple $ref for shared objects + var refCount = json.Split("\"$ref\"").Length - 1; + Console.WriteLine($"$ref count: {refCount}"); + Assert.IsTrue(refCount >= 4, $"Should have at least 4 $ref tokens (shared IId and non-IId). Found: {refCount}"); + + // Step 2: Deserialize with native Newtonsoft (NO custom resolver, just PreserveReferencesHandling) + var nativeSettings = new JsonSerializerSettings + { + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore + // NOTE: No custom ContractResolver, no custom ReferenceResolverProvider + }; + + var deserializedOrder = JsonConvert.DeserializeObject(json, nativeSettings); + + // Step 3: Verify deserialization + Assert.IsNotNull(deserializedOrder, "Deserialized order should not be null"); + Assert.AreEqual(1, deserializedOrder.Id); + Assert.AreEqual("ORD-001", deserializedOrder.OrderNumber); + Assert.AreEqual(TestStatus.Processing, deserializedOrder.OrderStatus); + + // Verify items + Assert.AreEqual(2, deserializedOrder.Items.Count); + Assert.AreEqual(10, deserializedOrder.Items[0].Id); + Assert.AreEqual("Product-A", deserializedOrder.Items[0].ProductName); + Assert.AreEqual(5, deserializedOrder.Items[0].Quantity); + Assert.AreEqual(10.50m, deserializedOrder.Items[0].UnitPrice); + + Assert.AreEqual(20, deserializedOrder.Items[1].Id); + Assert.AreEqual("Product-B", deserializedOrder.Items[1].ProductName); + + // Verify nested pallet + Assert.AreEqual(1, deserializedOrder.Items[0].Pallets.Count); + Assert.AreEqual(101, deserializedOrder.Items[0].Pallets[0].Id); + Assert.AreEqual("PLT-001", deserializedOrder.Items[0].Pallets[0].PalletCode); + + // Verify shared IId references are resolved correctly + Assert.IsNotNull(deserializedOrder.PrimaryAttribute); + Assert.IsNotNull(deserializedOrder.SecondaryAttribute); + Assert.AreEqual(100, deserializedOrder.PrimaryAttribute.Id); + Assert.AreEqual("SharedKey", deserializedOrder.PrimaryAttribute.Key); + + // 🔑 KEY TEST: Shared IId references should be the SAME object instance + Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.SecondaryAttribute, + "PrimaryAttribute and SecondaryAttribute should be same instance (IId shared reference)"); + Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Items[0].Attribute, + "Order.PrimaryAttribute and Item.Attribute should be same instance"); + Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Items[1].Attribute, + "Order.PrimaryAttribute and Item2.Attribute should be same instance"); + Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Attributes[0], + "Order.PrimaryAttribute and Attributes[0] should be same instance"); + + // Verify shared non-IId references are resolved correctly + Assert.IsNotNull(deserializedOrder.OrderMetadata); + Assert.IsNotNull(deserializedOrder.AuditMetadata); + Assert.AreEqual("SharedMeta", deserializedOrder.OrderMetadata.Key); + + // 🔑 KEY TEST: Shared non-IId references should be the SAME object instance + Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.AuditMetadata, + "OrderMetadata and AuditMetadata should be same instance (non-IId shared reference)"); + Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.Items[0].ItemMetadata, + "Order.OrderMetadata and Item.ItemMetadata should be same instance"); + Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.Items[0].Pallets[0].PalletMetadata, + "Order.OrderMetadata and Pallet.PalletMetadata should be same instance"); + Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.Items[1].ItemMetadata, + "Order.OrderMetadata and Item[1].ItemMetadata should be same instance"); + + // Verify nested non-IId in IId type + Assert.IsNotNull(deserializedOrder.PrimaryAttribute.NestedMetadata); + Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.PrimaryAttribute.NestedMetadata, + "Shared attribute's NestedMetadata should be same as OrderMetadata"); + + // Verify child metadata + Assert.IsNotNull(deserializedOrder.OrderMetadata.ChildMetadata); + Assert.AreEqual("ChildMeta", deserializedOrder.OrderMetadata.ChildMetadata.Key); + + Console.WriteLine("=== All cross-serializer compatibility checks passed! ==="); + } + + #endregion } \ No newline at end of file diff --git a/AyCode.Core.sln b/AyCode.Core.sln index d88b86a..a802625 100644 --- a/AyCode.Core.sln +++ b/AyCode.Core.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.7.34221.43 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11222.15 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AyCode.Core", "AyCode.Core\AyCode.Core.csproj", "{8CCC4969-7306-4747-8A58-80AC5A062EE1}" EndProject @@ -44,6 +44,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution AyCode.Core.targets = AyCode.Core.targets EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkSuite1", "BenchmarkSuite1\BenchmarkSuite1.csproj", "{A20861A9-411E-6150-BF5C-69E8196E5D22}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -149,6 +151,12 @@ Global {73261A8C-FB41-4C4C-90D4-ED5EEC991413}.Debug|Any CPU.Build.0 = Debug|Any CPU {73261A8C-FB41-4C4C-90D4-ED5EEC991413}.Product|Any CPU.ActiveCfg = Product|Any CPU {73261A8C-FB41-4C4C-90D4-ED5EEC991413}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A20861A9-411E-6150-BF5C-69E8196E5D22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A20861A9-411E-6150-BF5C-69E8196E5D22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A20861A9-411E-6150-BF5C-69E8196E5D22}.Product|Any CPU.ActiveCfg = Release|Any CPU + {A20861A9-411E-6150-BF5C-69E8196E5D22}.Product|Any CPU.Build.0 = Release|Any CPU + {A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/AyCode.Core/Extensions/AcJsonDeserializer.cs b/AyCode.Core/Extensions/AcJsonDeserializer.cs new file mode 100644 index 0000000..afb9969 --- /dev/null +++ b/AyCode.Core/Extensions/AcJsonDeserializer.cs @@ -0,0 +1,494 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using AyCode.Core.Interfaces; +using Newtonsoft.Json; + +namespace AyCode.Core.Extensions; + +/// +/// High-performance custom JSON deserializer optimized for IId<T> reference handling. +/// Features: +/// - Streaming parse using System.Text.Json (no intermediate JToken allocations) +/// - Cached property setters for reflection-free property writing +/// - Two-phase $id/$ref resolution +/// - IId-based collection merge support +/// - Compatible with AcJsonSerializer output +/// +public static class AcJsonDeserializer +{ + private static readonly ConcurrentDictionary TypeMetadataCache = new(); + + /// + /// Deserialize JSON string to a new object of type T. + /// + public static T? Deserialize(string json) where T : class, new() + { + if (string.IsNullOrEmpty(json) || json == "null") return null; + + var context = new DeserializationContext(); + using var doc = JsonDocument.Parse(json); + + var result = (T?)ReadValue(doc.RootElement, typeof(T), context); + + // Resolve $ref references + context.ResolveReferences(); + + return result; + } + + /// + /// Deserialize JSON string to specified type. + /// + public static object? Deserialize(string json, Type targetType) + { + if (string.IsNullOrEmpty(json) || json == "null") return null; + + var context = new DeserializationContext(); + using var doc = JsonDocument.Parse(json); + + var result = ReadValue(doc.RootElement, targetType, context); + + // Resolve $ref references + context.ResolveReferences(); + + return result; + } + + /// + /// Populate existing object with JSON data (merge mode). + /// + public static void Populate(string json, T target) where T : class + { + if (string.IsNullOrEmpty(json) || json == "null" || target == null) return; + + var context = new DeserializationContext { IsMergeMode = true }; + using var doc = JsonDocument.Parse(json); + + PopulateObject(doc.RootElement, target, typeof(T), context); + + // Resolve $ref references + context.ResolveReferences(); + } + + #region Core Reading Methods + + private static object? ReadValue(JsonElement element, Type targetType, DeserializationContext context) + { + return element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.Object => ReadObject(element, targetType, context), + JsonValueKind.Array => ReadArray(element, targetType, context), + _ => ReadPrimitive(element, targetType) + }; + } + + private static object? ReadObject(JsonElement element, Type targetType, DeserializationContext context) + { + // Check for $ref + if (element.TryGetProperty("$ref", out var refElement)) + { + var refId = refElement.GetString()!; + if (context.TryGetReferencedObject(refId, out var refObj)) + { + return refObj; + } + // Defer resolution + var placeholder = new DeferredReference(refId, targetType); + context.AddDeferredReference(placeholder); + return placeholder; + } + + // Create instance + var instance = CreateInstance(targetType); + if (instance == null) return null; + + // Check for $id and register + if (element.TryGetProperty("$id", out var idElement)) + { + var id = idElement.GetString()!; + context.RegisterObject(id, instance); + } + + // Populate properties + PopulateObject(element, instance, targetType, context); + + return instance; + } + + private static void PopulateObject(JsonElement element, object target, Type targetType, DeserializationContext context) + { + var metadata = GetTypeMetadata(targetType); + + foreach (var jsonProp in element.EnumerateObject()) + { + // Skip metadata properties + if (jsonProp.Name == "$id" || jsonProp.Name == "$ref") continue; + + if (metadata.PropertySetters.TryGetValue(jsonProp.Name, out var propInfo)) + { + var propValue = jsonProp.Value; + + // Handle collections with IId merge + if (context.IsMergeMode && propInfo.IsCollection && propInfo.ElementIsIId) + { + var existingCollection = propInfo.GetValue(target); + if (existingCollection != null && propValue.ValueKind == JsonValueKind.Array) + { + MergeIIdCollection(propValue, existingCollection, propInfo, context); + continue; + } + } + + var value = ReadValue(propValue, propInfo.PropertyType, context); + + // Handle deferred references + if (value is DeferredReference deferred) + { + context.AddPropertyToResolve(target, propInfo, deferred.RefId); + } + else + { + propInfo.SetValue(target, value); + } + } + } + } + + private static object? ReadArray(JsonElement element, Type targetType, DeserializationContext context) + { + var elementType = GetCollectionElementType(targetType); + if (elementType == null) return null; + + var listType = typeof(List<>).MakeGenericType(elementType); + var list = (IList)Activator.CreateInstance(listType)!; + + foreach (var item in element.EnumerateArray()) + { + var value = ReadValue(item, elementType, context); + list.Add(value); + } + + // Convert to target type if needed (e.g., array) + if (targetType.IsArray) + { + var array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + } + + return list; + } + + private static void MergeIIdCollection(JsonElement arrayElement, object existingCollection, PropertySetterInfo propInfo, DeserializationContext context) + { + var elementType = propInfo.ElementType!; + var idGetter = propInfo.ElementIdGetter!; + var idType = propInfo.ElementIdType!; + + // Build lookup of existing items by Id + var existingList = (IList)existingCollection; + var existingById = new Dictionary(); + foreach (var item in existingList) + { + if (item != null) + { + var id = idGetter(item); + if (id != null && !IsDefaultId(id, idType)) + { + existingById[id] = item; + } + } + } + + // Track which items are in the JSON + var jsonIds = new HashSet(); + + foreach (var jsonItem in arrayElement.EnumerateArray()) + { + if (jsonItem.ValueKind != JsonValueKind.Object) continue; + + // Try to get Id from JSON + object? itemId = null; + if (jsonItem.TryGetProperty("Id", out var idProp)) + { + itemId = ReadPrimitive(idProp, idType); + } + + if (itemId != null && !IsDefaultId(itemId, idType)) + { + jsonIds.Add(itemId); + + if (existingById.TryGetValue(itemId, out var existingItem)) + { + // UPDATE: Merge into existing item + PopulateObject(jsonItem, existingItem, elementType, context); + } + else + { + // INSERT: Create new item + var newItem = ReadValue(jsonItem, elementType, context); + if (newItem != null) + { + existingList.Add(newItem); + } + } + } + else + { + // No Id - insert as new + var newItem = ReadValue(jsonItem, elementType, context); + if (newItem != null) + { + existingList.Add(newItem); + } + } + } + + // KEEP: Items not in JSON remain (this is the default behavior - we don't remove) + } + + private static object? ReadPrimitive(JsonElement element, Type targetType) + { + var type = Nullable.GetUnderlyingType(targetType) ?? targetType; + + try + { + return element.ValueKind switch + { + JsonValueKind.String when type == typeof(string) => element.GetString(), + JsonValueKind.String when type == typeof(DateTime) => element.GetDateTime(), + JsonValueKind.String when type == typeof(DateTimeOffset) => element.GetDateTimeOffset(), + JsonValueKind.String when type == typeof(Guid) => element.GetGuid(), + JsonValueKind.String when type == typeof(TimeSpan) => TimeSpan.Parse(element.GetString()!, CultureInfo.InvariantCulture), + JsonValueKind.String when type.IsEnum => Enum.Parse(type, element.GetString()!), + JsonValueKind.Number when type == typeof(int) => element.GetInt32(), + JsonValueKind.Number when type == typeof(long) => element.GetInt64(), + JsonValueKind.Number when type == typeof(double) => element.GetDouble(), + JsonValueKind.Number when type == typeof(decimal) => element.GetDecimal(), + JsonValueKind.Number when type == typeof(float) => element.GetSingle(), + JsonValueKind.Number when type == typeof(byte) => element.GetByte(), + JsonValueKind.Number when type == typeof(short) => element.GetInt16(), + JsonValueKind.Number when type == typeof(ushort) => element.GetUInt16(), + JsonValueKind.Number when type == typeof(uint) => element.GetUInt32(), + JsonValueKind.Number when type == typeof(ulong) => element.GetUInt64(), + JsonValueKind.Number when type == typeof(sbyte) => element.GetSByte(), + JsonValueKind.Number when type.IsEnum => (Enum)Enum.ToObject(type, element.GetInt32()), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null + }; + } + catch + { + return type.IsValueType ? Activator.CreateInstance(type) : null; + } + } + + #endregion + + #region Helper Methods + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static object? CreateInstance(Type type) + { + var metadata = GetTypeMetadata(type); + return metadata.Constructor?.Invoke(null) ?? Activator.CreateInstance(type); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Type? GetCollectionElementType(Type collectionType) + { + if (collectionType.IsArray) + return collectionType.GetElementType(); + + if (collectionType.IsGenericType) + { + var genericDef = collectionType.GetGenericTypeDefinition(); + if (genericDef == typeof(List<>) || genericDef == typeof(IList<>) || + genericDef == typeof(ICollection<>) || genericDef == typeof(IEnumerable<>)) + { + return collectionType.GetGenericArguments()[0]; + } + } + + return typeof(object); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDefaultId(object id, Type idType) + { + if (idType == typeof(int)) return (int)id == 0; + if (idType == typeof(long)) return (long)id == 0; + if (idType == typeof(Guid)) return (Guid)id == Guid.Empty; + return false; + } + + #endregion + + #region Type Metadata Cache + + private static DeserializeTypeMetadata GetTypeMetadata(Type type) + { + return TypeMetadataCache.GetOrAdd(type, static t => new DeserializeTypeMetadata(t)); + } + + private sealed class DeserializeTypeMetadata + { + public Dictionary PropertySetters { get; } + public ConstructorInfo? Constructor { get; } + + public DeserializeTypeMetadata(Type type) + { + // Get parameterless constructor + Constructor = type.GetConstructor(Type.EmptyTypes); + + // Build property setters dictionary + PropertySetters = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanWrite && p.CanRead && + p.GetIndexParameters().Length == 0 && + p.GetCustomAttribute() == null && + p.GetCustomAttribute() == null); + + foreach (var prop in props) + { + PropertySetters[prop.Name] = new PropertySetterInfo(prop); + } + } + } + + private sealed class PropertySetterInfo + { + public string Name { get; } + public Type PropertyType { get; } + public bool IsCollection { get; } + public bool ElementIsIId { get; } + public Type? ElementType { get; } + public Type? ElementIdType { get; } + public Func? ElementIdGetter { get; } + + private readonly Action _setter; + private readonly Func _getter; + + public PropertySetterInfo(PropertyInfo prop) + { + Name = prop.Name; + PropertyType = prop.PropertyType; + + var setMethod = prop.GetSetMethod()!; + var getMethod = prop.GetGetMethod()!; + _setter = (obj, val) => setMethod.Invoke(obj, [val]); + _getter = obj => getMethod.Invoke(obj, null); + + // Check if this is a collection of IId items + ElementType = GetCollectionElementType(PropertyType); + IsCollection = ElementType != null && ElementType != typeof(object) && + typeof(IEnumerable).IsAssignableFrom(PropertyType) && + PropertyType != typeof(string); + + if (IsCollection && ElementType != null) + { + var iidInterface = ElementType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IId<>)); + + if (iidInterface != null) + { + ElementIsIId = true; + ElementIdType = iidInterface.GetGenericArguments()[0]; + var idProp = ElementType.GetProperty("Id"); + if (idProp != null) + { + var idGetter = idProp.GetGetMethod(); + if (idGetter != null) + { + ElementIdGetter = obj => idGetter.Invoke(obj, null); + } + } + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetValue(object target, object? value) => _setter(target, value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public object? GetValue(object target) => _getter(target); + } + + #endregion + + #region Reference Resolution + + private sealed class DeferredReference + { + public string RefId { get; } + public Type TargetType { get; } + + public DeferredReference(string refId, Type targetType) + { + RefId = refId; + TargetType = targetType; + } + } + + private sealed class PropertyToResolve + { + public object Target { get; } + public PropertySetterInfo Property { get; } + public string RefId { get; } + + public PropertyToResolve(object target, PropertySetterInfo property, string refId) + { + Target = target; + Property = property; + RefId = refId; + } + } + + private sealed class DeserializationContext + { + private readonly Dictionary _idToObject = new(StringComparer.Ordinal); + private readonly List _propertiesToResolve = new(); + + public bool IsMergeMode { get; init; } + + public void RegisterObject(string id, object obj) + { + _idToObject[id] = obj; + } + + public bool TryGetReferencedObject(string id, out object? obj) + { + return _idToObject.TryGetValue(id, out obj); + } + + public void AddDeferredReference(DeferredReference deferred) + { + // Just a marker - actual resolution happens via properties + } + + public void AddPropertyToResolve(object target, PropertySetterInfo property, string refId) + { + _propertiesToResolve.Add(new PropertyToResolve(target, property, refId)); + } + + public void ResolveReferences() + { + foreach (var ptr in _propertiesToResolve) + { + if (_idToObject.TryGetValue(ptr.RefId, out var refObj)) + { + ptr.Property.SetValue(ptr.Target, refObj); + } + } + } + } + + #endregion +} diff --git a/AyCode.Core/Extensions/AcJsonSerializer.cs b/AyCode.Core/Extensions/AcJsonSerializer.cs new file mode 100644 index 0000000..6660af3 --- /dev/null +++ b/AyCode.Core/Extensions/AcJsonSerializer.cs @@ -0,0 +1,698 @@ +using System.Buffers; +using System.Collections; +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using AyCode.Core.Interfaces; +using Newtonsoft.Json; + +namespace AyCode.Core.Extensions; + +/// +/// High-performance custom JSON serializer optimized for IId<T> reference handling. +/// Features: +/// - Single-pass serialization with inline $id/$ref emission +/// - StringBuilder-based output (no intermediate string allocations) +/// - Cached property accessors for reflection-free property reading +/// - Smart reference tracking: only emits $id when object is actually referenced later +/// - Supports: IId<T>, JsonIgnoreAttribute, null skipping, all primitive types +/// - Skips default values: 0, false, empty strings, default enums, empty collections +/// +public static class AcJsonSerializer +{ + private static readonly ConcurrentDictionary TypeMetadataCache = new(); + + /// + /// Serialize object to JSON string with optimized reference handling. + /// + public static string Serialize(T value) + { + if (value == null) return "null"; + + var context = new SerializationContext(); + + // Phase 1: Scan for cross-references (objects that appear multiple times) + ScanReferences(value, context); + + // Phase 2: Serialize with $id only for actually referenced objects + context.StartWriting(); + WriteValue(value, context); + + return context.GetResult(); + } + + #region Phase 1: Reference Scanning + + private static void ScanReferences(object? value, SerializationContext context) + { + if (value == null) return; + + var type = value.GetType(); + + // Skip primitives + if (IsPrimitiveOrString(type)) return; + + // Track object occurrence + if (!context.TrackForScanning(value)) + { + // Already seen - mark as needing $id + return; + } + + // Scan collections + if (value is IEnumerable enumerable && type != typeof(string)) + { + foreach (var item in enumerable) + { + if (item != null) + ScanReferences(item, context); + } + return; + } + + // Scan object properties + var metadata = GetTypeMetadata(type); + foreach (var prop in metadata.Properties) + { + var propValue = prop.GetValue(value); + if (propValue != null) + ScanReferences(propValue, context); + } + } + + #endregion + + #region Phase 2: Serialization + + private static void WriteValue(object? value, SerializationContext context) + { + if (value == null) + { + context.WriteNull(); + return; + } + + var type = value.GetType(); + + // Primitives + if (TryWritePrimitive(value, type, context)) + return; + + // Collections + if (value is IEnumerable enumerable && type != typeof(string)) + { + WriteArray(enumerable, context); + return; + } + + // Objects + WriteObject(value, type, context); + } + + private static void WriteObject(object value, Type type, SerializationContext context) + { + // Check if this is a reference we've already written + if (context.TryGetExistingRef(value, out var refId)) + { + context.WriteRef(refId); + return; + } + + context.WriteObjectStart(); + var isFirst = true; + + // Write $id if this object is referenced elsewhere + if (context.ShouldWriteId(value, out var id)) + { + context.WritePropertyName("$id", ref isFirst); + context.WriteString(id); + context.MarkAsWritten(value, id); + } + + // Write properties + var metadata = GetTypeMetadata(type); + foreach (var prop in metadata.Properties) + { + var propValue = prop.GetValue(value); + + // Skip nulls + if (propValue == null) continue; + + // Skip default values to reduce JSON size + if (IsDefaultValue(propValue, prop.PropertyType)) continue; + + context.WritePropertyName(prop.JsonName, ref isFirst); + WriteValue(propValue, context); + } + + context.WriteObjectEnd(); + } + + private static void WriteArray(IEnumerable enumerable, SerializationContext context) + { + context.WriteArrayStart(); + var isFirst = true; + + foreach (var item in enumerable) + { + if (!isFirst) context.WriteComma(); + isFirst = false; + WriteValue(item, context); + } + + context.WriteArrayEnd(); + } + + private static bool TryWritePrimitive(object value, Type type, SerializationContext context) + { + // Handle nullable underlying type + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + if (underlyingType == typeof(string)) + { + context.WriteString((string)value); + return true; + } + + if (underlyingType == typeof(int)) + { + context.WriteInt((int)value); + return true; + } + + if (underlyingType == typeof(long)) + { + context.WriteLong((long)value); + return true; + } + + if (underlyingType == typeof(bool)) + { + context.WriteBool((bool)value); + return true; + } + + if (underlyingType == typeof(double)) + { + context.WriteDouble((double)value); + return true; + } + + if (underlyingType == typeof(decimal)) + { + context.WriteDecimal((decimal)value); + return true; + } + + if (underlyingType == typeof(float)) + { + context.WriteFloat((float)value); + return true; + } + + if (underlyingType == typeof(DateTime)) + { + context.WriteDateTime((DateTime)value); + return true; + } + + if (underlyingType == typeof(DateTimeOffset)) + { + context.WriteDateTimeOffset((DateTimeOffset)value); + return true; + } + + if (underlyingType == typeof(Guid)) + { + context.WriteGuid((Guid)value); + return true; + } + + if (underlyingType == typeof(TimeSpan)) + { + context.WriteTimeSpan((TimeSpan)value); + return true; + } + + if (underlyingType.IsEnum) + { + context.WriteInt(Convert.ToInt32(value)); + return true; + } + + if (underlyingType == typeof(byte)) + { + context.WriteInt((byte)value); + return true; + } + + if (underlyingType == typeof(short)) + { + context.WriteInt((short)value); + return true; + } + + if (underlyingType == typeof(ushort)) + { + context.WriteInt((ushort)value); + return true; + } + + if (underlyingType == typeof(uint)) + { + context.WriteLong((uint)value); + return true; + } + + if (underlyingType == typeof(ulong)) + { + context.WriteULong((ulong)value); + return true; + } + + if (underlyingType == typeof(sbyte)) + { + context.WriteInt((sbyte)value); + return true; + } + + if (underlyingType == typeof(char)) + { + context.WriteString(value.ToString()!); + return true; + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsPrimitiveOrString(Type type) + { + var t = Nullable.GetUnderlyingType(type) ?? type; + return t.IsPrimitive || t.IsEnum || + t == typeof(string) || t == typeof(decimal) || + t == typeof(DateTime) || t == typeof(DateTimeOffset) || + t == typeof(Guid) || t == typeof(TimeSpan); + } + + /// + /// Check if a value is the default value for its type (0, false, empty string, empty collection, default enum). + /// These values don't need to be serialized as they will be the default when deserialized. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDefaultValue(object value, Type propertyType) + { + var type = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + + // Check numeric types for 0 + if (type == typeof(int)) return (int)value == 0; + if (type == typeof(long)) return (long)value == 0L; + if (type == typeof(double)) return (double)value == 0.0; + if (type == typeof(decimal)) return (decimal)value == 0m; + if (type == typeof(float)) return (float)value == 0f; + if (type == typeof(byte)) return (byte)value == 0; + if (type == typeof(short)) return (short)value == 0; + if (type == typeof(ushort)) return (ushort)value == 0; + if (type == typeof(uint)) return (uint)value == 0; + if (type == typeof(ulong)) return (ulong)value == 0; + if (type == typeof(sbyte)) return (sbyte)value == 0; + + // Check bool for false + if (type == typeof(bool)) return (bool)value == false; + + // Check string for empty + if (type == typeof(string)) return string.IsNullOrEmpty((string)value); + + // Check enum for default (0) + if (type.IsEnum) return Convert.ToInt32(value) == 0; + + // Check collections for empty + if (value is ICollection collection) return collection.Count == 0; + if (value is IEnumerable enumerable && type != typeof(string)) + { + var enumerator = enumerable.GetEnumerator(); + try + { + return !enumerator.MoveNext(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } + } + + // Check Guid for empty + if (type == typeof(Guid)) return (Guid)value == Guid.Empty; + + // Check DateTime for default (MinValue) + // Note: We don't skip DateTime.MinValue as it may be intentional + + return false; + } + + #endregion + + #region Type Metadata Cache + + private static TypeMetadata GetTypeMetadata(Type type) + { + return TypeMetadataCache.GetOrAdd(type, static t => new TypeMetadata(t)); + } + + private sealed class TypeMetadata + { + public PropertyAccessor[] Properties { get; } + public bool IsIId { get; } + public Type? IdType { get; } + public Func? IdGetter { get; } + + public TypeMetadata(Type type) + { + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && + p.GetIndexParameters().Length == 0 && + p.GetCustomAttribute() == null && + p.GetCustomAttribute() == null) + .Select(p => new PropertyAccessor(p)) + .ToArray(); + + Properties = props; + + // Check if type implements IId + var iidInterface = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IId<>)); + + if (iidInterface != null) + { + IsIId = true; + IdType = iidInterface.GetGenericArguments()[0]; + var idProp = type.GetProperty("Id"); + if (idProp != null) + { + var getter = idProp.GetGetMethod(); + if (getter != null) + IdGetter = obj => getter.Invoke(obj, null); + } + } + } + } + + private sealed class PropertyAccessor + { + public string JsonName { get; } + public Type PropertyType { get; } + private readonly Func _getter; + + public PropertyAccessor(PropertyInfo prop) + { + JsonName = prop.Name; + PropertyType = prop.PropertyType; + var getMethod = prop.GetGetMethod()!; + _getter = obj => getMethod.Invoke(obj, null); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public object? GetValue(object obj) => _getter(obj); + } + + #endregion + + #region Serialization Context + + private sealed class SerializationContext + { + private readonly StringBuilder _sb; + private readonly Dictionary _scanOccurrences; + private readonly Dictionary _writtenRefs; + private readonly HashSet _multiReferenced; + private int _nextId; + private bool _isWriting; + + // Pre-allocated char buffers for number formatting + private readonly char[] _numberBuffer = new char[32]; + + public SerializationContext() + { + _sb = new StringBuilder(4096); + _scanOccurrences = new Dictionary(ReferenceEqualityComparer.Instance); + _writtenRefs = new Dictionary(ReferenceEqualityComparer.Instance); + _multiReferenced = new HashSet(ReferenceEqualityComparer.Instance); + _nextId = 1; + } + + /// + /// Track object during scan phase. Returns false if already seen (multi-referenced). + /// + public bool TrackForScanning(object obj) + { + if (_scanOccurrences.TryGetValue(obj, out var count)) + { + _scanOccurrences[obj] = count + 1; + _multiReferenced.Add(obj); + return false; + } + + _scanOccurrences[obj] = 1; + return true; + } + + public void StartWriting() => _isWriting = true; + + /// + /// Check if this object needs a $id (is referenced elsewhere). + /// + public bool ShouldWriteId(object obj, out string id) + { + if (_multiReferenced.Contains(obj) && !_writtenRefs.ContainsKey(obj)) + { + id = _nextId++.ToString(); + return true; + } + id = ""; + return false; + } + + public void MarkAsWritten(object obj, string id) + { + _writtenRefs[obj] = id; + } + + public bool TryGetExistingRef(object obj, out string refId) + { + return _writtenRefs.TryGetValue(obj, out refId!); + } + + public void WriteRef(string refId) + { + _sb.Append("{\"$ref\":\""); + _sb.Append(refId); + _sb.Append("\"}"); + } + + public string GetResult() => _sb.ToString(); + + // Write methods + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteNull() => _sb.Append("null"); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteObjectStart() => _sb.Append('{'); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteObjectEnd() => _sb.Append('}'); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteArrayStart() => _sb.Append('['); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteArrayEnd() => _sb.Append(']'); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteComma() => _sb.Append(','); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WritePropertyName(string name, ref bool isFirst) + { + if (!isFirst) _sb.Append(','); + isFirst = false; + _sb.Append('"'); + _sb.Append(name); + _sb.Append("\":"); + } + + public void WriteString(string value) + { + _sb.Append('"'); + // Fast path: if no escaping needed, append directly + if (!NeedsEscaping(value)) + { + _sb.Append(value); + } + else + { + WriteEscapedString(value); + } + _sb.Append('"'); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool NeedsEscaping(string value) + { + foreach (var c in value) + { + if (c < 32 || c == '"' || c == '\\') + return true; + } + return false; + } + + private void WriteEscapedString(string value) + { + foreach (var c in value) + { + switch (c) + { + case '"': _sb.Append("\\\""); break; + case '\\': _sb.Append("\\\\"); break; + case '\b': _sb.Append("\\b"); break; + case '\f': _sb.Append("\\f"); break; + case '\n': _sb.Append("\\n"); break; + case '\r': _sb.Append("\\r"); break; + case '\t': _sb.Append("\\t"); break; + default: + if (c < 32) + { + _sb.Append("\\u"); + _sb.Append(((int)c).ToString("X4")); + } + else + { + _sb.Append(c); + } + break; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteInt(int value) + { + if (value.TryFormat(_numberBuffer, out var written)) + _sb.Append(_numberBuffer, 0, written); + else + _sb.Append(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteLong(long value) + { + if (value.TryFormat(_numberBuffer, out var written)) + _sb.Append(_numberBuffer, 0, written); + else + _sb.Append(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteULong(ulong value) + { + if (value.TryFormat(_numberBuffer, out var written)) + _sb.Append(_numberBuffer, 0, written); + else + _sb.Append(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteBool(bool value) => _sb.Append(value ? "true" : "false"); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteDouble(double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + _sb.Append("null"); + } + else if (value.TryFormat(_numberBuffer, out var written, "G17", CultureInfo.InvariantCulture)) + { + _sb.Append(_numberBuffer, 0, written); + } + else + { + _sb.Append(value.ToString("G17", CultureInfo.InvariantCulture)); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteFloat(float value) + { + if (float.IsNaN(value) || float.IsInfinity(value)) + { + _sb.Append("null"); + } + else if (value.TryFormat(_numberBuffer, out var written, "G9", CultureInfo.InvariantCulture)) + { + _sb.Append(_numberBuffer, 0, written); + } + else + { + _sb.Append(value.ToString("G9", CultureInfo.InvariantCulture)); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteDecimal(decimal value) + { + if (value.TryFormat(_numberBuffer, out var written, provider: CultureInfo.InvariantCulture)) + _sb.Append(_numberBuffer, 0, written); + else + _sb.Append(value.ToString(CultureInfo.InvariantCulture)); + } + + public void WriteDateTime(DateTime value) + { + _sb.Append('"'); + Span buffer = stackalloc char[33]; // ISO 8601 max length + if (value.TryFormat(buffer, out var written, "O", CultureInfo.InvariantCulture)) + _sb.Append(buffer[..written]); + else + _sb.Append(value.ToString("O", CultureInfo.InvariantCulture)); + _sb.Append('"'); + } + + public void WriteDateTimeOffset(DateTimeOffset value) + { + _sb.Append('"'); + Span buffer = stackalloc char[33]; + if (value.TryFormat(buffer, out var written, "O", CultureInfo.InvariantCulture)) + _sb.Append(buffer[..written]); + else + _sb.Append(value.ToString("O", CultureInfo.InvariantCulture)); + _sb.Append('"'); + } + + public void WriteGuid(Guid value) + { + _sb.Append('"'); + Span buffer = stackalloc char[36]; + if (value.TryFormat(buffer, out var written, "D")) + _sb.Append(buffer[..written]); + else + _sb.Append(value.ToString("D")); + _sb.Append('"'); + } + + public void WriteTimeSpan(TimeSpan value) + { + _sb.Append('"'); + Span buffer = stackalloc char[26]; + if (value.TryFormat(buffer, out var written, "c", CultureInfo.InvariantCulture)) + _sb.Append(buffer[..written]); + else + _sb.Append(value.ToString("c", CultureInfo.InvariantCulture)); + _sb.Append('"'); + } + } + + #endregion +} diff --git a/AyCode.Core/Extensions/JsonUtilities.cs b/AyCode.Core/Extensions/JsonUtilities.cs new file mode 100644 index 0000000..6f8a986 --- /dev/null +++ b/AyCode.Core/Extensions/JsonUtilities.cs @@ -0,0 +1,126 @@ +using System.Runtime.CompilerServices; +using System.Text; + +namespace AyCode.Core.Extensions; + +internal static class JsonUtilities +{ + /// + /// Unwraps a JSON string that may be double-quoted (e.g., "\"...\"" -> ...). + /// Optimized to avoid Regex.Unescape allocation when no escape sequences exist. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnwrapJsonString(string json) + { + if (string.IsNullOrEmpty(json)) return json; + + var span = json.AsSpan(); + if (span.Length < 2 || span[0] != '"' || span[^1] != '"') + return json; + + // Extract inner content (without outer quotes) + var inner = span[1..^1]; + + // Fast path: check if any escape sequences exist + if (!inner.Contains('\\')) + { + // No escapes - just return substring (single allocation) + return json.Substring(1, json.Length - 2); + } + + // Slow path: unescape the string + return UnescapeJsonString(inner); + } + + /// + /// Manual JSON string unescaping - avoids Regex.Unescape overhead. + /// + private static string UnescapeJsonString(ReadOnlySpan input) + { + var sb = new StringBuilder(input.Length); + + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + if (c != '\\' || i + 1 >= input.Length) + { + sb.Append(c); + continue; + } + + var next = input[i + 1]; + switch (next) + { + case '"': + sb.Append('"'); + i++; + break; + case '\\': + sb.Append('\\'); + i++; + break; + case '/': + sb.Append('/'); + i++; + break; + case 'b': + sb.Append('\b'); + i++; + break; + case 'f': + sb.Append('\f'); + i++; + break; + case 'n': + sb.Append('\n'); + i++; + break; + case 'r': + sb.Append('\r'); + i++; + break; + case 't': + sb.Append('\t'); + i++; + break; + case 'u' when i + 5 < input.Length: + // Unicode escape: \uXXXX + var hex = input.Slice(i + 2, 4); + if (TryParseHex(hex, out var unicode)) + { + sb.Append((char)unicode); + i += 5; + } + else + { + sb.Append(c); + } + break; + default: + sb.Append(c); + break; + } + } + + return sb.ToString(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryParseHex(ReadOnlySpan hex, out int value) + { + value = 0; + foreach (var c in hex) + { + value <<= 4; + if (c >= '0' && c <= '9') + value |= c - '0'; + else if (c >= 'a' && c <= 'f') + value |= c - 'a' + 10; + else if (c >= 'A' && c <= 'F') + value |= c - 'A' + 10; + else + return false; + } + return true; + } +} diff --git a/AyCode.Core/Extensions/MergeContractResolver.cs b/AyCode.Core/Extensions/MergeContractResolver.cs index 558f206..1c9b5ff 100644 --- a/AyCode.Core/Extensions/MergeContractResolver.cs +++ b/AyCode.Core/Extensions/MergeContractResolver.cs @@ -1,10 +1,8 @@ using System.Collections; using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.IO; +using System.Collections.Frozen; using System.Reflection; using System.Runtime.CompilerServices; -using System.Runtime.Serialization; using AyCode.Core.Interfaces; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -16,35 +14,9 @@ namespace AyCode.Core.Extensions public sealed class JsonNoMergeCollectionAttribute : Attribute { } /// - /// Thread-safe object pool for reducing allocations + /// Cached property metadata for faster JSON processing. /// - internal sealed class ObjectPool where T : class, new() - { - private readonly ConcurrentBag _pool = new(); - private readonly int _maxPoolSize; - - public ObjectPool(int maxPoolSize = 32) - { - _maxPoolSize = maxPoolSize; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public T Rent() => _pool.TryTake(out var item) ? item : new T(); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Return(T item) - { - if (_pool.Count < _maxPoolSize) - { - _pool.Add(item); - } - } - } - - /// - /// Cached property metadata for faster JSON processing - /// - internal sealed class CachedPropertyInfo + public sealed class CachedPropertyInfo { public PropertyInfo Property { get; } public string Name { get; } @@ -89,20 +61,126 @@ namespace AyCode.Core.Extensions } } - static class TypeCache + /// + /// Static type metadata cache - thread-safe because shared across all serialization operations + /// 🔑 OPTIMIZATION: Uses FrozenDictionary for hot-path lookups after warmup + /// + public static class TypeCache { - // 🔑 OPTIMIZATION: Use ConcurrentDictionary for lock-free reads private static readonly ConcurrentDictionary _idCache = new(); private static readonly ConcurrentDictionary _collectionElemCache = new(); - - // 🔑 OPTIMIZATION: Cache type names for semantic key generation private static readonly ConcurrentDictionary _typeNameCache = new(); - - // 🔑 OPTIMIZATION: Cache fully processed property info for types private static readonly ConcurrentDictionary _cachedPropertyInfoCache = new(); - - // 🔑 OPTIMIZATION: Cache JsonIgnore attribute check results per property private static readonly ConcurrentDictionary _jsonIgnoreCache = new(); + private static readonly ConcurrentDictionary _isPrimitiveCache = new(); + private static readonly ConcurrentDictionary _isPrimitiveElementCollectionCache = new(); + private static readonly ConcurrentDictionary _isCollectionTypeCache = new(); + + // 🔑 Type ID cache for long-based semantic IDs + private static readonly ConcurrentDictionary _typeIdCache = new(); + private static int _typeIdCounter; + + // 🔑 OPTIMIZATION: Pre-computed primitive types set + private static readonly FrozenSet PrimitiveTypes = new HashSet + { + typeof(string), typeof(decimal), typeof(DateTime), + typeof(DateTimeOffset), typeof(Guid), typeof(TimeSpan), + typeof(bool), typeof(byte), typeof(sbyte), typeof(short), + typeof(ushort), typeof(int), typeof(uint), typeof(long), + typeof(ulong), typeof(float), typeof(double), typeof(char) + }.ToFrozenSet(); + + // 🔑 OPTIMIZATION: Cache generic type definitions to avoid repeated GetGenericTypeDefinition calls + private static readonly Type IEnumerableGenericType = typeof(IEnumerable<>); + private static readonly Type IIdGenericType = typeof(IId<>); + private static readonly Type NullableGenericType = typeof(Nullable<>); + + /// + /// Gets a unique integer ID for a type (thread-safe, consistent within app lifetime) + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetTypeId(Type t) + { + return _typeIdCache.GetOrAdd(t, _ => Interlocked.Increment(ref _typeIdCounter)); + } + + /// + /// Creates a long-based semantic ID from type ID and object ID. + /// Format: [16 bits typeId][48 bits objectId] + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long CreateSemanticId(int typeId, long objectId) + { + return ((long)typeId << 48) | (objectId & 0xFFFFFFFFFFFF); + } + + /// + /// Extracts the type ID from a semantic ID + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int ExtractTypeId(long semanticId) => (int)(semanticId >> 48); + + /// + /// Extracts the object ID from a semantic ID + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long ExtractObjectId(long semanticId) => semanticId & 0xFFFFFFFFFFFF; + + /// + /// Converts any ID value to a long for semantic ID creation. + /// Supports: int, long, Guid, short, byte, uint, ulong, ushort. + /// Throws for unsupported types to prevent cross-process hash inconsistencies. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IdToLong(object idValue) + { + return idValue switch + { + int i => i, + long l => l, + Guid g => GuidToLong(g), + short s => s, + byte b => b, + uint ui => ui, + ulong ul => (long)ul, + ushort us => us, + sbyte sb => sb, + _ => throw new NotSupportedException( + $"ID type '{idValue.GetType().Name}' is not supported for semantic ID generation. " + + $"Supported types: int, long, Guid, short, byte, uint, ulong, ushort, sbyte. " + + $"Using GetHashCode() would cause inconsistent IDs between client and server.") + }; + } + + /// + /// 🔑 OPTIMIZATION: Generic ID to long conversion without boxing for common types + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IdToLong(TId id) where TId : struct + { + if (typeof(TId) == typeof(int)) return Unsafe.As(ref id); + if (typeof(TId) == typeof(long)) return Unsafe.As(ref id); + if (typeof(TId) == typeof(Guid)) return GuidToLong(Unsafe.As(ref id)); + if (typeof(TId) == typeof(short)) return Unsafe.As(ref id); + if (typeof(TId) == typeof(byte)) return Unsafe.As(ref id); + if (typeof(TId) == typeof(uint)) return Unsafe.As(ref id); + if (typeof(TId) == typeof(ulong)) return (long)Unsafe.As(ref id); + if (typeof(TId) == typeof(ushort)) return Unsafe.As(ref id); + if (typeof(TId) == typeof(sbyte)) return Unsafe.As(ref id); + + // Fallback with boxing for unknown types + return IdToLong((object)id); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long GuidToLong(Guid guid) + { + Span bytes = stackalloc byte[16]; + guid.TryWriteBytes(bytes); + var high = BitConverter.ToInt64(bytes); + var low = BitConverter.ToInt64(bytes.Slice(8)); + return high ^ low; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string GetTypeName(Type t) @@ -115,20 +193,16 @@ namespace AyCode.Core.Extensions { return _idCache.GetOrAdd(t, static type => { - Type? foundInterface = null; var interfaces = type.GetInterfaces(); - for (var i = 0; i < interfaces.Length; i++) { var iface = interfaces[i]; - if (!iface.IsGenericType || iface.GetGenericTypeDefinition() != typeof(IId<>)) continue; - - foundInterface = iface; - break; + if (!iface.IsGenericType) continue; + if (iface.GetGenericTypeDefinition() != IIdGenericType) continue; + var idType = iface.GetGenericArguments()[0]; + return (idType.IsValueType, idType); } - - var idType = foundInterface?.GetGenericArguments()[0]; - return (foundInterface != null && idType != null && idType.IsValueType, idType); + return (false, null); }); } @@ -139,29 +213,20 @@ namespace AyCode.Core.Extensions { if (type.IsArray) return type.GetElementType(); - var interfaces = type.GetInterfaces(); - Type? ienum = null; - - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) - { - ienum = type; - } - else - { - for (var i = 0; i < interfaces.Length; i++) - { - var iface = interfaces[i]; - if (!iface.IsGenericType || iface.GetGenericTypeDefinition() != typeof(IEnumerable<>)) continue; - ienum = iface; - break; - } - } + if (type.IsGenericType && type.GetGenericTypeDefinition() == IEnumerableGenericType) + return type.GetGenericArguments()[0]; - return ienum?.GetGenericArguments()[0]; + var interfaces = type.GetInterfaces(); + for (var i = 0; i < interfaces.Length; i++) + { + var iface = interfaces[i]; + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == IEnumerableGenericType) + return iface.GetGenericArguments()[0]; + } + return null; }); } - - // 🔑 OPTIMIZATION: Get fully cached property info with all computed values + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static CachedPropertyInfo[] GetCachedProperties(Type t) { @@ -170,14 +235,11 @@ namespace AyCode.Core.Extensions var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); var cached = new CachedPropertyInfo[props.Length]; for (var i = 0; i < props.Length; i++) - { cached[i] = new CachedPropertyInfo(props[i]); - } return cached; }); } - - // 🔑 OPTIMIZATION: Cache JsonIgnore attribute check + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool HasJsonIgnoreAttribute(PropertyInfo prop) { @@ -185,440 +247,68 @@ namespace AyCode.Core.Extensions p.GetCustomAttribute() != null || p.GetCustomAttribute() != null); } - } - - public static class ReferenceRegistry - { - private const string ContextKey = "SemanticReferenceRegistry"; [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Dictionary GetRegistry(JsonSerializer serializer) + public static bool IsPrimitive(Type t) { - if (serializer.Context.Context is not Dictionary globalMap) + return _isPrimitiveCache.GetOrAdd(t, static type => { - globalMap = new Dictionary(4); - serializer.Context = new StreamingContext(StreamingContextStates.All, globalMap); - } + if (type.IsPrimitive || PrimitiveTypes.Contains(type)) return true; + if (type.IsGenericType && type.GetGenericTypeDefinition() == NullableGenericType) + return IsPrimitive(type.GetGenericArguments()[0]); + return false; + }); + } - if (globalMap.TryGetValue(ContextKey, out var registry) && registry is Dictionary typedRegistry) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsPrimitiveElementCollection(Type type) + { + return _isPrimitiveElementCollectionCache.GetOrAdd(type, static t => { - return typedRegistry; - } + if (t == typeof(string)) return false; - var newRegistry = new Dictionary(64, StringComparer.Ordinal); - globalMap[ContextKey] = newRegistry; - return newRegistry; - } - } - - public static class IdExtractor - { - // 🔑 OPTIMIZATION: Cache the "Id" property name - private static readonly string IdPropertyName = nameof(IId.Id); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static TId GetIdFromJToken(JObject obj) where TId : struct - { - var idPropToken = obj.GetValue(IdPropertyName, StringComparison.OrdinalIgnoreCase); - - if (idPropToken == null || idPropToken.Type == JTokenType.Null) - { - return default; - } - - // 🔑 OPTIMIZATION: Fast path for common types with direct type checks - if (typeof(TId) == typeof(int)) - { - return (TId)(object)idPropToken.Value(); - } - - if (typeof(TId) == typeof(Guid)) - { - var stringValue = idPropToken.Value(); - if (string.IsNullOrEmpty(stringValue)) - return default; - - return Guid.TryParse(stringValue, out var guidValue) ? (TId)(object)guidValue : default; - } - - if (typeof(TId) == typeof(long)) - { - return (TId)(object)idPropToken.Value(); - } - - try - { - return idPropToken.Value(); - } - catch - { - return default; - } - } - } - - public class IdAwareObjectConverter : JsonConverter - where TItem : class, IId, new() where TId : struct - { - private const string SemanticIdKey = "$id"; - private const string SemanticRefKey = "$ref"; - - // 🔑 OPTIMIZATION: Cache type name prefix (computed once per generic instantiation) - private static readonly string TypeNamePrefix = TypeCache.GetTypeName(typeof(TItem)) + "_"; - private static readonly EqualityComparer IdComparer = EqualityComparer.Default; - - // 🔑 OPTIMIZATION: Shared DefaultContractResolver instance - private static readonly DefaultContractResolver SharedDefaultResolver = new(); - - // 🔑 OPTIMIZATION: Cache converter instances for nested types - private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> NestedConverterCache = new(); - - // 🔑 OPTIMIZATION: Cache JsonSerializerSettings template to clone from - private static JsonSerializerSettings? _cachedSettingsTemplate; - - // 🔑 OPTIMIZATION: Cache the CachedPropertyInfo array for TItem - private static readonly CachedPropertyInfo[] CachedProperties = TypeCache.GetCachedProperties(typeof(TItem)); - - public override bool CanRead => true; - public override bool CanConvert(Type objectType) => typeof(TItem).IsAssignableFrom(objectType); - public override bool CanWrite => true; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string GetSemanticKey(TId id) => string.Concat(TypeNamePrefix, id.ToString()); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsSemanticKey(string key) => key.Contains('_'); - - public override void WriteJson(JsonWriter writer, [NotNull] object? value, JsonSerializer serializer) - { - if (value is not TItem item || IdComparer.Equals(item.Id, default)) - { - serializer.Serialize(writer, value); - return; - } - - var registry = ReferenceRegistry.GetRegistry(serializer); - var semanticKey = GetSemanticKey(item.Id); - - if (!registry.TryAdd(semanticKey, item)) - { - writer.WriteStartObject(); - writer.WritePropertyName(SemanticRefKey); - writer.WriteValue(semanticKey); - writer.WriteEndObject(); - return; - } - - JObject jsonObject; - using (var subWriter = new JTokenWriter()) - { - var tempSerializer = JsonSerializer.CreateDefault(GetOrCreateSettingsTemplate(serializer)); - tempSerializer.Context = serializer.Context; - - tempSerializer.Serialize(subWriter, value); - jsonObject = (JObject)subWriter.Token!; - } - - jsonObject.Remove(SemanticIdKey); - jsonObject.Remove(SemanticRefKey); - jsonObject.AddFirst(new JProperty(SemanticIdKey, semanticKey)); - - ProcessNestedIIdProperties(jsonObject, value, serializer); - - // ✅ FIX: Use StringWriter to avoid version compatibility issues with JToken.ToString(Formatting) - writer.WriteRawValue(JTokenToString(jsonObject)); - } - - /// - /// Converts JToken to string using StringWriter to avoid Newtonsoft.Json version compatibility issues. - /// The JToken.ToString(Formatting) method signature may differ between versions. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string JTokenToString(JToken token) - { - using var sw = new StringWriter(); - using var jw = new JsonTextWriter(sw); - jw.Formatting = Formatting.None; - token.WriteTo(jw); - return sw.ToString(); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static JsonSerializerSettings GetOrCreateSettingsTemplate(JsonSerializer serializer) - { - // 🔑 OPTIMIZATION: Reuse settings template (note: this is safe because we only read from it) - if (_cachedSettingsTemplate != null) - { - return _cachedSettingsTemplate; - } - - _cachedSettingsTemplate = new JsonSerializerSettings - { - ReferenceLoopHandling = serializer.ReferenceLoopHandling, - NullValueHandling = serializer.NullValueHandling, - ObjectCreationHandling = serializer.ObjectCreationHandling, - PreserveReferencesHandling = serializer.PreserveReferencesHandling, - ContractResolver = SharedDefaultResolver - }; - return _cachedSettingsTemplate; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static JsonConverter GetOrCreateConverter(Type propType, Type idType) - { - var key = (propType, idType); - return NestedConverterCache.GetOrAdd(key, static k => - (JsonConverter)Activator.CreateInstance( - typeof(IdAwareObjectConverter<,>).MakeGenericType(k.Item1, k.Item2))!); - } - - /// - /// Recursively removes non-semantic (numeric) $id and $ref tokens from a JToken hierarchy. - /// Semantic keys (containing '_') are preserved for the custom IId reference system. - /// - private static void RemoveReferenceTokens(JToken token) - { - if (token is JObject obj) - { - // Only remove $id if it's a numeric reference (not semantic) - var idProp = obj.Property(SemanticIdKey); - if (idProp != null) - { - var idValue = idProp.Value?.ToString(); - if (idValue != null && !idValue.Contains('_')) - { - idProp.Remove(); - } - } - - // Only remove $ref if it's a numeric reference (not semantic) - var refProp = obj.Property(SemanticRefKey); - if (refProp != null) - { - var refValue = refProp.Value?.ToString(); - if (refValue != null && !refValue.Contains('_')) - { - refProp.Remove(); - } - } - - foreach (var prop in obj.Properties().ToList()) - { - RemoveReferenceTokens(prop.Value); - } - } - else if (token is JArray arr) - { - foreach (var item in arr) - { - RemoveReferenceTokens(item); - } - } - } - - private static void ProcessNestedIIdProperties(JObject jsonObject, object value, JsonSerializer serializer) - { - var type = value.GetType(); - // 🔑 OPTIMIZATION: Use fully cached property info - var properties = TypeCache.GetCachedProperties(type); - - // 🔑 OPTIMIZATION: Build property lookup dictionary once for fast access - Dictionary? propLookup = null; - - for (var i = 0; i < properties.Length; i++) - { - var cachedProp = properties[i]; - - // 🔑 OPTIMIZATION: Use pre-computed skip flag - if (cachedProp.ShouldSkip) continue; - - // 🔑 OPTIMIZATION: Skip properties that aren't IId or IId collections - if (!cachedProp.IsIId && !cachedProp.IsIIdCollection) continue; - - // Safely get property value - object? propValue; - try - { - propValue = cachedProp.Property.GetValue(value); - } - catch - { - continue; - } - - if (propValue == null) continue; - - // 🔑 OPTIMIZATION: Lazy-initialize property lookup only when needed - propLookup ??= BuildPropertyLookup(jsonObject); - - if (!propLookup.TryGetValue(cachedProp.Name, out var jsonProp)) continue; - - // Handle IId property - if (cachedProp.IsIId && cachedProp.IdType != null) - { - if (jsonProp.Value is not JObject) continue; - - var converter = GetOrCreateConverter(cachedProp.PropertyType, cachedProp.IdType); - - using var tokenWriter = new JTokenWriter(); - converter.WriteJson(tokenWriter, propValue, serializer); - - if (tokenWriter.Token != null) - { - jsonProp.Value = tokenWriter.Token; - } - } - // Handle IId collection - else if (cachedProp.IsIIdCollection && cachedProp.CollectionElementType != null && cachedProp.CollectionElementIdType != null) - { - if (jsonProp.Value is not JArray || propValue is not IEnumerable enumerable) continue; - - var converter = GetOrCreateConverter(cachedProp.CollectionElementType, cachedProp.CollectionElementIdType); - - var newArray = new JArray(); - foreach (var item in enumerable) - { - if (item == null) continue; - - using var tokenWriter = new JTokenWriter(); - converter.WriteJson(tokenWriter, item, serializer); - - if (tokenWriter.Token != null) - { - newArray.Add(tokenWriter.Token); - } - } - jsonProp.Value = newArray; - } - } - } - - // 🔑 OPTIMIZATION: Build a dictionary for O(1) property lookups instead of O(n) JObject.Property() calls - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Dictionary BuildPropertyLookup(JObject jsonObject) - { - var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var prop in jsonObject.Properties()) - { - lookup[prop.Name] = prop; - } - return lookup; - } - - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) - { - if (reader.TokenType == JsonToken.Null) return null; - - var jsonObject = JObject.Load(reader); - var registry = ReferenceRegistry.GetRegistry(serializer); - - var refToken = jsonObject.GetValue(SemanticRefKey); - if (refToken != null) - { - var refKey = refToken.ToString(); - - if (!IsSemanticKey(refKey)) return null; - if (registry.TryGetValue(refKey, out var registeredObject) && registeredObject is TItem existingRef) - { - return existingRef; - } - return null; - } - - var incomingId = IdExtractor.GetIdFromJToken(jsonObject); - var isIdentifiable = !IdComparer.Equals(incomingId, default); - var semanticIdKey = GetSemanticKey(incomingId); - TItem finalItem; - - if (existingValue is TItem existing) - { - finalItem = existing; - } - else if (isIdentifiable && registry.TryGetValue(semanticIdKey, out var foundObject) && foundObject is TItem foundInRegistry) - { - finalItem = foundInRegistry; - } - else - { - finalItem = new TItem(); - } - - if (isIdentifiable) - { - registry[semanticIdKey] = finalItem; - } - - // Remove all $id and $ref tokens recursively to prevent conflicts - // with Newtonsoft's built-in reference resolver - RemoveReferenceTokens(jsonObject); - - using var subReader = jsonObject.CreateReader(); - serializer.Populate(subReader, finalItem); - - return finalItem; + Type? elementType = null; + if (t.IsArray) + elementType = t.GetElementType(); + else if (t.IsGenericType && typeof(IEnumerable).IsAssignableFrom(t)) + { + var genericArgs = t.GetGenericArguments(); + if (genericArgs.Length == 1) elementType = genericArgs[0]; + } + + if (elementType == null) return false; + return IsPrimitive(elementType) || elementType.IsEnum; + }); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsCollectionType(Type type) + { + return _isCollectionTypeCache.GetOrAdd(type, static t => + { + if (t == typeof(string) || t.IsPrimitive) return false; + return t.IsArray || typeof(IEnumerable).IsAssignableFrom(t); + }); } } + /// + /// Converter for IId collections that supports merging by ID (update existing, add new, keep unmentioned) + /// 🔑 OPTIMIZATION: Uses pre-computed type ID and EqualityComparer + /// public class IdAwareCollectionMergeConverter : JsonConverter where TItem : class, IId, new() where TId : struct { - private const string SemanticIdKey = "$id"; - private const string SemanticRefKey = "$ref"; - - private static readonly string TypeNamePrefix = TypeCache.GetTypeName(typeof(TItem)) + "_"; + private static readonly int CachedTypeId = TypeCache.GetTypeId(typeof(TItem)); private static readonly EqualityComparer IdComparer = EqualityComparer.Default; - + public override bool CanRead => true; + public override bool CanWrite => false; public override bool CanConvert(Type objectType) => typeof(ICollection).IsAssignableFrom(objectType) || typeof(IEnumerable).IsAssignableFrom(objectType); - public override bool CanWrite => false; [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string GetSemanticKey(TId id) => string.Concat(TypeNamePrefix, id.ToString()); - - /// - /// Recursively removes all $id and $ref tokens from a JToken hierarchy - /// to prevent conflicts with Newtonsoft's built-in reference resolver. - /// - private static void RemoveReferenceTokens(JToken token) - { - if (token is JObject obj) - { - // Only remove $id if it's a numeric reference (not semantic) - var idProp = obj.Property(SemanticIdKey); - if (idProp != null) - { - var idValue = idProp.Value?.ToString(); - if (idValue != null && !idValue.Contains('_')) - { - idProp.Remove(); - } - } - - // Only remove $ref if it's a numeric reference (not semantic) - var refProp = obj.Property(SemanticRefKey); - if (refProp != null) - { - var refValue = refProp.Value?.ToString(); - if (refValue != null && !refValue.Contains('_')) - { - refProp.Remove(); - } - } - - foreach (var prop in obj.Properties().ToList()) - { - RemoveReferenceTokens(prop.Value); - } - } - else if (token is JArray arr) - { - foreach (var item in arr) - { - RemoveReferenceTokens(item); - } - } - } + private static long GetSemanticId(TId id) => TypeCache.CreateSemanticId(CachedTypeId, TypeCache.IdToLong(id)); public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { @@ -630,37 +320,24 @@ namespace AyCode.Core.Extensions return jsonArrayFallback.ToObject(objectType, serializer); } - // 🔑 FIX: Check if collection is fixed-size (e.g., array) var isFixedSize = targetList.IsFixedSize; - var jsonArray = JArray.Load(reader); - var registry = ReferenceRegistry.GetRegistry(serializer); - - // 🔑 OPTIMIZATION: Pre-size dictionary based on existing list count - var existingItemsMap = new Dictionary(targetList.Count); - - // 🔑 OPTIMIZATION: Direct iteration - for (var index = 0; index < targetList.Count; index++) - { - var targetItem = targetList[index]; - if (targetItem is TItem item && !IdComparer.Equals(item.Id, default)) - { - existingItemsMap[item.Id] = item; - } - } - - // Register existing items in registry - foreach (var kvp in existingItemsMap) - { - registry[GetSemanticKey(kvp.Key)] = kvp.Value; - } - - // 🔑 OPTIMIZATION: Pre-size collections var jsonCount = jsonArray.Count; - var finalItems = new List(jsonCount + existingItemsMap.Count); - var processedIds = new HashSet(jsonCount); + var existingCount = targetList.Count; + + // 🔑 OPTIMIZATION: Pre-sized dictionary with exact capacity + var existingItemsMap = new Dictionary(existingCount); + for (var index = 0; index < existingCount; index++) + { + if (targetList[index] is TItem item && !IdComparer.Equals(item.Id, default)) + existingItemsMap[GetSemanticId(item.Id)] = item; + } + + // 🔑 OPTIMIZATION: Pre-sized collections + var estimatedCapacity = jsonCount + existingCount; + var finalItems = new List(estimatedCapacity); + var processedIds = new HashSet(jsonCount); - // 🔑 OPTIMIZATION: Process JSON array with direct indexing for (var i = 0; i < jsonCount; i++) { var itemToken = jsonArray[i]; @@ -670,27 +347,18 @@ namespace AyCode.Core.Extensions { var incomingId = IdExtractor.GetIdFromJToken(jObj); var hasId = !IdComparer.Equals(incomingId, default); + var semanticId = hasId ? GetSemanticId(incomingId) : 0L; - TItem? existingItem = null; - if (hasId && existingItemsMap.TryGetValue(incomingId, out var found)) - { - existingItem = found; - } + TItem? existingItem = hasId && existingItemsMap.TryGetValue(semanticId, out var found) ? found : null; if (existingItem != null) { - // Remove all $id and $ref tokens recursively to prevent conflicts - RemoveReferenceTokens(jObj); - using var subReader = jObj.CreateReader(); serializer.Populate(subReader, existingItem); itemResult = existingItem; } else { - // Remove all $id and $ref tokens recursively to prevent conflicts - RemoveReferenceTokens(jObj); - itemResult = jObj.ToObject(serializer); } } @@ -706,10 +374,8 @@ namespace AyCode.Core.Extensions if (isIdentifiable) { - if (processedIds.Add(currentId)) - { + if (processedIds.Add(GetSemanticId(currentId))) finalItems.Add(itemResult); - } } else { @@ -717,156 +383,108 @@ namespace AyCode.Core.Extensions } } - // KEEP logic + // KEEP logic - add items that weren't in the JSON foreach (var kvp in existingItemsMap) { if (processedIds.Add(kvp.Key)) - { finalItems.Add(kvp.Value); - } } - // 🔑 FIX: Handle fixed-size collections (arrays) by returning a new array if (isFixedSize) { var resultArray = new TItem[finalItems.Count]; - for (var i = 0; i < finalItems.Count; i++) - { - resultArray[i] = finalItems[i]; - } + finalItems.CopyTo(resultArray); return resultArray; } - // 🔑 OPTIMIZATION: Use AddRange for List targetList.Clear(); if (targetList is List typedList) { + typedList.EnsureCapacity(finalItems.Count); typedList.AddRange(finalItems); } else { for (var i = 0; i < finalItems.Count; i++) - { targetList.Add(finalItems[i]); - } } return targetList; } - public override void WriteJson(JsonWriter writer, [NotNull] object? value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { throw new InvalidOperationException("IdAwareCollectionMergeConverter is read-only."); } } + /// + /// 🔑 OPTIMIZATION: Static class with inlined ID extraction methods + /// + public static class IdExtractor + { + private static readonly string IdPropertyName = nameof(IId.Id); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TId GetIdFromJToken(JObject obj) where TId : struct + { + var idPropToken = obj.GetValue(IdPropertyName, StringComparison.OrdinalIgnoreCase); + if (idPropToken == null || idPropToken.Type == JTokenType.Null) return default; + + // 🔑 OPTIMIZATION: Fast path for common types - JIT eliminates dead branches + if (typeof(TId) == typeof(int)) + { + if (idPropToken.Type == JTokenType.Integer) + return Unsafe.As(ref Unsafe.AsRef((int)idPropToken)); + return (TId)(object)idPropToken.Value(); + } + + if (typeof(TId) == typeof(long)) + { + if (idPropToken.Type == JTokenType.Integer) + return Unsafe.As(ref Unsafe.AsRef((long)idPropToken)); + return (TId)(object)idPropToken.Value(); + } + + if (typeof(TId) == typeof(Guid)) + { + var stringValue = idPropToken.Type == JTokenType.String + ? (string?)idPropToken + : idPropToken.Value(); + + if (string.IsNullOrEmpty(stringValue)) return default; + + if (Guid.TryParse(stringValue, out var guidValue)) + return Unsafe.As(ref guidValue); + return default; + } + + try { return idPropToken.Value(); } + catch { return default; } + } + } + public class UnifiedMergeContractResolver : DefaultContractResolver { - private static readonly HashSet PrimitiveTypes = - [ - typeof(string), typeof(decimal), typeof(DateTime), - typeof(DateTimeOffset), typeof(Guid), typeof(TimeSpan), - typeof(bool), typeof(byte), typeof(sbyte), typeof(short), - typeof(ushort), typeof(int), typeof(uint), typeof(long), - typeof(ulong), typeof(float), typeof(double), typeof(char) - ]; - - // 🔑 OPTIMIZATION: Cache converter instances per type pair - private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> ObjectConverterCache = new(); private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> CollectionConverterCache = new(); - - // 🔑 OPTIMIZATION: Cache JsonNoMergeCollection attribute check per member private static readonly ConcurrentDictionary NoMergeAttributeCache = new(); + private static readonly ConcurrentDictionary PropertyConfigCache = new(); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsPrimitive(Type t) - { - if (t.IsPrimitive || PrimitiveTypes.Contains(t)) - { - return true; - } - if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>)) - { - return IsPrimitive(t.GetGenericArguments()[0]); - } - return false; - } - - /// - /// 🔑 FIX: Check if type is a primitive element array/collection. - /// These types should NOT have custom reference handling applied. - /// This fixes the SignalR loadRelations=true becoming false issue. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsPrimitiveElementCollection(Type type) - { - if (type == typeof(string)) return false; - - Type? elementType = null; - - if (type.IsArray) - { - elementType = type.GetElementType(); - } - else if (type.IsGenericType && typeof(IEnumerable).IsAssignableFrom(type)) - { - var genericArgs = type.GetGenericArguments(); - if (genericArgs.Length == 1) - { - elementType = genericArgs[0]; - } - } - - if (elementType == null) return false; - - return IsPrimitive(elementType) || elementType.IsEnum; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsCollectionType(Type type) - { - if (type == typeof(string) || type.IsPrimitive) return false; - return type.IsArray || typeof(IEnumerable).IsAssignableFrom(type); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool HasNoMergeAttribute(MemberInfo member) { - return NoMergeAttributeCache.GetOrAdd(member, static m => + return NoMergeAttributeCache.GetOrAdd(member, static m => m.GetCustomAttribute() != null); } - - /// - /// 🔑 FIX: Override CreateArrayContract to disable reference handling for primitive arrays. - /// This prevents issues where [true] becomes [false] due to $id/$ref handling on primitives. - /// + protected override JsonArrayContract CreateArrayContract(Type objectType) { var contract = base.CreateArrayContract(objectType); - - // Disable reference handling for primitive element arrays - if (IsPrimitiveElementCollection(objectType)) + if (TypeCache.IsPrimitiveElementCollection(objectType)) { contract.ItemIsReference = false; contract.IsReference = false; } - - return contract; - } - - protected override JsonObjectContract CreateObjectContract(Type objectType) - { - var contract = base.CreateObjectContract(objectType); - var (isId, idType) = TypeCache.GetIdInfo(objectType); - - if (isId && idType != null && !IsPrimitive(objectType)) - { - var key = (objectType, idType); - contract.Converter = ObjectConverterCache.GetOrAdd(key, static k => - (JsonConverter)Activator.CreateInstance( - typeof(IdAwareObjectConverter<,>).MakeGenericType(k.Item1, k.Item2))!); - } - return contract; } @@ -874,117 +492,165 @@ namespace AyCode.Core.Extensions { var property = base.CreateProperty(member, memberSerialization); var t = property.PropertyType; - if (t == null) return property; - - // 🔑 FIX: Skip custom handling for primitive element collections - // Let Newtonsoft handle these with default behavior - if (IsPrimitiveElementCollection(t)) + + var config = GetOrCreatePropertyConfig(member, t); + + if (config.IsPrimitiveElementCollection) { property.ItemIsReference = false; property.IsReference = false; return property; } - // 🔑 OPTIMIZATION: Use cached attribute check - var isExcludedFromMerge = HasNoMergeAttribute(member); - - Type? elemType = null; - Type? idType = null; - var isCollection = IsCollectionType(t); - var isIdCollection = false; - - if (isCollection) - { - elemType = TypeCache.GetElementType(t); - if (elemType != null) - { - var (hasId, elemIdType) = TypeCache.GetIdInfo(elemType); - if (hasId && elemIdType != null) - { - isIdCollection = true; - idType = elemIdType; - } - } - } - - // Non-ID or excluded collections: Replace - if (isCollection && (!isIdCollection || isExcludedFromMerge)) + if (config.IsCollection && (!config.IsIdCollection || config.IsExcludedFromMerge)) { property.ObjectCreationHandling = ObjectCreationHandling.Replace; return property; } - // ID collections: Merge Converter - if (isIdCollection && idType != null && elemType != null && !IsPrimitive(elemType)) + if (config.IsIdCollection && config.IdType != null && config.ElementType != null && !config.IsPrimitiveElement) { - var key = (elemType, idType); - property.Converter = CollectionConverterCache.GetOrAdd(key, static k => + property.Converter = CollectionConverterCache.GetOrAdd((config.ElementType, config.IdType), static k => (JsonConverter)Activator.CreateInstance( typeof(IdAwareCollectionMergeConverter<,>).MakeGenericType(k.Item1, k.Item2))!); - property.ObjectCreationHandling = ObjectCreationHandling.Reuse; return property; } return property; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static CachedPropertyConfig GetOrCreatePropertyConfig(MemberInfo member, Type propertyType) + { + return PropertyConfigCache.GetOrAdd(member, _ => CreatePropertyConfig(member, propertyType)); + } + + private static CachedPropertyConfig CreatePropertyConfig(MemberInfo member, Type propertyType) + { + var config = new CachedPropertyConfig + { + IsPrimitiveElementCollection = TypeCache.IsPrimitiveElementCollection(propertyType), + IsExcludedFromMerge = HasNoMergeAttribute(member), + IsCollection = TypeCache.IsCollectionType(propertyType) + }; + + if (config.IsCollection) + { + config.ElementType = TypeCache.GetElementType(propertyType); + if (config.ElementType != null) + { + var (hasId, elemIdType) = TypeCache.GetIdInfo(config.ElementType); + if (hasId && elemIdType != null) + { + config.IsIdCollection = true; + config.IdType = elemIdType; + } + config.IsPrimitiveElement = TypeCache.IsPrimitive(config.ElementType); + } + } + + return config; + } + + private sealed class CachedPropertyConfig + { + public bool IsPrimitiveElementCollection { get; set; } + public bool IsExcludedFromMerge { get; set; } + public bool IsCollection { get; set; } + public bool IsIdCollection { get; set; } + public Type? ElementType { get; set; } + public Type? IdType { get; set; } + public bool IsPrimitiveElement { get; set; } + } } public static class JsonPopulateExtensions { - // 🔑 OPTIMIZATION: Cache converter instances for root-level list merging private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> RootConverterCache = new(); - - // 🔑 OPTIMIZATION: Cache UnifiedMergeContractResolver instance private static readonly UnifiedMergeContractResolver SharedContractResolver = new(); + + // Cached serializer for merge operations + private static readonly JsonSerializer CachedMergeSerializer = CreateMergeSerializer(); + + private static JsonSerializer CreateMergeSerializer() + { + var settings = new JsonSerializerSettings + { + ContractResolver = SharedContractResolver, + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore, + MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead, + }; + return JsonSerializer.Create(settings); + } public static void DeepPopulateWithMerge(this T target, string json, JsonSerializerSettings? settings = null) where T : notnull { ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(json); - settings ??= new JsonSerializerSettings(); + // Use centralized unwrap helper + json = JsonUtilities.UnwrapJsonString(json); + + // Create a local resolver indicating this serializer is used for a MERGE operation + var resolver = new HybridReferenceResolver(isForMerge: true, estimatedObjectCount: 32); - if (settings.Context.Context is not Dictionary) + JsonSerializer serializer; + if (settings == null) { - settings.Context = new StreamingContext(StreamingContextStates.All, new Dictionary(4)); + // Fast path: reuse cached serializer + serializer = CachedMergeSerializer; + } + else + { + settings.ContractResolver ??= SharedContractResolver; + var serializerSettings = new JsonSerializerSettings(settings) + { + ReferenceResolverProvider = () => resolver + }; + serializer = JsonSerializer.Create(serializerSettings); } - // 🔑 OPTIMIZATION: Use shared contract resolver - settings.ContractResolver ??= SharedContractResolver; - - var serializer = JsonSerializer.Create(settings); - var token = JToken.Parse(json); - - // Handle root-level list merge - if (target is IList targetList) + // Temporarily set the reference resolver for cached serializer + var originalResolver = serializer.ReferenceResolver; + serializer.ReferenceResolver = resolver; + + try { - var type = target.GetType(); - var elemType = TypeCache.GetElementType(type); - - if (elemType != null) + if (target is IList targetList) { - var (isId, idType) = TypeCache.GetIdInfo(elemType); - - if (isId && idType != null) - { - var key = (elemType, idType); - var converterInstance = RootConverterCache.GetOrAdd(key, static k => - (JsonConverter)Activator.CreateInstance( - typeof(IdAwareCollectionMergeConverter<,>).MakeGenericType(k.Item1, k.Item2))!); + var type = target.GetType(); + var elemType = TypeCache.GetElementType(type); - using var reader = token.CreateReader(); - converterInstance.ReadJson(reader, target.GetType(), target, serializer); - return; + if (elemType != null) + { + var (isId, idType) = TypeCache.GetIdInfo(elemType); + if (isId && idType != null) + { + var converterInstance = RootConverterCache.GetOrAdd((elemType, idType), static k => + (JsonConverter)Activator.CreateInstance( + typeof(IdAwareCollectionMergeConverter<,>).MakeGenericType(k.Item1, k.Item2))!); + + // Use JToken for collection merge (needed for complex logic) + var token = JToken.Parse(json); + using var reader = token.CreateReader(); + converterInstance.ReadJson(reader, target.GetType(), target, serializer); + return; + } } } - } - // Normal object-level merge - using (var reader = token.CreateReader()) + // For non-collection targets, use direct JsonTextReader for better performance + using var stringReader = new StringReader(json); + using var jsonReader = new JsonTextReader(stringReader); + serializer.Populate(jsonReader, target); + } + finally { - serializer.Populate(reader, target); + serializer.ReferenceResolver = originalResolver; } } } diff --git a/AyCode.Core/Extensions/SerializeObjectExtensions.cs b/AyCode.Core/Extensions/SerializeObjectExtensions.cs index 033e239..1805f35 100644 --- a/AyCode.Core/Extensions/SerializeObjectExtensions.cs +++ b/AyCode.Core/Extensions/SerializeObjectExtensions.cs @@ -1,156 +1,558 @@ using AyCode.Core.Interfaces; using MessagePack; -using MessagePack.Resolvers; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; +using System.Buffers; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Serialization; -using System.Text.RegularExpressions; +using System.Text; namespace AyCode.Core.Extensions; /// -/// Hybrid reference resolver that uses semantic IDs for IId<T> types -/// and standard numeric IDs for other types. +/// High-performance Base62 encoder for compact $id/$ref values. /// -public class HybridReferenceResolver : IReferenceResolver +internal static class Base62 { - private readonly Dictionary _idToObject = new(StringComparer.Ordinal); - private readonly Dictionary _objectToId = new(ReferenceEqualityComparer.Instance); - private int _nextNumericId = 1; - - public void AddReference(object context, string reference, object value) - { - _idToObject[reference] = value; - _objectToId[value] = reference; - } - - public string GetReference(object context, object value) - { - if (_objectToId.TryGetValue(value, out var existingRef)) - { - return existingRef; - } - - // Check if value implements IId - var type = value.GetType(); - var (isId, idType) = TypeCache.GetIdInfo(type); - - string newRef; - if (isId && idType != null) - { - // Use semantic ID for IId types - var idProperty = type.GetProperty("Id"); - var idValue = idProperty?.GetValue(value); - if (idValue != null && !idValue.Equals(GetDefault(idType))) - { - newRef = $"{type.Name}_{idValue}"; - } - else - { - // Fallback to numeric for IId types with default Id - newRef = (_nextNumericId++).ToString(); - } - } - else - { - // Use numeric ID for non-IId types - newRef = (_nextNumericId++).ToString(); - } - - _idToObject[newRef] = value; - _objectToId[value] = newRef; - return newRef; - } - - public bool IsReferenced(object context, object value) - { - return _objectToId.ContainsKey(value); - } - - public object ResolveReference(object context, string reference) - { - _idToObject.TryGetValue(reference, out var value); - return value!; - } + private const string Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - private static object? GetDefault(Type type) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string Encode(long value) { - return type.IsValueType ? Activator.CreateInstance(type) : null; + if (value == 0) return "0"; + + var isNegative = value < 0; + if (isNegative) value = -value; + + Span buffer = stackalloc char[16]; + var index = buffer.Length; + + while (value > 0) + { + buffer[--index] = Alphabet[(int)(value % 62)]; + value /= 62; + } + + if (isNegative) + buffer[--index] = '-'; + + return new string(buffer[index..]); } } /// -/// Reference equality comparer for proper object identity comparison +/// High-performance hybrid reference resolver using Base62 encoded semantic IDs. /// -internal class ReferenceEqualityComparer : IEqualityComparer +public class HybridReferenceResolver : IReferenceResolver +{ + internal Dictionary? _idToObject; + internal Dictionary? _objectToId; + internal HashSet? _referencedIds; + + private int _nextNumericId = 1; + private static readonly ConcurrentDictionary> _idGetterCache = new(); + + public bool IsForMerge { get; } + private readonly int _estimatedObjectCount; + + public HybridReferenceResolver(bool isForMerge = false, int estimatedObjectCount = 64) + { + IsForMerge = isForMerge; + _estimatedObjectCount = estimatedObjectCount; + } + + internal HashSet ReferencedIds => _referencedIds ??= + new HashSet(_estimatedObjectCount / 4, StringComparer.Ordinal); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Dictionary GetIdToObject() => + _idToObject ??= new Dictionary(_estimatedObjectCount, StringComparer.Ordinal); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Dictionary GetObjectToId() => + _objectToId ??= new Dictionary(_estimatedObjectCount, ReferenceEqualityComparer.Instance); + + public void AddReference(object context, string reference, object value) + { + GetIdToObject()[reference] = value; + GetObjectToId()[value] = reference; + } + + public string GetReference(object context, object value) + { + var objectToId = GetObjectToId(); + if (objectToId.TryGetValue(value, out var existingId)) + { + if (!IsForMerge) + ReferencedIds.Add(existingId); + return existingId; + } + + var type = value.GetType(); + var (isId, idType) = TypeCache.GetIdInfo(type); + + string newRef; + if (isId && idType != null) + { + var idGetter = GetOrCreateIdGetter(type); + var idValue = idGetter(value); + + if (idValue != null && !IsDefaultValue(idValue, idType)) + { + var typeId = TypeCache.GetTypeId(type); + var objectIdAsLong = TypeCache.IdToLong(idValue); + var semanticId = TypeCache.CreateSemanticId(typeId, objectIdAsLong); + newRef = Base62.Encode(semanticId); + } + else + { + newRef = Base62.Encode(-_nextNumericId++); + } + } + else + { + newRef = Base62.Encode(-_nextNumericId++); + } + + GetIdToObject()[newRef] = value; + objectToId[value] = newRef; + + return newRef; + } + + public bool IsReferenced(object context, object value) => _objectToId?.ContainsKey(value) ?? false; + + public object ResolveReference(object context, string reference) => + _idToObject != null && _idToObject.TryGetValue(reference, out var value) ? value : null!; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Func GetOrCreateIdGetter(Type type) => + _idGetterCache.GetOrAdd(type, static t => + { + var prop = t.GetProperty("Id"); + if (prop == null) return static _ => null; + var getMethod = prop.GetGetMethod(); + if (getMethod == null) return static _ => null; + return obj => getMethod.Invoke(obj, null); + }); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDefaultValue(object value, Type type) + { + if (type == typeof(int)) return (int)value == 0; + if (type == typeof(long)) return (long)value == 0L; + if (type == typeof(Guid)) return (Guid)value == Guid.Empty; + if (type == typeof(short)) return (short)value == 0; + if (type == typeof(byte)) return (byte)value == 0; + if (type == typeof(uint)) return (uint)value == 0; + if (type == typeof(ulong)) return (ulong)value == 0; + if (type == typeof(ushort)) return (ushort)value == 0; + if (type == typeof(sbyte)) return (sbyte)value == 0; + return false; + } +} + +internal sealed class ReferenceEqualityComparer : IEqualityComparer { public static readonly ReferenceEqualityComparer Instance = new(); - public new bool Equals(object? x, object? y) => ReferenceEquals(x, y); public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj); } +internal static class JsonReferencePostProcessor +{ + private const string IdMarker = "\"$id\""; + private const string RefMarker = "\"$ref\""; + + public static string RemoveUnreferencedIds(string json, HashSet? referencedIds) + { + if (!json.Contains(IdMarker)) + return json; + + if (referencedIds == null || referencedIds.Count == 0) + return RemoveAllIdsSpan(json); + + return RemoveUnreferencedIdsSpan(json, referencedIds); + } + + private static string RemoveAllIdsSpan(string json) + { + var sb = new StringBuilder(json.Length); + var lastCopyEnd = 0; + var searchStart = 0; + + while (searchStart < json.Length) + { + var idIndex = json.IndexOf(IdMarker, searchStart, StringComparison.Ordinal); + if (idIndex < 0) break; + + if (idIndex > lastCopyEnd) + sb.Append(json, lastCopyEnd, idIndex - lastCopyEnd); + + var endIndex = SkipIdEntry(json, idIndex); + lastCopyEnd = endIndex; + searchStart = endIndex; + } + + if (lastCopyEnd < json.Length) + sb.Append(json, lastCopyEnd, json.Length - lastCopyEnd); + + return sb.Length == json.Length ? json : sb.ToString(); + } + + private static string RemoveUnreferencedIdsSpan(string json, HashSet referencedIds) + { + var sb = new StringBuilder(json.Length); + var lastCopyEnd = 0; + var searchStart = 0; + + while (searchStart < json.Length) + { + var idIndex = json.IndexOf(IdMarker, searchStart, StringComparison.Ordinal); + if (idIndex < 0) break; + + var valueStart = idIndex + IdMarker.Length; + while (valueStart < json.Length && (json[valueStart] == ' ' || json[valueStart] == ':')) + valueStart++; + + string? idValue = null; + var valueEnd = valueStart; + + if (valueStart < json.Length && json[valueStart] == '"') + { + valueStart++; + valueEnd = valueStart; + while (valueEnd < json.Length && json[valueEnd] != '"') + valueEnd++; + + idValue = json.Substring(valueStart, valueEnd - valueStart); + valueEnd++; + } + + while (valueEnd < json.Length && (json[valueEnd] == ' ' || json[valueEnd] == ',')) + valueEnd++; + + if (idValue != null && referencedIds.Contains(idValue)) + { + searchStart = valueEnd; + } + else + { + if (idIndex > lastCopyEnd) + sb.Append(json, lastCopyEnd, idIndex - lastCopyEnd); + + lastCopyEnd = valueEnd; + searchStart = valueEnd; + } + } + + if (lastCopyEnd == 0) + return json; + + if (lastCopyEnd < json.Length) + sb.Append(json, lastCopyEnd, json.Length - lastCopyEnd); + + return sb.ToString(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int SkipIdEntry(string json, int idIndex) + { + var pos = idIndex + IdMarker.Length; + while (pos < json.Length && (json[pos] == ' ' || json[pos] == ':')) + pos++; + if (pos < json.Length && json[pos] == '"') + { + pos++; + while (pos < json.Length && json[pos] != '"') + pos++; + if (pos < json.Length) + pos++; + } + while (pos < json.Length && (json[pos] == ' ' || json[pos] == ',')) + pos++; + return pos; + } + + public static HashSet CollectReferencedIds(string json) + { + var result = new HashSet(StringComparer.Ordinal); + var searchStart = 0; + + while (searchStart < json.Length) + { + var refIndex = json.IndexOf(RefMarker, searchStart, StringComparison.Ordinal); + if (refIndex < 0) break; + + var valueStart = refIndex + RefMarker.Length; + while (valueStart < json.Length && (json[valueStart] == ' ' || json[valueStart] == ':')) + valueStart++; + + if (valueStart < json.Length && json[valueStart] == '"') + { + valueStart++; + var valueEnd = valueStart; + while (valueEnd < json.Length && json[valueEnd] != '"') + valueEnd++; + + if (valueEnd > valueStart) + result.Add(json.Substring(valueStart, valueEnd - valueStart)); + + searchStart = valueEnd + 1; + } + else + { + searchStart = valueStart; + } + } + + return result; + } +} + +internal sealed class PooledStringWriter : StringWriter +{ + private static readonly ObjectPool StringBuilderPool = + new DefaultObjectPool(new StringBuilderPooledObjectPolicy { InitialCapacity = 4096, MaximumRetainedCapacity = 4 * 1024 * 1024 }); + + private readonly StringBuilder _pooledBuilder; + private bool _disposed; + + private PooledStringWriter(StringBuilder sb) : base(sb) => _pooledBuilder = sb; + + public static PooledStringWriter Rent() + { + var sb = StringBuilderPool.Get(); + sb.Clear(); + return new PooledStringWriter(sb); + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + _disposed = true; + StringBuilderPool.Return(_pooledBuilder); + } + base.Dispose(disposing); + } +} + +internal interface ObjectPool where T : class +{ + T Get(); + void Return(T obj); +} + +internal sealed class DefaultObjectPool : ObjectPool where T : class +{ + [ThreadStatic] private static T? _threadLocalItem; + private readonly ConcurrentQueue _pool = new(); + private readonly IPooledObjectPolicy _policy; + private const int MaxPoolSize = 16; + + public DefaultObjectPool(IPooledObjectPolicy policy) => _policy = policy; + + public T Get() + { + var item = _threadLocalItem; + if (item != null) { _threadLocalItem = null; return item; } + return _pool.TryDequeue(out item) ? item : _policy.Create(); + } + + public void Return(T obj) + { + if (!_policy.Return(obj)) return; + if (_threadLocalItem == null) { _threadLocalItem = obj; return; } + if (_pool.Count < MaxPoolSize) _pool.Enqueue(obj); + } +} + +internal interface IPooledObjectPolicy { T Create(); bool Return(T obj); } + +internal sealed class StringBuilderPooledObjectPolicy : IPooledObjectPolicy +{ + public int InitialCapacity { get; init; } = 256; + public int MaximumRetainedCapacity { get; init; } = 4 * 1024 * 1024; + public StringBuilder Create() => new(InitialCapacity); + public bool Return(StringBuilder obj) { if (obj.Capacity > MaximumRetainedCapacity) return false; obj.Clear(); return true; } +} + public static class SerializeObjectExtensions { - // Hybrid settings that support both semantic IDs for IId types - // and standard reference handling for other types + private static readonly UnifiedMergeContractResolver SharedContractResolver = new(); + private static readonly Dictionary EmptyContextDict = new(); + private static readonly JsonSerializer CachedSerializer = CreateCachedSerializer(); + public static JsonSerializerSettings Options => new() { - ContractResolver = new UnifiedMergeContractResolver(), - Context = new StreamingContext(StreamingContextStates.All, new Dictionary()), - - // Enable reference handling with our hybrid resolver + ContractResolver = SharedContractResolver, + Context = new StreamingContext(StreamingContextStates.All, EmptyContextDict), PreserveReferencesHandling = PreserveReferencesHandling.Objects, - ReferenceLoopHandling = ReferenceLoopHandling.Serialize, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, ReferenceResolverProvider = () => new HybridReferenceResolver(), NullValueHandling = NullValueHandling.Ignore, + MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead, + Formatting = Formatting.None, }; - public static string ToJson(this T source, JsonSerializerSettings? options = null) => JsonConvert.SerializeObject(source, options ?? Options); - public static string ToJson(this IQueryable source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson => JsonConvert.SerializeObject(source, options ?? Options); - public static string ToJson(this IEnumerable source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson => JsonConvert.SerializeObject(source, options ?? Options); + private static JsonSerializer CreateCachedSerializer() => JsonSerializer.Create(new JsonSerializerSettings + { + ContractResolver = SharedContractResolver, + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore, + MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead, + Formatting = Formatting.None, + }); + + /// + /// Serialize object to JSON string using high-performance AcJsonSerializer. + /// Uses optimized reference handling with $id/$ref for shared objects. + /// Skips default values (0, false, empty strings, empty collections) to reduce JSON size. + /// + public static string ToJson(this T source, JsonSerializerSettings? options = null) + { + // Use our high-performance custom serializer + return AcJsonSerializer.Serialize(source); + + // ======================================================================== + // OLD IMPLEMENTATION - Newtonsoft with HybridReferenceResolver + // Uncomment below and comment out the AcJsonSerializer.Serialize line above to rollback + // ======================================================================== + // var resolver = new HybridReferenceResolver(estimatedObjectCount: 256); + // var serializer = CachedSerializer; + // + // string json; + // using (var sw = PooledStringWriter.Rent()) + // { + // var originalResolver = serializer.ReferenceResolver; + // serializer.ReferenceResolver = resolver; + // try + // { + // serializer.Serialize(sw, source); + // json = sw.ToString(); + // } + // finally + // { + // serializer.ReferenceResolver = originalResolver; + // } + // } + // + // // Skip post-processing if no $id in output + // if (!json.Contains("\"$id\"")) + // return json; + // + // // If we tracked references, use them + // if (resolver._referencedIds?.Count > 0) + // return JsonReferencePostProcessor.RemoveUnreferencedIds(json, resolver._referencedIds); + // + // // No references and no $ref - remove all $id + // if (!json.Contains("\"$ref\"")) + // return JsonReferencePostProcessor.RemoveUnreferencedIds(json, null); + // + // // Fallback: scan JSON for $ref values + // var referenced = JsonReferencePostProcessor.CollectReferencedIds(json); + // return JsonReferencePostProcessor.RemoveUnreferencedIds(json, referenced); + } + + public static string ToJson(this IQueryable source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson + => AcJsonSerializer.Serialize(source); + // OLD: => ((object)source).ToJson(options); + + public static string ToJson(this IEnumerable source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson + => AcJsonSerializer.Serialize(source); + // OLD: => ((object)source).ToJson(options); public static T? JsonTo(this string json, JsonSerializerSettings? options = null) { - if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"'); - + json = JsonUtilities.UnwrapJsonString(json); + + // Use our high-performance custom deserializer for simple deserialization + // Fall back to Newtonsoft for complex scenarios (custom settings) + if (options == null && typeof(T).IsClass && !typeof(T).IsAbstract && typeof(T).GetConstructor(Type.EmptyTypes) != null) + { + try + { + return (T?)AcJsonDeserializer.Deserialize(json, typeof(T)); + } + catch + { + // Fallback to Newtonsoft if custom deserializer fails + } + } + return JsonConvert.DeserializeObject(json, options ?? Options); + + // ======================================================================== + // OLD IMPLEMENTATION - Always Newtonsoft + // Uncomment below and comment out the above to rollback + // ======================================================================== + // return JsonConvert.DeserializeObject(json, options ?? Options); } public static object? JsonTo(this string json, Type toType, JsonSerializerSettings? options = null) { - if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"'); - + json = JsonUtilities.UnwrapJsonString(json); + + // Use our high-performance custom deserializer for simple deserialization + if (options == null && toType.IsClass && toType.GetConstructor(Type.EmptyTypes) != null) + { + try + { + return AcJsonDeserializer.Deserialize(json, toType); + } + catch + { + // Fallback to Newtonsoft if custom deserializer fails + } + } + return JsonConvert.DeserializeObject(json, toType, options ?? Options); + + // ======================================================================== + // OLD IMPLEMENTATION - Always Newtonsoft + // Uncomment below and comment out the above to rollback + // ======================================================================== + // return JsonConvert.DeserializeObject(json, toType, options ?? Options); } public static void JsonTo(this string json, object target, JsonSerializerSettings? options = null) { - if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"'); - + json = JsonUtilities.UnwrapJsonString(json); + + // For populate/merge, we still use Newtonsoft as it handles complex merge logic + // The AcJsonDeserializer.Populate can be used for simple cases target.DeepPopulateWithMerge(json, options ?? Options); + + // ======================================================================== + // ALTERNATIVE - Use AcJsonDeserializer for populate (simpler merge logic) + // Uncomment below for faster but simpler merge + // ======================================================================== + // if (options == null) + // { + // try + // { + // AcJsonDeserializer.Populate(json, target); + // return; + // } + // catch { } + // } + // target.DeepPopulateWithMerge(json, options ?? Options); } - /// - /// Using JSON - /// [return: NotNullIfNotNull(nameof(src))] - public static TDestination? CloneTo(this object? src, JsonSerializerSettings? options = null) where TDestination : class + public static TDestination? CloneTo(this object? src, JsonSerializerSettings? options = null) where TDestination : class => src?.ToJson(options).JsonTo(options); - /// - /// Using JSON - /// - public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null) => src?.ToJson(options).JsonTo(target, options); + public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null) + => src?.ToJson(options).JsonTo(target, options); public static byte[] ToMessagePack(this object message) => MessagePackSerializer.Serialize(message); public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options) => MessagePackSerializer.Serialize(message, options); - public static T MessagePackTo(this byte[] message) => MessagePackSerializer.Deserialize(message); public static T MessagePackTo(this byte[] message, MessagePackSerializerOptions options) => MessagePackSerializer.Deserialize(message, options); } @@ -163,55 +565,41 @@ public class IgnoreAndRenamePropertySerializerContractResolver : DefaultContract public void IgnoreProperty(Type type, params string[] jsonPropertyNames) { - if (!_ignores.ContainsKey(type)) _ignores[type] = []; - - foreach (var prop in jsonPropertyNames) _ignores[type].Add(prop); + if (!_ignores.TryGetValue(type, out var set)) { set = new HashSet(StringComparer.Ordinal); _ignores[type] = set; } + foreach (var prop in jsonPropertyNames) set.Add(prop); } public void IncludesProperty(Type type, params string[] jsonPropertyNames) { - if (!_includes.ContainsKey(type)) _includes[type] = []; - - foreach (var prop in jsonPropertyNames) _includes[type].Add(prop); + if (!_includes.TryGetValue(type, out var set)) { set = new HashSet(StringComparer.Ordinal); _includes[type] = set; } + foreach (var prop in jsonPropertyNames) set.Add(prop); } public void RenameProperty(Type type, string propertyName, string newJsonPropertyName) { - if (!_renames.ContainsKey(type)) _renames[type] = new Dictionary(); - - _renames[type][propertyName] = newJsonPropertyName; + if (!_renames.TryGetValue(type, out var dict)) { dict = new Dictionary(StringComparer.Ordinal); _renames[type] = dict; } + dict[propertyName] = newJsonPropertyName; } protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) { var property = base.CreateProperty(member, memberSerialization); - if (IsIgnored(property.DeclaringType, property.PropertyName) || !IsIncluded(property.DeclaringType, property.PropertyName)) - { - property.ShouldSerialize = i => false; - property.Ignored = true; - } - - if (IsRenamed(property.DeclaringType, property.PropertyName, out var newJsonPropertyName)) - property.PropertyName = newJsonPropertyName; - + { property.ShouldSerialize = _ => false; property.Ignored = true; } + if (IsRenamed(property.DeclaringType, property.PropertyName, out var newName)) property.PropertyName = newName; return property; } - private bool IsIgnored(Type type, string jsonPropertyName) - { - return _ignores.ContainsKey(type) && _ignores[type].Contains(jsonPropertyName); - } - private bool IsIncluded(Type type, string jsonPropertyName) - { - return _includes.Count == 0 || (_includes.ContainsKey(type) && _includes[type].Contains(jsonPropertyName)); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsIgnored(Type? type, string? name) => type != null && name != null && _ignores.TryGetValue(type, out var set) && set.Contains(name); - private bool IsRenamed(Type type, string jsonPropertyName, out string? newJsonPropertyName) - { - if (_renames.TryGetValue(type, out var renames) && renames.TryGetValue(jsonPropertyName, out newJsonPropertyName)) return true; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsIncluded(Type? type, string? name) => _includes.Count == 0 || (type != null && name != null && _includes.TryGetValue(type, out var set) && set.Contains(name)); - newJsonPropertyName = null; - return false; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsRenamed(Type? type, string? name, out string? newName) + { + if (type != null && name != null && _renames.TryGetValue(type, out var renames) && renames.TryGetValue(name, out newName)) return true; + newName = null; return false; } } diff --git a/AyCode.Core/Helpers/AcObservableCollection.cs b/AyCode.Core/Helpers/AcObservableCollection.cs index eea1e1f..e2ab5bb 100644 --- a/AyCode.Core/Helpers/AcObservableCollection.cs +++ b/AyCode.Core/Helpers/AcObservableCollection.cs @@ -2,6 +2,8 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; +using AyCode.Core.Extensions; +using System.Threading; namespace AyCode.Core.Helpers { @@ -11,6 +13,39 @@ namespace AyCode.Core.Helpers public void Replace(IEnumerable other); public void RemoveRange(IEnumerable other); public void Synchronize(NotifyCollectionChangedEventArgs args); + + /// + /// Populates/merges data from object source while suppressing per-item change events. + /// Fires a single Reset event at the end. + /// + void PopulateFrom(object source); + + /// + /// Populates/merges data from json while suppressing per-item change events. + /// Fires a single Reset event at the end. + /// + void PopulateFromJson(string json, bool clearAll = false); + + /// + /// Begins a batch update operation. All notifications are suppressed until EndUpdate is called. + /// Supports nested calls - only the outermost EndUpdate triggers the notification. + /// + public void BeginUpdate(); + + /// + /// Ends a batch update operation. Triggers a single Reset notification if this is the outermost call. + /// + public void EndUpdate(); + + /// + /// Forces a Reset notification to refresh bound UI controls. + /// + public void NotifyReset(); + + /// + /// Returns true if currently in a batch update operation. + /// + public bool IsUpdating { get; } } public interface IAcObservableCollection : IAcObservableCollection @@ -20,81 +55,298 @@ namespace AyCode.Core.Helpers public void SortAndReplace(IEnumerable other, IComparer comparer); } + /// + /// Thread-safe ObservableCollection with batch update support. + /// All public methods are synchronized using a lock. + /// public class AcObservableCollection : ObservableCollection, IAcObservableCollection { - private bool _suppressChangedEvent; + private readonly object _syncRoot = new(); + private int _updateCount; + + /// + /// Returns true if currently in a batch update operation. + /// + public bool IsUpdating + { + get + { + lock (_syncRoot) + { + return _updateCount > 0; + } + } + } + + /// + /// Gets the synchronization object for external locking scenarios. + /// + public object SyncRoot => _syncRoot; public AcObservableCollection() : base() { } + public AcObservableCollection(List list) : base(list) { } public AcObservableCollection(IEnumerable collection) : base(collection) { } + public void BeginUpdate() + { + lock (_syncRoot) + { + _updateCount++; + } + } + + public void EndUpdate() + { + bool shouldNotify; + lock (_syncRoot) + { + if (_updateCount <= 0) return; + _updateCount--; + shouldNotify = _updateCount == 0; + } + + if (shouldNotify) NotifyReset(); + } + + public void NotifyReset() + { + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); + } + + public new void Add(T item) + { + lock (_syncRoot) + { + base.Add(item); + } + } + + public new bool Remove(T item) + { + lock (_syncRoot) + { + return base.Remove(item); + } + } + + public new void Insert(int index, T item) + { + lock (_syncRoot) + { + base.Insert(index, item); + } + } + + public new void RemoveAt(int index) + { + lock (_syncRoot) + { + base.RemoveAt(index); + } + } + + public new void Clear() + { + lock (_syncRoot) + { + base.Clear(); + } + } + + public new void Move(int oldIndex, int newIndex) + { + lock (_syncRoot) + { + base.Move(oldIndex, newIndex); + } + } + + public new T this[int index] + { + get + { + lock (_syncRoot) + { + return base[index]; + } + } + set + { + lock (_syncRoot) + { + base[index] = value; + } + } + } + + public new int Count + { + get + { + lock (_syncRoot) + { + return base.Count; + } + } + } + + public new bool Contains(T item) + { + lock (_syncRoot) + { + return base.Contains(item); + } + } + + public new int IndexOf(T item) + { + lock (_syncRoot) + { + return base.IndexOf(item); + } + } + + public new void CopyTo(T[] array, int arrayIndex) + { + lock (_syncRoot) + { + base.CopyTo(array, arrayIndex); + } + } + + /// + /// Returns a snapshot copy of the collection for safe enumeration. + /// + public List ToList() + { + lock (_syncRoot) + { + return [..this.Items]; + } + } + public void Replace(IEnumerable other) { - _suppressChangedEvent = true; - - Clear(); - AddRange(other); + BeginUpdate(); + try + { + lock (_syncRoot) + { + base.Clear(); + foreach (var item in other) base.Add(item); + } + } + finally + { + EndUpdate(); + } } public void Replace(IEnumerable other) { - _suppressChangedEvent = true; - - Clear(); - foreach (T item in other) Add(item); - - _suppressChangedEvent = false; - - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); + BeginUpdate(); + try + { + lock (_syncRoot) + { + base.Clear(); + foreach (T item in other) base.Add(item); + } + } + finally + { + EndUpdate(); + } } public void AddRange(IEnumerable other) { - _suppressChangedEvent = true; - - foreach (var item in other) + BeginUpdate(); + try { - if (item is T tItem) Add(tItem); + lock (_syncRoot) + { + foreach (var item in other) + { + if (item is T tItem) base.Add(tItem); + } + } + } + finally + { + EndUpdate(); } - - _suppressChangedEvent = false; - - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); } public void RemoveRange(IEnumerable other) { - _suppressChangedEvent = true; - - foreach (var item in other) + BeginUpdate(); + try { - if (item is T tItem) Remove(tItem); + lock (_syncRoot) + { + foreach (var item in other) + { + if (item is T tItem) base.Remove(tItem); + } + } } + finally + { + EndUpdate(); + } + } - _suppressChangedEvent = false; + public void PopulateFrom(object source) + { + switch (source) + { + case IEnumerable typedSource: + Replace(typedSource); + break; + case IEnumerable enumerable: + Replace(enumerable); + break; + } + } - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); + public void PopulateFromJson(string json, bool clearAll = false) + { + BeginUpdate(); + try + { + lock (_syncRoot) + { + if (clearAll) base.Clear(); + json.JsonTo(this.Items); + } + } + finally + { + EndUpdate(); + } } public void SortAndReplace(IEnumerable other, IComparer comparer) { - List values = new(other); - + var values = new List(other); values.Sort(comparer); Replace(values); } public void Sort(IComparer comparer) { - List values = new(this); - + List values; + lock (_syncRoot) + { + values = new List(this.Items); + } values.Sort(comparer); Replace(values); } @@ -122,7 +374,7 @@ namespace AyCode.Core.Helpers protected override void OnPropertyChanged(PropertyChangedEventArgs e) { - if (_suppressChangedEvent) + if (IsUpdating) return; base.OnPropertyChanged(e); @@ -130,31 +382,10 @@ namespace AyCode.Core.Helpers protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) { - if (_suppressChangedEvent) + if (IsUpdating) return; base.OnCollectionChanged(e); } - - //protected override void ClearItems() - //{ - // base.ClearItems(); - //} - - //protected override void InsertItem(int index, T item) - //{ - // base.InsertItem(index, item); - //} - - //protected override void MoveItem(int oldIndex, int newIndex) - //{ - // base.MoveItem(oldIndex, newIndex); - //} - - //public override event NotifyCollectionChangedEventHandler? CollectionChanged - //{ - // add => base.CollectionChanged += value; - // remove => base.CollectionChanged -= value; - //} } } \ No newline at end of file diff --git a/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs b/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs new file mode 100644 index 0000000..0506902 --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs @@ -0,0 +1,1297 @@ +using AyCode.Core.Enums; +using AyCode.Core.Extensions; +using AyCode.Core.Helpers; +using AyCode.Core.Interfaces; +using AyCode.Core.Loggers; +using AyCode.Services.SignalRs; +using AyCode.Services.Server.SignalRs; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; + +namespace AyCode.Services.Server.Tests.SignalRs; + +#region Test Models + +public class TestDataItem : IId +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Value { get; set; } + + public TestDataItem() { } + public TestDataItem(int id, string name, int value = 0) + { + Id = id; + Name = name; + Value = value; + } + + public override string ToString() => $"TestDataItem[{Id}, {Name}, {Value}]"; +} + +public class TestDataSource : AcSignalRDataSource> +{ + public TestDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags signalRCrudTags, object[]? contextIds = null) + : base(signalRClient, signalRCrudTags, contextIds) + { + } +} + +public class TestObservableDataSource : AcSignalRDataSource> +{ + public TestObservableDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags signalRCrudTags, object[]? contextIds = null) + : base(signalRClient, signalRCrudTags, contextIds) + { + } +} + +#endregion + +#region Mock SignalR Client + +/// +/// Mock SignalR client for testing AcSignalRDataSource without actual network calls +/// +public class MockSignalRClient : AcSignalRClientBase +{ + private readonly ConcurrentDictionary> _responseHandlers = new(); + private readonly ConcurrentBag<(int MessageTag, object? Data, DateTime Timestamp)> _sentMessages = new(); + private static int _idCounter; + + public IReadOnlyList<(int MessageTag, object? Data, DateTime Timestamp)> SentMessages + => _sentMessages.OrderBy(x => x.Timestamp).ToList(); + + public int GetSentMessageCount(int messageTag) + => _sentMessages.Count(m => m.MessageTag == messageTag); + + public static int NextId() => Interlocked.Increment(ref _idCounter); + public static void ResetIdCounter() => _idCounter = 0; + + public MockSignalRClient() : base("http://test.local/hub", new MockLogger()) + { + } + + /// + /// Setup a static response for a specific message tag + /// + public void SetupResponse(int messageTag, TResponse response) + { + _responseHandlers[messageTag] = _ => response; + } + + /// + /// Setup a dynamic response based on the request + /// + public void SetupResponse(int messageTag, Func responseFactory) + { + _responseHandlers[messageTag] = req => responseFactory((TRequest?)req); + } + + /// + /// Setup a response that returns the posted data (echo) + /// + public void SetupEchoResponse(int messageTag) where T : class + { + _responseHandlers[messageTag] = req => req; + } + + /// + /// Clear all response handlers + /// + public void ClearResponses() + { + _responseHandlers.Clear(); + } + + public override Task GetAllAsync(int messageTag, object[]? contextParams) where TResponseData : default + { + _sentMessages.Add((messageTag, contextParams, DateTime.UtcNow)); + + if (_responseHandlers.TryGetValue(messageTag, out var handler)) + { + return Task.FromResult((TResponseData?)handler(contextParams)); + } + return Task.FromResult(default(TResponseData)); + } + + public override Task GetAllAsync(int messageTag, Func, Task> responseCallback, object[]? contextParams) where TResponseData : default + { + _sentMessages.Add((messageTag, contextParams, DateTime.UtcNow)); + + if (_responseHandlers.TryGetValue(messageTag, out var handler)) + { + var response = (TResponseData?)handler(contextParams); + var responseJson = response?.ToJson(); + ISignalResponseMessage message = new SignalResponseMessage(messageTag, SignalResponseStatus.Success, responseJson); + return responseCallback(message); + } + + ISignalResponseMessage errorMessage = new SignalResponseMessage(messageTag, SignalResponseStatus.Error, (string?)null); + return responseCallback(errorMessage); + } + + public override Task GetByIdAsync(int messageTag, object id) where TResponseData : default + { + _sentMessages.Add((messageTag, id, DateTime.UtcNow)); + + if (_responseHandlers.TryGetValue(messageTag, out var handler)) + { + return Task.FromResult((TResponseData?)handler(id)); + } + return Task.FromResult(default(TResponseData)); + } + + public override Task PostDataAsync(int messageTag, TPostData postData) where TPostData : class + { + _sentMessages.Add((messageTag, postData, DateTime.UtcNow)); + + if (_responseHandlers.TryGetValue(messageTag, out var handler)) + { + return Task.FromResult((TPostData?)handler(postData)); + } + // Default: echo back the posted data + return Task.FromResult(postData); + } + + public override Task PostDataAsync(int messageTag, TPostData postData, Func, Task> responseCallback) where TPostData : default + { + _sentMessages.Add((messageTag, postData, DateTime.UtcNow)); + + if (_responseHandlers.TryGetValue(messageTag, out var handler)) + { + var response = (TPostData?)handler(postData); + var responseJson = response?.ToJson(); + ISignalResponseMessage message = new SignalResponseMessage(messageTag, SignalResponseStatus.Success, responseJson); + return responseCallback(message); + } + + // Default: echo back the posted data + ISignalResponseMessage successMessage = new SignalResponseMessage(messageTag, SignalResponseStatus.Success, postData?.ToJson()); + return responseCallback(successMessage); + } + + protected override Task MessageReceived(int messageTag, byte[] messageBytes) + { + return Task.CompletedTask; + } +} + +/// +/// Silent logger for testing - does not require appsettings.json +/// +public class MockLogger : AcLoggerBase +{ + private readonly List _logs = new(); + public IReadOnlyList Logs => _logs; + + public MockLogger() : base(AppType.TestUnit, LogLevel.Error, "MockLogger") + { + } + + public override void Detail(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) + => _logs.Add($"DETAIL: {text}"); + + public override void Debug(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) + => _logs.Add($"DEBUG: {text}"); + + public override void Info(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) + => _logs.Add($"INFO: {text}"); + + public override void Warning(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) + => _logs.Add($"WARN: {text}"); + + public override void Error(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null) + => _logs.Add($"ERROR: {text}"); +} + +#endregion + +[TestClass] +public class AcSignalRDataSourceTests +{ + private const int GetAllTag = 100; + private const int GetItemTag = 101; + private const int AddTag = 102; + private const int UpdateTag = 103; + private const int RemoveTag = 104; + + private MockSignalRClient _mockClient = null!; + private SignalRCrudTags _crudTags = null!; + private TestDataSource _dataSource = null!; + + [TestInitialize] + public void Setup() + { + MockSignalRClient.ResetIdCounter(); + _mockClient = new MockSignalRClient(); + _crudTags = new SignalRCrudTags(GetAllTag, GetItemTag, AddTag, UpdateTag, RemoveTag); + _dataSource = new TestDataSource(_mockClient, _crudTags); + } + + #region Basic CRUD Tests + + [TestMethod] + public void Add_ValidItem_AddsToCollection() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + + // Act + _dataSource.Add(item); + + // Assert + Assert.AreEqual(1, _dataSource.Count); + Assert.IsTrue(_dataSource.Contains(item)); + } + + [TestMethod] + public void Add_ItemWithDefaultId_ThrowsArgumentNullException() + { + // Arrange + var item = new TestDataItem(0, "Invalid Item"); // 0 is default for int + + // Act & Assert + try + { + _dataSource.Add(item); + Assert.Fail("Expected ArgumentNullException was not thrown"); + } + catch (ArgumentNullException) + { + // Expected + } + } + + [TestMethod] + public void Add_DuplicateItem_ThrowsArgumentException() + { + // Arrange + var id = MockSignalRClient.NextId(); + var item1 = new TestDataItem(id, "Item 1"); + var item2 = new TestDataItem(id, "Item 2"); + _dataSource.Add(item1); + + // Act & Assert + try + { + _dataSource.Add(item2); + Assert.Fail("Expected ArgumentException was not thrown"); + } + catch (ArgumentException) + { + // Expected + } + } + + [TestMethod] + public void Remove_ExistingItem_RemovesFromCollection() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + var result = _dataSource.Remove(item); + + // Assert + Assert.IsTrue(result); + Assert.AreEqual(0, _dataSource.Count); + } + + [TestMethod] + public void Remove_NonExistingItem_ReturnsFalse() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + + // Act + var result = _dataSource.Remove(item); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void Indexer_ValidIndex_ReturnsItem() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + var result = _dataSource[0]; + + // Assert + Assert.AreEqual(item.Id, result.Id); + } + + [TestMethod] + public void Indexer_InvalidIndex_ThrowsArgumentOutOfRangeException() + { + // Act & Assert + try + { + var _ = _dataSource[0]; + Assert.Fail("Expected ArgumentOutOfRangeException was not thrown"); + } + catch (ArgumentOutOfRangeException) + { + // Expected + } + } + + [TestMethod] + public void Insert_ValidIndexAndItem_InsertsAtCorrectPosition() + { + // Arrange + var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); + var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); + var item3 = new TestDataItem(MockSignalRClient.NextId(), "Item 3"); + + _dataSource.Add(item1); + _dataSource.Add(item3); + + // Act + _dataSource.Insert(1, item2); + + // Assert + Assert.AreEqual(3, _dataSource.Count); + Assert.AreEqual(item2.Id, _dataSource[1].Id); + } + + [TestMethod] + public void Clear_WithItems_RemovesAllItems() + { + // Arrange + _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), "Item 1")); + _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), "Item 2")); + + // Act + _dataSource.Clear(); + + // Assert + Assert.AreEqual(0, _dataSource.Count); + } + + [TestMethod] + public void TryGetValue_ExistingId_ReturnsTrue() + { + // Arrange + var id = MockSignalRClient.NextId(); + var item = new TestDataItem(id, "Test Item"); + _dataSource.Add(item); + + // Act + var result = _dataSource.TryGetValue(id, out var foundItem); + + // Assert + Assert.IsTrue(result); + Assert.IsNotNull(foundItem); + Assert.AreEqual(id, foundItem.Id); + } + + [TestMethod] + public void TryGetValue_NonExistingId_ReturnsFalse() + { + // Act + var result = _dataSource.TryGetValue(999, out var foundItem); + + // Assert + Assert.IsFalse(result); + Assert.IsNull(foundItem); + } + + [TestMethod] + public void RemoveAt_ValidIndex_RemovesItem() + { + // Arrange + var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); + var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); + _dataSource.Add(item1); + _dataSource.Add(item2); + + // Act + _dataSource.RemoveAt(0); + + // Assert + Assert.AreEqual(1, _dataSource.Count); + Assert.AreEqual(item2.Id, _dataSource[0].Id); + } + + #endregion + + #region Tracking Tests + + [TestMethod] + public void Add_CreatesTrackingItem_WithAddState() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + + // Act + _dataSource.Add(item); + + // Assert + var trackingItems = _dataSource.GetTrackingItems(); + Assert.AreEqual(1, trackingItems.Count); + Assert.AreEqual(TrackingState.Add, trackingItems[0].TrackingState); + Assert.AreEqual(item.Id, trackingItems[0].CurrentValue.Id); + } + + [TestMethod] + public void Remove_AfterAdd_RemovesTrackingItem() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + _dataSource.Remove(item); + + // Assert - Tracking should be empty because Add followed by Remove cancels out + var trackingItems = _dataSource.GetTrackingItems(); + Assert.AreEqual(0, trackingItems.Count); + } + + [TestMethod] + public void TryGetTrackingItem_ExistingItem_ReturnsTrue() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + var result = _dataSource.TryGetTrackingItem(item.Id, out var trackingItem); + + // Assert + Assert.IsTrue(result); + Assert.IsNotNull(trackingItem); + Assert.AreEqual(TrackingState.Add, trackingItem.TrackingState); + } + + [TestMethod] + public void SetTrackingStateToUpdate_AddsUpdateTracking() + { + // Arrange - Load data without tracking + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item", 1); + var innerList = _dataSource.GetReferenceInnerList(); + innerList.Add(item); + + // Act + _dataSource.SetTrackingStateToUpdate(item); + + // Assert + Assert.IsTrue(_dataSource.TryGetTrackingItem(item.Id, out var trackingItem)); + Assert.AreEqual(TrackingState.Update, trackingItem!.TrackingState); + } + + [TestMethod] + public void SetTrackingStateToUpdate_DoesNotChangeAddState() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + _dataSource.SetTrackingStateToUpdate(item); + + // Assert - Should still be Add, not Update + Assert.IsTrue(_dataSource.TryGetTrackingItem(item.Id, out var trackingItem)); + Assert.AreEqual(TrackingState.Add, trackingItem!.TrackingState); + } + + [TestMethod] + public void TryRollbackItem_RollsBackAddedItem() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + var result = _dataSource.TryRollbackItem(item.Id, out var originalValue); + + // Assert + Assert.IsTrue(result); + Assert.IsNull(originalValue); // Added items have no original value + Assert.AreEqual(0, _dataSource.Count); + } + + [TestMethod] + public void Rollback_RollsBackAllChanges() + { + // Arrange + var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); + var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); + _dataSource.Add(item1); + _dataSource.Add(item2); + + // Act + _dataSource.Rollback(); + + // Assert + Assert.AreEqual(0, _dataSource.Count); + Assert.AreEqual(0, _dataSource.GetTrackingItems().Count); + } + + [TestMethod] + public void Clear_WithClearChangeTrackingFalse_KeepsTrackingItems() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + _dataSource.Clear(clearChangeTracking: false); + + // Assert + Assert.AreEqual(0, _dataSource.Count); + Assert.AreEqual(1, _dataSource.GetTrackingItems().Count); + } + + [TestMethod] + public void Clear_WithClearChangeTrackingTrue_RemovesTrackingItems() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + _dataSource.Clear(clearChangeTracking: true); + + // Assert + Assert.AreEqual(0, _dataSource.Count); + Assert.AreEqual(0, _dataSource.GetTrackingItems().Count); + } + + #endregion + + #region Async Save Tests + + [TestMethod] + public async Task Add_WithAutoSave_CallsSignalRClient() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _mockClient.SetupEchoResponse(AddTag); + + // Act + var result = await _dataSource.Add(item, autoSave: true); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(item.Id, result.Id); + Assert.AreEqual(1, _mockClient.GetSentMessageCount(AddTag)); + } + + [TestMethod] + public async Task Add_WithAutoSaveFalse_DoesNotCallSignalR() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + + // Act + var result = await _dataSource.Add(item, autoSave: false); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, _mockClient.GetSentMessageCount(AddTag)); + } + + [TestMethod] + public async Task Remove_WithAutoSave_CallsSignalRClient() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + _mockClient.SetupEchoResponse(RemoveTag); + + // Act + var result = await _dataSource.Remove(item, autoSave: true); + + // Assert + Assert.IsTrue(result); + Assert.AreEqual(1, _mockClient.GetSentMessageCount(RemoveTag)); + } + + [TestMethod] + public async Task Update_WithAutoSave_CallsSignalRClient() + { + // Arrange + var id = MockSignalRClient.NextId(); + var item = new TestDataItem(id, "Test Item", 1); + _dataSource.Add(item); + + var updatedItem = new TestDataItem(id, "Updated Item", 2); + _mockClient.SetupEchoResponse(UpdateTag); + + // Act + var result = await _dataSource.Update(updatedItem, autoSave: true); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(1, _mockClient.GetSentMessageCount(UpdateTag)); + } + + [TestMethod] + public async Task AddOrUpdate_NewItem_AddsItem() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "New Item"); + _mockClient.SetupEchoResponse(AddTag); + + // Act + var result = await _dataSource.AddOrUpdate(item, autoSave: true); + + // Assert + Assert.AreEqual(1, _dataSource.Count); + Assert.AreEqual(1, _mockClient.GetSentMessageCount(AddTag)); + Assert.AreEqual(0, _mockClient.GetSentMessageCount(UpdateTag)); + } + + [TestMethod] + public async Task AddOrUpdate_ExistingItem_UpdatesItem() + { + // Arrange + var id = MockSignalRClient.NextId(); + var item = new TestDataItem(id, "Original", 1); + _dataSource.Add(item); + + var updatedItem = new TestDataItem(id, "Updated", 2); + _mockClient.SetupEchoResponse(UpdateTag); + + // Act + var result = await _dataSource.AddOrUpdate(updatedItem, autoSave: true); + + // Assert + Assert.AreEqual(1, _dataSource.Count); + Assert.AreEqual(1, _mockClient.GetSentMessageCount(UpdateTag)); + } + + [TestMethod] + public async Task SaveChanges_SavesAllTrackedItems() + { + // Arrange + var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); + var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); + _dataSource.Add(item1); + _dataSource.Add(item2); + + _mockClient.SetupEchoResponse(AddTag); + + // Act + var unsavedItems = await _dataSource.SaveChanges(); + + // Assert + Assert.AreEqual(0, unsavedItems.Count); + Assert.AreEqual(2, _mockClient.GetSentMessageCount(AddTag)); + } + + [TestMethod] + public async Task SaveChangesAsync_SavesAllTrackedItems() + { + // Arrange + var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); + var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); + _dataSource.Add(item1); + _dataSource.Add(item2); + + _mockClient.SetupEchoResponse(AddTag); + + // Act + await _dataSource.SaveChangesAsync(); + + // Assert + Assert.AreEqual(0, _dataSource.GetTrackingItems().Count); + } + + #endregion + + #region Load Tests + + [TestMethod] + public async Task LoadDataSource_LoadsItemsFromSignalR() + { + // Arrange + var items = new List + { + new(MockSignalRClient.NextId(), "Item 1"), + new(MockSignalRClient.NextId(), "Item 2"), + new(MockSignalRClient.NextId(), "Item 3") + }; + _mockClient.SetupResponse(GetAllTag, items); + + // Act + await _dataSource.LoadDataSource(); + + // Assert + Assert.AreEqual(3, _dataSource.Count); + Assert.AreEqual(0, _dataSource.GetTrackingItems().Count); // No tracking for loaded items + } + + [TestMethod] + public async Task LoadItem_LoadsSingleItemFromSignalR() + { + // Arrange + var id = MockSignalRClient.NextId(); + var item = new TestDataItem(id, "Loaded Item"); + _mockClient.SetupResponse(GetItemTag, item); + + // Act + var result = await _dataSource.LoadItem(id); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(id, result.Id); + Assert.AreEqual(1, _dataSource.Count); + } + + [TestMethod] + public async Task LoadItem_ReturnsNullForNonExisting() + { + // Arrange + _mockClient.SetupResponse(GetItemTag, null); + + // Act + var result = await _dataSource.LoadItem(999); + + // Assert + Assert.IsNull(result); + Assert.AreEqual(0, _dataSource.Count); + } + + [TestMethod] + public async Task LoadDataSource_FromList_CopiesItems() + { + // Arrange + var sourceList = new List + { + new(MockSignalRClient.NextId(), "Item 1"), + new(MockSignalRClient.NextId(), "Item 2") + }; + + // Act + await _dataSource.LoadDataSource(sourceList); + + // Assert + Assert.AreEqual(2, _dataSource.Count); + } + + #endregion + + #region Thread Safety Tests + + [TestMethod] + public async Task ConcurrentAdds_AreThreadSafe() + { + // Arrange + var tasks = new List(); + var itemCount = 100; + + // Act + for (int i = 0; i < itemCount; i++) + { + var item = new TestDataItem(MockSignalRClient.NextId(), $"Item {i}"); + tasks.Add(Task.Run(() => _dataSource.Add(item))); + } + + await Task.WhenAll(tasks); + + // Assert + Assert.AreEqual(itemCount, _dataSource.Count); + } + + [TestMethod] + public async Task ConcurrentReadsAndWrites_AreThreadSafe() + { + // Arrange - Pre-populate with items + for (int i = 0; i < 50; i++) + { + _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), $"Initial Item {i}")); + } + + var tasks = new List(); + var readCount = 0; + var writeCount = 0; + + // Act - Concurrent reads + for (int i = 0; i < 100; i++) + { + tasks.Add(Task.Run(() => + { + var count = _dataSource.Count; + Interlocked.Increment(ref readCount); + })); + } + + // Act - Concurrent writes + for (int i = 0; i < 50; i++) + { + var item = new TestDataItem(MockSignalRClient.NextId(), $"New Item {i}"); + tasks.Add(Task.Run(() => + { + _dataSource.Add(item); + Interlocked.Increment(ref writeCount); + })); + } + + await Task.WhenAll(tasks); + + // Assert + Assert.AreEqual(100, readCount); + Assert.AreEqual(50, writeCount); + Assert.AreEqual(100, _dataSource.Count); // 50 original + 50 new + } + + [TestMethod] + public async Task ConcurrentAsyncOperations_AreThreadSafe() + { + // Arrange + _mockClient.SetupEchoResponse(AddTag); + + var tasks = new List>(); + + // Act + for (int i = 0; i < 20; i++) + { + var item = new TestDataItem(MockSignalRClient.NextId(), $"Item {i}"); + tasks.Add(_dataSource.Add(item, autoSave: true)); + } + + var results = await Task.WhenAll(tasks); + + // Assert + Assert.AreEqual(20, results.Length); + Assert.AreEqual(20, _dataSource.Count); + } + + [TestMethod] + public void GetEnumerator_ReturnsCopy_SafeForModification() + { + // Arrange + for (int i = 0; i < 10; i++) + { + _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), $"Item {i}")); + } + + // Act & Assert - Should not throw even when modifying during enumeration + var enumeratedItems = new List(); + foreach (var item in _dataSource) + { + enumeratedItems.Add(item); + if (enumeratedItems.Count == 5) + { + // This would throw if we're not using a copy + _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), "New Item")); + } + } + + Assert.AreEqual(10, enumeratedItems.Count); // Original count + Assert.AreEqual(11, _dataSource.Count); // After modification + } + + [TestMethod] + public async Task ConcurrentRemoves_AreThreadSafe() + { + // Arrange + var items = Enumerable.Range(0, 50) + .Select(i => new TestDataItem(MockSignalRClient.NextId(), $"Item {i}")) + .ToList(); + + foreach (var item in items) + { + _dataSource.Add(item); + } + + // Act + var tasks = items.Select(item => Task.Run(() => _dataSource.Remove(item))).ToList(); + await Task.WhenAll(tasks); + + // Assert + Assert.AreEqual(0, _dataSource.Count); + } + + #endregion + + #region Observable Collection Tests + + [TestMethod] + public void WithObservableCollection_BeginEndUpdate_SuppressesNotifications() + { + // Arrange + var observableDataSource = new TestObservableDataSource(_mockClient, _crudTags); + var innerList = observableDataSource.GetReferenceInnerList(); + var notificationCount = 0; + + innerList.CollectionChanged += (s, e) => notificationCount++; + + // Act + innerList.BeginUpdate(); + for (int i = 0; i < 10; i++) + { + innerList.Add(new TestDataItem(MockSignalRClient.NextId(), $"Item {i}")); + } + innerList.EndUpdate(); + + // Assert - Should only have 1 notification (Reset) instead of 10 + Assert.AreEqual(1, notificationCount); + Assert.AreEqual(10, innerList.Count); + } + + [TestMethod] + public void WithObservableCollection_NestedUpdates_OnlyFiresOnce() + { + // Arrange + var observableDataSource = new TestObservableDataSource(_mockClient, _crudTags); + var innerList = observableDataSource.GetReferenceInnerList(); + var notificationCount = 0; + + innerList.CollectionChanged += (s, e) => notificationCount++; + + // Act - Nested BeginUpdate/EndUpdate + innerList.BeginUpdate(); + innerList.BeginUpdate(); + innerList.Add(new TestDataItem(MockSignalRClient.NextId(), "Item 1")); + innerList.EndUpdate(); // Inner - should not fire + innerList.Add(new TestDataItem(MockSignalRClient.NextId(), "Item 2")); + innerList.EndUpdate(); // Outer - should fire once + + // Assert + Assert.AreEqual(1, notificationCount); + Assert.AreEqual(2, innerList.Count); + } + + #endregion + + #region Edge Cases + + [TestMethod] + public void AddRange_AddsMultipleItems() + { + // Arrange + var items = new List + { + new(MockSignalRClient.NextId(), "Item 1"), + new(MockSignalRClient.NextId(), "Item 2"), + new(MockSignalRClient.NextId(), "Item 3") + }; + + // Act + _dataSource.AddRange(items); + + // Assert + Assert.AreEqual(3, _dataSource.Count); + } + + [TestMethod] + public void IndexOf_ReturnsCorrectIndex() + { + // Arrange + var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); + var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); + _dataSource.Add(item1); + _dataSource.Add(item2); + + // Act + var index = _dataSource.IndexOf(item2); + + // Assert + Assert.AreEqual(1, index); + } + + [TestMethod] + public void IndexOf_NonExisting_ReturnsMinusOne() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Item"); + + // Act + var index = _dataSource.IndexOf(item); + + // Assert + Assert.AreEqual(-1, index); + } + + [TestMethod] + public void TryRemove_RemovesAndReturnsItem() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + var result = _dataSource.TryRemove(item.Id, out var removedItem); + + // Assert + Assert.IsTrue(result); + Assert.IsNotNull(removedItem); + Assert.AreEqual(item.Id, removedItem.Id); + Assert.AreEqual(0, _dataSource.Count); + } + + [TestMethod] + public void TryGetIndex_ExistingId_ReturnsTrueWithIndex() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + // Act + var result = _dataSource.TryGetIndex(item.Id, out var index); + + // Assert + Assert.IsTrue(result); + Assert.AreEqual(0, index); + } + + [TestMethod] + public void CopyTo_CopiesItemsToArray() + { + // Arrange + var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); + var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); + _dataSource.Add(item1); + _dataSource.Add(item2); + + var array = new TestDataItem[2]; + + // Act + _dataSource.CopyTo(array); + + // Assert + Assert.AreEqual(item1.Id, array[0].Id); + Assert.AreEqual(item2.Id, array[1].Id); + } + + [TestMethod] + public void SetWorkingReferenceList_SetsNewInnerList() + { + // Arrange + var newList = new List + { + new(MockSignalRClient.NextId(), "Item 1"), + new(MockSignalRClient.NextId(), "Item 2") + }; + + // Act + _dataSource.SetWorkingReferenceList(newList); + + // Assert + Assert.IsTrue(_dataSource.HasWorkingReferenceList); + Assert.AreEqual(2, _dataSource.Count); + Assert.AreSame(newList, _dataSource.GetReferenceInnerList()); + } + + [TestMethod] + public void SetWorkingReferenceList_WithNull_DoesNothing() + { + // Act + _dataSource.SetWorkingReferenceList(null); + + // Assert + Assert.IsFalse(_dataSource.HasWorkingReferenceList); + } + + [TestMethod] + public void AsReadOnly_ReturnsReadOnlyCollection() + { + // Arrange + _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), "Item")); + + // Act + var readOnly = _dataSource.AsReadOnly(); + + // Assert + Assert.IsNotNull(readOnly); + Assert.AreEqual(1, readOnly.Count); + } + + #endregion + + #region Event Tests + + [TestMethod] + public async Task OnDataSourceLoaded_IsCalledAfterLoad() + { + // Arrange + var loadedEventCalled = false; + _dataSource.OnDataSourceLoaded = () => + { + loadedEventCalled = true; + return Task.CompletedTask; + }; + + _mockClient.SetupResponse(GetAllTag, new List()); + + // Act + await _dataSource.LoadDataSource(); + + // Assert + Assert.IsTrue(loadedEventCalled); + } + + [TestMethod] + public async Task OnDataSourceItemChanged_IsCalledAfterLoadItem() + { + // Arrange + TestDataItem? changedItem = null; + TrackingState? changedState = null; + + _dataSource.OnDataSourceItemChanged = args => + { + changedItem = args.Item; + changedState = args.TrackingState; + return Task.CompletedTask; + }; + + var id = MockSignalRClient.NextId(); + var item = new TestDataItem(id, "Test Item"); + _mockClient.SetupResponse(GetItemTag, item); + + // Act + await _dataSource.LoadItem(id); + + // Assert + Assert.IsNotNull(changedItem); + Assert.AreEqual(id, changedItem.Id); + Assert.AreEqual(TrackingState.Get, changedState); + } + + [TestMethod] + public async Task IsSyncing_IsTrue_DuringSaveChanges() + { + // Arrange + var wasSyncing = false; + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + + _mockClient.SetupResponse(AddTag, req => + { + wasSyncing = _dataSource.IsSyncing; + return req!; + }); + + // Act + await _dataSource.SaveChanges(); + + // Assert + Assert.IsTrue(wasSyncing); + Assert.IsFalse(_dataSource.IsSyncing); // Should be false after completion + } + + [TestMethod] + public async Task OnSyncingStateChanged_FiresCorrectly() + { + // Arrange + var syncStates = new List(); + _dataSource.OnSyncingStateChanged += state => syncStates.Add(state); + + var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); + _dataSource.Add(item); + _mockClient.SetupEchoResponse(AddTag); + + // Act + await _dataSource.SaveChanges(); + + // Assert + Assert.AreEqual(2, syncStates.Count); + Assert.IsTrue(syncStates[0]); // Started + Assert.IsFalse(syncStates[1]); // Ended + } + + #endregion + + #region IList Interface Tests + + [TestMethod] + public void IList_Add_AddsItem() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test"); + var list = (System.Collections.IList)_dataSource; + + // Act + var index = list.Add(item); + + // Assert + Assert.AreEqual(0, index); + Assert.AreEqual(1, _dataSource.Count); + } + + [TestMethod] + public void IList_Contains_ReturnsCorrectly() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test"); + _dataSource.Add(item); + var list = (System.Collections.IList)_dataSource; + + // Act & Assert + Assert.IsTrue(list.Contains(item)); + Assert.IsFalse(list.Contains(new TestDataItem(MockSignalRClient.NextId(), "Other"))); + } + + [TestMethod] + public void IList_IndexOf_ReturnsCorrectIndex() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test"); + _dataSource.Add(item); + var list = (System.Collections.IList)_dataSource; + + // Act + var index = list.IndexOf(item); + + // Assert + Assert.AreEqual(0, index); + } + + [TestMethod] + public void IList_Remove_RemovesItem() + { + // Arrange + var item = new TestDataItem(MockSignalRClient.NextId(), "Test"); + _dataSource.Add(item); + var list = (System.Collections.IList)_dataSource; + + // Act + list.Remove(item); + + // Assert + Assert.AreEqual(0, _dataSource.Count); + } + + #endregion + + #region Context and Filter Tests + + [TestMethod] + public async Task LoadDataSource_WithContextIds_PassesContextToSignalR() + { + // Arrange + var contextIds = new object[] { 123, "SomeFilter" }; + var dataSourceWithContext = new TestDataSource(_mockClient, _crudTags, contextIds); + _mockClient.SetupResponse(GetAllTag, new List()); + + // Act + await dataSourceWithContext.LoadDataSource(); + + // Assert + Assert.AreEqual(1, _mockClient.GetSentMessageCount(GetAllTag)); + } + + [TestMethod] + public async Task LoadDataSource_WithFilterText_PassesFilterToSignalR() + { + // Arrange + _dataSource.FilterText = "MyFilter"; + _mockClient.SetupResponse(GetAllTag, new List()); + + // Act + await _dataSource.LoadDataSource(); + + // Assert + Assert.AreEqual(1, _mockClient.GetSentMessageCount(GetAllTag)); + } + + #endregion +} diff --git a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs index b47387a..fb09674 100644 --- a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs +++ b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs @@ -165,9 +165,10 @@ namespace AyCode.Services.Server.SignalRs where TIList : class, IList { private readonly object _syncRoot = new(); + private readonly SemaphoreSlim _asyncLock = new(1, 1); private readonly EqualityComparer _equalityComparerId = EqualityComparer.Default; - protected TIList InnerList = Activator.CreateInstance();// []; //TODO: Dictionary??? - J. + protected TIList InnerList = Activator.CreateInstance(); protected readonly ChangeTracking TrackingItems = new(); public object[]? ContextIds; @@ -178,22 +179,39 @@ namespace AyCode.Services.Server.SignalRs public Func, Task>? OnDataSourceItemChanged; public Func? OnDataSourceLoaded; + + /// + /// Event fired when synchronization state changes (true = syncing started, false = syncing ended) + /// + public event Action? OnSyncingStateChanged; + + private int _activeSyncOperations; + + /// + /// Indicates whether any synchronization operation is in progress + /// + public bool IsSyncing => _activeSyncOperations > 0; + + private void BeginSync() + { + var wasZero = Interlocked.Increment(ref _activeSyncOperations) == 1; + if (wasZero) OnSyncingStateChanged?.Invoke(true); + } + + private void EndSync() + { + var isZero = Interlocked.Decrement(ref _activeSyncOperations) == 0; + if (isZero) OnSyncingStateChanged?.Invoke(false); + } - //protected abstract bool HasIdValue(TDataItem dataItem); - //protected abstract bool IdEquals(TId id1, TId id2); - //protected abstract int FindIndexInnerList(TId id); - //protected abstract TDataItem? FirstOrDefaultInnerList(TId id); - - protected bool HasIdValue(TDataItem dataItem) => !_equalityComparerId.Equals(dataItem.Id, default);//dataItem.Id.IsNullOrEmpty(); + protected bool HasIdValue(TDataItem dataItem) => !_equalityComparerId.Equals(dataItem.Id, default); protected bool IdEquals(TId id1, TId id2) => _equalityComparerId.Equals(id1, id2); - protected int FindIndexInnerList(TId id) => InnerList.FindIndex(x => IdEquals(x.Id, id)); - protected TDataItem? FirstOrDefaultInnerList(TId id) => InnerList.FirstOrDefault(x => IdEquals(x.Id, id)); + protected int FindIndexInnerListUnsafe(TId id) => InnerList.FindIndex(x => IdEquals(x.Id, id)); + protected TDataItem? FirstOrDefaultInnerListUnsafe(TId id) => InnerList.FirstOrDefault(x => IdEquals(x.Id, id)); public AcSignalRDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags signalRCrudTags, object[]? contextIds = null) { - //if (contextIds != null) (ContextIds = new List()).AddRange(contextIds); ContextIds = contextIds; - SignalRCrudTags = signalRCrudTags; SignalRClient = signalRClient; } @@ -206,24 +224,18 @@ namespace AyCode.Services.Server.SignalRs public void SetWorkingReferenceList(TIList? workingIList) { - if (workingIList == null) return; //throw new ArgumentNullException(nameof(workingList)); + if (workingIList == null) return; - Monitor.Enter(_syncRoot); - - try + lock (_syncRoot) { HasWorkingReferenceList = true; if (ReferenceEquals(InnerList, workingIList)) return; - if (workingIList.Count == 0) AddRange(InnerList, workingIList); + if (workingIList.Count == 0) AddRangeUnsafe(InnerList, workingIList); - Clear(true); + ClearUnsafe(true); InnerList = workingIList; } - finally - { - Monitor.Exit(_syncRoot); - } } public TIList GetReferenceInnerList() => InnerList; @@ -232,62 +244,92 @@ namespace AyCode.Services.Server.SignalRs { var parameters = new List(); if (ContextIds != null) parameters.AddRange(ContextIds); - if (FilterText != null) parameters.Add(FilterText); //Az empty string-et beletesszük, h legyen paraméter! - J. + if (FilterText != null) parameters.Add(FilterText); if (parameters.Count == 0) parameters = null; return parameters?.ToArray(); } + #region Load Methods + /// - /// GetAllMessageTag + /// GetAllMessageTag - Synchronous wait version /// - /// - /// public async Task LoadDataSource(bool clearChangeTracking = true) { - if (SignalRCrudTags.GetAllMessageTag == AcSignalRTags.None) throw new ArgumentException($"SignalRCrudTags.GetAllMessageTag == SignalRTags.None"); + if (SignalRCrudTags.GetAllMessageTag == AcSignalRTags.None) + throw new ArgumentException($"SignalRCrudTags.GetAllMessageTag == SignalRTags.None"); - var responseData = (await SignalRClient.GetAllAsync(SignalRCrudTags.GetAllMessageTag, GetContextParams())) ?? throw new NullReferenceException(); + BeginSync(); + try + { + var responseData = (await SignalRClient.GetAllAsync(SignalRCrudTags.GetAllMessageTag, GetContextParams())) + ?? throw new NullReferenceException(); - await LoadDataSource(responseData, false, false, clearChangeTracking); + await LoadDataSource(responseData, false, false, clearChangeTracking); + } + finally + { + EndSync(); + } } + /// + /// GetAllMessageTag - Async callback version with optimized JSON handling + /// public Task LoadDataSourceAsync(bool clearChangeTracking = true) { - if (SignalRCrudTags.GetAllMessageTag == AcSignalRTags.None) throw new ArgumentException($"SignalRCrudTags.GetAllMessageTag == SignalRTags.None"); + if (SignalRCrudTags.GetAllMessageTag == AcSignalRTags.None) + throw new ArgumentException($"SignalRCrudTags.GetAllMessageTag == SignalRTags.None"); - return SignalRClient.GetAllAsync(SignalRCrudTags.GetAllMessageTag, result=> + BeginSync(); + return SignalRClient.GetAllAsync(SignalRCrudTags.GetAllMessageTag, async result => { - if (result.Status != SignalResponseStatus.Success || result.ResponseData == null) - throw new NullReferenceException($"LoadDataSourceAsync; result.Status != SignalResponseStatus.Success || result.ResponseData == null; Status: {SignalResponseStatus.Success}"); + try + { + if (result.Status != SignalResponseStatus.Success || string.IsNullOrEmpty(result.ResponseDataJson)) + throw new NullReferenceException($"LoadDataSourceAsync; Status: {result.Status}"); - return LoadDataSource(result.ResponseData, false, false, clearChangeTracking); + await LoadDataSourceFromJson(result.ResponseDataJson, false, false, clearChangeTracking); + } + finally + { + EndSync(); + } }, GetContextParams()); } - public async Task LoadDataSource(TIList fromSource, bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true) + /// + /// Loads data source directly from JSON string, avoiding double deserialization. + /// + public async Task LoadDataSourceFromJson(string json, bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true) { - Monitor.Enter(_syncRoot); - + await _asyncLock.WaitAsync(); try { - if (!ReferenceEquals(InnerList, fromSource)) + if (!setSourceToWorkingReferenceList) { - if (!setSourceToWorkingReferenceList) + if (InnerList is IAcObservableCollection observable) { - fromSource.CopyTo(InnerList); + observable.PopulateFromJson(json); } else { - Clear(clearChangeTracking); - - if (setSourceToWorkingReferenceList) SetWorkingReferenceList(fromSource); - else AddRange(fromSource); + json.JsonTo(InnerList); + } + } + else + { + var fromSource = json.JsonTo(); + if (fromSource != null) + { + ClearUnsafe(clearChangeTracking); + SetWorkingReferenceListUnsafe(fromSource); } } - else if (clearChangeTracking) TrackingItems.Clear(); - //TODO: Átgondolni, OnDataSourceLoaded meghívódik mielőtt az adatok betöltődnének a .Forget() miatt! - J. + if (clearChangeTracking) TrackingItems.Clear(); + if (refreshDataFromDbAsync) { LoadDataSourceAsync(false).Forget(); @@ -296,7 +338,46 @@ namespace AyCode.Services.Server.SignalRs } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); + } + + if (OnDataSourceLoaded != null) await OnDataSourceLoaded.Invoke(); + } + + public async Task LoadDataSource(TIList fromSource, bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true) + { + await _asyncLock.WaitAsync(); + try + { + if (!ReferenceEquals(InnerList, fromSource)) + { + if (!setSourceToWorkingReferenceList) + { + if (InnerList is IAcObservableCollection observable) + { + observable.BeginUpdate(); + fromSource.CopyTo(InnerList); + observable.EndUpdate(); + } + else fromSource.CopyTo(InnerList); + } + else + { + ClearUnsafe(clearChangeTracking); + SetWorkingReferenceListUnsafe(fromSource); + } + } + else if (clearChangeTracking) TrackingItems.Clear(); + + if (refreshDataFromDbAsync) + { + LoadDataSourceAsync(false).Forget(); + return; + } + } + finally + { + _asyncLock.Release(); } if (OnDataSourceLoaded != null) await OnDataSourceLoaded.Invoke(); @@ -304,152 +385,138 @@ namespace AyCode.Services.Server.SignalRs public async Task LoadItem(TId id) { - if (SignalRCrudTags.GetItemMessageTag == AcSignalRTags.None) throw new ArgumentException($"SignalRCrudTags.GetItemMessageTag == SignalRTags.None"); + if (SignalRCrudTags.GetItemMessageTag == AcSignalRTags.None) + throw new ArgumentException($"SignalRCrudTags.GetItemMessageTag == SignalRTags.None"); - TDataItem? resultitem = null; - - Monitor.Enter(_syncRoot); + var resultitem = await SignalRClient.GetByIdAsync(SignalRCrudTags.GetItemMessageTag, id); + if (resultitem == null) return null; + await _asyncLock.WaitAsync(); try { - resultitem = await SignalRClient.GetByIdAsync(SignalRCrudTags.GetItemMessageTag, id); - if (resultitem == null) return null; - - if (TryGetIndex(id, out var index)) resultitem.CopyTo(InnerList[index]);//InnerList[index] = resultitem); + var index = FindIndexInnerListUnsafe(id); + if (index >= 0) resultitem.CopyTo(InnerList[index]); else InnerList.Add(resultitem); - - var eventArgs = new ItemChangedEventArgs(resultitem, TrackingState.Get); - if (OnDataSourceItemChanged != null) await OnDataSourceItemChanged.Invoke(eventArgs); } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); } + var eventArgs = new ItemChangedEventArgs(resultitem, TrackingState.Get); + if (OnDataSourceItemChanged != null) await OnDataSourceItemChanged.Invoke(eventArgs); + return resultitem; } - /// - /// set: UpdateMessageTag - /// - /// - /// - /// + #endregion + + #region Indexer + public TDataItem this[int index] { get { - if ((uint)index >= (uint)Count) throw new ArgumentOutOfRangeException(nameof(index)); - - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { + if ((uint)index >= (uint)InnerList.Count) + throw new ArgumentOutOfRangeException(nameof(index)); return InnerList[index]; } - finally - { - Monitor.Exit(_syncRoot); - } } set { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { UpdateUnsafe(index, value); } - finally - { - Monitor.Exit(_syncRoot); - } - } } + #endregion + + #region Add Methods + public void Add(TDataItem newValue) { - if (!HasIdValue(newValue)) throw new ArgumentNullException(nameof(newValue), @"Add->HasIdValue(newValue) == false"); + if (!HasIdValue(newValue)) + throw new ArgumentNullException(nameof(newValue), @"Add->HasIdValue(newValue) == false"); - Monitor.Enter(_syncRoot); - - try + lock (_syncRoot) { - if (Contains(newValue)) + if (ContainsUnsafe(newValue)) throw new ArgumentException($@"It already contains this Id! {newValue}", nameof(newValue)); - UnsafeAdd(newValue); - } - finally - { - Monitor.Exit(_syncRoot); + AddUnsafe(newValue); } } - /// - /// AddMessageTag - /// - /// - /// - /// public async Task Add(TDataItem newValue, bool autoSave) { - Monitor.Enter(_syncRoot); + if (!HasIdValue(newValue)) + throw new ArgumentNullException(nameof(newValue), @"Add->HasIdValue(newValue) == false"); + await _asyncLock.WaitAsync(); try { - Add(newValue); + if (ContainsUnsafe(newValue)) + throw new ArgumentException($@"It already contains this Id! {newValue}", nameof(newValue)); - return autoSave ? await SaveItem(newValue, TrackingState.Add) : newValue; + AddUnsafe(newValue); } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); } + + return autoSave ? await SaveItem(newValue, TrackingState.Add) : newValue; } - /// - /// AddMessageTag or UpdateMessageTag - /// - /// - /// - /// public async Task AddOrUpdate(TDataItem newValue, bool autoSave) { - if (!HasIdValue(newValue)) throw new ArgumentNullException(nameof(newValue), @"AddOrUpdate->newValue.Id.IsNullOrEmpty()"); - - Monitor.Enter(_syncRoot); + if (!HasIdValue(newValue)) + throw new ArgumentNullException(nameof(newValue), @"AddOrUpdate->newValue.Id.IsNullOrEmpty()"); + int index; + await _asyncLock.WaitAsync(); try { - var index = IndexOf(newValue); - - return index > -1 ? await Update(index, newValue, autoSave) : await Add(newValue, autoSave); + index = FindIndexInnerListUnsafe(newValue.Id); + if (index > -1) + { + UpdateUnsafe(index, newValue); + } + else + { + AddUnsafe(newValue); + } } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); } + return autoSave + ? await SaveItem(newValue, index > -1 ? TrackingState.Update : TrackingState.Add) + : newValue; } - //public void AddRange(IEnumerable collection) - //{ - // lock (_syncRoot) - // { - - // } - //} - - protected void UnsafeAdd(TDataItem newValue) + private void AddUnsafe(TDataItem newValue) { TrackingItems.AddTrackingItem(TrackingState.Add, newValue); InnerList.Add(newValue); } - public void AddRange(IEnumerable source) => AddRange(source, InnerList); - protected void AddRange(IEnumerable source, TIList destination) + public void AddRange(IEnumerable source) + { + lock (_syncRoot) + { + AddRangeUnsafe(source, InnerList); + } + } + + private void AddRangeUnsafe(IEnumerable source, TIList destination) { - //TODO: CHANGETRACKINGITEM - J. switch (destination) { case IAcObservableCollection dest: @@ -459,31 +526,40 @@ namespace AyCode.Services.Server.SignalRs dest.AddRange(source); break; default: - { foreach (var dataItem in source) destination.Add(dataItem); break; - } } } - /// - /// AddMessageTag - /// - /// - /// - /// - /// - /// + #endregion + + #region Insert Methods + public void Insert(int index, TDataItem newValue) { - if (!HasIdValue(newValue)) throw new ArgumentNullException(nameof(newValue), @"Insert->newValue.Id.IsNullOrEmpty()"); + if (!HasIdValue(newValue)) + throw new ArgumentNullException(nameof(newValue), @"Insert->newValue.Id.IsNullOrEmpty()"); - Monitor.Enter(_syncRoot); + lock (_syncRoot) + { + if (ContainsUnsafe(newValue)) + throw new ArgumentException($@"Insert; It already contains this Id! {newValue}", nameof(newValue)); + TrackingItems.AddTrackingItem(TrackingState.Add, newValue); + InnerList.Insert(index, newValue); + } + } + + public async Task Insert(int index, TDataItem newValue, bool autoSave) + { + if (!HasIdValue(newValue)) + throw new ArgumentNullException(nameof(newValue), @"Insert->newValue.Id.IsNullOrEmpty()"); + + await _asyncLock.WaitAsync(); try { - if (Contains(newValue)) + if (ContainsUnsafe(newValue)) throw new ArgumentException($@"Insert; It already contains this Id! {newValue}", nameof(newValue)); TrackingItems.AddTrackingItem(TrackingState.Add, newValue); @@ -491,309 +567,286 @@ namespace AyCode.Services.Server.SignalRs } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); } + + return autoSave ? await SaveItem(newValue, TrackingState.Add) : newValue; } - public async Task Insert(int index, TDataItem newValue, bool autoSave) + #endregion + + #region Update Methods + + public Task Update(TDataItem newItem, bool autoSave) { - Monitor.Enter(_syncRoot); - - try + int index; + lock (_syncRoot) { - Insert(index, newValue); - - return autoSave ? await SaveItem(newValue, TrackingState.Add) : newValue; - } - finally - { - Monitor.Exit(_syncRoot); + index = FindIndexInnerListUnsafe(newItem.Id); } + return Update(index, newItem, autoSave); } - /// - /// UpdateMessageTag - /// - /// - /// - public Task Update(TDataItem newItem, bool autoSave) => Update(IndexOf(newItem), newItem, autoSave); - /// - /// UpdateMessageTag - /// - /// - /// - /// - /// /// - /// /// - /// - /// public async Task Update(int index, TDataItem newValue, bool autoSave) { - Monitor.Enter(_syncRoot); - + await _asyncLock.WaitAsync(); try { UpdateUnsafe(index, newValue); - - return autoSave ? await SaveItem(newValue, TrackingState.Update) : newValue; } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); } + + return autoSave ? await SaveItem(newValue, TrackingState.Update) : newValue; } private void UpdateUnsafe(int index, TDataItem newValue) { - if (default(TDataItem) != null && newValue == null) throw new NullReferenceException(nameof(newValue)); - if (!HasIdValue(newValue)) throw new ArgumentNullException(nameof(newValue), @"UpdateUnsafe->newValue.Id.IsNullOrEmpty()"); - if ((uint)index >= (uint)Count) throw new ArgumentOutOfRangeException(nameof(index)); + if (default(TDataItem) != null && newValue == null) + throw new NullReferenceException(nameof(newValue)); + if (!HasIdValue(newValue)) + throw new ArgumentNullException(nameof(newValue), @"UpdateUnsafe->newValue.Id.IsNullOrEmpty()"); + if ((uint)index >= (uint)InnerList.Count) + throw new ArgumentOutOfRangeException(nameof(index)); - Monitor.Enter(_syncRoot); + var currentItem = InnerList[index]; - try - { - var currentItem = InnerList[index]; + if (!IdEquals(currentItem.Id, newValue.Id)) + throw new ArgumentException($@"UpdateUnsafe; currentItem.Id != item.Id! {newValue}", nameof(newValue)); - if (!IdEquals(currentItem.Id, newValue.Id)) - throw new ArgumentException($@"UpdateUnsafe; currentItem.Id != item.Id! {newValue}", nameof(newValue)); - - TrackingItems.AddTrackingItem(TrackingState.Update, newValue, currentItem); - InnerList[index] = newValue; - } - finally - { - Monitor.Exit(_syncRoot); - } + TrackingItems.AddTrackingItem(TrackingState.Update, newValue, currentItem); + InnerList[index] = newValue; } + #endregion + + #region Remove Methods - /// - /// RemoveMessageTag - /// - /// - /// public bool Remove(TDataItem item) { - Monitor.Enter(_syncRoot); - - try + lock (_syncRoot) { - var index = IndexOf(item); - + var index = FindIndexInnerListUnsafe(item.Id); if (index < 0) return false; - RemoveAt(index); + RemoveAtUnsafe(index); return true; } - finally - { - Monitor.Exit(_syncRoot); - } } public async Task Remove(TId id, bool autoSave) { - Monitor.Enter(_syncRoot); - + TDataItem? item; + await _asyncLock.WaitAsync(); try { - var item = FirstOrDefaultInnerList(id); - return item == null || await Remove(item, autoSave); + item = FirstOrDefaultInnerListUnsafe(id); + if (item == null) return true; + + var index = FindIndexInnerListUnsafe(id); + if (index < 0) return false; + + RemoveAtUnsafe(index); } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); } + + if (autoSave) + { + await SaveItem(item, TrackingState.Remove); + } + return true; } public async Task Remove(TDataItem item, bool autoSave) { - Monitor.Enter(_syncRoot); - + bool result; + await _asyncLock.WaitAsync(); try { - var result = Remove(item); - - if (!autoSave || !result) return result; - - await SaveItem(item, TrackingState.Remove); - return true; - } - finally - { - Monitor.Exit(_syncRoot); - } - } - /// - /// - /// - /// - /// - /// - public bool TryRemove(TId id, out TDataItem? item) - { - Monitor.Enter(_syncRoot); - - try - { - return TryGetValue(id, out item) && Remove(item); - } - finally - { - Monitor.Exit(_syncRoot); - } - } - - /// - /// RemoveMessageTag - /// - /// - /// - /// /// - /// - public void RemoveAt(int index) - { - Monitor.Enter(_syncRoot); - - try - { - var currentItem = InnerList[index]; - if (!HasIdValue(currentItem)) throw new ArgumentNullException(nameof(currentItem), $@"RemoveAt->item.Id.IsNullOrEmpty(); index: {index}"); - - TrackingItems.AddTrackingItem(TrackingState.Remove, currentItem, currentItem); - InnerList.RemoveAt(index); - } - finally - { - Monitor.Exit(_syncRoot); - } - } - - public async Task RemoveAt(int index, bool autoSave) - { - Monitor.Enter(_syncRoot); - - try - { - var currentItem = InnerList[index]; - RemoveAt(index); - - if (autoSave) + var index = FindIndexInnerListUnsafe(item.Id); + if (index < 0) { - await SaveItem(currentItem, TrackingState.Remove); + result = false; + } + else + { + RemoveAtUnsafe(index); + result = true; } } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); + } + + if (autoSave && result) + { + await SaveItem(item, TrackingState.Remove); + } + return result; + } + + public bool TryRemove(TId id, out TDataItem? item) + { + lock (_syncRoot) + { + item = FirstOrDefaultInnerListUnsafe(id); + if (item == null) return false; + + var index = FindIndexInnerListUnsafe(id); + if (index < 0) return false; + + RemoveAtUnsafe(index); + return true; } } - /// - /// - /// - /// - public List> GetTrackingItems() - { - Monitor.Enter(_syncRoot); + public void RemoveAt(int index) + { + lock (_syncRoot) + { + RemoveAtUnsafe(index); + } + } + + private void RemoveAtUnsafe(int index) + { + var currentItem = InnerList[index]; + if (!HasIdValue(currentItem)) + throw new ArgumentNullException(nameof(currentItem), $@"RemoveAt->item.Id.IsNullOrEmpty(); index: {index}"); + + TrackingItems.AddTrackingItem(TrackingState.Remove, currentItem, currentItem); + InnerList.RemoveAt(index); + } + + public async Task RemoveAt(int index, bool autoSave) + { + TDataItem currentItem; + await _asyncLock.WaitAsync(); try { - return TrackingItems.ToList(); + currentItem = InnerList[index]; + RemoveAtUnsafe(index); } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); + } + + if (autoSave) + { + await SaveItem(currentItem, TrackingState.Remove); + } + } + + #endregion + + #region Tracking Methods + + public List> GetTrackingItems() + { + lock (_syncRoot) + { + return TrackingItems.ToList(); } } public void SetTrackingStateToUpdate(TDataItem item) { - Monitor.Enter(_syncRoot); - - try + lock (_syncRoot) { if (TrackingItems.TryGetTrackingItem(item.Id, out var trackingItem)) { if (trackingItem.TrackingState != TrackingState.Add) trackingItem.TrackingState = TrackingState.Update; - return; } - if (!TryGetValue(item.Id, out var originalItem)) return; + var originalItem = FirstOrDefaultInnerListUnsafe(item.Id); + if (originalItem == null) return; TrackingItems.AddTrackingItem(TrackingState.Update, item, originalItem); } - finally - { - Monitor.Exit(_syncRoot); - } } - /// - /// - /// - /// - /// - /// public bool TryGetTrackingItem(TId id, [NotNullWhen(true)] out TrackingItem? trackingItem) { - Monitor.Enter(_syncRoot); - - try + lock (_syncRoot) { return TrackingItems.TryGetTrackingItem(id, out trackingItem); } - finally - { - Monitor.Exit(_syncRoot); - } - } - /// - /// - /// - /// Unsaved items + #endregion + + #region Save Methods + public async Task>> SaveChanges() { - Monitor.Enter(_syncRoot); - + BeginSync(); try { - foreach (var trackingItem in TrackingItems.ToList()) + List> itemsToSave; + await _asyncLock.WaitAsync(); + try + { + itemsToSave = TrackingItems.ToList(); + } + finally + { + _asyncLock.Release(); + } + + foreach (var trackingItem in itemsToSave) { try { await SaveTrackingItemUnsafe(trackingItem); } - catch(Exception ex) + catch (Exception) { TryRollbackItem(trackingItem.CurrentValue.Id, out _); } } - return TrackingItems.ToList(); + lock (_syncRoot) + { + return TrackingItems.ToList(); + } } finally { - Monitor.Exit(_syncRoot); + EndSync(); } } public async Task SaveChangesAsync() { - Monitor.Enter(_syncRoot); - + BeginSync(); try { - foreach (var trackingItem in TrackingItems.ToList()) + List> itemsToSave; + await _asyncLock.WaitAsync(); + try + { + itemsToSave = TrackingItems.ToList(); + } + finally + { + _asyncLock.Release(); + } + + foreach (var trackingItem in itemsToSave) { try { await SaveTrackingItemUnsafeAsync(trackingItem); } - catch(Exception ex) + catch (Exception) { TryRollbackItem(trackingItem.CurrentValue.Id, out _); } @@ -801,74 +854,61 @@ namespace AyCode.Services.Server.SignalRs } finally { - Monitor.Exit(_syncRoot); + EndSync(); } } - /// - /// - /// - /// - /// public async Task SaveItem(TId id) { - Monitor.Enter(_syncRoot); - + TrackingItem? trackingItem; + await _asyncLock.WaitAsync(); try { - TDataItem resultItem = null!; - - if (TryGetTrackingItem(id, out var trackingItem)) - resultItem = await SaveTrackingItemUnsafe(trackingItem); - - if (resultItem == null) throw new NullReferenceException($"SaveItem; resultItem == null"); - return resultItem; + if (!TrackingItems.TryGetTrackingItem(id, out trackingItem)) + throw new NullReferenceException($"SaveItem; trackingItem not found for id: {id}"); } finally { - Monitor.Exit(_syncRoot); + _asyncLock.Release(); } + + return await SaveTrackingItemUnsafe(trackingItem); } public async Task SaveItem(TId id, TrackingState trackingState) { - //Monitor.Enter(_syncRoot); - - try + TDataItem? item; + lock (_syncRoot) { - TDataItem resultItem = null!; - - if (TryGetValue(id, out var item)) - resultItem = await SaveItem(item, trackingState); - - if (resultItem == null) throw new NullReferenceException($"SaveItem; resultItem == null"); - return resultItem; - } - finally - { - //Monitor.Exit(_syncRoot); + item = FirstOrDefaultInnerListUnsafe(id); } + + if (item == null) + throw new NullReferenceException($"SaveItem; item not found for id: {id}"); + + return await SaveItem(item, trackingState); } - public Task SaveItem(TDataItem item, TrackingState trackingState) => SaveItemUnsafe(item, trackingState); + public Task SaveItem(TDataItem item, TrackingState trackingState) + => SaveItemUnsafe(item, trackingState); - protected Task SaveTrackingItemUnsafe(TrackingItem trackingItem) + private Task SaveTrackingItemUnsafe(TrackingItem trackingItem) => SaveItemUnsafe(trackingItem.CurrentValue, trackingItem.TrackingState); - protected Task SaveTrackingItemUnsafeAsync(TrackingItem trackingItem) + private Task SaveTrackingItemUnsafeAsync(TrackingItem trackingItem) => SaveItemUnsafeAsync(trackingItem.CurrentValue, trackingItem.TrackingState); - protected Task SaveItemUnsafe(TDataItem item, TrackingState trackingState) + private Task SaveItemUnsafe(TDataItem item, TrackingState trackingState) { var messageTag = SignalRCrudTags.GetMessageTagByTrackingState(trackingState); - if (messageTag == AcSignalRTags.None) throw new ArgumentException($"SaveItemUnsafe; messageTag == SignalRTags.None"); + if (messageTag == AcSignalRTags.None) + throw new ArgumentException($"SaveItemUnsafe; messageTag == SignalRTags.None"); return SignalRClient.PostDataAsync(messageTag, item).ContinueWith(x => { if (x.Result == null) { if (TryRollbackItem(item.Id, out _)) return item; - throw new NullReferenceException($"SaveItemUnsafe; result == null"); } @@ -877,30 +917,20 @@ namespace AyCode.Services.Server.SignalRs }); } - protected Task SaveItemUnsafeAsync(TDataItem item, TrackingState trackingState) + private Task SaveItemUnsafeAsync(TDataItem item, TrackingState trackingState) { var messageTag = SignalRCrudTags.GetMessageTagByTrackingState(trackingState); if (messageTag == AcSignalRTags.None) return Task.CompletedTask; return SignalRClient.PostDataAsync(messageTag, item, response => { - Monitor.Enter(_syncRoot); - - try + if (response.Status != SignalResponseStatus.Success || response.ResponseData == null) { - if (response.Status != SignalResponseStatus.Success || response.ResponseData == null) - { - if (TryRollbackItem(item.Id, out _)) return Task.CompletedTask; - - throw new NullReferenceException($"SaveItemUnsafeAsync; result.Status != SignalResponseStatus.Success || result.ResponseData == null; Status: {SignalResponseStatus.Success}"); - } - - return ProcessSavedResponseItem(response.ResponseData, trackingState, item.Id); - } - finally - { - Monitor.Exit(_syncRoot); + if (TryRollbackItem(item.Id, out _)) return Task.CompletedTask; + throw new NullReferenceException($"SaveItemUnsafeAsync; Status: {response.Status}"); } + + return ProcessSavedResponseItem(response.ResponseData, trackingState, item.Id); }); } @@ -908,13 +938,16 @@ namespace AyCode.Services.Server.SignalRs { if (resultItem == null) return Task.CompletedTask; - if (TryGetTrackingItem(originalId, out var trackingItem)) - TrackingItems.Remove(trackingItem); - - if (TryGetIndex(originalId, out var index)) + lock (_syncRoot) { - //InnerList[index] = resultItem; - resultItem.CopyTo(InnerList[index]); + if (TrackingItems.TryGetTrackingItem(originalId, out var trackingItem)) + TrackingItems.Remove(trackingItem); + + var index = FindIndexInnerListUnsafe(originalId); + if (index >= 0) + { + resultItem.CopyTo(InnerList[index]); + } } var eventArgs = new ItemChangedEventArgs(resultItem, trackingState); @@ -923,28 +956,35 @@ namespace AyCode.Services.Server.SignalRs return Task.CompletedTask; } - protected void RollbackItemUnsafe(TrackingItem trackingItem) + #endregion + + #region Rollback Methods + + private void RollbackItemUnsafe(TrackingItem trackingItem) { - if (TryGetIndex(trackingItem.CurrentValue.Id, out var index)) + var index = FindIndexInnerListUnsafe(trackingItem.CurrentValue.Id); + if (index >= 0) { - if (trackingItem.TrackingState == TrackingState.Add) InnerList.RemoveAt(index); - else trackingItem.OriginalValue!.CopyTo(InnerList[index]);//InnerList[index] = trackingItem.OriginalValue!); + if (trackingItem.TrackingState == TrackingState.Add) + InnerList.RemoveAt(index); + else + trackingItem.OriginalValue!.CopyTo(InnerList[index]); } else if (trackingItem.TrackingState != TrackingState.Add) + { InnerList.Add(trackingItem.OriginalValue!); + } TrackingItems.Remove(trackingItem); } public bool TryRollbackItem(TId id, out TDataItem? originalValue) { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { - if (TryGetTrackingItem(id, out var trackingItem)) + if (TrackingItems.TryGetTrackingItem(id, out var trackingItem)) { originalValue = trackingItem.OriginalValue; - RollbackItemUnsafe(trackingItem); return true; } @@ -952,40 +992,29 @@ namespace AyCode.Services.Server.SignalRs originalValue = null; return false; } - finally - { - Monitor.Exit(_syncRoot); - } } public void Rollback() { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { foreach (var trackingItem in TrackingItems.ToList()) RollbackItemUnsafe(trackingItem); } - finally - { - Monitor.Exit(_syncRoot); - } } + #endregion + + #region Collection Properties and Methods + public int Count { get { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { return InnerList.Count; } - finally - { - Monitor.Exit(_syncRoot); - } - } } @@ -993,86 +1022,56 @@ namespace AyCode.Services.Server.SignalRs public void Clear(bool clearChangeTracking) { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { - if (clearChangeTracking) TrackingItems.Clear(); - InnerList.Clear(); - } - finally - { - Monitor.Exit(_syncRoot); + ClearUnsafe(clearChangeTracking); } } + private void ClearUnsafe(bool clearChangeTracking) + { + if (clearChangeTracking) TrackingItems.Clear(); + InnerList.Clear(); + } + public int IndexOf(TId id) { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { - return FindIndexInnerList(id); + return FindIndexInnerListUnsafe(id); } - finally - { - Monitor.Exit(_syncRoot); - } - } public int IndexOf(TDataItem item) => IndexOf(item.Id); + public bool TryGetIndex(TId id, out int index) => (index = IndexOf(id)) > -1; public bool Contains(TDataItem item) => IndexOf(item) > -1; + private bool ContainsUnsafe(TDataItem item) => FindIndexInnerListUnsafe(item.Id) > -1; + public bool TryGetValue(TId id, [NotNullWhen(true)] out TDataItem? item) { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { - item = FirstOrDefaultInnerList(id);//InnerList.FirstOrDefault(x => x.Id == id); + item = FirstOrDefaultInnerListUnsafe(id); return item != null; } - finally - { - Monitor.Exit(_syncRoot); - } } public void CopyTo(TDataItem[] array) => CopyTo(array, 0); public void CopyTo(TDataItem[] array, int arrayIndex) { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { InnerList.CopyTo(array, arrayIndex); } - finally - { - Monitor.Exit(_syncRoot); - } - } public int BinarySearch(int index, int count, TDataItem item, IComparer? comparer) { throw new NotImplementedException($"BinarySearch"); - if (index < 0) - throw new ArgumentOutOfRangeException(nameof(index)); - if (count < 0) - throw new ArgumentOutOfRangeException(nameof(count)); - if (Count - index < count) - throw new ArgumentException("Invalid length"); - - //Monitor.Enter(_syncRoot); - //try - //{ - // return InnerList.BinarySearch(index, count, item, comparer); - //} - //finally - //{ - // Monitor.Exit(_syncRoot); - //} } public int BinarySearch(TDataItem item) => BinarySearch(0, Count, item, null); @@ -1080,24 +1079,32 @@ namespace AyCode.Services.Server.SignalRs public IEnumerator GetEnumerator() { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { - //return InnerList.ToList().GetEnumerator(); - return InnerList.GetEnumerator(); + // Return a copy to avoid modification during enumeration + return InnerList.ToList().GetEnumerator(); } - finally - { - Monitor.Exit(_syncRoot); - } - } public ReadOnlyCollection AsReadOnly() => new(this); - private static bool IsCompatibleObject(object? value) => (value is TDataItem) || (value == null && default(TDataItem) == null); + + private static bool IsCompatibleObject(object? value) + => (value is TDataItem) || (value == null && default(TDataItem) == null); + private void SetWorkingReferenceListUnsafe(TIList workingIList) + { + HasWorkingReferenceList = true; + if (ReferenceEquals(InnerList, workingIList)) return; - #region IList, ICollection + if (workingIList.Count == 0) AddRangeUnsafe(InnerList, workingIList); + + ClearUnsafe(true); + InnerList = workingIList; + } + + #endregion + + #region IList, ICollection Interface Implementation bool IList.IsReadOnly => false; @@ -1106,7 +1113,8 @@ namespace AyCode.Services.Server.SignalRs get => this[index]; set { - if (default(TDataItem) != null && value == null) throw new NullReferenceException(nameof(value)); + if (default(TDataItem) != null && value == null) + throw new NullReferenceException(nameof(value)); try { @@ -1121,7 +1129,8 @@ namespace AyCode.Services.Server.SignalRs int IList.Add(object? item) { - if (default(TDataItem) != null && item == null) throw new NullReferenceException(nameof(item)); + if (default(TDataItem) != null && item == null) + throw new NullReferenceException(nameof(item)); try { @@ -1138,11 +1147,12 @@ namespace AyCode.Services.Server.SignalRs void IList.Clear() => Clear(true); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); bool IList.Contains(object? item) => IsCompatibleObject(item) && Contains((TDataItem)item!); - int IList.IndexOf(object? item) => (IsCompatibleObject(item)) ? IndexOf((TDataItem)item!) : -1; + int IList.IndexOf(object? item) => IsCompatibleObject(item) ? IndexOf((TDataItem)item!) : -1; void IList.Insert(int index, object? item) { - if (default(TDataItem) != null && item == null) throw new NullReferenceException(nameof(item)); + if (default(TDataItem) != null && item == null) + throw new NullReferenceException(nameof(item)); try { @@ -1163,24 +1173,15 @@ namespace AyCode.Services.Server.SignalRs void ICollection.CopyTo(Array array, int arrayIndex) { - if ((array != null) && (array.Rank != 1)) - { - throw new ArgumentException(); - } + if (array != null && array.Rank != 1) + throw new ArgumentException("Multi-dimensional arrays not supported"); try { - Monitor.Enter(_syncRoot); - try + lock (_syncRoot) { - //TODO: _list.ToArray() - ez nem az igazi... - J. Array.Copy(InnerList.ToArray(), 0, array!, arrayIndex, InnerList.Count); } - finally - { - Monitor.Exit(_syncRoot); - } - } catch (ArrayTypeMismatchException) { @@ -1194,7 +1195,7 @@ namespace AyCode.Services.Server.SignalRs void IList.RemoveAt(int index) => RemoveAt(index); int IReadOnlyCollection.Count => Count; - #endregion IList, ICollection + #endregion } public class ItemChangedEventArgs where T : class diff --git a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs index 00c16da..292e3b2 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs @@ -132,6 +132,9 @@ public sealed class SignalResponseJsonMessage : ISignalResponseMessage [Key(1)] public SignalResponseStatus Status { get; set; } [Key(2)] public string? ResponseData { get; set; } = null; + + [IgnoreMember] + public string? ResponseDataJson => ResponseData; public SignalResponseJsonMessage(){} @@ -154,29 +157,91 @@ public sealed class SignalResponseJsonMessage : ISignalResponseMessage } } +/// +/// Signal response message with lazy deserialization support. +/// ResponseData is only deserialized on first access and cached. +/// Use ResponseDataJson for direct JSON access without deserialization. +/// [MessagePackObject] -public sealed class SignalResponseMessage(int messageTag, SignalResponseStatus status, TResponseData? responseData) : ISignalResponseMessage +public sealed class SignalResponseMessage : ISignalResponseMessage { - [Key(0)] public int MessageTag { get; set; } - [Key(1)] public SignalResponseStatus Status { get; set; } = status; - [Key(2)] public TResponseData? ResponseData { get; set; } = responseData; -} + [IgnoreMember] + private TResponseData? _responseData; + + [IgnoreMember] + private bool _isDeserialized; -public sealed class SignalResponseStatusMessage(SignalResponseStatus status) : ISignalRMessage -{ - public SignalResponseStatus Status { get; set; } = status; -} + [Key(0)] + public int MessageTag { get; set; } + + [Key(1)] + public SignalResponseStatus Status { get; set; } + + /// + /// Raw JSON string. Use this for direct JSON access without triggering deserialization. + /// + [Key(2)] + public string? ResponseDataJson { get; set; } -//[MessagePackObject] -//public sealed class SignalResponseMessage(SignalResponseStatus status) : ISignalResponseMessage -//{ -// [Key(0)] -// public SignalResponseStatus Status { get; set; } = status; -//} + /// + /// Deserialized response data. Lazy-loaded on first access. + /// + [IgnoreMember] + public TResponseData? ResponseData + { + get + { + if (!_isDeserialized) + { + _responseData = ResponseDataJson != null + ? ResponseDataJson.JsonTo() + : default; + _isDeserialized = true; + } + return _responseData; + } + set + { + _responseData = value; + _isDeserialized = true; + ResponseDataJson = value?.ToJson(); + } + } + + public SignalResponseMessage() + { + } + + public SignalResponseMessage(int messageTag, SignalResponseStatus status) + { + MessageTag = messageTag; + Status = status; + } + + public SignalResponseMessage(int messageTag, SignalResponseStatus status, TResponseData? responseData) + : this(messageTag, status) + { + ResponseData = responseData; + } + + public SignalResponseMessage(int messageTag, SignalResponseStatus status, string? responseDataJson) + : this(messageTag, status) + { + ResponseDataJson = responseDataJson; + } +} public interface ISignalResponseMessage : ISignalResponseMessage { + /// + /// Deserialized response data. May trigger lazy deserialization. + /// TResponseData? ResponseData { get; set; } + + /// + /// Raw JSON string for direct access without deserialization. + /// + string? ResponseDataJson { get; } } public interface ISignalResponseMessage : ISignalRMessage diff --git a/BenchmarkSuite1/BenchmarkSuite1.csproj b/BenchmarkSuite1/BenchmarkSuite1.csproj new file mode 100644 index 0000000..620211d --- /dev/null +++ b/BenchmarkSuite1/BenchmarkSuite1.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + Exe + enable + enable + + + + + + + + + + + + + + + diff --git a/BenchmarkSuite1/Program.cs b/BenchmarkSuite1/Program.cs new file mode 100644 index 0000000..c1c3a61 --- /dev/null +++ b/BenchmarkSuite1/Program.cs @@ -0,0 +1,66 @@ +using BenchmarkDotNet.Running; + +namespace BenchmarkSuite1 +{ + internal class Program + { + static void Main(string[] args) + { + // Quick size comparison test + if (args.Length > 0 && args[0] == "--sizes") + { + RunSizeComparison(); + return; + } + + var _ = BenchmarkRunner.Run(typeof(Program).Assembly); + } + + static void RunSizeComparison() + { + Console.WriteLine("=== JSON Size Comparison ===\n"); + + var benchmark = new AyCode.Core.Benchmarks.SerializationBenchmarks(); + + // Manually invoke setup + var setupMethod = typeof(AyCode.Core.Benchmarks.SerializationBenchmarks) + .GetMethod("Setup"); + setupMethod?.Invoke(benchmark, null); + + // Get JSON sizes via reflection (private fields) + var newtonsoftJson = typeof(AyCode.Core.Benchmarks.SerializationBenchmarks) + .GetField("_newtonsoftJson", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.GetValue(benchmark) as string; + + var ayCodeJson = typeof(AyCode.Core.Benchmarks.SerializationBenchmarks) + .GetField("_ayCodeJson", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.GetValue(benchmark) as string; + + if (newtonsoftJson != null && ayCodeJson != null) + { + var newtonsoftBytes = System.Text.Encoding.UTF8.GetByteCount(newtonsoftJson); + var ayCodeBytes = System.Text.Encoding.UTF8.GetByteCount(ayCodeJson); + + Console.WriteLine($"Newtonsoft JSON (no refs):"); + Console.WriteLine($" - Characters: {newtonsoftJson.Length:N0}"); + Console.WriteLine($" - Bytes: {newtonsoftBytes:N0} ({newtonsoftBytes / 1024.0 / 1024.0:F2} MB)"); + Console.WriteLine(); + + Console.WriteLine($"AyCode JSON (with refs):"); + Console.WriteLine($" - Characters: {ayCodeJson.Length:N0}"); + Console.WriteLine($" - Bytes: {ayCodeBytes:N0} ({ayCodeBytes / 1024.0 / 1024.0:F2} MB)"); + Console.WriteLine(); + + var reduction = (1.0 - (double)ayCodeBytes / newtonsoftBytes) * 100; + Console.WriteLine($"Size Reduction: {reduction:F1}%"); + Console.WriteLine($"AyCode is {(reduction > 0 ? "smaller" : "larger")} by {Math.Abs(newtonsoftBytes - ayCodeBytes):N0} bytes"); + + // Count $ref occurrences + var refCount = System.Text.RegularExpressions.Regex.Matches(ayCodeJson, @"\$ref").Count; + var idCount = System.Text.RegularExpressions.Regex.Matches(ayCodeJson, @"\$id").Count; + Console.WriteLine($"\nAyCode $id count: {idCount}"); + Console.WriteLine($"AyCode $ref count: {refCount}"); + } + } + } +} diff --git a/BenchmarkSuite1/SerializationBenchmarks.cs b/BenchmarkSuite1/SerializationBenchmarks.cs new file mode 100644 index 0000000..3c76de9 --- /dev/null +++ b/BenchmarkSuite1/SerializationBenchmarks.cs @@ -0,0 +1,516 @@ +using AyCode.Core.Extensions; +using AyCode.Core.Interfaces; +using BenchmarkDotNet.Attributes; +using Newtonsoft.Json; +using System.Text; + +namespace AyCode.Core.Benchmarks; + +[MemoryDiagnoser] +public class SerializationBenchmarks +{ + // Complex graph with 7 levels, ~1500 objects, cross-references + private Level1_Company _complexGraph = null!; + + // Pre-serialized JSON for deserialization benchmarks + private string _newtonsoftJson = null!; + private string _ayCodeJson = null!; + + // Settings + private JsonSerializerSettings _newtonsoftNoRefSettings = null!; + private JsonSerializerSettings _ayCodeSettings = null!; + + [GlobalSetup] + public void Setup() + { + // Newtonsoft WITHOUT reference handling (baseline) + _newtonsoftNoRefSettings = new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore, // Fair comparison - also skip defaults + Formatting = Formatting.None + }; + + // AyCode WITH reference handling (our optimized solution) + _ayCodeSettings = SerializeObjectExtensions.Options; + + // Create complex 7-level graph with ~1500 objects and cross-references + _complexGraph = CreateComplexGraph(); + + // Pre-serialize for deserialization benchmarks + _newtonsoftJson = JsonConvert.SerializeObject(_complexGraph, _newtonsoftNoRefSettings); + _ayCodeJson = _complexGraph.ToJson(_ayCodeSettings); + + // Output sizes for comparison + var newtonsoftBytes = Encoding.UTF8.GetByteCount(_newtonsoftJson); + var ayCodeBytes = Encoding.UTF8.GetByteCount(_ayCodeJson); + + Console.WriteLine("=== JSON Size Comparison ==="); + Console.WriteLine($"Newtonsoft (skip defaults): {_newtonsoftJson.Length:N0} chars ({newtonsoftBytes:N0} bytes, {newtonsoftBytes / 1024.0:F1} KB)"); + Console.WriteLine($"AcJsonSerializer (refs+skip): {_ayCodeJson.Length:N0} chars ({ayCodeBytes:N0} bytes, {ayCodeBytes / 1024.0:F1} KB)"); + Console.WriteLine($"Size reduction: {(1.0 - (double)ayCodeBytes / newtonsoftBytes) * 100:F1}%"); + Console.WriteLine($"Bytes saved: {newtonsoftBytes - ayCodeBytes:N0}"); + } + + #region Serialization Benchmarks + + [Benchmark(Description = "Newtonsoft (no refs)")] + [BenchmarkCategory("Serialize")] + public string Serialize_Newtonsoft_NoRefs() + => JsonConvert.SerializeObject(_complexGraph, _newtonsoftNoRefSettings); + + [Benchmark(Description = "AyCode (with refs)")] + [BenchmarkCategory("Serialize")] + public string Serialize_AyCode_WithRefs() + => _complexGraph.ToJson(_ayCodeSettings); + + #endregion + + #region Deserialization Benchmarks + + [Benchmark(Description = "Newtonsoft (no refs)")] + [BenchmarkCategory("Deserialize")] + public Level1_Company? Deserialize_Newtonsoft_NoRefs() + => JsonConvert.DeserializeObject(_newtonsoftJson, _newtonsoftNoRefSettings); + + [Benchmark(Description = "AyCode (with refs)")] + [BenchmarkCategory("Deserialize")] + public Level1_Company? Deserialize_AyCode_WithRefs() + => _ayCodeJson.JsonTo(_ayCodeSettings); + + [Benchmark(Description = "AcJsonDeserializer (custom)")] + [BenchmarkCategory("Deserialize")] + public Level1_Company? Deserialize_AcJsonDeserializer() + => AcJsonDeserializer.Deserialize(_ayCodeJson); + + #endregion + + #region JSON Size Comparison (not timed, just for reporting) + + [Benchmark(Description = "JSON Size - Newtonsoft")] + [BenchmarkCategory("Size")] + public int JsonSize_Newtonsoft() => _newtonsoftJson.Length; + + [Benchmark(Description = "JSON Size - AyCode")] + [BenchmarkCategory("Size")] + public int JsonSize_AyCode() => _ayCodeJson.Length; + + #endregion + + #region Complex Graph Factory - 7 Levels, ~1500 objects, Cross-references + + private static int _idCounter = 1; + + /// + /// Creates a 7-level deep graph with approximately 1500 objects and cross-references. + /// Structure: Company -> Departments -> Teams -> Projects -> Tasks -> SubTasks -> Comments + /// Each object has 8-15 properties of various types. + /// + private static Level1_Company CreateComplexGraph() + { + _idCounter = 1; + + // Shared references (cross-references across the graph) + var sharedTags = Enumerable.Range(1, 10) + .Select(i => new SharedTag + { + Id = _idCounter++, + Name = $"Tag-{i}", + Color = $"#{i:X2}{i * 10:X2}{i * 20:X2}", + Priority = i % 5, + IsActive = i % 2 == 0, + CreatedAt = DateTime.UtcNow.AddDays(-i * 10), + Metadata = $"Metadata for tag {i}" + }) + .ToList(); + + var sharedCategories = Enumerable.Range(1, 5) + .Select(i => new SharedCategory + { + Id = _idCounter++, + Name = $"Category-{i}", + Description = $"Description for category {i} with some extra text to make it longer", + SortOrder = i * 100, + IconUrl = $"https://icons.example.com/cat-{i}.png", + IsDefault = i == 1, + ParentCategoryId = i > 1 ? i - 1 : null, + CreatedAt = DateTime.UtcNow.AddMonths(-i), + UpdatedAt = DateTime.UtcNow.AddDays(-i) + }) + .ToList(); + + var sharedUser = new SharedUser + { + Id = _idCounter++, + Username = "admin", + Email = "admin@company.com", + FirstName = "System", + LastName = "Administrator", + PhoneNumber = "+1-555-0100", + IsActive = true, + Role = UserRole.Admin, + LastLoginAt = DateTime.UtcNow.AddHours(-1), + CreatedAt = DateTime.UtcNow.AddYears(-2), + Preferences = new UserPreferences + { + Theme = "dark", + Language = "en-US", + NotificationsEnabled = true, + EmailDigestFrequency = "daily" + } + }; + + // Level 1: Company (1 object) + var company = new Level1_Company + { + Id = _idCounter++, + Name = "TechCorp International", + LegalName = "TechCorp International Holdings Ltd.", + TaxId = "TC-123456789", + FoundedDate = new DateTime(2010, 3, 15), + EmployeeCount = 1500, + AnnualRevenue = 125_000_000.50m, + IsPubliclyTraded = true, + StockSymbol = "TECH", + HeadquartersAddress = "123 Innovation Drive, Tech City, TC 12345", + Website = "https://www.techcorp.example.com", + PrimaryContact = sharedUser, + MainCategory = sharedCategories[0], + Tags = [sharedTags[0], sharedTags[1], sharedTags[2]], + CreatedAt = DateTime.UtcNow.AddYears(-5), + UpdatedAt = DateTime.UtcNow + }; + + // Level 2: Departments (5 objects) + company.Departments = Enumerable.Range(1, 5).Select(deptIdx => new Level2_Department + { + Id = _idCounter++, + Name = $"Department-{deptIdx}", + Code = $"DEPT-{deptIdx:D3}", + Description = $"This is department {deptIdx} responsible for various operations and strategic initiatives", + Budget = 1_000_000m + (deptIdx * 250_000m), + HeadCount = 50 + (deptIdx * 20), + Location = $"Building {(char)('A' + deptIdx - 1)}, Floor {deptIdx}", + CostCenter = $"CC-{1000 + deptIdx}", + IsActive = true, + Manager = sharedUser, // Cross-reference + Category = sharedCategories[deptIdx % sharedCategories.Count], // Cross-reference + Tags = [sharedTags[deptIdx % sharedTags.Count], sharedTags[(deptIdx + 1) % sharedTags.Count]], // Cross-reference + EstablishedDate = DateTime.UtcNow.AddYears(-4).AddMonths(deptIdx), + CreatedAt = DateTime.UtcNow.AddYears(-4), + UpdatedAt = DateTime.UtcNow.AddMonths(-deptIdx), + // Level 3: Teams (6 per department = 30 total) + Teams = Enumerable.Range(1, 6).Select(teamIdx => new Level3_Team + { + Id = _idCounter++, + Name = $"Team-{deptIdx}-{teamIdx}", + Acronym = $"T{deptIdx}{teamIdx}", + Description = $"Team {teamIdx} in department {deptIdx}, focused on delivering excellence", + MemberCount = 5 + (teamIdx * 2), + Capacity = 10 + (teamIdx * 2), + Utilization = 0.65 + (teamIdx * 0.05), + SprintLength = 14, + VelocityAverage = 42.5 + teamIdx, + IsRemote = teamIdx % 3 == 0, + Timezone = teamIdx % 2 == 0 ? "UTC" : "America/New_York", + SlackChannel = $"#team-{deptIdx}-{teamIdx}", + TeamLead = sharedUser, // Cross-reference + PrimaryTag = sharedTags[(deptIdx + teamIdx) % sharedTags.Count], // Cross-reference + CreatedAt = DateTime.UtcNow.AddYears(-3).AddMonths(teamIdx), + UpdatedAt = DateTime.UtcNow.AddDays(-teamIdx * 7), + // Level 4: Projects (4 per team = 120 total) + Projects = Enumerable.Range(1, 4).Select(projIdx => new Level4_Project + { + Id = _idCounter++, + Name = $"Project-{deptIdx}-{teamIdx}-{projIdx}", + Code = $"PRJ-{deptIdx}{teamIdx}{projIdx:D2}", + Description = $"Project {projIdx} for team {teamIdx}, delivering key business value and innovation", + Status = (ProjectStatus)(projIdx % 4), + Priority = (Priority)(projIdx % 3), + Budget = 50_000m + (projIdx * 15_000m), + SpentAmount = 25_000m + (projIdx * 5_000m), + ProgressPercent = 0.1 + (projIdx * 0.2), + StartDate = DateTime.UtcNow.AddMonths(-projIdx * 2), + DueDate = DateTime.UtcNow.AddMonths(projIdx), + CompletedDate = projIdx == 4 ? DateTime.UtcNow.AddDays(-10) : null, + EstimatedHours = 200 + (projIdx * 50), + ActualHours = 150 + (projIdx * 40), + RiskLevel = projIdx % 3, + Owner = sharedUser, // Cross-reference + Category = sharedCategories[projIdx % sharedCategories.Count], // Cross-reference + Tags = [sharedTags[projIdx % sharedTags.Count]], // Cross-reference + CreatedAt = DateTime.UtcNow.AddMonths(-projIdx * 3), + UpdatedAt = DateTime.UtcNow.AddDays(-projIdx), + // Level 5: Tasks (5 per project = 600 total) + Tasks = Enumerable.Range(1, 5).Select(taskIdx => new Level5_Task + { + Id = _idCounter++, + Title = $"Task-{deptIdx}-{teamIdx}-{projIdx}-{taskIdx}", + Description = $"Detailed task description for task {taskIdx} in project {projIdx}. This includes requirements and acceptance criteria.", + Status = (TaskStatus)(taskIdx % 5), + Priority = (Priority)(taskIdx % 3), + Type = (TaskType)(taskIdx % 4), + StoryPoints = taskIdx * 2, + EstimatedHours = 4 + taskIdx * 2, + ActualHours = 3 + taskIdx * 1.5, + DueDate = DateTime.UtcNow.AddDays(taskIdx * 3), + CompletedDate = taskIdx <= 2 ? DateTime.UtcNow.AddDays(-taskIdx) : null, + IsBlocked = taskIdx == 3, + BlockedReason = taskIdx == 3 ? "Waiting for external dependency" : null, + Assignee = sharedUser, // Cross-reference + Reporter = sharedUser, // Cross-reference + Labels = [sharedTags[taskIdx % sharedTags.Count]], // Cross-reference + CreatedAt = DateTime.UtcNow.AddDays(-taskIdx * 5), + UpdatedAt = DateTime.UtcNow.AddHours(-taskIdx), + // Level 6: SubTasks (3 per task = 1800 total -> we'll limit to keep ~1500) + SubTasks = Enumerable.Range(1, 2).Select(subIdx => new Level6_SubTask + { + Id = _idCounter++, + Title = $"SubTask-{taskIdx}-{subIdx}", + Description = $"Sub-task {subIdx} details for completing parent task {taskIdx}", + Status = (TaskStatus)(subIdx % 5), + EstimatedMinutes = 30 + subIdx * 15, + ActualMinutes = 25 + subIdx * 12, + IsCompleted = subIdx == 1, + CompletedAt = subIdx == 1 ? DateTime.UtcNow.AddHours(-subIdx * 2) : null, + Assignee = sharedUser, // Cross-reference + CreatedAt = DateTime.UtcNow.AddDays(-subIdx), + UpdatedAt = DateTime.UtcNow.AddMinutes(-subIdx * 30), + // Level 7: Comments (2 per subtask = 2400 total -> limiting) + Comments = Enumerable.Range(1, 1).Select(comIdx => new Level7_Comment + { + Id = _idCounter++, + Text = $"Comment {comIdx} on subtask {subIdx}: This is a detailed comment with feedback and suggestions for improvement.", + Author = sharedUser, // Cross-reference + IsEdited = comIdx % 2 == 0, + EditedAt = comIdx % 2 == 0 ? DateTime.UtcNow.AddHours(-1) : null, + LikeCount = comIdx * 3, + ReplyCount = comIdx, + CreatedAt = DateTime.UtcNow.AddHours(-comIdx * 4), + MentionedTags = [sharedTags[comIdx % sharedTags.Count]] // Cross-reference + }).ToList() + }).ToList() + }).ToList() + }).ToList() + }).ToList() + }).ToList(); + + return company; + } + + #endregion + + #region 7-Level Deep DTOs with 8-15 Properties Each + + // Shared cross-reference types + public class SharedTag : IId + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Color { get; set; } = ""; + public int Priority { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public string Metadata { get; set; } = ""; + } + + public class SharedCategory : IId + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public int SortOrder { get; set; } + public string IconUrl { get; set; } = ""; + public bool IsDefault { get; set; } + public int? ParentCategoryId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } + + public class SharedUser : IId + { + public int Id { get; set; } + public string Username { get; set; } = ""; + public string Email { get; set; } = ""; + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + public string PhoneNumber { get; set; } = ""; + public bool IsActive { get; set; } + public UserRole Role { get; set; } + public DateTime? LastLoginAt { get; set; } + public DateTime CreatedAt { get; set; } + public UserPreferences? Preferences { get; set; } + } + + public class UserPreferences + { + public string Theme { get; set; } = ""; + public string Language { get; set; } = ""; + public bool NotificationsEnabled { get; set; } + public string EmailDigestFrequency { get; set; } = ""; + } + + public enum UserRole { User, Manager, Admin } + public enum ProjectStatus { Planning, Active, OnHold, Completed } + public enum TaskStatus { Backlog, Todo, InProgress, Review, Done } + public enum TaskType { Feature, Bug, Improvement, Task } + public enum Priority { Low, Medium, High } + + // Level 1: Company (15 properties) + public class Level1_Company : IId + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string LegalName { get; set; } = ""; + public string TaxId { get; set; } = ""; + public DateTime FoundedDate { get; set; } + public int EmployeeCount { get; set; } + public decimal AnnualRevenue { get; set; } + public bool IsPubliclyTraded { get; set; } + public string? StockSymbol { get; set; } + public string HeadquartersAddress { get; set; } = ""; + public string Website { get; set; } = ""; + public SharedUser? PrimaryContact { get; set; } // Cross-ref + public SharedCategory? MainCategory { get; set; } // Cross-ref + public List Tags { get; set; } = []; // Cross-ref + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Departments { get; set; } = []; + } + + // Level 2: Department (15 properties) + public class Level2_Department : IId + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Code { get; set; } = ""; + public string Description { get; set; } = ""; + public decimal Budget { get; set; } + public int HeadCount { get; set; } + public string Location { get; set; } = ""; + public string CostCenter { get; set; } = ""; + public bool IsActive { get; set; } + public SharedUser? Manager { get; set; } // Cross-ref + public SharedCategory? Category { get; set; } // Cross-ref + public List Tags { get; set; } = []; // Cross-ref + public DateTime EstablishedDate { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Teams { get; set; } = []; + } + + // Level 3: Team (15 properties) + public class Level3_Team : IId + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Acronym { get; set; } = ""; + public string Description { get; set; } = ""; + public int MemberCount { get; set; } + public int Capacity { get; set; } + public double Utilization { get; set; } + public int SprintLength { get; set; } + public double VelocityAverage { get; set; } + public bool IsRemote { get; set; } + public string Timezone { get; set; } = ""; + public string SlackChannel { get; set; } = ""; + public SharedUser? TeamLead { get; set; } // Cross-ref + public SharedTag? PrimaryTag { get; set; } // Cross-ref + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Projects { get; set; } = []; + } + + // Level 4: Project (18 properties) + public class Level4_Project : IId + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Code { get; set; } = ""; + public string Description { get; set; } = ""; + public ProjectStatus Status { get; set; } + public Priority Priority { get; set; } + public decimal Budget { get; set; } + public decimal SpentAmount { get; set; } + public double ProgressPercent { get; set; } + public DateTime StartDate { get; set; } + public DateTime DueDate { get; set; } + public DateTime? CompletedDate { get; set; } + public int EstimatedHours { get; set; } + public int ActualHours { get; set; } + public int RiskLevel { get; set; } + public SharedUser? Owner { get; set; } // Cross-ref + public SharedCategory? Category { get; set; } // Cross-ref + public List Tags { get; set; } = []; // Cross-ref + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Tasks { get; set; } = []; + } + + // Level 5: Task (18 properties) + public class Level5_Task : IId + { + public int Id { get; set; } + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public TaskStatus Status { get; set; } + public Priority Priority { get; set; } + public TaskType Type { get; set; } + public int StoryPoints { get; set; } + public double EstimatedHours { get; set; } + public double ActualHours { get; set; } + public DateTime DueDate { get; set; } + public DateTime? CompletedDate { get; set; } + public bool IsBlocked { get; set; } + public string? BlockedReason { get; set; } + public SharedUser? Assignee { get; set; } // Cross-ref + public SharedUser? Reporter { get; set; } // Cross-ref + public List Labels { get; set; } = []; // Cross-ref + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List SubTasks { get; set; } = []; + } + + // Level 6: SubTask (11 properties) + public class Level6_SubTask : IId + { + public int Id { get; set; } + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public TaskStatus Status { get; set; } + public int EstimatedMinutes { get; set; } + public int ActualMinutes { get; set; } + public bool IsCompleted { get; set; } + public DateTime? CompletedAt { get; set; } + public SharedUser? Assignee { get; set; } // Cross-ref + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Comments { get; set; } = []; + } + + // Level 7: Comment (10 properties) + public class Level7_Comment : IId + { + public int Id { get; set; } + public string Text { get; set; } = ""; + public SharedUser? Author { get; set; } // Cross-ref + public bool IsEdited { get; set; } + public DateTime? EditedAt { get; set; } + public int LikeCount { get; set; } + public int ReplyCount { get; set; } + public DateTime CreatedAt { get; set; } + public List MentionedTags { get; set; } = []; // Cross-ref + } + + #endregion + + #region AcJsonSerializer Benchmarks + + [Benchmark(Description = "AcJsonSerializer (custom)")] + [BenchmarkCategory("Serialize")] + public string Serialize_AcJsonSerializer() + => AcJsonSerializer.Serialize(_complexGraph); + + #endregion +} \ No newline at end of file