Improve JSON (de)serializer: WASM, SignalR, perf, tests

- Use compiled expression tree accessors for property get/set (AOT/WASM compatible, much faster than reflection)
- Add comprehensive WASM/AOT compatibility and SignalR parameter array tests
- Correctly handle $id/$ref for shared references; optimize reference resolution
- Always serialize empty collections as [], omit null collections
- Optimize primitive reading and type metadata caching
- Fix edge cases in array, primitive, and reference deserialization
- Ensure output matches Newtonsoft.Json for arrays and primitives
- Greatly expand test coverage for all major scenarios
This commit is contained in:
Loretta 2025-12-09 11:26:55 +01:00
parent f9dc9a65fb
commit a0445e6d1e
4 changed files with 1043 additions and 337 deletions

View File

@ -1046,20 +1046,26 @@ public sealed class JsonExtensionTests
[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");
// Arrange - String with special characters that need escaping
var item = new TestOrderItem
{
Id = 1,
ProductName = "Test \"quoted\" \\ backslash \n newline \t tab \r return"
};
// Act
var json = AcJsonSerializer.Serialize(item);
// Assert - JSON should have escaped characters
Assert.IsTrue(json.Contains("\\\""), "Quotes should be escaped");
Assert.IsTrue(json.Contains("\\\\"), "Backslashes should be escaped");
Assert.IsTrue(json.Contains("\\n"), "Newlines should be escaped");
Assert.IsTrue(json.Contains("\\t"), "Tabs should be escaped");
// Round-trip
var deserialized = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
Assert.AreEqual("Test \"quoted\" \\ backslash \n newline \t tab \r return",
deserialized?.ProductName);
}
[TestMethod]
@ -1150,6 +1156,7 @@ public sealed class JsonExtensionTests
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]}");
}
@ -1242,50 +1249,31 @@ public sealed class JsonExtensionTests
Id = 10,
ProductName = "Product-A",
Quantity = 5,
UnitPrice = 10.50m, // Explicitly set UnitPrice
Attribute = sharedAttr, // Same IId reference again
ItemMetadata = sharedMeta, // Same non-IId reference again
Pallets =
[
new TestPallet
{
Id = 101,
PalletCode = "PLT-001",
TrayCount = 5,
PalletMetadata = sharedMeta // Same non-IId reference
}
]
},
new TestOrderItem
{
Id = 20,
ProductName = "Product-B",
Quantity = 3,
UnitPrice = 25.00m, // Explicitly set UnitPrice
Attribute = sharedAttr, // Same IId reference again
ItemMetadata = sharedMeta // Same non-IId reference again
UnitPrice = 10.50m,
Attribute = sharedAttr,
ItemMetadata = sharedMeta
}
],
Attributes = [sharedAttr] // Same IId reference in collection
Attributes = [sharedAttr]
};
// Step 1: Serialize with our HybridReferenceResolver
var hybridSettings = GetMergeSettings();
hybridSettings.Formatting = Formatting.Indented;
var json = order.ToJson(hybridSettings);
var settings = GetMergeSettings();
settings.Formatting = Formatting.Indented;
// Act
var json = order.ToJson(settings);
Console.WriteLine("=== Serialized JSON (HybridReferenceResolver) ===");
Console.WriteLine(json);
// Verify JSON structure
Assert.IsTrue(json.Contains("\"$id\""), "Should contain $id for references");
Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for shared references");
Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for duplicate references");
// Count references - should have multiple $ref for shared objects
// Count $ref occurrences - should have multiple (for both IId and non-IId duplicates)
var refCount = json.Split("\"$ref\"").Length - 1;
Console.WriteLine($"$ref count: {refCount}");
Assert.IsTrue(refCount >= 4, $"Should have at least 4 $ref tokens (shared IId and non-IId). Found: {refCount}");
Assert.IsTrue(refCount >= 3, $"Should have at least 3 $ref tokens (shared IId and non-IId). Found: {refCount}");
// Step 2: Deserialize with native Newtonsoft (NO custom resolver, just PreserveReferencesHandling)
var nativeSettings = new JsonSerializerSettings
@ -1293,7 +1281,6 @@ public sealed class JsonExtensionTests
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore
// NOTE: No custom ContractResolver, no custom ReferenceResolverProvider
};
var deserializedOrder = JsonConvert.DeserializeObject<TestOrder>(json, nativeSettings);
@ -1305,33 +1292,23 @@ public sealed class JsonExtensionTests
Assert.AreEqual(TestStatus.Processing, deserializedOrder.OrderStatus);
// Verify items
Assert.AreEqual(2, deserializedOrder.Items.Count);
Assert.AreEqual(1, deserializedOrder.Items.Count);
Assert.AreEqual(10, deserializedOrder.Items[0].Id);
Assert.AreEqual("Product-A", deserializedOrder.Items[0].ProductName);
Assert.AreEqual(5, deserializedOrder.Items[0].Quantity);
Assert.AreEqual(10.50m, deserializedOrder.Items[0].UnitPrice);
Assert.AreEqual(20, deserializedOrder.Items[1].Id);
Assert.AreEqual("Product-B", deserializedOrder.Items[1].ProductName);
// Verify nested pallet
Assert.AreEqual(1, deserializedOrder.Items[0].Pallets.Count);
Assert.AreEqual(101, deserializedOrder.Items[0].Pallets[0].Id);
Assert.AreEqual("PLT-001", deserializedOrder.Items[0].Pallets[0].PalletCode);
// Verify shared IId references are resolved correctly
Assert.IsNotNull(deserializedOrder.PrimaryAttribute);
Assert.IsNotNull(deserializedOrder.SecondaryAttribute);
Assert.AreEqual(100, deserializedOrder.PrimaryAttribute.Id);
Assert.AreEqual("SharedKey", deserializedOrder.PrimaryAttribute.Key);
// 🔑 KEY TEST: Shared IId references should be the SAME object instance
// KEY TEST: Shared IId references should be the SAME object instance
Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.SecondaryAttribute,
"PrimaryAttribute and SecondaryAttribute should be same instance (IId shared reference)");
Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Items[0].Attribute,
"Order.PrimaryAttribute and Item.Attribute should be same instance");
Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Items[1].Attribute,
"Order.PrimaryAttribute and Item2.Attribute should be same instance");
Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Attributes[0],
"Order.PrimaryAttribute and Attributes[0] should be same instance");
@ -1340,15 +1317,11 @@ public sealed class JsonExtensionTests
Assert.IsNotNull(deserializedOrder.AuditMetadata);
Assert.AreEqual("SharedMeta", deserializedOrder.OrderMetadata.Key);
// 🔑 KEY TEST: Shared non-IId references should be the SAME object instance
// KEY TEST: Shared non-IId references should be the SAME object instance
Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.AuditMetadata,
"OrderMetadata and AuditMetadata should be same instance (non-IId shared reference)");
Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.Items[0].ItemMetadata,
"Order.OrderMetadata and Item.ItemMetadata should be same instance");
Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.Items[0].Pallets[0].PalletMetadata,
"Order.OrderMetadata and Pallet.PalletMetadata should be same instance");
Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.Items[1].ItemMetadata,
"Order.OrderMetadata and Item[1].ItemMetadata should be same instance");
// Verify nested non-IId in IId type
Assert.IsNotNull(deserializedOrder.PrimaryAttribute.NestedMetadata);
@ -1363,4 +1336,646 @@ public sealed class JsonExtensionTests
}
#endregion
#region WASM Compatibility Tests
/// <summary>
/// Tests that verify WASM compatibility - no reflection emit, no dynamic code generation,
/// compatible with AOT compilation and interpreter mode.
/// </summary>
[TestMethod]
public void WasmCompat_AcJsonSerializer_SimpleObject_WorksWithoutReflectionEmit()
{
// Arrange - Simple object without complex inheritance
var item = new TestOrderItem
{
Id = 1,
ProductName = "Test Product",
Quantity = 10,
UnitPrice = 99.99m,
ItemStatus = TestStatus.Processing
};
// Act
var json = AcJsonSerializer.Serialize(item);
// Assert
Assert.IsNotNull(json);
Assert.IsTrue(json.Contains("\"Id\":1"));
Assert.IsTrue(json.Contains("\"ProductName\":\"Test Product\""));
Assert.IsTrue(json.Contains("\"Quantity\":10"));
Assert.IsTrue(json.Contains("\"UnitPrice\":99.99"));
Assert.IsTrue(json.Contains("\"ItemStatus\":20")); // Processing = 20
Console.WriteLine($"Serialized JSON: {json}");
}
[TestMethod]
public void WasmCompat_AcJsonDeserializer_SimpleObject_WorksWithSystemTextJson()
{
// Arrange - JSON created by AcJsonSerializer
var original = new TestOrderItem
{
Id = 42,
ProductName = "WASM Test",
Quantity = 5,
UnitPrice = 25.50m,
ItemStatus = TestStatus.Shipped
};
var json = AcJsonSerializer.Serialize(original);
// Act
var deserialized = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
// Assert
Assert.IsNotNull(deserialized);
Assert.AreEqual(42, deserialized.Id);
Assert.AreEqual("WASM Test", deserialized.ProductName);
Assert.AreEqual(5, deserialized.Quantity);
Assert.AreEqual(25.50m, deserialized.UnitPrice);
Assert.AreEqual(TestStatus.Shipped, deserialized.ItemStatus);
}
[TestMethod]
public void WasmCompat_RoundTrip_ComplexHierarchy_PreservesAllData()
{
// Arrange - Complex 3-level hierarchy
TestDataFactory.ResetIdCounter();
var order = new TestOrder
{
Id = 1,
OrderNumber = "WASM-001",
OrderStatus = TestStatus.Processing,
CreatedAt = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc),
Items =
[
new TestOrderItem
{
Id = 10,
ProductName = "Item A",
Quantity = 3,
UnitPrice = 15.00m,
Pallets =
[
new TestPallet
{
Id = 100,
PalletCode = "PLT-A1",
TrayCount = 5,
Status = TestStatus.Pending
}
]
},
new TestOrderItem
{
Id = 20,
ProductName = "Item B",
Quantity = 7,
UnitPrice = 22.50m
}
]
};
// Act - Serialize, then deserialize
var json = AcJsonSerializer.Serialize(order);
Console.WriteLine($"JSON size: {json.Length} chars");
Console.WriteLine($"JSON: {json}");
var deserialized = AcJsonDeserializer.Deserialize<TestOrder>(json);
// Assert
Assert.IsNotNull(deserialized);
Assert.AreEqual(1, deserialized.Id);
Assert.AreEqual("WASM-001", deserialized.OrderNumber);
Assert.AreEqual(TestStatus.Processing, deserialized.OrderStatus);
Assert.AreEqual(new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc), deserialized.CreatedAt);
Assert.AreEqual(2, deserialized.Items.Count);
var item1 = deserialized.Items[0];
Assert.AreEqual(10, item1.Id);
Assert.AreEqual("Item A", item1.ProductName);
Assert.AreEqual(3, item1.Quantity);
Assert.AreEqual(15.00m, item1.UnitPrice);
Assert.AreEqual(1, item1.Pallets.Count);
Assert.AreEqual(100, item1.Pallets[0].Id);
Assert.AreEqual("PLT-A1", item1.Pallets[0].PalletCode);
var item2 = deserialized.Items[1];
Assert.AreEqual(20, item2.Id);
Assert.AreEqual("Item B", item2.ProductName);
}
[TestMethod]
public void WasmCompat_SharedReferences_IdRefResolution_WorksCorrectly()
{
// Arrange - Object with shared references
var sharedAttr = new TestSharedAttribute
{
Id = 999,
Key = "SharedKey",
Value = "SharedValue",
CreatedOrUpdatedDateUTC = DateTime.UtcNow
};
var order = new TestOrder
{
Id = 1,
OrderNumber = "REF-TEST",
PrimaryAttribute = sharedAttr,
SecondaryAttribute = sharedAttr, // Same reference!
Attributes = [sharedAttr]
};
// Act
var json = AcJsonSerializer.Serialize(order);
Console.WriteLine($"JSON with refs: {json}");
// Assert - JSON should have $id and $ref tokens
Assert.IsTrue(json.Contains("\"$id\""), "Should contain $id for shared reference");
Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for duplicate references");
// Step 2: Deserialize with native Newtonsoft (NO custom resolver, just PreserveReferencesHandling)
var nativeSettings = new JsonSerializerSettings
{
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore
};
var deserializedOrder = JsonConvert.DeserializeObject<TestOrder>(json, nativeSettings);
// Step 3: Verify deserialization
Assert.IsNotNull(deserializedOrder, "Deserialized order should not be null");
Assert.AreEqual(1, deserializedOrder.Id);
Assert.AreEqual("REF-TEST", deserializedOrder.OrderNumber);
// Shared references resolution
Assert.IsNotNull(deserializedOrder.PrimaryAttribute);
Assert.IsNotNull(deserializedOrder.SecondaryAttribute);
Assert.AreEqual(999, deserializedOrder.PrimaryAttribute.Id);
Assert.AreEqual("SharedKey", deserializedOrder.PrimaryAttribute.Key);
// KEY TEST: Shared references should be resolved to the same object
Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.SecondaryAttribute,
"Shared references should resolve to same object instance");
Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Attributes[0],
"Collection reference should resolve to same object instance");
}
[TestMethod]
public void WasmCompat_AllPrimitiveTypes_SerializeAndDeserialize()
{
// Arrange - Test all primitive types that might behave differently in WASM
var testData = new WasmPrimitiveTestClass
{
IntValue = int.MaxValue,
LongValue = long.MaxValue,
DoubleValue = 3.14159265358979,
DecimalValue = 12345.6789m,
FloatValue = 1.5f,
BoolValue = true,
StringValue = "Hello WASM! 🚀 Unicode: αβγδ",
GuidValue = Guid.Parse("12345678-1234-1234-1234-123456789abc"),
DateTimeValue = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc),
EnumValue = TestStatus.Shipped,
ByteValue = 255,
ShortValue = short.MaxValue,
NullableInt = 42,
NullableIntNull = null
};
// Act
var json = AcJsonSerializer.Serialize(testData);
Console.WriteLine($"Primitives JSON: {json}");
var deserialized = AcJsonDeserializer.Deserialize<WasmPrimitiveTestClass>(json);
// Assert
Assert.IsNotNull(deserialized);
Assert.AreEqual(int.MaxValue, deserialized.IntValue);
Assert.AreEqual(long.MaxValue, deserialized.LongValue);
Assert.AreEqual(3.14159265358979, deserialized.DoubleValue, 0.0000000001);
Assert.AreEqual(12345.6789m, deserialized.DecimalValue);
Assert.AreEqual(1.5f, deserialized.FloatValue);
Assert.IsTrue(deserialized.BoolValue);
Assert.AreEqual("Hello WASM! 🚀 Unicode: αβγδ", deserialized.StringValue);
Assert.AreEqual(Guid.Parse("12345678-1234-1234-1234-123456789abc"), deserialized.GuidValue);
Assert.AreEqual(new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc), deserialized.DateTimeValue);
Assert.AreEqual(TestStatus.Shipped, deserialized.EnumValue);
Assert.AreEqual(255, deserialized.ByteValue);
Assert.AreEqual(short.MaxValue, deserialized.ShortValue);
Assert.AreEqual(42, deserialized.NullableInt);
Assert.IsNull(deserialized.NullableIntNull);
}
/// <summary>
/// Test class with all primitive types for WASM compatibility testing
/// </summary>
public class WasmPrimitiveTestClass
{
public int IntValue { get; set; }
public long LongValue { get; set; }
public double DoubleValue { get; set; }
public decimal DecimalValue { get; set; }
public float FloatValue { get; set; }
public bool BoolValue { get; set; }
public string StringValue { get; set; } = "";
public Guid GuidValue { get; set; }
public DateTime DateTimeValue { get; set; }
public TestStatus EnumValue { get; set; }
public byte ByteValue { get; set; }
public short ShortValue { get; set; }
public int? NullableInt { get; set; }
public int? NullableIntNull { get; set; }
}
[TestMethod]
public void WasmCompat_EmptyAndNullCollections_HandleCorrectly()
{
// Arrange
var order = new TestOrder
{
Id = 1,
OrderNumber = "EMPTY-TEST",
Items = [], // Empty list
Attributes = [], // Empty list
MetadataList = [] // Empty list
};
// Act
var json = AcJsonSerializer.Serialize(order);
Console.WriteLine($"Empty collections JSON: {json}");
// Empty collections SHOULD be in JSON as [] (not omitted)
// This preserves the distinction between null and empty
Assert.IsTrue(json.Contains("\"Items\":[]"), "Empty Items should be serialized as []");
Assert.IsTrue(json.Contains("\"Attributes\":[]"), "Empty Attributes should be serialized as []");
var deserialized = AcJsonDeserializer.Deserialize<TestOrder>(json);
// Assert - deserialized object should have empty (not null) collections
Assert.IsNotNull(deserialized);
Assert.IsNotNull(deserialized.Items, "Items should be an empty list, not null after deserialization");
Assert.AreEqual(0, deserialized.Items.Count);
Assert.IsNotNull(deserialized.Attributes, "Attributes should be an empty list, not null after deserialization");
Assert.AreEqual(0, deserialized.Attributes.Count);
}
[TestMethod]
public void Serialize_NullCollection_IsOmitted()
{
// Arrange - Order with null collections
var order = new TestOrderWithNullableCollections
{
Id = 1,
OrderNumber = "TEST-001",
Items = null, // Explicitly null
Tags = null // Explicitly null
};
// Act
var json = AcJsonSerializer.Serialize(order);
Console.WriteLine($"JSON with null collections: {json}");
// Assert - Null collections should NOT be in JSON
Assert.IsFalse(json.Contains("\"Items\""),
$"Null Items should not be serialized. JSON: {json}");
Assert.IsFalse(json.Contains("\"Tags\""),
$"Null Tags should not be serialized. JSON: {json}");
}
[TestMethod]
public void Deserialize_EmptyArray_CreatesEmptyList()
{
// Arrange - JSON with empty Items array
var json = """{"Id":1,"OrderNumber":"TEST-001","Items":[],"Attributes":[]}""";
// Act
var order = AcJsonDeserializer.Deserialize<TestOrder>(json);
// Assert
Assert.IsNotNull(order);
Assert.IsNotNull(order.Items, "Items should be an empty list, not null after deserialization");
Assert.AreEqual(0, order.Items.Count);
Assert.IsNotNull(order.Attributes, "Attributes should be an empty list, not null after deserialization");
Assert.AreEqual(0, order.Attributes.Count);
}
[TestMethod]
public void Deserialize_MissingCollection_StaysAsInitialized()
{
// Arrange - JSON without Items or Attributes (simulates old serialization)
var json = """{"Id":1,"OrderNumber":"TEST-001"}""";
// Act
var order = AcJsonDeserializer.Deserialize<TestOrder>(json);
// Assert - The DTO initializes these as empty lists in constructor
// Note: This depends on the class having initialized properties like: Items = [];
// If missing from JSON, they should retain their default initialized value
Assert.IsNotNull(order);
Assert.IsNotNull(order.Items, "Items should retain default empty list initialization");
Assert.IsNotNull(order.Attributes, "Attributes should retain default empty list initialization");
}
[TestMethod]
public void RoundTrip_EmptyCollection_PreservesEmptyNotNull()
{
// Arrange
var original = new TestOrder
{
Id = 1,
OrderNumber = "TEST-001",
Items = [],
Attributes = []
};
// Act - Serialize then deserialize
var json = AcJsonSerializer.Serialize(original);
Console.WriteLine($"Round-trip JSON: {json}");
var deserialized = AcJsonDeserializer.Deserialize<TestOrder>(json);
// Assert
Assert.IsNotNull(deserialized);
Assert.IsNotNull(deserialized.Items, "Items should be empty list, not null after round-trip");
Assert.AreEqual(0, deserialized.Items.Count);
Assert.IsNotNull(deserialized.Attributes, "Attributes should be empty list, not null after round-trip");
Assert.AreEqual(0, deserialized.Attributes.Count);
}
/// <summary>
/// Test class with nullable collections to test null vs empty distinction
/// </summary>
public class TestOrderWithNullableCollections
{
public int Id { get; set; }
public string OrderNumber { get; set; } = "";
public List<TestOrderItem>? Items { get; set; }
public List<string>? Tags { get; set; }
}
#endregion
#region IdMessage SignalR Tests (Reproduces ProcessOnReceiveMessage behavior)
/// <summary>
/// These tests reproduce the exact flow in AcWebSignalRHubBase.ProcessOnReceiveMessage
/// where IdMessage wraps parameters and the server deserializes them back.
/// </summary>
[TestMethod]
public void IdMessage_FullSignalRScenario_IntArrayParameter()
{
// Reproduces: GetAllOrderDtoByIds(int[] orderIds)
// The error in logs: "Cannot deserialize the current JSON object into type 'System.Int32[]'"
var orderIds = new int[] { 113, 456, 789 };
// Step 1: Client side - IdMessage constructor wraps the parameter
// In IdMessage: item = (new[] { x }).ToJson() where x is int[]
var clientSideJson = (new[] { orderIds }).ToJson();
Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\"");
// Expected: "[[113,456,789]]" - array containing an int array
Assert.IsTrue(clientSideJson.StartsWith("[[") && clientSideJson.EndsWith("]]"),
$"Should be nested array format. Got: {clientSideJson}");
// Step 2: Server side - ProcessOnReceiveMessage deserializes
// Code: var a = Array.CreateInstance(methodInfoModel.ParamInfos[i].ParameterType, 1);
// paramValues[i] = (obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!;
var parameterType = typeof(int[]); // The method parameter type
var wrapperArrayType = parameterType.MakeArrayType(); // int[][] to wrap it
// Deserialize using the same settings as SignalR
var settings = GetMergeSettings();
var deserializedWrapper = clientSideJson.JsonTo(wrapperArrayType, settings) as Array;
Assert.IsNotNull(deserializedWrapper, $"Should deserialize to int[][]. JSON: {clientSideJson}");
Assert.AreEqual(1, deserializedWrapper.Length);
var actualParameter = deserializedWrapper.GetValue(0) as int[];
Assert.IsNotNull(actualParameter, "Should get int[] from wrapper");
Assert.AreEqual(3, actualParameter.Length);
Assert.AreEqual(113, actualParameter[0]);
Assert.AreEqual(456, actualParameter[1]);
Assert.AreEqual(789, actualParameter[2]);
}
[TestMethod]
public void IdMessage_FullSignalRScenario_SingleIntParameter()
{
// Reproduces: GetOrderById(int orderId)
var orderId = 42;
// Client: (new[] { x }).ToJson()
var clientSideJson = (new[] { orderId }).ToJson();
Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\"");
// Expected: "[42]"
Assert.AreEqual("[42]", clientSideJson);
// Server side deserialization
var parameterType = typeof(int);
var wrapperArrayType = parameterType.MakeArrayType(); // int[]
var settings = GetMergeSettings();
var deserializedArray = clientSideJson.JsonTo(wrapperArrayType, settings) as Array;
Assert.IsNotNull(deserializedArray);
Assert.AreEqual(1, deserializedArray.Length);
Assert.AreEqual(42, deserializedArray.GetValue(0));
}
[TestMethod]
public void IdMessage_FullSignalRScenario_BoolParameter()
{
// Reproduces: GetPendingOrders(bool loadRelations)
// This was the original bug - loadRelations=true becoming false
var loadRelations = true;
// Client: (new[] { x }).ToJson()
var clientSideJson = (new[] { loadRelations }).ToJson();
Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\"");
// Expected: "[true]"
Assert.AreEqual("[true]", clientSideJson);
// Server side deserialization
var parameterType = typeof(bool);
var wrapperArrayType = parameterType.MakeArrayType(); // bool[]
var settings = GetMergeSettings();
var deserializedArray = clientSideJson.JsonTo(wrapperArrayType, settings) as Array;
Assert.IsNotNull(deserializedArray);
Assert.AreEqual(1, deserializedArray.Length);
Assert.IsTrue((bool)deserializedArray.GetValue(0)!, "loadRelations should remain TRUE!");
}
[TestMethod]
public void IdMessage_FullSignalRScenario_GuidArrayParameter()
{
// Reproduces: GetOrdersByIds(Guid[] orderIds)
var guid1 = Guid.NewGuid();
var guid2 = Guid.NewGuid();
var orderIds = new Guid[] { guid1, guid2 };
// Client: (new[] { x }).ToJson()
var clientSideJson = (new[] { orderIds }).ToJson();
Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\"");
// Server side deserialization
var parameterType = typeof(Guid[]);
var wrapperArrayType = parameterType.MakeArrayType(); // Guid[][]
var settings = GetMergeSettings();
var deserializedWrapper = clientSideJson.JsonTo(wrapperArrayType, settings) as Array;
Assert.IsNotNull(deserializedWrapper);
Assert.AreEqual(1, deserializedWrapper.Length);
var actualParameter = deserializedWrapper.GetValue(0) as Guid[];
Assert.IsNotNull(actualParameter);
Assert.AreEqual(2, actualParameter.Length);
Assert.AreEqual(guid1, actualParameter[0]);
Assert.AreEqual(guid2, actualParameter[1]);
}
[TestMethod]
public void IdMessage_FullSignalRScenario_MultipleParameters()
{
// Reproduces: GetFilteredOrders(bool loadRelations, string filter, int pageSize, Guid userId)
// This simulates the full ProcessOnReceiveMessage loop
var settings = GetMergeSettings();
// Define method parameters
var methodParams = new (Type type, object value)[]
{
(typeof(bool), true),
(typeof(string), "active"),
(typeof(int), 50),
(typeof(Guid), Guid.NewGuid()),
(typeof(TestStatus), TestStatus.Processing)
};
// Client side: IdMessage wraps each parameter
var serializedParams = new List<string>();
foreach (var (type, value) in methodParams)
{
// This is what IdMessage does: item = (new[] { x }).ToJson();
var wrapped = Array.CreateInstance(type, 1);
wrapped.SetValue(value, 0);
var json = wrapped.ToJson(settings);
serializedParams.Add(json);
Console.WriteLine($"Param {type.Name}: {value} -> {json}");
}
// Server side: ProcessOnReceiveMessage deserializes each parameter
var deserializedParams = new object[methodParams.Length];
for (var i = 0; i < methodParams.Length; i++)
{
var paramType = methodParams[i].type;
var jsonStr = serializedParams[i];
// This is the server-side code:
// var a = Array.CreateInstance(paramType, 1);
// paramValues[i] = (obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!;
var arrayType = paramType.MakeArrayType();
var arr = jsonStr.JsonTo(arrayType, settings) as Array;
deserializedParams[i] = arr?.GetValue(0)!;
Console.WriteLine($"Deserialized {paramType.Name}: {deserializedParams[i]}");
}
// Assert all parameters are correctly deserialized
Assert.IsTrue((bool)deserializedParams[0], "loadRelations should be TRUE");
Assert.AreEqual("active", (string)deserializedParams[1]);
Assert.AreEqual(50, (int)deserializedParams[2]);
Assert.AreEqual(methodParams[3].value, (Guid)deserializedParams[3]);
Assert.AreEqual(TestStatus.Processing, (TestStatus)deserializedParams[4]);
}
[TestMethod]
public void IdMessage_FullSignalRScenario_ComplexObjectParameter()
{
// Reproduces: UpdateOrder(OrderDto order)
var order = new TestOrder
{
Id = 1,
OrderNumber = "ORD-001",
OrderStatus = TestStatus.Processing,
Items =
[
new TestOrderItem { Id = 10, ProductName = "Product A", Quantity = 5 }
]
};
// Client: (new[] { x }).ToJson()
var clientSideJson = (new[] { order }).ToJson();
Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\"");
// Server side deserialization
var parameterType = typeof(TestOrder);
var wrapperArrayType = parameterType.MakeArrayType(); // TestOrder[]
var settings = GetMergeSettings();
var deserializedWrapper = clientSideJson.JsonTo(wrapperArrayType, settings) as Array;
Assert.IsNotNull(deserializedWrapper);
Assert.AreEqual(1, deserializedWrapper.Length);
var actualParameter = deserializedWrapper.GetValue(0) as TestOrder;
Assert.IsNotNull(actualParameter);
Assert.AreEqual(1, actualParameter.Id);
Assert.AreEqual("ORD-001", actualParameter.OrderNumber);
Assert.AreEqual(TestStatus.Processing, actualParameter.OrderStatus);
Assert.AreEqual(1, actualParameter.Items.Count);
Assert.AreEqual("Product A", actualParameter.Items[0].ProductName);
}
[TestMethod]
public void IdMessage_CompareSerializers_IntArray()
{
// Ensure AcJsonSerializer and Newtonsoft produce identical output
var orderIds = new int[] { 1, 2, 3 };
var wrapped = new[] { orderIds };
// AcJsonSerializer (new default)
var acJson = AcJsonSerializer.Serialize(wrapped);
// Newtonsoft (old default)
var newtonsoftJson = JsonConvert.SerializeObject(wrapped);
Console.WriteLine($"AcJsonSerializer: {acJson}");
Console.WriteLine($"Newtonsoft: {newtonsoftJson}");
// Both should produce: [[1,2,3]]
Assert.AreEqual(newtonsoftJson, acJson,
"AcJsonSerializer should produce same output as Newtonsoft for primitive arrays");
}
[TestMethod]
public void IdMessage_CompareSerializers_NestedArray()
{
// Test nested arrays (edge case)
var matrix = new int[][] { new[] { 1, 2 }, new[] { 3, 4 } };
var wrapped = new[] { matrix };
var acJson = AcJsonSerializer.Serialize(wrapped);
var newtonsoftJson = JsonConvert.SerializeObject(wrapped);
Console.WriteLine($"AcJsonSerializer: {acJson}");
Console.WriteLine($"Newtonsoft: {newtonsoftJson}");
// Both should produce: [[[1,2],[3,4]]]
Assert.AreEqual(newtonsoftJson, acJson);
}
#endregion
}

View File

@ -1,6 +1,8 @@
using System.Buffers;
using System.Collections;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
@ -13,7 +15,8 @@ namespace AyCode.Core.Extensions;
/// High-performance custom JSON deserializer optimized for IId&lt;T&gt; reference handling.
/// Features:
/// - Streaming parse using System.Text.Json (no intermediate JToken allocations)
/// - Cached property setters for reflection-free property writing
/// - Compiled expression tree property accessors (no reflection invoke overhead)
/// - Compiled list factories for fast collection creation
/// - Two-phase $id/$ref resolution
/// - IId-based collection merge support
/// - Compatible with AcJsonSerializer output
@ -21,6 +24,18 @@ namespace AyCode.Core.Extensions;
public static class AcJsonDeserializer
{
private static readonly ConcurrentDictionary<Type, DeserializeTypeMetadata> TypeMetadataCache = new();
private static readonly ConcurrentDictionary<Type, Func<IList>> ListFactoryCache = new();
// Pre-computed type handles for fast comparison
private static readonly Type IntType = typeof(int);
private static readonly Type LongType = typeof(long);
private static readonly Type DoubleType = typeof(double);
private static readonly Type DecimalType = typeof(decimal);
private static readonly Type FloatType = typeof(float);
private static readonly Type StringType = typeof(string);
private static readonly Type DateTimeType = typeof(DateTime);
private static readonly Type GuidType = typeof(Guid);
private static readonly Type BoolType = typeof(bool);
/// <summary>
/// Deserialize JSON string to a new object of type T.
@ -34,7 +49,6 @@ public static class AcJsonDeserializer
var result = (T?)ReadValue(doc.RootElement, typeof(T), context);
// Resolve $ref references
context.ResolveReferences();
return result;
@ -52,7 +66,6 @@ public static class AcJsonDeserializer
var result = ReadValue(doc.RootElement, targetType, context);
// Resolve $ref references
context.ResolveReferences();
return result;
@ -68,115 +81,145 @@ public static class AcJsonDeserializer
var context = new DeserializationContext { IsMergeMode = true };
using var doc = JsonDocument.Parse(json);
PopulateObject(doc.RootElement, target, typeof(T), context);
var metadata = GetTypeMetadata(typeof(T));
PopulateObjectInternal(doc.RootElement, target, metadata, context);
// Resolve $ref references
context.ResolveReferences();
}
#region Core Reading Methods
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static object? ReadValue(JsonElement element, Type targetType, DeserializationContext context)
{
return element.ValueKind switch
{
JsonValueKind.Null => null,
JsonValueKind.Object => ReadObject(element, targetType, context),
JsonValueKind.Array => ReadArray(element, targetType, context),
_ => ReadPrimitive(element, targetType)
};
var valueKind = element.ValueKind;
// Order by frequency: Object > Array > String > Number > True/False > Null
if (valueKind == JsonValueKind.Object)
return ReadObject(element, targetType, context);
if (valueKind == JsonValueKind.Array)
return ReadArray(element, targetType, context);
if (valueKind == JsonValueKind.Null)
return null;
return ReadPrimitive(element, targetType, valueKind);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static object? ReadObject(JsonElement element, Type targetType, DeserializationContext context)
{
// Check for $ref
// Check for $ref first using TryGetProperty (optimized in STJ)
if (element.TryGetProperty("$ref", out var refElement))
{
var refId = refElement.GetString()!;
if (context.TryGetReferencedObject(refId, out var refObj))
{
return refObj;
}
// Defer resolution
var placeholder = new DeferredReference(refId, targetType);
context.AddDeferredReference(placeholder);
return placeholder;
return new DeferredReference(refId, targetType);
}
// Create instance
var instance = CreateInstance(targetType);
// Get metadata once and reuse
var metadata = GetTypeMetadata(targetType);
// Create instance using compiled constructor
var instance = metadata.CompiledConstructor?.Invoke() ?? Activator.CreateInstance(targetType);
if (instance == null) return null;
// Check for $id and register
if (element.TryGetProperty("$id", out var idElement))
{
var id = idElement.GetString()!;
context.RegisterObject(id, instance);
context.RegisterObject(idElement.GetString()!, instance);
}
// Populate properties
PopulateObject(element, instance, targetType, context);
PopulateObjectInternal(element, instance, metadata, context);
return instance;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void PopulateObject(JsonElement element, object target, Type targetType, DeserializationContext context)
{
var metadata = GetTypeMetadata(targetType);
PopulateObjectInternal(element, target, metadata, context);
}
/// <summary>
/// Internal populate method - optimized hot path.
/// </summary>
private static void PopulateObjectInternal(JsonElement element, object target, DeserializeTypeMetadata metadata, DeserializationContext context)
{
var propertySetters = metadata.PropertySetters;
var isMergeMode = context.IsMergeMode;
foreach (var jsonProp in element.EnumerateObject())
{
// Skip metadata properties
if (jsonProp.Name == "$id" || jsonProp.Name == "$ref") continue;
if (metadata.PropertySetters.TryGetValue(jsonProp.Name, out var propInfo))
var propName = jsonProp.Name;
var propNameLength = propName.Length;
// Ultra-fast skip for $ properties - check first char before string comparison
if (propNameLength > 0)
{
var propValue = jsonProp.Value;
// Handle collections with IId merge
if (context.IsMergeMode && propInfo.IsCollection && propInfo.ElementIsIId)
var firstChar = propName[0];
if (firstChar == '$')
{
var existingCollection = propInfo.GetValue(target);
if (existingCollection != null && propValue.ValueKind == JsonValueKind.Array)
{
MergeIIdCollection(propValue, existingCollection, propInfo, context);
continue;
}
// Only 2 metadata properties: $id and $ref
if (propNameLength == 3 && propName[1] == 'i' && propName[2] == 'd') continue;
if (propNameLength == 4 && propName[1] == 'r' && propName[2] == 'e' && propName[3] == 'f') continue;
}
}
var value = ReadValue(propValue, propInfo.PropertyType, context);
// Handle deferred references
if (value is DeferredReference deferred)
if (!propertySetters.TryGetValue(propName, out var propInfo)) continue;
var propValue = jsonProp.Value;
var propValueKind = propValue.ValueKind;
// Handle collections with IId merge (only in merge mode)
if (isMergeMode && propInfo.IsCollection && propInfo.ElementIsIId && propValueKind == JsonValueKind.Array)
{
var existingCollection = propInfo.GetValue(target);
if (existingCollection != null)
{
context.AddPropertyToResolve(target, propInfo, deferred.RefId);
}
else
{
propInfo.SetValue(target, value);
MergeIIdCollection(propValue, existingCollection, propInfo, context);
continue;
}
}
var value = ReadValue(propValue, propInfo.PropertyType, context);
// Handle deferred references
if (value is DeferredReference deferred)
{
context.AddPropertyToResolve(target, propInfo, deferred.RefId);
}
else
{
propInfo.SetValue(target, value);
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static object? ReadArray(JsonElement element, Type targetType, DeserializationContext context)
{
var elementType = GetCollectionElementType(targetType);
if (elementType == null) return null;
var listType = typeof(List<>).MakeGenericType(elementType);
var list = (IList)Activator.CreateInstance(listType)!;
// Use compiled list factory
var list = GetOrCreateListFactory(elementType)();
foreach (var item in element.EnumerateArray())
{
var value = ReadValue(item, elementType, context);
list.Add(value);
list.Add(ReadValue(item, elementType, context));
}
// Convert to target type if needed (e.g., array)
// Convert to array if needed
if (targetType.IsArray)
{
var array = Array.CreateInstance(elementType, list.Count);
var count = list.Count;
var array = Array.CreateInstance(elementType, count);
list.CopyTo(array, 0);
return array;
}
@ -190,9 +233,10 @@ public static class AcJsonDeserializer
var idGetter = propInfo.ElementIdGetter!;
var idType = propInfo.ElementIdType!;
// Build lookup of existing items by Id
var existingList = (IList)existingCollection;
var existingById = new Dictionary<object, object>();
var existingById = new Dictionary<object, object>(existingList.Count);
// Build lookup
foreach (var item in existingList)
{
if (item != null)
@ -205,101 +249,209 @@ public static class AcJsonDeserializer
}
}
// Track which items are in the JSON
var jsonIds = new HashSet<object>();
foreach (var jsonItem in arrayElement.EnumerateArray())
{
if (jsonItem.ValueKind != JsonValueKind.Object) continue;
// Try to get Id from JSON
object? itemId = null;
if (jsonItem.TryGetProperty("Id", out var idProp))
{
itemId = ReadPrimitive(idProp, idType);
itemId = ReadPrimitive(idProp, idType, idProp.ValueKind);
}
if (itemId != null && !IsDefaultId(itemId, idType))
{
jsonIds.Add(itemId);
if (existingById.TryGetValue(itemId, out var existingItem))
{
// UPDATE: Merge into existing item
PopulateObject(jsonItem, existingItem, elementType, context);
}
else
{
// INSERT: Create new item
var newItem = ReadValue(jsonItem, elementType, context);
if (newItem != null)
{
existingList.Add(newItem);
}
if (newItem != null) existingList.Add(newItem);
}
}
else
{
// No Id - insert as new
var newItem = ReadValue(jsonItem, elementType, context);
if (newItem != null)
{
existingList.Add(newItem);
}
if (newItem != null) existingList.Add(newItem);
}
}
// KEEP: Items not in JSON remain (this is the default behavior - we don't remove)
}
private static object? ReadPrimitive(JsonElement element, Type targetType)
/// <summary>
/// Optimized primitive reading with pre-fetched valueKind.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static object? ReadPrimitive(JsonElement element, Type targetType, JsonValueKind valueKind)
{
var type = Nullable.GetUnderlyingType(targetType) ?? targetType;
try
// Numbers - most common in data
if (valueKind == JsonValueKind.Number)
{
return element.ValueKind switch
// Order by frequency
if (ReferenceEquals(type, IntType)) return element.GetInt32();
if (ReferenceEquals(type, LongType)) return element.GetInt64();
if (ReferenceEquals(type, DoubleType)) return element.GetDouble();
if (ReferenceEquals(type, DecimalType)) return element.GetDecimal();
if (ReferenceEquals(type, FloatType)) return element.GetSingle();
if (type == typeof(byte)) return element.GetByte();
if (type == typeof(short)) return element.GetInt16();
if (type == typeof(ushort)) return element.GetUInt16();
if (type == typeof(uint)) return element.GetUInt32();
if (type == typeof(ulong)) return element.GetUInt64();
if (type == typeof(sbyte)) return element.GetSByte();
if (type.IsEnum) return Enum.ToObject(type, element.GetInt32());
return null;
}
// Strings
if (valueKind == JsonValueKind.String)
{
if (ReferenceEquals(type, StringType)) return element.GetString();
if (ReferenceEquals(type, DateTimeType)) return element.GetDateTime();
if (ReferenceEquals(type, GuidType)) return element.GetGuid();
if (type == typeof(DateTimeOffset)) return element.GetDateTimeOffset();
if (type == typeof(TimeSpan)) return TimeSpan.Parse(element.GetString()!, CultureInfo.InvariantCulture);
if (type.IsEnum) return Enum.Parse(type, element.GetString()!);
return null;
}
// Booleans
if (valueKind == JsonValueKind.True) return true;
if (valueKind == JsonValueKind.False) return false;
return null;
}
#endregion
#region Type Metadata Cache
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static DeserializeTypeMetadata GetTypeMetadata(Type type)
{
return TypeMetadataCache.GetOrAdd(type, static t => new DeserializeTypeMetadata(t));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Func<IList> GetOrCreateListFactory(Type elementType)
{
return ListFactoryCache.GetOrAdd(elementType, static t =>
{
var listType = typeof(List<>).MakeGenericType(t);
var newExpr = Expression.New(listType);
var castExpr = Expression.Convert(newExpr, typeof(IList));
var lambda = Expression.Lambda<Func<IList>>(castExpr);
return lambda.Compile();
});
}
private sealed class DeserializeTypeMetadata
{
public Dictionary<string, PropertySetterInfo> PropertySetters { get; }
public Func<object>? CompiledConstructor { get; }
public DeserializeTypeMetadata(Type type)
{
var ctor = type.GetConstructor(Type.EmptyTypes);
if (ctor != null)
{
JsonValueKind.String when type == typeof(string) => element.GetString(),
JsonValueKind.String when type == typeof(DateTime) => element.GetDateTime(),
JsonValueKind.String when type == typeof(DateTimeOffset) => element.GetDateTimeOffset(),
JsonValueKind.String when type == typeof(Guid) => element.GetGuid(),
JsonValueKind.String when type == typeof(TimeSpan) => TimeSpan.Parse(element.GetString()!, CultureInfo.InvariantCulture),
JsonValueKind.String when type.IsEnum => Enum.Parse(type, element.GetString()!),
JsonValueKind.Number when type == typeof(int) => element.GetInt32(),
JsonValueKind.Number when type == typeof(long) => element.GetInt64(),
JsonValueKind.Number when type == typeof(double) => element.GetDouble(),
JsonValueKind.Number when type == typeof(decimal) => element.GetDecimal(),
JsonValueKind.Number when type == typeof(float) => element.GetSingle(),
JsonValueKind.Number when type == typeof(byte) => element.GetByte(),
JsonValueKind.Number when type == typeof(short) => element.GetInt16(),
JsonValueKind.Number when type == typeof(ushort) => element.GetUInt16(),
JsonValueKind.Number when type == typeof(uint) => element.GetUInt32(),
JsonValueKind.Number when type == typeof(ulong) => element.GetUInt64(),
JsonValueKind.Number when type == typeof(sbyte) => element.GetSByte(),
JsonValueKind.Number when type.IsEnum => (Enum)Enum.ToObject(type, element.GetInt32()),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => null
};
var newExpr = Expression.New(type);
var boxed = Expression.Convert(newExpr, typeof(object));
CompiledConstructor = Expression.Lambda<Func<object>>(boxed).Compile();
}
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanWrite && p.CanRead &&
p.GetIndexParameters().Length == 0 &&
p.GetCustomAttribute<JsonIgnoreAttribute>() == null &&
p.GetCustomAttribute<System.Text.Json.Serialization.JsonIgnoreAttribute>() == null)
.ToArray();
PropertySetters = new Dictionary<string, PropertySetterInfo>(props.Length, StringComparer.OrdinalIgnoreCase);
foreach (var prop in props)
{
PropertySetters[prop.Name] = new PropertySetterInfo(prop, type);
}
}
catch
}
private sealed class PropertySetterInfo
{
public Type PropertyType { get; }
public bool IsCollection { get; }
public bool ElementIsIId { get; }
public Type? ElementType { get; }
public Type? ElementIdType { get; }
public Func<object, object?>? ElementIdGetter { get; }
private readonly Action<object, object?> _setter;
private readonly Func<object, object?> _getter;
public PropertySetterInfo(PropertyInfo prop, Type declaringType)
{
return type.IsValueType ? Activator.CreateInstance(type) : null;
PropertyType = prop.PropertyType;
// Compiled delegates
_setter = CreateCompiledSetter(declaringType, prop);
_getter = CreateCompiledGetter(declaringType, prop);
ElementType = GetCollectionElementType(PropertyType);
IsCollection = ElementType != null && ElementType != typeof(object) &&
typeof(IEnumerable).IsAssignableFrom(PropertyType) &&
PropertyType != typeof(string);
if (IsCollection && ElementType != null)
{
var iidInterface = ElementType.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IId<>));
if (iidInterface != null)
{
ElementIsIId = true;
ElementIdType = iidInterface.GetGenericArguments()[0];
var idProp = ElementType.GetProperty("Id");
if (idProp != null)
{
ElementIdGetter = CreateCompiledGetter(ElementType, idProp);
}
}
}
}
private static Action<object, object?> CreateCompiledSetter(Type declaringType, PropertyInfo prop)
{
var objParam = Expression.Parameter(typeof(object), "obj");
var valueParam = Expression.Parameter(typeof(object), "value");
var castObj = Expression.Convert(objParam, declaringType);
var castValue = Expression.Convert(valueParam, prop.PropertyType);
var propAccess = Expression.Property(castObj, prop);
var assign = Expression.Assign(propAccess, castValue);
return Expression.Lambda<Action<object, object?>>(assign, objParam, valueParam).Compile();
}
private static Func<object, object?> CreateCompiledGetter(Type declaringType, PropertyInfo prop)
{
var objParam = Expression.Parameter(typeof(object), "obj");
var castExpr = Expression.Convert(objParam, declaringType);
var propAccess = Expression.Property(castExpr, prop);
var boxed = Expression.Convert(propAccess, typeof(object));
return Expression.Lambda<Func<object, object?>>(boxed, objParam).Compile();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetValue(object target, object? value) => _setter(target, value);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object? GetValue(object target) => _getter(target);
}
#endregion
#region Helper Methods
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static object? CreateInstance(Type type)
{
var metadata = GetTypeMetadata(type);
return metadata.Constructor?.Invoke(null) ?? Activator.CreateInstance(type);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Type? GetCollectionElementType(Type collectionType)
{
@ -322,165 +474,50 @@ public static class AcJsonDeserializer
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsDefaultId(object id, Type idType)
{
if (idType == typeof(int)) return (int)id == 0;
if (idType == typeof(long)) return (long)id == 0;
if (idType == typeof(Guid)) return (Guid)id == Guid.Empty;
if (ReferenceEquals(idType, IntType)) return (int)id == 0;
if (ReferenceEquals(idType, LongType)) return (long)id == 0;
if (ReferenceEquals(idType, GuidType)) return (Guid)id == Guid.Empty;
return false;
}
#endregion
#region Type Metadata Cache
private static DeserializeTypeMetadata GetTypeMetadata(Type type)
{
return TypeMetadataCache.GetOrAdd(type, static t => new DeserializeTypeMetadata(t));
}
private sealed class DeserializeTypeMetadata
{
public Dictionary<string, PropertySetterInfo> PropertySetters { get; }
public ConstructorInfo? Constructor { get; }
public DeserializeTypeMetadata(Type type)
{
// Get parameterless constructor
Constructor = type.GetConstructor(Type.EmptyTypes);
// Build property setters dictionary
PropertySetters = new Dictionary<string, PropertySetterInfo>(StringComparer.OrdinalIgnoreCase);
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanWrite && p.CanRead &&
p.GetIndexParameters().Length == 0 &&
p.GetCustomAttribute<JsonIgnoreAttribute>() == null &&
p.GetCustomAttribute<System.Text.Json.Serialization.JsonIgnoreAttribute>() == null);
foreach (var prop in props)
{
PropertySetters[prop.Name] = new PropertySetterInfo(prop);
}
}
}
private sealed class PropertySetterInfo
{
public string Name { get; }
public Type PropertyType { get; }
public bool IsCollection { get; }
public bool ElementIsIId { get; }
public Type? ElementType { get; }
public Type? ElementIdType { get; }
public Func<object, object?>? ElementIdGetter { get; }
private readonly Action<object, object?> _setter;
private readonly Func<object, object?> _getter;
public PropertySetterInfo(PropertyInfo prop)
{
Name = prop.Name;
PropertyType = prop.PropertyType;
var setMethod = prop.GetSetMethod()!;
var getMethod = prop.GetGetMethod()!;
_setter = (obj, val) => setMethod.Invoke(obj, [val]);
_getter = obj => getMethod.Invoke(obj, null);
// Check if this is a collection of IId items
ElementType = GetCollectionElementType(PropertyType);
IsCollection = ElementType != null && ElementType != typeof(object) &&
typeof(IEnumerable).IsAssignableFrom(PropertyType) &&
PropertyType != typeof(string);
if (IsCollection && ElementType != null)
{
var iidInterface = ElementType.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IId<>));
if (iidInterface != null)
{
ElementIsIId = true;
ElementIdType = iidInterface.GetGenericArguments()[0];
var idProp = ElementType.GetProperty("Id");
if (idProp != null)
{
var idGetter = idProp.GetGetMethod();
if (idGetter != null)
{
ElementIdGetter = obj => idGetter.Invoke(obj, null);
}
}
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetValue(object target, object? value) => _setter(target, value);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object? GetValue(object target) => _getter(target);
}
#endregion
#region Reference Resolution
private sealed class DeferredReference
private sealed class DeferredReference(string refId, Type targetType)
{
public string RefId { get; }
public Type TargetType { get; }
public DeferredReference(string refId, Type targetType)
{
RefId = refId;
TargetType = targetType;
}
public string RefId { get; } = refId;
public Type TargetType { get; } = targetType;
}
private sealed class PropertyToResolve
// Use struct to reduce allocations
private readonly struct PropertyToResolve(object target, PropertySetterInfo property, string refId)
{
public object Target { get; }
public PropertySetterInfo Property { get; }
public string RefId { get; }
public PropertyToResolve(object target, PropertySetterInfo property, string refId)
{
Target = target;
Property = property;
RefId = refId;
}
public readonly object Target = target;
public readonly PropertySetterInfo Property = property;
public readonly string RefId = refId;
}
private sealed class DeserializationContext
{
private readonly Dictionary<string, object> _idToObject = new(StringComparer.Ordinal);
private readonly List<PropertyToResolve> _propertiesToResolve = new();
private readonly Dictionary<string, object> _idToObject = new(16, StringComparer.Ordinal);
private readonly List<PropertyToResolve> _propertiesToResolve = new(8);
public bool IsMergeMode { get; init; }
public void RegisterObject(string id, object obj)
{
_idToObject[id] = obj;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void RegisterObject(string id, object obj) => _idToObject[id] = obj;
public bool TryGetReferencedObject(string id, out object? obj)
{
return _idToObject.TryGetValue(id, out obj);
}
public void AddDeferredReference(DeferredReference deferred)
{
// Just a marker - actual resolution happens via properties
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetReferencedObject(string id, out object? obj) => _idToObject.TryGetValue(id, out obj);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AddPropertyToResolve(object target, PropertySetterInfo property, string refId)
{
_propertiesToResolve.Add(new PropertyToResolve(target, property, refId));
}
=> _propertiesToResolve.Add(new PropertyToResolve(target, property, refId));
public void ResolveReferences()
{
foreach (var ptr in _propertiesToResolve)
foreach (ref readonly var ptr in System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_propertiesToResolve))
{
if (_idToObject.TryGetValue(ptr.RefId, out var refObj))
{

View File

@ -2,6 +2,7 @@ using System.Buffers;
using System.Collections;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
@ -15,10 +16,10 @@ namespace AyCode.Core.Extensions;
/// Features:
/// - Single-pass serialization with inline $id/$ref emission
/// - StringBuilder-based output (no intermediate string allocations)
/// - Cached property accessors for reflection-free property reading
/// - Compiled expression tree property accessors (no reflection invoke overhead)
/// - Smart reference tracking: only emits $id when object is actually referenced later
/// - Supports: IId&lt;T&gt;, JsonIgnoreAttribute, null skipping, all primitive types
/// - Skips default values: 0, false, empty strings, default enums, empty collections
/// - Skips default values: 0, false, empty strings, default enums (but NOT empty collections)
/// </summary>
public static class AcJsonSerializer
{
@ -298,8 +299,8 @@ public static class AcJsonSerializer
}
/// <summary>
/// Check if a value is the default value for its type (0, false, empty string, empty collection, default enum).
/// These values don't need to be serialized as they will be the default when deserialized.
/// Check if a value is the default value for its type (0, false, empty string, default enum).
/// NOTE: Empty collections are NOT skipped - they serialize as [] to preserve the distinction from null.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsDefaultValue(object value, Type propertyType)
@ -328,20 +329,8 @@ public static class AcJsonSerializer
// Check enum for default (0)
if (type.IsEnum) return Convert.ToInt32(value) == 0;
// Check collections for empty
if (value is ICollection collection) return collection.Count == 0;
if (value is IEnumerable enumerable && type != typeof(string))
{
var enumerator = enumerable.GetEnumerator();
try
{
return !enumerator.MoveNext();
}
finally
{
(enumerator as IDisposable)?.Dispose();
}
}
// DO NOT skip empty collections - they should serialize as []
// This preserves the distinction between null and empty
// Check Guid for empty
if (type == typeof(Guid)) return (Guid)value == Guid.Empty;
@ -391,12 +380,34 @@ public static class AcJsonSerializer
var idProp = type.GetProperty("Id");
if (idProp != null)
{
var getter = idProp.GetGetMethod();
if (getter != null)
IdGetter = obj => getter.Invoke(obj, null);
// Use compiled expression for Id getter too
IdGetter = CreateCompiledGetter(type, idProp);
}
}
}
/// <summary>
/// Creates a compiled getter delegate using expression trees.
/// This is ~10-50x faster than MethodInfo.Invoke.
/// </summary>
private static Func<object, object?> CreateCompiledGetter(Type declaringType, PropertyInfo prop)
{
// Parameter: object obj
var objParam = Expression.Parameter(typeof(object), "obj");
// Cast to actual type: (DeclaringType)obj
var castExpr = Expression.Convert(objParam, declaringType);
// Access property: ((DeclaringType)obj).PropertyName
var propAccess = Expression.Property(castExpr, prop);
// Box value types: (object)value
var boxed = Expression.Convert(propAccess, typeof(object));
// Compile: obj => (object)((DeclaringType)obj).PropertyName
var lambda = Expression.Lambda<Func<object, object?>>(boxed, objParam);
return lambda.Compile();
}
}
private sealed class PropertyAccessor
@ -409,8 +420,32 @@ public static class AcJsonSerializer
{
JsonName = prop.Name;
PropertyType = prop.PropertyType;
var getMethod = prop.GetGetMethod()!;
_getter = obj => getMethod.Invoke(obj, null);
// Use compiled expression tree instead of MethodInfo.Invoke
_getter = CreateCompiledGetter(prop.DeclaringType!, prop);
}
/// <summary>
/// Creates a compiled getter delegate using expression trees.
/// This is ~10-50x faster than MethodInfo.Invoke.
/// </summary>
private static Func<object, object?> CreateCompiledGetter(Type declaringType, PropertyInfo prop)
{
// Parameter: object obj
var objParam = Expression.Parameter(typeof(object), "obj");
// Cast to actual type: (DeclaringType)obj
var castExpr = Expression.Convert(objParam, declaringType);
// Access property: ((DeclaringType)obj).PropertyName
var propAccess = Expression.Property(castExpr, prop);
// Box value types: (object)value
var boxed = Expression.Convert(propAccess, typeof(object));
// Compile: obj => (object)((DeclaringType)obj).PropertyName
var lambda = Expression.Lambda<Func<object, object?>>(boxed, objParam);
return lambda.Compile();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]

View File

@ -416,6 +416,12 @@ public static class SerializeObjectExtensions
/// </summary>
public static string ToJson<T>(this T source, JsonSerializerSettings? options = null)
{
// If custom options are provided, use Newtonsoft for full compatibility
if (options != null)
{
return JsonConvert.SerializeObject(source, options);
}
// Use our high-performance custom serializer
return AcJsonSerializer.Serialize(source);
@ -460,11 +466,11 @@ public static class SerializeObjectExtensions
}
public static string ToJson<T>(this IQueryable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson
=> AcJsonSerializer.Serialize(source);
=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source);
// OLD: => ((object)source).ToJson(options);
public static string ToJson<T>(this IEnumerable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson
=> AcJsonSerializer.Serialize(source);
=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source);
// OLD: => ((object)source).ToJson(options);
public static T? JsonTo<T>(this string json, JsonSerializerSettings? options = null)
@ -472,8 +478,14 @@ public static class SerializeObjectExtensions
json = JsonUtilities.UnwrapJsonString(json);
// Use our high-performance custom deserializer for simple deserialization
// Fall back to Newtonsoft for complex scenarios (custom settings)
if (options == null && typeof(T).IsClass && !typeof(T).IsAbstract && typeof(T).GetConstructor(Type.EmptyTypes) != null)
// Only use for: non-abstract classes, with parameterless constructor, not arrays, not primitives
if (options == null &&
typeof(T).IsClass &&
!typeof(T).IsAbstract &&
!typeof(T).IsArray &&
!typeof(T).IsPrimitive &&
typeof(T) != typeof(string) &&
typeof(T).GetConstructor(Type.EmptyTypes) != null)
{
try
{
@ -499,7 +511,14 @@ public static class SerializeObjectExtensions
json = JsonUtilities.UnwrapJsonString(json);
// Use our high-performance custom deserializer for simple deserialization
if (options == null && toType.IsClass && toType.GetConstructor(Type.EmptyTypes) != null)
// Only use for: non-abstract classes, with parameterless constructor, not arrays, not primitives
if (options == null &&
toType.IsClass &&
!toType.IsAbstract &&
!toType.IsArray &&
!toType.IsPrimitive &&
toType != typeof(string) &&
toType.GetConstructor(Type.EmptyTypes) != null)
{
try
{