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:
parent
f9dc9a65fb
commit
a0445e6d1e
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<T> 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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<T>, 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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue