diff --git a/AyCode.Core.Server/AyCode.Core.Server.csproj b/AyCode.Core.Server/AyCode.Core.Server.csproj index c3f7447..54de42a 100644 --- a/AyCode.Core.Server/AyCode.Core.Server.csproj +++ b/AyCode.Core.Server/AyCode.Core.Server.csproj @@ -7,6 +7,7 @@ + diff --git a/AyCode.Core.Tests.Internal/AyCode.Core.Tests.Internal.csproj b/AyCode.Core.Tests.Internal/AyCode.Core.Tests.Internal.csproj index 090fd43..70d0d20 100644 --- a/AyCode.Core.Tests.Internal/AyCode.Core.Tests.Internal.csproj +++ b/AyCode.Core.Tests.Internal/AyCode.Core.Tests.Internal.csproj @@ -17,7 +17,7 @@ - + diff --git a/AyCode.Core.Tests/AyCode.Core.Tests.csproj b/AyCode.Core.Tests/AyCode.Core.Tests.csproj index d6378cc..ec7e61b 100644 --- a/AyCode.Core.Tests/AyCode.Core.Tests.csproj +++ b/AyCode.Core.Tests/AyCode.Core.Tests.csproj @@ -17,6 +17,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/AyCode.Core.Tests/JsonExtensionTests.cs b/AyCode.Core.Tests/JsonExtensionTests.cs new file mode 100644 index 0000000..7f68a94 --- /dev/null +++ b/AyCode.Core.Tests/JsonExtensionTests.cs @@ -0,0 +1,1190 @@ +ο»Ώusing System.Runtime.Serialization; +using AyCode.Core.Enums; +using AyCode.Core.Extensions; +using AyCode.Core.Interfaces; +using AyCode.Core.Loggers; +using Newtonsoft.Json; + +namespace AyCode.Core.Tests; + +[TestClass] +public sealed class JsonExtensionTests +{ + [TestInitialize] + public void TestInit() + { + TestDataFactory.ResetIdCounter(); + } + + private static JsonSerializerSettings GetMergeSettings() + { + return SerializeObjectExtensions.Options; + //return new JsonSerializerSettings + //{ + // ContractResolver = new UnifiedMergeContractResolver(), + // Context = new StreamingContext(StreamingContextStates.All, new Dictionary()), + // PreserveReferencesHandling = PreserveReferencesHandling.Objects, + // ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + // NullValueHandling = NullValueHandling.Ignore, + //}; + } + + #region Deep Hierarchy Tests (5 Levels) + + [TestMethod] + public void DeepHierarchy_5Levels_MergePreservesAllReferences() + { + // Arrange: Create 5-level deep hierarchy + TestDataFactory.ResetIdCounter(); + var order = TestDataFactory.CreateDeepOrder(itemCount: 2, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3); + + // Store original references at all levels + var originalOrder = order; + var originalItem = order.Items[0]; + var originalPallet = order.Items[0].Pallets[0]; + var originalMeasurement = order.Items[0].Pallets[0].Measurements[0]; + var originalPoint = order.Items[0].Pallets[0].Measurements[0].Points[0]; + + var settings = GetMergeSettings(); + + // Create update JSON that modifies values at all 5 levels + var updateJson = $@"{{ + ""Id"": {order.Id}, + ""OrderNumber"": ""ORD-UPDATED"", + ""Items"": [{{ + ""Id"": {originalItem.Id}, + ""ProductName"": ""Updated-Product"", + ""Pallets"": [{{ + ""Id"": {originalPallet.Id}, + ""PalletCode"": ""PLT-UPDATED"", + ""Measurements"": [{{ + ""Id"": {originalMeasurement.Id}, + ""Name"": ""Measurement-UPDATED"", + ""Points"": [{{ + ""Id"": {originalPoint.Id}, + ""Label"": ""Point-UPDATED"", + ""Value"": 999.99 + }}] + }}] + }}] + }}] + }}"; + + // Act + updateJson.JsonTo(order, settings); + + // Assert: All references preserved + Assert.AreSame(originalOrder, order, "Level 1: Order reference must be preserved"); + Assert.AreSame(originalItem, order.Items[0], "Level 2: Item reference must be preserved"); + Assert.AreSame(originalPallet, order.Items[0].Pallets[0], "Level 3: Pallet reference must be preserved"); + Assert.AreSame(originalMeasurement, order.Items[0].Pallets[0].Measurements[0], "Level 4: Measurement reference must be preserved"); + Assert.AreSame(originalPoint, order.Items[0].Pallets[0].Measurements[0].Points[0], "Level 5: Point reference must be preserved"); + + // Assert: Values updated + Assert.AreEqual("ORD-UPDATED", order.OrderNumber); + Assert.AreEqual("Updated-Product", order.Items[0].ProductName); + Assert.AreEqual("PLT-UPDATED", order.Items[0].Pallets[0].PalletCode); + Assert.AreEqual("Measurement-UPDATED", order.Items[0].Pallets[0].Measurements[0].Name); + Assert.AreEqual("Point-UPDATED", order.Items[0].Pallets[0].Measurements[0].Points[0].Label); + Assert.AreEqual(999.99, order.Items[0].Pallets[0].Measurements[0].Points[0].Value); + } + + [TestMethod] + public void DeepHierarchy_5Levels_InsertAndKeepLogic() + { + // Arrange + TestDataFactory.ResetIdCounter(); + var order = TestDataFactory.CreateDeepOrder(itemCount: 2, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2); + + var originalItemCount = order.Items.Count; + var originalItem2 = order.Items[1]; // This should be KEPT + var existingPointId = order.Items[0].Pallets[0].Measurements[0].Points[0].Id; + + var settings = GetMergeSettings(); + + // Update only first item, add new point - second item should be KEPT + var updateJson = $@"{{ + ""Id"": {order.Id}, + ""Items"": [{{ + ""Id"": {order.Items[0].Id}, + ""Pallets"": [{{ + ""Id"": {order.Items[0].Pallets[0].Id}, + ""Measurements"": [{{ + ""Id"": {order.Items[0].Pallets[0].Measurements[0].Id}, + ""Points"": [ + {{ ""Id"": {existingPointId}, ""Label"": ""Updated-Point"" }}, + {{ ""Id"": 9999, ""Label"": ""NEW-Point"", ""Value"": 123.45 }} + ] + }}] + }}] + }}] + }}"; + + // Act + updateJson.JsonTo(order, settings); + + // Assert: KEEP logic works at all levels + Assert.AreEqual(originalItemCount, order.Items.Count, "Items count should be preserved (KEEP logic)"); + Assert.AreSame(originalItem2, order.Items[1], "Second item reference should be preserved (KEEP)"); + + // Assert: INSERT logic works at deepest level + var points = order.Items[0].Pallets[0].Measurements[0].Points; + Assert.IsTrue(points.Count >= 2, "Should have at least 2 points (existing + new)"); + Assert.IsTrue(points.Any(p => p.Id == 9999 && p.Label == "NEW-Point"), "New point should be inserted"); + } + + #endregion + + #region Semantic Reference Tests (IId types with TypeName_Id format) + + [TestMethod] + public void SemanticReference_SharedAttribute_SerializesWithSemanticId() + { + // Arrange: Create order with shared attribute across multiple properties + TestDataFactory.ResetIdCounter(); + var sharedAttr = TestDataFactory.CreateSharedAttribute(); + var order = TestDataFactory.CreateDeepOrder( + itemCount: 2, + palletsPerItem: 1, + measurementsPerPallet: 1, + pointsPerMeasurement: 1, + sharedAttribute: sharedAttr); + + var settings = GetMergeSettings(); + settings.Formatting = Formatting.Indented; + + // Act + var json = order.ToJson(settings); + + 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: $ref used for duplicate semantic references + Assert.IsTrue(json.Contains("\"$ref\""), $"Should contain $ref for shared attribute references. JSON:\n{json}"); + } + + [TestMethod] + public void SemanticReference_DeserializeAndMerge_PreservesSharedReferences() + { + // Arrange + TestDataFactory.ResetIdCounter(); + var sharedAttr = TestDataFactory.CreateSharedAttribute(); + sharedAttr.Key = "OriginalKey"; + + var order = new TestOrder + { + Id = 1, + OrderNumber = "ORD-001", + PrimaryAttribute = sharedAttr, + SecondaryAttribute = sharedAttr, // Same reference + Attributes = [sharedAttr] + }; + + var originalAttrRef = order.PrimaryAttribute; + + // Update that modifies the shared attribute + var updateJson = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-UPDATED"", + ""PrimaryAttribute"": { ""Id"": 1, ""Key"": ""UpdatedKey"", ""Value"": ""UpdatedValue"" } + }"; + + var settings = GetMergeSettings(); + + // Act + updateJson.JsonTo(order, settings); + + // Assert + Assert.AreEqual("ORD-UPDATED", order.OrderNumber); + Assert.AreEqual("UpdatedKey", order.PrimaryAttribute?.Key); + + // SecondaryAttribute should still reference the same object (wasn't in update JSON) + Assert.AreSame(originalAttrRef, order.SecondaryAttribute, "SecondaryAttribute reference should be preserved"); + } + + #endregion + + #region Newtonsoft Reference Tests (Non-IId types with numeric $id/$ref) + + [TestMethod] + public void NewtonsoftReference_SharedNonIdMetadata_SerializesWithNumericId() + { + // Arrange: Create order with shared non-IId metadata + TestDataFactory.ResetIdCounter(); + var sharedMeta = TestDataFactory.CreateNonIdMetadata(withChild: true); + var order = TestDataFactory.CreateDeepOrder( + itemCount: 2, + palletsPerItem: 1, + measurementsPerPallet: 1, + pointsPerMeasurement: 1, + sharedNonIdMetadata: sharedMeta); + + var settings = GetMergeSettings(); + settings.Formatting = Formatting.Indented; + + // Act + var json = order.ToJson(settings); + + 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: Semantic IId references also present + Assert.IsTrue(json.Contains($"TestOrder_{order.Id}"), "Should also contain semantic $id for IId types"); + } + + [TestMethod] + public void NewtonsoftReference_DeepNestedNonId_HandlesCorrectly() + { + // Arrange: Create chain of non-IId metadata + var rootMeta = new TestNonIdMetadata + { + Key = "Root", + Value = "RootValue", + Timestamp = DateTime.UtcNow, + ChildMetadata = new TestNonIdMetadata + { + Key = "Child", + Value = "ChildValue", + Timestamp = DateTime.UtcNow, + ChildMetadata = new TestNonIdMetadata + { + Key = "GrandChild", + Value = "GrandChildValue", + Timestamp = DateTime.UtcNow + } + } + }; + + var order = new TestOrder + { + Id = 1, + OrderNumber = "ORD-001", + OrderMetadata = rootMeta, + AuditMetadata = rootMeta // Same reference + }; + + var settings = GetMergeSettings(); + settings.Formatting = Formatting.Indented; + + // Act + var json = order.ToJson(settings); + + Console.WriteLine("Deep Non-IId JSON:"); + Console.WriteLine(json); + + // Assert + Assert.IsTrue(json.Contains("Root"), "Should contain root metadata"); + Assert.IsTrue(json.Contains("Child"), "Should contain child metadata"); + Assert.IsTrue(json.Contains("GrandChild"), "Should contain grandchild metadata"); + Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for duplicate rootMeta reference"); + } + + #endregion + + #region Hybrid Reference Tests (Mixed IId and Non-IId) + + [TestMethod] + public void HybridReference_MixedTypes_BothRefSystemsWork() + { + // Arrange: Create complex object with both IId and non-IId shared references + TestDataFactory.ResetIdCounter(); + var sharedAttr = TestDataFactory.CreateSharedAttribute(); + var sharedMeta = TestDataFactory.CreateNonIdMetadata(); + + // Shared attribute also has nested non-IId metadata + sharedAttr.NestedMetadata = sharedMeta; + + var order = new TestOrder + { + Id = 1, + OrderNumber = "ORD-001", + PrimaryAttribute = sharedAttr, + SecondaryAttribute = sharedAttr, // IId duplicate + OrderMetadata = sharedMeta, + AuditMetadata = sharedMeta, // Non-IId duplicate + Items = + [ + new TestOrderItem + { + Id = 10, + ProductName = "Product-A", + Quantity = 5, + Attribute = sharedAttr, // Same IId reference again + ItemMetadata = sharedMeta // Same non-IId reference again + } + ] + }; + + var settings = GetMergeSettings(); + settings.Formatting = Formatting.Indented; + + // Act + var json = order.ToJson(settings); + + 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.IsTrue(json.Contains("\"$ref\""), "Should have $ref tokens for duplicates"); + + // Count $ref occurrences - should have multiple (for both IId and non-IId duplicates) + var refCount = json.Split("\"$ref\"").Length - 1; + Assert.IsTrue(refCount >= 2, $"Should have at least 2 $ref tokens. Found: {refCount}. JSON:\n{json}"); + } + + [TestMethod] + public void HybridReference_MergeWithMixedReferences() + { + // Arrange + TestDataFactory.ResetIdCounter(); + var sharedAttr = TestDataFactory.CreateSharedAttribute(); + sharedAttr.Key = "OriginalAttr"; + + var sharedMeta = TestDataFactory.CreateNonIdMetadata(); + sharedMeta.Key = "OriginalMeta"; + + var order = new TestOrder + { + Id = 1, + OrderNumber = "ORD-001", + PrimaryAttribute = sharedAttr, + SecondaryAttribute = sharedAttr, + OrderMetadata = sharedMeta, + AuditMetadata = sharedMeta, + Items = + [ + new TestOrderItem { Id = 10, ProductName = "Product-A", Quantity = 5 }, + new TestOrderItem { Id = 20, ProductName = "Product-B", Quantity = 3 } + ] + }; + + var originalItem10Ref = order.Items[0]; + var originalItem20Ref = order.Items[1]; + var originalAttrRef = order.PrimaryAttribute; + var originalMetaRef = order.OrderMetadata; + + // Update: modify item 10, add item 30, update PrimaryAttribute + var updateJson = $@"{{ + ""Id"": 1, + ""OrderNumber"": ""ORD-UPDATED"", + ""Items"": [ + {{ ""Id"": 10, ""ProductName"": ""Product-A-UPDATED"", ""Quantity"": 50 }}, + {{ ""Id"": 30, ""ProductName"": ""Product-C-NEW"", ""Quantity"": 7 }} + ], + ""PrimaryAttribute"": {{ ""Id"": {sharedAttr.Id}, ""Key"": ""UpdatedAttr"" }} + }}"; + + var settings = GetMergeSettings(); + + // Act + updateJson.JsonTo(order, settings); + + // Assert: References preserved + Assert.AreSame(originalItem10Ref, order.Items.Single(i => i.Id == 10), "Item 10 reference preserved"); + Assert.AreSame(originalItem20Ref, order.Items.Single(i => i.Id == 20), "Item 20 reference preserved (KEEP)"); + Assert.IsNotNull(order.Items.SingleOrDefault(i => i.Id == 30), "Item 30 inserted"); + + // Values updated + Assert.AreEqual("ORD-UPDATED", order.OrderNumber); + Assert.AreEqual("Product-A-UPDATED", order.Items.Single(i => i.Id == 10).ProductName); + Assert.AreEqual(50, order.Items.Single(i => i.Id == 10).Quantity); + Assert.AreEqual("UpdatedAttr", order.PrimaryAttribute?.Key); + + // SecondaryAttribute still references original (wasn't updated) + Assert.AreSame(originalAttrRef, order.SecondaryAttribute); + + // Metadata references unchanged + Assert.AreSame(originalMetaRef, order.OrderMetadata); + Assert.AreSame(originalMetaRef, order.AuditMetadata); + } + + #endregion + + #region NoMerge Collection Tests + + [TestMethod] + public void NoMergeCollection_DeepHierarchy_ReplacesEntireCollection() + { + // Arrange + TestDataFactory.ResetIdCounter(); + var order = TestDataFactory.CreateDeepOrder(itemCount: 2); + + // Add items to NoMergeItems + order.NoMergeItems = + [ + new TestOrderItem { Id = 100, ProductName = "NoMerge-A", Quantity = 1 }, + new TestOrderItem { Id = 101, ProductName = "NoMerge-B", Quantity = 2 } + ]; + + var originalNoMergeRef = order.NoMergeItems; + + var updateJson = $@"{{ + ""Id"": {order.Id}, + ""NoMergeItems"": [ + {{ ""Id"": 200, ""ProductName"": ""NoMerge-NEW-A"", ""Quantity"": 10 }}, + {{ ""Id"": 201, ""ProductName"": ""NoMerge-NEW-B"", ""Quantity"": 20 }} + ] + }}"; + + var settings = GetMergeSettings(); + + // Act + order.DeepPopulateWithMerge(updateJson, settings); + + // Assert: Collection replaced, not merged + Assert.AreNotSame(originalNoMergeRef, order.NoMergeItems, "NoMergeItems collection should be replaced"); + Assert.AreEqual(2, order.NoMergeItems.Count); + Assert.IsFalse(order.NoMergeItems.Any(i => i.Id == 100), "Original item 100 should be gone"); + Assert.IsFalse(order.NoMergeItems.Any(i => i.Id == 101), "Original item 101 should be gone"); + Assert.IsTrue(order.NoMergeItems.Any(i => i.Id == 200), "New item 200 should exist"); + Assert.IsTrue(order.NoMergeItems.Any(i => i.Id == 201), "New item 201 should exist"); + } + + #endregion + + #region Non-IId Collection Tests + + [TestMethod] + public void NonIdCollection_ReplacesContent() + { + // Arrange + var order = new TestOrder + { + Id = 1, + OrderNumber = "ORD-001", + MetadataList = + [ + new TestNonIdMetadata { Key = "Old-A", Value = "Old-Value-A" }, + new TestNonIdMetadata { Key = "Old-B", Value = "Old-Value-B" } + ] + }; + + var updateJson = @"{ + ""Id"": 1, + ""MetadataList"": [ + { ""Key"": ""New-X"", ""Value"": ""New-Value-X"" }, + { ""Key"": ""New-Y"", ""Value"": ""New-Value-Y"" }, + { ""Key"": ""New-Z"", ""Value"": ""New-Value-Z"" } + ] + }"; + + var settings = GetMergeSettings(); + + // Act + order.DeepPopulateWithMerge(updateJson, settings); + + // Assert: Collection content replaced + Assert.AreEqual(3, order.MetadataList.Count); + Assert.IsFalse(order.MetadataList.Any(m => m.Key == "Old-A")); + Assert.IsTrue(order.MetadataList.Any(m => m.Key == "New-X")); + Assert.IsTrue(order.MetadataList.Any(m => m.Key == "New-Y")); + Assert.IsTrue(order.MetadataList.Any(m => m.Key == "New-Z")); + } + + #endregion + + #region Guid IId Tests + + [TestMethod] + public void GuidId_DeepPopulate_ReferencePreserved() + { + var order = new TestGuidOrder + { + Id = Guid.NewGuid(), + Code = "ORD-001", + Items = + [ + new TestGuidItem { Id = Guid.NewGuid(), Name = "Apple", Qty = 5 }, + new TestGuidItem { Id = Guid.NewGuid(), Name = "Orange", Qty = 3 } + ], + Count = 2 + }; + + var originalItemsRef = order.Items; + var originalAppleRef = order.Items[0]; + + var json = new + { + Id = order.Id, + Code = "ORD-UPDATED", + Items = new[] + { + new { Id = order.Items[0].Id, Name = "Apple", Qty = 7 }, + new { Id = Guid.NewGuid(), Name = "Banana", Qty = 4 } + }, + Count = 999 + }.ToJson(GetMergeSettings()); + + json.JsonTo(order, GetMergeSettings()); + + Assert.AreSame(originalItemsRef, order.Items, "List reference must be preserved."); + Assert.AreSame(originalAppleRef, order.Items[0], "Apple item reference must be preserved."); + Assert.AreEqual(3, order.Items.Count, "Items should include: updated Apple, new Banana, and kept Orange."); + Assert.AreEqual(7, order.Items[0].Qty); + Assert.AreEqual("ORD-UPDATED", order.Code); + Assert.AreEqual(999, order.Count); + } + + #endregion + + #region Round-Trip Serialization Tests + + [TestMethod] + public void RoundTrip_DeepHierarchy_SerializeAndVerifyStructure() + { + // Arrange + TestDataFactory.ResetIdCounter(); + var sharedAttr = TestDataFactory.CreateSharedAttribute(); + var sharedMeta = TestDataFactory.CreateNonIdMetadata(withChild: true); + + var originalOrder = TestDataFactory.CreateDeepOrder( + itemCount: 2, + palletsPerItem: 2, + measurementsPerPallet: 2, + pointsPerMeasurement: 3, + sharedAttribute: sharedAttr, + sharedNonIdMetadata: sharedMeta); + + var settings = GetMergeSettings(); + settings.Formatting = Formatting.Indented; + + // Act: Serialize + var json = originalOrder.ToJson(settings); + + 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 - $ref for shared references + Assert.IsTrue(json.Contains("\"$ref\""), "Should have $ref for shared references"); + + // 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"); + } + + [TestMethod] + public void RoundTrip_MergeIntoExistingObject_PreservesReferences() + { + // Arrange: Create original order + TestDataFactory.ResetIdCounter(); + var originalOrder = TestDataFactory.CreateDeepOrder(itemCount: 2, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2); + + // Store original values + var originalProductName = originalOrder.Items[0].ProductName; + + // Store references + var originalItemRef = originalOrder.Items[0]; + var originalPalletRef = originalOrder.Items[0].Pallets[0]; + + // Create update JSON (without shared non-IId to avoid numeric $ref conflicts) + var updateJson = $@"{{ + ""Id"": {originalOrder.Id}, + ""OrderNumber"": ""UPDATED-ORDER"", + ""Items"": [{{ + ""Id"": {originalOrder.Items[0].Id}, + ""Pallets"": [{{ + ""Id"": {originalOrder.Items[0].Pallets[0].Id}, + ""PalletCode"": ""UPDATED-PALLET"" + }}] + }}] + }}"; + + var settings = GetMergeSettings(); + + // Act + updateJson.JsonTo(originalOrder, settings); + + // Assert: References preserved + Assert.AreSame(originalItemRef, originalOrder.Items[0], "Item reference should be preserved"); + Assert.AreSame(originalPalletRef, originalOrder.Items[0].Pallets[0], "Pallet reference should be preserved"); + + // Assert: Values updated (only what was in the JSON) + Assert.AreEqual("UPDATED-ORDER", originalOrder.OrderNumber); + Assert.AreEqual(originalProductName, originalOrder.Items[0].ProductName, "ProductName should be unchanged (not in update JSON)"); + Assert.AreEqual("UPDATED-PALLET", originalOrder.Items[0].Pallets[0].PalletCode); + } + + #endregion + + #region Test DTO Classes - 5 Levels Deep with Reference Loops + + /// + /// Status enum used across tests + /// + public enum TestStatus + { + Pending = 10, + Processing = 20, + Shipped = 30 + } + + /// + /// Level 5: Deepest level - Measurement point with value + /// + public class TestMeasurementPoint : IId + { + public int Id { get; set; } + public string Label { get; set; } = string.Empty; + public double Value { get; set; } + public DateTime MeasuredAt { get; set; } + + // Reference loop back to Level 4 (parent) + [JsonIgnore] // Prevent infinite serialization loop + public TestMeasurement? ParentMeasurement { get; set; } + } + + /// + /// Level 4: Measurement with multiple points + /// + public class TestMeasurement : IId + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public double TotalWeight { get; set; } + public DateTime CreatedAt { get; set; } + + // IId collection - Level 5 + public List Points { get; set; } = []; + + // Reference loop back to Level 3 (parent) + [JsonIgnore] public TestPallet? ParentPallet { get; set; } + } + + /// + /// Level 3: Pallet containing measurements + /// + public class TestPallet : IId + { + public int Id { get; set; } + public string PalletCode { get; set; } = string.Empty; + public int TrayCount { get; set; } + public TestStatus Status { get; set; } = TestStatus.Pending; + + // IId collection - Level 4 + public List Measurements { get; set; } = []; + + // Non-IId metadata (for Newtonsoft $ref testing) + public TestNonIdMetadata? PalletMetadata { get; set; } + + // Reference loop back to Level 2 (parent) + [JsonIgnore] public TestOrderItem? ParentOrderItem { get; set; } + } + + /// + /// Level 2: Order item with pallets + /// + public class TestOrderItem : IId + { + public int Id { get; set; } + public string ProductName { get; set; } = string.Empty; + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + public TestStatus ItemStatus { get; set; } = TestStatus.Pending; + + // IId collection - Level 3 + public List Pallets { get; set; } = []; + + // IId object property - shared metadata with semantic $id + public TestSharedAttribute? Attribute { get; set; } + + // Non-IId metadata (for Newtonsoft $ref testing) + public TestNonIdMetadata? ItemMetadata { get; set; } + + // Reference loop back to Level 1 (parent) + [JsonIgnore] public TestOrder? ParentOrder { get; set; } + } + + /// + /// Level 1: Main order containing items + /// + public class TestOrder : IId + { + public int Id { get; set; } + public string OrderNumber { get; set; } = string.Empty; + public TestStatus OrderStatus { get; set; } = TestStatus.Pending; + public DateTime? PaidDateUtc { get; set; } + public DateTime CreatedAt { get; set; } + + // IId collection - Level 2 + public List Items { get; set; } = []; + + // IId collection for attributes (shared references testing) + public List Attributes { get; set; } = []; + + // Non-IId collection - will be replaced, not merged + public List MetadataList { get; set; } = []; + + // Collection with NoMerge attribute - will be replaced + [JsonNoMergeCollection] public List NoMergeItems { get; set; } = []; + + // IId object properties for shared reference testing + public TestSharedAttribute? PrimaryAttribute { get; set; } + public TestSharedAttribute? SecondaryAttribute { get; set; } + + // Non-IId object properties for Newtonsoft $ref testing + public TestNonIdMetadata? OrderMetadata { get; set; } + public TestNonIdMetadata? AuditMetadata { get; set; } + } + + /// + /// Shared IId attribute - for semantic $id/$ref testing across objects + /// + public class TestSharedAttribute : IId + { + public int Id { get; set; } + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public DateTime CreatedOrUpdatedDateUTC { get; set; } + + // Nested non-IId for mixed reference testing + public TestNonIdMetadata? NestedMetadata { get; set; } + } + + /// + /// Non-IId metadata class - uses Newtonsoft PreserveReferencesHandling (numeric $id/$ref) + /// + public class TestNonIdMetadata + { + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + + // Nested non-IId for deep Newtonsoft reference testing + public TestNonIdMetadata? ChildMetadata { get; set; } + } + + /// + /// Order DTO with Guid Id - for testing Guid-based IId + /// + public class TestGuidOrder : IId + { + public Guid Id { get; set; } + public string Code { get; set; } = string.Empty; + public List Items { get; set; } = []; + public int Count { get; set; } + } + + /// + /// Item DTO with Guid Id - for testing Guid-based IId + /// + public class TestGuidItem : IId + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Qty { get; set; } + } + + #endregion + + #region Test Data Factory + + /// + /// Factory for creating deep test object hierarchies + /// + public static class TestDataFactory + { + private static int _idCounter = 1; + + public static void ResetIdCounter() => _idCounter = 1; + + public static TestOrder CreateDeepOrder( + int itemCount = 2, + int palletsPerItem = 2, + int measurementsPerPallet = 2, + int pointsPerMeasurement = 3, + TestNonIdMetadata? sharedNonIdMetadata = null, + TestSharedAttribute? sharedAttribute = null) + { + var order = new TestOrder + { + Id = _idCounter++, + OrderNumber = $"ORD-{_idCounter:D4}", + OrderStatus = TestStatus.Pending, + CreatedAt = DateTime.UtcNow, + PrimaryAttribute = sharedAttribute, + SecondaryAttribute = sharedAttribute, // Same reference for $ref testing + OrderMetadata = sharedNonIdMetadata, + AuditMetadata = sharedNonIdMetadata // Same reference for Newtonsoft $ref + }; + + if (sharedAttribute != null) + { + order.Attributes.Add(sharedAttribute); + } + + for (int i = 0; i < itemCount; i++) + { + var item = CreateOrderItem(palletsPerItem, measurementsPerPallet, pointsPerMeasurement, sharedNonIdMetadata, sharedAttribute); + item.ParentOrder = order; // Reference loop + order.Items.Add(item); + } + + return order; + } + + public static TestOrderItem CreateOrderItem( + int palletCount = 2, + int measurementsPerPallet = 2, + int pointsPerMeasurement = 3, + TestNonIdMetadata? sharedNonIdMetadata = null, + TestSharedAttribute? sharedAttribute = null) + { + var item = new TestOrderItem + { + Id = _idCounter++, + ProductName = $"Product-{_idCounter}", + Quantity = 10 + _idCounter, + UnitPrice = 5.5m * _idCounter, + ItemStatus = TestStatus.Pending, + Attribute = sharedAttribute, + ItemMetadata = sharedNonIdMetadata + }; + + for (int i = 0; i < palletCount; i++) + { + var pallet = CreatePallet(measurementsPerPallet, pointsPerMeasurement, sharedNonIdMetadata); + pallet.ParentOrderItem = item; // Reference loop + item.Pallets.Add(pallet); + } + + return item; + } + + public static TestPallet CreatePallet( + int measurementCount = 2, + int pointsPerMeasurement = 3, + TestNonIdMetadata? sharedNonIdMetadata = null) + { + var pallet = new TestPallet + { + Id = _idCounter++, + PalletCode = $"PLT-{_idCounter:D4}", + TrayCount = 5 + _idCounter % 10, + Status = TestStatus.Pending, + PalletMetadata = sharedNonIdMetadata + }; + + for (int i = 0; i < measurementCount; i++) + { + var measurement = CreateMeasurement(pointsPerMeasurement); + measurement.ParentPallet = pallet; // Reference loop + pallet.Measurements.Add(measurement); + } + + return pallet; + } + + public static TestMeasurement CreateMeasurement(int pointCount = 3) + { + var measurement = new TestMeasurement + { + Id = _idCounter++, + Name = $"Measurement-{_idCounter}", + TotalWeight = 100.5 + _idCounter, + CreatedAt = DateTime.UtcNow + }; + + for (int i = 0; i < pointCount; i++) + { + var point = CreateMeasurementPoint(); + point.ParentMeasurement = measurement; // Reference loop + measurement.Points.Add(point); + } + + return measurement; + } + + public static TestMeasurementPoint CreateMeasurementPoint() + { + return new TestMeasurementPoint + { + Id = _idCounter++, + Label = $"Point-{_idCounter}", + Value = 10.5 + (_idCounter * 0.1), + MeasuredAt = DateTime.UtcNow + }; + } + + public static TestSharedAttribute CreateSharedAttribute() + { + return new TestSharedAttribute + { + Id = _idCounter++, + Key = $"Attr-{_idCounter}", + Value = $"Value-{_idCounter}", + CreatedOrUpdatedDateUTC = DateTime.UtcNow + }; + } + + public static TestNonIdMetadata CreateNonIdMetadata(bool withChild = false) + { + return new TestNonIdMetadata + { + Key = $"Meta-{_idCounter++}", + Value = $"MetaValue-{_idCounter}", + Timestamp = DateTime.UtcNow, + ChildMetadata = withChild ? CreateNonIdMetadata(false) : null + }; + } + } + + #endregion + + #region Primitive Element Array Tests (SignalR IdMessage pattern fix) + + /// + /// Test that reproduces the exact SignalR loadRelations=true becoming false issue. + /// IdMessage wraps primitives in arrays: (new[] { x }).ToJson() -> "[true]" string + /// Server deserializes: "[true]".JsonTo(typeof(bool[])) -> should return bool[] with true + /// + [TestMethod] + public void PrimitiveArray_BooleanTrue_SerializesAndDeserializesCorrectly() + { + // Arrange - This is exactly what IdMessage constructor does + var loadRelations = true; + var settings = GetMergeSettings(); + + // Step 1: Client side - IdMessage constructor wraps in array and serializes + var jsonString = (new[] { loadRelations }).ToJson(settings); + Console.WriteLine($"Step 1 - Client serialized [true]: {jsonString}"); + + // Step 2: Server side - ProcessOnReceiveMessage deserializes + // This is: paramValues[i] = (obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!; + var targetArrayType = typeof(bool).MakeArrayType(); + var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; + var deserializedValue = deserializedArray?.GetValue(0); + + Console.WriteLine($"Step 2 - Server deserialized value: {deserializedValue} (type: {deserializedValue?.GetType()})"); + + // Assert + Assert.IsNotNull(deserializedArray, "Deserialized array should not be null"); + Assert.AreEqual(1, deserializedArray.Length, "Array should have 1 element"); + Assert.IsInstanceOfType(deserializedValue, typeof(bool), "Value should be bool"); + Assert.IsTrue((bool)deserializedValue!, "Boolean true should deserialize as true, NOT false!"); + } + + [TestMethod] + public void PrimitiveArray_BooleanFalse_SerializesAndDeserializesCorrectly() + { + var loadRelations = false; + var settings = GetMergeSettings(); + + var jsonString = (new[] { loadRelations }).ToJson(settings); + Console.WriteLine($"Serialized [false]: {jsonString}"); + + var targetArrayType = typeof(bool).MakeArrayType(); + var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; + var deserializedValue = deserializedArray?.GetValue(0); + + Console.WriteLine($"Deserialized value: {deserializedValue}"); + + Assert.IsNotNull(deserializedArray); + Assert.IsFalse((bool)deserializedValue!, "Boolean false should deserialize as false"); + } + + [TestMethod] + public void PrimitiveArray_Int_SerializesAndDeserializesCorrectly() + { + var pageSize = 42; + var settings = GetMergeSettings(); + + var jsonString = (new[] { pageSize }).ToJson(settings); + Console.WriteLine($"Serialized [42]: {jsonString}"); + + var targetArrayType = typeof(int).MakeArrayType(); + var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; + var deserializedValue = deserializedArray?.GetValue(0); + + Console.WriteLine($"Deserialized value: {deserializedValue}"); + + Assert.IsNotNull(deserializedArray); + Assert.AreEqual(42, (int)deserializedValue!, "Int 42 should deserialize as 42"); + } + + [TestMethod] + public void PrimitiveArray_Guid_SerializesAndDeserializesCorrectly() + { + var id = Guid.NewGuid(); + var settings = GetMergeSettings(); + + var jsonString = (new[] { id }).ToJson(settings); + Console.WriteLine($"Serialized [{id}]: {jsonString}"); + + var targetArrayType = typeof(Guid).MakeArrayType(); + var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; + var deserializedValue = deserializedArray?.GetValue(0); + + Console.WriteLine($"Deserialized value: {deserializedValue}"); + + Assert.IsNotNull(deserializedArray); + Assert.AreEqual(id, (Guid)deserializedValue!, "Guid should deserialize correctly"); + } + + [TestMethod] + public void PrimitiveArray_String_SerializesAndDeserializesCorrectly() + { + var filterText = "test filter"; + var settings = GetMergeSettings(); + + var jsonString = (new[] { filterText }).ToJson(settings); + Console.WriteLine($"Serialized [\"test filter\"]: {jsonString}"); + + var targetArrayType = typeof(string).MakeArrayType(); + var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; + var deserializedValue = deserializedArray?.GetValue(0); + + Console.WriteLine($"Deserialized value: {deserializedValue}"); + + Assert.IsNotNull(deserializedArray); + Assert.AreEqual("test filter", (string)deserializedValue!, "String should deserialize correctly"); + } + + [TestMethod] + public void PrimitiveArray_Enum_SerializesAndDeserializesCorrectly() + { + var status = TestStatus.Processing; + var settings = GetMergeSettings(); + + var jsonString = (new[] { status }).ToJson(settings); + Console.WriteLine($"Serialized [TestStatus.Processing]: {jsonString}"); + + var targetArrayType = typeof(TestStatus).MakeArrayType(); + var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; + var deserializedValue = deserializedArray?.GetValue(0); + + Console.WriteLine($"Deserialized value: {deserializedValue}"); + + Assert.IsNotNull(deserializedArray); + Assert.AreEqual(TestStatus.Processing, (TestStatus)deserializedValue!, "Enum should deserialize correctly"); + } + + [TestMethod] + public void PrimitiveArray_DateTime_SerializesAndDeserializesCorrectly() + { + var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); + var settings = GetMergeSettings(); + + var jsonString = (new[] { dateTime }).ToJson(settings); + Console.WriteLine($"Serialized [{dateTime}]: {jsonString}"); + + var targetArrayType = typeof(DateTime).MakeArrayType(); + var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; + var deserializedValue = deserializedArray?.GetValue(0); + + Console.WriteLine($"Deserialized value: {deserializedValue}"); + + Assert.IsNotNull(deserializedArray); + Assert.AreEqual(dateTime, (DateTime)deserializedValue!, "DateTime should deserialize correctly"); + } + + [TestMethod] + public void PrimitiveArray_Decimal_SerializesAndDeserializesCorrectly() + { + var price = 123.45m; + var settings = GetMergeSettings(); + + var jsonString = (new[] { price }).ToJson(settings); + Console.WriteLine($"Serialized [{price}]: {jsonString}"); + + var targetArrayType = typeof(decimal).MakeArrayType(); + var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; + var deserializedValue = deserializedArray?.GetValue(0); + + Console.WriteLine($"Deserialized value: {deserializedValue}"); + + Assert.IsNotNull(deserializedArray); + Assert.AreEqual(price, (decimal)deserializedValue!, "Decimal should deserialize correctly"); + } + + [TestMethod] + public void PrimitiveArray_MultipleParameters_SimulateSignalRCall() + { + // Simulate: GetStockTakings(loadRelations: true, filter: "test", pageSize: 100) + // Client sends: contextParams = new object[] { true, "test", 100 } + // IdMessage wraps each param: Ids.Add((new[] { param }).ToJson()) -> "[true]", "[\"test\"]", "[100]" + var settings = GetMergeSettings(); + + // Simulate IdMessage constructor + var contextParams = new object[] { true, "filterText", 100, Guid.NewGuid(), TestStatus.Processing }; + var serializedParams = new List(); + foreach (var param in contextParams) + { + // This is what IdMessage does: item = (new[] { x }).ToJson(); + var wrapped = Array.CreateInstance(param.GetType(), 1); + wrapped.SetValue(param, 0); + var json = wrapped.ToJson(settings); + serializedParams.Add(json); + Console.WriteLine($"IdMessage serialized: {param.GetType().Name} '{param}' -> {json}"); + } + + // Simulate ProcessOnReceiveMessage deserialization + var paramTypes = new[] { typeof(bool), typeof(string), typeof(int), typeof(Guid), typeof(TestStatus) }; + var deserializedParams = new object[paramTypes.Length]; + + for (var i = 0; i < paramTypes.Length; i++) + { + var jsonStr = serializedParams[i]; + var arrayType = paramTypes[i].MakeArrayType(); + var arr = jsonStr.JsonTo(arrayType, settings) as Array; + deserializedParams[i] = arr?.GetValue(0)!; + Console.WriteLine($"ProcessOnReceiveMessage deserialized: {paramTypes[i].Name} -> {deserializedParams[i]}"); + } + + // Assert - All values should be correctly deserialized + Assert.IsTrue((bool)deserializedParams[0], "loadRelations should be TRUE!"); + Assert.AreEqual("filterText", (string)deserializedParams[1]); + Assert.AreEqual(100, (int)deserializedParams[2]); + Assert.AreEqual(contextParams[3], (Guid)deserializedParams[3]); + Assert.AreEqual(TestStatus.Processing, (TestStatus)deserializedParams[4]); + } + + [TestMethod] + public void PrimitiveArray_GenericJsonTo_BoolArray_WorksCorrectly() + { + // Test the generic JsonTo method with primitive arrays + var settings = GetMergeSettings(); + + var json = "[true, false, true]"; + var result = json.JsonTo(settings); + + Assert.IsNotNull(result); + Assert.AreEqual(3, result.Length); + Assert.IsTrue(result[0]); + Assert.IsFalse(result[1]); + Assert.IsTrue(result[2]); + } + + [TestMethod] + public void PrimitiveArray_GenericJsonTo_IntList_WorksCorrectly() + { + // Test the generic JsonTo method with primitive lists + var settings = GetMergeSettings(); + + var json = "[1, 2, 3, 4, 5]"; + var result = json.JsonTo>(settings); + + Assert.IsNotNull(result); + Assert.AreEqual(5, result.Count); + CollectionAssert.AreEqual(new[] { 1, 2, 3, 4, 5 }, result.ToArray()); + } + + #endregion +} \ No newline at end of file diff --git a/AyCode.Core/AyCode.Core.csproj b/AyCode.Core/AyCode.Core.csproj index 49582f5..6496fa0 100644 --- a/AyCode.Core/AyCode.Core.csproj +++ b/AyCode.Core/AyCode.Core.csproj @@ -14,7 +14,7 @@ - + diff --git a/AyCode.Core/Extensions/MergeContractResolver.cs b/AyCode.Core/Extensions/MergeContractResolver.cs new file mode 100644 index 0000000..558f206 --- /dev/null +++ b/AyCode.Core/Extensions/MergeContractResolver.cs @@ -0,0 +1,991 @@ +ο»Ώusing System.Collections; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; +using AyCode.Core.Interfaces; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace AyCode.Core.Extensions +{ + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + public sealed class JsonNoMergeCollectionAttribute : Attribute { } + + /// + /// Thread-safe object pool for reducing allocations + /// + 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 PropertyInfo Property { get; } + public string Name { get; } + public Type PropertyType { get; } + public bool IsIId { get; } + public Type? IdType { get; } + public bool IsIIdCollection { get; } + public Type? CollectionElementType { get; } + public Type? CollectionElementIdType { get; } + public bool ShouldSkip { get; } + public bool CanRead { get; } + public bool HasIndexParameters { get; } + + public CachedPropertyInfo(PropertyInfo prop) + { + Property = prop; + Name = prop.Name; + PropertyType = prop.PropertyType; + CanRead = prop.CanRead; + HasIndexParameters = prop.GetIndexParameters().Length > 0; + + // Pre-compute skip condition + ShouldSkip = !CanRead || HasIndexParameters || TypeCache.HasJsonIgnoreAttribute(prop); + + if (!ShouldSkip) + { + var (isId, idType) = TypeCache.GetIdInfo(PropertyType); + IsIId = isId; + IdType = idType; + + if (!IsIId && typeof(IEnumerable).IsAssignableFrom(PropertyType) && PropertyType != typeof(string)) + { + CollectionElementType = TypeCache.GetElementType(PropertyType); + if (CollectionElementType != null) + { + var (elemIsId, elemIdType) = TypeCache.GetIdInfo(CollectionElementType); + IsIIdCollection = elemIsId; + CollectionElementIdType = elemIdType; + } + } + } + } + } + + 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(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetTypeName(Type t) + { + return _typeNameCache.GetOrAdd(t, static type => type.Name); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static (bool IsId, Type? IdType) GetIdInfo(Type t) + { + 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; + } + + var idType = foundInterface?.GetGenericArguments()[0]; + return (foundInterface != null && idType != null && idType.IsValueType, idType); + }); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Type? GetElementType(Type t) + { + return _collectionElemCache.GetOrAdd(t, static type => + { + 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; + } + } + + return ienum?.GetGenericArguments()[0]; + }); + } + + // πŸ”‘ OPTIMIZATION: Get fully cached property info with all computed values + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static CachedPropertyInfo[] GetCachedProperties(Type t) + { + return _cachedPropertyInfoCache.GetOrAdd(t, static type => + { + 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) + { + return _jsonIgnoreCache.GetOrAdd(prop, static p => + p.GetCustomAttribute() != null || + p.GetCustomAttribute() != null); + } + } + + public static class ReferenceRegistry + { + private const string ContextKey = "SemanticReferenceRegistry"; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Dictionary GetRegistry(JsonSerializer serializer) + { + if (serializer.Context.Context is not Dictionary globalMap) + { + globalMap = new Dictionary(4); + serializer.Context = new StreamingContext(StreamingContextStates.All, globalMap); + } + + if (globalMap.TryGetValue(ContextKey, out var registry) && registry is Dictionary typedRegistry) + { + return typedRegistry; + } + + 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; + } + } + + 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 EqualityComparer IdComparer = EqualityComparer.Default; + + public override bool CanRead => true; + 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); + } + } + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return existingValue; + + if (existingValue is not IList targetList) + { + var jsonArrayFallback = JArray.Load(reader); + 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); + + // πŸ”‘ OPTIMIZATION: Process JSON array with direct indexing + for (var i = 0; i < jsonCount; i++) + { + var itemToken = jsonArray[i]; + TItem? itemResult = null; + + if (itemToken is JObject jObj) + { + var incomingId = IdExtractor.GetIdFromJToken(jObj); + var hasId = !IdComparer.Equals(incomingId, default); + + TItem? existingItem = null; + if (hasId && existingItemsMap.TryGetValue(incomingId, out var found)) + { + existingItem = found; + } + + 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); + } + } + else + { + itemResult = itemToken.ToObject(serializer); + } + + if (itemResult == null) continue; + + var currentId = itemResult.Id; + var isIdentifiable = !IdComparer.Equals(currentId, default); + + if (isIdentifiable) + { + if (processedIds.Add(currentId)) + { + finalItems.Add(itemResult); + } + } + else + { + finalItems.Add(itemResult); + } + } + + // KEEP logic + 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]; + } + return resultArray; + } + + // πŸ”‘ OPTIMIZATION: Use AddRange for List + targetList.Clear(); + if (targetList is List typedList) + { + 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) + { + throw new InvalidOperationException("IdAwareCollectionMergeConverter is read-only."); + } + } + + 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(); + + [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 => + 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)) + { + 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; + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + 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)) + { + 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)) + { + property.ObjectCreationHandling = ObjectCreationHandling.Replace; + return property; + } + + // ID collections: Merge Converter + if (isIdCollection && idType != null && elemType != null && !IsPrimitive(elemType)) + { + var key = (elemType, idType); + property.Converter = CollectionConverterCache.GetOrAdd(key, static k => + (JsonConverter)Activator.CreateInstance( + typeof(IdAwareCollectionMergeConverter<,>).MakeGenericType(k.Item1, k.Item2))!); + + property.ObjectCreationHandling = ObjectCreationHandling.Reuse; + return property; + } + + return property; + } + } + + 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(); + + public static void DeepPopulateWithMerge(this T target, string json, JsonSerializerSettings? settings = null) where T : notnull + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(json); + + settings ??= new JsonSerializerSettings(); + + if (settings.Context.Context is not Dictionary) + { + settings.Context = new StreamingContext(StreamingContextStates.All, new Dictionary(4)); + } + + // πŸ”‘ 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) + { + var type = target.GetType(); + var elemType = TypeCache.GetElementType(type); + + if (elemType != null) + { + 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))!); + + using var reader = token.CreateReader(); + converterInstance.ReadJson(reader, target.GetType(), target, serializer); + return; + } + } + } + + // Normal object-level merge + using (var reader = token.CreateReader()) + { + serializer.Populate(reader, target); + } + } + } +} diff --git a/AyCode.Core/Extensions/SerializeObjectExtensions.cs b/AyCode.Core/Extensions/SerializeObjectExtensions.cs index d55b523..033e239 100644 --- a/AyCode.Core/Extensions/SerializeObjectExtensions.cs +++ b/AyCode.Core/Extensions/SerializeObjectExtensions.cs @@ -7,25 +7,110 @@ using Newtonsoft.Json.Serialization; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; using System.Text.RegularExpressions; namespace AyCode.Core.Extensions; +/// +/// Hybrid reference resolver that uses semantic IDs for IId<T> types +/// and standard numeric IDs for other types. +/// +public class HybridReferenceResolver : IReferenceResolver +{ + 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 static object? GetDefault(Type type) + { + return type.IsValueType ? Activator.CreateInstance(type) : null; + } +} + +/// +/// Reference equality comparer for proper object identity comparison +/// +internal 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); +} + public static class SerializeObjectExtensions { - public static readonly JsonSerializerSettings Options = new() + // Hybrid settings that support both semantic IDs for IId types + // and standard reference handling for other types + public static JsonSerializerSettings Options => new() { - //TypeNameHandling = TypeNameHandling.All, - PreserveReferencesHandling = PreserveReferencesHandling.Objects, - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - NullValueHandling = NullValueHandling.Ignore, + ContractResolver = new UnifiedMergeContractResolver(), + Context = new StreamingContext(StreamingContextStates.All, new Dictionary()), - ////System.Text.Json - //ReferenceHandler.Preserve - //ReferenceHandler.IgnoreCycles + // Enable reference handling with our hybrid resolver + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + ReferenceLoopHandling = ReferenceLoopHandling.Serialize, + ReferenceResolverProvider = () => new HybridReferenceResolver(), + NullValueHandling = NullValueHandling.Ignore, }; - 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); @@ -34,7 +119,6 @@ public static class SerializeObjectExtensions { if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"'); - //JsonConvert.PopulateObject(json, existingObject); return JsonConvert.DeserializeObject(json, options ?? Options); } @@ -49,15 +133,12 @@ public static class SerializeObjectExtensions { if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"'); - JsonConvert.PopulateObject(json, target, options ?? Options); + target.DeepPopulateWithMerge(json, options ?? Options); } + /// /// Using JSON /// - /// - /// - /// - /// [return: NotNullIfNotNull(nameof(src))] public static TDestination? CloneTo(this object? src, JsonSerializerSettings? options = null) where TDestination : class => src?.ToJson(options).JsonTo(options); @@ -65,14 +146,8 @@ public static class SerializeObjectExtensions /// /// Using JSON /// - /// - /// - /// - /// public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null) => src?.ToJson(options).JsonTo(target, options); - //public static string ToJson(this Expression source) => JsonConvert.SerializeObject(source, Options); - public static byte[] ToMessagePack(this object message) => MessagePackSerializer.Serialize(message); public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options) => MessagePackSerializer.Serialize(message, options); diff --git a/AyCode.Core/Interfaces/IId.cs b/AyCode.Core/Interfaces/IId.cs index d551577..1706fb3 100644 --- a/AyCode.Core/Interfaces/IId.cs +++ b/AyCode.Core/Interfaces/IId.cs @@ -1,12 +1,13 @@ ο»Ώusing System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; namespace AyCode.Core.Interfaces { - public interface IId + public interface IId// : IEquatable { T Id { get; set; } } diff --git a/AyCode.Database/AyCode.Database.csproj b/AyCode.Database/AyCode.Database.csproj index 1520541..36c9210 100644 --- a/AyCode.Database/AyCode.Database.csproj +++ b/AyCode.Database/AyCode.Database.csproj @@ -12,7 +12,7 @@ - + diff --git a/AyCode.Services.Server/AyCode.Services.Server.csproj b/AyCode.Services.Server/AyCode.Services.Server.csproj index 8bb4b63..f837aff 100644 --- a/AyCode.Services.Server/AyCode.Services.Server.csproj +++ b/AyCode.Services.Server/AyCode.Services.Server.csproj @@ -1,4 +1,4 @@ -ο»Ώ +ο»Ώ @@ -11,6 +11,7 @@ + diff --git a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs index ec8bf59..b47387a 100644 --- a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs +++ b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs @@ -265,27 +265,6 @@ namespace AyCode.Services.Server.SignalRs }, GetContextParams()); } - protected void AddRange(IEnumerable source, TIList destination) - { - switch (destination) - { - case IAcObservableCollection dest: - dest.AddRange(source); - break; - case List dest: - dest.AddRange(source); - break; - default: - { - foreach (var dataItem in source) - destination.Add(dataItem); - break; - } - } - } - - public void AddRange(IEnumerable source) => AddRange(source, InnerList); - public async Task LoadDataSource(TIList fromSource, bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true) { Monitor.Enter(_syncRoot); @@ -294,10 +273,17 @@ namespace AyCode.Services.Server.SignalRs { if (!ReferenceEquals(InnerList, fromSource)) { - Clear(clearChangeTracking); + if (!setSourceToWorkingReferenceList) + { + fromSource.CopyTo(InnerList); + } + else + { + Clear(clearChangeTracking); - if (setSourceToWorkingReferenceList) SetWorkingReferenceList(fromSource); - else AddRange(fromSource); + if (setSourceToWorkingReferenceList) SetWorkingReferenceList(fromSource); + else AddRange(fromSource); + } } else if (clearChangeTracking) TrackingItems.Clear(); @@ -329,7 +315,7 @@ namespace AyCode.Services.Server.SignalRs resultitem = await SignalRClient.GetByIdAsync(SignalRCrudTags.GetItemMessageTag, id); if (resultitem == null) return null; - if (TryGetIndex(id, out var index)) InnerList[index] = resultitem; + if (TryGetIndex(id, out var index)) resultitem.CopyTo(InnerList[index]);//InnerList[index] = resultitem); else InnerList.Add(resultitem); var eventArgs = new ItemChangedEventArgs(resultitem, TrackingState.Get); @@ -460,6 +446,27 @@ namespace AyCode.Services.Server.SignalRs InnerList.Add(newValue); } + public void AddRange(IEnumerable source) => AddRange(source, InnerList); + protected void AddRange(IEnumerable source, TIList destination) + { + //TODO: CHANGETRACKINGITEM - J. + switch (destination) + { + case IAcObservableCollection dest: + dest.AddRange(source); + break; + case List dest: + dest.AddRange(source); + break; + default: + { + foreach (var dataItem in source) + destination.Add(dataItem); + break; + } + } + } + /// /// AddMessageTag /// @@ -905,7 +912,10 @@ namespace AyCode.Services.Server.SignalRs TrackingItems.Remove(trackingItem); if (TryGetIndex(originalId, out var index)) - InnerList[index] = resultItem; + { + //InnerList[index] = resultItem; + resultItem.CopyTo(InnerList[index]); + } var eventArgs = new ItemChangedEventArgs(resultItem, trackingState); if (OnDataSourceItemChanged != null) return OnDataSourceItemChanged.Invoke(eventArgs); @@ -918,7 +928,7 @@ namespace AyCode.Services.Server.SignalRs if (TryGetIndex(trackingItem.CurrentValue.Id, out var index)) { if (trackingItem.TrackingState == TrackingState.Add) InnerList.RemoveAt(index); - else InnerList[index] = trackingItem.OriginalValue!; + else trackingItem.OriginalValue!.CopyTo(InnerList[index]);//InnerList[index] = trackingItem.OriginalValue!); } else if (trackingItem.TrackingState != TrackingState.Add) InnerList.Add(trackingItem.OriginalValue!);