Enhance JSON handling and add hybrid reference support
- Updated all projects to use `Newtonsoft.Json` v13.0.3 for consistency. - Introduced `HybridReferenceResolver` for semantic and numeric ID handling. - Refactored `SerializeObjectExtensions` to support deep JSON merging. - Simplified `IId<T>` interface by removing `IEquatable<T>` constraint. - Improved `AcSignalRDataSource` with robust `AddRange` and `CopyTo` methods. - Added `JsonExtensionTests` for deep hierarchy, reference, and edge cases. - Implemented `UnifiedMergeContractResolver` for custom JSON behavior. - Optimized type/property caching with `TypeCache` and `CachedPropertyInfo`. - Enhanced SignalR integration to fix primitive array deserialization issues. - Introduced `JsonNoMergeCollection` attribute for replace-only collections. - Added test DTOs and `TestDataFactory` for real-world scenario simulations. - Improved performance with `ConcurrentDictionary` and `ObjectPool`. - Fixed `$id`/`$ref` handling for non-semantic references and arrays.
This commit is contained in:
parent
f3ec941774
commit
166d97106d
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="4.0.2" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="4.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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<object, object>()),
|
||||
// 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
|
||||
|
||||
/// <summary>
|
||||
/// Status enum used across tests
|
||||
/// </summary>
|
||||
public enum TestStatus
|
||||
{
|
||||
Pending = 10,
|
||||
Processing = 20,
|
||||
Shipped = 30
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 5: Deepest level - Measurement point with value
|
||||
/// </summary>
|
||||
public class TestMeasurementPoint : IId<int>
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 4: Measurement with multiple points
|
||||
/// </summary>
|
||||
public class TestMeasurement : IId<int>
|
||||
{
|
||||
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<TestMeasurementPoint> Points { get; set; } = [];
|
||||
|
||||
// Reference loop back to Level 3 (parent)
|
||||
[JsonIgnore] public TestPallet? ParentPallet { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 3: Pallet containing measurements
|
||||
/// </summary>
|
||||
public class TestPallet : IId<int>
|
||||
{
|
||||
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<TestMeasurement> 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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 2: Order item with pallets
|
||||
/// </summary>
|
||||
public class TestOrderItem : IId<int>
|
||||
{
|
||||
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<TestPallet> 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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 1: Main order containing items
|
||||
/// </summary>
|
||||
public class TestOrder : IId<int>
|
||||
{
|
||||
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<TestOrderItem> Items { get; set; } = [];
|
||||
|
||||
// IId collection for attributes (shared references testing)
|
||||
public List<TestSharedAttribute> Attributes { get; set; } = [];
|
||||
|
||||
// Non-IId collection - will be replaced, not merged
|
||||
public List<TestNonIdMetadata> MetadataList { get; set; } = [];
|
||||
|
||||
// Collection with NoMerge attribute - will be replaced
|
||||
[JsonNoMergeCollection] public List<TestOrderItem> 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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared IId attribute - for semantic $id/$ref testing across objects
|
||||
/// </summary>
|
||||
public class TestSharedAttribute : IId<int>
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-IId metadata class - uses Newtonsoft PreserveReferencesHandling (numeric $id/$ref)
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order DTO with Guid Id - for testing Guid-based IId
|
||||
/// </summary>
|
||||
public class TestGuidOrder : IId<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public List<TestGuidItem> Items { get; set; } = [];
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Item DTO with Guid Id - for testing Guid-based IId
|
||||
/// </summary>
|
||||
public class TestGuidItem : IId<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int Qty { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Data Factory
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating deep test object hierarchies
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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<string>();
|
||||
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<T> method with primitive arrays
|
||||
var settings = GetMergeSettings();
|
||||
|
||||
var json = "[true, false, true]";
|
||||
var result = json.JsonTo<bool[]>(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<T> method with primitive lists
|
||||
var settings = GetMergeSettings();
|
||||
|
||||
var json = "[1, 2, 3, 4, 5]";
|
||||
var result = json.JsonTo<List<int>>(settings);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(5, result.Count);
|
||||
CollectionAssert.AreEqual(new[] { 1, 2, 3, 4, 5 }, result.ToArray());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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 { }
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe object pool for reducing allocations
|
||||
/// </summary>
|
||||
internal sealed class ObjectPool<T> where T : class, new()
|
||||
{
|
||||
private readonly ConcurrentBag<T> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached property metadata for faster JSON processing
|
||||
/// </summary>
|
||||
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<Type, (bool IsId, Type? IdType)> _idCache = new();
|
||||
private static readonly ConcurrentDictionary<Type, Type?> _collectionElemCache = new();
|
||||
|
||||
// 🔑 OPTIMIZATION: Cache type names for semantic key generation
|
||||
private static readonly ConcurrentDictionary<Type, string> _typeNameCache = new();
|
||||
|
||||
// 🔑 OPTIMIZATION: Cache fully processed property info for types
|
||||
private static readonly ConcurrentDictionary<Type, CachedPropertyInfo[]> _cachedPropertyInfoCache = new();
|
||||
|
||||
// 🔑 OPTIMIZATION: Cache JsonIgnore attribute check results per property
|
||||
private static readonly ConcurrentDictionary<PropertyInfo, bool> _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<JsonIgnoreAttribute>() != null ||
|
||||
p.GetCustomAttribute<System.Text.Json.Serialization.JsonIgnoreAttribute>() != null);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ReferenceRegistry
|
||||
{
|
||||
private const string ContextKey = "SemanticReferenceRegistry";
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static Dictionary<string, object> GetRegistry(JsonSerializer serializer)
|
||||
{
|
||||
if (serializer.Context.Context is not Dictionary<object, object> globalMap)
|
||||
{
|
||||
globalMap = new Dictionary<object, object>(4);
|
||||
serializer.Context = new StreamingContext(StreamingContextStates.All, globalMap);
|
||||
}
|
||||
|
||||
if (globalMap.TryGetValue(ContextKey, out var registry) && registry is Dictionary<string, object> typedRegistry)
|
||||
{
|
||||
return typedRegistry;
|
||||
}
|
||||
|
||||
var newRegistry = new Dictionary<string, object>(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<int>.Id);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static TId GetIdFromJToken<TId>(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<int>();
|
||||
}
|
||||
|
||||
if (typeof(TId) == typeof(Guid))
|
||||
{
|
||||
var stringValue = idPropToken.Value<string>();
|
||||
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<long>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return idPropToken.Value<TId>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class IdAwareObjectConverter<TItem, TId> : JsonConverter
|
||||
where TItem : class, IId<TId>, 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<TId> IdComparer = EqualityComparer<TId>.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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts JToken to string using StringWriter to avoid Newtonsoft.Json version compatibility issues.
|
||||
/// The JToken.ToString(Formatting) method signature may differ between versions.
|
||||
/// </summary>
|
||||
[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))!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively removes non-semantic (numeric) $id and $ref tokens from a JToken hierarchy.
|
||||
/// Semantic keys (containing '_') are preserved for the custom IId reference system.
|
||||
/// </summary>
|
||||
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<string, JProperty>? 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<string, JProperty> BuildPropertyLookup(JObject jsonObject)
|
||||
{
|
||||
var lookup = new Dictionary<string, JProperty>(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<TId>(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<TItem, TId> : JsonConverter
|
||||
where TItem : class, IId<TId>, 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<TId> IdComparer = EqualityComparer<TId>.Default;
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanConvert(Type objectType) =>
|
||||
typeof(ICollection<TItem>).IsAssignableFrom(objectType) || typeof(IEnumerable<TItem>).IsAssignableFrom(objectType);
|
||||
public override bool CanWrite => false;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string GetSemanticKey(TId id) => string.Concat(TypeNamePrefix, id.ToString());
|
||||
|
||||
/// <summary>
|
||||
/// Recursively removes all $id and $ref tokens from a JToken hierarchy
|
||||
/// to prevent conflicts with Newtonsoft's built-in reference resolver.
|
||||
/// </summary>
|
||||
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<TId, TItem>(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<TItem>(jsonCount + existingItemsMap.Count);
|
||||
var processedIds = new HashSet<TId>(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<TId>(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<TItem>(serializer);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
itemResult = itemToken.ToObject<TItem>(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<T>
|
||||
targetList.Clear();
|
||||
if (targetList is List<TItem> 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<Type> 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<MemberInfo, bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 🔑 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.
|
||||
/// </summary>
|
||||
[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<JsonNoMergeCollectionAttribute>() != null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 🔑 FIX: Override CreateArrayContract to disable reference handling for primitive arrays.
|
||||
/// This prevents issues where [true] becomes [false] due to $id/$ref handling on primitives.
|
||||
/// </summary>
|
||||
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<T>(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<object, object>)
|
||||
{
|
||||
settings.Context = new StreamingContext(StreamingContextStates.All, new Dictionary<object, object>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid reference resolver that uses semantic IDs for IId<T> types
|
||||
/// and standard numeric IDs for other types.
|
||||
/// </summary>
|
||||
public class HybridReferenceResolver : IReferenceResolver
|
||||
{
|
||||
private readonly Dictionary<string, object> _idToObject = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<object, string> _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<T>
|
||||
var type = value.GetType();
|
||||
var (isId, idType) = TypeCache.GetIdInfo(type);
|
||||
|
||||
string newRef;
|
||||
if (isId && idType != null)
|
||||
{
|
||||
// Use semantic ID for IId<T> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference equality comparer for proper object identity comparison
|
||||
/// </summary>
|
||||
internal class ReferenceEqualityComparer : IEqualityComparer<object>
|
||||
{
|
||||
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<T> 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<object, object>()),
|
||||
|
||||
////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<T>(this T source, JsonSerializerSettings? options = null) => JsonConvert.SerializeObject(source, options ?? Options);
|
||||
public static string ToJson<T>(this IQueryable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson => JsonConvert.SerializeObject(source, options ?? Options);
|
||||
public static string ToJson<T>(this IEnumerable<T> 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<T>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Using JSON
|
||||
/// </summary>
|
||||
/// <typeparam name="TDestination"></typeparam>
|
||||
/// <param name="src"></param>
|
||||
/// <param name="options"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull(nameof(src))]
|
||||
public static TDestination? CloneTo<TDestination>(this object? src, JsonSerializerSettings? options = null) where TDestination : class
|
||||
=> src?.ToJson(options).JsonTo<TDestination>(options);
|
||||
|
|
@ -65,14 +146,8 @@ public static class SerializeObjectExtensions
|
|||
/// <summary>
|
||||
/// Using JSON
|
||||
/// </summary>
|
||||
/// <param name="src"></param>
|
||||
/// <param name="target"></param>
|
||||
/// <param name="options"></param>
|
||||
/// <returns></returns>
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>
|
||||
public interface IId<T>// : IEquatable<T>
|
||||
{
|
||||
T Id { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.11" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
</PropertyGroup>
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.11" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -265,27 +265,6 @@ namespace AyCode.Services.Server.SignalRs
|
|||
}, GetContextParams());
|
||||
}
|
||||
|
||||
protected void AddRange(IEnumerable<TDataItem> source, TIList destination)
|
||||
{
|
||||
switch (destination)
|
||||
{
|
||||
case IAcObservableCollection dest:
|
||||
dest.AddRange(source);
|
||||
break;
|
||||
case List<TDataItem> dest:
|
||||
dest.AddRange(source);
|
||||
break;
|
||||
default:
|
||||
{
|
||||
foreach (var dataItem in source)
|
||||
destination.Add(dataItem);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void AddRange(IEnumerable<TDataItem> 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<TDataItem>(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<TDataItem>(resultitem, TrackingState.Get);
|
||||
|
|
@ -460,6 +446,27 @@ namespace AyCode.Services.Server.SignalRs
|
|||
InnerList.Add(newValue);
|
||||
}
|
||||
|
||||
public void AddRange(IEnumerable<TDataItem> source) => AddRange(source, InnerList);
|
||||
protected void AddRange(IEnumerable<TDataItem> source, TIList destination)
|
||||
{
|
||||
//TODO: CHANGETRACKINGITEM - J.
|
||||
switch (destination)
|
||||
{
|
||||
case IAcObservableCollection dest:
|
||||
dest.AddRange(source);
|
||||
break;
|
||||
case List<TDataItem> dest:
|
||||
dest.AddRange(source);
|
||||
break;
|
||||
default:
|
||||
{
|
||||
foreach (var dataItem in source)
|
||||
destination.Add(dataItem);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AddMessageTag
|
||||
/// </summary>
|
||||
|
|
@ -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<TDataItem>(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!);
|
||||
|
|
|
|||
Loading…
Reference in New Issue